Merge pull request 'feat: persistent Gemini ACP provider + status spinner' (#40) from feat/gemini-acp-provider into main
This commit was merged in pull request #40.
This commit is contained in:
@@ -11,12 +11,21 @@ import { APP_VERSION } from '@mcpctl/shared';
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
// ANSI helpers
|
||||||
|
const GREEN = '\x1b[32m';
|
||||||
|
const RED = '\x1b[31m';
|
||||||
|
const DIM = '\x1b[2m';
|
||||||
|
const RESET = '\x1b[0m';
|
||||||
|
const CLEAR_LINE = '\x1b[2K\r';
|
||||||
|
|
||||||
export interface StatusCommandDeps {
|
export interface StatusCommandDeps {
|
||||||
configDeps: Partial<ConfigLoaderDeps>;
|
configDeps: Partial<ConfigLoaderDeps>;
|
||||||
credentialsDeps: Partial<CredentialsDeps>;
|
credentialsDeps: Partial<CredentialsDeps>;
|
||||||
log: (...args: string[]) => void;
|
log: (...args: string[]) => void;
|
||||||
|
write: (text: string) => void;
|
||||||
checkHealth: (url: string) => Promise<boolean>;
|
checkHealth: (url: string) => Promise<boolean>;
|
||||||
checkLlm: (llm: LlmConfig) => Promise<string>;
|
checkLlm: (llm: LlmConfig) => Promise<string>;
|
||||||
|
isTTY: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultCheckHealth(url: string): Promise<boolean> {
|
function defaultCheckHealth(url: string): Promise<boolean> {
|
||||||
@@ -64,16 +73,20 @@ async function defaultCheckLlm(llm: LlmConfig): Promise<string> {
|
|||||||
return 'ok (key stored)';
|
return 'ok (key stored)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
|
|
||||||
const defaultDeps: StatusCommandDeps = {
|
const defaultDeps: StatusCommandDeps = {
|
||||||
configDeps: {},
|
configDeps: {},
|
||||||
credentialsDeps: {},
|
credentialsDeps: {},
|
||||||
log: (...args) => console.log(...args),
|
log: (...args) => console.log(...args),
|
||||||
|
write: (text) => process.stdout.write(text),
|
||||||
checkHealth: defaultCheckHealth,
|
checkHealth: defaultCheckHealth,
|
||||||
checkLlm: defaultCheckLlm,
|
checkLlm: defaultCheckLlm,
|
||||||
|
isTTY: process.stdout.isTTY ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
|
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
|
||||||
const { configDeps, credentialsDeps, log, checkHealth, checkLlm } = { ...defaultDeps, ...deps };
|
const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, isTTY } = { ...defaultDeps, ...deps };
|
||||||
|
|
||||||
return new Command('status')
|
return new Command('status')
|
||||||
.description('Show mcpctl status and connectivity')
|
.description('Show mcpctl status and connectivity')
|
||||||
@@ -86,45 +99,81 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
? `${config.llm.provider}${config.llm.model ? ` / ${config.llm.model}` : ''}`
|
? `${config.llm.provider}${config.llm.model ? ` / ${config.llm.model}` : ''}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Run health checks in parallel (include LLM check if configured)
|
if (opts.output !== 'table') {
|
||||||
const healthPromises: [Promise<boolean>, Promise<boolean>, Promise<string | null>] = [
|
// JSON/YAML: run everything in parallel, wait, output at once
|
||||||
|
const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all([
|
||||||
|
checkHealth(config.mcplocalUrl),
|
||||||
|
checkHealth(config.mcpdUrl),
|
||||||
|
llmLabel ? checkLlm(config.llm!) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const llm = llmLabel
|
||||||
|
? llmStatus === 'ok' ? llmLabel : `${llmLabel} (${llmStatus})`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const status = {
|
||||||
|
version: APP_VERSION,
|
||||||
|
mcplocalUrl: config.mcplocalUrl,
|
||||||
|
mcplocalReachable,
|
||||||
|
mcpdUrl: config.mcpdUrl,
|
||||||
|
mcpdReachable,
|
||||||
|
auth: creds ? { user: creds.user } : null,
|
||||||
|
registries: config.registries,
|
||||||
|
outputFormat: config.outputFormat,
|
||||||
|
llm,
|
||||||
|
llmStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
log(opts.output === 'json' ? formatJson(status) : formatYaml(status));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table format: print lines progressively, LLM last with spinner
|
||||||
|
|
||||||
|
// Fast health checks first
|
||||||
|
const [mcplocalReachable, mcpdReachable] = await Promise.all([
|
||||||
checkHealth(config.mcplocalUrl),
|
checkHealth(config.mcplocalUrl),
|
||||||
checkHealth(config.mcpdUrl),
|
checkHealth(config.mcpdUrl),
|
||||||
config.llm && config.llm.provider !== 'none'
|
]);
|
||||||
? checkLlm(config.llm)
|
|
||||||
: Promise.resolve(null),
|
|
||||||
];
|
|
||||||
const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all(healthPromises);
|
|
||||||
|
|
||||||
const llm = llmLabel
|
log(`mcpctl v${APP_VERSION}`);
|
||||||
? llmStatus === 'ok' ? llmLabel : `${llmLabel} (${llmStatus})`
|
log(`mcplocal: ${config.mcplocalUrl} (${mcplocalReachable ? 'connected' : 'unreachable'})`);
|
||||||
: null;
|
log(`mcpd: ${config.mcpdUrl} (${mcpdReachable ? 'connected' : 'unreachable'})`);
|
||||||
|
log(`Auth: ${creds ? `logged in as ${creds.user}` : 'not logged in'}`);
|
||||||
|
log(`Registries: ${config.registries.join(', ')}`);
|
||||||
|
log(`Output: ${config.outputFormat}`);
|
||||||
|
|
||||||
const status = {
|
if (!llmLabel) {
|
||||||
version: APP_VERSION,
|
log(`LLM: not configured (run 'mcpctl config setup')`);
|
||||||
mcplocalUrl: config.mcplocalUrl,
|
return;
|
||||||
mcplocalReachable,
|
}
|
||||||
mcpdUrl: config.mcpdUrl,
|
|
||||||
mcpdReachable,
|
|
||||||
auth: creds ? { user: creds.user } : null,
|
|
||||||
registries: config.registries,
|
|
||||||
outputFormat: config.outputFormat,
|
|
||||||
llm,
|
|
||||||
llmStatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (opts.output === 'json') {
|
// LLM check with spinner
|
||||||
log(formatJson(status));
|
const llmPromise = checkLlm(config.llm!);
|
||||||
} else if (opts.output === 'yaml') {
|
|
||||||
log(formatYaml(status));
|
if (isTTY) {
|
||||||
|
let frame = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
write(`${CLEAR_LINE}LLM: ${llmLabel} ${DIM}${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} checking...${RESET}`);
|
||||||
|
frame++;
|
||||||
|
}, 80);
|
||||||
|
|
||||||
|
const llmStatus = await llmPromise;
|
||||||
|
clearInterval(interval);
|
||||||
|
|
||||||
|
if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') {
|
||||||
|
write(`${CLEAR_LINE}LLM: ${llmLabel} ${GREEN}✓ ${llmStatus}${RESET}\n`);
|
||||||
|
} else {
|
||||||
|
write(`${CLEAR_LINE}LLM: ${llmLabel} ${RED}✗ ${llmStatus}${RESET}\n`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log(`mcpctl v${status.version}`);
|
// Non-TTY: no spinner, just wait and print
|
||||||
log(`mcplocal: ${status.mcplocalUrl} (${mcplocalReachable ? 'connected' : 'unreachable'})`);
|
const llmStatus = await llmPromise;
|
||||||
log(`mcpd: ${status.mcpdUrl} (${mcpdReachable ? 'connected' : 'unreachable'})`);
|
if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') {
|
||||||
log(`Auth: ${creds ? `logged in as ${creds.user}` : 'not logged in'}`);
|
log(`LLM: ${llmLabel} ✓ ${llmStatus}`);
|
||||||
log(`Registries: ${status.registries.join(', ')}`);
|
} else {
|
||||||
log(`Output: ${status.outputFormat}`);
|
log(`LLM: ${llmLabel} ✗ ${llmStatus}`);
|
||||||
log(`LLM: ${status.llm ?? "not configured (run 'mcpctl config setup')"}`);
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,38 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { createStatusCommand } from '../../src/commands/status.js';
|
import { createStatusCommand } from '../../src/commands/status.js';
|
||||||
|
import type { StatusCommandDeps } from '../../src/commands/status.js';
|
||||||
import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
|
import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
|
||||||
import { saveCredentials } from '../../src/auth/index.js';
|
import { saveCredentials } from '../../src/auth/index.js';
|
||||||
|
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let output: string[];
|
let output: string[];
|
||||||
|
let written: string[];
|
||||||
|
|
||||||
function log(...args: string[]) {
|
function log(...args: string[]) {
|
||||||
output.push(args.join(' '));
|
output.push(args.join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function write(text: string) {
|
||||||
|
written.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDeps(overrides?: Partial<StatusCommandDeps>): Partial<StatusCommandDeps> {
|
||||||
|
return {
|
||||||
|
configDeps: { configDir: tempDir },
|
||||||
|
credentialsDeps: { configDir: tempDir },
|
||||||
|
log,
|
||||||
|
write,
|
||||||
|
checkHealth: async () => true,
|
||||||
|
isTTY: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-'));
|
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-'));
|
||||||
output = [];
|
output = [];
|
||||||
|
written = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -24,12 +43,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('status command', () => {
|
describe('status command', () => {
|
||||||
it('shows status in table format', async () => {
|
it('shows status in table format', async () => {
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps());
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
});
|
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
const out = output.join('\n');
|
const out = output.join('\n');
|
||||||
expect(out).toContain('mcpctl v');
|
expect(out).toContain('mcpctl v');
|
||||||
@@ -39,46 +53,26 @@ describe('status command', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows unreachable when daemons are down', async () => {
|
it('shows unreachable when daemons are down', async () => {
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps({ checkHealth: async () => false }));
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => false,
|
|
||||||
});
|
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
expect(output.join('\n')).toContain('unreachable');
|
expect(output.join('\n')).toContain('unreachable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows not logged in when no credentials', async () => {
|
it('shows not logged in when no credentials', async () => {
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps());
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
});
|
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
expect(output.join('\n')).toContain('not logged in');
|
expect(output.join('\n')).toContain('not logged in');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows logged in user when credentials exist', async () => {
|
it('shows logged in user when credentials exist', async () => {
|
||||||
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice@example.com' }, { configDir: tempDir });
|
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice@example.com' }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps());
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
});
|
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
expect(output.join('\n')).toContain('logged in as alice@example.com');
|
expect(output.join('\n')).toContain('logged in as alice@example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows status in JSON format', async () => {
|
it('shows status in JSON format', async () => {
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps());
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
});
|
|
||||||
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
|
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
|
||||||
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
|
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
|
||||||
expect(parsed['version']).toBe('0.1.0');
|
expect(parsed['version']).toBe('0.1.0');
|
||||||
@@ -87,12 +81,7 @@ describe('status command', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows status in YAML format', async () => {
|
it('shows status in YAML format', async () => {
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps({ checkHealth: async () => false }));
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => false,
|
|
||||||
});
|
|
||||||
await cmd.parseAsync(['-o', 'yaml'], { from: 'user' });
|
await cmd.parseAsync(['-o', 'yaml'], { from: 'user' });
|
||||||
expect(output[0]).toContain('mcplocalReachable: false');
|
expect(output[0]).toContain('mcplocalReachable: false');
|
||||||
});
|
});
|
||||||
@@ -100,15 +89,12 @@ describe('status command', () => {
|
|||||||
it('checks correct URLs from config', async () => {
|
it('checks correct URLs from config', async () => {
|
||||||
saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://local:3200', mcpdUrl: 'http://remote:3100' }, { configDir: tempDir });
|
saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://local:3200', mcpdUrl: 'http://remote:3100' }, { configDir: tempDir });
|
||||||
const checkedUrls: string[] = [];
|
const checkedUrls: string[] = [];
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps({
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async (url) => {
|
checkHealth: async (url) => {
|
||||||
checkedUrls.push(url);
|
checkedUrls.push(url);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
expect(checkedUrls).toContain('http://local:3200');
|
expect(checkedUrls).toContain('http://local:3200');
|
||||||
expect(checkedUrls).toContain('http://remote:3100');
|
expect(checkedUrls).toContain('http://remote:3100');
|
||||||
@@ -116,24 +102,14 @@ describe('status command', () => {
|
|||||||
|
|
||||||
it('shows registries from config', async () => {
|
it('shows registries from config', async () => {
|
||||||
saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir });
|
saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps());
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
});
|
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
expect(output.join('\n')).toContain('official');
|
expect(output.join('\n')).toContain('official');
|
||||||
expect(output.join('\n')).not.toContain('glama');
|
expect(output.join('\n')).not.toContain('glama');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows LLM not configured hint when no LLM is set', async () => {
|
it('shows LLM not configured hint when no LLM is set', async () => {
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps());
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
});
|
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
const out = output.join('\n');
|
const out = output.join('\n');
|
||||||
expect(out).toContain('LLM:');
|
expect(out).toContain('LLM:');
|
||||||
@@ -141,71 +117,64 @@ describe('status command', () => {
|
|||||||
expect(out).toContain('mcpctl config setup');
|
expect(out).toContain('mcpctl config setup');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows configured LLM provider and model when healthy', async () => {
|
it('shows green check when LLM is healthy (non-TTY)', async () => {
|
||||||
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'anthropic', model: 'claude-haiku-3-5-20241022' } }, { configDir: tempDir });
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'anthropic', model: 'claude-haiku-3-5-20241022' } }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'ok' }));
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
checkLlm: async () => 'ok',
|
|
||||||
});
|
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
const out = output.join('\n');
|
const out = output.join('\n');
|
||||||
expect(out).toContain('LLM:');
|
|
||||||
expect(out).toContain('anthropic / claude-haiku-3-5-20241022');
|
expect(out).toContain('anthropic / claude-haiku-3-5-20241022');
|
||||||
// Should NOT show error status when ok
|
expect(out).toContain('✓ ok');
|
||||||
expect(out).not.toContain('(ok)');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows LLM error status when check fails', async () => {
|
it('shows red cross when LLM check fails (non-TTY)', async () => {
|
||||||
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'not authenticated' }));
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
checkLlm: async () => 'not authenticated',
|
|
||||||
});
|
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
const out = output.join('\n');
|
const out = output.join('\n');
|
||||||
expect(out).toContain('gemini-cli / gemini-2.5-flash (not authenticated)');
|
expect(out).toContain('✗ not authenticated');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows binary not found status', async () => {
|
it('shows binary not found error', async () => {
|
||||||
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'binary not found' }));
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
checkLlm: async () => 'binary not found',
|
|
||||||
});
|
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
expect(output.join('\n')).toContain('(binary not found)');
|
expect(output.join('\n')).toContain('✗ binary not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses spinner on TTY and writes final result', async () => {
|
||||||
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||||
|
const cmd = createStatusCommand(baseDeps({
|
||||||
|
isTTY: true,
|
||||||
|
checkLlm: async () => 'ok',
|
||||||
|
}));
|
||||||
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
|
// On TTY, the final LLM line goes through write(), not log()
|
||||||
|
const finalWrite = written[written.length - 1];
|
||||||
|
expect(finalWrite).toContain('gemini-cli / gemini-2.5-flash');
|
||||||
|
expect(finalWrite).toContain('✓ ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses spinner on TTY and shows failure', async () => {
|
||||||
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||||
|
const cmd = createStatusCommand(baseDeps({
|
||||||
|
isTTY: true,
|
||||||
|
checkLlm: async () => 'not authenticated',
|
||||||
|
}));
|
||||||
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
|
const finalWrite = written[written.length - 1];
|
||||||
|
expect(finalWrite).toContain('✗ not authenticated');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows not configured when LLM provider is none', async () => {
|
it('shows not configured when LLM provider is none', async () => {
|
||||||
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'none' } }, { configDir: tempDir });
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'none' } }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps());
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
});
|
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
expect(output.join('\n')).toContain('not configured');
|
expect(output.join('\n')).toContain('not configured');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes llm and llmStatus in JSON output', async () => {
|
it('includes llm and llmStatus in JSON output', async () => {
|
||||||
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'ok' }));
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
checkLlm: async () => 'ok',
|
|
||||||
});
|
|
||||||
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
|
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
|
||||||
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
|
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
|
||||||
expect(parsed['llm']).toBe('gemini-cli / gemini-2.5-flash');
|
expect(parsed['llm']).toBe('gemini-cli / gemini-2.5-flash');
|
||||||
@@ -213,12 +182,7 @@ describe('status command', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('includes null llm in JSON output when not configured', async () => {
|
it('includes null llm in JSON output when not configured', async () => {
|
||||||
const cmd = createStatusCommand({
|
const cmd = createStatusCommand(baseDeps());
|
||||||
configDeps: { configDir: tempDir },
|
|
||||||
credentialsDeps: { configDir: tempDir },
|
|
||||||
log,
|
|
||||||
checkHealth: async () => true,
|
|
||||||
});
|
|
||||||
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
|
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
|
||||||
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
|
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
|
||||||
expect(parsed['llm']).toBeNull();
|
expect(parsed['llm']).toBeNull();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { SecretStore } from '@mcpctl/shared';
|
import type { SecretStore } from '@mcpctl/shared';
|
||||||
import type { LlmFileConfig } from './http/config.js';
|
import type { LlmFileConfig } from './http/config.js';
|
||||||
import { ProviderRegistry } from './providers/registry.js';
|
import { ProviderRegistry } from './providers/registry.js';
|
||||||
import { GeminiCliProvider } from './providers/gemini-cli.js';
|
import { GeminiAcpProvider } from './providers/gemini-acp.js';
|
||||||
import { OllamaProvider } from './providers/ollama.js';
|
import { OllamaProvider } from './providers/ollama.js';
|
||||||
import { AnthropicProvider } from './providers/anthropic.js';
|
import { AnthropicProvider } from './providers/anthropic.js';
|
||||||
import { OpenAiProvider } from './providers/openai.js';
|
import { OpenAiProvider } from './providers/openai.js';
|
||||||
import { DeepSeekProvider } from './providers/deepseek.js';
|
import { DeepSeekProvider } from './providers/deepseek.js';
|
||||||
import type { GeminiCliConfig } from './providers/gemini-cli.js';
|
import type { GeminiAcpConfig } from './providers/gemini-acp.js';
|
||||||
import type { OllamaConfig } from './providers/ollama.js';
|
import type { OllamaConfig } from './providers/ollama.js';
|
||||||
import type { AnthropicConfig } from './providers/anthropic.js';
|
import type { AnthropicConfig } from './providers/anthropic.js';
|
||||||
import type { OpenAiConfig } from './providers/openai.js';
|
import type { OpenAiConfig } from './providers/openai.js';
|
||||||
@@ -25,10 +25,10 @@ export async function createProviderFromConfig(
|
|||||||
|
|
||||||
switch (config.provider) {
|
switch (config.provider) {
|
||||||
case 'gemini-cli': {
|
case 'gemini-cli': {
|
||||||
const cfg: GeminiCliConfig = {};
|
const cfg: GeminiAcpConfig = {};
|
||||||
if (config.binaryPath) cfg.binaryPath = config.binaryPath;
|
if (config.binaryPath) cfg.binaryPath = config.binaryPath;
|
||||||
if (config.model) cfg.defaultModel = config.model;
|
if (config.model) cfg.defaultModel = config.model;
|
||||||
registry.register(new GeminiCliProvider(cfg));
|
registry.register(new GeminiAcpProvider(cfg));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export async function main(argv: string[] = process.argv): Promise<MainResult> {
|
|||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
|
|
||||||
|
providerRegistry.disposeAll();
|
||||||
server.stop();
|
server.stop();
|
||||||
if (httpServer) {
|
if (httpServer) {
|
||||||
await httpServer.close();
|
await httpServer.close();
|
||||||
|
|||||||
287
src/mcplocal/src/providers/acp-client.ts
Normal file
287
src/mcplocal/src/providers/acp-client.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
|
||||||
|
|
||||||
|
export interface AcpClientConfig {
|
||||||
|
binaryPath: string;
|
||||||
|
model: string;
|
||||||
|
/** Timeout for individual RPC requests in ms (default: 60000) */
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
/** Timeout for process initialization in ms (default: 30000) */
|
||||||
|
initTimeoutMs: number;
|
||||||
|
/** Override spawn for testing */
|
||||||
|
spawn?: typeof spawn;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingRequest {
|
||||||
|
resolve: (result: unknown) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level ACP (Agent Client Protocol) client.
|
||||||
|
* Manages a persistent `gemini --experimental-acp` subprocess and communicates
|
||||||
|
* via JSON-RPC 2.0 over NDJSON stdio.
|
||||||
|
*
|
||||||
|
* Pattern follows StdioUpstream: readline for parsing, pending request map with timeouts.
|
||||||
|
*/
|
||||||
|
export class AcpClient {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private readline: ReadlineInterface | null = null;
|
||||||
|
private pendingRequests = new Map<number, PendingRequest>();
|
||||||
|
private nextId = 1;
|
||||||
|
private sessionId: string | null = null;
|
||||||
|
private ready = false;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private readonly config: AcpClientConfig;
|
||||||
|
|
||||||
|
/** Accumulates text chunks from session/update agent_message_chunk during a prompt. */
|
||||||
|
private activePromptChunks: string[] = [];
|
||||||
|
|
||||||
|
constructor(config: AcpClientConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure the subprocess is spawned and initialized. Idempotent and lazy. */
|
||||||
|
async ensureReady(): Promise<void> {
|
||||||
|
if (this.ready && this.process && !this.process.killed) return;
|
||||||
|
|
||||||
|
// If already initializing, wait for it
|
||||||
|
if (this.initPromise) return this.initPromise;
|
||||||
|
|
||||||
|
this.initPromise = this.doInit();
|
||||||
|
try {
|
||||||
|
await this.initPromise;
|
||||||
|
} catch (err) {
|
||||||
|
this.initPromise = null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a prompt and collect the streamed text response. */
|
||||||
|
async prompt(text: string): Promise<string> {
|
||||||
|
await this.ensureReady();
|
||||||
|
|
||||||
|
// Set up chunk accumulator
|
||||||
|
this.activePromptChunks = [];
|
||||||
|
|
||||||
|
const result = await this.sendRequest('session/prompt', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
prompt: [{ type: 'text', text }],
|
||||||
|
}, this.config.requestTimeoutMs) as { stopReason: string };
|
||||||
|
|
||||||
|
const collected = this.activePromptChunks.join('');
|
||||||
|
this.activePromptChunks = [];
|
||||||
|
|
||||||
|
if (result.stopReason === 'refusal') {
|
||||||
|
throw new Error('Gemini refused to process the prompt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill the subprocess and clean up. */
|
||||||
|
dispose(): void {
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the subprocess is alive and initialized. */
|
||||||
|
get isAlive(): boolean {
|
||||||
|
return this.ready && this.process !== null && !this.process.killed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private ---
|
||||||
|
|
||||||
|
private async doInit(): Promise<void> {
|
||||||
|
// Clean up any previous state
|
||||||
|
this.cleanup();
|
||||||
|
|
||||||
|
this.spawnProcess();
|
||||||
|
this.setupReadline();
|
||||||
|
|
||||||
|
// ACP handshake: initialize
|
||||||
|
await this.sendRequest('initialize', {
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientCapabilities: {},
|
||||||
|
clientInfo: { name: 'mcpctl', version: '1.0.0' },
|
||||||
|
}, this.config.initTimeoutMs);
|
||||||
|
|
||||||
|
// ACP handshake: session/new
|
||||||
|
const sessionResult = await this.sendRequest('session/new', {
|
||||||
|
cwd: '/tmp',
|
||||||
|
mcpServers: [],
|
||||||
|
}, this.config.initTimeoutMs) as { sessionId: string };
|
||||||
|
|
||||||
|
this.sessionId = sessionResult.sessionId;
|
||||||
|
this.ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnProcess(): void {
|
||||||
|
const spawnFn = this.config.spawn ?? spawn;
|
||||||
|
this.process = spawnFn(this.config.binaryPath, ['--experimental-acp'], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('exit', () => {
|
||||||
|
this.ready = false;
|
||||||
|
this.initPromise = null;
|
||||||
|
this.sessionId = null;
|
||||||
|
|
||||||
|
// Reject all pending requests
|
||||||
|
for (const [id, pending] of this.pendingRequests) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error('Gemini ACP process exited'));
|
||||||
|
this.pendingRequests.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('error', (err) => {
|
||||||
|
this.ready = false;
|
||||||
|
this.initPromise = null;
|
||||||
|
|
||||||
|
for (const [id, pending] of this.pendingRequests) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.reject(err);
|
||||||
|
this.pendingRequests.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupReadline(): void {
|
||||||
|
if (!this.process?.stdout) return;
|
||||||
|
|
||||||
|
this.readline = createInterface({ input: this.process.stdout });
|
||||||
|
this.readline.on('line', (line) => this.handleLine(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLine(line: string): void {
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
// Skip non-JSON lines (e.g., debug output on stdout)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response to a pending request (has 'id')
|
||||||
|
if ('id' in msg && msg.id !== undefined && ('result' in msg || 'error' in msg)) {
|
||||||
|
this.handleResponse(msg as { id: number; result?: unknown; error?: { code: number; message: string } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification (has 'method', no 'id')
|
||||||
|
if ('method' in msg && !('id' in msg)) {
|
||||||
|
this.handleNotification(msg as { method: string; params?: Record<string, unknown> });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request from agent (has 'method' AND 'id') — agent asking us for something
|
||||||
|
if ('method' in msg && 'id' in msg) {
|
||||||
|
this.handleAgentRequest(msg as { id: number; method: string; params?: Record<string, unknown> });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleResponse(msg: { id: number; result?: unknown; error?: { code: number; message: string } }): void {
|
||||||
|
const pending = this.pendingRequests.get(msg.id);
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pendingRequests.delete(msg.id);
|
||||||
|
|
||||||
|
if (msg.error) {
|
||||||
|
pending.reject(new Error(`ACP error ${msg.error.code}: ${msg.error.message}`));
|
||||||
|
} else {
|
||||||
|
pending.resolve(msg.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleNotification(msg: { method: string; params?: Record<string, unknown> }): void {
|
||||||
|
if (msg.method !== 'session/update' || !msg.params) return;
|
||||||
|
|
||||||
|
const update = msg.params.update as Record<string, unknown> | undefined;
|
||||||
|
if (!update) return;
|
||||||
|
|
||||||
|
// Collect text from agent_message_chunk
|
||||||
|
if (update.sessionUpdate === 'agent_message_chunk') {
|
||||||
|
const content = update.content as Array<{ type: string; text?: string }> | undefined;
|
||||||
|
if (content) {
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
this.activePromptChunks.push(block.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle requests from the agent (e.g., session/request_permission). Reject them all. */
|
||||||
|
private handleAgentRequest(msg: { id: number; method: string; params?: Record<string, unknown> }): void {
|
||||||
|
if (!this.process?.stdin) return;
|
||||||
|
|
||||||
|
if (msg.method === 'session/request_permission') {
|
||||||
|
// Reject permission requests — we don't want tool use
|
||||||
|
const response = JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: msg.id,
|
||||||
|
result: { outcome: { outcome: 'cancelled' } },
|
||||||
|
});
|
||||||
|
this.process.stdin.write(response + '\n');
|
||||||
|
} else {
|
||||||
|
// Unknown method — return error
|
||||||
|
const response = JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: msg.id,
|
||||||
|
error: { code: -32601, message: 'Method not supported' },
|
||||||
|
});
|
||||||
|
this.process.stdin.write(response + '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendRequest(method: string, params: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
|
||||||
|
if (!this.process?.stdin) {
|
||||||
|
return Promise.reject(new Error('ACP process not started'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.nextId++;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingRequests.delete(id);
|
||||||
|
// Kill the process on timeout — it's hung
|
||||||
|
this.cleanup();
|
||||||
|
reject(new Error(`ACP request '${method}' timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
this.pendingRequests.set(id, { resolve, reject, timer });
|
||||||
|
|
||||||
|
const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
||||||
|
this.process!.stdin!.write(msg + '\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
this.ready = false;
|
||||||
|
this.initPromise = null;
|
||||||
|
this.sessionId = null;
|
||||||
|
this.activePromptChunks = [];
|
||||||
|
|
||||||
|
// Reject all pending requests
|
||||||
|
for (const [id, pending] of this.pendingRequests) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error('ACP client disposed'));
|
||||||
|
this.pendingRequests.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.readline) {
|
||||||
|
this.readline.close();
|
||||||
|
this.readline = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.process) {
|
||||||
|
this.process.kill('SIGTERM');
|
||||||
|
this.process = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/mcplocal/src/providers/gemini-acp.ts
Normal file
97
src/mcplocal/src/providers/gemini-acp.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import type { LlmProvider, CompletionOptions, CompletionResult } from './types.js';
|
||||||
|
import { AcpClient } from './acp-client.js';
|
||||||
|
import type { AcpClientConfig } from './acp-client.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface GeminiAcpConfig {
|
||||||
|
binaryPath?: string;
|
||||||
|
defaultModel?: string;
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
initTimeoutMs?: number;
|
||||||
|
/** Override for testing — passed through to AcpClient */
|
||||||
|
spawn?: AcpClientConfig['spawn'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini CLI provider using ACP (Agent Client Protocol) mode.
|
||||||
|
* Keeps the gemini process alive as a persistent subprocess, eliminating
|
||||||
|
* the ~10s cold-start per call. Auto-restarts on crash or timeout.
|
||||||
|
*/
|
||||||
|
export class GeminiAcpProvider implements LlmProvider {
|
||||||
|
readonly name = 'gemini-cli';
|
||||||
|
private client: AcpClient;
|
||||||
|
private binaryPath: string;
|
||||||
|
private defaultModel: string;
|
||||||
|
private queue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(config?: GeminiAcpConfig) {
|
||||||
|
this.binaryPath = config?.binaryPath ?? 'gemini';
|
||||||
|
this.defaultModel = config?.defaultModel ?? 'gemini-2.5-flash';
|
||||||
|
|
||||||
|
const acpConfig: AcpClientConfig = {
|
||||||
|
binaryPath: this.binaryPath,
|
||||||
|
model: this.defaultModel,
|
||||||
|
requestTimeoutMs: config?.requestTimeoutMs ?? 60_000,
|
||||||
|
initTimeoutMs: config?.initTimeoutMs ?? 30_000,
|
||||||
|
};
|
||||||
|
if (config?.spawn) acpConfig.spawn = config.spawn;
|
||||||
|
|
||||||
|
this.client = new AcpClient(acpConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(options: CompletionOptions): Promise<CompletionResult> {
|
||||||
|
return this.enqueue(() => this.doComplete(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listModels(): Promise<string[]> {
|
||||||
|
return ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'];
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execFileAsync(this.binaryPath, ['--version'], { timeout: 5000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.client.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private ---
|
||||||
|
|
||||||
|
private async doComplete(options: CompletionOptions): Promise<CompletionResult> {
|
||||||
|
const prompt = options.messages
|
||||||
|
.map((m) => {
|
||||||
|
if (m.role === 'system') return `System: ${m.content}`;
|
||||||
|
if (m.role === 'user') return m.content;
|
||||||
|
if (m.role === 'assistant') return `Assistant: ${m.content}`;
|
||||||
|
return m.content;
|
||||||
|
})
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
const content = await this.client.prompt(prompt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: content.trim(),
|
||||||
|
toolCalls: [],
|
||||||
|
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||||
|
finishReason: 'stop',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const result = new Promise<T>((resolve, reject) => {
|
||||||
|
this.queue = this.queue.then(
|
||||||
|
() => fn().then(resolve, reject),
|
||||||
|
() => fn().then(resolve, reject),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,4 +9,8 @@ export { GeminiCliProvider } from './gemini-cli.js';
|
|||||||
export type { GeminiCliConfig } from './gemini-cli.js';
|
export type { GeminiCliConfig } from './gemini-cli.js';
|
||||||
export { DeepSeekProvider } from './deepseek.js';
|
export { DeepSeekProvider } from './deepseek.js';
|
||||||
export type { DeepSeekConfig } from './deepseek.js';
|
export type { DeepSeekConfig } from './deepseek.js';
|
||||||
|
export { GeminiAcpProvider } from './gemini-acp.js';
|
||||||
|
export type { GeminiAcpConfig } from './gemini-acp.js';
|
||||||
|
export { AcpClient } from './acp-client.js';
|
||||||
|
export type { AcpClientConfig } from './acp-client.js';
|
||||||
export { ProviderRegistry } from './registry.js';
|
export { ProviderRegistry } from './registry.js';
|
||||||
|
|||||||
@@ -45,4 +45,11 @@ export class ProviderRegistry {
|
|||||||
getActiveName(): string | null {
|
getActiveName(): string | null {
|
||||||
return this.activeProvider;
|
return this.activeProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dispose all registered providers that have a dispose method. */
|
||||||
|
disposeAll(): void {
|
||||||
|
for (const provider of this.providers.values()) {
|
||||||
|
provider.dispose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,4 +53,6 @@ export interface LlmProvider {
|
|||||||
listModels(): Promise<string[]>;
|
listModels(): Promise<string[]>;
|
||||||
/** Check if the provider is configured and reachable */
|
/** Check if the provider is configured and reachable */
|
||||||
isAvailable(): Promise<boolean>;
|
isAvailable(): Promise<boolean>;
|
||||||
|
/** Optional cleanup for providers with persistent resources (e.g., subprocesses). */
|
||||||
|
dispose?(): void;
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,7 @@ describe('createProviderFromConfig', () => {
|
|||||||
expect(registry.getActive()).toBeNull();
|
expect(registry.getActive()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates gemini-cli provider', async () => {
|
it('creates gemini-cli provider using ACP', async () => {
|
||||||
const store = mockSecretStore();
|
const store = mockSecretStore();
|
||||||
const registry = await createProviderFromConfig(
|
const registry = await createProviderFromConfig(
|
||||||
{ provider: 'gemini-cli', model: 'gemini-2.5-flash', binaryPath: '/usr/bin/gemini' },
|
{ provider: 'gemini-cli', model: 'gemini-2.5-flash', binaryPath: '/usr/bin/gemini' },
|
||||||
@@ -33,6 +33,8 @@ describe('createProviderFromConfig', () => {
|
|||||||
);
|
);
|
||||||
expect(registry.getActive()).not.toBeNull();
|
expect(registry.getActive()).not.toBeNull();
|
||||||
expect(registry.getActive()!.name).toBe('gemini-cli');
|
expect(registry.getActive()!.name).toBe('gemini-cli');
|
||||||
|
// ACP provider has dispose method
|
||||||
|
expect(typeof registry.getActive()!.dispose).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates ollama provider', async () => {
|
it('creates ollama provider', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user