diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index fe530e2..210a044 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -45,6 +45,8 @@ 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 { PersonalityService } from './services/personality.service.js'; +import { registerPersonalityRoutes } from './routes/personalities.js'; import { bootstrapSystemProject } from './bootstrap/system-project.js'; import { McpServerService, @@ -163,6 +165,9 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { 'mcptokens': 'mcptokens', 'llms': 'llms', 'agents': 'agents', + // Personalities inherit the agent's RBAC: managing a personality + // requires view/edit/create/delete on the `agents` resource. + 'personalities': 'agents', }; const resource = resourceMap[segment]; @@ -451,6 +456,7 @@ async function main(): Promise { promptRuleRegistry.register(systemPromptVarsRule); const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo); const personalityRepo = new PersonalityRepository(prisma); + const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); 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 @@ -579,6 +585,7 @@ async function main(): Promise { registerSecretMigrateRoutes(app, secretMigrateService); registerLlmRoutes(app, llmService); registerAgentRoutes(app, agentService); + registerPersonalityRoutes(app, personalityService); // ChatService needs an `app.log`-aware tool dispatcher. const chatToolDispatcher = new ChatToolDispatcherImpl({ proxy: mcpProxyService, @@ -592,6 +599,7 @@ async function main(): Promise { chatRepo, promptRepo, chatToolDispatcher, + personalityRepo, ); registerAgentChatRoutes(app, chatService); registerLlmInferRoutes(app, { @@ -627,7 +635,7 @@ async function main(): Promise { registerUserRoutes(app, userService); registerGroupRoutes(app, groupService); registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo }); - registerPromptRoutes(app, promptService, projectRepo); + registerPromptRoutes(app, promptService, projectRepo, agentRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/routes/agent-chat.ts b/src/mcpd/src/routes/agent-chat.ts index 0a83579..e332f7a 100644 --- a/src/mcpd/src/routes/agent-chat.ts +++ b/src/mcpd/src/routes/agent-chat.ts @@ -37,7 +37,7 @@ export function registerAgentChatRoutes( } const { - threadId, message, messages: messagesOverride, stream, + threadId, message, messages: messagesOverride, stream, personality, ...paramsRest } = parsed; @@ -49,6 +49,7 @@ export function registerAgentChatRoutes( ...(messagesOverride !== undefined ? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) } : {}), + ...(personality !== undefined ? { personalityName: personality } : {}), params: paramsRest, }; diff --git a/src/mcpd/src/routes/personalities.ts b/src/mcpd/src/routes/personalities.ts new file mode 100644 index 0000000..1b818d3 --- /dev/null +++ b/src/mcpd/src/routes/personalities.ts @@ -0,0 +1,154 @@ +/** + * /api/v1/.../personalities — CRUD for agent personalities + prompt bindings. + * + * RBAC inherits from `agents` (see `mapUrlToPermission` in main.ts) — only + * users who can `view/edit/create/delete:agents` can manage that agent's + * personalities. Personalities never escape their agent: there is no + * top-level `/api/v1/personalities` listing. + */ +import type { FastifyInstance } from 'fastify'; +import type { PersonalityService } from '../services/personality.service.js'; +import { NotFoundError, ConflictError } from '../services/mcp-server.service.js'; + +export function registerPersonalityRoutes( + app: FastifyInstance, + service: PersonalityService, +): void { + app.get<{ Params: { agentName: string } }>( + '/api/v1/agents/:agentName/personalities', + async (request, reply) => { + try { + return await service.listForAgent(request.params.agentName); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.post<{ Params: { agentName: string } }>( + '/api/v1/agents/:agentName/personalities', + async (request, reply) => { + try { + const personality = await service.create(request.params.agentName, request.body); + reply.code(201); + return personality; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + if (err instanceof ConflictError) { + reply.code(409); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.get<{ Params: { id: string } }>( + '/api/v1/personalities/:id', + async (request, reply) => { + try { + return await service.getById(request.params.id); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.put<{ Params: { id: string } }>( + '/api/v1/personalities/:id', + async (request, reply) => { + try { + return await service.update(request.params.id, request.body); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.delete<{ Params: { id: string } }>( + '/api/v1/personalities/:id', + async (request, reply) => { + try { + await service.delete(request.params.id); + reply.code(204); + return null; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + // ── Prompt bindings ── + + app.get<{ Params: { id: string } }>( + '/api/v1/personalities/:id/prompts', + async (request, reply) => { + try { + return await service.listBoundPrompts(request.params.id); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.post<{ Params: { id: string } }>( + '/api/v1/personalities/:id/prompts', + async (request, reply) => { + try { + const binding = await service.attachPrompt(request.params.id, request.body); + reply.code(201); + return binding; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + if (err instanceof ConflictError) { + reply.code(409); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.delete<{ Params: { id: string; promptId: string } }>( + '/api/v1/personalities/:id/prompts/:promptId', + async (request, reply) => { + try { + await service.detachPrompt(request.params.id, request.params.promptId); + reply.code(204); + return null; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); +} diff --git a/src/mcpd/src/routes/prompts.ts b/src/mcpd/src/routes/prompts.ts index 61ddf08..a8b6b85 100644 --- a/src/mcpd/src/routes/prompts.ts +++ b/src/mcpd/src/routes/prompts.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import type { Prompt } from '@prisma/client'; import type { PromptService } from '../services/prompt.service.js'; import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; type PromptWithLinkStatus = Prompt & { linkStatus: 'alive' | 'dead' | null }; @@ -56,6 +57,7 @@ export function registerPromptRoutes( app: FastifyInstance, service: PromptService, projectRepo: IProjectRepository, + agentRepo?: IAgentRepository, ): void { // ── Prompts (approved) ── @@ -85,7 +87,31 @@ export function registerPromptRoutes( }); app.post('/api/v1/prompts', async (request, reply) => { - const prompt = await service.createPrompt(request.body); + // Resolve `agent: ` and `project: ` to FK ids before + // handing off to the service. Mirrors the existing project-name + // resolution on `/api/v1/promptrequests`. + const body = request.body as Record; + const resolved: Record = { ...body }; + if (typeof body['project'] === 'string') { + const project = await projectRepo.findByName(body['project']); + if (!project) { + throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 }); + } + resolved['projectId'] = project.id; + delete resolved['project']; + } + if (typeof body['agent'] === 'string') { + if (!agentRepo) { + throw Object.assign(new Error('Agent-scoped prompts not enabled'), { statusCode: 500 }); + } + const agent = await agentRepo.findByName(body['agent']); + if (!agent) { + throw Object.assign(new Error(`Agent not found: ${body['agent']}`), { statusCode: 404 }); + } + resolved['agentId'] = agent.id; + delete resolved['agent']; + } + const prompt = await service.createPrompt(resolved); reply.code(201); return prompt; }); @@ -209,4 +235,26 @@ export function registerPromptRoutes( return req; }, ); + + // ── Agent-direct prompts ── + // + // Lists prompts whose `Prompt.agentId` matches the agent. These prompts + // are *not* in any project — they're "always-on" overlays for the agent, + // injected after agent.systemPrompt and before project prompts in the + // chat system block. + app.get<{ Params: { agentName: string } }>( + '/api/v1/agents/:agentName/prompts', + async (request, reply) => { + if (!agentRepo) { + throw Object.assign(new Error('Agent-scoped prompts not enabled'), { statusCode: 500 }); + } + const agent = await agentRepo.findByName(request.params.agentName); + if (!agent) { + reply.code(404); + return { error: `Agent not found: ${request.params.agentName}` }; + } + const prompts = await service.listPromptsForAgent(agent.id); + return enrichWithLinkStatus(prompts, projectRepo); + }, + ); } diff --git a/src/mcpd/src/services/chat.service.ts b/src/mcpd/src/services/chat.service.ts index 50a5015..fc375b7 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -30,6 +30,7 @@ import type { ChatRole, } from '../repositories/chat.repository.js'; import type { IPromptRepository } from '../repositories/prompt.repository.js'; +import type { IPersonalityRepository } from '../repositories/personality.repository.js'; import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js'; import type { AgentChatParams } from '../validation/agent.schema.js'; import { NotFoundError } from './mcp-server.service.js'; @@ -107,6 +108,13 @@ export interface ChatRequestArgs { messagesOverride?: OpenAiMessage[]; ownerId: string; params?: AgentChatParams; + /** + * Personality overlay for this turn. If set, the personality's bound + * prompts are appended to the system block (additive). If unset, falls + * back to `agent.defaultPersonalityId`. If neither is present, today's + * behavior (no personality overlay) holds. + */ + personalityName?: string; } export interface ChatResult { @@ -123,6 +131,7 @@ export class ChatService { private readonly chatRepo: IChatRepository, private readonly promptRepo: IPromptRepository, private readonly tools: ChatToolDispatcher, + private readonly personalities?: IPersonalityRepository, ) {} async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> { @@ -361,13 +370,28 @@ export class ChatService { const threadId = await this.resolveThreadId(args, agent.id); const projectId = agent.project?.id ?? null; + // Project prompts (existing): only those whose projectId actually matches + // the agent's project — `findAll(projectId)` also returns globals which + // we exclude here so they don't double-count if a future change adds an + // explicit "global" injection step. const projectPrompts = projectId !== null ? await this.promptRepo.findAll(projectId) : []; - const sortedPrompts = [...projectPrompts] + const sortedProjectPrompts = [...projectPrompts] .filter((p) => p.projectId === projectId) .sort((a, b) => b.priority - a.priority); + // Agent-direct prompts: always-on overlay scoped to this specific agent. + // Ordered after agent.systemPrompt and BEFORE project prompts so + // agent-specific tone/guardrails win over project-wide context. + const agentDirectPrompts = (await this.promptRepo.findByAgent(agent.id)) + .sort((a, b) => b.priority - a.priority); + + // Personality overlay: chooses by request-supplied name first, falling + // back to the agent's defaultPersonalityId. Without a personality this + // path is a no-op and the resulting block matches today's behavior. + const personalityPromptContents = await this.resolvePersonalityPrompts(args, agent); + const mergedParams: AgentChatParams = { ...(agent.defaultParams ?? {}), ...(args.params ?? {}), @@ -376,7 +400,9 @@ export class ChatService { const baseSystem = mergedParams.systemOverride ?? agent.systemPrompt; const systemBlock = [ baseSystem, - ...sortedPrompts.map((p) => p.content), + ...agentDirectPrompts.map((p) => p.content), + ...sortedProjectPrompts.map((p) => p.content), + ...personalityPromptContents, mergedParams.systemAppend ?? '', ] .filter((s) => s.length > 0) @@ -421,6 +447,40 @@ export class ChatService { }; } + /** + * Resolves a personality (request override → agent default) and returns + * its bound prompt contents in `PersonalityPrompt.priority` desc order. + * Returns `[]` when no personality is selected, when the personality + * repository is not wired (legacy callers), or when the named personality + * doesn't exist on this agent. The "doesn't exist" case throws — typos in + * a CLI flag should fail loudly, not silently fall back to no overlay. + */ + private async resolvePersonalityPrompts( + args: ChatRequestArgs, + agent: Awaited>, + ): Promise { + if (this.personalities === undefined) return []; + + let personalityId: string | null = null; + if (args.personalityName !== undefined && args.personalityName !== '') { + const named = await this.personalities.findByNameAndAgent(args.personalityName, agent.id); + if (named === null) { + throw new NotFoundError( + `Personality not found on agent ${agent.name}: ${args.personalityName}`, + ); + } + personalityId = named.id; + } else if (agent.defaultPersonality !== null) { + personalityId = agent.defaultPersonality.id; + } + if (personalityId === null) return []; + + const bindings = await this.personalities.listPrompts(personalityId); + return [...bindings] + .sort((a, b) => b.priority - a.priority) + .map((b) => b.prompt.content); + } + private async resolveThreadId(args: ChatRequestArgs, agentId: string): Promise { if (args.threadId !== undefined) { const existing = await this.chatRepo.findThread(args.threadId); diff --git a/src/mcpd/src/validation/agent.schema.ts b/src/mcpd/src/validation/agent.schema.ts index 5f253c0..2710598 100644 --- a/src/mcpd/src/validation/agent.schema.ts +++ b/src/mcpd/src/validation/agent.schema.ts @@ -114,6 +114,12 @@ export const AgentChatRequestSchema = AgentChatParamsSchema.merge( ) .optional(), stream: z.boolean().optional(), + /** + * Optional personality overlay for this turn. Looked up by name on the + * agent's own personality set (per-agent unique). Falls back to the + * agent's `defaultPersonalityId` when omitted. + */ + personality: z.string().min(1).optional(), }), ).strict().refine((v) => v.message !== undefined || (v.messages?.length ?? 0) > 0, { message: 'Either `message` or `messages` is required', diff --git a/src/mcpd/tests/chat-service.test.ts b/src/mcpd/tests/chat-service.test.ts index c7d8ee8..fe62c3c 100644 --- a/src/mcpd/tests/chat-service.test.ts +++ b/src/mcpd/tests/chat-service.test.ts @@ -6,7 +6,8 @@ import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js'; import type { LlmAdapter, NonStreamingResult, InferContext } from '../src/services/llm/types.js'; import type { IChatRepository } from '../src/repositories/chat.repository.js'; import type { IPromptRepository } from '../src/repositories/prompt.repository.js'; -import type { ChatMessage, ChatThread, Prompt } from '@prisma/client'; +import type { IPersonalityRepository } from '../src/repositories/personality.repository.js'; +import type { ChatMessage, ChatThread, Prompt, Personality, PersonalityPrompt } from '@prisma/client'; const NOW = new Date(); @@ -76,9 +77,11 @@ function mockChatRepo(): IChatRepository & { _msgs: ChatMessage[]; _threads: Cha function mockPromptRepo(rows: Prompt[] = []): IPromptRepository { return { findAll: vi.fn(async () => rows), - findGlobal: vi.fn(async () => rows.filter((p) => p.projectId === null)), + findGlobal: vi.fn(async () => rows.filter((p) => p.projectId === null && p.agentId === null)), + findByAgent: vi.fn(async (agentId: string) => rows.filter((p) => p.agentId === agentId)), findById: vi.fn(async (id: string) => rows.find((p) => p.id === id) ?? null), findByNameAndProject: vi.fn(async () => null), + findByNameAndAgent: vi.fn(async () => null), create: vi.fn(), update: vi.fn(), delete: vi.fn(), @@ -92,7 +95,7 @@ function mockTools(impl: Partial = {}): ChatToolDispatcher { }; } -function mockAgents(): AgentService { +function mockAgents(opts: { defaultPersonality?: { id: string; name: string } | null } = {}): AgentService { return { getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, @@ -103,6 +106,7 @@ function mockAgents(): AgentService { project: name === 'no-project' ? null : { id: 'proj-1', name: 'mcpctl-dev' }, + defaultPersonality: opts.defaultPersonality ?? null, proxyModelName: null, defaultParams: { temperature: 0.5 }, extras: {}, @@ -567,4 +571,210 @@ describe('ChatService', () => { await expect(svc.listMessages('cnonexistent000000000000000', 'alice')) .rejects.toThrow(/not found/i); }); + + // ── Agent-direct prompts + personality overlay (Stage 3 system block) ── + + it('injects agent-direct prompts BETWEEN agent.systemPrompt and project prompts', async () => { + const chatRepo = mockChatRepo(); + const adapter = scriptedAdapter([chatCompletion('ok')]); + const inferSpy = adapter.infer as ReturnType; + const prompts: Prompt[] = [ + // Project prompt + { + id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT', + projectId: 'proj-1', agentId: null, priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }, + // Agent-direct prompt + { + id: 'p-direct', name: 'direct', content: 'AGENT_DIRECT_TEXT', + projectId: null, agentId: 'agent-reviewer', priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }, + ]; + const svc = new ChatService( + mockAgents(), mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo(prompts), mockTools(), + ); + await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }); + const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); + const text = sys?.content as string; + expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('AGENT_DIRECT_TEXT')); + expect(text.indexOf('AGENT_DIRECT_TEXT')).toBeLessThan(text.indexOf('PROJECT_TEXT')); + }); + + it('appends personality-bound prompts after project prompts when --personality is passed', async () => { + const chatRepo = mockChatRepo(); + const adapter = scriptedAdapter([chatCompletion('ok')]); + const inferSpy = adapter.infer as ReturnType; + const projectPrompt: Prompt = { + id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT', + projectId: 'proj-1', agentId: null, priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }; + const personalityPrompt: Prompt = { + id: 'p-pers', name: 'pers', content: 'PERSONALITY_TEXT', + projectId: null, agentId: null, priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }; + + const personalities = mockPersonalityRepo({ + 'pers-grumpy': { + personality: makePersonality({ id: 'pers-grumpy', name: 'grumpy', agentId: 'agent-reviewer' }), + bindings: [{ promptId: personalityPrompt.id, priority: 5 }], + }, + }, [projectPrompt, personalityPrompt]); + + const svc = new ChatService( + mockAgents(), mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo([projectPrompt, personalityPrompt]), mockTools(), + personalities, + ); + await svc.chat({ + agentName: 'reviewer', + userMessage: 'hi', + ownerId: 'owner-1', + personalityName: 'grumpy', + }); + const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); + const text = sys?.content as string; + expect(text.indexOf('PROJECT_TEXT')).toBeLessThan(text.indexOf('PERSONALITY_TEXT')); + }); + + it('falls back to agent.defaultPersonality when --personality is omitted', async () => { + const chatRepo = mockChatRepo(); + const adapter = scriptedAdapter([chatCompletion('ok')]); + const inferSpy = adapter.infer as ReturnType; + const personalityPrompt: Prompt = { + id: 'p-pers', name: 'pers', content: 'DEFAULT_PERSONALITY_TEXT', + projectId: null, agentId: null, priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }; + const personalities = mockPersonalityRepo({ + 'pers-default': { + personality: makePersonality({ id: 'pers-default', name: 'default', agentId: 'agent-reviewer' }), + bindings: [{ promptId: personalityPrompt.id, priority: 5 }], + }, + }, [personalityPrompt]); + + const svc = new ChatService( + mockAgents({ defaultPersonality: { id: 'pers-default', name: 'default' } }), + mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo([personalityPrompt]), mockTools(), + personalities, + ); + await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }); + const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); + expect(sys?.content as string).toContain('DEFAULT_PERSONALITY_TEXT'); + }); + + it('throws when --personality references a name the agent does not own', async () => { + const chatRepo = mockChatRepo(); + const adapter = scriptedAdapter([chatCompletion('ok')]); + const personalities = mockPersonalityRepo({}); + + const svc = new ChatService( + mockAgents(), mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo(), mockTools(), + personalities, + ); + await expect(svc.chat({ + agentName: 'reviewer', + userMessage: 'hi', + ownerId: 'owner-1', + personalityName: 'ghost', + })).rejects.toThrow(/Personality not found/); + }); + + it('preserves today\'s system block when no personality and no agent-direct prompts exist', async () => { + // Regression guard: backwards-compatible by construction. + const chatRepo = mockChatRepo(); + const adapter = scriptedAdapter([chatCompletion('ok')]); + const inferSpy = adapter.infer as ReturnType; + const projectPrompt: Prompt = { + id: 'p-proj', name: 'proj', content: 'ONLY_PROJECT_TEXT', + projectId: 'proj-1', agentId: null, priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }; + const svc = new ChatService( + mockAgents(), mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo([projectPrompt]), mockTools(), + ); + await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }); + const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); + const text = sys?.content as string; + expect(text).toContain('You are a helpful agent.'); + expect(text).toContain('ONLY_PROJECT_TEXT'); + }); }); + +// ── Helpers for personality-overlay tests ── + +function makePersonality(overrides: Partial = {}): Personality { + return { + id: `pers-${Math.random().toString(36).slice(2, 8)}`, + name: 'p', + description: '', + agentId: 'agent-reviewer', + priority: 5, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +interface MockPersonalityFixture { + personality: Personality; + bindings: Array<{ promptId: string; priority: number }>; +} + +function mockPersonalityRepo( + fixtures: Record, + prompts: Prompt[] = [], +): IPersonalityRepository { + const byId = new Map(Object.entries(fixtures)); + const promptsById = new Map(prompts.map((p) => [p.id, p])); + return { + findAll: vi.fn(async () => [...byId.values()].map((f) => f.personality)), + findByAgent: vi.fn(async (agentId: string) => + [...byId.values()].filter((f) => f.personality.agentId === agentId).map((f) => f.personality)), + findById: vi.fn(async (id: string) => byId.get(id)?.personality ?? null), + findByNameAndAgent: vi.fn(async (name: string, agentId: string) => { + for (const f of byId.values()) { + if (f.personality.name === name && f.personality.agentId === agentId) { + return f.personality; + } + } + return null; + }), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + listPrompts: vi.fn(async (personalityId: string) => { + const fixture = byId.get(personalityId); + if (!fixture) return []; + return fixture.bindings.map((b) => ({ + id: `bind-${b.promptId}`, + personalityId, + promptId: b.promptId, + priority: b.priority, + createdAt: NOW, + prompt: promptsById.get(b.promptId) ?? ({ + id: b.promptId, name: 'p', content: '', + projectId: null, agentId: null, priority: b.priority, + summary: null, chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + } as Prompt), + })); + }), + attachPrompt: vi.fn(), + detachPrompt: vi.fn(), + findBinding: vi.fn(async () => null), + }; +}