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 { return { binaryPath: '/usr/bin/gemini', model: 'gemini-2.5-flash', requestTimeoutMs: 5000, initTimeoutMs: 5000, ...overrides, }; } describe('AcpClient', () => { let client: AcpClient; let mock: ReturnType; beforeEach(() => { mock = createMockProcess(); }); afterEach(() => { client?.dispose(); }); function createClient(configOverrides?: Partial) { 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('handles single-object content (real Gemini ACP format)', 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(() => { // Real Gemini ACP sends content as a single object, not an array mock.sendLine({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 'sess-1', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' }, }, }, }); mock.sendResponse(msg.id, { stopReason: 'end_turn' }); }); } }); const result = await client.prompt('test'); expect(result).toBe('ok'); }); it('ignores agent_thought_chunk notifications', 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(() => { // Gemini sends thought chunks before message chunks mock.sendLine({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 'sess-1', update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking about it...' }, }, }, }); mock.sendLine({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 'sess-1', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' }, }, }, }); mock.sendResponse(msg.id, { stopReason: 'end_turn' }); }); } }); const result = await client.prompt('test'); expect(result).toBe('ok'); }); 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) => 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); }); }); });