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:
@@ -38,14 +38,16 @@ export interface ChatCommandDeps {
|
||||
|
||||
export function createChatCommand(deps: ChatCommandDeps): Command {
|
||||
return new Command('chat')
|
||||
.description('Open an interactive chat session with an agent (REPL or one-shot).')
|
||||
.argument('<agent>', 'Agent name (see `mcpctl get agents`)')
|
||||
.description('Open an interactive chat session with an agent or a project (REPL or one-shot).')
|
||||
.argument('[agent]', 'Agent name (see `mcpctl get agents`). Omit and use --project to chat with a project.')
|
||||
.option('-p, --project <name>', 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets')
|
||||
.option('--allow-secrets', 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)')
|
||||
.option('-m, --message <text>', 'One-shot: send a single message and exit (no REPL)')
|
||||
.option('--thread <id>', 'Resume an existing thread')
|
||||
.option('--system <text>', 'Replace agent.systemPrompt for this session')
|
||||
.option('--system <text>', 'Replace the base system prompt for this session')
|
||||
.option('--system-file <path>', 'Read --system text from a file')
|
||||
.option('--system-append <text>', 'Append to the agent system block for this session')
|
||||
.option('--personality <name>', 'Personality overlay (additive prompts on top of the agent)')
|
||||
.option('--system-append <text>', 'Append to the system block for this session')
|
||||
.option('--personality <name>', 'Personality overlay (agent chat only)')
|
||||
.option('--temperature <n>', 'Sampling temperature (0..2)', parseFloat)
|
||||
.option('--top-p <n>', 'Nucleus sampling cutoff (0..1)', parseFloat)
|
||||
.option('--top-k <n>', 'Top-K sampling (Anthropic; OpenAI ignores)', parseFloatInt)
|
||||
@@ -55,18 +57,61 @@ export function createChatCommand(deps: ChatCommandDeps): Command {
|
||||
.option('--allow-tool <name>', 'Restrict to this tool only (repeatable)', collect, [])
|
||||
.option('--extra <kv>', 'Provider-specific knob k=v (repeatable)', collect, [])
|
||||
.option('--no-stream', 'Disable SSE streaming (single JSON response)')
|
||||
.action(async (agent: string, opts: ChatOpts) => {
|
||||
.action(async (agent: string | undefined, opts: ChatOpts) => {
|
||||
const subject = resolveSubject(agent, opts);
|
||||
const overrides = await buildInitialOverrides(opts);
|
||||
|
||||
if (opts.message !== undefined) {
|
||||
await runOneShot(deps, agent, opts.message, opts.thread, overrides, opts.stream);
|
||||
await runOneShot(deps, subject, opts.message, opts.thread, overrides, opts.stream);
|
||||
return;
|
||||
}
|
||||
await runRepl(deps, agent, opts.thread, overrides, opts.stream);
|
||||
await runRepl(deps, subject, opts.thread, overrides, opts.stream);
|
||||
});
|
||||
}
|
||||
|
||||
/** What the chat is bound to: a named Agent or a Project. */
|
||||
interface ChatSubject {
|
||||
kind: 'agent' | 'project';
|
||||
name: string;
|
||||
/** URL segment, e.g. `agents/reviewer` or `projects/sre` (name url-encoded). */
|
||||
path: string;
|
||||
/** Project chat only: opt into the gated get_secret tool. */
|
||||
allowSecrets: boolean;
|
||||
}
|
||||
|
||||
function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject {
|
||||
if (opts.project !== undefined && agent !== undefined) {
|
||||
throw new Error('Pass either an <agent> or --project <name>, not both.');
|
||||
}
|
||||
if (opts.project !== undefined) {
|
||||
return { kind: 'project', name: opts.project, path: `projects/${encodeURIComponent(opts.project)}`, allowSecrets: opts.allowSecrets === true };
|
||||
}
|
||||
if (agent !== undefined) {
|
||||
return { kind: 'agent', name: agent, path: `agents/${encodeURIComponent(agent)}`, allowSecrets: false };
|
||||
}
|
||||
throw new Error('Specify an <agent> name or --project <name>. See `mcpctl chat --help`.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the chat request body for a subject. Project chat omits the agent-only
|
||||
* `personality` overlay (the project schema rejects unknown fields) and adds
|
||||
* `allowSecrets` when requested.
|
||||
*/
|
||||
function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record<string, unknown> {
|
||||
const o: Record<string, unknown> = { ...overrides };
|
||||
if (subject.kind === 'project') {
|
||||
delete o.personality;
|
||||
if (subject.allowSecrets) o.allowSecrets = true;
|
||||
}
|
||||
const body: Record<string, unknown> = { message, ...o };
|
||||
if (threadId !== undefined) body.threadId = threadId;
|
||||
if (stream === true) body.stream = true;
|
||||
return body;
|
||||
}
|
||||
|
||||
interface ChatOpts {
|
||||
project?: string;
|
||||
allowSecrets?: boolean;
|
||||
message?: string;
|
||||
thread?: string;
|
||||
system?: string;
|
||||
@@ -136,18 +181,17 @@ function parseExtraValue(raw: string): unknown {
|
||||
|
||||
async function runOneShot(
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
message: string,
|
||||
threadId: string | undefined,
|
||||
overrides: Overrides,
|
||||
stream: boolean | undefined,
|
||||
): Promise<void> {
|
||||
await printChatHeader(deps, agent, overrides);
|
||||
await printChatHeader(deps, subject, overrides);
|
||||
if (stream === false) {
|
||||
const body: Record<string, unknown> = { message, ...overrides };
|
||||
if (threadId !== undefined) body.threadId = threadId;
|
||||
const body = chatBody(subject, message, threadId, overrides);
|
||||
const startMs = Date.now();
|
||||
const res = await chatRequestNonStream(deps, agent, body);
|
||||
const res = await chatRequestNonStream(deps, subject, body);
|
||||
const sec = Math.max(0.05, (Date.now() - startMs) / 1000);
|
||||
const words = (res.assistant.match(/\S+/g) ?? []).length;
|
||||
process.stdout.write(`${res.assistant}\n`);
|
||||
@@ -159,7 +203,7 @@ async function runOneShot(
|
||||
}
|
||||
const bar = installStatusBar();
|
||||
try {
|
||||
const finalThread = await streamOnce(deps, agent, message, threadId, overrides, bar);
|
||||
const finalThread = await streamOnce(deps, subject, message, threadId, overrides, bar);
|
||||
process.stderr.write(`\n(thread: ${finalThread})\n`);
|
||||
} finally {
|
||||
bar?.teardown();
|
||||
@@ -168,7 +212,7 @@ async function runOneShot(
|
||||
|
||||
async function runRepl(
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
initialThread: string | undefined,
|
||||
initialOverrides: Overrides,
|
||||
stream: boolean | undefined,
|
||||
@@ -178,14 +222,14 @@ async function runRepl(
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
await printChatHeader(deps, agent, overrides);
|
||||
await printChatHeader(deps, subject, overrides);
|
||||
|
||||
// The status bar persists across turns inside a REPL — it shows the last
|
||||
// response's final rate between messages, then refreshes live during the
|
||||
// next stream. Only enabled for streaming mode (no rate to show otherwise).
|
||||
const bar = stream === false ? null : installStatusBar();
|
||||
|
||||
process.stderr.write(`Slash commands: /set /system /tools /clear /save /quit. Ctrl-D to exit.\n`);
|
||||
process.stderr.write(`Slash commands: /threads /resume /delete /set /system /tools /clear /save /quit. Ctrl-D to exit.\n`);
|
||||
if (threadId !== undefined) {
|
||||
process.stderr.write(`(resuming thread ${threadId})\n`);
|
||||
}
|
||||
@@ -200,20 +244,22 @@ async function runRepl(
|
||||
}
|
||||
if (line === '') continue;
|
||||
if (line.startsWith('/')) {
|
||||
const handled = await handleSlash(line, deps, agent, overrides, () => { threadId = undefined; });
|
||||
const handled = await handleSlash(line, deps, subject, overrides, {
|
||||
get: () => threadId,
|
||||
set: (id) => { threadId = id; },
|
||||
});
|
||||
if (handled === 'quit') break;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (stream === false) {
|
||||
const body: Record<string, unknown> = { message: line, ...overrides };
|
||||
if (threadId !== undefined) body.threadId = threadId;
|
||||
const res = await chatRequestNonStream(deps, agent, body);
|
||||
const body = chatBody(subject, line, threadId, overrides);
|
||||
const res = await chatRequestNonStream(deps, subject, body);
|
||||
threadId = res.threadId;
|
||||
process.stdout.write(`${res.assistant}\n`);
|
||||
} else {
|
||||
threadId = await streamOnce(deps, agent, line, threadId, overrides, bar);
|
||||
threadId = await streamOnce(deps, subject, line, threadId, overrides, bar);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -226,12 +272,17 @@ async function runRepl(
|
||||
}
|
||||
}
|
||||
|
||||
interface ThreadControl {
|
||||
get(): string | undefined;
|
||||
set(id: string | undefined): void;
|
||||
}
|
||||
|
||||
async function handleSlash(
|
||||
raw: string,
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
overrides: Overrides,
|
||||
resetThread: () => void,
|
||||
thread: ThreadControl,
|
||||
): Promise<'quit' | 'continue'> {
|
||||
const [cmd, ...rest] = raw.slice(1).split(/\s+/);
|
||||
switch (cmd) {
|
||||
@@ -239,9 +290,59 @@ async function handleSlash(
|
||||
case 'exit':
|
||||
return 'quit';
|
||||
case 'clear':
|
||||
resetThread();
|
||||
process.stderr.write('(new thread\n)');
|
||||
thread.set(undefined);
|
||||
process.stderr.write('(new thread)\n');
|
||||
return 'continue';
|
||||
case 'threads': {
|
||||
try {
|
||||
const rows = await deps.client.get<Array<{ id: string; title: string; lastTurnAt: string }>>(
|
||||
`/api/v1/${subject.path}/threads`,
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
process.stderr.write('(no saved threads yet)\n');
|
||||
} else {
|
||||
const active = thread.get();
|
||||
for (const r of rows) {
|
||||
const mark = r.id === active ? '* ' : ' ';
|
||||
const title = r.title !== '' ? ` — ${r.title}` : '';
|
||||
process.stderr.write(`${mark}${r.id} ${new Date(r.lastTurnAt).toLocaleString()}${title}\n`);
|
||||
}
|
||||
process.stderr.write('(/resume <id> to switch, /delete <id> to remove)\n');
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(`error listing threads: ${(err as Error).message}\n`);
|
||||
}
|
||||
return 'continue';
|
||||
}
|
||||
case 'resume': {
|
||||
const id = rest[0];
|
||||
if (id === undefined || id === '') {
|
||||
process.stderr.write('usage: /resume <thread-id> (see /threads)\n');
|
||||
} else {
|
||||
thread.set(id);
|
||||
process.stderr.write(`(resuming thread ${id})\n`);
|
||||
}
|
||||
return 'continue';
|
||||
}
|
||||
case 'delete': {
|
||||
const id = rest[0];
|
||||
if (id === undefined || id === '') {
|
||||
process.stderr.write('usage: /delete <thread-id> (see /threads)\n');
|
||||
return 'continue';
|
||||
}
|
||||
if (subject.kind !== 'project') {
|
||||
process.stderr.write('(/delete is project-chat only)\n');
|
||||
return 'continue';
|
||||
}
|
||||
try {
|
||||
await deps.client.delete(`/api/v1/projects/${encodeURIComponent(subject.name)}/threads/${encodeURIComponent(id)}`);
|
||||
process.stderr.write(`(deleted thread ${id})\n`);
|
||||
if (thread.get() === id) thread.set(undefined);
|
||||
} catch (err) {
|
||||
process.stderr.write(`error deleting thread: ${(err as Error).message}\n`);
|
||||
}
|
||||
return 'continue';
|
||||
}
|
||||
case 'system': {
|
||||
const text = rest.join(' ');
|
||||
if (text === '') {
|
||||
@@ -265,15 +366,21 @@ async function handleSlash(
|
||||
}
|
||||
case 'tools': {
|
||||
try {
|
||||
const a = await deps.client.get<{ project: { name: string } | null }>(
|
||||
`/api/v1/agents/${encodeURIComponent(agent)}`,
|
||||
);
|
||||
if (a.project === null) {
|
||||
let projectName: string | null;
|
||||
if (subject.kind === 'project') {
|
||||
projectName = subject.name;
|
||||
} else {
|
||||
const a = await deps.client.get<{ project: { name: string } | null }>(
|
||||
`/api/v1/agents/${encodeURIComponent(subject.name)}`,
|
||||
);
|
||||
projectName = a.project?.name ?? null;
|
||||
}
|
||||
if (projectName === null) {
|
||||
process.stderr.write('(agent has no project — no tools available)\n');
|
||||
return 'continue';
|
||||
}
|
||||
const servers = await deps.client.get<Array<{ server: { name: string } }>>(
|
||||
`/api/v1/projects/${encodeURIComponent(a.project.name)}/servers`,
|
||||
`/api/v1/projects/${encodeURIComponent(projectName)}/servers`,
|
||||
);
|
||||
if (servers.length === 0) {
|
||||
process.stderr.write('(project has no MCP servers attached)\n');
|
||||
@@ -281,6 +388,9 @@ async function handleSlash(
|
||||
for (const s of servers) {
|
||||
process.stderr.write(` ${s.server.name}\n`);
|
||||
}
|
||||
if (subject.kind === 'project' && subject.allowSecrets) {
|
||||
process.stderr.write(' secrets (get_secret — reads secret values)\n');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(`error listing tools: ${(err as Error).message}\n`);
|
||||
@@ -288,8 +398,12 @@ async function handleSlash(
|
||||
return 'continue';
|
||||
}
|
||||
case 'save': {
|
||||
if (subject.kind === 'project') {
|
||||
process.stderr.write('(/save is agent-only — project chats have no persisted defaults)\n');
|
||||
return 'continue';
|
||||
}
|
||||
try {
|
||||
await deps.client.put(`/api/v1/agents/${encodeURIComponent(agent)}`, {
|
||||
await deps.client.put(`/api/v1/agents/${encodeURIComponent(subject.name)}`, {
|
||||
defaultParams: stripSession(overrides),
|
||||
});
|
||||
process.stderr.write('(saved current overrides as agent.defaultParams)\n');
|
||||
@@ -339,10 +453,10 @@ function applySetCommand(o: Overrides, key: string, valueRaw: string): void {
|
||||
*/
|
||||
async function chatRequestNonStream(
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ assistant: string; threadId: string; turnIndex: number }> {
|
||||
const url = new URL(`${deps.baseUrl}/api/v1/agents/${encodeURIComponent(agent)}/chat`);
|
||||
const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`);
|
||||
const payload = JSON.stringify(body);
|
||||
return new Promise((resolve, reject) => {
|
||||
const driver = url.protocol === 'https:' ? https : http;
|
||||
@@ -388,14 +502,14 @@ async function chatRequestNonStream(
|
||||
/** Stream a single chat call. Returns the resolved threadId. */
|
||||
async function streamOnce(
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
message: string,
|
||||
threadId: string | undefined,
|
||||
overrides: Overrides,
|
||||
bar: StatusBar | null = null,
|
||||
): Promise<string> {
|
||||
const url = new URL(`${deps.baseUrl}/api/v1/agents/${encodeURIComponent(agent)}/chat`);
|
||||
const body = JSON.stringify({ message, threadId, stream: true, ...overrides });
|
||||
const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`);
|
||||
const body = JSON.stringify(chatBody(subject, message, threadId, overrides, true));
|
||||
|
||||
// Per-response counters. Updated on every text/thinking delta, surfaced
|
||||
// live through the bottom-row status bar and the final stats footer.
|
||||
@@ -683,6 +797,14 @@ interface AgentInfo {
|
||||
defaultPersonality?: { name: string } | null;
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
prompt?: string;
|
||||
llmProvider?: string | null;
|
||||
llmModel?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints a startup banner showing what the chat session will be running with:
|
||||
* agent name, LLM, project, the assembled system prompt, and any session
|
||||
@@ -693,22 +815,53 @@ interface AgentInfo {
|
||||
*/
|
||||
async function printChatHeader(
|
||||
deps: ChatCommandDeps,
|
||||
agent: string,
|
||||
subject: ChatSubject,
|
||||
overrides: Overrides,
|
||||
): Promise<void> {
|
||||
let info: AgentInfo;
|
||||
try {
|
||||
info = await deps.client.get<AgentInfo>(`/api/v1/agents/${encodeURIComponent(agent)}`);
|
||||
} catch (err) {
|
||||
process.stderr.write(`(could not fetch agent metadata: ${(err as Error).message})\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sep = '─'.repeat(60);
|
||||
const out = (s: string): void => { process.stderr.write(`${styleStats(s)}\n`); };
|
||||
const indent = (text: string): string =>
|
||||
text.split('\n').map((l) => ` ${l}`).join('\n');
|
||||
|
||||
if (subject.kind === 'project') {
|
||||
let proj: ProjectInfo;
|
||||
try {
|
||||
proj = await deps.client.get<ProjectInfo>(`/api/v1/projects/${encodeURIComponent(subject.name)}`);
|
||||
} catch (err) {
|
||||
process.stderr.write(`(could not fetch project metadata: ${(err as Error).message})\n`);
|
||||
return;
|
||||
}
|
||||
out(sep);
|
||||
out(`Project: ${proj.name}${proj.description !== '' ? ` — ${proj.description}` : ''}`);
|
||||
const model = proj.llmModel !== null && proj.llmModel !== undefined && proj.llmModel !== '' ? ` (${proj.llmModel})` : '';
|
||||
out(`LLM: ${proj.llmProvider ?? '(none set — set project.llmProvider)'}${model}`);
|
||||
if (overrides.systemOverride !== undefined) {
|
||||
out('System prompt (--system replaces project.prompt):');
|
||||
out(indent(overrides.systemOverride));
|
||||
} else if (proj.prompt !== undefined && proj.prompt !== '') {
|
||||
out('Project prompt:');
|
||||
out(indent(proj.prompt));
|
||||
}
|
||||
if (overrides.systemAppend !== undefined) {
|
||||
out('System append (--system-append):');
|
||||
out(indent(overrides.systemAppend));
|
||||
}
|
||||
out('(project Prompts auto-appended at chat time; /tools lists MCP servers)');
|
||||
if (subject.allowSecrets) out('Secret access: ENABLED (get_secret tool available — requires view:secrets)');
|
||||
const so = describeSessionOverrides(overrides);
|
||||
if (so !== '') out(`Sampling overrides: ${so}`);
|
||||
out(sep);
|
||||
return;
|
||||
}
|
||||
|
||||
let info: AgentInfo;
|
||||
try {
|
||||
info = await deps.client.get<AgentInfo>(`/api/v1/agents/${encodeURIComponent(subject.name)}`);
|
||||
} catch (err) {
|
||||
process.stderr.write(`(could not fetch agent metadata: ${(err as Error).message})\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
out(sep);
|
||||
out(`Agent: ${info.name}${info.description !== '' ? ` — ${info.description}` : ''}`);
|
||||
const tail = info.project !== null ? ` Project: ${info.project.name}` : '';
|
||||
|
||||
@@ -19,6 +19,17 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
|
||||
.action(async (resourceArg: string, idOrName: string, opts: { project?: string; agent?: string }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
|
||||
// Chat threads: project-scoped, deleted by id. Owner can delete their own;
|
||||
// admins with delete:projects can delete anyone's (enforced server-side).
|
||||
if (resource === 'threads') {
|
||||
if (!opts.project) {
|
||||
throw new Error('--project is required. Usage: mcpctl delete thread <id> --project <name>');
|
||||
}
|
||||
await client.delete(`/api/v1/projects/${encodeURIComponent(opts.project)}/threads/${encodeURIComponent(idOrName)}`);
|
||||
log(`thread '${idOrName}' deleted from project '${opts.project}'.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Serverattachments: delete serverattachment <server> --project <project>
|
||||
if (resource === 'serverattachments') {
|
||||
if (!opts.project) {
|
||||
|
||||
@@ -234,6 +234,14 @@ const agentColumns: Column<AgentRow>[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
interface ThreadRow { id: string; title: string; lastTurnAt: string; createdAt: string }
|
||||
const threadColumns: Column<ThreadRow>[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
{ header: 'TITLE', key: (r) => truncate(r.title, 40) || '(untitled)', width: 40 },
|
||||
{ header: 'LAST TURN', key: (r) => formatAge(r.lastTurnAt), width: 10 },
|
||||
{ header: 'CREATED', key: (r) => formatAge(r.createdAt), width: 10 },
|
||||
];
|
||||
|
||||
interface PersonalityRow {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -441,6 +449,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
||||
return agentColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'personalities':
|
||||
return personalityColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'threads':
|
||||
return threadColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'inference-tasks':
|
||||
return inferenceTaskColumns as unknown as Column<Record<string, unknown>>[];
|
||||
default:
|
||||
|
||||
@@ -131,6 +131,14 @@ export function createProgram(): Command {
|
||||
return client.get<unknown[]>(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`);
|
||||
}
|
||||
|
||||
// Chat threads are project-scoped history — `get threads --project <name>`.
|
||||
if (!nameOrId && resource === 'threads') {
|
||||
if (!projectName) {
|
||||
throw new Error('threads are scoped — pass --project <name> (see `mcpctl chat --project`)');
|
||||
}
|
||||
return client.get<unknown[]>(`/api/v1/projects/${encodeURIComponent(projectName)}/threads`);
|
||||
}
|
||||
|
||||
// Name-based lookup for mcptokens: names are unique only within a project
|
||||
if (nameOrId && resource === 'mcptokens' && !/^c[a-z0-9]{24}/.test(nameOrId)) {
|
||||
if (!projectName) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Project-scoped chat threads: a ChatThread is now scoped to an Agent XOR a
|
||||
-- Project. Existing rows are all agent-scoped, so dropping NOT NULL on agentId
|
||||
-- is backward-compatible.
|
||||
|
||||
-- Agent link becomes optional; add the project link.
|
||||
ALTER TABLE "ChatThread" ALTER COLUMN "agentId" DROP NOT NULL;
|
||||
ALTER TABLE "ChatThread" ADD COLUMN "projectId" TEXT;
|
||||
|
||||
-- Exactly one of agentId / projectId must be set.
|
||||
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_agent_xor_project"
|
||||
CHECK ((("agentId" IS NOT NULL)::int + ("projectId" IS NOT NULL)::int) = 1);
|
||||
|
||||
-- Project FK (cascade delete threads with their project, mirroring the agent FK).
|
||||
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_projectId_fkey"
|
||||
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "ChatThread_projectId_lastTurnAt_idx" ON "ChatThread"("projectId", "lastTurnAt" DESC);
|
||||
@@ -336,6 +336,7 @@ model Project {
|
||||
skills Skill[]
|
||||
mcpTokens McpToken[]
|
||||
agents Agent[]
|
||||
chatThreads ChatThread[]
|
||||
|
||||
@@index([name])
|
||||
@@index([ownerId])
|
||||
@@ -751,18 +752,24 @@ model PersonalityPrompt {
|
||||
|
||||
model ChatThread {
|
||||
id String @id @default(cuid())
|
||||
agentId String
|
||||
// A thread is scoped to an Agent XOR a Project (enforced by a CHECK
|
||||
// constraint in the migration + the service layer). Project-scoped threads
|
||||
// back `mcpctl chat --project <name>`.
|
||||
agentId String?
|
||||
projectId String?
|
||||
ownerId String
|
||||
title String @default("")
|
||||
lastTurnAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
|
||||
agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
|
||||
@@index([agentId, lastTurnAt(sort: Desc)])
|
||||
@@index([projectId, lastTurnAt(sort: Desc)])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import { registerInferenceTaskRoutes } from './routes/inference-tasks.js';
|
||||
import { VirtualLlmService } from './services/virtual-llm.service.js';
|
||||
import { registerAgentRoutes } from './routes/agents.js';
|
||||
import { registerAgentChatRoutes } from './routes/agent-chat.js';
|
||||
import { registerProjectChatRoutes } from './routes/project-chat.js';
|
||||
import { PromptRepository } from './repositories/prompt.repository.js';
|
||||
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
||||
import { PersonalityRepository } from './repositories/personality.repository.js';
|
||||
@@ -233,6 +234,14 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
||||
return { kind: 'resource', resource: 'mcptokens', action: 'delete', resourceName: revokeMatch[1] };
|
||||
}
|
||||
|
||||
// Special case: /api/v1/projects/:name/(chat|threads) → run:projects:<name>.
|
||||
// Driving a project chat turn or managing its threads is a "run" on the
|
||||
// project; per-thread ownership + admin-delete are enforced in the service.
|
||||
const projectRunMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/(chat|threads)/);
|
||||
if (projectRunMatch?.[1]) {
|
||||
return { kind: 'resource', resource: 'projects', action: 'run', resourceName: projectRunMatch[1] };
|
||||
}
|
||||
|
||||
// Special case: /api/v1/projects/:name/prompts/visible → view prompts
|
||||
const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/);
|
||||
if (visiblePromptsMatch?.[1]) {
|
||||
@@ -683,8 +692,12 @@ async function main(): Promise<void> {
|
||||
chatToolDispatcher,
|
||||
personalityRepo,
|
||||
virtualLlmService,
|
||||
projectRepo,
|
||||
rbacService,
|
||||
secretService,
|
||||
);
|
||||
registerAgentChatRoutes(app, chatService);
|
||||
registerProjectChatRoutes(app, chatService, rbacService);
|
||||
registerLlmInferRoutes(app, {
|
||||
llmService,
|
||||
adapters: llmAdapters,
|
||||
|
||||
@@ -27,10 +27,20 @@ export interface AppendMessageInput {
|
||||
turnIndex?: number;
|
||||
}
|
||||
|
||||
/** A thread is scoped to an Agent XOR a Project (exactly one id set). */
|
||||
export interface CreateThreadInput {
|
||||
agentId?: string;
|
||||
projectId?: string;
|
||||
ownerId: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface IChatRepository {
|
||||
createThread(input: { agentId: string; ownerId: string; title?: string }): Promise<ChatThread>;
|
||||
createThread(input: CreateThreadInput): Promise<ChatThread>;
|
||||
findThread(id: string): Promise<ChatThread | null>;
|
||||
listThreadsByAgent(agentId: string, ownerId?: string): Promise<ChatThread[]>;
|
||||
listThreadsByProject(projectId: string, ownerId?: string): Promise<ChatThread[]>;
|
||||
deleteThread(id: string): Promise<void>;
|
||||
appendMessage(input: AppendMessageInput): Promise<ChatMessage>;
|
||||
listMessages(threadId: string): Promise<ChatMessage[]>;
|
||||
updateStatus(messageId: string, status: ChatStatus): Promise<ChatMessage>;
|
||||
@@ -47,10 +57,11 @@ const UNIQUE_VIOLATION = 'P2002';
|
||||
export class ChatRepository implements IChatRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async createThread(input: { agentId: string; ownerId: string; title?: string }): Promise<ChatThread> {
|
||||
async createThread(input: CreateThreadInput): Promise<ChatThread> {
|
||||
return this.prisma.chatThread.create({
|
||||
data: {
|
||||
agentId: input.agentId,
|
||||
agentId: input.agentId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
ownerId: input.ownerId,
|
||||
title: input.title ?? '',
|
||||
},
|
||||
@@ -68,6 +79,18 @@ export class ChatRepository implements IChatRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async listThreadsByProject(projectId: string, ownerId?: string): Promise<ChatThread[]> {
|
||||
return this.prisma.chatThread.findMany({
|
||||
where: { projectId, ...(ownerId !== undefined ? { ownerId } : {}) },
|
||||
orderBy: { lastTurnAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteThread(id: string): Promise<void> {
|
||||
// Messages cascade via the ChatMessage.thread FK (onDelete: Cascade).
|
||||
await this.prisma.chatThread.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async listMessages(threadId: string): Promise<ChatMessage[]> {
|
||||
return this.prisma.chatMessage.findMany({
|
||||
where: { threadId },
|
||||
|
||||
150
src/mcpd/src/routes/project-chat.ts
Normal file
150
src/mcpd/src/routes/project-chat.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Project-scoped chat + threads HTTP surface. Mirrors `agent-chat.ts`, but the
|
||||
* chat subject is a Project (its Prompts → system, its MCP servers → tools,
|
||||
* its llmProvider/llmModel → the LLM), and history is persisted as
|
||||
* project-scoped ChatThreads attributed to the caller.
|
||||
*
|
||||
* POST /api/v1/projects/:name/chat — chat (non-streaming + SSE)
|
||||
* POST /api/v1/projects/:name/threads — explicit thread create
|
||||
* GET /api/v1/projects/:name/threads — list threads (caller-scoped)
|
||||
* DELETE /api/v1/projects/:name/threads/:id — delete a thread (owner or admin)
|
||||
*
|
||||
* RBAC: chat/threads route through `run:projects:<name>` (see main.ts
|
||||
* mapUrlToPermission). Thread reads/deletes add a service-level owner check;
|
||||
* delete additionally honors `delete:projects:<name>` as an admin override.
|
||||
* The SSE frame format matches agent-chat / llm-infer exactly.
|
||||
*/
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { ChatService, ChatStreamChunk } from '../services/chat.service.js';
|
||||
import type { RbacService } from '../services/rbac.service.js';
|
||||
import { ProjectChatRequestSchema } from '../validation/agent.schema.js';
|
||||
import { NotFoundError } from '../services/mcp-server.service.js';
|
||||
|
||||
export function registerProjectChatRoutes(
|
||||
app: FastifyInstance,
|
||||
chat: ChatService,
|
||||
rbac: RbacService,
|
||||
): void {
|
||||
app.post<{ Params: { name: string } }>(
|
||||
'/api/v1/projects/:name/chat',
|
||||
async (request, reply) => {
|
||||
const ownerId = request.userId ?? 'system';
|
||||
let parsed;
|
||||
try {
|
||||
parsed = ProjectChatRequestSchema.parse(request.body ?? {});
|
||||
} catch (err) {
|
||||
reply.code(400);
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
|
||||
const { threadId, message, messages: messagesOverride, stream, allowSecrets, ...paramsRest } = parsed;
|
||||
|
||||
const args = {
|
||||
projectName: request.params.name,
|
||||
ownerId,
|
||||
...(threadId !== undefined ? { threadId } : {}),
|
||||
...(message !== undefined ? { userMessage: message } : {}),
|
||||
...(messagesOverride !== undefined
|
||||
? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) }
|
||||
: {}),
|
||||
...(allowSecrets !== undefined ? { allowSecrets } : {}),
|
||||
params: paramsRest,
|
||||
};
|
||||
|
||||
if (stream !== true) {
|
||||
try {
|
||||
return await chat.chatProject(args);
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
reply.code(404);
|
||||
return { error: err.message };
|
||||
}
|
||||
reply.code(502);
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
try {
|
||||
for await (const chunk of chat.chatProjectStream(args)) {
|
||||
writeSseChunk(reply, JSON.stringify(chunk));
|
||||
if (chunk.type === 'final' || chunk.type === 'error') break;
|
||||
}
|
||||
writeSseChunk(reply, '[DONE]');
|
||||
} catch (err) {
|
||||
const payload: ChatStreamChunk = { type: 'error', message: (err as Error).message };
|
||||
writeSseChunk(reply, JSON.stringify(payload));
|
||||
writeSseChunk(reply, '[DONE]');
|
||||
} finally {
|
||||
reply.raw.end();
|
||||
}
|
||||
return reply;
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { name: string }; Body: { title?: string } }>(
|
||||
'/api/v1/projects/:name/threads',
|
||||
async (request, reply) => {
|
||||
const ownerId = request.userId ?? 'system';
|
||||
try {
|
||||
const t = await chat.createProjectThread(request.params.name, ownerId, request.body?.title);
|
||||
reply.code(201);
|
||||
return t;
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
reply.code(404);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { name: string } }>(
|
||||
'/api/v1/projects/:name/threads',
|
||||
async (request, reply) => {
|
||||
const ownerId = request.userId ?? undefined;
|
||||
try {
|
||||
return await chat.listProjectThreads(request.params.name, ownerId);
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
reply.code(404);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { name: string; id: string } }>(
|
||||
'/api/v1/projects/:name/threads/:id',
|
||||
async (request, reply) => {
|
||||
const ownerId = request.userId ?? 'system';
|
||||
// Admin override: a caller who can delete the project can prune anyone's
|
||||
// threads; otherwise only their own (enforced in the service, 404 on miss).
|
||||
const canDeleteOthers = request.userId !== undefined
|
||||
&& await rbac.canAccess(request.userId, 'delete', 'projects', request.params.name);
|
||||
try {
|
||||
await chat.deleteThread(request.params.id, ownerId, canDeleteOthers);
|
||||
reply.code(204);
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
reply.code(404);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function writeSseChunk(reply: FastifyReply, data: string): void {
|
||||
if (reply.raw.destroyed || reply.raw.writableEnded) return;
|
||||
reply.raw.write(`data: ${data}\n\n`);
|
||||
}
|
||||
@@ -32,11 +32,22 @@ import type {
|
||||
} from '../repositories/chat.repository.js';
|
||||
import type { IPromptRepository } from '../repositories/prompt.repository.js';
|
||||
import type { IPersonalityRepository } from '../repositories/personality.repository.js';
|
||||
import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||
import type { IVirtualLlmService } from './virtual-llm.service.js';
|
||||
import type { RbacService } from './rbac.service.js';
|
||||
import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js';
|
||||
import type { AgentChatParams } from '../validation/agent.schema.js';
|
||||
import { NotFoundError } from './mcp-server.service.js';
|
||||
|
||||
/** Minimal secret-read surface the project-chat `get_secret` tool needs. */
|
||||
export interface ChatSecretResolver {
|
||||
resolve(secretName: string, key: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** Wire name of the gated virtual secret-read tool offered in project chat. */
|
||||
export const SECRET_TOOL_SERVER = 'secrets';
|
||||
export const SECRET_TOOL_NAME = 'get_secret';
|
||||
|
||||
export const TOOL_NAME_SEPARATOR = '__';
|
||||
/** Default tool-loop cap. Per-agent override via `agent.extras.maxIterations`, clamped to MIN..MAX. */
|
||||
export const MAX_ITERATIONS = 12;
|
||||
@@ -142,6 +153,36 @@ export interface ChatResult {
|
||||
turnIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-scoped chat: `mcpctl chat --project <name>`. Same turn loop as an
|
||||
* agent, but the LLM/system-block/tools are resolved from the Project instead
|
||||
* of a named Agent. `allowSecrets` gates the virtual `get_secret` tool.
|
||||
*/
|
||||
export interface ChatProjectRequestArgs {
|
||||
projectName: string;
|
||||
threadId?: string;
|
||||
userMessage?: string;
|
||||
messagesOverride?: OpenAiMessage[];
|
||||
ownerId: string;
|
||||
params?: AgentChatParams;
|
||||
allowSecrets?: boolean;
|
||||
}
|
||||
|
||||
/** The fully-resolved per-turn context both the agent and project paths share. */
|
||||
interface PreparedChatContext {
|
||||
threadId: string;
|
||||
history: OpenAiMessage[];
|
||||
systemBlock: string;
|
||||
poolCandidates: PoolCandidate[];
|
||||
mergedParams: AgentChatParams;
|
||||
toolList: ChatTool[];
|
||||
projectId: string | null;
|
||||
startingTurnIndex: number;
|
||||
maxIterations: number;
|
||||
/** When true, `dispatchTool` will honor the virtual `secrets__get_secret` call. */
|
||||
allowSecrets: boolean;
|
||||
}
|
||||
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly agents: AgentService,
|
||||
@@ -159,6 +200,10 @@ export class ChatService {
|
||||
* with a clear error.
|
||||
*/
|
||||
private readonly virtualLlms?: IVirtualLlmService,
|
||||
/** Project-chat deps (optional so agent-only test wirings still compile). */
|
||||
private readonly projects?: IProjectRepository,
|
||||
private readonly rbac?: RbacService,
|
||||
private readonly secrets?: ChatSecretResolver,
|
||||
) {}
|
||||
|
||||
async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> {
|
||||
@@ -190,9 +235,18 @@ export class ChatService {
|
||||
return this.chatRepo.listMessages(threadId);
|
||||
}
|
||||
|
||||
/** Non-streaming chat. Persists rows + returns the final assistant text. */
|
||||
/** Non-streaming chat with an agent. */
|
||||
async chat(args: ChatRequestArgs): Promise<ChatResult> {
|
||||
const ctx = await this.prepareContext(args);
|
||||
return this.runChatLoop(await this.prepareContext(args));
|
||||
}
|
||||
|
||||
/** Non-streaming chat scoped to a project. */
|
||||
async chatProject(args: ChatProjectRequestArgs): Promise<ChatResult> {
|
||||
return this.runChatLoop(await this.prepareProjectContext(args));
|
||||
}
|
||||
|
||||
/** Shared non-streaming turn loop. Persists rows + returns the final text. */
|
||||
private async runChatLoop(ctx: PreparedChatContext): Promise<ChatResult> {
|
||||
let assistantFinal = '';
|
||||
let lastTurnIndex = ctx.startingTurnIndex;
|
||||
try {
|
||||
@@ -224,7 +278,7 @@ export class ChatService {
|
||||
tool_calls: choice.tool_calls,
|
||||
});
|
||||
for (const call of choice.tool_calls) {
|
||||
const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId);
|
||||
const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId, ctx.allowSecrets);
|
||||
const resultMsg = await this.chatRepo.appendMessage({
|
||||
threadId: ctx.threadId,
|
||||
role: 'tool',
|
||||
@@ -263,9 +317,18 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Streaming chat. Yields text deltas + tool events. Persists rows in lockstep. */
|
||||
/** Streaming chat with an agent. Yields text deltas + tool events. */
|
||||
async *chatStream(args: ChatRequestArgs): AsyncGenerator<ChatStreamChunk> {
|
||||
const ctx = await this.prepareContext(args);
|
||||
yield* this.runChatStreamLoop(await this.prepareContext(args));
|
||||
}
|
||||
|
||||
/** Streaming chat scoped to a project. */
|
||||
async *chatProjectStream(args: ChatProjectRequestArgs): AsyncGenerator<ChatStreamChunk> {
|
||||
yield* this.runChatStreamLoop(await this.prepareProjectContext(args));
|
||||
}
|
||||
|
||||
/** Shared streaming turn loop. Persists rows in lockstep. */
|
||||
private async *runChatStreamLoop(ctx: PreparedChatContext): AsyncGenerator<ChatStreamChunk> {
|
||||
try {
|
||||
for (let i = 0; i < ctx.maxIterations; i += 1) {
|
||||
// `reasoning` is accumulated alongside `content` so we can fall back
|
||||
@@ -335,7 +398,7 @@ export class ChatService {
|
||||
for (const call of accumulated.toolCalls) {
|
||||
yield { type: 'tool_call', toolName: call.name, args: safeParseJson(call.argumentsJson) as Record<string, unknown> };
|
||||
try {
|
||||
const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId);
|
||||
const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId, ctx.allowSecrets);
|
||||
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
await this.chatRepo.appendMessage({
|
||||
threadId: ctx.threadId,
|
||||
@@ -564,24 +627,7 @@ export class ChatService {
|
||||
});
|
||||
}
|
||||
|
||||
private async prepareContext(args: ChatRequestArgs): Promise<{
|
||||
threadId: string;
|
||||
history: OpenAiMessage[];
|
||||
systemBlock: string;
|
||||
/**
|
||||
* v4: ordered pool members for this turn. [0] is the candidate to try
|
||||
* first (random shuffle of viable members so load spreads). On
|
||||
* transport-level failure the dispatcher iterates the rest. Always
|
||||
* non-empty — at minimum the agent's pinned Llm (or a sibling pool
|
||||
* member when the pinned row is itself inactive but others are up).
|
||||
*/
|
||||
poolCandidates: PoolCandidate[];
|
||||
mergedParams: AgentChatParams;
|
||||
toolList: ChatTool[];
|
||||
projectId: string | null;
|
||||
startingTurnIndex: number;
|
||||
maxIterations: number;
|
||||
}> {
|
||||
private async prepareContext(args: ChatRequestArgs): Promise<PreparedChatContext> {
|
||||
const agent = await this.agents.getByName(args.agentName);
|
||||
const pinned = await this.llms.getByName(agent.llm.name);
|
||||
const poolCandidates = await this.resolvePoolCandidates(pinned);
|
||||
@@ -668,9 +714,141 @@ export class ChatService {
|
||||
projectId,
|
||||
startingTurnIndex,
|
||||
maxIterations: resolveMaxIterations(agent.extras),
|
||||
allowSecrets: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Project-scoped chat ──────────────────────────────────────────────────
|
||||
|
||||
/** Create a new project-scoped thread (owned by the caller). */
|
||||
async createProjectThread(projectName: string, ownerId: string, title?: string): Promise<{ id: string }> {
|
||||
const project = await this.requireProject(projectName);
|
||||
const thread = await this.chatRepo.createThread({
|
||||
projectId: project.id,
|
||||
ownerId,
|
||||
...(title !== undefined ? { title } : {}),
|
||||
});
|
||||
return { id: thread.id };
|
||||
}
|
||||
|
||||
/** List the caller's threads for a project (or all, when ownerId omitted). */
|
||||
async listProjectThreads(projectName: string, ownerId?: string): Promise<Array<{ id: string; title: string; lastTurnAt: Date; createdAt: Date }>> {
|
||||
const project = await this.requireProject(projectName);
|
||||
const rows = await this.chatRepo.listThreadsByProject(project.id, ownerId);
|
||||
return rows.map((r) => ({ id: r.id, title: r.title, lastTurnAt: r.lastTurnAt, createdAt: r.createdAt }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a thread. Allowed when the caller owns it, or holds admin override
|
||||
* (`canDeleteOthers`, wired to `delete:projects:<name>` at the route). 404
|
||||
* (not 403) on a foreign thread the caller can't override — no id-enumeration.
|
||||
*/
|
||||
async deleteThread(threadId: string, ownerId: string, canDeleteOthers = false): Promise<void> {
|
||||
const thread = await this.chatRepo.findThread(threadId);
|
||||
if (thread === null) throw new NotFoundError(`Thread not found: ${threadId}`);
|
||||
if (thread.ownerId !== ownerId && !canDeleteOthers) {
|
||||
throw new NotFoundError(`Thread not found: ${threadId}`);
|
||||
}
|
||||
await this.chatRepo.deleteThread(threadId);
|
||||
}
|
||||
|
||||
private async requireProject(projectName: string): Promise<{ id: string; name: string; prompt: string; llmProvider: string | null; llmModel: string | null }> {
|
||||
if (this.projects === undefined) {
|
||||
throw new Error('project chat is not wired into ChatService (no project repository)');
|
||||
}
|
||||
const project = await this.projects.findByName(projectName);
|
||||
if (project === null) throw new NotFoundError(`Project not found: ${projectName}`);
|
||||
return project;
|
||||
}
|
||||
|
||||
private async prepareProjectContext(args: ChatProjectRequestArgs): Promise<PreparedChatContext> {
|
||||
const project = await this.requireProject(args.projectName);
|
||||
if (project.llmProvider === null || project.llmProvider === '') {
|
||||
throw new NotFoundError(
|
||||
`Project '${project.name}' has no llmProvider set. Set one with 'mcpctl edit project ${project.name}' (llmProvider = an Llm name).`,
|
||||
);
|
||||
}
|
||||
const pinned = await this.llms.getByName(project.llmProvider);
|
||||
// project.llmModel overrides the pinned Llm's own model for this chat.
|
||||
const poolCandidates = await this.resolvePoolCandidates(pinned, project.llmModel ?? undefined);
|
||||
if (poolCandidates.length === 0) {
|
||||
throw new NotFoundError(
|
||||
`No active Llm in pool '${effectivePoolName(pinned)}' (project '${project.name}' → ${pinned.name})`,
|
||||
);
|
||||
}
|
||||
|
||||
const threadId = await this.resolveProjectThreadId(args, project.id);
|
||||
const projectId = project.id;
|
||||
|
||||
// System block: the project's own prompt text first, then its Prompts by
|
||||
// priority desc (reusing the agent-path filter). No agent/personality.
|
||||
const projectPrompts = (await this.promptRepo.findAll(projectId))
|
||||
.filter((p) => p.projectId === projectId)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
const mergedParams: AgentChatParams = { ...(args.params ?? {}) };
|
||||
const baseSystem = mergedParams.systemOverride ?? project.prompt;
|
||||
const systemBlock = [
|
||||
baseSystem,
|
||||
...projectPrompts.map((p) => p.content),
|
||||
mergedParams.systemAppend ?? '',
|
||||
]
|
||||
.filter((s) => s.length > 0)
|
||||
.join('\n\n');
|
||||
|
||||
const history = args.messagesOverride !== undefined
|
||||
? [...args.messagesOverride]
|
||||
: await this.loadHistory(threadId);
|
||||
|
||||
let startingTurnIndex = await this.chatRepo.nextTurnIndex(threadId);
|
||||
if (args.userMessage !== undefined && args.messagesOverride === undefined) {
|
||||
const userTurn = await this.chatRepo.appendMessage({ threadId, role: 'user', content: args.userMessage });
|
||||
startingTurnIndex = userTurn.turnIndex;
|
||||
history.push({ role: 'user', content: args.userMessage });
|
||||
}
|
||||
|
||||
const baseTools = await this.tools.listTools(projectId);
|
||||
// Gated secret-read tool: only offered when the caller asked (--allow-secrets)
|
||||
// AND holds view:secrets. Never exposed by default.
|
||||
const allowSecrets = await this.resolveAllowSecrets(args);
|
||||
const toolList = allowSecrets ? [...baseTools, secretReadTool()] : baseTools;
|
||||
const allowed = mergedParams.tools_allowlist;
|
||||
const filteredTools = allowed === undefined ? toolList : toolList.filter((t) => allowed.includes(t.name));
|
||||
|
||||
return {
|
||||
threadId,
|
||||
history,
|
||||
systemBlock,
|
||||
poolCandidates,
|
||||
mergedParams,
|
||||
toolList: filteredTools,
|
||||
projectId,
|
||||
startingTurnIndex,
|
||||
maxIterations: MAX_ITERATIONS,
|
||||
allowSecrets,
|
||||
};
|
||||
}
|
||||
|
||||
/** Gate the get_secret tool: requires the flag AND the caller's view:secrets. */
|
||||
private async resolveAllowSecrets(args: ChatProjectRequestArgs): Promise<boolean> {
|
||||
if (args.allowSecrets !== true) return false;
|
||||
if (this.secrets === undefined) return false;
|
||||
if (this.rbac === undefined) return true; // no RBAC wired (tests) → trust the flag
|
||||
return this.rbac.canAccess(args.ownerId, 'view', 'secrets');
|
||||
}
|
||||
|
||||
private async resolveProjectThreadId(args: ChatProjectRequestArgs, projectId: string): Promise<string> {
|
||||
if (args.threadId !== undefined) {
|
||||
const existing = await this.chatRepo.findThread(args.threadId);
|
||||
if (existing === null) throw new NotFoundError(`Thread not found: ${args.threadId}`);
|
||||
// Owner check on resume (mirrors listMessages): a foreign thread id 404s.
|
||||
if (existing.ownerId !== args.ownerId) throw new NotFoundError(`Thread not found: ${args.threadId}`);
|
||||
return existing.id;
|
||||
}
|
||||
const created = await this.chatRepo.createThread({ projectId, ownerId: args.ownerId });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* v4: resolve the load-balanced pool for an agent's pinned Llm. The
|
||||
* pool is "all Llms whose effective key (poolName ?? name) matches the
|
||||
@@ -684,7 +862,10 @@ export class ChatService {
|
||||
* doesn't get priority — if it happens to be down, a sibling takes
|
||||
* the call without the caller noticing.
|
||||
*/
|
||||
private async resolvePoolCandidates(pinned: { name: string; poolName: string | null }): Promise<PoolCandidate[]> {
|
||||
private async resolvePoolCandidates(
|
||||
pinned: { name: string; poolName: string | null },
|
||||
modelOverride?: string,
|
||||
): Promise<PoolCandidate[]> {
|
||||
const poolKey = effectivePoolName(pinned);
|
||||
const rows = await this.llms.findByPoolName(poolKey);
|
||||
const viable = rows.filter((r) => r.status !== 'inactive');
|
||||
@@ -697,7 +878,9 @@ export class ChatService {
|
||||
llmName: r.name,
|
||||
llmType: r.type,
|
||||
llmKind: r.kind as 'public' | 'virtual',
|
||||
modelOverride: r.model,
|
||||
// A project can pin all its pool members to one served model via
|
||||
// project.llmModel; otherwise each row uses its own configured model.
|
||||
modelOverride: modelOverride ?? r.model,
|
||||
url: r.url,
|
||||
apiKey,
|
||||
extraConfig: r.extraConfig as Record<string, unknown>,
|
||||
@@ -809,10 +992,23 @@ export class ChatService {
|
||||
return body;
|
||||
}
|
||||
|
||||
private async dispatchTool(toolWireName: string, argsJson: string, projectId: string | null): Promise<unknown> {
|
||||
private async dispatchTool(toolWireName: string, argsJson: string, projectId: string | null, allowSecrets: boolean): Promise<unknown> {
|
||||
if (projectId === null) {
|
||||
throw new Error('Tool calls require an agent attached to a Project');
|
||||
}
|
||||
// Gated virtual tool: reading a secret value. Only honored when the chat
|
||||
// was started with secret access AND the resolver is wired — never routes
|
||||
// to an MCP server. A hallucinated call without the gate fails loudly.
|
||||
if (toolWireName === `${SECRET_TOOL_SERVER}${TOOL_NAME_SEPARATOR}${SECRET_TOOL_NAME}`) {
|
||||
if (!allowSecrets || this.secrets === undefined) {
|
||||
throw new Error('secret access is not enabled for this chat');
|
||||
}
|
||||
const parsed = safeParseJson(argsJson) as { name?: unknown; key?: unknown };
|
||||
if (typeof parsed.name !== 'string' || typeof parsed.key !== 'string') {
|
||||
throw new Error('get_secret requires string arguments { name, key }');
|
||||
}
|
||||
return this.secrets.resolve(parsed.name, parsed.key);
|
||||
}
|
||||
const sep = toolWireName.indexOf(TOOL_NAME_SEPARATOR);
|
||||
if (sep === -1) {
|
||||
throw new Error(`Tool name '${toolWireName}' missing '${TOOL_NAME_SEPARATOR}' separator`);
|
||||
@@ -899,6 +1095,25 @@ function safeParseJson(s: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/** The gated virtual secret-read tool offered to project chat with --allow-secrets. */
|
||||
function secretReadTool(): ChatTool {
|
||||
return {
|
||||
name: `${SECRET_TOOL_SERVER}${TOOL_NAME_SEPARATOR}${SECRET_TOOL_NAME}`,
|
||||
description:
|
||||
'Read the value of one key inside a named mcpctl Secret. Use ONLY when you '
|
||||
+ 'genuinely need a credential or config value to complete the task. Returns '
|
||||
+ 'the raw secret value as text.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'The Secret resource name (see `mcpctl get secrets`).' },
|
||||
key: { type: 'string', description: "The key inside that secret's data map." },
|
||||
},
|
||||
required: ['name', 'key'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedStreamEvent {
|
||||
contentDelta?: string;
|
||||
/**
|
||||
|
||||
@@ -125,6 +125,33 @@ export const AgentChatRequestSchema = AgentChatParamsSchema.merge(
|
||||
message: 'Either `message` or `messages` is required',
|
||||
});
|
||||
|
||||
/**
|
||||
* Project-scoped chat request (`POST /api/v1/projects/:name/chat`). Same
|
||||
* LiteLLM-style params as an agent chat, minus the agent-only `personality`
|
||||
* overlay, plus `allowSecrets` to opt into the gated get_secret tool.
|
||||
*/
|
||||
export const ProjectChatRequestSchema = AgentChatParamsSchema.merge(
|
||||
z.object({
|
||||
threadId: z.string().min(1).optional(),
|
||||
message: z.string().min(1).optional(),
|
||||
messages: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(['system', 'user', 'assistant', 'tool']),
|
||||
content: z.string(),
|
||||
tool_call_id: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
stream: z.boolean().optional(),
|
||||
/** Opt into the gated get_secret tool (still requires the caller's view:secrets). */
|
||||
allowSecrets: z.boolean().optional(),
|
||||
}),
|
||||
).strict().refine((v) => v.message !== undefined || (v.messages?.length ?? 0) > 0, {
|
||||
message: 'Either `message` or `messages` is required',
|
||||
});
|
||||
|
||||
export type CreateAgentInput = z.infer<typeof CreateAgentSchema>;
|
||||
export type UpdateAgentInput = z.infer<typeof UpdateAgentSchema>;
|
||||
export type AgentChatRequest = z.infer<typeof AgentChatRequestSchema>;
|
||||
export type ProjectChatRequest = z.infer<typeof ProjectChatRequestSchema>;
|
||||
|
||||
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