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