193 lines
7.1 KiB
TypeScript
193 lines
7.1 KiB
TypeScript
|
|
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> = {}): 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<string, Agent>(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);
|
||
|
|
});
|
||
|
|
});
|