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,337 @@
import { describe, it, expect, vi } from 'vitest';
import { PersonalityService } from '../src/services/personality.service.js';
import type { IPersonalityRepository } from '../src/repositories/personality.repository.js';
import type { IAgentRepository } from '../src/repositories/agent.repository.js';
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
import type { Agent, Personality, PersonalityPrompt, Prompt } from '@prisma/client';
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 makePersonality(overrides: Partial<Personality> = {}): Personality {
return {
id: `pers-${Math.random().toString(36).slice(2, 8)}`,
name: 'grumpy',
description: '',
agentId: 'agent-1',
priority: 5,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
return {
id: `prompt-${Math.random().toString(36).slice(2, 8)}`,
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 mockPersonalityRepo(initial: Personality[] = []): IPersonalityRepository {
const rows = new Map<string, Personality>(initial.map((r) => [r.id, r]));
const bindings = new Map<string, PersonalityPrompt>();
const key = (pid: string, prid: string): string => `${pid}::${prid}`;
return {
findAll: vi.fn(async () => [...rows.values()]),
findByAgent: vi.fn(async (agentId: string) =>
[...rows.values()].filter((r) => r.agentId === agentId)),
findById: vi.fn(async (id: string) => rows.get(id) ?? null),
findByNameAndAgent: vi.fn(async (name: string, agentId: string) => {
for (const r of rows.values()) {
if (r.name === name && r.agentId === agentId) return r;
}
return null;
}),
create: vi.fn(async (data) => {
const row = makePersonality({
id: `pers-${String(rows.size + 1)}`,
name: data.name,
description: data.description ?? '',
agentId: data.agentId,
priority: data.priority ?? 5,
});
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: Personality = {
...existing,
...(data.description !== undefined ? { description: data.description } : {}),
...(data.priority !== undefined ? { priority: data.priority } : {}),
};
rows.set(id, next);
return next;
}),
delete: vi.fn(async (id: string) => {
rows.delete(id);
// Cascade-emulate: drop bindings whose personality is gone.
for (const k of [...bindings.keys()]) {
if (k.startsWith(`${id}::`)) bindings.delete(k);
}
}),
listPrompts: vi.fn(async (personalityId: string) =>
[...bindings.values()]
.filter((b) => b.personalityId === personalityId)
.map((b) => ({
...b,
prompt: makePrompt({ id: b.promptId, name: `prompt-${b.promptId}` }),
}))),
attachPrompt: vi.fn(async (personalityId: string, promptId: string, priority?: number) => {
const binding: PersonalityPrompt = {
id: `bind-${bindings.size + 1}`,
personalityId,
promptId,
priority: priority ?? 5,
createdAt: new Date(),
};
bindings.set(key(personalityId, promptId), binding);
return binding;
}),
detachPrompt: vi.fn(async (personalityId: string, promptId: string) => {
bindings.delete(key(personalityId, promptId));
}),
findBinding: vi.fn(async (personalityId: string, promptId: string) =>
bindings.get(key(personalityId, promptId)) ?? null),
};
}
function mockAgentRepo(agents: Agent[] = []): IAgentRepository {
const rows = new Map<string, Agent>(agents.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(),
update: vi.fn(),
delete: vi.fn(),
};
}
function mockPromptRepo(prompts: Prompt[] = []): IPromptRepository {
const rows = new Map<string, Prompt>(prompts.map((p) => [p.id, p]));
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(),
findByNameAndAgent: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
}
describe('PersonalityService', () => {
it('creates a personality bound to an agent', async () => {
const agent = makeAgent();
const repo = mockPersonalityRepo();
const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo());
const p = await service.create('reviewer', { name: 'grumpy', priority: 7 });
expect(p.name).toBe('grumpy');
expect(p.agentName).toBe('reviewer');
expect(p.priority).toBe(7);
expect(p.promptCount).toBe(0);
});
it('rejects create when the agent does not exist', async () => {
const service = new PersonalityService(
mockPersonalityRepo(),
mockAgentRepo([]),
mockPromptRepo(),
);
await expect(service.create('ghost', { name: 'x' })).rejects.toThrow(/Agent not found/);
});
it('rejects duplicate (name, agent) personalities', async () => {
const agent = makeAgent();
const repo = mockPersonalityRepo([
makePersonality({ id: 'pers-existing', name: 'grumpy', agentId: 'agent-1' }),
]);
const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo());
await expect(service.create('reviewer', { name: 'grumpy' })).rejects.toThrow(/already exists/);
});
it('lists personalities for an agent', async () => {
const agent = makeAgent();
const repo = mockPersonalityRepo([
makePersonality({ id: 'p1', name: 'a', agentId: 'agent-1' }),
makePersonality({ id: 'p2', name: 'b', agentId: 'agent-1' }),
makePersonality({ id: 'p3', name: 'c', agentId: 'other-agent' }),
]);
const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo());
const list = await service.listForAgent('reviewer');
expect(list.map((p) => p.name).sort()).toEqual(['a', 'b']);
});
it('updates description + priority', async () => {
const agent = makeAgent();
const personality = makePersonality({ id: 'pers-up', description: 'old', priority: 3 });
const repo = mockPersonalityRepo([personality]);
const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo());
const updated = await service.update('pers-up', { description: 'new', priority: 9 });
expect(updated.description).toBe('new');
expect(updated.priority).toBe(9);
});
it('attaches an agent-direct prompt', async () => {
const agent = makeAgent();
const personality = makePersonality({ id: 'pers-1', agentId: agent.id });
const prompt = makePrompt({ id: 'pr-1', agentId: agent.id });
const repo = mockPersonalityRepo([personality]);
const service = new PersonalityService(
repo,
mockAgentRepo([agent]),
mockPromptRepo([prompt]),
);
const binding = await service.attachPrompt('pers-1', { promptId: 'pr-1', priority: 8 });
expect(binding.priority).toBe(8);
});
it('attaches a prompt from the agent\'s project', async () => {
const agent = makeAgent({ projectId: 'proj-1' });
const personality = makePersonality({ id: 'pers-2', agentId: agent.id });
const prompt = makePrompt({ id: 'pr-proj', projectId: 'proj-1' });
const repo = mockPersonalityRepo([personality]);
const service = new PersonalityService(
repo,
mockAgentRepo([agent]),
mockPromptRepo([prompt]),
);
const binding = await service.attachPrompt('pers-2', { promptId: 'pr-proj' });
expect(binding.promptId).toBe('pr-proj');
});
it('attaches a global prompt (projectId=null, agentId=null)', async () => {
const agent = makeAgent();
const personality = makePersonality({ id: 'pers-3', agentId: agent.id });
const prompt = makePrompt({ id: 'pr-global' });
const repo = mockPersonalityRepo([personality]);
const service = new PersonalityService(
repo,
mockAgentRepo([agent]),
mockPromptRepo([prompt]),
);
const binding = await service.attachPrompt('pers-3', { promptId: 'pr-global' });
expect(binding.promptId).toBe('pr-global');
});
it('rejects attaching a prompt that belongs to a different agent', async () => {
const agent = makeAgent({ id: 'agent-1' });
const personality = makePersonality({ id: 'pers-4', agentId: 'agent-1' });
const foreign = makePrompt({ id: 'pr-foreign', agentId: 'agent-other' });
const repo = mockPersonalityRepo([personality]);
const service = new PersonalityService(
repo,
mockAgentRepo([agent]),
mockPromptRepo([foreign]),
);
await expect(
service.attachPrompt('pers-4', { promptId: 'pr-foreign' }),
).rejects.toThrow(/different agent/);
});
it('rejects attaching a prompt from a foreign project', async () => {
const agent = makeAgent({ projectId: 'proj-1' });
const personality = makePersonality({ id: 'pers-5', agentId: agent.id });
const foreign = makePrompt({ id: 'pr-other-proj', projectId: 'proj-other' });
const repo = mockPersonalityRepo([personality]);
const service = new PersonalityService(
repo,
mockAgentRepo([agent]),
mockPromptRepo([foreign]),
);
await expect(
service.attachPrompt('pers-5', { promptId: 'pr-other-proj' }),
).rejects.toThrow(/different project/);
});
it('rejects double-attach of the same prompt', async () => {
const agent = makeAgent();
const personality = makePersonality({ id: 'pers-dup', agentId: agent.id });
const prompt = makePrompt({ id: 'pr-dup', agentId: agent.id });
const repo = mockPersonalityRepo([personality]);
const service = new PersonalityService(
repo,
mockAgentRepo([agent]),
mockPromptRepo([prompt]),
);
await service.attachPrompt('pers-dup', { promptId: 'pr-dup' });
await expect(
service.attachPrompt('pers-dup', { promptId: 'pr-dup' }),
).rejects.toThrow(/already bound/);
});
it('detaches a prompt and rejects detaching one that was never bound', async () => {
const agent = makeAgent();
const personality = makePersonality({ id: 'pers-det', agentId: agent.id });
const prompt = makePrompt({ id: 'pr-det', agentId: agent.id });
const repo = mockPersonalityRepo([personality]);
const service = new PersonalityService(
repo,
mockAgentRepo([agent]),
mockPromptRepo([prompt]),
);
await service.attachPrompt('pers-det', { promptId: 'pr-det' });
await service.detachPrompt('pers-det', 'pr-det');
await expect(service.detachPrompt('pers-det', 'pr-det')).rejects.toThrow(/not bound/);
});
});

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