feat(mcpd): personality + prompt-by-agent repos and services (Stage 2)

Wires the schema landed in Stage 1 into the service layer. No HTTP
routes yet — Stage 3 will register `/api/v1/...` endpoints and update
chat.service to read agent-direct + personality prompts when building
the system block.

Repositories:
- PersonalityRepository: CRUD + listPrompts/attach/detach bindings.
- PromptRepository: findByAgent + findByNameAndAgent; create/update
  accept the new agentId column. findGlobal now also filters
  agentId=null so agent-direct prompts don't leak into global lists.
- AgentRepository: defaultPersonalityId on create + connect/disconnect
  in update.

Services:
- PersonalityService: CRUD scoped per agent, plus attach/detach with
  scope enforcement — a prompt may bind only if it's agent-direct on
  the same agent, in the agent's project, or global. Foreign-project
  / foreign-agent attachments are rejected with 400.
- PromptService: createPrompt / upsertByName accept agentId and
  resolve `agent: <name>`, with XOR-with-project guard. Adds
  listPromptsForAgent.
- AgentService: defaultPersonality (by name on the agent's own
  personality set) round-trips through update + AgentView.

Validation:
- prompt.schema.ts: refine() rejects projectId+agentId together.
- personality.schema.ts: new Create/Update/AttachPrompt schemas.
- agent.schema.ts: defaultPersonality { name } | null on update.

Tests: 12 PersonalityService + 7 PromptService agent-scope tests
covering happy paths, XOR/scope enforcement, double-attach guard,
detach-not-bound. mcpd suite: 796/796 (was 777). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-26 19:20:51 +01:00
parent f60f00f1fd
commit 6b5bd78cfa
12 changed files with 1095 additions and 21 deletions

View File

