feat: gated project experience & prompt intelligence
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

Implements the full gated session flow and prompt intelligence system:

- Prisma schema: add gated, priority, summary, chapters, linkTarget fields
- Session gate: state machine (gated → begin_session → ungated) with LLM-powered
  tool selection based on prompt index
- Tag matcher: intelligent prompt-to-tool matching with project/server/action tags
- LLM selector: tiered provider selection (fast for gating, heavy for complex tasks)
- Link resolver: cross-project MCP resource references (project/server:uri format)
- Prompt summary service: LLM-generated summaries and chapter extraction
- System project bootstrap: ensures default project exists on startup
- Structural link health checks: enrichWithLinkStatus on prompt GET endpoints
- CLI: create prompt --priority/--link, create project --gated/--no-gated,
  describe project shows prompts section, get prompts shows PRI/LINK/STATUS
- Apply/edit: priority, linkTarget, gated fields supported
- Shell completions: fish updated with new flags
- 1,253 tests passing across all packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-25 23:22:42 +00:00
parent 62647a7f90
commit 705df06996
46 changed files with 4946 additions and 105 deletions

View File

@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { bootstrapSystemProject, SYSTEM_PROJECT_NAME, SYSTEM_OWNER_ID, getSystemPromptNames } from '../src/bootstrap/system-project.js';
import type { PrismaClient } from '@prisma/client';
function mockPrisma(): PrismaClient {
const prompts = new Map<string, { id: string; name: string; projectId: string }>();
let promptIdCounter = 1;
return {
project: {
upsert: vi.fn(async (args: { where: { name: string }; create: Record<string, unknown>; update: Record<string, unknown> }) => ({
id: 'sys-proj-id',
name: args.where.name,
...args.create,
})),
},
prompt: {
findFirst: vi.fn(async (args: { where: { name: string; projectId: string } }) => {
return prompts.get(`${args.where.projectId}:${args.where.name}`) ?? null;
}),
create: vi.fn(async (args: { data: { name: string; content: string; priority: number; projectId: string } }) => {
const id = `prompt-${promptIdCounter++}`;
const prompt = { id, ...args.data };
prompts.set(`${args.data.projectId}:${args.data.name}`, prompt);
return prompt;
}),
},
} as unknown as PrismaClient;
}
describe('bootstrapSystemProject', () => {
let prisma: PrismaClient;
beforeEach(() => {
prisma = mockPrisma();
});
it('creates the mcpctl-system project via upsert', async () => {
await bootstrapSystemProject(prisma);
expect(prisma.project.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { name: SYSTEM_PROJECT_NAME },
create: expect.objectContaining({
name: SYSTEM_PROJECT_NAME,
ownerId: SYSTEM_OWNER_ID,
gated: false,
}),
update: {},
}),
);
});
it('creates all system prompts', async () => {
await bootstrapSystemProject(prisma);
const expectedNames = getSystemPromptNames();
expect(expectedNames.length).toBeGreaterThanOrEqual(4);
for (const name of expectedNames) {
expect(prisma.prompt.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { name, projectId: 'sys-proj-id' },
}),
);
}
expect(prisma.prompt.create).toHaveBeenCalledTimes(expectedNames.length);
});
it('creates system prompts with priority 10', async () => {
await bootstrapSystemProject(prisma);
const createCalls = vi.mocked(prisma.prompt.create).mock.calls;
for (const call of createCalls) {
const data = (call[0] as { data: { priority: number } }).data;
expect(data.priority).toBe(10);
}
});
it('does not re-create existing prompts (idempotent)', async () => {
// First call creates everything
await bootstrapSystemProject(prisma);
const firstCallCount = vi.mocked(prisma.prompt.create).mock.calls.length;
// Second call — prompts already exist in mock, should not create again
await bootstrapSystemProject(prisma);
// create should not have been called additional times
expect(vi.mocked(prisma.prompt.create).mock.calls.length).toBe(firstCallCount);
});
it('re-creates deleted prompts on subsequent startup', async () => {
// First run creates everything
await bootstrapSystemProject(prisma);
// Simulate deletion: clear the map so findFirst returns null
vi.mocked(prisma.prompt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.prompt.create).mockClear();
// Second run should recreate
await bootstrapSystemProject(prisma);
const expectedNames = getSystemPromptNames();
expect(vi.mocked(prisma.prompt.create).mock.calls.length).toBe(expectedNames.length);
});
it('system project has gated=false', async () => {
await bootstrapSystemProject(prisma);
const upsertCall = vi.mocked(prisma.project.upsert).mock.calls[0]![0];
expect((upsertCall as { create: { gated: boolean } }).create.gated).toBe(false);
});
});
describe('getSystemPromptNames', () => {
it('returns all system prompt names', () => {
const names = getSystemPromptNames();
expect(names).toContain('gate-instructions');
expect(names).toContain('gate-encouragement');
expect(names).toContain('gate-intercept-preamble');
expect(names).toContain('session-greeting');
});
});

