/** * Smoke tests: v3 virtual agents — register a virtual Llm + a virtual * Agent through the same `_provider-register` payload, then verify mcpd * surfaces the agent as kind=virtual / status=active. Mirrors * virtual-llm.smoke.test.ts's in-process registrar pattern so we don't * need to mutate ~/.mcpctl/config.json or bounce systemd's mcplocal. * * Heartbeat-stale → inactive (90 s) and 4 h auto-deletion are covered by * the unit suite (mcpd virtual-agent-service.test.ts); waiting > 90 s in * smoke would balloon the suite duration. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import http from 'node:http'; import https from 'node:https'; import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { VirtualLlmRegistrar, type RegistrarPublishedProvider, type RegistrarPublishedAgent, } from '../../src/providers/registrar.js'; import type { LlmProvider, CompletionResult } from '../../src/providers/types.js'; const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; const SUFFIX = Date.now().toString(36); const PROVIDER_NAME = `smoke-vagent-llm-${SUFFIX}`; const AGENT_NAME = `smoke-vagent-${SUFFIX}`; function makeFakeProvider(name: string, content: string): LlmProvider { return { name, async complete(): Promise { return { content, toolCalls: [], usage: { promptTokens: 1, completionTokens: 4, totalTokens: 5 }, finishReason: 'stop', }; }, async listModels() { return []; }, async isAvailable() { return true; }, }; } function healthz(url: string, timeoutMs = 5000): Promise { return new Promise((resolve) => { const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`); const driver = parsed.protocol === 'https:' ? https : http; const req = driver.get({ hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname, timeout: timeoutMs, }, (res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); } function readToken(): string | null { try { const path = join(process.env.HOME ?? '', '.mcpctl', 'credentials'); if (!existsSync(path)) return null; const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { token?: string }; return parsed.token ?? null; } catch { return null; } } interface HttpResponse { status: number; body: string } function httpRequest(method: string, urlStr: string, body: unknown): Promise { return new Promise((resolve, reject) => { const tokenRaw = readToken(); const parsed = new URL(urlStr); const driver = parsed.protocol === 'https:' ? https : http; const headers: Record = { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(tokenRaw !== null ? { Authorization: `Bearer ${tokenRaw}` } : {}), }; const req = driver.request({ hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname + parsed.search, method, headers, timeout: 30_000, }, (res) => { const chunks: Buffer[] = []; res.on('data', (c: Buffer) => chunks.push(c)); res.on('end', () => { resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }); }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error(`httpRequest timeout: ${method} ${urlStr}`)); }); if (body !== undefined) req.write(JSON.stringify(body)); req.end(); }); } interface AgentRow { id: string; name: string; kind?: string; status?: string; llm?: { name: string }; description?: string } let mcpdUp = false; let registrar: VirtualLlmRegistrar | null = null; let tempDir: string; describe('virtual-agent smoke (v3)', () => { beforeAll(async () => { mcpdUp = await healthz(MCPD_URL); if (!mcpdUp) { // eslint-disable-next-line no-console console.warn(`\n ○ virtual-agent smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`); return; } if (readToken() === null) { mcpdUp = false; // eslint-disable-next-line no-console console.warn('\n ○ virtual-agent smoke: skipped — no ~/.mcpctl/credentials.\n'); return; } tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-virtual-agent-smoke-')); }, 20_000); afterAll(async () => { if (registrar !== null) registrar.stop(); if (tempDir !== undefined) rmSync(tempDir, { recursive: true, force: true }); // Defensive cleanup: agent first (Llm.id has Restrict FK), then Llm. if (mcpdUp) { const agents = await httpRequest('GET', `${MCPD_URL}/api/v1/agents`, undefined); if (agents.status === 200) { const rows = JSON.parse(agents.body) as Array<{ id: string; name: string }>; const row = rows.find((r) => r.name === AGENT_NAME); if (row !== undefined) { await httpRequest('DELETE', `${MCPD_URL}/api/v1/agents/${row.id}`, undefined); } } const llms = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`, undefined); if (llms.status === 200) { const rows = JSON.parse(llms.body) as Array<{ id: string; name: string }>; const row = rows.find((r) => r.name === PROVIDER_NAME); if (row !== undefined) { await httpRequest('DELETE', `${MCPD_URL}/api/v1/llms/${row.id}`, undefined); } } } }); it('registrar publishes provider + agent in one round-trip and mcpd lists the agent kind=virtual / status=active', async () => { if (!mcpdUp) return; const token = readToken(); if (token === null) return; const published: RegistrarPublishedProvider[] = [ { provider: makeFakeProvider(PROVIDER_NAME, 'hi from virtual agent'), type: 'openai', model: 'fake-vagent', tier: 'fast' }, ]; const publishedAgents: RegistrarPublishedAgent[] = [ { name: AGENT_NAME, llmName: PROVIDER_NAME, description: 'v3 virtual agent smoke', systemPrompt: 'You are a smoke test. Reply READY.', defaultParams: { temperature: 0 }, }, ]; registrar = new VirtualLlmRegistrar({ mcpdUrl: MCPD_URL, token, publishedProviders: published, publishedAgents, sessionFilePath: join(tempDir, 'session'), log: { info: () => {}, warn: () => {}, error: () => {} }, heartbeatIntervalMs: 60_000, }); await registrar.start(); expect(registrar.getSessionId()).not.toBeNull(); // Give the SSE handshake + atomic register a moment to settle. await new Promise((r) => setTimeout(r, 400)); const res = await httpRequest('GET', `${MCPD_URL}/api/v1/agents`, undefined); expect(res.status).toBe(200); const rows = JSON.parse(res.body) as AgentRow[]; const row = rows.find((r) => r.name === AGENT_NAME); expect(row, `${AGENT_NAME} must be present`).toBeDefined(); expect(row!.kind).toBe('virtual'); expect(row!.status).toBe('active'); expect(row!.llm?.name).toBe(PROVIDER_NAME); expect(row!.description).toBe('v3 virtual agent smoke'); }, 30_000); it('publisher disconnect flips the agent to status=inactive (paired with its Llm)', async () => { if (!mcpdUp) return; if (registrar !== null) { registrar.stop(); registrar = null; } // unbindSession runs synchronously on the SSE close handler; mcpd // flips both the Llm and any agents owned by the session to // inactive. A short wait covers the request round-trip. await new Promise((r) => setTimeout(r, 400)); const agents = await httpRequest('GET', `${MCPD_URL}/api/v1/agents`, undefined); expect(agents.status).toBe(200); const agentRow = (JSON.parse(agents.body) as AgentRow[]).find((r) => r.name === AGENT_NAME); expect(agentRow, `${AGENT_NAME} must still exist (deletion is GC-driven, not disconnect-driven)`).toBeDefined(); expect(agentRow!.status).toBe('inactive'); const llms = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`, undefined); const llmRow = (JSON.parse(llms.body) as Array<{ name: string; status: string }>).find((r) => r.name === PROVIDER_NAME); expect(llmRow!.status).toBe('inactive'); }, 30_000); });