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:
337
src/mcpd/tests/personality-service.test.ts
Normal file
337
src/mcpd/tests/personality-service.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
286
src/mcpd/tests/services/prompt-agent-scope.test.ts
Normal file
286
src/mcpd/tests/services/prompt-agent-scope.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user