feat(chat): project-scoped chat — mcpctl chat --project <name>
Some checks failed
CI/CD / lint (pull_request) Successful in 1m2s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m17s
CI/CD / smoke (pull_request) Failing after 1m49s
CI/CD / build (pull_request) Successful in 2m8s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m2s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m17s
CI/CD / smoke (pull_request) Failing after 1m49s
CI/CD / build (pull_request) Successful in 2m8s
CI/CD / publish (pull_request) Has been skipped
Chat directly with a Project (no Agent needed): its Prompts become the system context, its MCP-server tools are callable, its llmProvider/llmModel drive the LLM, and (opt-in) the model can read secret values. History is saved inside the project, attributed per user, resumable, and deletable (RBAC-permitting) — "use it like Claude, scoped to the project". Backend (reuses the agent-chat orchestrator): - ChatThread is now agent-XOR-project (schema + migration + CHECK constraint); new listThreadsByProject / deleteThread on the repo. - ChatService: prepareProjectContext (project prompt + Prompts by priority, llm from llmProvider with llmModel override, project tools), shared runChatLoop/runChatStreamLoop, project thread CRUD with owner enforcement (404-not-403 on foreign threads), admin-override delete. - Gated get_secret virtual tool: offered only with --allow-secrets AND the caller's view:secrets; resolves via SecretService, never routes to a server. - routes/project-chat.ts (chat SSE+non-stream, threads create/list/delete); RBAC run:projects:<name>. CLI: - `mcpctl chat --project <name>` (+ --allow-secrets), one-shot/REPL/resume. - REPL /threads, /resume <id>, /delete <id>; project-aware header + /tools. - `mcpctl get threads --project <name>`, `mcpctl delete thread <id> --project`. - completions regenerated (--project completes project names). Tests: 8 new project-chat unit tests; full mcpd (945) + CLI (508) green; schema validated against Postgres. Docs: docs/chat.md "Project chat" section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
232
src/mcpd/tests/chat-service-project.test.ts
Normal file
232
src/mcpd/tests/chat-service-project.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ChatService, TOOL_NAME_SEPARATOR, type ChatToolDispatcher, type ChatSecretResolver } from '../src/services/chat.service.js';
|
||||
import type { AgentService } from '../src/services/agent.service.js';
|
||||
import type { LlmService } from '../src/services/llm.service.js';
|
||||
import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js';
|
||||
import type { LlmAdapter, NonStreamingResult, InferContext } from '../src/services/llm/types.js';
|
||||
import type { IChatRepository } from '../src/repositories/chat.repository.js';
|
||||
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
import type { RbacService } from '../src/services/rbac.service.js';
|
||||
import type { ChatMessage, ChatThread, Prompt } from '@prisma/client';
|
||||
|
||||
const NOW = new Date();
|
||||
|
||||
function mockChatRepo(): IChatRepository & { _msgs: ChatMessage[]; _threads: ChatThread[] } {
|
||||
const msgs: ChatMessage[] = [];
|
||||
const threads: ChatThread[] = [];
|
||||
let id = 1;
|
||||
return {
|
||||
_msgs: msgs,
|
||||
_threads: threads,
|
||||
createThread: vi.fn(async ({ agentId, projectId, ownerId, title }) => {
|
||||
const t = {
|
||||
id: `thread-${String(id++)}`,
|
||||
agentId: agentId ?? null,
|
||||
projectId: projectId ?? null,
|
||||
ownerId,
|
||||
title: title ?? '',
|
||||
lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW,
|
||||
} as ChatThread;
|
||||
threads.push(t);
|
||||
return t;
|
||||
}),
|
||||
findThread: vi.fn(async (tid: string) => threads.find((t) => t.id === tid) ?? null),
|
||||
listThreadsByAgent: vi.fn(async () => []),
|
||||
listThreadsByProject: vi.fn(async (projectId: string, ownerId?: string) =>
|
||||
threads.filter((t) => t.projectId === projectId && (ownerId === undefined || t.ownerId === ownerId))),
|
||||
deleteThread: vi.fn(async (tid: string) => { const i = threads.findIndex((t) => t.id === tid); if (i >= 0) threads.splice(i, 1); }),
|
||||
listMessages: vi.fn(async (tid: string) => msgs.filter((m) => m.threadId === tid).sort((a, b) => a.turnIndex - b.turnIndex)),
|
||||
appendMessage: vi.fn(async (input) => {
|
||||
const m = {
|
||||
id: `msg-${String(id++)}`, threadId: input.threadId,
|
||||
turnIndex: input.turnIndex ?? msgs.filter((x) => x.threadId === input.threadId).length,
|
||||
role: input.role, content: input.content,
|
||||
toolCalls: (input.toolCalls ?? null) as ChatMessage['toolCalls'],
|
||||
toolCallId: input.toolCallId ?? null, status: input.status ?? 'complete', createdAt: NOW,
|
||||
} as ChatMessage;
|
||||
msgs.push(m);
|
||||
return m;
|
||||
}),
|
||||
updateStatus: vi.fn(async (mid: string, status) => { const m = msgs.find((x) => x.id === mid)!; m.status = status; return m; }),
|
||||
markPendingAsError: vi.fn(async () => 0),
|
||||
touchThread: vi.fn(async () => undefined),
|
||||
nextTurnIndex: vi.fn(async (tid: string) => msgs.filter((m) => m.threadId === tid).length),
|
||||
};
|
||||
}
|
||||
|
||||
function mockPromptRepo(rows: Prompt[] = []): IPromptRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => rows),
|
||||
findGlobal: vi.fn(async () => []),
|
||||
findByAgent: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
findByNameAndAgent: vi.fn(async () => null),
|
||||
create: vi.fn(), update: vi.fn(), delete: vi.fn(),
|
||||
} as unknown as IPromptRepository;
|
||||
}
|
||||
|
||||
function prompt(name: string, content: string, priority: number): Prompt {
|
||||
return { id: name, name, content, priority, projectId: 'proj-1', agentId: null, version: 1, createdAt: NOW, updatedAt: NOW } as unknown as Prompt;
|
||||
}
|
||||
|
||||
function mockProjects(over: Partial<{ llmProvider: string | null; llmModel: string | null; prompt: string }> = {}): IProjectRepository {
|
||||
return {
|
||||
findByName: vi.fn(async (name: string) => ({
|
||||
id: 'proj-1', name,
|
||||
description: 'the project', prompt: over.prompt ?? 'Project system prompt.',
|
||||
llmProvider: over.llmProvider === undefined ? 'qwen3-thinking' : over.llmProvider,
|
||||
llmModel: over.llmModel ?? null,
|
||||
proxyModel: '', gated: true, serverOverrides: null, ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW,
|
||||
servers: [],
|
||||
})),
|
||||
findById: vi.fn(async () => null), findAll: vi.fn(async () => []),
|
||||
create: vi.fn(), update: vi.fn(), delete: vi.fn(), setServers: vi.fn(), addServer: vi.fn(), removeServer: vi.fn(),
|
||||
} as unknown as IProjectRepository;
|
||||
}
|
||||
|
||||
function mockLlms(): LlmService {
|
||||
const row = (name: string): Record<string, unknown> => ({
|
||||
id: 'llm-1', name, type: 'openai', model: 'served-model', url: '', tier: 'fast', description: '',
|
||||
apiKeySecretId: null, apiKeySecretKey: null, extraConfig: {}, poolName: null, kind: 'public',
|
||||
providerSessionId: null, status: 'active', lastHeartbeatAt: null, inactiveSince: null, version: 1, createdAt: NOW, updatedAt: NOW,
|
||||
});
|
||||
return {
|
||||
getByName: vi.fn(async (name: string) => ({ ...row(name), apiKeyRef: null })),
|
||||
findByPoolName: vi.fn(async (poolName: string) => [row(poolName)]),
|
||||
resolveApiKey: vi.fn(async () => 'k'),
|
||||
} as unknown as LlmService;
|
||||
}
|
||||
|
||||
function mockTools(impl: Partial<ChatToolDispatcher> = {}): ChatToolDispatcher {
|
||||
return { listTools: impl.listTools ?? vi.fn(async () => []), callTool: impl.callTool ?? vi.fn(async () => ({ ok: true })) };
|
||||
}
|
||||
|
||||
function adapterRegistry(adapter: LlmAdapter): LlmAdapterRegistry {
|
||||
return { get: () => adapter } as unknown as LlmAdapterRegistry;
|
||||
}
|
||||
|
||||
function scriptedAdapter(responses: NonStreamingResult[]): LlmAdapter {
|
||||
let i = 0;
|
||||
return {
|
||||
kind: 'scripted',
|
||||
infer: vi.fn(async (_ctx: InferContext) => responses[i++] ?? responses[responses.length - 1]!),
|
||||
stream: async function*() { yield { data: '[DONE]', done: true }; },
|
||||
};
|
||||
}
|
||||
|
||||
function text(content: string): NonStreamingResult {
|
||||
return { status: 200, body: { id: 'c', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] } };
|
||||
}
|
||||
function toolCall(name: string, args: Record<string, unknown>): NonStreamingResult {
|
||||
return { status: 200, body: { id: 'c', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: '', tool_calls: [{ id: `call-${name}`, type: 'function', function: { name, arguments: JSON.stringify(args) } }] }, finish_reason: 'tool_calls' }] } };
|
||||
}
|
||||
|
||||
function svc(opts: {
|
||||
chatRepo?: ReturnType<typeof mockChatRepo>;
|
||||
prompts?: Prompt[];
|
||||
projects?: IProjectRepository;
|
||||
tools?: ChatToolDispatcher;
|
||||
adapter?: LlmAdapter;
|
||||
rbac?: RbacService;
|
||||
secrets?: ChatSecretResolver;
|
||||
} = {}): { service: ChatService; chatRepo: ReturnType<typeof mockChatRepo>; adapter: LlmAdapter } {
|
||||
const chatRepo = opts.chatRepo ?? mockChatRepo();
|
||||
const adapter = opts.adapter ?? scriptedAdapter([text('project reply')]);
|
||||
const service = new ChatService(
|
||||
{} as AgentService, mockLlms(), adapterRegistry(adapter),
|
||||
chatRepo, mockPromptRepo(opts.prompts ?? []), opts.tools ?? mockTools(),
|
||||
undefined, undefined,
|
||||
opts.projects ?? mockProjects(), opts.rbac, opts.secrets,
|
||||
);
|
||||
return { service, chatRepo, adapter };
|
||||
}
|
||||
|
||||
describe('ChatService — project chat', () => {
|
||||
it('assembles system block from project.prompt + project prompts (priority desc) and replies', async () => {
|
||||
const { service, chatRepo, adapter } = svc({
|
||||
prompts: [prompt('low', 'LOW', 1), prompt('high', 'HIGH', 9)],
|
||||
});
|
||||
const res = await service.chatProject({ projectName: 'sre', userMessage: 'hi', ownerId: 'owner-1' });
|
||||
expect(res.assistant).toBe('project reply');
|
||||
// thread is project-scoped, attributed to the owner
|
||||
const t = chatRepo._threads[0]!;
|
||||
expect(t.projectId).toBe('proj-1');
|
||||
expect(t.agentId).toBeNull();
|
||||
expect(t.ownerId).toBe('owner-1');
|
||||
// system message = project.prompt then HIGH then LOW
|
||||
const sys = (adapter.infer as ReturnType<typeof vi.fn>).mock.calls[0][0].body.messages[0];
|
||||
expect(sys.role).toBe('system');
|
||||
expect(sys.content).toBe('Project system prompt.\n\nHIGH\n\nLOW');
|
||||
});
|
||||
|
||||
it('routes tool calls to the project dispatcher with projectId', async () => {
|
||||
const callTool = vi.fn(async () => ({ content: [{ type: 'text', text: 'tool ok' }] }));
|
||||
const { service } = svc({
|
||||
tools: mockTools({ listTools: vi.fn(async () => [{ name: `srv${TOOL_NAME_SEPARATOR}do`, description: 'd', parameters: {} }]), callTool }),
|
||||
adapter: scriptedAdapter([toolCall('srv__do', { x: 1 }), text('done')]),
|
||||
});
|
||||
await service.chatProject({ projectName: 'sre', userMessage: 'go', ownerId: 'owner-1' });
|
||||
expect(callTool).toHaveBeenCalledWith({ projectId: 'proj-1', serverName: 'srv', toolName: 'do', args: { x: 1 } });
|
||||
});
|
||||
|
||||
it('does NOT offer get_secret without --allow-secrets', async () => {
|
||||
const { service, adapter } = svc();
|
||||
await service.chatProject({ projectName: 'sre', userMessage: 'hi', ownerId: 'owner-1' });
|
||||
const body = (adapter.infer as ReturnType<typeof vi.fn>).mock.calls[0][0].body;
|
||||
expect(body.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('offers + dispatches get_secret with --allow-secrets when RBAC allows', async () => {
|
||||
const resolve = vi.fn(async () => 'sk-secret-value');
|
||||
const rbac = { canAccess: vi.fn(async () => true) } as unknown as RbacService;
|
||||
const { service } = svc({
|
||||
rbac, secrets: { resolve },
|
||||
adapter: scriptedAdapter([toolCall('secrets__get_secret', { name: 'litellm-key', key: 'API_KEY' }), text('used it')]),
|
||||
});
|
||||
const res = await service.chatProject({ projectName: 'sre', userMessage: 'read the key', ownerId: 'owner-1', allowSecrets: true });
|
||||
expect(res.assistant).toBe('used it');
|
||||
expect(resolve).toHaveBeenCalledWith('litellm-key', 'API_KEY');
|
||||
});
|
||||
|
||||
it('refuses get_secret when RBAC denies view:secrets (tool never offered; hallucinated call is rejected, not resolved)', async () => {
|
||||
const resolve = vi.fn(async () => 'nope');
|
||||
const rbac = { canAccess: vi.fn(async () => false) } as unknown as RbacService;
|
||||
const { service } = svc({
|
||||
rbac, secrets: { resolve },
|
||||
adapter: scriptedAdapter([toolCall('secrets__get_secret', { name: 'x', key: 'y' }), text('recovered')]),
|
||||
});
|
||||
// RBAC-denied → allowSecrets resolves false → the gated tool is refused.
|
||||
// (Non-streaming dispatch surfaces the tool error by failing the turn.)
|
||||
await expect(service.chatProject({ projectName: 'sre', userMessage: 'try', ownerId: 'owner-1', allowSecrets: true }))
|
||||
.rejects.toThrow(/secret access is not enabled/);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('errors when the project has no llmProvider', async () => {
|
||||
const { service } = svc({ projects: mockProjects({ llmProvider: null }) });
|
||||
await expect(service.chatProject({ projectName: 'sre', userMessage: 'hi', ownerId: 'owner-1' }))
|
||||
.rejects.toThrow(/no llmProvider/);
|
||||
});
|
||||
|
||||
it('resume 404s on a thread owned by another user', async () => {
|
||||
const chatRepo = mockChatRepo();
|
||||
chatRepo._threads.push({ id: 'other', agentId: null, projectId: 'proj-1', ownerId: 'someone-else', title: '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW } as ChatThread);
|
||||
const { service } = svc({ chatRepo });
|
||||
await expect(service.chatProject({ projectName: 'sre', threadId: 'other', userMessage: 'hi', ownerId: 'owner-1' }))
|
||||
.rejects.toThrow(/Thread not found/);
|
||||
});
|
||||
|
||||
it('deleteThread: owner deletes; foreign 404s unless admin override', async () => {
|
||||
const chatRepo = mockChatRepo();
|
||||
chatRepo._threads.push({ id: 'mine', agentId: null, projectId: 'proj-1', ownerId: 'owner-1', title: '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW } as ChatThread);
|
||||
chatRepo._threads.push({ id: 'theirs', agentId: null, projectId: 'proj-1', ownerId: 'other', title: '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW } as ChatThread);
|
||||
const { service } = svc({ chatRepo });
|
||||
await service.deleteThread('mine', 'owner-1');
|
||||
expect(chatRepo._threads.find((t) => t.id === 'mine')).toBeUndefined();
|
||||
await expect(service.deleteThread('theirs', 'owner-1')).rejects.toThrow(/not found/);
|
||||
await service.deleteThread('theirs', 'owner-1', true); // admin override
|
||||
expect(chatRepo._threads.find((t) => t.id === 'theirs')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user