feat(ide): Check for IDE diffing capabilities before opening diffs (#8266)

This commit is contained in:
Shreya Keshive
2025-09-11 16:17:57 -04:00
committed by GitHub
parent 59182a9fa0
commit 8969a232ec
9 changed files with 183 additions and 50 deletions
+90
View File
@@ -83,6 +83,7 @@ describe('IdeClient', () => {
close: vi.fn(),
setNotificationHandler: vi.fn(),
callTool: vi.fn(),
request: vi.fn(),
} as unknown as Mocked<Client>;
mockHttpTransport = {
close: vi.fn(),
@@ -534,4 +535,93 @@ describe('IdeClient', () => {
);
});
});
describe('isDiffingEnabled', () => {
it('should return false if not connected', async () => {
const ideClient = await IdeClient.getInstance();
expect(ideClient.isDiffingEnabled()).toBe(false);
});
it('should return false if tool discovery fails', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
mockClient.request.mockRejectedValue(new Error('Method not found'));
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(ideClient.isDiffingEnabled()).toBe(false);
});
it('should return false if diffing tools are not available', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
mockClient.request.mockResolvedValue({
tools: [{ name: 'someOtherTool' }],
});
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(ideClient.isDiffingEnabled()).toBe(false);
});
it('should return false if only openDiff tool is available', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
mockClient.request.mockResolvedValue({
tools: [{ name: 'openDiff' }],
});
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(ideClient.isDiffingEnabled()).toBe(false);
});
it('should return true if connected and diffing tools are available', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
mockClient.request.mockResolvedValue({
tools: [{ name: 'openDiff' }, { name: 'closeDiff' }],
});
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(ideClient.isDiffingEnabled()).toBe(true);
});
});
});
+51
View File
@@ -22,6 +22,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import * as os from 'node:os';
import * as path from 'node:path';
import { EnvHttpProxyAgent } from 'undici';
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
const logger = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -78,6 +79,7 @@ export class IdeClient {
private diffResponses = new Map<string, (result: DiffUpdateResult) => void>();
private statusListeners = new Set<(state: IDEConnectionState) => void>();
private trustChangeListeners = new Set<(isTrusted: boolean) => void>();
private availableTools: string[] = [];
/**
* A mutex to ensure that only one diff view is open in the IDE at a time.
* This prevents race conditions and UI issues in IDEs like VSCode that
@@ -334,6 +336,53 @@ export class IdeClient {
return this.currentIdeDisplayName;
}
isDiffingEnabled(): boolean {
return (
!!this.client &&
this.state.status === IDEConnectionStatus.Connected &&
this.availableTools.includes('openDiff') &&
this.availableTools.includes('closeDiff')
);
}
private async discoverTools(): Promise<void> {
if (!this.client) {
return;
}
try {
logger.debug('Discovering tools from IDE...');
const response = await this.client.request(
{ method: 'tools/list', params: {} },
ListToolsResultSchema,
);
// Map the array of tool objects to an array of tool names (strings)
this.availableTools = response.tools.map((tool) => tool.name);
if (this.availableTools.length > 0) {
logger.debug(
`Discovered ${this.availableTools.length} tools from IDE: ${this.availableTools.join(', ')}`,
);
} else {
logger.debug(
'IDE supports tool discovery, but no tools are available.',
);
}
} catch (error) {
// It's okay if this fails, the IDE might not support it.
// Don't log an error if the method is not found, which is a common case.
if (
error instanceof Error &&
!error.message?.includes('Method not found')
) {
logger.error(`Error discovering tools from IDE: ${error.message}`);
} else {
logger.debug('IDE does not support tool discovery.');
}
this.availableTools = [];
}
}
private setState(
status: IDEConnectionStatus,
details?: string,
@@ -631,6 +680,7 @@ export class IdeClient {
);
await this.client.connect(transport);
this.registerClientHandlers();
await this.discoverTools();
this.setState(IDEConnectionStatus.Connected);
return true;
} catch (_error) {
@@ -664,6 +714,7 @@ export class IdeClient {
});
await this.client.connect(transport);
this.registerClientHandlers();
await this.discoverTools();
this.setState(IDEConnectionStatus.Connected);
return true;
} catch (_error) {
+2 -8
View File
@@ -10,16 +10,12 @@ const mockEnsureCorrectEdit = vi.hoisted(() => vi.fn());
const mockGenerateJson = vi.hoisted(() => vi.fn());
const mockOpenDiff = vi.hoisted(() => vi.fn());
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
import { IdeClient } from '../ide/ide-client.js';
vi.mock('../ide/ide-client.js', () => ({
IdeClient: {
getInstance: vi.fn(),
},
IDEConnectionStatus: {
Connected: 'connected',
Disconnected: 'disconnected',
},
}));
vi.mock('../utils/editCorrector.js', () => ({
@@ -896,9 +892,7 @@ describe('EditTool', () => {
filePath = path.join(rootDir, testFile);
ideClient = {
openDiff: vi.fn(),
getConnectionStatus: vi.fn().mockReturnValue({
status: IDEConnectionStatus.Connected,
}),
isDiffingEnabled: vi.fn().mockReturnValue(true),
};
vi.mocked(IdeClient.getInstance).mockResolvedValue(ideClient);
(mockConfig as any).getIdeMode = () => true;
+2 -3
View File
@@ -32,7 +32,7 @@ import type {
ModifiableDeclarativeTool,
ModifyContext,
} from './modifiable-tool.js';
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
import { IdeClient } from '../ide/ide-client.js';
export function applyReplacement(
currentContent: string | null,
@@ -268,8 +268,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
);
const ideClient = await IdeClient.getInstance();
const ideConfirmation =
this.config.getIdeMode() &&
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
this.config.getIdeMode() && ideClient.isDiffingEnabled()
? ideClient.openDiff(this.params.file_path, editData.newContent)
: undefined;
+2 -8
View File
@@ -10,16 +10,12 @@ const mockFixLLMEditWithInstruction = vi.hoisted(() => vi.fn());
const mockGenerateJson = vi.hoisted(() => vi.fn());
const mockOpenDiff = vi.hoisted(() => vi.fn());
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
import { IdeClient } from '../ide/ide-client.js';
vi.mock('../ide/ide-client.js', () => ({
IdeClient: {
getInstance: vi.fn(),
},
IDEConnectionStatus: {
Connected: 'connected',
Disconnected: 'disconnected',
},
}));
vi.mock('../utils/llm-edit-fixer.js', () => ({
@@ -461,9 +457,7 @@ describe('SmartEditTool', () => {
filePath = path.join(rootDir, testFile);
ideClient = {
openDiff: vi.fn(),
getConnectionStatus: vi.fn().mockReturnValue({
status: IDEConnectionStatus.Connected,
}),
isDiffingEnabled: vi.fn().mockReturnValue(true),
};
vi.mocked(IdeClient.getInstance).mockResolvedValue(ideClient);
(mockConfig as any).getIdeMode = () => true;
+2 -3
View File
@@ -28,7 +28,7 @@ import {
type ModifiableDeclarativeTool,
type ModifyContext,
} from './modifiable-tool.js';
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
import { IdeClient } from '../ide/ide-client.js';
import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js';
export function applyReplacement(
@@ -528,8 +528,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
);
const ideClient = await IdeClient.getInstance();
const ideConfirmation =
this.config.getIdeMode() &&
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
this.config.getIdeMode() && ideClient.isDiffingEnabled()
? ideClient.openDiff(this.params.file_path, editData.newContent)
: undefined;
+4 -13
View File
@@ -33,7 +33,7 @@ import {
} from '../utils/editCorrector.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
import { IdeClient } from '../ide/ide-client.js';
import type { DiffUpdateResult } from '../ide/ideContext.js';
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
@@ -45,18 +45,13 @@ vi.mock('../ide/ide-client.js', () => ({
IdeClient: {
getInstance: vi.fn(),
},
IDEConnectionStatus: {
Connected: 'connected',
Disconnected: 'disconnected',
Connecting: 'connecting',
},
}));
let mockGeminiClientInstance: Mocked<GeminiClient>;
const mockEnsureCorrectEdit = vi.fn<typeof ensureCorrectEdit>();
const mockEnsureCorrectFileContent = vi.fn<typeof ensureCorrectFileContent>();
const mockIdeClient = {
getConnectionStatus: vi.fn(),
openDiff: vi.fn(),
isDiffingEnabled: vi.fn(),
};
// Wire up the mocked functions to be used by the actual module imports
@@ -456,9 +451,7 @@ describe('WriteFileTool', () => {
beforeEach(() => {
// Enable IDE mode and set connection status for these tests
mockConfigInternal.getIdeMode.mockReturnValue(true);
mockIdeClient.getConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Connected,
});
mockIdeClient.isDiffingEnabled.mockReturnValue(true);
mockIdeClient.openDiff.mockResolvedValue({
status: 'accepted',
content: 'ide-modified-content',
@@ -495,9 +488,7 @@ describe('WriteFileTool', () => {
});
it('should not call openDiff if IDE is not connected', async () => {
mockIdeClient.getConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Disconnected,
});
mockIdeClient.isDiffingEnabled.mockReturnValue(false);
const filePath = path.join(rootDir, 'ide_disconnected_file.txt');
const params = { file_path: filePath, content: 'test' };
const invocation = tool.build(params);
+2 -3
View File
@@ -35,7 +35,7 @@ import type {
ModifiableDeclarativeTool,
ModifyContext,
} from './modifiable-tool.js';
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
import { IdeClient } from '../ide/ide-client.js';
import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperationEvent } from '../telemetry/types.js';
import { FileOperation } from '../telemetry/metrics.js';
@@ -195,8 +195,7 @@ class WriteFileToolInvocation extends BaseToolInvocation<
const ideClient = await IdeClient.getInstance();
const ideConfirmation =
this.config.getIdeMode() &&
ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected
this.config.getIdeMode() && ideClient.isDiffingEnabled()
? ideClient.openDiff(this.params.file_path, correctedContent)
: undefined;