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[]
|
ownedProjects Project[]
|
||||||
groupMemberships GroupMember[]
|
groupMemberships GroupMember[]
|
||||||
mcpTokens McpToken[]
|
mcpTokens McpToken[]
|
||||||
|
ownedAgents Agent[]
|
||||||
|
chatThreads ChatThread[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
@@ -197,6 +199,7 @@ model Llm {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
|
apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
|
||||||
|
agents Agent[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([tier])
|
@@index([tier])
|
||||||
@@ -268,6 +271,7 @@ model Project {
|
|||||||
prompts Prompt[]
|
prompts Prompt[]
|
||||||
promptRequests PromptRequest[]
|
promptRequests PromptRequest[]
|
||||||
mcpTokens McpToken[]
|
mcpTokens McpToken[]
|
||||||
|
agents Agent[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@ -427,6 +431,81 @@ model BackupPending {
|
|||||||
@@index([createdAt])
|
@@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 ──
|
// ── Audit Logs ──
|
||||||
|
|
||||||
model AuditLog {
|
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
|
// Delete in order respecting foreign keys
|
||||||
await client.auditEvent.deleteMany();
|
await client.auditEvent.deleteMany();
|
||||||
await client.auditLog.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.mcpInstance.deleteMany();
|
||||||
await client.promptRequest.deleteMany();
|
await client.promptRequest.deleteMany();
|
||||||
await client.prompt.deleteMany();
|
await client.prompt.deleteMany();
|
||||||
|
|||||||
@@ -6,5 +6,10 @@ export default defineConfig({
|
|||||||
include: ['tests/**/*.test.ts'],
|
include: ['tests/**/*.test.ts'],
|
||||||
// Schema pushed once by globalSetup before any tests.
|
// Schema pushed once by globalSetup before any tests.
|
||||||
globalSetup: ['tests/global-setup.ts'],
|
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