feat: persistent Gemini ACP provider + status spinner
Replace per-call gemini CLI spawning (~10s cold start each time) with persistent ACP (Agent Client Protocol) subprocess. First call absorbs the cold start, subsequent calls are near-instant over JSON-RPC stdio. - Add AcpClient: manages persistent gemini --experimental-acp subprocess with lazy init, auto-restart on crash/timeout, NDJSON framing - Add GeminiAcpProvider: LlmProvider wrapper with serial queue for concurrent calls, same interface as GeminiCliProvider - Add dispose() to LlmProvider interface + disposeAll() to registry - Wire provider disposal into mcplocal shutdown handler - Add status command spinner with progressive output and color-coded LLM health check results (green checkmark/red cross) - 25 new tests (17 ACP client + 8 provider) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
415
src/mcplocal/tests/acp-client.test.ts
Normal file
415
src/mcplocal/tests/acp-client.test.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter, Readable } from 'node:stream';
|
||||
import { AcpClient } from '../src/providers/acp-client.js';
|
||||
import type { AcpClientConfig } from '../src/providers/acp-client.js';
|
||||
|
||||
/**
|
||||
* Creates a mock child process that speaks ACP protocol.
|
||||
* Returns the mock process and helpers to send responses.
|
||||
*/
|
||||
function createMockProcess() {
|
||||
const stdin = {
|
||||
write: vi.fn(),
|
||||
writable: true,
|
||||
};
|
||||
|
||||
const stdoutEmitter = new EventEmitter();
|
||||
const stdout = Object.assign(stdoutEmitter, {
|
||||
readable: true,
|
||||
// readline needs these
|
||||
[Symbol.asyncIterator]: undefined,
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
isPaused: () => false,
|
||||
setEncoding: vi.fn(),
|
||||
read: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
pipe: vi.fn(),
|
||||
unpipe: vi.fn(),
|
||||
unshift: vi.fn(),
|
||||
wrap: vi.fn(),
|
||||
}) as unknown as Readable;
|
||||
|
||||
const proc = Object.assign(new EventEmitter(), {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr: new EventEmitter(),
|
||||
pid: 12345,
|
||||
killed: false,
|
||||
kill: vi.fn(function (this: { killed: boolean }) {
|
||||
this.killed = true;
|
||||
}),
|
||||
});
|
||||
|
||||
/** Send a line of JSON from the "agent" to our client */
|
||||
function sendLine(data: unknown) {
|
||||
stdoutEmitter.emit('data', Buffer.from(JSON.stringify(data) + '\n'));
|
||||
}
|
||||
|
||||
/** Send a JSON-RPC response */
|
||||
function sendResponse(id: number, result: unknown) {
|
||||
sendLine({ jsonrpc: '2.0', id, result });
|
||||
}
|
||||
|
||||
/** Send a JSON-RPC error */
|
||||
function sendError(id: number, code: number, message: string) {
|
||||
sendLine({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
|
||||
/** Send a session/update notification with agent_message_chunk */
|
||||
function sendChunk(sessionId: string, text: string) {
|
||||
sendLine({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: [{ type: 'text', text }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a session/request_permission request */
|
||||
function sendPermissionRequest(id: number, sessionId: string) {
|
||||
sendLine({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'session/request_permission',
|
||||
params: { sessionId },
|
||||
});
|
||||
}
|
||||
|
||||
return { proc, stdin, stdout: stdoutEmitter, sendLine, sendResponse, sendError, sendChunk, sendPermissionRequest };
|
||||
}
|
||||
|
||||
function createConfig(overrides?: Partial<AcpClientConfig>): AcpClientConfig {
|
||||
return {
|
||||
binaryPath: '/usr/bin/gemini',
|
||||
model: 'gemini-2.5-flash',
|
||||
requestTimeoutMs: 5000,
|
||||
initTimeoutMs: 5000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AcpClient', () => {
|
||||
let client: AcpClient;
|
||||
let mock: ReturnType<typeof createMockProcess>;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = createMockProcess();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client?.dispose();
|
||||
});
|
||||
|
||||
function createClient(configOverrides?: Partial<AcpClientConfig>) {
|
||||
const config = createConfig({
|
||||
spawn: (() => mock.proc) as unknown as AcpClientConfig['spawn'],
|
||||
...configOverrides,
|
||||
});
|
||||
client = new AcpClient(config);
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Helper: auto-respond to the initialize + session/new handshake */
|
||||
function autoHandshake(sessionId = 'test-session') {
|
||||
mock.stdin.write.mockImplementation((data: string) => {
|
||||
const msg = JSON.parse(data.trim()) as { id: number; method: string };
|
||||
if (msg.method === 'initialize') {
|
||||
// Respond async to simulate real behavior
|
||||
setImmediate(() => mock.sendResponse(msg.id, {
|
||||
protocolVersion: 1,
|
||||
agentInfo: { name: 'gemini-cli', version: '1.0.0' },
|
||||
}));
|
||||
} else if (msg.method === 'session/new') {
|
||||
setImmediate(() => mock.sendResponse(msg.id, { sessionId }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('ensureReady', () => {
|
||||
it('spawns process and completes ACP handshake', async () => {
|
||||
createClient();
|
||||
autoHandshake();
|
||||
|
||||
await client.ensureReady();
|
||||
|
||||
expect(client.isAlive).toBe(true);
|
||||
// Verify initialize was sent
|
||||
const calls = mock.stdin.write.mock.calls.map((c) => JSON.parse(c[0] as string));
|
||||
expect(calls[0].method).toBe('initialize');
|
||||
expect(calls[0].params.protocolVersion).toBe(1);
|
||||
expect(calls[0].params.clientInfo.name).toBe('mcpctl');
|
||||
// Verify session/new was sent
|
||||
expect(calls[1].method).toBe('session/new');
|
||||
expect(calls[1].params.cwd).toBe('/tmp');
|
||||
expect(calls[1].params.mcpServers).toEqual([]);
|
||||
});
|
||||
|
||||
it('is idempotent when already ready', async () => {
|
||||
createClient();
|
||||
autoHandshake();
|
||||
|
||||
await client.ensureReady();
|
||||
await client.ensureReady();
|
||||
|
||||
// Should only have sent initialize + session/new once
|
||||
const calls = mock.stdin.write.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('shares init promise for concurrent calls', async () => {
|
||||
createClient();
|
||||
autoHandshake();
|
||||
|
||||
const p1 = client.ensureReady();
|
||||
const p2 = client.ensureReady();
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
expect(mock.stdin.write.mock.calls.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt', () => {
|
||||
it('sends session/prompt and collects agent_message_chunk text', async () => {
|
||||
createClient();
|
||||
const sessionId = 'sess-123';
|
||||
autoHandshake(sessionId);
|
||||
|
||||
await client.ensureReady();
|
||||
|
||||
// Now set up the prompt response handler
|
||||
mock.stdin.write.mockImplementation((data: string) => {
|
||||
const msg = JSON.parse(data.trim()) as { id: number; method: string };
|
||||
if (msg.method === 'session/prompt') {
|
||||
setImmediate(() => {
|
||||
mock.sendChunk(sessionId, 'Hello ');
|
||||
mock.sendChunk(sessionId, 'world!');
|
||||
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const result = await client.prompt('Say hello');
|
||||
expect(result).toBe('Hello world!');
|
||||
});
|
||||
|
||||
it('handles multi-block content in a single chunk', async () => {
|
||||
createClient();
|
||||
autoHandshake('sess-1');
|
||||
await client.ensureReady();
|
||||
|
||||
mock.stdin.write.mockImplementation((data: string) => {
|
||||
const msg = JSON.parse(data.trim()) as { id: number; method: string };
|
||||
if (msg.method === 'session/prompt') {
|
||||
setImmediate(() => {
|
||||
mock.sendLine({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId: 'sess-1',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: [
|
||||
{ type: 'text', text: 'Part A' },
|
||||
{ type: 'text', text: ' Part B' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const result = await client.prompt('test');
|
||||
expect(result).toBe('Part A Part B');
|
||||
});
|
||||
|
||||
it('calls ensureReady automatically (lazy init)', async () => {
|
||||
createClient();
|
||||
autoHandshake('sess-auto');
|
||||
|
||||
// After handshake, handle prompts
|
||||
const originalWrite = mock.stdin.write;
|
||||
let handshakeDone = false;
|
||||
mock.stdin.write.mockImplementation((data: string) => {
|
||||
const msg = JSON.parse(data.trim()) as { id: number; method: string };
|
||||
if (msg.method === 'initialize') {
|
||||
setImmediate(() => mock.sendResponse(msg.id, { protocolVersion: 1 }));
|
||||
} else if (msg.method === 'session/new') {
|
||||
setImmediate(() => {
|
||||
mock.sendResponse(msg.id, { sessionId: 'sess-auto' });
|
||||
handshakeDone = true;
|
||||
});
|
||||
} else if (msg.method === 'session/prompt') {
|
||||
setImmediate(() => {
|
||||
mock.sendChunk('sess-auto', 'ok');
|
||||
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Call prompt directly without ensureReady
|
||||
const result = await client.prompt('test');
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-restart', () => {
|
||||
it('restarts after process exit', async () => {
|
||||
createClient();
|
||||
autoHandshake('sess-1');
|
||||
await client.ensureReady();
|
||||
expect(client.isAlive).toBe(true);
|
||||
|
||||
// Simulate process exit
|
||||
mock.proc.killed = true;
|
||||
mock.proc.emit('exit', 1);
|
||||
expect(client.isAlive).toBe(false);
|
||||
|
||||
// Create a new mock for the respawned process
|
||||
mock = createMockProcess();
|
||||
// Update the spawn function to return new mock
|
||||
(client as unknown as { config: { spawn: unknown } }).config.spawn = () => mock.proc;
|
||||
autoHandshake('sess-2');
|
||||
|
||||
await client.ensureReady();
|
||||
expect(client.isAlive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout', () => {
|
||||
it('kills process and rejects on request timeout', async () => {
|
||||
createClient({ requestTimeoutMs: 50 });
|
||||
autoHandshake('sess-1');
|
||||
await client.ensureReady();
|
||||
|
||||
// Don't respond to the prompt — let it timeout
|
||||
mock.stdin.write.mockImplementation(() => {});
|
||||
|
||||
await expect(client.prompt('test')).rejects.toThrow('timed out');
|
||||
expect(client.isAlive).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects on init timeout', async () => {
|
||||
createClient({ initTimeoutMs: 50 });
|
||||
// Don't respond to initialize
|
||||
mock.stdin.write.mockImplementation(() => {});
|
||||
|
||||
await expect(client.ensureReady()).rejects.toThrow('timed out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('rejects on ACP error response', async () => {
|
||||
createClient();
|
||||
mock.stdin.write.mockImplementation((data: string) => {
|
||||
const msg = JSON.parse(data.trim()) as { id: number; method: string };
|
||||
setImmediate(() => mock.sendError(msg.id, -32603, 'Internal error'));
|
||||
});
|
||||
|
||||
await expect(client.ensureReady()).rejects.toThrow('ACP error -32603: Internal error');
|
||||
});
|
||||
|
||||
it('rejects pending requests on process crash', async () => {
|
||||
createClient();
|
||||
autoHandshake('sess-1');
|
||||
await client.ensureReady();
|
||||
|
||||
// Override write so prompt sends but gets no response; then crash the process
|
||||
mock.stdin.write.mockImplementation(() => {
|
||||
// After the prompt is sent, simulate a process crash
|
||||
setImmediate(() => {
|
||||
mock.proc.killed = true;
|
||||
mock.proc.emit('exit', 1);
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = client.prompt('test');
|
||||
await expect(promptPromise).rejects.toThrow('process exited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission requests', () => {
|
||||
it('rejects session/request_permission from agent', async () => {
|
||||
createClient();
|
||||
autoHandshake('sess-1');
|
||||
await client.ensureReady();
|
||||
|
||||
mock.stdin.write.mockImplementation((data: string) => {
|
||||
const msg = JSON.parse(data.trim()) as { id: number; method: string };
|
||||
if (msg.method === 'session/prompt') {
|
||||
setImmediate(() => {
|
||||
// Agent asks for permission first
|
||||
mock.sendPermissionRequest(100, 'sess-1');
|
||||
// Then provides the actual response
|
||||
mock.sendChunk('sess-1', 'done');
|
||||
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const result = await client.prompt('test');
|
||||
expect(result).toBe('done');
|
||||
|
||||
// Verify we sent a rejection for the permission request
|
||||
const writes = mock.stdin.write.mock.calls.map((c) => {
|
||||
try { return JSON.parse(c[0] as string); } catch { return null; }
|
||||
}).filter(Boolean);
|
||||
const rejection = writes.find((w: Record<string, unknown>) => w.id === 100);
|
||||
expect(rejection).toBeTruthy();
|
||||
expect((rejection as { result: { outcome: { outcome: string } } }).result.outcome.outcome).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('kills process and rejects pending', async () => {
|
||||
createClient();
|
||||
autoHandshake('sess-1');
|
||||
await client.ensureReady();
|
||||
|
||||
// Override write so prompt is sent but gets no response; then dispose
|
||||
mock.stdin.write.mockImplementation(() => {
|
||||
setImmediate(() => client.dispose());
|
||||
});
|
||||
|
||||
const promptPromise = client.prompt('test');
|
||||
await expect(promptPromise).rejects.toThrow('disposed');
|
||||
expect(mock.proc.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
});
|
||||
|
||||
it('is safe to call multiple times', () => {
|
||||
createClient();
|
||||
client.dispose();
|
||||
client.dispose();
|
||||
// No error thrown
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAlive', () => {
|
||||
it('returns false before init', () => {
|
||||
createClient();
|
||||
expect(client.isAlive).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true after successful init', async () => {
|
||||
createClient();
|
||||
autoHandshake();
|
||||
await client.ensureReady();
|
||||
expect(client.isAlive).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false after dispose', async () => {
|
||||
createClient();
|
||||
autoHandshake();
|
||||
await client.ensureReady();
|
||||
client.dispose();
|
||||
expect(client.isAlive).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user