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:
134
src/mcplocal/tests/gemini-acp.test.ts
Normal file
134
src/mcplocal/tests/gemini-acp.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockEnsureReady = vi.fn(async () => {});
|
||||
const mockPrompt = vi.fn(async () => 'mock response');
|
||||
const mockDispose = vi.fn();
|
||||
|
||||
vi.mock('../src/providers/acp-client.js', () => ({
|
||||
AcpClient: vi.fn(function (this: Record<string, unknown>) {
|
||||
this.ensureReady = mockEnsureReady;
|
||||
this.prompt = mockPrompt;
|
||||
this.dispose = mockDispose;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Must import after mock setup
|
||||
const { GeminiAcpProvider } = await import('../src/providers/gemini-acp.js');
|
||||
|
||||
describe('GeminiAcpProvider', () => {
|
||||
let provider: InstanceType<typeof GeminiAcpProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPrompt.mockResolvedValue('mock response');
|
||||
provider = new GeminiAcpProvider({ binaryPath: '/usr/bin/gemini', defaultModel: 'gemini-2.5-flash' });
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
it('builds prompt from messages and returns CompletionResult', async () => {
|
||||
mockPrompt.mockResolvedValueOnce('The answer is 42.');
|
||||
|
||||
const result = await provider.complete({
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are helpful.' },
|
||||
{ role: 'user', content: 'What is the answer?' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.content).toBe('The answer is 42.');
|
||||
expect(result.toolCalls).toEqual([]);
|
||||
expect(result.finishReason).toBe('stop');
|
||||
|
||||
const promptText = mockPrompt.mock.calls[0][0] as string;
|
||||
expect(promptText).toContain('System: You are helpful.');
|
||||
expect(promptText).toContain('What is the answer?');
|
||||
});
|
||||
|
||||
it('formats assistant messages with prefix', async () => {
|
||||
mockPrompt.mockResolvedValueOnce('ok');
|
||||
|
||||
await provider.complete({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
{ role: 'user', content: 'How are you?' },
|
||||
],
|
||||
});
|
||||
|
||||
const promptText = mockPrompt.mock.calls[0][0] as string;
|
||||
expect(promptText).toContain('Assistant: Hi there');
|
||||
});
|
||||
|
||||
it('trims response content', async () => {
|
||||
mockPrompt.mockResolvedValueOnce(' padded response \n');
|
||||
|
||||
const result = await provider.complete({
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
});
|
||||
|
||||
expect(result.content).toBe('padded response');
|
||||
});
|
||||
|
||||
it('serializes concurrent calls', async () => {
|
||||
const callOrder: number[] = [];
|
||||
let callCount = 0;
|
||||
|
||||
mockPrompt.mockImplementation(async () => {
|
||||
const myCall = ++callCount;
|
||||
callOrder.push(myCall);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return `response-${myCall}`;
|
||||
});
|
||||
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
provider.complete({ messages: [{ role: 'user', content: 'a' }] }),
|
||||
provider.complete({ messages: [{ role: 'user', content: 'b' }] }),
|
||||
provider.complete({ messages: [{ role: 'user', content: 'c' }] }),
|
||||
]);
|
||||
|
||||
expect(r1.content).toBe('response-1');
|
||||
expect(r2.content).toBe('response-2');
|
||||
expect(r3.content).toBe('response-3');
|
||||
expect(callOrder).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('continues queue after error', async () => {
|
||||
mockPrompt
|
||||
.mockRejectedValueOnce(new Error('first fails'))
|
||||
.mockResolvedValueOnce('second works');
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
provider.complete({ messages: [{ role: 'user', content: 'a' }] }),
|
||||
provider.complete({ messages: [{ role: 'user', content: 'b' }] }),
|
||||
]);
|
||||
|
||||
expect(results[0].status).toBe('rejected');
|
||||
expect(results[1].status).toBe('fulfilled');
|
||||
if (results[1].status === 'fulfilled') {
|
||||
expect(results[1].value.content).toBe('second works');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listModels', () => {
|
||||
it('returns static model list', async () => {
|
||||
const models = await provider.listModels();
|
||||
expect(models).toContain('gemini-2.5-flash');
|
||||
expect(models).toContain('gemini-2.5-pro');
|
||||
expect(models).toContain('gemini-2.0-flash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('delegates to AcpClient', () => {
|
||||
provider.dispose();
|
||||
expect(mockDispose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('name', () => {
|
||||
it('is gemini-cli for config compatibility', () => {
|
||||
expect(provider.name).toBe('gemini-cli');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user