feat(agents+chat): agents feature + live chat UX #57
@@ -0,0 +1,91 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Agent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"systemPrompt" TEXT NOT NULL DEFAULT '',
|
||||
"llmId" TEXT NOT NULL,
|
||||
"projectId" TEXT,
|
||||
"proxyModelName" TEXT,
|
||||
"defaultParams" JSONB NOT NULL DEFAULT '{}',
|
||||
"extras" JSONB NOT NULL DEFAULT '{}',
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Agent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChatThread" (
|
||||
"id" TEXT NOT NULL,
|
||||
"agentId" TEXT NOT NULL,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL DEFAULT '',
|
||||
"lastTurnAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ChatThread_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChatMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"threadId" TEXT NOT NULL,
|
||||
"turnIndex" INTEGER NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"toolCalls" JSONB,
|
||||
"toolCallId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'complete',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Agent_name_key" ON "Agent"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Agent_name_idx" ON "Agent"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Agent_llmId_idx" ON "Agent"("llmId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Agent_projectId_idx" ON "Agent"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Agent_ownerId_idx" ON "Agent"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChatThread_agentId_lastTurnAt_idx" ON "ChatThread"("agentId", "lastTurnAt" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChatThread_ownerId_idx" ON "ChatThread"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChatMessage_threadId_createdAt_idx" ON "ChatMessage"("threadId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ChatMessage_threadId_turnIndex_key" ON "ChatMessage"("threadId", "turnIndex");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_llmId_fkey" FOREIGN KEY ("llmId") REFERENCES "Llm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "ChatThread"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -26,6 +26,8 @@ model User {
|
||||
ownedProjects Project[]
|
||||
groupMemberships GroupMember[]
|
||||
mcpTokens McpToken[]
|
||||
ownedAgents Agent[]
|
||||
chatThreads ChatThread[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
@@ -197,6 +199,7 @@ model Llm {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
|
||||
agents Agent[]
|
||||
|
||||
@@index([name])
|
||||
@@index([tier])
|
||||
@@ -268,6 +271,7 @@ model Project {
|
||||
prompts Prompt[]
|
||||
promptRequests PromptRequest[]
|
||||
mcpTokens McpToken[]
|
||||
agents Agent[]
|
||||
|
||||
@@index([name])
|
||||
@@index([ownerId])
|
||||
@@ -427,6 +431,81 @@ model BackupPending {
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// ── Agents (LLM personas pinned to a specific Llm) ──
|
||||
//
|
||||
// Agents combine a system prompt, a pinned LLM, and (optionally) a project to
|
||||
// inherit Prompts from. Each Agent is also exposed by mcplocal as a virtual
|
||||
// MCP server (`agent-<name>/chat`), so other clients can consult it as a tool.
|
||||
// Per-call LiteLLM-style overrides stack on top of `defaultParams`.
|
||||
|
||||
model Agent {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String @default("") // shown in MCP tools/list
|
||||
systemPrompt String @default("") @db.Text // agent persona
|
||||
llmId String
|
||||
projectId String?
|
||||
proxyModelName String? // optional informational override
|
||||
defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ...
|
||||
extras Json @default("{}") // future LoRA / tool-allowlist
|
||||
ownerId String
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
threads ChatThread[]
|
||||
|
||||
@@index([name])
|
||||
@@index([llmId])
|
||||
@@index([projectId])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
// ── Chat Threads (persisted conversation per Agent) ──
|
||||
|
||||
model ChatThread {
|
||||
id String @id @default(cuid())
|
||||
agentId 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)
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
|
||||
@@index([agentId, lastTurnAt(sort: Desc)])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
// ── Chat Messages ──
|
||||
//
|
||||
// `turnIndex` is monotonic per thread; the @@unique enforces ordering even
|
||||
// under racing appends (callers retry on collision). `status` stays `pending`
|
||||
// until the orchestrator confirms the turn completed successfully.
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(cuid())
|
||||
threadId String
|
||||
turnIndex Int
|
||||
role String // 'system' | 'user' | 'assistant' | 'tool'
|
||||
content String @db.Text
|
||||
toolCalls Json? // assistant turn: [{id,name,arguments}]
|
||||
toolCallId String? // tool turn: which call this answers
|
||||
status String @default("complete") // 'pending' | 'complete' | 'error'
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([threadId, turnIndex])
|
||||
@@index([threadId, createdAt])
|
||||
}
|
||||
|
||||
// ── Audit Logs ──
|
||||
|
||||
model AuditLog {
|
||||
|
||||
204
src/db/tests/agent-schema.test.ts
Normal file
204
src/db/tests/agent-schema.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
|
||||
|
||||
describe('agent / chat-thread / chat-message schema', () => {
|
||||
let prisma: PrismaClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = await setupTestDb();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAllTables(prisma);
|
||||
});
|
||||
|
||||
async function makeUser(suffix = '') {
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
email: `agent-test-${Date.now()}${suffix}@example.com`,
|
||||
name: 'Agent Tester',
|
||||
passwordHash: 'x',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function makeLlm(name: string) {
|
||||
return prisma.llm.create({
|
||||
data: { name, type: 'openai', model: 'qwen3-thinking' },
|
||||
});
|
||||
}
|
||||
|
||||
async function makeProject(ownerId: string, name: string) {
|
||||
return prisma.project.create({ data: { name, ownerId } });
|
||||
}
|
||||
|
||||
async function makeAgent(opts: {
|
||||
name: string;
|
||||
llmId: string;
|
||||
ownerId: string;
|
||||
projectId?: string;
|
||||
}) {
|
||||
return prisma.agent.create({
|
||||
data: {
|
||||
name: opts.name,
|
||||
llmId: opts.llmId,
|
||||
ownerId: opts.ownerId,
|
||||
projectId: opts.projectId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('creates an agent with required fields and JSON defaults', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-default-fields');
|
||||
const agent = await makeAgent({ name: 'a1', llmId: llm.id, ownerId: user.id });
|
||||
|
||||
expect(agent.id).toBeDefined();
|
||||
expect(agent.description).toBe('');
|
||||
expect(agent.systemPrompt).toBe('');
|
||||
expect(agent.defaultParams).toEqual({});
|
||||
expect(agent.extras).toEqual({});
|
||||
expect(agent.version).toBe(1);
|
||||
});
|
||||
|
||||
it('enforces unique agent name', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-uniq');
|
||||
await makeAgent({ name: 'dup', llmId: llm.id, ownerId: user.id });
|
||||
await expect(
|
||||
makeAgent({ name: 'dup', llmId: llm.id, ownerId: user.id }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('blocks deleting an Llm referenced by an agent (Restrict)', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-in-use');
|
||||
await makeAgent({ name: 'pinned', llmId: llm.id, ownerId: user.id });
|
||||
|
||||
await expect(prisma.llm.delete({ where: { id: llm.id } })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('sets agent.projectId NULL when its Project is deleted (SetNull)', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-setnull');
|
||||
const project = await makeProject(user.id, 'proj-detach');
|
||||
const agent = await makeAgent({
|
||||
name: 'detachable',
|
||||
llmId: llm.id,
|
||||
ownerId: user.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
expect(agent.projectId).toBe(project.id);
|
||||
|
||||
await prisma.project.delete({ where: { id: project.id } });
|
||||
const reloaded = await prisma.agent.findUnique({ where: { id: agent.id } });
|
||||
expect(reloaded?.projectId).toBeNull();
|
||||
});
|
||||
|
||||
it('cascades thread + message delete when an Agent is deleted', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-cascade');
|
||||
const agent = await makeAgent({ name: 'doomed', llmId: llm.id, ownerId: user.id });
|
||||
const thread = await prisma.chatThread.create({
|
||||
data: { agentId: agent.id, ownerId: user.id, title: 't' },
|
||||
});
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
turnIndex: 0,
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.agent.delete({ where: { id: agent.id } });
|
||||
|
||||
expect(await prisma.chatThread.findUnique({ where: { id: thread.id } })).toBeNull();
|
||||
expect(await prisma.chatMessage.count({ where: { threadId: thread.id } })).toBe(0);
|
||||
});
|
||||
|
||||
it('blocks duplicate (threadId, turnIndex)', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-turn-uniq');
|
||||
const agent = await makeAgent({ name: 'orderly', llmId: llm.id, ownerId: user.id });
|
||||
const thread = await prisma.chatThread.create({
|
||||
data: { agentId: agent.id, ownerId: user.id },
|
||||
});
|
||||
await prisma.chatMessage.create({
|
||||
data: { threadId: thread.id, turnIndex: 0, role: 'user', content: 'a' },
|
||||
});
|
||||
await expect(
|
||||
prisma.chatMessage.create({
|
||||
data: { threadId: thread.id, turnIndex: 0, role: 'assistant', content: 'b' },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('persists tool-call shape on assistant + tool turns', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-tools');
|
||||
const agent = await makeAgent({ name: 'toolish', llmId: llm.id, ownerId: user.id });
|
||||
const thread = await prisma.chatThread.create({
|
||||
data: { agentId: agent.id, ownerId: user.id },
|
||||
});
|
||||
|
||||
await prisma.chatMessage.create({
|
||||
data: { threadId: thread.id, turnIndex: 0, role: 'user', content: 'do x' },
|
||||
});
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
turnIndex: 1,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{ id: 'call_1', name: 'do_thing', arguments: { x: 1 } },
|
||||
],
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
turnIndex: 2,
|
||||
role: 'tool',
|
||||
content: 'ok',
|
||||
toolCallId: 'call_1',
|
||||
},
|
||||
});
|
||||
|
||||
const messages = await prisma.chatMessage.findMany({
|
||||
where: { threadId: thread.id },
|
||||
orderBy: { turnIndex: 'asc' },
|
||||
});
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(messages[1]?.toolCalls).toEqual([
|
||||
{ id: 'call_1', name: 'do_thing', arguments: { x: 1 } },
|
||||
]);
|
||||
expect(messages[2]?.toolCallId).toBe('call_1');
|
||||
});
|
||||
|
||||
it('orders threads by lastTurnAt DESC for an agent', async () => {
|
||||
const user = await makeUser();
|
||||
const llm = await makeLlm('llm-order');
|
||||
const agent = await makeAgent({ name: 'history', llmId: llm.id, ownerId: user.id });
|
||||
|
||||
const t1 = await prisma.chatThread.create({
|
||||
data: { agentId: agent.id, ownerId: user.id, lastTurnAt: new Date(2000, 0, 1) },
|
||||
});
|
||||
const t2 = await prisma.chatThread.create({
|
||||
data: { agentId: agent.id, ownerId: user.id, lastTurnAt: new Date(2030, 0, 1) },
|
||||
});
|
||||
|
||||
const ordered = await prisma.chatThread.findMany({
|
||||
where: { agentId: agent.id },
|
||||
orderBy: { lastTurnAt: 'desc' },
|
||||
});
|
||||
expect(ordered.map((t) => t.id)).toEqual([t2.id, t1.id]);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,10 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
|
||||
// Delete in order respecting foreign keys
|
||||
await client.auditEvent.deleteMany();
|
||||
await client.auditLog.deleteMany();
|
||||
await client.chatMessage.deleteMany();
|
||||
await client.chatThread.deleteMany();
|
||||
await client.agent.deleteMany();
|
||||
await client.llm.deleteMany();
|
||||
await client.mcpInstance.deleteMany();
|
||||
await client.promptRequest.deleteMany();
|
||||
await client.prompt.deleteMany();
|
||||
|
||||
@@ -6,5 +6,10 @@ export default defineConfig({
|
||||
include: ['tests/**/*.test.ts'],
|
||||
// Schema pushed once by globalSetup before any tests.
|
||||
globalSetup: ['tests/global-setup.ts'],
|
||||
// All test files share one Postgres database. Running them in parallel
|
||||
// causes cross-file FK contention (e.g. one file's beforeEach clears Llm
|
||||
// while another file is mid-create). Per-test isolation still comes from
|
||||
// beforeEach; this just serializes files.
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user