import { describe, it, expect, vi } from 'vitest'; import { AgentService } from '../src/services/agent.service.js'; import type { IAgentRepository } from '../src/repositories/agent.repository.js'; import type { LlmService } from '../src/services/llm.service.js'; import type { ProjectService } from '../src/services/project.service.js'; import type { Agent } from '@prisma/client'; function makeAgent(overrides: Partial = {}): Agent { return { id: 'agent-1', name: 'reviewer', description: '', systemPrompt: '', llmId: 'llm-1', projectId: null, proxyModelName: null, defaultParams: {} as Agent['defaultParams'], extras: {} as Agent['extras'], ownerId: 'owner-1', version: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } function mockRepo(initial: Agent[] = []): IAgentRepository { const rows = new Map(initial.map((r) => [r.id, r])); return { findAll: vi.fn(async () => [...rows.values()]), findById: vi.fn(async (id: string) => rows.get(id) ?? null), findByName: vi.fn(async (name: string) => { for (const r of rows.values()) if (r.name === name) return r; return null; }), findByProjectId: vi.fn(async (projectId: string) => [...rows.values()].filter((r) => r.projectId === projectId)), create: vi.fn(async (data) => { const row = makeAgent({ id: `agent-${String(rows.size + 1)}`, name: data.name, description: data.description ?? '', systemPrompt: data.systemPrompt ?? '', llmId: data.llmId, projectId: data.projectId ?? null, proxyModelName: data.proxyModelName ?? null, defaultParams: (data.defaultParams ?? {}) as Agent['defaultParams'], extras: (data.extras ?? {}) as Agent['extras'], ownerId: data.ownerId, }); rows.set(row.id, row); return row; }), update: vi.fn(async (id, data) => { const existing = rows.get(id); if (!existing) throw new Error('not found'); const next: Agent = { ...existing, ...(data.description !== undefined ? { description: data.description } : {}), ...(data.systemPrompt !== undefined ? { systemPrompt: data.systemPrompt } : {}), ...(data.llmId !== undefined ? { llmId: data.llmId } : {}), ...(data.projectId !== undefined ? { projectId: data.projectId } : {}), ...(data.proxyModelName !== undefined ? { proxyModelName: data.proxyModelName } : {}), ...(data.defaultParams !== undefined ? { defaultParams: data.defaultParams as Agent['defaultParams'] } : {}), ...(data.extras !== undefined ? { extras: data.extras as Agent['extras'] } : {}), version: existing.version + 1, }; rows.set(id, next); return next; }), delete: vi.fn(async (id: string) => { rows.delete(id); }), }; } function mockLlms(): LlmService { return { getById: vi.fn(async (id: string) => ({ id, name: id === 'llm-1' ? 'qwen3-thinking' : 'other', type: 'openai', model: 'm', url: '', tier: 'fast', description: '', apiKeyRef: null, extraConfig: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), })), getByName: vi.fn(async (name: string) => ({ id: name === 'qwen3-thinking' ? 'llm-1' : 'llm-other', name, type: 'openai', model: 'm', url: '', tier: 'fast', description: '', apiKeyRef: null, extraConfig: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), })), } as unknown as LlmService; } function mockProjects(): ProjectService { return { getById: vi.fn(async (id: string) => ({ id, name: id === 'proj-1' ? 'mcpctl-dev' : 'other' })), resolveAndGet: vi.fn(async (idOrName: string) => ({ id: idOrName === 'mcpctl-dev' ? 'proj-1' : 'proj-other', name: idOrName, })), } as unknown as ProjectService; } describe('AgentService', () => { it('creates an agent resolving llm + project by name', async () => { const repo = mockRepo(); const svc = new AgentService(repo, mockLlms(), mockProjects()); const view = await svc.create({ name: 'reviewer', description: 'I review security', systemPrompt: 'be terse', llm: { name: 'qwen3-thinking' }, project: { name: 'mcpctl-dev' }, defaultParams: { temperature: 0.2, max_tokens: 4096 }, }, 'owner-1'); expect(view.name).toBe('reviewer'); expect(view.llm.name).toBe('qwen3-thinking'); expect(view.project?.name).toBe('mcpctl-dev'); expect(view.defaultParams.temperature).toBe(0.2); expect(repo.create).toHaveBeenCalledOnce(); }); it('creates an agent without a project (null projectId stays null)', async () => { const repo = mockRepo(); const svc = new AgentService(repo, mockLlms(), mockProjects()); const view = await svc.create({ name: 'standalone', llm: { name: 'qwen3-thinking' }, }, 'owner-1'); expect(view.project).toBeNull(); }); it('rejects creating an agent with a duplicate name (Conflict)', async () => { const repo = mockRepo([makeAgent({ id: 'a1', name: 'dup' })]); const svc = new AgentService(repo, mockLlms(), mockProjects()); await expect(svc.create({ name: 'dup', llm: { name: 'qwen3-thinking' }, }, 'owner-1')).rejects.toThrow(/already exists/); }); it('updates llm reference by name', async () => { const repo = mockRepo([makeAgent({ id: 'a1', name: 'switcher', llmId: 'llm-1' })]); const svc = new AgentService(repo, mockLlms(), mockProjects()); const updated = await svc.update('a1', { llm: { name: 'other' } }); expect(updated.llm.id).toBe('llm-other'); }); it('detaches a project when project is set to null', async () => { const repo = mockRepo([makeAgent({ id: 'a1', name: 'attached', projectId: 'proj-1' })]); const svc = new AgentService(repo, mockLlms(), mockProjects()); const updated = await svc.update('a1', { project: null }); expect(updated.project).toBeNull(); }); it('listByProject returns only agents in the project', async () => { const repo = mockRepo([ makeAgent({ id: 'a1', name: 'in-proj', projectId: 'proj-1' }), makeAgent({ id: 'a2', name: 'no-proj', projectId: null }), makeAgent({ id: 'a3', name: 'other-proj', projectId: 'proj-other' }), ]); const svc = new AgentService(repo, mockLlms(), mockProjects()); const list = await svc.listByProject('mcpctl-dev'); expect(list.map((a) => a.name)).toEqual(['in-proj']); }); it('upsertByName creates if missing, updates if present', async () => { const repo = mockRepo(); const svc = new AgentService(repo, mockLlms(), mockProjects()); const created = await svc.upsertByName({ name: 'roundtrip', description: 'first', systemPrompt: '', llm: { name: 'qwen3-thinking' }, defaultParams: {}, extras: {}, }, 'owner-1'); expect(created.description).toBe('first'); const updated = await svc.upsertByName({ name: 'roundtrip', description: 'second', systemPrompt: '', llm: { name: 'qwen3-thinking' }, defaultParams: {}, extras: {}, }, 'owner-1'); expect(updated.description).toBe('second'); expect(updated.id).toBe(created.id); }); });