@@ -0,0 +1,286 @@
import { describe, it, expect, vi } from 'vitest';
import { PromptService } from '../../src/services/prompt.service.js';
import type { IPromptRepository } from '../../src/repositories/prompt.repository.js';
import type { IPromptRequestRepository } from '../../src/repositories/prompt-request.repository.js';
import type { IProjectRepository } from '../../src/repositories/project.repository.js';
import type { IAgentRepository } from '../../src/repositories/agent.repository.js';
import type { Prompt, Agent } from '@prisma/client';
/**
* Coverage for the new "Prompt.agentId" path: creating a prompt scoped to an
* agent, listing prompts by agent, XOR-with-project enforcement at the schema
* level, and the upsert path that resolves agent name → id.
*/
function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
return {
id: 'prompt-1',
name: 'p',
content: 'c',
projectId: null,
agentId: null,
priority: 5,
summary: null,
chapters: null,
linkTarget: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: 'agent-1',
name: 'reviewer',
description: '',
systemPrompt: '',
llmId: 'llm-1',
projectId: null,
defaultPersonalityId: null,
proxyModelName: null,
defaultParams: {} as Agent['defaultParams'],
extras: {} as Agent['extras'],
ownerId: 'owner-1',
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockPromptRepo(): IPromptRepository {
const rows = new Map<string, Prompt>();
return {
findAll: vi.fn(async () => [...rows.values()]),
findGlobal: vi.fn(async () =>
[...rows.values()].filter((p) => p.projectId === null && p.agentId === null)),
findByAgent: vi.fn(async (agentId: string) =>
[...rows.values()].filter((p) => p.agentId === agentId)),
findById: vi.fn(async (id: string) => rows.get(id) ?? null),
findByNameAndProject: vi.fn(async (name: string, projectId: string | null) => {
for (const p of rows.values()) {
if (p.name === name && (p.projectId ?? null) === projectId) return p;
}
return null;
}),
findByNameAndAgent: vi.fn(async (name: string, agentId: string | null) => {
for (const p of rows.values()) {
if (p.name === name && (p.agentId ?? null) === agentId) return p;
}
return null;
}),
create: vi.fn(async (data) => {
const row = makePrompt({
id: `prompt-${rows.size + 1}`,
name: data.name,
content: data.content,
projectId: data.projectId ?? null,
agentId: data.agentId ?? null,
priority: data.priority ?? 5,
linkTarget: data.linkTarget ?? null,
});
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 = { ...existing, ...data } as Prompt;
rows.set(id, next);
return next;
}),
delete: vi.fn(async (id: string) => { rows.delete(id); }),
};
}
function mockPromptRequestRepo(): IPromptRequestRepository {
return {
findAll: vi.fn(async () => []),
findGlobal: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByNameAndProject: vi.fn(async () => null),
findBySession: vi.fn(async () => []),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
}
function mockProjectRepo(): IProjectRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
}
function mockAgentRepo(initial: Agent[]): IAgentRepository {
const rows = new Map<string, Agent>(initial.map((a) => [a.id, a]));
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 a of rows.values()) if (a.name === name) return a;
return null;
}),
findByProjectId: vi.fn(async () => []),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
}
describe('PromptService — agent-direct scope', () => {
it('creates a prompt directly attached to an agent', async () => {
const promptRepo = mockPromptRepo();
const agentRepo = mockAgentRepo([makeAgent()]);
const service = new PromptService(
promptRepo,
mockPromptRequestRepo(),
mockProjectRepo(),
undefined,
agentRepo,
);
const prompt = await service.createPrompt({
name: 'agent-only',
content: 'hi',
agentId: 'agent-1',
});
expect(prompt.agentId).toBe('agent-1');
expect(prompt.projectId).toBeNull();
});
it('rejects create when both projectId and agentId are set', async () => {
const service = new PromptService(
mockPromptRepo(),
mockPromptRequestRepo(),
mockProjectRepo(),
undefined,
mockAgentRepo([makeAgent()]),
);
await expect(
service.createPrompt({
name: 'bad',
content: 'c',
projectId: 'proj-1',
agentId: 'agent-1',
}),
).rejects.toThrow();
});
it('rejects create when the agent does not exist', async () => {
const service = new PromptService(
mockPromptRepo(),
mockPromptRequestRepo(),
mockProjectRepo(),
undefined,
mockAgentRepo([]),
);
await expect(
service.createPrompt({ name: 'orphan', content: 'c', agentId: 'agent-ghost' }),
).rejects.toThrow(/Agent not found/);
});
it('refuses agent-scoped create when AgentRepository is not wired', async () => {
// Mirrors the "agentRepo: undefined" wiring that older callers may use.
const service = new PromptService(
mockPromptRepo(),
mockPromptRequestRepo(),
mockProjectRepo(),
);
await expect(
service.createPrompt({ name: 'oops', content: 'c', agentId: 'agent-1' }),
).rejects.toThrow(/AgentRepository/);
});
it('lists prompts directly attached to an agent', async () => {
const promptRepo = mockPromptRepo();
const agentRepo = mockAgentRepo([makeAgent()]);
const service = new PromptService(
promptRepo,
mockPromptRequestRepo(),
mockProjectRepo(),
undefined,
agentRepo,
);
await service.createPrompt({ name: 'a', content: 'A', agentId: 'agent-1' });
await service.createPrompt({ name: 'b', content: 'B', agentId: 'agent-1' });
// Different agent — should NOT show up.
const otherAgentRepo = mockAgentRepo([makeAgent({ id: 'agent-other', name: 'other' })]);
const otherService = new PromptService(
promptRepo,
mockPromptRequestRepo(),
mockProjectRepo(),
undefined,
otherAgentRepo,
);
await otherService.createPrompt({ name: 'c', content: 'C', agentId: 'agent-other' });
const list = await service.listPromptsForAgent('agent-1');
expect(list.map((p) => p.name).sort()).toEqual(['a', 'b']);
});
it('upserts by name with agent scope', async () => {
const promptRepo = mockPromptRepo();
const agentRepo = mockAgentRepo([makeAgent()]);
const service = new PromptService(
promptRepo,
mockPromptRequestRepo(),
mockProjectRepo(),
undefined,
agentRepo,
);
const first = await service.upsertByName({
name: 'tone',
content: 'be polite',
agent: 'reviewer',
});
expect(first.agentId).toBe('agent-1');
const second = await service.upsertByName({
name: 'tone',
content: 'be terse',
agent: 'reviewer',
});
expect(second.id).toBe(first.id);
expect(second.content).toBe('be terse');
});
it('upsert rejects when both project and agent are provided', async () => {
// Project must resolve (otherwise the service throws "Project not found"
// before reaching the XOR check). Make findByName return a project.
const projectRepo = mockProjectRepo();
vi.mocked(projectRepo.findByName).mockResolvedValue({
id: 'proj-1', name: 'p',
} as Awaited<ReturnType<typeof projectRepo.findByName>>);
const service = new PromptService(
mockPromptRepo(),
mockPromptRequestRepo(),
projectRepo,
undefined,
mockAgentRepo([makeAgent()]),
);
await expect(
service.upsertByName({
name: 'x',
content: 'c',
project: 'p',
agent: 'reviewer',
}),
).rejects.toThrow(/XOR/);
});
});