From 3726a65f535ce170f95ed68070e0fa8bf8d23cba Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 25 Apr 2026 16:29:55 +0100 Subject: [PATCH] feat(agents): add Agent + ChatThread + ChatMessage schema (Stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the persistence layer for the upcoming Agent feature: an LLM persona pinned to a specific Llm, optionally attached to a Project, with persisted chat threads/messages so conversations survive REPL exits. Constraint shape: - Agent.llm uses ON DELETE RESTRICT — deleting an Llm in active use fails. - Agent.project uses ON DELETE SET NULL — agents survive project deletion. - ChatThread → ChatMessage cascade so deleting an agent purges its history. - ChatMessage @@unique([threadId, turnIndex]) gives append ordering even under racing writers (services retry on collision). LiteLLM-style per-call overrides will live in Agent.defaultParams (Json); the loose extras Json field is reserved for future LoRA/tool-allowlist work. Pinned vitest fileParallelism=false in @mcpctl/db: all suites share the same Postgres, and adding a second suite exposed FK contention between a clearAllTables in one file and a create in another. Per-test isolation still comes from beforeEach. Tests: 8/8 green in src/db/tests/agent-schema.test.ts (defaults, name uniqueness, llm-in-use Restrict, project-delete SetNull, agent-delete cascade, duplicate (threadId, turnIndex) blocked, tool-call payload round-trip, lastTurnAt DESC ordering). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 91 ++++++++ src/db/prisma/schema.prisma | 79 +++++++ src/db/tests/agent-schema.test.ts | 204 ++++++++++++++++++ src/db/tests/helpers.ts | 4 + src/db/vitest.config.ts | 5 + 5 files changed, 383 insertions(+) create mode 100644 src/db/prisma/migrations/20260425160000_add_agents_and_chat/migration.sql create mode 100644 src/db/tests/agent-schema.test.ts diff --git a/src/db/prisma/migrations/20260425160000_add_agents_and_chat/migration.sql b/src/db/prisma/migrations/20260425160000_add_agents_and_chat/migration.sql new file mode 100644 index 0000000..e341dfa --- /dev/null +++ b/src/db/prisma/migrations/20260425160000_add_agents_and_chat/migration.sql @@ -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; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 75467d3..4089f87 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -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-/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 { diff --git a/src/db/tests/agent-schema.test.ts b/src/db/tests/agent-schema.test.ts new file mode 100644 index 0000000..0761735 --- /dev/null +++ b/src/db/tests/agent-schema.test.ts @@ -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]); + }); +}); diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index 86437f0..ed742a2 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -30,6 +30,10 @@ export async function clearAllTables(client: PrismaClient): Promise { // 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(); diff --git a/src/db/vitest.config.ts b/src/db/vitest.config.ts index 0df0fc2..f67be52 100644 --- a/src/db/vitest.config.ts +++ b/src/db/vitest.config.ts @@ -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, }, });