feat(agents+chat): agents feature + live chat UX #57

Merged
michal merged 14 commits from feat/agents-and-chat-ux into main 2026-04-26 17:53:30 +00:00
5 changed files with 383 additions and 0 deletions
Showing only changes of commit 3726a65f53 - Show all commits

View File

@@ -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;

View File

@@ -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 {

View 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]);
});
});

View File

@@ -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();

View File

@@ -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,
}, },
}); });