View File

@@ -16,6 +16,7 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
description: '',
ownerId: 'user-1',
proxyMode: 'direct',
gated: true,
llmProvider: null,
llmModel: null,
version: 1,

View File

@@ -12,6 +12,7 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
description: '',
ownerId: 'user-1',
proxyMode: 'direct',
gated: true,
llmProvider: null,
llmModel: null,
version: 1,

View File

@@ -0,0 +1,508 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerPromptRoutes } from '../src/routes/prompts.js';
import { PromptService } from '../src/services/prompt.service.js';
import { errorHandler } from '../src/middleware/error-handler.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 { Prompt, PromptRequest, Project } from '@prisma/client';
let app: FastifyInstance;
function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
return {
id: 'prompt-1',
name: 'test-prompt',
content: 'Hello world',
projectId: null,
priority: 5,
summary: null,
chapters: null,
linkTarget: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makePromptRequest(overrides: Partial<PromptRequest> = {}): PromptRequest {
return {
id: 'req-1',
name: 'test-request',
content: 'Proposed content',
projectId: null,
priority: 5,
createdBySession: 'session-abc',
createdByUserId: null,
createdAt: new Date(),
...overrides,
};
}
function makeProject(overrides: Partial<Project> = {}): Project {
return {
id: 'proj-1',
name: 'homeautomation',
description: '',
prompt: '',
proxyMode: 'direct',
gated: true,
llmProvider: null,
llmModel: null,
ownerId: 'user-1',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as Project;
}
function mockPromptRepo(): IPromptRepository {
return {
findAll: vi.fn(async () => []),
findGlobal: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByNameAndProject: vi.fn(async () => null),
create: vi.fn(async (data) => makePrompt(data)),
update: vi.fn(async (id, data) => makePrompt({ id, ...data })),
delete: vi.fn(async () => {}),
};
}
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(async (data) => makePromptRequest(data)),
update: vi.fn(async (id, data) => makePromptRequest({ id, ...data })),
delete: vi.fn(async () => {}),
};
}
function makeProjectWithServers(
overrides: Partial<Project> = {},
serverNames: string[] = [],
) {
return {
...makeProject(overrides),
servers: serverNames.map((name, i) => ({
id: `ps-${i}`,
projectId: overrides.id ?? 'proj-1',
serverId: `srv-${i}`,
server: { id: `srv-${i}`, name },
})),
};
}
function mockProjectRepo(): IProjectRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => makeProject(data)),
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<Project> })),
delete: vi.fn(async () => {}),
};
}
afterEach(async () => {
if (app) await app.close();
});
function buildApp(opts?: {
promptRepo?: IPromptRepository;
promptRequestRepo?: IPromptRequestRepository;
projectRepo?: IProjectRepository;
}) {
const promptRepo = opts?.promptRepo ?? mockPromptRepo();
const promptRequestRepo = opts?.promptRequestRepo ?? mockPromptRequestRepo();
const projectRepo = opts?.projectRepo ?? mockProjectRepo();
const service = new PromptService(promptRepo, promptRequestRepo, projectRepo);
app = Fastify();
app.setErrorHandler(errorHandler);
registerPromptRoutes(app, service, projectRepo);
return { app, promptRepo, promptRequestRepo, projectRepo, service };
}
describe('Prompt routes', () => {
describe('GET /api/v1/prompts', () => {
it('returns all prompts without project filter', async () => {
const promptRepo = mockPromptRepo();
const globalPrompt = makePrompt({ id: 'p-1', name: 'global-rule', projectId: null });
const scopedPrompt = makePrompt({ id: 'p-2', name: 'scoped-rule', projectId: 'proj-1' });
vi.mocked(promptRepo.findAll).mockResolvedValue([globalPrompt, scopedPrompt]);
const { app: a } = buildApp({ promptRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
expect(res.statusCode).toBe(200);
const body = res.json() as Prompt[];
expect(body).toHaveLength(2);
expect(promptRepo.findAll).toHaveBeenCalledWith(undefined);
});
it('filters by project name when ?project= is given', async () => {
const promptRepo = mockPromptRepo();
const projectRepo = mockProjectRepo();
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1', name: 'homeautomation' }));
vi.mocked(promptRepo.findAll).mockResolvedValue([
makePrompt({ id: 'p-1', name: 'ha-rule', projectId: 'proj-1' }),
makePrompt({ id: 'p-2', name: 'global-rule', projectId: null }),
]);
const { app: a } = buildApp({ promptRepo, projectRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?project=homeautomation' });
expect(res.statusCode).toBe(200);
expect(projectRepo.findByName).toHaveBeenCalledWith('homeautomation');
expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1');
});
it('returns only global prompts when ?scope=global', async () => {
const promptRepo = mockPromptRepo();
const globalOnly = [makePrompt({ id: 'p-g', name: 'global-rule', projectId: null })];
vi.mocked(promptRepo.findGlobal).mockResolvedValue(globalOnly);
const { app: a } = buildApp({ promptRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?scope=global' });
expect(res.statusCode).toBe(200);
const body = res.json() as Prompt[];
expect(body).toHaveLength(1);
expect(promptRepo.findGlobal).toHaveBeenCalled();
expect(promptRepo.findAll).not.toHaveBeenCalled();
});
it('returns 404 when ?project= references unknown project', async () => {
const { app: a } = buildApp();
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?project=nonexistent' });
expect(res.statusCode).toBe(404);
const body = res.json() as { error: string };
expect(body.error).toContain('Project not found');
});
});
describe('GET /api/v1/promptrequests', () => {
it('returns all prompt requests without project filter', async () => {
const promptRequestRepo = mockPromptRequestRepo();
vi.mocked(promptRequestRepo.findAll).mockResolvedValue([
makePromptRequest({ id: 'r-1', name: 'req-a' }),
]);
const { app: a } = buildApp({ promptRequestRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests' });
expect(res.statusCode).toBe(200);
expect(promptRequestRepo.findAll).toHaveBeenCalledWith(undefined);
});
it('returns only global prompt requests when ?scope=global', async () => {
const promptRequestRepo = mockPromptRequestRepo();
vi.mocked(promptRequestRepo.findGlobal).mockResolvedValue([]);
const { app: a } = buildApp({ promptRequestRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?scope=global' });
expect(res.statusCode).toBe(200);
expect(promptRequestRepo.findGlobal).toHaveBeenCalled();
expect(promptRequestRepo.findAll).not.toHaveBeenCalled();
});
it('filters by project name when ?project= is given', async () => {
const promptRequestRepo = mockPromptRequestRepo();
const projectRepo = mockProjectRepo();
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1' }));
const { app: a } = buildApp({ promptRequestRepo, projectRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?project=homeautomation' });
expect(res.statusCode).toBe(200);
expect(promptRequestRepo.findAll).toHaveBeenCalledWith('proj-1');
});
it('returns 404 for unknown project on promptrequests', async () => {
const { app: a } = buildApp();
const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?project=nope' });
expect(res.statusCode).toBe(404);
});
});
describe('POST /api/v1/promptrequests', () => {
it('creates a global prompt request (no project)', async () => {
const promptRequestRepo = mockPromptRequestRepo();
const { app: a } = buildApp({ promptRequestRepo });
const res = await a.inject({
method: 'POST',
url: '/api/v1/promptrequests',
payload: { name: 'global-req', content: 'some content' },
});
expect(res.statusCode).toBe(201);
expect(promptRequestRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'global-req', content: 'some content' }),
);
});
it('resolves project name to ID when project given', async () => {
const promptRequestRepo = mockPromptRequestRepo();
const projectRepo = mockProjectRepo();
const proj = makeProject({ id: 'proj-1', name: 'myproj' });
vi.mocked(projectRepo.findByName).mockResolvedValue(proj);
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
const { app: a } = buildApp({ promptRequestRepo, projectRepo });
const res = await a.inject({
method: 'POST',
url: '/api/v1/promptrequests',
payload: { name: 'scoped-req', content: 'text', project: 'myproj' },
});
expect(res.statusCode).toBe(201);
expect(projectRepo.findByName).toHaveBeenCalledWith('myproj');
expect(promptRequestRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'scoped-req', projectId: 'proj-1' }),
);
});
it('returns 404 for unknown project name', async () => {
const { app: a } = buildApp();
const res = await a.inject({
method: 'POST',
url: '/api/v1/promptrequests',
payload: { name: 'bad-req', content: 'x', project: 'nope' },
});
expect(res.statusCode).toBe(404);
});
});
describe('POST /api/v1/promptrequests/:id/approve', () => {
it('atomically approves a prompt request', async () => {
const promptRequestRepo = mockPromptRequestRepo();
const promptRepo = mockPromptRepo();
const req = makePromptRequest({ id: 'req-1', name: 'my-rule', projectId: 'proj-1' });
vi.mocked(promptRequestRepo.findById).mockResolvedValue(req);
const { app: a } = buildApp({ promptRepo, promptRequestRepo });
const res = await a.inject({ method: 'POST', url: '/api/v1/promptrequests/req-1/approve' });
expect(res.statusCode).toBe(200);
expect(promptRepo.create).toHaveBeenCalledWith({
name: 'my-rule',
content: 'Proposed content',
projectId: 'proj-1',
});
expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1');
});
});
describe('Security: projectId tampering', () => {
it('rejects projectId in prompt update payload', async () => {
const promptRepo = mockPromptRepo();
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ id: 'p-1', projectId: 'proj-a' }));
const { app: a } = buildApp({ promptRepo });
const res = await a.inject({
method: 'PUT',
url: '/api/v1/prompts/p-1',
payload: { content: 'new content', projectId: 'proj-evil' },
});
// Should succeed but ignore projectId — UpdatePromptSchema strips it
expect(res.statusCode).toBe(200);
expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content' });
// projectId must NOT be in the update call
const updateArg = vi.mocked(promptRepo.update).mock.calls[0]![1];
expect(updateArg).not.toHaveProperty('projectId');
});
it('rejects projectId in promptrequest update payload', async () => {
const promptRequestRepo = mockPromptRequestRepo();
vi.mocked(promptRequestRepo.findById).mockResolvedValue(makePromptRequest({ id: 'r-1', projectId: 'proj-a' }));
const { app: a } = buildApp({ promptRequestRepo });
const res = await a.inject({
method: 'PUT',
url: '/api/v1/promptrequests/r-1',
payload: { content: 'new content', projectId: 'proj-evil' },
});
expect(res.statusCode).toBe(200);
expect(promptRequestRepo.update).toHaveBeenCalledWith('r-1', { content: 'new content' });
const updateArg = vi.mocked(promptRequestRepo.update).mock.calls[0]![1];
expect(updateArg).not.toHaveProperty('projectId');
});
});
describe('linkStatus enrichment', () => {
it('returns linkStatus=null for non-linked prompts', async () => {
const promptRepo = mockPromptRepo();
vi.mocked(promptRepo.findAll).mockResolvedValue([
makePrompt({ id: 'p-1', name: 'plain', linkTarget: null }),
]);
const { app: a } = buildApp({ promptRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
expect(res.statusCode).toBe(200);
const body = res.json() as Array<{ linkStatus: string | null }>;
expect(body[0]!.linkStatus).toBeNull();
});
it('returns linkStatus=alive when project and server exist', async () => {
const promptRepo = mockPromptRepo();
const projectRepo = mockProjectRepo();
vi.mocked(promptRepo.findAll).mockResolvedValue([
makePrompt({ id: 'p-1', name: 'linked', linkTarget: 'source-proj/docmost-mcp:docmost://pages/abc' }),
]);
vi.mocked(projectRepo.findByName).mockImplementation(async (name) => {
if (name === 'source-proj') {
return makeProjectWithServers({ id: 'sp-1', name: 'source-proj' }, ['docmost-mcp']) as never;
}
return null;
});
const { app: a } = buildApp({ promptRepo, projectRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
expect(res.statusCode).toBe(200);
const body = res.json() as Array<{ linkStatus: string }>;
expect(body[0]!.linkStatus).toBe('alive');
});
it('returns linkStatus=dead when source project not found', async () => {
const promptRepo = mockPromptRepo();
vi.mocked(promptRepo.findAll).mockResolvedValue([
makePrompt({ id: 'p-1', name: 'broken', linkTarget: 'missing-proj/srv:some://uri' }),
]);
const { app: a } = buildApp({ promptRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
expect(res.statusCode).toBe(200);
const body = res.json() as Array<{ linkStatus: string }>;
expect(body[0]!.linkStatus).toBe('dead');
});
it('returns linkStatus=dead when server not in project', async () => {
const promptRepo = mockPromptRepo();
const projectRepo = mockProjectRepo();
vi.mocked(promptRepo.findAll).mockResolvedValue([
makePrompt({ id: 'p-1', name: 'wrong-srv', linkTarget: 'proj/wrong-server:some://uri' }),
]);
vi.mocked(projectRepo.findByName).mockResolvedValue(
makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['other-server']) as never,
);
const { app: a } = buildApp({ promptRepo, projectRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
expect(res.statusCode).toBe(200);
const body = res.json() as Array<{ linkStatus: string }>;
expect(body[0]!.linkStatus).toBe('dead');
});
it('enriches single prompt GET with linkStatus', async () => {
const promptRepo = mockPromptRepo();
const projectRepo = mockProjectRepo();
vi.mocked(promptRepo.findById).mockResolvedValue(
makePrompt({ id: 'p-1', name: 'linked', linkTarget: 'proj/srv:some://uri' }),
);
vi.mocked(projectRepo.findByName).mockResolvedValue(
makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['srv']) as never,
);
const { app: a } = buildApp({ promptRepo, projectRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts/p-1' });
expect(res.statusCode).toBe(200);
const body = res.json() as { linkStatus: string };
expect(body.linkStatus).toBe('alive');
});
it('caches project lookup for multiple linked prompts', async () => {
const promptRepo = mockPromptRepo();
const projectRepo = mockProjectRepo();
vi.mocked(promptRepo.findAll).mockResolvedValue([
makePrompt({ id: 'p-1', name: 'link-a', linkTarget: 'proj/srv:uri-a' }),
makePrompt({ id: 'p-2', name: 'link-b', linkTarget: 'proj/srv:uri-b' }),
]);
vi.mocked(projectRepo.findByName).mockResolvedValue(
makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['srv']) as never,
);
const { app: a } = buildApp({ promptRepo, projectRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });
expect(res.statusCode).toBe(200);
const body = res.json() as Array<{ linkStatus: string }>;
expect(body).toHaveLength(2);
expect(body[0]!.linkStatus).toBe('alive');
expect(body[1]!.linkStatus).toBe('alive');
// Should only call findByName once (cached)
expect(projectRepo.findByName).toHaveBeenCalledTimes(1);
});
it('supports ?projectId= query parameter', async () => {
const promptRepo = mockPromptRepo();
vi.mocked(promptRepo.findAll).mockResolvedValue([
makePrompt({ id: 'p-1', name: 'scoped', projectId: 'proj-1' }),
]);
const { app: a } = buildApp({ promptRepo });
const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?projectId=proj-1' });
expect(res.statusCode).toBe(200);
expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1');
});
});
describe('GET /api/v1/projects/:name/prompts/visible', () => {
it('returns approved prompts + session pending requests', async () => {
const promptRepo = mockPromptRepo();
const promptRequestRepo = mockPromptRequestRepo();
const projectRepo = mockProjectRepo();
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1' }));
vi.mocked(promptRepo.findAll).mockResolvedValue([
makePrompt({ name: 'approved-one', projectId: 'proj-1' }),
makePrompt({ name: 'global-one', projectId: null }),
]);
vi.mocked(promptRequestRepo.findBySession).mockResolvedValue([
makePromptRequest({ name: 'pending-one', projectId: 'proj-1' }),
]);
const { app: a } = buildApp({ promptRepo, promptRequestRepo, projectRepo });
const res = await a.inject({
method: 'GET',
url: '/api/v1/projects/homeautomation/prompts/visible?session=sess-123',
});
expect(res.statusCode).toBe(200);
const body = res.json() as Array<{ name: string; type: string }>;
expect(body).toHaveLength(3);
expect(body.map((b) => b.name)).toContain('approved-one');
expect(body.map((b) => b.name)).toContain('global-one');
expect(body.map((b) => b.name)).toContain('pending-one');
const pending = body.find((b) => b.name === 'pending-one');
expect(pending?.type).toBe('promptrequest');
});
it('returns 404 for unknown project', async () => {
const { app: a } = buildApp();
const res = await a.inject({
method: 'GET',
url: '/api/v1/projects/nonexistent/prompts/visible',
});
expect(res.statusCode).toBe(404);
});
});
});

View File

@@ -11,6 +11,10 @@ function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
name: 'test-prompt',
content: 'Hello world',
projectId: null,
priority: 5,
summary: null,
chapters: null,
linkTarget: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
@@ -24,6 +28,7 @@ function makePromptRequest(overrides: Partial<PromptRequest> = {}): PromptReques
name: 'test-request',
content: 'Proposed content',
projectId: null,
priority: 5,
createdBySession: 'session-abc',
createdByUserId: null,
createdAt: new Date(),
@@ -38,6 +43,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
description: '',
prompt: '',
proxyMode: 'direct',
gated: true,
llmProvider: null,
llmModel: null,
ownerId: 'user-1',
@@ -50,6 +56,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
function mockPromptRepo(): IPromptRepository {
return {
findAll: vi.fn(async () => []),
findGlobal: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByNameAndProject: vi.fn(async () => null),
create: vi.fn(async (data) => makePrompt(data)),
@@ -61,10 +68,12 @@ function mockPromptRepo(): IPromptRepository {
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(async (data) => makePromptRequest(data)),
update: vi.fn(async (id, data) => makePromptRequest({ id, ...data })),
delete: vi.fn(async () => {}),
};
}
@@ -111,6 +120,17 @@ describe('PromptService', () => {
});
});
describe('listGlobalPrompts', () => {
it('should return only global prompts', async () => {
const globalPrompts = [makePrompt({ name: 'global-rule', projectId: null })];
vi.mocked(promptRepo.findGlobal).mockResolvedValue(globalPrompts);
const result = await service.listGlobalPrompts();
expect(result).toEqual(globalPrompts);
expect(promptRepo.findGlobal).toHaveBeenCalled();
});
});
describe('getPrompt', () => {
it('should return a prompt by id', async () => {
const prompt = makePrompt();
@@ -173,6 +193,21 @@ describe('PromptService', () => {
it('should throw for missing prompt', async () => {
await expect(service.deletePrompt('nope')).rejects.toThrow('Prompt not found');
});
it('should reject deletion of system prompts', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'sys-proj' }));
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'sys-proj', name: 'mcpctl-system' }));
await expect(service.deletePrompt('prompt-1')).rejects.toThrow('Cannot delete system prompts');
});
it('should allow deletion of non-system project prompts', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'proj-1' }));
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1', name: 'my-project' }));
await service.deletePrompt('prompt-1');
expect(promptRepo.delete).toHaveBeenCalledWith('prompt-1');
});
});
// ── PromptRequest CRUD ──
@@ -267,6 +302,90 @@ describe('PromptService', () => {
});
});
// ── Priority ──
describe('prompt priority', () => {
it('creates prompt with explicit priority', async () => {
const result = await service.createPrompt({ name: 'high-pri', content: 'x', priority: 8 });
expect(promptRepo.create).toHaveBeenCalledWith(expect.objectContaining({ priority: 8 }));
expect(result.priority).toBe(8);
});
it('uses default priority 5 when not specified', async () => {
const result = await service.createPrompt({ name: 'default-pri', content: 'x' });
// Default in schema is 5 — create is called without priority
const createArg = vi.mocked(promptRepo.create).mock.calls[0]![0];
expect(createArg.priority).toBeUndefined();
});
it('rejects priority below 1', async () => {
await expect(
service.createPrompt({ name: 'bad-pri', content: 'x', priority: 0 }),
).rejects.toThrow();
});
it('rejects priority above 10', async () => {
await expect(
service.createPrompt({ name: 'bad-pri', content: 'x', priority: 11 }),
).rejects.toThrow();
});
it('updates prompt priority', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt());
await service.updatePrompt('prompt-1', { priority: 3 });
expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', expect.objectContaining({ priority: 3 }));
});
});
// ── Link Target ──
describe('prompt links', () => {
it('creates linked prompt with valid linkTarget', async () => {
const result = await service.createPrompt({
name: 'linked',
content: 'link content',
linkTarget: 'other-project/docmost-mcp:docmost://pages/abc',
});
expect(promptRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ linkTarget: 'other-project/docmost-mcp:docmost://pages/abc' }),
);
});
it('rejects invalid link format', async () => {
await expect(
service.createPrompt({ name: 'bad-link', content: 'x', linkTarget: 'invalid-format' }),
).rejects.toThrow();
});
it('rejects link without server part', async () => {
await expect(
service.createPrompt({ name: 'bad-link', content: 'x', linkTarget: 'project:uri' }),
).rejects.toThrow();
});
it('approve carries priority from request to prompt', async () => {
const req = makePromptRequest({ id: 'req-1', name: 'high-pri', content: 'x', projectId: 'proj-1', priority: 9 });
vi.mocked(promptRequestRepo.findById).mockResolvedValue(req);
await service.approve('req-1');
expect(promptRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ priority: 9 }),
);
});
it('propose passes priority through', async () => {
const result = await service.propose({
name: 'pri-req',
content: 'x',
priority: 7,
});
expect(promptRequestRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ priority: 7 }),
);
});
});
// ── Visibility ──
describe('getVisiblePrompts', () => {

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi } from 'vitest';
import {
PromptSummaryService,
extractFirstSentence,
extractHeadings,
type LlmSummaryGenerator,
} from '../../src/services/prompt-summary.service.js';
describe('extractFirstSentence', () => {
it('extracts first sentence from plain text', () => {
const result = extractFirstSentence('This is the first sentence. And this is the second.', 20);
expect(result).toBe('This is the first sentence.');
});
it('truncates to maxWords', () => {
const long = 'word '.repeat(30).trim();
const result = extractFirstSentence(long, 5);
expect(result).toBe('word word word word word...');
});
it('skips markdown headings to find content', () => {
const content = '# Title\n\n## Subtitle\n\nActual content here. More text.';
expect(extractFirstSentence(content, 20)).toBe('Actual content here.');
});
it('falls back to first heading if no content lines', () => {
const content = '# Only Headings\n## Nothing Else';
expect(extractFirstSentence(content, 20)).toBe('Only Headings');
});
it('strips markdown formatting', () => {
const content = 'This has **bold** and *italic* and `code` and [link](http://example.com).';
expect(extractFirstSentence(content, 20)).toBe('This has bold and italic and code and link.');
});
it('handles empty content', () => {
expect(extractFirstSentence('', 20)).toBe('');
expect(extractFirstSentence(' ', 20)).toBe('');
});
it('handles content with no sentence boundary', () => {
const content = 'No period at the end';
expect(extractFirstSentence(content, 20)).toBe('No period at the end');
});
it('handles exclamation and question marks', () => {
expect(extractFirstSentence('Is this a question? Yes it is.', 20)).toBe('Is this a question?');
expect(extractFirstSentence('Watch out! Be careful.', 20)).toBe('Watch out!');
});
});
describe('extractHeadings', () => {
it('extracts all levels of markdown headings', () => {
const content = '# H1\n## H2\n### H3\nSome text\n#### H4';
expect(extractHeadings(content)).toEqual(['H1', 'H2', 'H3', 'H4']);
});
it('returns empty array for content without headings', () => {
expect(extractHeadings('Just plain text\nMore text')).toEqual([]);
});
it('handles empty content', () => {
expect(extractHeadings('')).toEqual([]);
});
it('trims heading text', () => {
const content = '# Spaced Heading \n## Another ';
expect(extractHeadings(content)).toEqual(['Spaced Heading', 'Another']);
});
});
describe('PromptSummaryService', () => {
it('uses regex fallback when no LLM', async () => {
const service = new PromptSummaryService(null);
const result = await service.generateSummary('# Overview\n\nThis is a test document. It has content.\n\n## Section One\n\n## Section Two');
expect(result.summary).toBe('This is a test document.');
expect(result.chapters).toEqual(['Overview', 'Section One', 'Section Two']);
});
it('uses LLM when available', async () => {
const mockLlm: LlmSummaryGenerator = {
generate: vi.fn(async () => ({
summary: 'LLM-generated summary',
chapters: ['LLM Chapter 1'],
})),
};
const service = new PromptSummaryService(mockLlm);
const result = await service.generateSummary('Some content');
expect(result.summary).toBe('LLM-generated summary');
expect(result.chapters).toEqual(['LLM Chapter 1']);
expect(mockLlm.generate).toHaveBeenCalledWith('Some content');
});
it('falls back to regex on LLM failure', async () => {
const mockLlm: LlmSummaryGenerator = {
generate: vi.fn(async () => { throw new Error('LLM unavailable'); }),
};
const service = new PromptSummaryService(mockLlm);
const result = await service.generateSummary('Fallback content here. Second sentence.');
expect(result.summary).toBe('Fallback content here.');
expect(mockLlm.generate).toHaveBeenCalled();
});
it('generateWithRegex works directly', () => {
const service = new PromptSummaryService(null);
const result = service.generateWithRegex('# Title\n\nContent line. More.\n\n## Chapter A\n\n## Chapter B');
expect(result.summary).toBe('Content line.');
expect(result.chapters).toEqual(['Title', 'Chapter A', 'Chapter B']);
});
});