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>
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
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/);
|
|
});
|
|
});
|