feat(cli): add DevTools integration with gemini-cli-devtools (#18648)

This commit is contained in:
Sandy Tao
2026-02-09 14:03:10 -08:00
committed by GitHub
parent a3e5b564f7
commit ef957a368d
13 changed files with 596 additions and 62 deletions

View File

@@ -21,29 +21,6 @@ import WebSocket from 'ws';
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
const MAX_BUFFER_SIZE = 100;
/**
* Parse a host:port string into its components.
* Uses the URL constructor for robust handling of IPv4, IPv6, and hostnames.
* Returns null for file paths or values without a valid port.
*/
function parseHostPort(value: string): { host: string; port: number } | null {
if (value.startsWith('/') || value.startsWith('.')) return null;
try {
const url = new URL(`ws://${value}`);
if (!url.port) return null;
const port = parseInt(url.port, 10);
if (url.hostname && !isNaN(port) && port > 0 && port <= 65535) {
return { host: url.hostname, port };
}
} catch {
// Not a valid host:port
}
return null;
}
export interface NetworkLog {
id: string;
timestamp: number;
@@ -494,12 +471,15 @@ function setupNetworkLogging(
host: string,
port: number,
config: Config,
onReconnectFailed?: () => void,
) {
const buffer: Array<Record<string, unknown>> = [];
let ws: WebSocket | null = null;
let reconnectTimer: NodeJS.Timeout | null = null;
let sessionId: string | null = null;
let pingInterval: NodeJS.Timeout | null = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 2;
const connect = () => {
try {
@@ -507,6 +487,7 @@ function setupNetworkLogging(
ws.on('open', () => {
debugLogger.debug(`WebSocket connected to ${host}:${port}`);
reconnectAttempts = 0;
// Register with CLI's session ID
sendMessage({
type: 'register',
@@ -620,11 +601,20 @@ function setupNetworkLogging(
const scheduleReconnect = () => {
if (reconnectTimer) return;
reconnectAttempts++;
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS && onReconnectFailed) {
debugLogger.debug(
`WebSocket reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts, promoting to server...`,
);
onReconnectFailed();
return;
}
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
debugLogger.debug('Reconnecting WebSocket...');
connect();
}, 5000);
}, 1000);
};
// Initial connection
@@ -645,41 +635,65 @@ function setupNetworkLogging(
});
}
let bridgeAttached = false;
/**
* Registers the activity logger if debug mode and interactive session are enabled.
* Captures network and console logs to a session-specific JSONL file or sends to network.
*
* Environment variable GEMINI_CLI_ACTIVITY_LOG_TARGET controls the output:
* - host:port format (e.g., "localhost:25417") → network mode (auto-enabled)
* - file path (e.g., "/tmp/logs.jsonl") → file mode (immediate)
* - not set → uses default file location in project temp logs dir
*
* @param config The CLI configuration
* Bridge coreEvents to the ActivityLogger singleton (guarded — only once).
*/
export function registerActivityLogger(config: Config) {
const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
const hostPort = target ? parseHostPort(target) : null;
// Network mode doesn't need storage; file mode does
if (!hostPort && !config.storage) {
return;
}
const capture = ActivityLogger.getInstance();
capture.enable();
if (hostPort) {
// Network mode: send logs via WebSocket
setupNetworkLogging(capture, hostPort.host, hostPort.port, config);
// Auto-enable network logging when target is explicitly configured
capture.enableNetworkLogging();
} else {
// File mode: write to JSONL file
setupFileLogging(capture, config, target);
}
// Bridge CoreEvents to local capture
function bridgeCoreEvents(capture: ActivityLogger) {
if (bridgeAttached) return;
bridgeAttached = true;
coreEvents.on(CoreEvent.ConsoleLog, (payload) => {
capture.logConsole(payload);
});
}
/**
* Initialize the activity logger with a specific transport mode.
*
* @param config CLI configuration
* @param options Transport configuration: network (WebSocket) or file (JSONL)
*/
export function initActivityLogger(
config: Config,
options:
| {
mode: 'network';
host: string;
port: number;
onReconnectFailed?: () => void;
}
| { mode: 'file'; filePath?: string },
): void {
const capture = ActivityLogger.getInstance();
capture.enable();
if (options.mode === 'network') {
setupNetworkLogging(
capture,
options.host,
options.port,
config,
options.onReconnectFailed,
);
capture.enableNetworkLogging();
} else {
setupFileLogging(capture, config, options.filePath);
}
bridgeCoreEvents(capture);
}
/**
* Add a network (WebSocket) transport to the existing ActivityLogger singleton.
* Used for promotion re-entry without re-bridging coreEvents.
*/
export function addNetworkTransport(
config: Config,
host: string,
port: number,
onReconnectFailed?: () => void,
): void {
const capture = ActivityLogger.getInstance();
setupNetworkLogging(capture, host, port, config, onReconnectFailed);
}

View File

@@ -0,0 +1,303 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { Config } from '@google/gemini-cli-core';
// --- Mocks (hoisted) ---
const mockInitActivityLogger = vi.hoisted(() => vi.fn());
const mockAddNetworkTransport = vi.hoisted(() => vi.fn());
type Listener = (...args: unknown[]) => void;
const { MockWebSocket } = vi.hoisted(() => {
class MockWebSocket {
close = vi.fn();
url: string;
static instances: MockWebSocket[] = [];
private listeners = new Map<string, Listener[]>();
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
on(event: string, fn: Listener) {
const fns = this.listeners.get(event) || [];
fns.push(fn);
this.listeners.set(event, fns);
return this;
}
emit(event: string, ...args: unknown[]) {
for (const fn of this.listeners.get(event) || []) {
fn(...args);
}
}
simulateOpen() {
this.emit('open');
}
simulateError() {
this.emit('error', new Error('ECONNREFUSED'));
}
}
return { MockWebSocket };
});
const mockDevToolsInstance = vi.hoisted(() => ({
start: vi.fn(),
stop: vi.fn(),
getPort: vi.fn(),
}));
vi.mock('./activityLogger.js', () => ({
initActivityLogger: mockInitActivityLogger,
addNetworkTransport: mockAddNetworkTransport,
}));
vi.mock('@google/gemini-cli-core', () => ({
debugLogger: {
log: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('ws', () => ({
default: MockWebSocket,
}));
vi.mock('gemini-cli-devtools', () => ({
DevTools: {
getInstance: () => mockDevToolsInstance,
},
}));
// --- Import under test (after mocks) ---
import { registerActivityLogger, resetForTesting } from './devtoolsService.js';
function createMockConfig(overrides: Record<string, unknown> = {}) {
return {
isInteractive: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session'),
getDebugMode: vi.fn().mockReturnValue(false),
storage: { getProjectTempLogsDir: vi.fn().mockReturnValue('/tmp/logs') },
...overrides,
} as unknown as Config;
}
describe('devtoolsService', () => {
beforeEach(() => {
vi.clearAllMocks();
MockWebSocket.instances = [];
resetForTesting();
delete process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
});
describe('registerActivityLogger', () => {
it('connects to existing DevTools server when probe succeeds', async () => {
const config = createMockConfig();
// The probe WebSocket will succeed
const promise = registerActivityLogger(config);
// Wait for WebSocket to be created
await vi.waitFor(() => {
expect(MockWebSocket.instances.length).toBe(1);
});
// Simulate probe success
MockWebSocket.instances[0].simulateOpen();
await promise;
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
mode: 'network',
host: '127.0.0.1',
port: 25417,
onReconnectFailed: expect.any(Function),
});
});
it('starts new DevTools server when probe fails', async () => {
const config = createMockConfig();
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
mockDevToolsInstance.getPort.mockReturnValue(25417);
const promise = registerActivityLogger(config);
// Wait for probe WebSocket
await vi.waitFor(() => {
expect(MockWebSocket.instances.length).toBe(1);
});
// Simulate probe failure
MockWebSocket.instances[0].simulateError();
await promise;
expect(mockDevToolsInstance.start).toHaveBeenCalled();
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
mode: 'network',
host: '127.0.0.1',
port: 25417,
onReconnectFailed: expect.any(Function),
});
});
it('falls back to file mode when target env var is set', async () => {
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
const config = createMockConfig();
await registerActivityLogger(config);
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
mode: 'file',
filePath: '/tmp/test.jsonl',
});
});
it('does nothing in file mode when config.storage is missing', async () => {
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
const config = createMockConfig({ storage: undefined });
await registerActivityLogger(config);
expect(mockInitActivityLogger).not.toHaveBeenCalled();
});
it('falls back to file logging when DevTools start fails', async () => {
const config = createMockConfig();
mockDevToolsInstance.start.mockRejectedValue(
new Error('MODULE_NOT_FOUND'),
);
const promise = registerActivityLogger(config);
// Wait for probe WebSocket
await vi.waitFor(() => {
expect(MockWebSocket.instances.length).toBe(1);
});
// Probe fails → tries to start server → server start fails → file fallback
MockWebSocket.instances[0].simulateError();
await promise;
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
mode: 'file',
filePath: undefined,
});
});
});
describe('startOrJoinDevTools (via registerActivityLogger)', () => {
it('stops own server and connects to existing when losing port race', async () => {
const config = createMockConfig();
// Server starts on a different port (lost the race)
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418');
mockDevToolsInstance.getPort.mockReturnValue(25418);
const promise = registerActivityLogger(config);
// First: probe for existing server (fails)
await vi.waitFor(() => {
expect(MockWebSocket.instances.length).toBe(1);
});
MockWebSocket.instances[0].simulateError();
// Second: after starting, probes the default port winner
await vi.waitFor(() => {
expect(MockWebSocket.instances.length).toBe(2);
});
// Winner is alive
MockWebSocket.instances[1].simulateOpen();
await promise;
expect(mockDevToolsInstance.stop).toHaveBeenCalled();
expect(mockInitActivityLogger).toHaveBeenCalledWith(
config,
expect.objectContaining({
mode: 'network',
host: '127.0.0.1',
port: 25417, // connected to winner's port
}),
);
});
it('keeps own server when winner is not responding', async () => {
const config = createMockConfig();
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418');
mockDevToolsInstance.getPort.mockReturnValue(25418);
const promise = registerActivityLogger(config);
// Probe for existing (fails)
await vi.waitFor(() => {
expect(MockWebSocket.instances.length).toBe(1);
});
MockWebSocket.instances[0].simulateError();
// Probe the winner (also fails)
await vi.waitFor(() => {
expect(MockWebSocket.instances.length).toBe(2);
});
MockWebSocket.instances[1].simulateError();
await promise;
expect(mockDevToolsInstance.stop).not.toHaveBeenCalled();
expect(mockInitActivityLogger).toHaveBeenCalledWith(
config,
expect.objectContaining({
mode: 'network',
port: 25418, // kept own port
}),
);
});
});
describe('handlePromotion (via onReconnectFailed)', () => {
it('caps promotion attempts at MAX_PROMOTION_ATTEMPTS', async () => {
const config = createMockConfig();
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
mockDevToolsInstance.getPort.mockReturnValue(25417);
// First: set up the logger so we can grab onReconnectFailed
const promise = registerActivityLogger(config);
await vi.waitFor(() => {
expect(MockWebSocket.instances.length).toBe(1);
});
MockWebSocket.instances[0].simulateError();
await promise;
// Extract onReconnectFailed callback
const initCall = mockInitActivityLogger.mock.calls[0];
const onReconnectFailed = initCall[1].onReconnectFailed;
expect(onReconnectFailed).toBeDefined();
// Trigger promotion MAX_PROMOTION_ATTEMPTS + 1 times
// Each call should succeed (addNetworkTransport called) until cap is hit
mockAddNetworkTransport.mockClear();
await onReconnectFailed(); // attempt 1
await onReconnectFailed(); // attempt 2
await onReconnectFailed(); // attempt 3
await onReconnectFailed(); // attempt 4 — should be capped
// Only 3 calls to addNetworkTransport (capped at MAX_PROMOTION_ATTEMPTS)
expect(mockAddNetworkTransport).toHaveBeenCalledTimes(3);
});
});
});

View File

@@ -0,0 +1,179 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import WebSocket from 'ws';
import { initActivityLogger, addNetworkTransport } from './activityLogger.js';
interface IDevTools {
start(): Promise<string>;
stop(): Promise<void>;
getPort(): number;
}
const DEVTOOLS_PKG = 'gemini-cli-devtools';
const DEFAULT_DEVTOOLS_PORT = 25417;
const DEFAULT_DEVTOOLS_HOST = '127.0.0.1';
const MAX_PROMOTION_ATTEMPTS = 3;
let promotionAttempts = 0;
/**
* Probe whether a DevTools server is already listening on the given host:port.
* Returns true if a WebSocket handshake succeeds within a short timeout.
*/
function probeDevTools(host: string, port: number): Promise<boolean> {
return new Promise((resolve) => {
const ws = new WebSocket(`ws://${host}:${port}/ws`);
const timer = setTimeout(() => {
ws.close();
resolve(false);
}, 500);
ws.on('open', () => {
clearTimeout(timer);
ws.close();
resolve(true);
});
ws.on('error', () => {
clearTimeout(timer);
ws.close();
resolve(false);
});
});
}
/**
* Start a DevTools server, then check if we won the default port.
* If another instance grabbed it first (race), stop ours and connect as client.
* Returns { host, port } of the DevTools to connect to.
*/
async function startOrJoinDevTools(
defaultHost: string,
defaultPort: number,
): Promise<{ host: string; port: number }> {
const mod = await import(DEVTOOLS_PKG);
const devtools: IDevTools = mod.DevTools.getInstance();
const url = await devtools.start();
const actualPort = devtools.getPort();
if (actualPort === defaultPort) {
// We won the port — we are the server
debugLogger.log(`DevTools available at: ${url}`);
return { host: defaultHost, port: actualPort };
}
// Lost the race — someone else has the default port.
// Verify the winner is actually alive, then stop ours and connect to theirs.
const winnerAlive = await probeDevTools(defaultHost, defaultPort);
if (winnerAlive) {
await devtools.stop();
debugLogger.log(
`DevTools (existing) at: http://${defaultHost}:${defaultPort}`,
);
return { host: defaultHost, port: defaultPort };
}
// Winner isn't responding (maybe also racing and failed) — keep ours
debugLogger.log(`DevTools available at: ${url}`);
return { host: defaultHost, port: actualPort };
}
/**
* Handle promotion: when reconnect fails, start or join a DevTools server
* and add a new network transport for the logger.
*/
async function handlePromotion(config: Config) {
promotionAttempts++;
if (promotionAttempts > MAX_PROMOTION_ATTEMPTS) {
debugLogger.debug(
`Giving up on DevTools promotion after ${MAX_PROMOTION_ATTEMPTS} attempts`,
);
return;
}
try {
const result = await startOrJoinDevTools(
DEFAULT_DEVTOOLS_HOST,
DEFAULT_DEVTOOLS_PORT,
);
addNetworkTransport(config, result.host, result.port, () =>
handlePromotion(config),
);
} catch (err) {
debugLogger.debug('Failed to promote to DevTools server:', err);
}
}
/**
* Registers the activity logger.
* Captures network and console logs via DevTools WebSocket or to a file.
*
* Environment variable GEMINI_CLI_ACTIVITY_LOG_TARGET controls the output:
* - file path (e.g., "/tmp/logs.jsonl") → file mode
* - not set → auto-start DevTools (reuses existing instance if already running)
*
* @param config The CLI configuration
*/
export async function registerActivityLogger(config: Config) {
const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
if (!target) {
// No explicit target: try connecting to existing DevTools, then start new one
const onReconnectFailed = () => handlePromotion(config);
// Probe for an existing DevTools server
const existing = await probeDevTools(
DEFAULT_DEVTOOLS_HOST,
DEFAULT_DEVTOOLS_PORT,
);
if (existing) {
debugLogger.log(
`DevTools (existing) at: http://${DEFAULT_DEVTOOLS_HOST}:${DEFAULT_DEVTOOLS_PORT}`,
);
initActivityLogger(config, {
mode: 'network',
host: DEFAULT_DEVTOOLS_HOST,
port: DEFAULT_DEVTOOLS_PORT,
onReconnectFailed,
});
return;
}
// No existing server — start (or join if we lose the race)
try {
const result = await startOrJoinDevTools(
DEFAULT_DEVTOOLS_HOST,
DEFAULT_DEVTOOLS_PORT,
);
initActivityLogger(config, {
mode: 'network',
host: result.host,
port: result.port,
onReconnectFailed,
});
return;
} catch (err) {
debugLogger.debug(
'Failed to start DevTools, falling back to file logging:',
err,
);
}
}
// File mode fallback
if (!config.storage) {
return;
}
initActivityLogger(config, { mode: 'file', filePath: target });
}
/** Reset module-level state — test only. */
export function resetForTesting() {
promotionAttempts = 0;
}