Refactor IDE client state management, improve user-facing error messages, and add logging of connection events (#5591)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
This commit is contained in:
parent
6a72cd064b
commit
268627469b
|
@ -1007,16 +1007,6 @@ describe('loadCliConfig ideModeFeature', () => {
|
||||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||||
expect(config.getIdeModeFeature()).toBe(false);
|
expect(config.getIdeModeFeature()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be false when settings.ideModeFeature is true, but SANDBOX is set', async () => {
|
|
||||||
process.argv = ['node', 'script.js'];
|
|
||||||
const argv = await parseArguments();
|
|
||||||
process.env.TERM_PROGRAM = 'vscode';
|
|
||||||
process.env.SANDBOX = 'true';
|
|
||||||
const settings: Settings = { ideModeFeature: true };
|
|
||||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
|
||||||
expect(config.getIdeModeFeature()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('fs', async () => {
|
vi.mock('fs', async () => {
|
||||||
|
|
|
@ -22,7 +22,6 @@ import {
|
||||||
FileDiscoveryService,
|
FileDiscoveryService,
|
||||||
TelemetryTarget,
|
TelemetryTarget,
|
||||||
FileFilteringOptions,
|
FileFilteringOptions,
|
||||||
IdeClient,
|
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { Settings } from './settings.js';
|
import { Settings } from './settings.js';
|
||||||
|
|
||||||
|
@ -296,13 +295,10 @@ export async function loadCliConfig(
|
||||||
) ||
|
) ||
|
||||||
false;
|
false;
|
||||||
const memoryImportFormat = settings.memoryImportFormat || 'tree';
|
const memoryImportFormat = settings.memoryImportFormat || 'tree';
|
||||||
|
|
||||||
const ideMode = settings.ideMode ?? false;
|
const ideMode = settings.ideMode ?? false;
|
||||||
|
|
||||||
const ideModeFeature =
|
const ideModeFeature =
|
||||||
(argv.ideModeFeature ?? settings.ideModeFeature ?? false) &&
|
argv.ideModeFeature ?? settings.ideModeFeature ?? false;
|
||||||
!process.env.SANDBOX;
|
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance(ideMode && ideModeFeature);
|
|
||||||
|
|
||||||
const allExtensions = annotateActiveExtensions(
|
const allExtensions = annotateActiveExtensions(
|
||||||
extensions,
|
extensions,
|
||||||
|
@ -471,7 +467,6 @@ export async function loadCliConfig(
|
||||||
summarizeToolOutput: settings.summarizeToolOutput,
|
summarizeToolOutput: settings.summarizeToolOutput,
|
||||||
ideMode,
|
ideMode,
|
||||||
ideModeFeature,
|
ideModeFeature,
|
||||||
ideClient,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -42,9 +42,15 @@ describe('ideCommand', () => {
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
getIdeModeFeature: vi.fn(),
|
getIdeModeFeature: vi.fn(),
|
||||||
getIdeMode: vi.fn(),
|
getIdeMode: vi.fn(),
|
||||||
getIdeClient: vi.fn(),
|
getIdeClient: vi.fn(() => ({
|
||||||
|
reconnect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
getCurrentIde: vi.fn(),
|
||||||
|
getDetectedIdeDisplayName: vi.fn(),
|
||||||
|
getConnectionStatus: vi.fn(),
|
||||||
|
})),
|
||||||
|
setIdeModeAndSyncConnection: vi.fn(),
|
||||||
setIdeMode: vi.fn(),
|
setIdeMode: vi.fn(),
|
||||||
setIdeClientDisconnected: vi.fn(),
|
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
platformSpy = vi.spyOn(process, 'platform', 'get');
|
platformSpy = vi.spyOn(process, 'platform', 'get');
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
IDEConnectionStatus,
|
IDEConnectionStatus,
|
||||||
getIdeDisplayName,
|
getIdeDisplayName,
|
||||||
getIdeInstaller,
|
getIdeInstaller,
|
||||||
|
IdeClient,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
CommandContext,
|
CommandContext,
|
||||||
|
@ -19,6 +20,35 @@ import {
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
|
||||||
|
function getIdeStatusMessage(ideClient: IdeClient): {
|
||||||
|
messageType: 'info' | 'error';
|
||||||
|
content: string;
|
||||||
|
} {
|
||||||
|
const connection = ideClient.getConnectionStatus();
|
||||||
|
switch (connection.status) {
|
||||||
|
case IDEConnectionStatus.Connected:
|
||||||
|
return {
|
||||||
|
messageType: 'info',
|
||||||
|
content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
|
||||||
|
};
|
||||||
|
case IDEConnectionStatus.Connecting:
|
||||||
|
return {
|
||||||
|
messageType: 'info',
|
||||||
|
content: `🟡 Connecting...`,
|
||||||
|
};
|
||||||
|
default: {
|
||||||
|
let content = `🔴 Disconnected`;
|
||||||
|
if (connection?.details) {
|
||||||
|
content += `: ${connection.details}`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messageType: 'error',
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const ideCommand = (config: Config | null): SlashCommand | null => {
|
export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||||
if (!config || !config.getIdeModeFeature()) {
|
if (!config || !config.getIdeModeFeature()) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -54,33 +84,13 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||||
name: 'status',
|
name: 'status',
|
||||||
description: 'check status of IDE integration',
|
description: 'check status of IDE integration',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (_context: CommandContext): SlashCommandActionReturn => {
|
action: (): SlashCommandActionReturn => {
|
||||||
const connection = ideClient.getConnectionStatus();
|
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||||
switch (connection.status) {
|
|
||||||
case IDEConnectionStatus.Connected:
|
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType,
|
||||||
content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
|
|
||||||
} as const;
|
|
||||||
case IDEConnectionStatus.Connecting:
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: `🟡 Connecting...`,
|
|
||||||
} as const;
|
|
||||||
default: {
|
|
||||||
let content = `🔴 Disconnected`;
|
|
||||||
if (connection?.details) {
|
|
||||||
content += `: ${connection.details}`;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'error',
|
|
||||||
content,
|
content,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -110,6 +120,10 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await installer.install();
|
const result = await installer.install();
|
||||||
|
if (result.success) {
|
||||||
|
config.setIdeMode(true);
|
||||||
|
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
|
||||||
|
}
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: result.success ? 'info' : 'error',
|
type: result.success ? 'info' : 'error',
|
||||||
|
@ -126,8 +140,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context: CommandContext) => {
|
action: async (context: CommandContext) => {
|
||||||
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
|
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
|
||||||
config.setIdeMode(true);
|
await config.setIdeModeAndSyncConnection(true);
|
||||||
config.setIdeClientConnected();
|
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: messageType,
|
||||||
|
text: content,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -137,8 +158,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context: CommandContext) => {
|
action: async (context: CommandContext) => {
|
||||||
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
|
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
|
||||||
config.setIdeMode(false);
|
await config.setIdeModeAndSyncConnection(false);
|
||||||
config.setIdeClientDisconnected();
|
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: messageType,
|
||||||
|
text: content,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,6 @@ import {
|
||||||
} from '../core/contentGenerator.js';
|
} from '../core/contentGenerator.js';
|
||||||
import { GeminiClient } from '../core/client.js';
|
import { GeminiClient } from '../core/client.js';
|
||||||
import { GitService } from '../services/gitService.js';
|
import { GitService } from '../services/gitService.js';
|
||||||
import { IdeClient } from '../ide/ide-client.js';
|
|
||||||
|
|
||||||
vi.mock('fs', async (importOriginal) => {
|
vi.mock('fs', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('fs')>();
|
const actual = await importOriginal<typeof import('fs')>();
|
||||||
|
@ -120,7 +119,6 @@ describe('Server Config (config.ts)', () => {
|
||||||
telemetry: TELEMETRY_SETTINGS,
|
telemetry: TELEMETRY_SETTINGS,
|
||||||
sessionId: SESSION_ID,
|
sessionId: SESSION_ID,
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
ideClient: IdeClient.getInstance(false),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
|
@ -48,6 +48,8 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
||||||
import { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
import { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||||
import { IdeClient } from '../ide/ide-client.js';
|
import { IdeClient } from '../ide/ide-client.js';
|
||||||
import type { Content } from '@google/genai';
|
import type { Content } from '@google/genai';
|
||||||
|
import { logIdeConnection } from '../telemetry/loggers.js';
|
||||||
|
import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js';
|
||||||
|
|
||||||
// Re-export OAuth config type
|
// Re-export OAuth config type
|
||||||
export type { MCPOAuthConfig };
|
export type { MCPOAuthConfig };
|
||||||
|
@ -187,7 +189,6 @@ export interface ConfigParameters {
|
||||||
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
|
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
|
||||||
ideModeFeature?: boolean;
|
ideModeFeature?: boolean;
|
||||||
ideMode?: boolean;
|
ideMode?: boolean;
|
||||||
ideClient: IdeClient;
|
|
||||||
loadMemoryFromIncludeDirectories?: boolean;
|
loadMemoryFromIncludeDirectories?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -305,7 +306,11 @@ export class Config {
|
||||||
this.summarizeToolOutput = params.summarizeToolOutput;
|
this.summarizeToolOutput = params.summarizeToolOutput;
|
||||||
this.ideModeFeature = params.ideModeFeature ?? false;
|
this.ideModeFeature = params.ideModeFeature ?? false;
|
||||||
this.ideMode = params.ideMode ?? false;
|
this.ideMode = params.ideMode ?? false;
|
||||||
this.ideClient = params.ideClient;
|
this.ideClient = IdeClient.getInstance();
|
||||||
|
if (this.ideMode && this.ideModeFeature) {
|
||||||
|
this.ideClient.connect();
|
||||||
|
logIdeConnection(this, new IdeConnectionEvent(IdeConnectionType.START));
|
||||||
|
}
|
||||||
this.loadMemoryFromIncludeDirectories =
|
this.loadMemoryFromIncludeDirectories =
|
||||||
params.loadMemoryFromIncludeDirectories ?? false;
|
params.loadMemoryFromIncludeDirectories ?? false;
|
||||||
|
|
||||||
|
@ -633,10 +638,6 @@ export class Config {
|
||||||
return this.ideModeFeature;
|
return this.ideModeFeature;
|
||||||
}
|
}
|
||||||
|
|
||||||
getIdeClient(): IdeClient {
|
|
||||||
return this.ideClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIdeMode(): boolean {
|
getIdeMode(): boolean {
|
||||||
return this.ideMode;
|
return this.ideMode;
|
||||||
}
|
}
|
||||||
|
@ -645,12 +646,18 @@ export class Config {
|
||||||
this.ideMode = value;
|
this.ideMode = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIdeClientDisconnected(): void {
|
async setIdeModeAndSyncConnection(value: boolean): Promise<void> {
|
||||||
this.ideClient.setDisconnected();
|
this.ideMode = value;
|
||||||
|
if (value) {
|
||||||
|
await this.ideClient.connect();
|
||||||
|
logIdeConnection(this, new IdeConnectionEvent(IdeConnectionType.SESSION));
|
||||||
|
} else {
|
||||||
|
this.ideClient.disconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIdeClientConnected(): void {
|
getIdeClient(): IdeClient {
|
||||||
this.ideClient.reconnect(this.ideMode && this.ideModeFeature);
|
return this.ideClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGitService(): Promise<GitService> {
|
async getGitService(): Promise<GitService> {
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { Config } from './config.js';
|
import { Config } from './config.js';
|
||||||
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js';
|
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js';
|
||||||
import { IdeClient } from '../ide/ide-client.js';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
vi.mock('node:fs');
|
vi.mock('node:fs');
|
||||||
|
@ -26,7 +26,6 @@ describe('Flash Model Fallback Configuration', () => {
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
model: DEFAULT_GEMINI_MODEL,
|
model: DEFAULT_GEMINI_MODEL,
|
||||||
ideClient: IdeClient.getInstance(false),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize contentGeneratorConfig for testing
|
// Initialize contentGeneratorConfig for testing
|
||||||
|
@ -51,7 +50,6 @@ describe('Flash Model Fallback Configuration', () => {
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
model: DEFAULT_GEMINI_MODEL,
|
model: DEFAULT_GEMINI_MODEL,
|
||||||
ideClient: IdeClient.getInstance(false),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should not crash when contentGeneratorConfig is undefined
|
// Should not crash when contentGeneratorConfig is undefined
|
||||||
|
@ -75,7 +73,6 @@ describe('Flash Model Fallback Configuration', () => {
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
model: 'custom-model',
|
model: 'custom-model',
|
||||||
ideClient: IdeClient.getInstance(false),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(newConfig.getModel()).toBe('custom-model');
|
expect(newConfig.getModel()).toBe('custom-model');
|
||||||
|
|
|
@ -33,154 +33,38 @@ export enum IDEConnectionStatus {
|
||||||
* Manages the connection to and interaction with the IDE server.
|
* Manages the connection to and interaction with the IDE server.
|
||||||
*/
|
*/
|
||||||
export class IdeClient {
|
export class IdeClient {
|
||||||
client: Client | undefined = undefined;
|
private static instance: IdeClient;
|
||||||
|
private client: Client | undefined = undefined;
|
||||||
private state: IDEConnectionState = {
|
private state: IDEConnectionState = {
|
||||||
status: IDEConnectionStatus.Disconnected,
|
status: IDEConnectionStatus.Disconnected,
|
||||||
|
details:
|
||||||
|
'IDE integration is currently disabled. To enable it, run /ide enable.',
|
||||||
};
|
};
|
||||||
private static instance: IdeClient;
|
|
||||||
private readonly currentIde: DetectedIde | undefined;
|
private readonly currentIde: DetectedIde | undefined;
|
||||||
private readonly currentIdeDisplayName: string | undefined;
|
private readonly currentIdeDisplayName: string | undefined;
|
||||||
|
|
||||||
constructor(ideMode: boolean) {
|
private constructor() {
|
||||||
this.currentIde = detectIde();
|
this.currentIde = detectIde();
|
||||||
if (this.currentIde) {
|
if (this.currentIde) {
|
||||||
this.currentIdeDisplayName = getIdeDisplayName(this.currentIde);
|
this.currentIdeDisplayName = getIdeDisplayName(this.currentIde);
|
||||||
}
|
}
|
||||||
if (!ideMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.init().catch((err) => {
|
|
||||||
logger.debug('Failed to initialize IdeClient:', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getInstance(ideMode: boolean): IdeClient {
|
static getInstance(): IdeClient {
|
||||||
if (!IdeClient.instance) {
|
if (!IdeClient.instance) {
|
||||||
IdeClient.instance = new IdeClient(ideMode);
|
IdeClient.instance = new IdeClient();
|
||||||
}
|
}
|
||||||
return IdeClient.instance;
|
return IdeClient.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentIde(): DetectedIde | undefined {
|
async connect(): Promise<void> {
|
||||||
return this.currentIde;
|
|
||||||
}
|
|
||||||
|
|
||||||
getConnectionStatus(): IDEConnectionState {
|
|
||||||
return this.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setState(status: IDEConnectionStatus, details?: string) {
|
|
||||||
this.state = { status, details };
|
|
||||||
|
|
||||||
if (status === IDEConnectionStatus.Disconnected) {
|
|
||||||
logger.debug('IDE integration is disconnected. ', details);
|
|
||||||
ideContext.clearIdeContext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPortFromEnv(): string | undefined {
|
|
||||||
const port = process.env['GEMINI_CLI_IDE_SERVER_PORT'];
|
|
||||||
if (!port) {
|
|
||||||
this.setState(
|
|
||||||
IDEConnectionStatus.Disconnected,
|
|
||||||
'Gemini CLI Companion extension not found. Install via /ide install and restart the CLI in a fresh terminal window.',
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateWorkspacePath(): boolean {
|
|
||||||
const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
|
||||||
if (!ideWorkspacePath) {
|
|
||||||
this.setState(
|
|
||||||
IDEConnectionStatus.Disconnected,
|
|
||||||
'IDE integration requires a single workspace folder to be open in the IDE. Please ensure one folder is open and try again.',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ideWorkspacePath !== process.cwd()) {
|
|
||||||
this.setState(
|
|
||||||
IDEConnectionStatus.Disconnected,
|
|
||||||
`Gemini CLI is running in a different directory (${process.cwd()}) from the IDE's open workspace (${ideWorkspacePath}). Please run Gemini CLI in the same directory.`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private registerClientHandlers() {
|
|
||||||
if (!this.client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.client.setNotificationHandler(
|
|
||||||
IdeContextNotificationSchema,
|
|
||||||
(notification) => {
|
|
||||||
ideContext.setIdeContext(notification.params);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
this.client.onerror = (_error) => {
|
|
||||||
this.setState(IDEConnectionStatus.Disconnected, 'Client error.');
|
|
||||||
};
|
|
||||||
|
|
||||||
this.client.onclose = () => {
|
|
||||||
this.setState(IDEConnectionStatus.Disconnected, 'Connection closed.');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async reconnect(ideMode: boolean) {
|
|
||||||
IdeClient.instance = new IdeClient(ideMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async establishConnection(port: string) {
|
|
||||||
let transport: StreamableHTTPClientTransport | undefined;
|
|
||||||
try {
|
|
||||||
this.client = new Client({
|
|
||||||
name: 'streamable-http-client',
|
|
||||||
// TODO(#3487): use the CLI version here.
|
|
||||||
version: '1.0.0',
|
|
||||||
});
|
|
||||||
|
|
||||||
transport = new StreamableHTTPClientTransport(
|
|
||||||
new URL(`http://localhost:${port}/mcp`),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.registerClientHandlers();
|
|
||||||
|
|
||||||
await this.client.connect(transport);
|
|
||||||
|
|
||||||
this.setState(IDEConnectionStatus.Connected);
|
|
||||||
} catch (error) {
|
|
||||||
this.setState(
|
|
||||||
IDEConnectionStatus.Disconnected,
|
|
||||||
`Failed to connect to IDE server: ${error}`,
|
|
||||||
);
|
|
||||||
if (transport) {
|
|
||||||
try {
|
|
||||||
await transport.close();
|
|
||||||
} catch (closeError) {
|
|
||||||
logger.debug('Failed to close transport:', closeError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async init(): Promise<void> {
|
|
||||||
if (this.state.status === IDEConnectionStatus.Connected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.currentIde) {
|
|
||||||
this.setState(
|
|
||||||
IDEConnectionStatus.Disconnected,
|
|
||||||
'Not running in a supported IDE, skipping connection.',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState(IDEConnectionStatus.Connecting);
|
this.setState(IDEConnectionStatus.Connecting);
|
||||||
|
|
||||||
|
if (!this.currentIde || !this.currentIdeDisplayName) {
|
||||||
|
this.setState(IDEConnectionStatus.Disconnected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.validateWorkspacePath()) {
|
if (!this.validateWorkspacePath()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -193,15 +77,132 @@ export class IdeClient {
|
||||||
await this.establishConnection(port);
|
await this.establishConnection(port);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
disconnect() {
|
||||||
|
this.setState(
|
||||||
|
IDEConnectionStatus.Disconnected,
|
||||||
|
'IDE integration disabled. To enable it again, run /ide enable.',
|
||||||
|
);
|
||||||
this.client?.close();
|
this.client?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCurrentIde(): DetectedIde | undefined {
|
||||||
|
return this.currentIde;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectionStatus(): IDEConnectionState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
getDetectedIdeDisplayName(): string | undefined {
|
getDetectedIdeDisplayName(): string | undefined {
|
||||||
return this.currentIdeDisplayName;
|
return this.currentIdeDisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDisconnected() {
|
private setState(status: IDEConnectionStatus, details?: string) {
|
||||||
this.setState(IDEConnectionStatus.Disconnected);
|
const isAlreadyDisconnected =
|
||||||
|
this.state.status === IDEConnectionStatus.Disconnected &&
|
||||||
|
status === IDEConnectionStatus.Disconnected;
|
||||||
|
|
||||||
|
// Only update details if the state wasn't already disconnected, so that
|
||||||
|
// the first detail message is preserved.
|
||||||
|
if (!isAlreadyDisconnected) {
|
||||||
|
this.state = { status, details };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === IDEConnectionStatus.Disconnected) {
|
||||||
|
logger.debug('IDE integration disconnected:', details);
|
||||||
|
ideContext.clearIdeContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateWorkspacePath(): boolean {
|
||||||
|
const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
||||||
|
if (ideWorkspacePath === undefined) {
|
||||||
|
this.setState(
|
||||||
|
IDEConnectionStatus.Disconnected,
|
||||||
|
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ideWorkspacePath === '') {
|
||||||
|
this.setState(
|
||||||
|
IDEConnectionStatus.Disconnected,
|
||||||
|
`To use this feature, please open a single workspace folder in ${this.currentIdeDisplayName} and try again.`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ideWorkspacePath !== process.cwd()) {
|
||||||
|
this.setState(
|
||||||
|
IDEConnectionStatus.Disconnected,
|
||||||
|
`Directory mismatch. Gemini CLI is running in a different location than the open workspace in ${this.currentIdeDisplayName}. Please run the CLI from the same directory as your project's root folder.`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPortFromEnv(): string | undefined {
|
||||||
|
const port = process.env['GEMINI_CLI_IDE_SERVER_PORT'];
|
||||||
|
if (!port) {
|
||||||
|
this.setState(
|
||||||
|
IDEConnectionStatus.Disconnected,
|
||||||
|
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerClientHandlers() {
|
||||||
|
if (!this.client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.setNotificationHandler(
|
||||||
|
IdeContextNotificationSchema,
|
||||||
|
(notification) => {
|
||||||
|
ideContext.setIdeContext(notification.params);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.client.onerror = (_error) => {
|
||||||
|
this.setState(
|
||||||
|
IDEConnectionStatus.Disconnected,
|
||||||
|
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
this.client.onclose = () => {
|
||||||
|
this.setState(
|
||||||
|
IDEConnectionStatus.Disconnected,
|
||||||
|
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async establishConnection(port: string) {
|
||||||
|
let transport: StreamableHTTPClientTransport | undefined;
|
||||||
|
try {
|
||||||
|
this.client = new Client({
|
||||||
|
name: 'streamable-http-client',
|
||||||
|
// TODO(#3487): use the CLI version here.
|
||||||
|
version: '1.0.0',
|
||||||
|
});
|
||||||
|
transport = new StreamableHTTPClientTransport(
|
||||||
|
new URL(`http://localhost:${port}/mcp`),
|
||||||
|
);
|
||||||
|
await this.client.connect(transport);
|
||||||
|
this.registerClientHandlers();
|
||||||
|
this.setState(IDEConnectionStatus.Connected);
|
||||||
|
} catch (_error) {
|
||||||
|
this.setState(
|
||||||
|
IDEConnectionStatus.Disconnected,
|
||||||
|
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`,
|
||||||
|
);
|
||||||
|
if (transport) {
|
||||||
|
try {
|
||||||
|
await transport.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
logger.debug('Failed to close transport:', closeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
NextSpeakerCheckEvent,
|
NextSpeakerCheckEvent,
|
||||||
SlashCommandEvent,
|
SlashCommandEvent,
|
||||||
MalformedJsonResponseEvent,
|
MalformedJsonResponseEvent,
|
||||||
|
IdeConnectionEvent,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { EventMetadataKey } from './event-metadata-key.js';
|
import { EventMetadataKey } from './event-metadata-key.js';
|
||||||
import { Config } from '../../config/config.js';
|
import { Config } from '../../config/config.js';
|
||||||
|
@ -44,6 +45,7 @@ const loop_detected_event_name = 'loop_detected';
|
||||||
const next_speaker_check_event_name = 'next_speaker_check';
|
const next_speaker_check_event_name = 'next_speaker_check';
|
||||||
const slash_command_event_name = 'slash_command';
|
const slash_command_event_name = 'slash_command';
|
||||||
const malformed_json_response_event_name = 'malformed_json_response';
|
const malformed_json_response_event_name = 'malformed_json_response';
|
||||||
|
const ide_connection_event_name = 'ide_connection';
|
||||||
|
|
||||||
export interface LogResponse {
|
export interface LogResponse {
|
||||||
nextRequestWaitMs?: number;
|
nextRequestWaitMs?: number;
|
||||||
|
@ -578,6 +580,18 @@ export class ClearcutLogger {
|
||||||
this.flushIfNeeded();
|
this.flushIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logIdeConnectionEvent(event: IdeConnectionEvent): void {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
gemini_cli_key: EventMetadataKey.GEMINI_CLI_IDE_CONNECTION_TYPE,
|
||||||
|
value: JSON.stringify(event.connection_type),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
this.enqueueLogEvent(this.createLogEvent(ide_connection_event_name, data));
|
||||||
|
this.flushIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
logEndSessionEvent(event: EndSessionEvent): void {
|
logEndSessionEvent(event: EndSessionEvent): void {
|
||||||
const data = [
|
const data = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -190,6 +190,13 @@ export enum EventMetadataKey {
|
||||||
|
|
||||||
// Logs the model that produced the malformed JSON response.
|
// Logs the model that produced the malformed JSON response.
|
||||||
GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL = 45,
|
GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL = 45,
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// IDE Connection Event Keys
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// Logs the type of the IDE connection.
|
||||||
|
GEMINI_CLI_IDE_CONNECTION_TYPE = 46,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEventMetadataKey(
|
export function getEventMetadataKey(
|
||||||
|
|
|
@ -15,6 +15,7 @@ export const EVENT_CLI_CONFIG = 'gemini_cli.config';
|
||||||
export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback';
|
export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback';
|
||||||
export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check';
|
export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check';
|
||||||
export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command';
|
export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command';
|
||||||
|
export const EVENT_IDE_CONNECTION = 'gemini_cli.ide_connection';
|
||||||
|
|
||||||
export const METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count';
|
export const METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count';
|
||||||
export const METRIC_TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency';
|
export const METRIC_TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency';
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
EVENT_API_REQUEST,
|
EVENT_API_REQUEST,
|
||||||
EVENT_API_RESPONSE,
|
EVENT_API_RESPONSE,
|
||||||
EVENT_CLI_CONFIG,
|
EVENT_CLI_CONFIG,
|
||||||
|
EVENT_IDE_CONNECTION,
|
||||||
EVENT_TOOL_CALL,
|
EVENT_TOOL_CALL,
|
||||||
EVENT_USER_PROMPT,
|
EVENT_USER_PROMPT,
|
||||||
EVENT_FLASH_FALLBACK,
|
EVENT_FLASH_FALLBACK,
|
||||||
|
@ -23,6 +24,7 @@ import {
|
||||||
ApiErrorEvent,
|
ApiErrorEvent,
|
||||||
ApiRequestEvent,
|
ApiRequestEvent,
|
||||||
ApiResponseEvent,
|
ApiResponseEvent,
|
||||||
|
IdeConnectionEvent,
|
||||||
StartSessionEvent,
|
StartSessionEvent,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
UserPromptEvent,
|
UserPromptEvent,
|
||||||
|
@ -355,3 +357,24 @@ export function logSlashCommand(
|
||||||
};
|
};
|
||||||
logger.emit(logRecord);
|
logger.emit(logRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logIdeConnection(
|
||||||
|
config: Config,
|
||||||
|
event: IdeConnectionEvent,
|
||||||
|
): void {
|
||||||
|
ClearcutLogger.getInstance(config)?.logIdeConnectionEvent(event);
|
||||||
|
if (!isTelemetrySdkInitialized()) return;
|
||||||
|
|
||||||
|
const attributes: LogAttributes = {
|
||||||
|
...getCommonAttributes(config),
|
||||||
|
...event,
|
||||||
|
'event.name': EVENT_IDE_CONNECTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = logs.getLogger(SERVICE_NAME);
|
||||||
|
const logRecord: LogRecord = {
|
||||||
|
body: `Ide connection. Type: ${event.connection_type}.`,
|
||||||
|
attributes,
|
||||||
|
};
|
||||||
|
logger.emit(logRecord);
|
||||||
|
}
|
||||||
|
|
|
@ -12,7 +12,6 @@ import {
|
||||||
} from './sdk.js';
|
} from './sdk.js';
|
||||||
import { Config } from '../config/config.js';
|
import { Config } from '../config/config.js';
|
||||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||||
import { IdeClient } from '../ide/ide-client.js';
|
|
||||||
|
|
||||||
vi.mock('@opentelemetry/sdk-node');
|
vi.mock('@opentelemetry/sdk-node');
|
||||||
vi.mock('../config/config.js');
|
vi.mock('../config/config.js');
|
||||||
|
@ -30,7 +29,6 @@ describe('telemetry', () => {
|
||||||
targetDir: '/test/dir',
|
targetDir: '/test/dir',
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
cwd: '/test/dir',
|
cwd: '/test/dir',
|
||||||
ideClient: IdeClient.getInstance(false),
|
|
||||||
});
|
});
|
||||||
vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true);
|
vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true);
|
||||||
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
|
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
|
||||||
|
|
|
@ -308,6 +308,23 @@ export class MalformedJsonResponseEvent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum IdeConnectionType {
|
||||||
|
START = 'start',
|
||||||
|
SESSION = 'session',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IdeConnectionEvent {
|
||||||
|
'event.name': 'ide_connection';
|
||||||
|
'event.timestamp': string; // ISO 8601
|
||||||
|
connection_type: IdeConnectionType;
|
||||||
|
|
||||||
|
constructor(connection_type: IdeConnectionType) {
|
||||||
|
this['event.name'] = 'ide_connection';
|
||||||
|
this['event.timestamp'] = new Date().toISOString();
|
||||||
|
this.connection_type = connection_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type TelemetryEvent =
|
export type TelemetryEvent =
|
||||||
| StartSessionEvent
|
| StartSessionEvent
|
||||||
| EndSessionEvent
|
| EndSessionEvent
|
||||||
|
@ -320,4 +337,5 @@ export type TelemetryEvent =
|
||||||
| LoopDetectedEvent
|
| LoopDetectedEvent
|
||||||
| NextSpeakerCheckEvent
|
| NextSpeakerCheckEvent
|
||||||
| SlashCommandEvent
|
| SlashCommandEvent
|
||||||
| MalformedJsonResponseEvent;
|
| MalformedJsonResponseEvent
|
||||||
|
| IdeConnectionEvent;
|
||||||
|
|
|
@ -30,7 +30,7 @@ import {
|
||||||
Schema,
|
Schema,
|
||||||
} from '@google/genai';
|
} from '@google/genai';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { IdeClient } from '../ide/ide-client.js';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
vi.mock('node:fs');
|
vi.mock('node:fs');
|
||||||
|
@ -140,7 +140,6 @@ const baseConfigParams: ConfigParameters = {
|
||||||
geminiMdFileCount: 0,
|
geminiMdFileCount: 0,
|
||||||
approvalMode: ApprovalMode.DEFAULT,
|
approvalMode: ApprovalMode.DEFAULT,
|
||||||
sessionId: 'test-session-id',
|
sessionId: 'test-session-id',
|
||||||
ideClient: IdeClient.getInstance(false),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ToolRegistry', () => {
|
describe('ToolRegistry', () => {
|
||||||
|
|
|
@ -17,7 +17,6 @@ import {
|
||||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||||
import { retryWithBackoff } from './retry.js';
|
import { retryWithBackoff } from './retry.js';
|
||||||
import { AuthType } from '../core/contentGenerator.js';
|
import { AuthType } from '../core/contentGenerator.js';
|
||||||
import { IdeClient } from '../ide/ide-client.js';
|
|
||||||
|
|
||||||
vi.mock('node:fs');
|
vi.mock('node:fs');
|
||||||
|
|
||||||
|
@ -35,7 +34,6 @@ describe('Flash Fallback Integration', () => {
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
ideClient: IdeClient.getInstance(false),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset simulation state for each test
|
// Reset simulation state for each test
|
||||||
|
|
Loading…
Reference in New Issue