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>
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);
|
|
});
|
|
});
|