From 6b5bd78cfa1c7bad620fec09424535431523b83c Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 26 Apr 2026 19:20:51 +0100 Subject: [PATCH] feat(mcpd): personality + prompt-by-agent repos and services (Stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: `, 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) --- src/mcpd/src/main.ts | 6 +- src/mcpd/src/repositories/agent.repository.ts | 8 + .../repositories/personality.repository.ts | 101 ++++++ .../src/repositories/prompt.repository.ts | 49 ++- src/mcpd/src/services/agent.service.ts | 28 ++ src/mcpd/src/services/personality.service.ts | 206 +++++++++++ src/mcpd/src/services/prompt.service.ts | 45 ++- src/mcpd/src/validation/agent.schema.ts | 6 + src/mcpd/src/validation/personality.schema.ts | 24 ++ src/mcpd/src/validation/prompt.schema.ts | 20 +- src/mcpd/tests/personality-service.test.ts | 337 ++++++++++++++++++ .../tests/services/prompt-agent-scope.test.ts | 286 +++++++++++++++ 12 files changed, 1095 insertions(+), 21 deletions(-) create mode 100644 src/mcpd/src/repositories/personality.repository.ts create mode 100644 src/mcpd/src/services/personality.service.ts create mode 100644 src/mcpd/src/validation/personality.schema.ts create mode 100644 src/mcpd/tests/personality-service.test.ts create mode 100644 src/mcpd/tests/services/prompt-agent-scope.test.ts diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 4e9845c..fe530e2 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -44,6 +44,7 @@ import { registerAgentRoutes } from './routes/agents.js'; import { registerAgentChatRoutes } from './routes/agent-chat.js'; import { PromptRepository } from './repositories/prompt.repository.js'; import { PromptRequestRepository } from './repositories/prompt-request.repository.js'; +import { PersonalityRepository } from './repositories/personality.repository.js'; import { bootstrapSystemProject } from './bootstrap/system-project.js'; import { McpServerService, @@ -448,8 +449,9 @@ async function main(): Promise { const promptRequestRepo = new PromptRequestRepository(prisma); const promptRuleRegistry = new ResourceRuleRegistry(); promptRuleRegistry.register(systemPromptVarsRule); - const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry); - const agentService = new AgentService(agentRepo, llmService, projectService); + const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo); + const personalityRepo = new PersonalityRepository(prisma); + const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); // ChatService needs the proxy + project repo via the ChatToolDispatcher // bridge. The dispatcher's logger references `app.log`, which is not // constructed until further down — `chatService` itself is built right diff --git a/src/mcpd/src/repositories/agent.repository.ts b/src/mcpd/src/repositories/agent.repository.ts index 3e30fd0..fb5c597 100644 --- a/src/mcpd/src/repositories/agent.repository.ts +++ b/src/mcpd/src/repositories/agent.repository.ts @@ -6,6 +6,7 @@ export interface CreateAgentRepoInput { systemPrompt?: string; llmId: string; projectId?: string | null; + defaultPersonalityId?: string | null; proxyModelName?: string | null; defaultParams?: Record; extras?: Record; @@ -17,6 +18,7 @@ export interface UpdateAgentRepoInput { systemPrompt?: string; llmId?: string; projectId?: string | null; + defaultPersonalityId?: string | null; proxyModelName?: string | null; defaultParams?: Record; extras?: Record; @@ -62,6 +64,7 @@ export class AgentRepository implements IAgentRepository { systemPrompt: data.systemPrompt ?? '', llmId: data.llmId, projectId: data.projectId ?? null, + defaultPersonalityId: data.defaultPersonalityId ?? null, proxyModelName: data.proxyModelName ?? null, defaultParams: (data.defaultParams ?? {}) as Prisma.InputJsonValue, extras: (data.extras ?? {}) as Prisma.InputJsonValue, @@ -82,6 +85,11 @@ export class AgentRepository implements IAgentRepository { ? { disconnect: true } : { connect: { id: data.projectId } }; } + if (data.defaultPersonalityId !== undefined) { + updateData.defaultPersonality = data.defaultPersonalityId === null + ? { disconnect: true } + : { connect: { id: data.defaultPersonalityId } }; + } if (data.proxyModelName !== undefined) { updateData.proxyModelName = data.proxyModelName; } diff --git a/src/mcpd/src/repositories/personality.repository.ts b/src/mcpd/src/repositories/personality.repository.ts new file mode 100644 index 0000000..a44478d --- /dev/null +++ b/src/mcpd/src/repositories/personality.repository.ts @@ -0,0 +1,101 @@ +import type { PrismaClient, Personality, PersonalityPrompt, Prompt } from '@prisma/client'; + +export interface PersonalityCreateInput { + name: string; + description?: string; + agentId: string; + priority?: number; +} + +export interface PersonalityUpdateInput { + description?: string; + priority?: number; +} + +export interface IPersonalityRepository { + findAll(): Promise; + findByAgent(agentId: string): Promise; + findById(id: string): Promise; + findByNameAndAgent(name: string, agentId: string): Promise; + create(data: PersonalityCreateInput): Promise; + update(id: string, data: PersonalityUpdateInput): Promise; + delete(id: string): Promise; + listPrompts(personalityId: string): Promise>; + attachPrompt(personalityId: string, promptId: string, priority?: number): Promise; + detachPrompt(personalityId: string, promptId: string): Promise; + findBinding(personalityId: string, promptId: string): Promise; +} + +export class PersonalityRepository implements IPersonalityRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.personality.findMany({ orderBy: { name: 'asc' } }); + } + + async findByAgent(agentId: string): Promise { + return this.prisma.personality.findMany({ + where: { agentId }, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.personality.findUnique({ where: { id } }); + } + + async findByNameAndAgent(name: string, agentId: string): Promise { + return this.prisma.personality.findUnique({ + where: { name_agentId: { name, agentId } }, + }); + } + + async create(data: PersonalityCreateInput): Promise { + return this.prisma.personality.create({ + data: { + name: data.name, + description: data.description ?? '', + agentId: data.agentId, + priority: data.priority ?? 5, + }, + }); + } + + async update(id: string, data: PersonalityUpdateInput): Promise { + return this.prisma.personality.update({ where: { id }, data }); + } + + async delete(id: string): Promise { + await this.prisma.personality.delete({ where: { id } }); + } + + async listPrompts(personalityId: string): Promise> { + return this.prisma.personalityPrompt.findMany({ + where: { personalityId }, + include: { prompt: true }, + orderBy: { priority: 'desc' }, + }); + } + + async attachPrompt(personalityId: string, promptId: string, priority?: number): Promise { + return this.prisma.personalityPrompt.create({ + data: { + personalityId, + promptId, + priority: priority ?? 5, + }, + }); + } + + async detachPrompt(personalityId: string, promptId: string): Promise { + await this.prisma.personalityPrompt.delete({ + where: { personalityId_promptId: { personalityId, promptId } }, + }); + } + + async findBinding(personalityId: string, promptId: string): Promise { + return this.prisma.personalityPrompt.findUnique({ + where: { personalityId_promptId: { personalityId, promptId } }, + }); + } +} diff --git a/src/mcpd/src/repositories/prompt.repository.ts b/src/mcpd/src/repositories/prompt.repository.ts index f60b6fd..80d2511 100644 --- a/src/mcpd/src/repositories/prompt.repository.ts +++ b/src/mcpd/src/repositories/prompt.repository.ts @@ -1,12 +1,30 @@ import type { PrismaClient, Prompt } from '@prisma/client'; +export interface PromptCreateInput { + name: string; + content: string; + projectId?: string; + agentId?: string; + priority?: number; + linkTarget?: string; +} + +export interface PromptUpdateInput { + content?: string; + priority?: number; + summary?: string; + chapters?: string[]; +} + export interface IPromptRepository { findAll(projectId?: string): Promise; findGlobal(): Promise; + findByAgent(agentId: string): Promise; findById(id: string): Promise; findByNameAndProject(name: string, projectId: string | null): Promise; - create(data: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string }): Promise; - update(id: string, data: { content?: string; priority?: number; summary?: string; chapters?: string[] }): Promise; + findByNameAndAgent(name: string, agentId: string | null): Promise; + create(data: PromptCreateInput): Promise; + update(id: string, data: PromptUpdateInput): Promise; delete(id: string): Promise; } @@ -18,7 +36,7 @@ export class PromptRepository implements IPromptRepository { if (projectId !== undefined) { // Project-scoped + global prompts return this.prisma.prompt.findMany({ - where: { OR: [{ projectId }, { projectId: null }] }, + where: { OR: [{ projectId }, { projectId: null, agentId: null }] }, include, orderBy: { name: 'asc' }, }); @@ -28,16 +46,27 @@ export class PromptRepository implements IPromptRepository { async findGlobal(): Promise { return this.prisma.prompt.findMany({ - where: { projectId: null }, + where: { projectId: null, agentId: null }, include: { project: { select: { name: true } } }, orderBy: { name: 'asc' }, }); } + async findByAgent(agentId: string): Promise { + return this.prisma.prompt.findMany({ + where: { agentId }, + include: { agent: { select: { name: true } } }, + orderBy: { name: 'asc' }, + }); + } + async findById(id: string): Promise { return this.prisma.prompt.findUnique({ where: { id }, - include: { project: { select: { name: true } } }, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, }); } @@ -47,11 +76,17 @@ export class PromptRepository implements IPromptRepository { }); } - async create(data: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string }): Promise { + async findByNameAndAgent(name: string, agentId: string | null): Promise { + return this.prisma.prompt.findUnique({ + where: { name_agentId: { name, agentId: agentId ?? '' } }, + }); + } + + async create(data: PromptCreateInput): Promise { return this.prisma.prompt.create({ data }); } - async update(id: string, data: { content?: string; priority?: number; summary?: string; chapters?: string[] }): Promise { + async update(id: string, data: PromptUpdateInput): Promise { return this.prisma.prompt.update({ where: { id }, data }); } diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index b958367..0be9e5f 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -10,6 +10,7 @@ */ import type { Agent } from '@prisma/client'; import type { IAgentRepository } from '../repositories/agent.repository.js'; +import type { IPersonalityRepository } from '../repositories/personality.repository.js'; import type { LlmService } from './llm.service.js'; import type { ProjectService } from './project.service.js'; import { @@ -28,6 +29,7 @@ export interface AgentView { systemPrompt: string; llm: { id: string; name: string }; project: { id: string; name: string } | null; + defaultPersonality: { id: string; name: string } | null; proxyModelName: string | null; defaultParams: AgentChatParams; extras: Record; @@ -42,6 +44,7 @@ export class AgentService { private readonly repo: IAgentRepository, private readonly llms: LlmService, private readonly projects: ProjectService, + private readonly personalities?: IPersonalityRepository, ) {} async list(): Promise { @@ -107,6 +110,25 @@ export class AgentService { ? null : (await this.projects.resolveAndGet(data.project.name)).id; } + if (data.defaultPersonality !== undefined) { + if (data.defaultPersonality === null) { + updateFields.defaultPersonalityId = null; + } else { + if (this.personalities === undefined) { + throw new Error('PersonalityRepository must be wired into AgentService to set defaultPersonality'); + } + const agent = await this.repo.findById(id); + if (agent === null) throw new NotFoundError(`Agent not found: ${id}`); + const personality = await this.personalities.findByNameAndAgent( + data.defaultPersonality.name, + agent.id, + ); + if (personality === null) { + throw new NotFoundError(`Personality not found on agent ${agent.name}: ${data.defaultPersonality.name}`); + } + updateFields.defaultPersonalityId = personality.id; + } + } if (data.proxyModelName !== undefined) updateFields.proxyModelName = data.proxyModelName; if (data.defaultParams !== undefined) updateFields.defaultParams = data.defaultParams as Record; if (data.extras !== undefined) updateFields.extras = data.extras; @@ -141,6 +163,11 @@ export class AgentService { const project = row.projectId !== null ? await this.projects.getById(row.projectId).catch(() => null) : null; + let defaultPersonality: { id: string; name: string } | null = null; + if (row.defaultPersonalityId !== null && this.personalities !== undefined) { + const p = await this.personalities.findById(row.defaultPersonalityId); + if (p !== null) defaultPersonality = { id: p.id, name: p.name }; + } return { id: row.id, name: row.name, @@ -148,6 +175,7 @@ export class AgentService { systemPrompt: row.systemPrompt, llm: { id: llm.id, name: llm.name }, project: project !== null ? { id: project.id, name: project.name } : null, + defaultPersonality, proxyModelName: row.proxyModelName, defaultParams: row.defaultParams as AgentChatParams, extras: row.extras as Record, diff --git a/src/mcpd/src/services/personality.service.ts b/src/mcpd/src/services/personality.service.ts new file mode 100644 index 0000000..d6812cf --- /dev/null +++ b/src/mcpd/src/services/personality.service.ts @@ -0,0 +1,206 @@ +/** + * PersonalityService — CRUD over `Personality` rows + prompt bindings. + * + * A Personality is a named overlay attached to one Agent. At chat time, the + * caller may pick a Personality (by `--personality` flag, or by the agent's + * `defaultPersonalityId`); the prompts bound to that Personality are appended + * to the system block on top of the agent's own systemPrompt + agent-direct + * prompts + project prompts. VLAN-on-ethernet: the agent works without one; + * with one, segmentation kicks in. + * + * Scope rule for attaching a Prompt to a Personality (enforced here, not in + * the DB, so error messages stay readable): + * A prompt may be bound only if it's: + * - directly attached to the same Agent (Prompt.agentId === personality.agentId), OR + * - in the Agent's Project (Prompt.projectId === agent.projectId, when projectId set), OR + * - global (Prompt.projectId === null && Prompt.agentId === null). + * Attaching a prompt from a foreign project / foreign agent is rejected. + */ +import type { Personality, PersonalityPrompt, Prompt } from '@prisma/client'; +import type { IPersonalityRepository } from '../repositories/personality.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; +import type { IPromptRepository } from '../repositories/prompt.repository.js'; +import { + CreatePersonalitySchema, + UpdatePersonalitySchema, + AttachPromptSchema, +} from '../validation/personality.schema.js'; +import { NotFoundError, ConflictError } from './mcp-server.service.js'; + +export interface PersonalityView { + id: string; + name: string; + description: string; + agentId: string; + agentName: string; + priority: number; + promptCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface PersonalityPromptView { + promptId: string; + promptName: string; + promptContent: string; + priority: number; // PersonalityPrompt-scoped priority (overrides Prompt.priority within this overlay) + createdAt: Date; +} + +export class PersonalityService { + constructor( + private readonly repo: IPersonalityRepository, + private readonly agentRepo: IAgentRepository, + private readonly promptRepo: IPromptRepository, + ) {} + + async listForAgent(agentName: string): Promise { + const agent = await this.agentRepo.findByName(agentName); + if (agent === null) throw new NotFoundError(`Agent not found: ${agentName}`); + const rows = await this.repo.findByAgent(agent.id); + return Promise.all(rows.map((r) => this.toView(r, agent.name))); + } + + async getById(id: string): Promise { + const row = await this.repo.findById(id); + if (row === null) throw new NotFoundError(`Personality not found: ${id}`); + const agent = await this.agentRepo.findById(row.agentId); + return this.toView(row, agent?.name ?? row.agentId); + } + + async create(agentName: string, input: unknown): Promise { + const agent = await this.agentRepo.findByName(agentName); + if (agent === null) throw new NotFoundError(`Agent not found: ${agentName}`); + + // Inject the resolved agentId so the schema can validate the full shape + // (callers pass `{ name, description?, priority? }` without re-stating it). + const data = CreatePersonalitySchema.parse({ + ...(input as Record), + agentId: agent.id, + }); + + const existing = await this.repo.findByNameAndAgent(data.name, agent.id); + if (existing !== null) { + throw new ConflictError(`Personality already exists: ${agentName}/${data.name}`); + } + + const created = await this.repo.create({ + name: data.name, + description: data.description, + agentId: agent.id, + priority: data.priority, + }); + return this.toView(created, agent.name); + } + + async update(id: string, input: unknown): Promise { + const data = UpdatePersonalitySchema.parse(input); + const existing = await this.repo.findById(id); + if (existing === null) throw new NotFoundError(`Personality not found: ${id}`); + + const updateFields: Parameters[1] = {}; + if (data.description !== undefined) updateFields.description = data.description; + if (data.priority !== undefined) updateFields.priority = data.priority; + + const updated = await this.repo.update(id, updateFields); + const agent = await this.agentRepo.findById(updated.agentId); + return this.toView(updated, agent?.name ?? updated.agentId); + } + + async delete(id: string): Promise { + const existing = await this.repo.findById(id); + if (existing === null) throw new NotFoundError(`Personality not found: ${id}`); + await this.repo.delete(id); + } + + // ── Prompt bindings ── + + async listBoundPrompts(personalityId: string): Promise { + const personality = await this.repo.findById(personalityId); + if (personality === null) throw new NotFoundError(`Personality not found: ${personalityId}`); + + const rows = await this.repo.listPrompts(personalityId); + return rows.map((r) => ({ + promptId: r.prompt.id, + promptName: r.prompt.name, + promptContent: r.prompt.content, + priority: r.priority, + createdAt: r.createdAt, + })); + } + + async attachPrompt(personalityId: string, input: unknown): Promise { + const data = AttachPromptSchema.parse(input); + const personality = await this.repo.findById(personalityId); + if (personality === null) throw new NotFoundError(`Personality not found: ${personalityId}`); + + const prompt = await this.promptRepo.findById(data.promptId); + if (prompt === null) throw new NotFoundError(`Prompt not found: ${data.promptId}`); + + await this.assertPromptInScope(prompt, personality); + + const dup = await this.repo.findBinding(personalityId, data.promptId); + if (dup !== null) { + throw new ConflictError(`Prompt already bound to personality: ${prompt.name}`); + } + + return this.repo.attachPrompt(personalityId, data.promptId, data.priority); + } + + async detachPrompt(personalityId: string, promptId: string): Promise { + const personality = await this.repo.findById(personalityId); + if (personality === null) throw new NotFoundError(`Personality not found: ${personalityId}`); + const binding = await this.repo.findBinding(personalityId, promptId); + if (binding === null) { + throw new NotFoundError(`Prompt not bound to personality: ${promptId}`); + } + await this.repo.detachPrompt(personalityId, promptId); + } + + /** + * A prompt may overlay a personality only if it shares scope with the + * agent — direct attachment, the agent's project, or global. Anything else + * is a leak attempt (e.g., someone trying to attach Project A's prompts to + * Project B's agent's personality). + */ + private async assertPromptInScope(prompt: Prompt, personality: Personality): Promise { + if (prompt.agentId !== null) { + if (prompt.agentId !== personality.agentId) { + throw Object.assign( + new Error(`Prompt belongs to a different agent and cannot be attached to this personality`), + { statusCode: 400 }, + ); + } + return; + } + if (prompt.projectId !== null) { + const agent = await this.agentRepo.findById(personality.agentId); + if (agent === null) { + throw new NotFoundError(`Agent not found: ${personality.agentId}`); + } + if (agent.projectId !== prompt.projectId) { + throw Object.assign( + new Error(`Prompt belongs to a different project and cannot be attached to this personality`), + { statusCode: 400 }, + ); + } + return; + } + // Global prompt — allowed for any personality. + } + + private async toView(row: Personality, agentName: string): Promise { + const prompts = await this.repo.listPrompts(row.id); + return { + id: row.id, + name: row.name, + description: row.description, + agentId: row.agentId, + agentName, + priority: row.priority, + promptCount: prompts.length, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } +} diff --git a/src/mcpd/src/services/prompt.service.ts b/src/mcpd/src/services/prompt.service.ts index 408ab8a..528826a 100644 --- a/src/mcpd/src/services/prompt.service.ts +++ b/src/mcpd/src/services/prompt.service.ts @@ -2,6 +2,7 @@ import type { Prompt, PromptRequest } from '@prisma/client'; import type { IPromptRepository } from '../repositories/prompt.repository.js'; import type { IPromptRequestRepository } from '../repositories/prompt-request.repository.js'; import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema, UpdatePromptRequestSchema } from '../validation/prompt.schema.js'; import { NotFoundError } from './mcp-server.service.js'; import type { PromptSummaryService } from './prompt-summary.service.js'; @@ -16,6 +17,7 @@ export class PromptService { private readonly promptRequestRepo: IPromptRequestRepository, private readonly projectRepo: IProjectRepository, private readonly ruleRegistry?: ResourceRuleRegistry, + private readonly agentRepo?: IAgentRepository, ) {} setSummaryService(service: PromptSummaryService): void { @@ -66,6 +68,10 @@ export class PromptService { return this.promptRepo.findGlobal(); } + async listPromptsForAgent(agentId: string): Promise { + return this.promptRepo.findByAgent(agentId); + } + async getPrompt(id: string): Promise { const prompt = await this.promptRepo.findById(id); if (prompt === null) throw new NotFoundError(`Prompt not found: ${id}`); @@ -75,18 +81,26 @@ export class PromptService { async createPrompt(input: unknown): Promise { const data = CreatePromptSchema.parse(input); - if (data.projectId) { + if (data.projectId !== undefined) { const project = await this.projectRepo.findById(data.projectId); if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); } + if (data.agentId !== undefined) { + if (this.agentRepo === undefined) { + throw new Error('Agent-scoped prompts require AgentRepository to be wired into PromptService'); + } + const agent = await this.agentRepo.findById(data.agentId); + if (agent === null) throw new NotFoundError(`Agent not found: ${data.agentId}`); + } await this.validatePromptRules(data.name, data.content, data.projectId, 'create'); - const createData: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string } = { + const createData: { name: string; content: string; projectId?: string; agentId?: string; priority?: number; linkTarget?: string } = { name: data.name, content: data.content, }; if (data.projectId !== undefined) createData.projectId = data.projectId; + if (data.agentId !== undefined) createData.agentId = data.agentId; if (data.priority !== undefined) createData.priority = data.priority; if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget; const prompt = await this.promptRepo.create(createData); @@ -223,8 +237,8 @@ export class PromptService { async upsertByName(data: Record): Promise { const name = data['name'] as string; let projectId: string | null = null; + let agentId: string | null = null; - // Resolve project name to ID if provided if (data['project'] !== undefined) { const project = await this.projectRepo.findByName(data['project'] as string); if (project === null) throw new NotFoundError(`Project not found: ${data['project']}`); @@ -233,7 +247,27 @@ export class PromptService { projectId = data['projectId'] as string; } - const existing = await this.promptRepo.findByNameAndProject(name, projectId); + if (data['agent'] !== undefined) { + if (this.agentRepo === undefined) { + throw new Error('Agent-scoped prompts require AgentRepository to be wired into PromptService'); + } + const agent = await this.agentRepo.findByName(data['agent'] as string); + if (agent === null) throw new NotFoundError(`Agent not found: ${data['agent']}`); + agentId = agent.id; + } else if (data['agentId'] !== undefined) { + agentId = data['agentId'] as string; + } + + if (projectId !== null && agentId !== null) { + throw Object.assign( + new Error('A prompt may attach to a project XOR an agent, not both'), + { statusCode: 400 }, + ); + } + + const existing = agentId !== null + ? await this.promptRepo.findByNameAndAgent(name, agentId) + : await this.promptRepo.findByNameAndProject(name, projectId); if (existing !== null) { const updateData: { content?: string; priority?: number } = {}; @@ -245,11 +279,12 @@ export class PromptService { return existing; } - const createData: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string } = { + const createData: { name: string; content: string; projectId?: string; agentId?: string; priority?: number; linkTarget?: string } = { name, content: (data['content'] as string) ?? '', }; if (projectId !== null) createData.projectId = projectId; + if (agentId !== null) createData.agentId = agentId; if (data['priority'] !== undefined) createData.priority = data['priority'] as number; if (data['linkTarget'] !== undefined) createData.linkTarget = data['linkTarget'] as string; diff --git a/src/mcpd/src/validation/agent.schema.ts b/src/mcpd/src/validation/agent.schema.ts index 27c4de7..5f253c0 100644 --- a/src/mcpd/src/validation/agent.schema.ts +++ b/src/mcpd/src/validation/agent.schema.ts @@ -61,6 +61,11 @@ const LlmRefSchema = z.union([ z.object({ id: z.string().min(1) }), ]); const ProjectRefSchema = z.object({ name: z.string().min(1) }); +/** + * Personality reference is by name only (per-agent unique constraint). + * `null` clears the agent's defaultPersonalityId on update. + */ +const PersonalityRefSchema = z.object({ name: z.string().min(1) }); const NAME_RE = /^[a-z0-9-]+$/; @@ -84,6 +89,7 @@ export const UpdateAgentSchema = z.object({ systemPrompt: z.string().max(64_000).optional(), llm: LlmRefSchema.optional(), project: ProjectRefSchema.nullable().optional(), + defaultPersonality: PersonalityRefSchema.nullable().optional(), proxyModelName: z.string().min(1).nullable().optional(), defaultParams: AgentChatParamsSchema.optional(), extras: z.record(z.unknown()).optional(), diff --git a/src/mcpd/src/validation/personality.schema.ts b/src/mcpd/src/validation/personality.schema.ts new file mode 100644 index 0000000..0a5060c --- /dev/null +++ b/src/mcpd/src/validation/personality.schema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +const NAME_RE = /^[a-z0-9-]+$/; + +export const CreatePersonalitySchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + description: z.string().max(2000).default(''), + agentId: z.string().min(1), + priority: z.number().int().min(1).max(10).default(5), +}); + +export const UpdatePersonalitySchema = z.object({ + description: z.string().max(2000).optional(), + priority: z.number().int().min(1).max(10).optional(), +}); + +export const AttachPromptSchema = z.object({ + promptId: z.string().min(1), + priority: z.number().int().min(1).max(10).optional(), +}); + +export type CreatePersonalityInput = z.infer; +export type UpdatePersonalityInput = z.infer; +export type AttachPromptInput = z.infer; diff --git a/src/mcpd/src/validation/prompt.schema.ts b/src/mcpd/src/validation/prompt.schema.ts index f1b760e..dc1e56c 100644 --- a/src/mcpd/src/validation/prompt.schema.ts +++ b/src/mcpd/src/validation/prompt.schema.ts @@ -2,13 +2,19 @@ import { z } from 'zod'; const LINK_TARGET_RE = /^[a-z0-9-]+\/[a-z0-9-]+:\S+$/; -export const CreatePromptSchema = z.object({ - name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), - content: z.string().min(1).max(50000), - projectId: z.string().optional(), - priority: z.number().int().min(1).max(10).default(5).optional(), - linkTarget: z.string().regex(LINK_TARGET_RE, 'Link target must be project/server:resource-uri').optional(), -}); +export const CreatePromptSchema = z + .object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + content: z.string().min(1).max(50000), + projectId: z.string().optional(), + agentId: z.string().optional(), + priority: z.number().int().min(1).max(10).default(5).optional(), + linkTarget: z.string().regex(LINK_TARGET_RE, 'Link target must be project/server:resource-uri').optional(), + }) + .refine( + (data) => !(data.projectId !== undefined && data.agentId !== undefined), + { message: 'A prompt may attach to a project XOR an agent, not both', path: ['agentId'] }, + ); export const UpdatePromptSchema = z.object({ content: z.string().min(1).max(50000).optional(), diff --git a/src/mcpd/tests/personality-service.test.ts b/src/mcpd/tests/personality-service.test.ts new file mode 100644 index 0000000..0b6f89d --- /dev/null +++ b/src/mcpd/tests/personality-service.test.ts @@ -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 { + 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 { + 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 { + 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(initial.map((r) => [r.id, r])); + const bindings = new Map(); + 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(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(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/); + }); +}); diff --git a/src/mcpd/tests/services/prompt-agent-scope.test.ts b/src/mcpd/tests/services/prompt-agent-scope.test.ts new file mode 100644 index 0000000..146d7bd --- /dev/null +++ b/src/mcpd/tests/services/prompt-agent-scope.test.ts @@ -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 { + 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 { + 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(); + 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(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>); + + 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/); + }); +});