mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5966c5c401 | |||
| f28ffaa3b0 | |||
| babc691c8a | |||
| b9e046de34 | |||
| 8a22238f01 | |||
| d39cd04537 | |||
| fd2bc71e39 | |||
| 89aba7cbcd | |||
| 2fbfeb39fe | |||
| 903cc7bbbe |
@@ -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
|
||||
};
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.7.0-nightly.20250918.2722473a",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.7.0-nightly.20250918.2722473a",
|
||||
"version": "0.7.0",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.7.0-nightly.20250918.2722473a",
|
||||
"version": "0.7.0",
|
||||
"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"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.7.0-nightly.20250918.2722473a",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.7.0-nightly.20250918.2722473a",
|
||||
"version": "0.7.0",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.7.0-nightly.20250918.2722473a",
|
||||
"version": "0.7.0",
|
||||
"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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.7.0-nightly.20250918.2722473a",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -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",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user