feat(db): add personalities + agent-direct prompts schema (Stage 1)
A Personality is a named overlay on top of an Agent — same agent,
same LLM, but a different bundle of prompts injected into the system
block at chat time. VLAN-on-ethernet semantics: ethernet still works
without VLAN; with a VLAN tag, frames are segmented but still ethernet.
Schema additions:
- Prompt.agentId (nullable FK + index, cascade on delete) so prompts
can attach directly to an agent without going through a project.
- Personality { id, name, description, agentId, priority } with
unique (name, agentId).
- PersonalityPrompt join table with per-binding priority override.
- Agent.defaultPersonalityId (SetNull on delete) so an agent can pick
one personality as the default when no --personality flag is passed.
Backwards-compatible by construction: every new column is nullable;
existing rows are valid as-is; the chat.service systemBlock changes
land in Stage 3.
8 new prisma-level assertions in agent-schema.test.ts cover unique
constraints, cascade behavior, the SetNull on defaultPersonalityId,
and shared-prompt-across-personalities. All 16 db tests pass; mcpd
typecheck + 777 mcpd unit tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -201,4 +201,146 @@ describe('agent / chat-thread / chat-message schema', () => {
|
||||
});
|
||||
expect(ordered.map((t) => t.id)).toEqual([t2.id, t1.id]);
|
||||
});
|
||||
|
||||
// ── Agent-direct prompts (Prompt.agentId) ──
|
||||
|
||||
it('attaches a prompt directly to an agent (no project)', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-agent-prompt');
|
||||
const agent = await makeAgent({ name: 'directprompt', llmId: llm.id, ownerId: user.id });
|
||||
|
||||
const prompt = await prisma.prompt.create({
|
||||
data: { name: 'agent-only', content: 'hi from agent', agentId: agent.id },
|
||||
});
|
||||
expect(prompt.agentId).toBe(agent.id);
|
||||
expect(prompt.projectId).toBeNull();
|
||||
});
|
||||
|
||||
it('cascade-deletes agent-direct prompts when the agent is removed', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-agent-prompt-cascade');
|
||||
const agent = await makeAgent({ name: 'goingaway', llmId: llm.id, ownerId: user.id });
|
||||
await prisma.prompt.create({
|
||||
data: { name: 'p1', content: 'c', agentId: agent.id },
|
||||
});
|
||||
|
||||
await prisma.agent.delete({ where: { id: agent.id } });
|
||||
|
||||
expect(await prisma.prompt.count({ where: { agentId: agent.id } })).toBe(0);
|
||||
});
|
||||
|
||||
it('enforces (name, agentId) uniqueness independently of (name, projectId)', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-uniq-prompt-agent');
|
||||
const agent = await makeAgent({ name: 'has-prompts', llmId: llm.id, ownerId: user.id });
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: { name: 'shared', content: 'a', agentId: agent.id },
|
||||
});
|
||||
await expect(
|
||||
prisma.prompt.create({
|
||||
data: { name: 'shared', content: 'b', agentId: agent.id },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Same name on a different agent is fine.
|
||||
const agent2 = await makeAgent({ name: 'other-agent', llmId: llm.id, ownerId: user.id });
|
||||
const ok = await prisma.prompt.create({
|
||||
data: { name: 'shared', content: 'c', agentId: agent2.id },
|
||||
});
|
||||
expect(ok.id).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Personalities ──
|
||||
|
||||
it('creates a personality bound to an agent', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-pers-1');
|
||||
const agent = await makeAgent({ name: 'with-pers', llmId: llm.id, ownerId: user.id });
|
||||
|
||||
const p = await prisma.personality.create({
|
||||
data: { name: 'grumpy', description: 'curmudgeonly', agentId: agent.id },
|
||||
});
|
||||
expect(p.id).toBeDefined();
|
||||
expect(p.priority).toBe(5);
|
||||
});
|
||||
|
||||
it('enforces (name, agentId) uniqueness on personalities', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-pers-uniq');
|
||||
const agent = await makeAgent({ name: 'pers-uniq', llmId: llm.id, ownerId: user.id });
|
||||
|
||||
await prisma.personality.create({ data: { name: 'mode', agentId: agent.id } });
|
||||
await expect(
|
||||
prisma.personality.create({ data: { name: 'mode', agentId: agent.id } }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('cascade-deletes personalities + bindings when the agent is removed', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-pers-cascade');
|
||||
const agent = await makeAgent({ name: 'kaboom', llmId: llm.id, ownerId: user.id });
|
||||
|
||||
const personality = await prisma.personality.create({
|
||||
data: { name: 'snarky', agentId: agent.id },
|
||||
});
|
||||
const prompt = await prisma.prompt.create({
|
||||
data: { name: 'tone', content: 'be snarky', agentId: agent.id },
|
||||
});
|
||||
await prisma.personalityPrompt.create({
|
||||
data: { personalityId: personality.id, promptId: prompt.id },
|
||||
});
|
||||
|
||||
await prisma.agent.delete({ where: { id: agent.id } });
|
||||
|
||||
expect(await prisma.personality.count({ where: { id: personality.id } })).toBe(0);
|
||||
expect(await prisma.personalityPrompt.count({ where: { personalityId: personality.id } })).toBe(0);
|
||||
// Prompts directly attached to the agent also go via Prompt.agentId cascade.
|
||||
expect(await prisma.prompt.count({ where: { id: prompt.id } })).toBe(0);
|
||||
});
|
||||
|
||||
it('SetNull on Agent.defaultPersonalityId when the personality is deleted', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-default-pers');
|
||||
const agent = await makeAgent({ name: 'has-default', llmId: llm.id, ownerId: user.id });
|
||||
const personality = await prisma.personality.create({
|
||||
data: { name: 'def', agentId: agent.id },
|
||||
});
|
||||
await prisma.agent.update({
|
||||
where: { id: agent.id },
|
||||
data: { defaultPersonalityId: personality.id },
|
||||
});
|
||||
|
||||
await prisma.personality.delete({ where: { id: personality.id } });
|
||||
|
||||
const reloaded = await prisma.agent.findUnique({ where: { id: agent.id } });
|
||||
expect(reloaded?.defaultPersonalityId).toBeNull();
|
||||
});
|
||||
|
||||
it('binds the same prompt to multiple personalities of an agent', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-shared-prompt');
|
||||
const agent = await makeAgent({ name: 'shared', llmId: llm.id, ownerId: user.id });
|
||||
const p1 = await prisma.personality.create({ data: { name: 'a', agentId: agent.id } });
|
||||
const p2 = await prisma.personality.create({ data: { name: 'b', agentId: agent.id } });
|
||||
const prompt = await prisma.prompt.create({
|
||||
data: { name: 'shared-text', content: 'reusable', agentId: agent.id },
|
||||
});
|
||||
|
||||
await prisma.personalityPrompt.create({
|
||||
data: { personalityId: p1.id, promptId: prompt.id, priority: 9 },
|
||||
});
|
||||
await prisma.personalityPrompt.create({
|
||||
data: { personalityId: p2.id, promptId: prompt.id, priority: 1 },
|
||||
});
|
||||
|
||||
expect(await prisma.personalityPrompt.count({ where: { promptId: prompt.id } })).toBe(2);
|
||||
|
||||
// Same (personalityId, promptId) cannot collide.
|
||||
await expect(
|
||||
prisma.personalityPrompt.create({
|
||||
data: { personalityId: p1.id, promptId: prompt.id },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user