diff --git a/src/cli/src/commands/status.ts b/src/cli/src/commands/status.ts index f30fe72..8709319 100644 --- a/src/cli/src/commands/status.ts +++ b/src/cli/src/commands/status.ts @@ -11,12 +11,21 @@ import { APP_VERSION } from '@mcpctl/shared'; 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 { configDeps: Partial; credentialsDeps: Partial; log: (...args: string[]) => void; + write: (text: string) => void; checkHealth: (url: string) => Promise; checkLlm: (llm: LlmConfig) => Promise; + isTTY: boolean; } function defaultCheckHealth(url: string): Promise { @@ -64,16 +73,20 @@ async function defaultCheckLlm(llm: LlmConfig): Promise { return 'ok (key stored)'; } +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + const defaultDeps: StatusCommandDeps = { configDeps: {}, credentialsDeps: {}, log: (...args) => console.log(...args), + write: (text) => process.stdout.write(text), checkHealth: defaultCheckHealth, checkLlm: defaultCheckLlm, + isTTY: process.stdout.isTTY ?? false, }; export function createStatusCommand(deps?: Partial): Command { - const { configDeps, credentialsDeps, log, checkHealth, checkLlm } = { ...defaultDeps, ...deps }; + const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, isTTY } = { ...defaultDeps, ...deps }; return new Command('status') .description('Show mcpctl status and connectivity') @@ -86,45 +99,81 @@ export function createStatusCommand(deps?: Partial): Command ? `${config.llm.provider}${config.llm.model ? ` / ${config.llm.model}` : ''}` : null; - // Run health checks in parallel (include LLM check if configured) - const healthPromises: [Promise, Promise, Promise] = [ + if (opts.output !== 'table') { + // 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.mcpdUrl), - config.llm && config.llm.provider !== 'none' - ? checkLlm(config.llm) - : Promise.resolve(null), - ]; - const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all(healthPromises); + ]); - const llm = llmLabel - ? llmStatus === 'ok' ? llmLabel : `${llmLabel} (${llmStatus})` - : null; + log(`mcpctl v${APP_VERSION}`); + log(`mcplocal: ${config.mcplocalUrl} (${mcplocalReachable ? 'connected' : 'unreachable'})`); + 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 = { - 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, - }; + if (!llmLabel) { + log(`LLM: not configured (run 'mcpctl config setup')`); + return; + } - if (opts.output === 'json') { - log(formatJson(status)); - } else if (opts.output === 'yaml') { - log(formatYaml(status)); + // LLM check with spinner + const llmPromise = checkLlm(config.llm!); + + 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 { - log(`mcpctl v${status.version}`); - log(`mcplocal: ${status.mcplocalUrl} (${mcplocalReachable ? 'connected' : 'unreachable'})`); - log(`mcpd: ${status.mcpdUrl} (${mcpdReachable ? 'connected' : 'unreachable'})`); - log(`Auth: ${creds ? `logged in as ${creds.user}` : 'not logged in'}`); - log(`Registries: ${status.registries.join(', ')}`); - log(`Output: ${status.outputFormat}`); - log(`LLM: ${status.llm ?? "not configured (run 'mcpctl config setup')"}`); + // Non-TTY: no spinner, just wait and print + const llmStatus = await llmPromise; + if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') { + log(`LLM: ${llmLabel} ✓ ${llmStatus}`); + } else { + log(`LLM: ${llmLabel} ✗ ${llmStatus}`); + } } }); } diff --git a/src/cli/tests/commands/status.test.ts b/src/cli/tests/commands/status.test.ts index 1aaa50b..402b466 100644 --- a/src/cli/tests/commands/status.test.ts +++ b/src/cli/tests/commands/status.test.ts @@ -3,19 +3,38 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; 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 { saveCredentials } from '../../src/auth/index.js'; let tempDir: string; let output: string[]; +let written: string[]; function log(...args: string[]) { output.push(args.join(' ')); } +function write(text: string) { + written.push(text); +} + +function baseDeps(overrides?: Partial): Partial { + return { + configDeps: { configDir: tempDir }, + credentialsDeps: { configDir: tempDir }, + log, + write, + checkHealth: async () => true, + isTTY: false, + ...overrides, + }; +} + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-')); output = []; + written = []; }); afterEach(() => { @@ -24,12 +43,7 @@ afterEach(() => { describe('status command', () => { it('shows status in table format', async () => { - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - }); + const cmd = createStatusCommand(baseDeps()); await cmd.parseAsync([], { from: 'user' }); const out = output.join('\n'); expect(out).toContain('mcpctl v'); @@ -39,46 +53,26 @@ describe('status command', () => { }); it('shows unreachable when daemons are down', async () => { - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => false, - }); + const cmd = createStatusCommand(baseDeps({ checkHealth: async () => false })); await cmd.parseAsync([], { from: 'user' }); expect(output.join('\n')).toContain('unreachable'); }); it('shows not logged in when no credentials', async () => { - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - }); + const cmd = createStatusCommand(baseDeps()); await cmd.parseAsync([], { from: 'user' }); expect(output.join('\n')).toContain('not logged in'); }); it('shows logged in user when credentials exist', async () => { saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice@example.com' }, { configDir: tempDir }); - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - }); + const cmd = createStatusCommand(baseDeps()); await cmd.parseAsync([], { from: 'user' }); expect(output.join('\n')).toContain('logged in as alice@example.com'); }); it('shows status in JSON format', async () => { - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - }); + const cmd = createStatusCommand(baseDeps()); await cmd.parseAsync(['-o', 'json'], { from: 'user' }); const parsed = JSON.parse(output[0]) as Record; expect(parsed['version']).toBe('0.1.0'); @@ -87,12 +81,7 @@ describe('status command', () => { }); it('shows status in YAML format', async () => { - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => false, - }); + const cmd = createStatusCommand(baseDeps({ checkHealth: async () => false })); await cmd.parseAsync(['-o', 'yaml'], { from: 'user' }); expect(output[0]).toContain('mcplocalReachable: false'); }); @@ -100,15 +89,12 @@ describe('status command', () => { it('checks correct URLs from config', async () => { saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://local:3200', mcpdUrl: 'http://remote:3100' }, { configDir: tempDir }); const checkedUrls: string[] = []; - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, + const cmd = createStatusCommand(baseDeps({ checkHealth: async (url) => { checkedUrls.push(url); return false; }, - }); + })); await cmd.parseAsync([], { from: 'user' }); expect(checkedUrls).toContain('http://local:3200'); expect(checkedUrls).toContain('http://remote:3100'); @@ -116,24 +102,14 @@ describe('status command', () => { it('shows registries from config', async () => { saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir }); - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - }); + const cmd = createStatusCommand(baseDeps()); await cmd.parseAsync([], { from: 'user' }); expect(output.join('\n')).toContain('official'); expect(output.join('\n')).not.toContain('glama'); }); it('shows LLM not configured hint when no LLM is set', async () => { - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - }); + const cmd = createStatusCommand(baseDeps()); await cmd.parseAsync([], { from: 'user' }); const out = output.join('\n'); expect(out).toContain('LLM:'); @@ -141,71 +117,64 @@ describe('status command', () => { 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 }); - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - checkLlm: async () => 'ok', - }); + const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'ok' })); await cmd.parseAsync([], { from: 'user' }); const out = output.join('\n'); - expect(out).toContain('LLM:'); expect(out).toContain('anthropic / claude-haiku-3-5-20241022'); - // Should NOT show error status when ok - expect(out).not.toContain('(ok)'); + expect(out).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 }); - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - checkLlm: async () => 'not authenticated', - }); + const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'not authenticated' })); await cmd.parseAsync([], { from: 'user' }); 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 }); - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - checkLlm: async () => 'binary not found', - }); + const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'binary not found' })); 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 () => { saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'none' } }, { configDir: tempDir }); - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - }); + const cmd = createStatusCommand(baseDeps()); await cmd.parseAsync([], { from: 'user' }); expect(output.join('\n')).toContain('not configured'); }); it('includes llm and llmStatus in JSON output', async () => { saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir }); - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - checkLlm: async () => 'ok', - }); + const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'ok' })); await cmd.parseAsync(['-o', 'json'], { from: 'user' }); const parsed = JSON.parse(output[0]) as Record; 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 () => { - const cmd = createStatusCommand({ - configDeps: { configDir: tempDir }, - credentialsDeps: { configDir: tempDir }, - log, - checkHealth: async () => true, - }); + const cmd = createStatusCommand(baseDeps()); await cmd.parseAsync(['-o', 'json'], { from: 'user' }); const parsed = JSON.parse(output[0]) as Record; expect(parsed['llm']).toBeNull(); diff --git a/src/mcplocal/src/llm-config.ts b/src/mcplocal/src/llm-config.ts index 369df66..786fc19 100644 --- a/src/mcplocal/src/llm-config.ts +++ b/src/mcplocal/src/llm-config.ts @@ -1,12 +1,12 @@ import type { SecretStore } from '@mcpctl/shared'; import type { LlmFileConfig } from './http/config.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 { AnthropicProvider } from './providers/anthropic.js'; import { OpenAiProvider } from './providers/openai.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 { AnthropicConfig } from './providers/anthropic.js'; import type { OpenAiConfig } from './providers/openai.js'; @@ -25,10 +25,10 @@ export async function createProviderFromConfig( switch (config.provider) { case 'gemini-cli': { - const cfg: GeminiCliConfig = {}; + const cfg: GeminiAcpConfig = {}; if (config.binaryPath) cfg.binaryPath = config.binaryPath; if (config.model) cfg.defaultModel = config.model; - registry.register(new GeminiCliProvider(cfg)); + registry.register(new GeminiAcpProvider(cfg)); break; } diff --git a/src/mcplocal/src/main.ts b/src/mcplocal/src/main.ts index 5473e74..4af65d1 100644 --- a/src/mcplocal/src/main.ts +++ b/src/mcplocal/src/main.ts @@ -139,6 +139,7 @@ export async function main(argv: string[] = process.argv): Promise { if (shuttingDown) return; shuttingDown = true; + providerRegistry.disposeAll(); server.stop(); if (httpServer) { await httpServer.close(); diff --git a/src/mcplocal/src/providers/acp-client.ts b/src/mcplocal/src/providers/acp-client.ts new file mode 100644 index 0000000..037288e --- /dev/null +++ b/src/mcplocal/src/providers/acp-client.ts @@ -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; +} + +/** + * 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(); + private nextId = 1; + private sessionId: string | null = null; + private ready = false; + private initPromise: Promise | 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 { + 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 { + 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 { + // 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; + try { + msg = JSON.parse(line) as Record; + } 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 }); + 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 }); + 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 }): void { + if (msg.method !== 'session/update' || !msg.params) return; + + const update = msg.params.update as Record | 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 }): 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, timeoutMs: number): Promise { + 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; + } + } +} diff --git a/src/mcplocal/src/providers/gemini-acp.ts b/src/mcplocal/src/providers/gemini-acp.ts new file mode 100644 index 0000000..e770321 --- /dev/null +++ b/src/mcplocal/src/providers/gemini-acp.ts @@ -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 = 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 { + return this.enqueue(() => this.doComplete(options)); + } + + async listModels(): Promise { + return ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash']; + } + + async isAvailable(): Promise { + 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 { + 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(fn: () => Promise): Promise { + const result = new Promise((resolve, reject) => { + this.queue = this.queue.then( + () => fn().then(resolve, reject), + () => fn().then(resolve, reject), + ); + }); + return result; + } +} diff --git a/src/mcplocal/src/providers/index.ts b/src/mcplocal/src/providers/index.ts index 52caed0..1aefe09 100644 --- a/src/mcplocal/src/providers/index.ts +++ b/src/mcplocal/src/providers/index.ts @@ -9,4 +9,8 @@ export { GeminiCliProvider } from './gemini-cli.js'; export type { GeminiCliConfig } from './gemini-cli.js'; export { DeepSeekProvider } 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'; diff --git a/src/mcplocal/src/providers/registry.ts b/src/mcplocal/src/providers/registry.ts index f164307..f74d051 100644 --- a/src/mcplocal/src/providers/registry.ts +++ b/src/mcplocal/src/providers/registry.ts @@ -45,4 +45,11 @@ export class ProviderRegistry { getActiveName(): string | null { return this.activeProvider; } + + /** Dispose all registered providers that have a dispose method. */ + disposeAll(): void { + for (const provider of this.providers.values()) { + provider.dispose?.(); + } + } } diff --git a/src/mcplocal/src/providers/types.ts b/src/mcplocal/src/providers/types.ts index 8aab79a..e6b3415 100644 --- a/src/mcplocal/src/providers/types.ts +++ b/src/mcplocal/src/providers/types.ts @@ -53,4 +53,6 @@ export interface LlmProvider { listModels(): Promise; /** Check if the provider is configured and reachable */ isAvailable(): Promise; + /** Optional cleanup for providers with persistent resources (e.g., subprocesses). */ + dispose?(): void; } diff --git a/src/mcplocal/tests/acp-client.test.ts b/src/mcplocal/tests/acp-client.test.ts new file mode 100644 index 0000000..da7a0d7 --- /dev/null +++ b/src/mcplocal/tests/acp-client.test.ts @@ -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 { + 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('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); + }); + }); +}); diff --git a/src/mcplocal/tests/gemini-acp.test.ts b/src/mcplocal/tests/gemini-acp.test.ts new file mode 100644 index 0000000..081aa7b --- /dev/null +++ b/src/mcplocal/tests/gemini-acp.test.ts @@ -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) { + 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; + + 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'); + }); + }); +}); diff --git a/src/mcplocal/tests/llm-config.test.ts b/src/mcplocal/tests/llm-config.test.ts index e1015ad..cf888a5 100644 --- a/src/mcplocal/tests/llm-config.test.ts +++ b/src/mcplocal/tests/llm-config.test.ts @@ -25,7 +25,7 @@ describe('createProviderFromConfig', () => { expect(registry.getActive()).toBeNull(); }); - it('creates gemini-cli provider', async () => { + it('creates gemini-cli provider using ACP', async () => { const store = mockSecretStore(); const registry = await createProviderFromConfig( { 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()!.name).toBe('gemini-cli'); + // ACP provider has dispose method + expect(typeof registry.getActive()!.dispose).toBe('function'); }); it('creates ollama provider', async () => {