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

@@ -44,6 +44,7 @@ import { registerAgentRoutes } from './routes/agents.js';
import { registerAgentChatRoutes } from './routes/agent-chat.js'; import { registerAgentChatRoutes } from './routes/agent-chat.js';
import { PromptRepository } from './repositories/prompt.repository.js'; import { PromptRepository } from './repositories/prompt.repository.js';
import { PromptRequestRepository } from './repositories/prompt-request.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 { bootstrapSystemProject } from './bootstrap/system-project.js';
import { import {
McpServerService, McpServerService,
@@ -448,8 +449,9 @@ async function main(): Promise<void> {
const promptRequestRepo = new PromptRequestRepository(prisma); const promptRequestRepo = new PromptRequestRepository(prisma);
const promptRuleRegistry = new ResourceRuleRegistry(); const promptRuleRegistry = new ResourceRuleRegistry();
promptRuleRegistry.register(systemPromptVarsRule); promptRuleRegistry.register(systemPromptVarsRule);
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry); const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo);
const agentService = new AgentService(agentRepo, llmService, projectService); const personalityRepo = new PersonalityRepository(prisma);
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
// ChatService needs the proxy + project repo via the ChatToolDispatcher // ChatService needs the proxy + project repo via the ChatToolDispatcher
// bridge. The dispatcher's logger references `app.log`, which is not // bridge. The dispatcher's logger references `app.log`, which is not
// constructed until further down — `chatService` itself is built right // constructed until further down — `chatService` itself is built right

View File

@@ -6,6 +6,7 @@ export interface CreateAgentRepoInput {
systemPrompt?: string; systemPrompt?: string;
llmId: string; llmId: string;
projectId?: string | null; projectId?: string | null;
defaultPersonalityId?: string | null;
proxyModelName?: string | null; proxyModelName?: string | null;
defaultParams?: Record<string, unknown>; defaultParams?: Record<string, unknown>;
extras?: Record<string, unknown>; extras?: Record<string, unknown>;
@@ -17,6 +18,7 @@ export interface UpdateAgentRepoInput {
systemPrompt?: string; systemPrompt?: string;
llmId?: string; llmId?: string;
projectId?: string | null; projectId?: string | null;
defaultPersonalityId?: string | null;
proxyModelName?: string | null; proxyModelName?: string | null;
defaultParams?: Record<string, unknown>; defaultParams?: Record<string, unknown>;
extras?: Record<string, unknown>; extras?: Record<string, unknown>;
@@ -62,6 +64,7 @@ export class AgentRepository implements IAgentRepository {
systemPrompt: data.systemPrompt ?? '', systemPrompt: data.systemPrompt ?? '',
llmId: data.llmId, llmId: data.llmId,
projectId: data.projectId ?? null, projectId: data.projectId ?? null,
defaultPersonalityId: data.defaultPersonalityId ?? null,
proxyModelName: data.proxyModelName ?? null, proxyModelName: data.proxyModelName ?? null,
defaultParams: (data.defaultParams ?? {}) as Prisma.InputJsonValue, defaultParams: (data.defaultParams ?? {}) as Prisma.InputJsonValue,
extras: (data.extras ?? {}) as Prisma.InputJsonValue, extras: (data.extras ?? {}) as Prisma.InputJsonValue,
@@ -82,6 +85,11 @@ export class AgentRepository implements IAgentRepository {
? { disconnect: true } ? { disconnect: true }
: { connect: { id: data.projectId } }; : { connect: { id: data.projectId } };
} }
if (data.defaultPersonalityId !== undefined) {
updateData.defaultPersonality = data.defaultPersonalityId === null
? { disconnect: true }
: { connect: { id: data.defaultPersonalityId } };
}
if (data.proxyModelName !== undefined) { if (data.proxyModelName !== undefined) {
updateData.proxyModelName = data.proxyModelName; updateData.proxyModelName = data.proxyModelName;
} }

View File

@@ -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<Personality[]>;
findByAgent(agentId: string): Promise<Personality[]>;
findById(id: string): Promise<Personality | null>;
findByNameAndAgent(name: string, agentId: string): Promise<Personality | null>;
create(data: PersonalityCreateInput): Promise<Personality>;
update(id: string, data: PersonalityUpdateInput): Promise<Personality>;
delete(id: string): Promise<void>;
listPrompts(personalityId: string): Promise<Array<PersonalityPrompt & { prompt: Prompt }>>;
attachPrompt(personalityId: string, promptId: string, priority?: number): Promise<PersonalityPrompt>;
detachPrompt(personalityId: string, promptId: string): Promise<void>;
findBinding(personalityId: string, promptId: string): Promise<PersonalityPrompt | null>;
}
export class PersonalityRepository implements IPersonalityRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<Personality[]> {
return this.prisma.personality.findMany({ orderBy: { name: 'asc' } });
}
async findByAgent(agentId: string): Promise<Personality[]> {
return this.prisma.personality.findMany({
where: { agentId },
orderBy: { name: 'asc' },
});
}
async findById(id: string): Promise<Personality | null> {
return this.prisma.personality.findUnique({ where: { id } });
}
async findByNameAndAgent(name: string, agentId: string): Promise<Personality | null> {
return this.prisma.personality.findUnique({
where: { name_agentId: { name, agentId } },
});
}
async create(data: PersonalityCreateInput): Promise<Personality> {
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<Personality> {
return this.prisma.personality.update({ where: { id }, data });
}
async delete(id: string): Promise<void> {
await this.prisma.personality.delete({ where: { id } });
}
async listPrompts(personalityId: string): Promise<Array<PersonalityPrompt & { prompt: Prompt }>> {
return this.prisma.personalityPrompt.findMany({
where: { personalityId },
include: { prompt: true },
orderBy: { priority: 'desc' },
});
}
async attachPrompt(personalityId: string, promptId: string, priority?: number): Promise<PersonalityPrompt> {
return this.prisma.personalityPrompt.create({
data: {
personalityId,
promptId,
priority: priority ?? 5,
},
});
}
async detachPrompt(personalityId: string, promptId: string): Promise<void> {
await this.prisma.personalityPrompt.delete({
where: { personalityId_promptId: { personalityId, promptId } },
});
}
async findBinding(personalityId: string, promptId: string): Promise<PersonalityPrompt | null> {
return this.prisma.personalityPrompt.findUnique({
where: { personalityId_promptId: { personalityId, promptId } },
});
}
}

View File

@@ -1,12 +1,30 @@
import type { PrismaClient, Prompt } from '@prisma/client'; 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 { export interface IPromptRepository {
findAll(projectId?: string): Promise<Prompt[]>; findAll(projectId?: string): Promise<Prompt[]>;
findGlobal(): Promise<Prompt[]>; findGlobal(): Promise<Prompt[]>;
findByAgent(agentId: string): Promise<Prompt[]>;
findById(id: string): Promise<Prompt | null>; findById(id: string): Promise<Prompt | null>;
findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null>; findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null>;
create(data: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string }): Promise<Prompt>; findByNameAndAgent(name: string, agentId: string | null): Promise<Prompt | null>;
update(id: string, data: { content?: string; priority?: number; summary?: string; chapters?: string[] }): Promise<Prompt>; create(data: PromptCreateInput): Promise<Prompt>;
update(id: string, data: PromptUpdateInput): Promise<Prompt>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
} }
@@ -18,7 +36,7 @@ export class PromptRepository implements IPromptRepository {
if (projectId !== undefined) { if (projectId !== undefined) {
// Project-scoped + global prompts // Project-scoped + global prompts
return this.prisma.prompt.findMany({ return this.prisma.prompt.findMany({
where: { OR: [{ projectId }, { projectId: null }] }, where: { OR: [{ projectId }, { projectId: null, agentId: null }] },
include, include,
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
@@ -28,16 +46,27 @@ export class PromptRepository implements IPromptRepository {
async findGlobal(): Promise<Prompt[]> { async findGlobal(): Promise<Prompt[]> {
return this.prisma.prompt.findMany({ return this.prisma.prompt.findMany({
where: { projectId: null }, where: { projectId: null, agentId: null },
include: { project: { select: { name: true } } }, include: { project: { select: { name: true } } },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
} }
async findByAgent(agentId: string): Promise<Prompt[]> {
return this.prisma.prompt.findMany({
where: { agentId },
include: { agent: { select: { name: true } } },
orderBy: { name: 'asc' },
});
}
async findById(id: string): Promise<Prompt | null> { async findById(id: string): Promise<Prompt | null> {
return this.prisma.prompt.findUnique({ return this.prisma.prompt.findUnique({
where: { id }, 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<Prompt> { async findByNameAndAgent(name: string, agentId: string | null): Promise<Prompt | null> {
return this.prisma.prompt.findUnique({
where: { name_agentId: { name, agentId: agentId ?? '' } },
});
}
async create(data: PromptCreateInput): Promise<Prompt> {
return this.prisma.prompt.create({ data }); return this.prisma.prompt.create({ data });
} }
async update(id: string, data: { content?: string; priority?: number; summary?: string; chapters?: string[] }): Promise<Prompt> { async update(id: string, data: PromptUpdateInput): Promise<Prompt> {
return this.prisma.prompt.update({ where: { id }, data }); return this.prisma.prompt.update({ where: { id }, data });
} }

View File

@@ -10,6 +10,7 @@
*/ */
import type { Agent } from '@prisma/client'; import type { Agent } from '@prisma/client';
import type { IAgentRepository } from '../repositories/agent.repository.js'; 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 { LlmService } from './llm.service.js';
import type { ProjectService } from './project.service.js'; import type { ProjectService } from './project.service.js';
import { import {
@@ -28,6 +29,7 @@ export interface AgentView {
systemPrompt: string; systemPrompt: string;
llm: { id: string; name: string }; llm: { id: string; name: string };
project: { id: string; name: string } | null; project: { id: string; name: string } | null;
defaultPersonality: { id: string; name: string } | null;
proxyModelName: string | null; proxyModelName: string | null;
defaultParams: AgentChatParams; defaultParams: AgentChatParams;
extras: Record<string, unknown>; extras: Record<string, unknown>;
@@ -42,6 +44,7 @@ export class AgentService {
private readonly repo: IAgentRepository, private readonly repo: IAgentRepository,
private readonly llms: LlmService, private readonly llms: LlmService,
private readonly projects: ProjectService, private readonly projects: ProjectService,
private readonly personalities?: IPersonalityRepository,
) {} ) {}
async list(): Promise<AgentView[]> { async list(): Promise<AgentView[]> {
@@ -107,6 +110,25 @@ export class AgentService {
? null ? null
: (await this.projects.resolveAndGet(data.project.name)).id; : (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.proxyModelName !== undefined) updateFields.proxyModelName = data.proxyModelName;
if (data.defaultParams !== undefined) updateFields.defaultParams = data.defaultParams as Record<string, unknown>; if (data.defaultParams !== undefined) updateFields.defaultParams = data.defaultParams as Record<string, unknown>;
if (data.extras !== undefined) updateFields.extras = data.extras; if (data.extras !== undefined) updateFields.extras = data.extras;
@@ -141,6 +163,11 @@ export class AgentService {
const project = row.projectId !== null const project = row.projectId !== null
? await this.projects.getById(row.projectId).catch(() => null) ? await this.projects.getById(row.projectId).catch(() => null)
: 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 { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -148,6 +175,7 @@ export class AgentService {
systemPrompt: row.systemPrompt, systemPrompt: row.systemPrompt,
llm: { id: llm.id, name: llm.name }, llm: { id: llm.id, name: llm.name },
project: project !== null ? { id: project.id, name: project.name } : null, project: project !== null ? { id: project.id, name: project.name } : null,
defaultPersonality,
proxyModelName: row.proxyModelName, proxyModelName: row.proxyModelName,
defaultParams: row.defaultParams as AgentChatParams, defaultParams: row.defaultParams as AgentChatParams,
extras: row.extras as Record<string, unknown>, extras: row.extras as Record<string, unknown>,

View File

@@ -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<PersonalityView[]> {
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<PersonalityView> {
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<PersonalityView> {
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<string, unknown>),
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<PersonalityView> {
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<IPersonalityRepository['update']>[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<void> {
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<PersonalityPromptView[]> {
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<PersonalityPrompt> {
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<void> {
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<void> {
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<PersonalityView> {
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,
};
}
}

View File

@@ -2,6 +2,7 @@ import type { Prompt, PromptRequest } from '@prisma/client';
import type { IPromptRepository } from '../repositories/prompt.repository.js'; import type { IPromptRepository } from '../repositories/prompt.repository.js';
import type { IPromptRequestRepository } from '../repositories/prompt-request.repository.js'; import type { IPromptRequestRepository } from '../repositories/prompt-request.repository.js';
import type { IProjectRepository } from '../repositories/project.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 { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema, UpdatePromptRequestSchema } from '../validation/prompt.schema.js';
import { NotFoundError } from './mcp-server.service.js'; import { NotFoundError } from './mcp-server.service.js';
import type { PromptSummaryService } from './prompt-summary.service.js'; import type { PromptSummaryService } from './prompt-summary.service.js';
@@ -16,6 +17,7 @@ export class PromptService {
private readonly promptRequestRepo: IPromptRequestRepository, private readonly promptRequestRepo: IPromptRequestRepository,
private readonly projectRepo: IProjectRepository, private readonly projectRepo: IProjectRepository,
private readonly ruleRegistry?: ResourceRuleRegistry, private readonly ruleRegistry?: ResourceRuleRegistry,
private readonly agentRepo?: IAgentRepository,
) {} ) {}
setSummaryService(service: PromptSummaryService): void { setSummaryService(service: PromptSummaryService): void {
@@ -66,6 +68,10 @@ export class PromptService {
return this.promptRepo.findGlobal(); return this.promptRepo.findGlobal();
} }
async listPromptsForAgent(agentId: string): Promise<Prompt[]> {
return this.promptRepo.findByAgent(agentId);
}
async getPrompt(id: string): Promise<Prompt> { async getPrompt(id: string): Promise<Prompt> {
const prompt = await this.promptRepo.findById(id); const prompt = await this.promptRepo.findById(id);
if (prompt === null) throw new NotFoundError(`Prompt not found: ${id}`); if (prompt === null) throw new NotFoundError(`Prompt not found: ${id}`);
@@ -75,18 +81,26 @@ export class PromptService {
async createPrompt(input: unknown): Promise<Prompt> { async createPrompt(input: unknown): Promise<Prompt> {
const data = CreatePromptSchema.parse(input); const data = CreatePromptSchema.parse(input);
if (data.projectId) { if (data.projectId !== undefined) {
const project = await this.projectRepo.findById(data.projectId); const project = await this.projectRepo.findById(data.projectId);
if (project === null) throw new NotFoundError(`Project not found: ${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'); 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, name: data.name,
content: data.content, content: data.content,
}; };
if (data.projectId !== undefined) createData.projectId = data.projectId; 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.priority !== undefined) createData.priority = data.priority;
if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget; if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget;
const prompt = await this.promptRepo.create(createData); const prompt = await this.promptRepo.create(createData);
@@ -223,8 +237,8 @@ export class PromptService {
async upsertByName(data: Record<string, unknown>): Promise<Prompt> { async upsertByName(data: Record<string, unknown>): Promise<Prompt> {
const name = data['name'] as string; const name = data['name'] as string;
let projectId: string | null = null; let projectId: string | null = null;
let agentId: string | null = null;
// Resolve project name to ID if provided
if (data['project'] !== undefined) { if (data['project'] !== undefined) {
const project = await this.projectRepo.findByName(data['project'] as string); const project = await this.projectRepo.findByName(data['project'] as string);
if (project === null) throw new NotFoundError(`Project not found: ${data['project']}`); if (project === null) throw new NotFoundError(`Project not found: ${data['project']}`);
@@ -233,7 +247,27 @@ export class PromptService {
projectId = data['projectId'] as string; 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) { if (existing !== null) {
const updateData: { content?: string; priority?: number } = {}; const updateData: { content?: string; priority?: number } = {};
@@ -245,11 +279,12 @@ export class PromptService {
return existing; 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, name,
content: (data['content'] as string) ?? '', content: (data['content'] as string) ?? '',
}; };
if (projectId !== null) createData.projectId = projectId; if (projectId !== null) createData.projectId = projectId;
if (agentId !== null) createData.agentId = agentId;
if (data['priority'] !== undefined) createData.priority = data['priority'] as number; if (data['priority'] !== undefined) createData.priority = data['priority'] as number;
if (data['linkTarget'] !== undefined) createData.linkTarget = data['linkTarget'] as string; if (data['linkTarget'] !== undefined) createData.linkTarget = data['linkTarget'] as string;

View File

@@ -61,6 +61,11 @@ const LlmRefSchema = z.union([
z.object({ id: z.string().min(1) }), z.object({ id: z.string().min(1) }),
]); ]);
const ProjectRefSchema = z.object({ name: 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-]+$/; const NAME_RE = /^[a-z0-9-]+$/;
@@ -84,6 +89,7 @@ export const UpdateAgentSchema = z.object({
systemPrompt: z.string().max(64_000).optional(), systemPrompt: z.string().max(64_000).optional(),
llm: LlmRefSchema.optional(), llm: LlmRefSchema.optional(),
project: ProjectRefSchema.nullable().optional(), project: ProjectRefSchema.nullable().optional(),
defaultPersonality: PersonalityRefSchema.nullable().optional(),
proxyModelName: z.string().min(1).nullable().optional(), proxyModelName: z.string().min(1).nullable().optional(),
defaultParams: AgentChatParamsSchema.optional(), defaultParams: AgentChatParamsSchema.optional(),
extras: z.record(z.unknown()).optional(), extras: z.record(z.unknown()).optional(),

View File

@@ -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<typeof CreatePersonalitySchema>;
export type UpdatePersonalityInput = z.infer<typeof UpdatePersonalitySchema>;
export type AttachPromptInput = z.infer<typeof AttachPromptSchema>;

View File

@@ -2,13 +2,19 @@ import { z } from 'zod';
const LINK_TARGET_RE = /^[a-z0-9-]+\/[a-z0-9-]+:\S+$/; const LINK_TARGET_RE = /^[a-z0-9-]+\/[a-z0-9-]+:\S+$/;
export const CreatePromptSchema = z.object({ export const CreatePromptSchema = z
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), .object({
content: z.string().min(1).max(50000), name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
projectId: z.string().optional(), content: z.string().min(1).max(50000),
priority: z.number().int().min(1).max(10).default(5).optional(), projectId: z.string().optional(),
linkTarget: z.string().regex(LINK_TARGET_RE, 'Link target must be project/server:resource-uri').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({ export const UpdatePromptSchema = z.object({
content: z.string().min(1).max(50000).optional(), content: z.string().min(1).max(50000).optional(),

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