Compare commits

...

9 Commits

Author SHA1 Message Date
gemini-cli-robot f28ffaa3b0 chore(release): v0.7.0-preview.2 2025-10-01 15:51:29 +00:00
gemini-cli-robot babc691c8a fix(patch): cherry-pick 4c5ab80 to release/v0.7.0-preview.1-pr-10283 [CONFLICTS] (#10342)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Abhi <abhipatel@google.com>
2025-10-01 10:56:17 -04:00
gemini-cli-robot b9e046de34 chore(release): v0.7.0-preview.1 2025-09-25 21:01:43 +00:00
Abhi 8a22238f01 feat(integration): Force single model for more determinisitic e2e tests (#9278) 2025-09-25 13:24:23 -07:00
Shreya Keshive d39cd04537 fix(zed): Fix broken 'auto' model selection in Zed integration (#9769) 2025-09-25 12:28:51 -07:00
Abhi fd2bc71e39 feat(cli): Enable model router by default and add to settings dialog (#9262)
(cherry picked from commit ee36307495)
2025-09-25 12:18:02 -04:00
Sandy Tao 89aba7cbcd fix(core): Fix unable to cancel edit tool (#9299) 2025-09-24 15:37:40 -07:00
fuyou 2fbfeb39fe feat: Add AbortSignal support for retry logic and tool execution (#9196)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-09-24 15:37:32 -07:00
gemini-cli-robot 903cc7bbbe chore(release): v0.7.0-preview.0 2025-09-24 00:33:02 +00:00
20 changed files with 519 additions and 32 deletions
+2
View File
@@ -9,6 +9,7 @@ import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { env } from 'node:process';
import { DEFAULT_GEMINI_MODEL } from '../packages/core/src/config/models.js';
import fs from 'node:fs';
import * as pty from '@lydell/node-pty';
@@ -152,6 +153,7 @@ export class TestRig {
otlpEndpoint: '',
outfile: telemetryPath,
},
model: DEFAULT_GEMINI_MODEL,
sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false,
...options.settings, // Allow tests to override/add settings
};
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"workspaces": [
"packages/*"
],
@@ -16911,7 +16911,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"dependencies": {
"@a2a-js/sdk": "^0.3.2",
"@google-cloud/storage": "^7.16.0",
@@ -17182,7 +17182,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.16.0",
@@ -17375,7 +17375,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"dependencies": {
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
@@ -17520,7 +17520,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -17531,7 +17531,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"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.7.0-nightly.20250918.2722473a"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.7.0-preview.2"
},
"scripts": {
"start": "cross-env node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"private": true,
"description": "Gemini CLI A2A Server",
"repository": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.7.0-nightly.20250918.2722473a"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.7.0-preview.2"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
+1 -1
View File
@@ -1485,7 +1485,7 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('gemini-2.5-pro');
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
});
it('always prefers model from argvs', async () => {
+1 -1
View File
@@ -989,7 +989,7 @@ const SETTINGS_SCHEMA = {
default: false,
description:
'Enable model routing to route requests to the best model based on complexity.',
showInDialog: false,
showInDialog: true,
},
},
},
@@ -342,7 +342,7 @@ describe('SettingsDialog', () => {
await wait();
expect(lastFrame()).toContain('● Folder Trust');
expect(lastFrame()).toContain('● Use Model Router');
unmount();
});
@@ -25,6 +25,9 @@ import {
MCPServerConfig,
DiscoveredMCPTool,
StreamEventType,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_FLASH_MODEL,
} from '@google/gemini-cli-core';
import * as acp from './acp.js';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -41,6 +44,19 @@ import type { Extension } from '../config/extension.js';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
/**
* Resolves the model to use based on the current configuration.
*
* If the model is set to "auto", it will use the flash model if in fallback
* mode, otherwise it will use the default model.
*/
export function resolveModel(model: string, isInFallbackMode: boolean): string {
if (model === DEFAULT_GEMINI_MODEL_AUTO) {
return isInFallbackMode ? DEFAULT_GEMINI_FLASH_MODEL : DEFAULT_GEMINI_MODEL;
}
return model;
}
export async function runZedIntegration(
config: Config,
settings: LoadedSettings,
@@ -255,7 +271,7 @@ class Session {
try {
const responseStream = await chat.sendMessageStream(
this.config.getModel(),
resolveModel(this.config.getModel(), this.config.isInFallbackMode()),
{
message: nextMessage?.parts ?? [],
config: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"description": "Gemini CLI Core",
"repository": {
"type": "git",
@@ -109,6 +109,65 @@ class TestApprovalInvocation extends BaseToolInvocation<
}
}
class AbortDuringConfirmationInvocation extends BaseToolInvocation<
Record<string, unknown>,
ToolResult
> {
constructor(
private readonly abortController: AbortController,
private readonly abortError: Error,
params: Record<string, unknown>,
) {
super(params);
}
override async shouldConfirmExecute(
_signal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
this.abortController.abort();
throw this.abortError;
}
async execute(_abortSignal: AbortSignal): Promise<ToolResult> {
throw new Error('execute should not be called when confirmation fails');
}
getDescription(): string {
return 'Abort during confirmation invocation';
}
}
class AbortDuringConfirmationTool extends BaseDeclarativeTool<
Record<string, unknown>,
ToolResult
> {
constructor(
private readonly abortController: AbortController,
private readonly abortError: Error,
) {
super(
'abortDuringConfirmationTool',
'Abort During Confirmation Tool',
'A tool that aborts while confirming execution.',
Kind.Other,
{
type: 'object',
properties: {},
},
);
}
protected createInvocation(
params: Record<string, unknown>,
): ToolInvocation<Record<string, unknown>, ToolResult> {
return new AbortDuringConfirmationInvocation(
this.abortController,
this.abortError,
params,
);
}
}
async function waitForStatus(
onToolCallsUpdate: Mock,
status: 'awaiting_approval' | 'executing' | 'success' | 'error' | 'cancelled',
@@ -218,6 +277,85 @@ describe('CoreToolScheduler', () => {
expect(completedCalls[0].status).toBe('cancelled');
});
it('should mark tool call as cancelled when abort happens during confirmation error', async () => {
const abortController = new AbortController();
const abortError = new Error('Abort requested during confirmation');
const declarativeTool = new AbortDuringConfirmationTool(
abortController,
abortError,
);
const mockToolRegistry = {
getTool: () => declarativeTool,
getFunctionDeclarations: () => [],
tools: new Map(),
discovery: {},
registerTool: () => {},
getToolByName: () => declarativeTool,
getToolByDisplayName: () => declarativeTool,
getTools: () => [],
discoverTools: async () => {},
getAllTools: () => [],
getToolsByServer: () => [],
} as unknown as ToolRegistry;
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
const mockConfig = {
getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'oauth-personal',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
terminalHeight: 30,
}),
storage: {
getProjectTempDir: () => '/tmp',
},
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
getToolRegistry: () => mockToolRegistry,
getUseSmartEdit: () => false,
getUseModelRouter: () => false,
getGeminiClient: () => null,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const request = {
callId: 'abort-1',
name: 'abortDuringConfirmationTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-abort',
};
await scheduler.schedule([request], abortController.signal);
expect(onAllToolCallsComplete).toHaveBeenCalled();
const completedCalls = onAllToolCallsComplete.mock
.calls[0][0] as ToolCall[];
expect(completedCalls[0].status).toBe('cancelled');
const statuses = onToolCallsUpdate.mock.calls.flatMap((call) =>
(call[0] as ToolCall[]).map((toolCall) => toolCall.status),
);
expect(statuses).not.toContain('error');
});
describe('getToolSuggestion', () => {
it('should suggest the top N closest tool names for a typo', () => {
// Create mocked tool registry
+28 -11
View File
@@ -799,6 +799,15 @@ export class CoreToolScheduler {
);
}
} catch (error) {
if (signal.aborted) {
this.setStatusInternal(
reqInfo.callId,
'cancelled',
'Tool call cancelled by user.',
);
continue;
}
this.setStatusInternal(
reqInfo.callId,
'error',
@@ -1078,17 +1087,25 @@ export class CoreToolScheduler {
}
})
.catch((executionError: Error) => {
this.setStatusInternal(
callId,
'error',
createErrorResponse(
scheduledCall.request,
executionError instanceof Error
? executionError
: new Error(String(executionError)),
ToolErrorType.UNHANDLED_EXCEPTION,
),
);
if (signal.aborted) {
this.setStatusInternal(
callId,
'cancelled',
'User cancelled tool execution.',
);
} else {
this.setStatusInternal(
callId,
'error',
createErrorResponse(
scheduledCall.request,
executionError instanceof Error
? executionError
: new Error(String(executionError)),
ToolErrorType.UNHANDLED_EXCEPTION,
),
);
}
});
});
}
+55
View File
@@ -471,6 +471,34 @@ describe('EditTool', () => {
);
expect(patchedContent).toBe(expectedFinalContent);
});
it('should rethrow calculateEdit errors when the abort signal is triggered', async () => {
const filePath = path.join(rootDir, 'abort-confirmation.txt');
const params: EditToolParams = {
file_path: filePath,
old_string: 'old',
new_string: 'new',
};
const invocation = tool.build(params);
const abortController = new AbortController();
const abortError = new Error('Abort requested');
const calculateSpy = vi
.spyOn(invocation as any, 'calculateEdit')
.mockImplementation(async () => {
if (!abortController.signal.aborted) {
abortController.abort();
}
throw abortError;
});
await expect(
invocation.shouldConfirmExecute(abortController.signal),
).rejects.toBe(abortError);
calculateSpy.mockRestore();
});
});
describe('execute', () => {
@@ -515,6 +543,33 @@ describe('EditTool', () => {
);
});
it('should reject when calculateEdit fails after an abort signal', async () => {
const params: EditToolParams = {
file_path: path.join(rootDir, 'abort-execute.txt'),
old_string: 'old',
new_string: 'new',
};
const invocation = tool.build(params);
const abortController = new AbortController();
const abortError = new Error('Abort requested during execute');
const calculateSpy = vi
.spyOn(invocation as any, 'calculateEdit')
.mockImplementation(async () => {
if (!abortController.signal.aborted) {
abortController.abort();
}
throw abortError;
});
await expect(invocation.execute(abortController.signal)).rejects.toBe(
abortError,
);
calculateSpy.mockRestore();
});
it('should edit an existing file and return diff with fileName', async () => {
const initialContent = 'This is some old text.';
const newContent = 'This is some new text.'; // old -> new
+6
View File
@@ -251,6 +251,9 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
try {
editData = await this.calculateEdit(this.params, abortSignal);
} catch (error) {
if (abortSignal.aborted) {
throw error;
}
const errorMsg = error instanceof Error ? error.message : String(error);
console.log(`Error preparing edit: ${errorMsg}`);
return false;
@@ -336,6 +339,9 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
try {
editData = await this.calculateEdit(this.params, signal);
} catch (error) {
if (signal.aborted) {
throw error;
}
const errorMsg = error instanceof Error ? error.message : String(error);
return {
llmContent: `Error preparing edit: ${errorMsg}`,
+155
View File
@@ -572,6 +572,161 @@ describe('DiscoveredMCPTool', () => {
'Here is a resource.\n[Link to My Resource: file:///path/to/resource]\nEmbedded text content.\n[Image: image/jpeg]',
);
});
describe('AbortSignal support', () => {
it('should abort immediately if signal is already aborted', async () => {
const params = { param: 'test' };
const controller = new AbortController();
controller.abort();
const invocation = tool.build(params);
await expect(invocation.execute(controller.signal)).rejects.toThrow(
'Tool call aborted',
);
// Tool should not be called if signal is already aborted
expect(mockCallTool).not.toHaveBeenCalled();
});
it('should abort during tool execution', async () => {
const params = { param: 'test' };
const controller = new AbortController();
// Mock a delayed response to simulate long-running tool
mockCallTool.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve([
{
functionResponse: {
name: serverToolName,
response: {
content: [{ type: 'text', text: 'Success' }],
},
},
},
]);
}, 1000);
}),
);
const invocation = tool.build(params);
const promise = invocation.execute(controller.signal);
// Abort after a short delay to simulate cancellation during execution
setTimeout(() => controller.abort(), 50);
await expect(promise).rejects.toThrow('Tool call aborted');
});
it('should complete successfully if not aborted', async () => {
const params = { param: 'test' };
const controller = new AbortController();
const successResponse = [
{
functionResponse: {
name: serverToolName,
response: {
content: [{ type: 'text', text: 'Success' }],
},
},
},
];
mockCallTool.mockResolvedValue(successResponse);
const invocation = tool.build(params);
const result = await invocation.execute(controller.signal);
expect(result.llmContent).toEqual([{ text: 'Success' }]);
expect(result.returnDisplay).toBe('Success');
expect(mockCallTool).toHaveBeenCalledWith([
{ name: serverToolName, args: params },
]);
});
it('should handle tool error even when abort signal is provided', async () => {
const params = { param: 'test' };
const controller = new AbortController();
const errorResponse = [
{
functionResponse: {
name: serverToolName,
response: { error: { isError: true } },
},
},
];
mockCallTool.mockResolvedValue(errorResponse);
const invocation = tool.build(params);
const result = await invocation.execute(controller.signal);
expect(result.error?.type).toBe(ToolErrorType.MCP_TOOL_ERROR);
expect(result.returnDisplay).toContain(
`Error: MCP tool '${serverToolName}' reported an error.`,
);
});
it('should handle callTool rejection with abort signal', async () => {
const params = { param: 'test' };
const controller = new AbortController();
const expectedError = new Error('Network error');
mockCallTool.mockRejectedValue(expectedError);
const invocation = tool.build(params);
await expect(invocation.execute(controller.signal)).rejects.toThrow(
expectedError,
);
});
it('should cleanup event listeners properly on successful completion', async () => {
const params = { param: 'test' };
const controller = new AbortController();
const successResponse = [
{
functionResponse: {
name: serverToolName,
response: {
content: [{ type: 'text', text: 'Success' }],
},
},
},
];
mockCallTool.mockResolvedValue(successResponse);
const invocation = tool.build(params);
await invocation.execute(controller.signal);
controller.abort();
expect(controller.signal.aborted).toBe(true);
});
it('should cleanup event listeners properly on error', async () => {
const params = { param: 'test' };
const controller = new AbortController();
const expectedError = new Error('Tool execution failed');
mockCallTool.mockRejectedValue(expectedError);
const invocation = tool.build(params);
try {
await invocation.execute(controller.signal);
} catch (error) {
expect(error).toBe(expectedError);
}
// Verify cleanup by aborting after error
controller.abort();
expect(controller.signal.aborted).toBe(true);
});
});
});
describe('shouldConfirmExecute', () => {
+31 -2
View File
@@ -131,7 +131,7 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
return false;
}
async execute(): Promise<ToolResult> {
async execute(signal: AbortSignal): Promise<ToolResult> {
const functionCalls: FunctionCall[] = [
{
name: this.serverToolName,
@@ -139,7 +139,36 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
},
];
const rawResponseParts = await this.mcpTool.callTool(functionCalls);
// Race MCP tool call with abort signal to respect cancellation
const rawResponseParts = await new Promise<Part[]>((resolve, reject) => {
if (signal.aborted) {
const error = new Error('Tool call aborted');
error.name = 'AbortError';
reject(error);
return;
}
const onAbort = () => {
cleanup();
const error = new Error('Tool call aborted');
error.name = 'AbortError';
reject(error);
};
const cleanup = () => {
signal.removeEventListener('abort', onAbort);
};
signal.addEventListener('abort', onAbort, { once: true });
this.mcpTool
.callTool(functionCalls)
.then((res) => {
cleanup();
resolve(res);
})
.catch((err) => {
cleanup();
reject(err);
});
});
// Ensure the response is not an error
if (this.isMCPToolError(rawResponseParts)) {
@@ -274,6 +274,36 @@ describe('SmartEditTool', () => {
filePath = path.join(rootDir, testFile);
});
it('should reject when calculateEdit fails after an abort signal', async () => {
const params: EditToolParams = {
file_path: path.join(rootDir, 'abort-execute.txt'),
instruction: 'Abort during execute',
old_string: 'old',
new_string: 'new',
};
const invocation = tool.build(params);
const abortController = new AbortController();
const abortError = new Error(
'Abort requested during smart edit execution',
);
const calculateSpy = vi
.spyOn(invocation as any, 'calculateEdit')
.mockImplementation(async () => {
if (!abortController.signal.aborted) {
abortController.abort();
}
throw abortError;
});
await expect(invocation.execute(abortController.signal)).rejects.toBe(
abortError,
);
calculateSpy.mockRestore();
});
it('should edit an existing file and return diff with fileName', async () => {
const initialContent = 'This is some old text.';
const newContent = 'This is some new text.';
@@ -511,4 +541,37 @@ describe('SmartEditTool', () => {
expect(params.new_string).toBe(modifiedContent);
});
});
describe('shouldConfirmExecute', () => {
it('should rethrow calculateEdit errors when the abort signal is triggered', async () => {
const filePath = path.join(rootDir, 'abort-confirmation.txt');
const params: EditToolParams = {
file_path: filePath,
instruction: 'Abort during confirmation',
old_string: 'old',
new_string: 'new',
};
const invocation = tool.build(params);
const abortController = new AbortController();
const abortError = new Error(
'Abort requested during smart edit confirmation',
);
const calculateSpy = vi
.spyOn(invocation as any, 'calculateEdit')
.mockImplementation(async () => {
if (!abortController.signal.aborted) {
abortController.abort();
}
throw abortError;
});
await expect(
invocation.shouldConfirmExecute(abortController.signal),
).rejects.toBe(abortError);
calculateSpy.mockRestore();
});
});
});
+6
View File
@@ -490,6 +490,9 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
try {
editData = await this.calculateEdit(this.params, abortSignal);
} catch (error) {
if (abortSignal.aborted) {
throw error;
}
const errorMsg = error instanceof Error ? error.message : String(error);
console.log(`Error preparing edit: ${errorMsg}`);
return false;
@@ -575,6 +578,9 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
try {
editData = await this.calculateEdit(this.params, signal);
} catch (error) {
if (signal.aborted) {
throw error;
}
const errorMsg = error instanceof Error ? error.message : String(error);
return {
llmContent: `Error preparing edit: ${errorMsg}`,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+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.7.0-nightly.20250918.2722473a",
"version": "0.7.0-preview.2",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {