import { describe, it, expect, vi, afterEach } from 'vitest'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { registerAgentRoutes } from '../src/routes/agents.js'; import { registerAgentChatRoutes } from '../src/routes/agent-chat.js'; import { errorHandler } from '../src/middleware/error-handler.js'; import { ConflictError, NotFoundError } from '../src/services/mcp-server.service.js'; import type { AgentService, AgentView } from '../src/services/agent.service.js'; import type { ChatService } from '../src/services/chat.service.js'; const NOW = new Date(); function makeView(overrides: Partial = {}): AgentView { return { id: 'agent-1', name: 'reviewer', description: '', systemPrompt: '', llm: { id: 'llm-1', name: 'qwen3-thinking' }, project: null, proxyModelName: null, defaultParams: {}, extras: {}, ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW, ...overrides, }; } function mockAgentService(initial: AgentView[] = []): AgentService { const rows = new Map(initial.map((r) => [r.id, r])); return { list: vi.fn(async () => [...rows.values()]), listByProject: vi.fn(async (projectName: string) => [...rows.values()].filter((r) => r.project?.name === projectName)), getById: vi.fn(async (id: string) => { const r = rows.get(id); if (!r) throw new NotFoundError(`Agent not found: ${id}`); return r; }), getByName: vi.fn(async (name: string) => { for (const r of rows.values()) if (r.name === name) return r; throw new NotFoundError(`Agent not found: ${name}`); }), create: vi.fn(async (input: unknown) => { const data = input as { name: string }; for (const r of rows.values()) if (r.name === data.name) throw new ConflictError(`Agent already exists: ${data.name}`); const v = makeView({ id: `agent-${String(rows.size + 1)}`, name: data.name }); rows.set(v.id, v); return v; }), update: vi.fn(async (id: string, input: unknown) => { const existing = rows.get(id); if (!existing) throw new NotFoundError(`Agent not found: ${id}`); const next = { ...existing, ...(input as Partial) }; rows.set(id, next); return next; }), delete: vi.fn(async (id: string) => { if (!rows.has(id)) throw new NotFoundError(`Agent not found: ${id}`); rows.delete(id); }), upsertByName: vi.fn(), deleteByName: vi.fn(), } as unknown as AgentService; } function mockChatService(): ChatService { return { chat: vi.fn(async (args: { agentName: string; userMessage?: string }) => ({ threadId: 'thread-1', assistant: `echo: ${args.userMessage ?? ''}`, turnIndex: 1, })), chatStream: vi.fn(async function*() { yield { type: 'text' as const, delta: 'hi' }; yield { type: 'final' as const, threadId: 'thread-1', turnIndex: 1 }; }), createThread: vi.fn(async () => ({ id: 'thread-2' })), listThreads: vi.fn(async () => [ { id: 'thread-1', title: 't1', lastTurnAt: NOW, createdAt: NOW }, ]), listMessages: vi.fn(async () => []), } as unknown as ChatService; } let app: FastifyInstance; afterEach(async () => { if (app) await app.close(); }); async function createApp(opts: { agents?: AgentService; chat?: ChatService } = {}): Promise { app = Fastify({ logger: false }); app.setErrorHandler(errorHandler); registerAgentRoutes(app, opts.agents ?? mockAgentService()); registerAgentChatRoutes(app, opts.chat ?? mockChatService()); await app.ready(); return app; } describe('Agent CRUD routes', () => { it('GET /api/v1/agents lists agents', async () => { await createApp({ agents: mockAgentService([makeView()]) }); const res = await app.inject({ method: 'GET', url: '/api/v1/agents' }); expect(res.statusCode).toBe(200); expect(res.json()).toHaveLength(1); }); it('GET /api/v1/agents/:name resolves by name when not a CUID', async () => { await createApp({ agents: mockAgentService([makeView({ id: 'agent-1', name: 'reviewer' })]) }); const res = await app.inject({ method: 'GET', url: '/api/v1/agents/reviewer' }); expect(res.statusCode).toBe(200); expect(res.json<{ name: string }>().name).toBe('reviewer'); }); it('GET /api/v1/agents/:id returns 404 when missing', async () => { await createApp(); const res = await app.inject({ method: 'GET', url: '/api/v1/agents/missing' }); expect(res.statusCode).toBe(404); }); it('POST /api/v1/agents creates and returns 201', async () => { await createApp(); const res = await app.inject({ method: 'POST', url: '/api/v1/agents', payload: { name: 'deployer', llm: { name: 'qwen3-thinking' } }, }); expect(res.statusCode).toBe(201); expect(res.json<{ name: string }>().name).toBe('deployer'); }); it('POST /api/v1/agents returns 409 on duplicate name', async () => { await createApp({ agents: mockAgentService([makeView({ id: 'a1', name: 'dup' })]) }); const res = await app.inject({ method: 'POST', url: '/api/v1/agents', payload: { name: 'dup', llm: { name: 'qwen3-thinking' } }, }); expect(res.statusCode).toBe(409); }); it('PUT /api/v1/agents/:name updates by name', async () => { await createApp({ agents: mockAgentService([makeView({ id: 'a1', name: 'editable' })]) }); const res = await app.inject({ method: 'PUT', url: '/api/v1/agents/editable', payload: { description: 'changed' }, }); expect(res.statusCode).toBe(200); expect(res.json<{ description: string }>().description).toBe('changed'); }); it('DELETE /api/v1/agents/:name returns 204', async () => { await createApp({ agents: mockAgentService([makeView({ id: 'a1', name: 'doomed' })]) }); const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/doomed' }); expect(res.statusCode).toBe(204); }); it('GET /api/v1/projects/:name/agents lists project-scoped agents', async () => { await createApp({ agents: mockAgentService([ makeView({ id: 'a1', name: 'in', project: { id: 'p1', name: 'mcpctl-dev' } }), makeView({ id: 'a2', name: 'out' }), ]), }); const res = await app.inject({ method: 'GET', url: '/api/v1/projects/mcpctl-dev/agents' }); expect(res.statusCode).toBe(200); expect(res.json>().map((a) => a.name)).toEqual(['in']); }); }); describe('Chat + threads routes', () => { it('POST /api/v1/agents/:name/chat (non-streaming) returns assistant body', async () => { await createApp(); const res = await app.inject({ method: 'POST', url: '/api/v1/agents/reviewer/chat', payload: { message: 'hi' }, }); expect(res.statusCode).toBe(200); const body = res.json<{ threadId: string; assistant: string }>(); expect(body.assistant).toContain('echo'); expect(body.threadId).toBe('thread-1'); }); it('POST /api/v1/agents/:name/chat rejects empty body with 400', async () => { await createApp(); const res = await app.inject({ method: 'POST', url: '/api/v1/agents/reviewer/chat', payload: {}, }); expect(res.statusCode).toBe(400); }); it('POST /api/v1/agents/:name/chat (streaming) emits SSE frames', async () => { await createApp(); const res = await app.inject({ method: 'POST', url: '/api/v1/agents/reviewer/chat', payload: { message: 'hi', stream: true }, }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('text/event-stream'); const body = res.body; expect(body).toContain('data: '); expect(body).toContain('"type":"text"'); expect(body).toContain('"type":"final"'); expect(body.endsWith('data: [DONE]\n\n')).toBe(true); }); it('POST /api/v1/agents/:name/threads returns 201 with new thread id', async () => { await createApp(); const res = await app.inject({ method: 'POST', url: '/api/v1/agents/reviewer/threads', payload: { title: 'kickoff' }, }); expect(res.statusCode).toBe(201); expect(res.json<{ id: string }>().id).toBe('thread-2'); }); it('GET /api/v1/agents/:name/threads lists threads', async () => { await createApp(); const res = await app.inject({ method: 'GET', url: '/api/v1/agents/reviewer/threads' }); expect(res.statusCode).toBe(200); const body = res.json>(); expect(body).toHaveLength(1); expect(body[0]!.id).toBe('thread-1'); }); it('GET /api/v1/threads/:id/messages returns the message log', async () => { await createApp(); const res = await app.inject({ method: 'GET', url: '/api/v1/threads/thread-1/messages' }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual([]); }); }); describe('mapUrlToPermission for agents', () => { // The mapping itself is tested implicitly through main.ts; this asserts the // shape we export for the chat URL → run:agents:. it('routes /agents/:name/chat through run:agents:', async () => { // Smoke check via the route working at all — RBAC integration is exercised // in main.ts tests; this just guards against regressions in the URL shape. await createApp(); const res = await app.inject({ method: 'POST', url: '/api/v1/agents/r/chat', payload: { message: 'x' }, }); expect(res.statusCode).toBe(200); }); });