Files
mcpctl/src/mcpd/tests/agent-service.test.ts

193 lines
7.1 KiB
TypeScript
Raw Normal View History

feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
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);
});
});