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

216 lines
8.9 KiB
TypeScript
Raw Normal View History

feat(mcpd): make AgentProject real — an agent joins many projects The AgentProject junction has existed since the data-model commit and nothing read it. `grep -c agentProject src/mcpd/src/services/chat.service.ts` returned 0; chat resolved an agent's project from the legacy single `Agent.projectId` scalar. So the schema claimed "an agent is a person who joins zero, one, or many projects" (docs/agentic-teams.md §4) while the running system still gave every agent exactly one project. A schema that lies about behaviour is worse than a missing feature: it reads as done, so nobody builds it. This makes the junction load-bearing. Membership is now a real surface (task 152): GET /api/v1/agents/:name/projects memberships POST /api/v1/agents/:name/projects/:project join, body {role?, llm?} DELETE /api/v1/agents/:name/projects/:project leave GET /api/v1/projects/:name/memberships the roster, with roles `role` is TEXT (developer|qa|security|sre in v1) and is validated in the service layer, not the database — production provisions with `prisma db push`, so a CHECK constraint written into a migration would never run there. Adding a fifth role stays a one-line change. RBAC: joining/leaving is `edit:agents:<name>`, not the `create:agents` the default method→action mapping would have produced. Minting a whole new agent and adding an existing one to a team are not the same authority; this mirrors project↔server attach/detach already being `edit:projects`. Reading a roster is `view:` on the side you asked from. Chat honours it. `prepareContext` resolves the project from the membership when the caller names one, and the resolution runs *before* the Llm lookup so a per-membership `llmId` can replace the agent's pinned brain — and therefore its whole failover pool — rather than patching the dispatch list afterwards. That override is why the brain-ownership question (§15 Q1) can still go either way without a migration: today the answer is per membership. Naming a project the agent has not joined is an error that names the fix, not a silent fallback. Answering from a *different* project's tools and prompts than the one asked for is the failure mode worth being loud about. The legacy scalar keeps working this release, as the schema comment promises: `--project` accepts a membership *or* a legacy pin naming that same project, bare `mcpctl chat <agent>` is unchanged, and `GET /projects/:p/agents` returns the union of members and legacy-pinned agents so nothing disappears from a project mid-migration (mcplocal's agents plugin reads that endpoint). CLI: `mcpctl chat <agent> --project <p>` (previously rejected as "not both"), plus `attach-agent` / `detach-agent` alongside the existing attach-server pair, `get agents --project`, and `get memberships` with ROLE and BRAIN columns. Completions come from the generator, not hand-edits. Threads stay scoped to the agent, not to (agent, project) — §4 wants the latter, but that needs a schema change and a story for what happens to those threads when a membership is revoked. Deliberately not decided here. Tests: 51 new (43 mcpd, 8 CLI). Full suite 2514 pass. Lint unchanged at 794 errors / 77 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:14:45 +01:00
import type { AgentProject } from '@prisma/client';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { IAgentProjectRepository } from '../../src/repositories/agent-project.repository.js';
import { AgentProjectService } from '../../src/services/agent-project.service.js';
import type { AgentService } from '../../src/services/agent.service.js';
import type { LlmService } from '../../src/services/llm.service.js';
import { NotFoundError } from '../../src/services/mcp-server.service.js';
import type { ProjectService } from '../../src/services/project.service.js';
const NOW = new Date('2026-01-01T00:00:00Z');
/**
* In-memory AgentProject junction. The unique (agentId, projectId) constraint
* is modelled here because the service's idempotent re-join depends on it —
* without it the "join twice" test would pass for the wrong reason.
*/
function makeHarness(seed: Array<Partial<AgentProject>> = []) {
let idCounter = 1;
let rows: AgentProject[] = seed.map((r) => ({
id: r.id ?? `ap-${String(idCounter++)}`,
agentId: r.agentId ?? 'agent-alice',
projectId: r.projectId ?? 'proj-sre',
role: r.role ?? 'developer',
llmId: r.llmId ?? null,
createdAt: NOW,
}) as AgentProject);
const repo = {
findByAgent: vi.fn(async (agentId: string) => rows.filter((r) => r.agentId === agentId)),
findByProject: vi.fn(async (projectId: string) => rows.filter((r) => r.projectId === projectId)),
find: vi.fn(async (agentId: string, projectId: string) =>
rows.find((r) => r.agentId === agentId && r.projectId === projectId) ?? null,
),
create: vi.fn(async (data: { agentId: string; projectId: string; role?: string; llmId?: string | null }) => {
const row = {
id: `ap-${String(idCounter++)}`,
agentId: data.agentId,
projectId: data.projectId,
role: data.role ?? 'developer',
llmId: data.llmId ?? null,
createdAt: NOW,
} as AgentProject;
rows = [...rows, row];
return row;
}),
update: vi.fn(async (id: string, data: { role?: string; llmId?: string | null }) => {
const row = rows.find((r) => r.id === id);
if (row === undefined) throw new Error(`no such membership: ${id}`);
if (data.role !== undefined) row.role = data.role;
if (data.llmId !== undefined) row.llmId = data.llmId;
return row;
}),
delete: vi.fn(async (id: string) => { rows = rows.filter((r) => r.id !== id); }),
} as unknown as IAgentProjectRepository & Record<string, ReturnType<typeof vi.fn>>;
const agents = {
getByName: vi.fn(async (name: string) => {
if (name === 'ghost') throw new NotFoundError(`Agent not found: ${name}`);
return { id: `agent-${name}`, name };
}),
getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^agent-/, '') })),
} as unknown as AgentService;
const projects = {
resolveAndGet: vi.fn(async (name: string) => {
if (name === 'nowhere') throw new NotFoundError(`Project not found: ${name}`);
return { id: `proj-${name}`, name };
}),
getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^proj-/, '') })),
} as unknown as ProjectService;
const llms = {
getByName: vi.fn(async (name: string) => {
if (name === 'missing-brain') throw new NotFoundError(`Llm not found: ${name}`);
return { id: `llm-${name}`, name };
}),
getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^llm-/, '') })),
} as unknown as LlmService;
return {
repo, agents, projects, llms,
service: new AgentProjectService(repo, agents, projects, llms),
all: () => rows,
};
}
describe('AgentProjectService — joining and leaving', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('joins a project with the default role', async () => {
const { service } = makeHarness();
const m = await service.join('alice', 'sre', {});
expect(m.project.name).toBe('sre');
expect(m.agent.name).toBe('alice');
expect(m.role).toBe('developer');
expect(m.llm).toBeNull();
});
it('joins the SAME agent to several projects — the whole point of the junction', async () => {
const { service } = makeHarness();
await service.join('alice', 'sre', {});
await service.join('alice', 'payments', { role: 'security' });
const memberships = await service.listForAgent('alice');
expect(memberships.map((m) => m.project.name).sort()).toEqual(['payments', 'sre']);
expect(memberships.find((m) => m.project.name === 'payments')?.role).toBe('security');
});
it('re-joining updates the role instead of 409-ing — repeat applies converge', async () => {
const { service, repo, all } = makeHarness();
await service.join('alice', 'sre', { role: 'developer' });
const second = await service.join('alice', 'sre', { role: 'qa' });
expect(second.role).toBe('qa');
expect(all()).toHaveLength(1);
expect(repo.create).toHaveBeenCalledTimes(1);
expect(repo.update).toHaveBeenCalledTimes(1);
});
it('rejects a role outside the v1 set', async () => {
const { service } = makeHarness();
await expect(service.join('alice', 'sre', { role: 'chief-vibes-officer' })).rejects.toThrow();
});
it('accepts every v1 role', async () => {
const { service } = makeHarness();
for (const role of AgentProjectService.roles()) {
const m = await service.join('alice', 'sre', { role });
expect(m.role).toBe(role);
}
});
it('rejects unknown body fields rather than silently dropping them', async () => {
const { service } = makeHarness();
// A typo'd `llmName` would otherwise leave the membership with no brain
// override and no complaint — the exact failure the strict schema exists for.
await expect(service.join('alice', 'sre', { llmName: 'x' })).rejects.toThrow();
});
it('404s on an unknown agent and on an unknown project', async () => {
const { service } = makeHarness();
await expect(service.join('ghost', 'sre', {})).rejects.toThrow(/Agent not found/);
await expect(service.join('alice', 'nowhere', {})).rejects.toThrow(/Project not found/);
});
it('leaves a project', async () => {
const { service, all } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre' }]);
await service.leave('alice', 'sre');
expect(all()).toHaveLength(0);
});
it('404s when leaving a project the agent never joined — a typo must not no-op', async () => {
const { service } = makeHarness();
await expect(service.leave('alice', 'sre')).rejects.toThrow(/is not a member of project 'sre'/);
});
});
describe('AgentProjectService — the per-membership brain override', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('records the named Llm', async () => {
const { service } = makeHarness();
const m = await service.join('alice', 'sre', { llm: 'qwen3-thinking' });
expect(m.llm).toEqual({ id: 'llm-qwen3-thinking', name: 'qwen3-thinking' });
});
it('clears the override with an explicit null', async () => {
const { service } = makeHarness();
await service.join('alice', 'sre', { llm: 'qwen3-thinking' });
const cleared = await service.join('alice', 'sre', { llm: null });
expect(cleared.llm).toBeNull();
});
it('leaves an existing override untouched when `llm` is absent', async () => {
const { service } = makeHarness();
await service.join('alice', 'sre', { llm: 'qwen3-thinking' });
const roleOnly = await service.join('alice', 'sre', { role: 'qa' });
expect(roleOnly.role).toBe('qa');
expect(roleOnly.llm?.name).toBe('qwen3-thinking');
});
it('404s on an Llm that does not exist', async () => {
const { service } = makeHarness();
await expect(service.join('alice', 'sre', { llm: 'missing-brain' })).rejects.toThrow(/Llm not found/);
});
it('survives a brain that vanished under the membership', async () => {
// onDelete: SetNull should prevent this, but a torn read must not 500 the
// whole roster.
const { service, llms } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre', llmId: 'llm-gone' }]);
(llms.getById as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new NotFoundError('Llm not found: llm-gone'));
const [m] = await service.listForAgent('alice');
expect(m?.llm).toBeNull();
});
});
describe('AgentProjectService — the reverse view', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('lists the whole roster for a project, with roles', async () => {
const { service } = makeHarness([
{ agentId: 'agent-alice', projectId: 'proj-sre', role: 'sre' },
{ agentId: 'agent-bob', projectId: 'proj-sre', role: 'qa' },
{ agentId: 'agent-carol', projectId: 'proj-payments', role: 'security' },
]);
const roster = await service.listForProject('sre');
expect(roster.map((m) => `${m.agent.name}:${m.role}`)).toEqual(['alice:sre', 'bob:qa']);
});
it('404s on an unknown project', async () => {
const { service } = makeHarness();
await expect(service.listForProject('nowhere')).rejects.toThrow(/Project not found/);
});
});