evOo-local/packages/db/src/repos.test.ts
cynarAI 86723f0f6f
feat(db): main agent, main conversation, user model and delegated task foundation
The product needs one main agent that leads a main conversation, knows the
user across spaces, and hands work to visible specialists. Today none of
that exists in the data model: "Chief" is a client-side naming convention,
the "Hauptthread" is a group the web client creates by name, and nothing
stores what the agents know about the user.

This adds the shared foundation the Phase 1 features build on:

- Bot.kind ("main" | "specialist", one main bot per user) plus capability
  fields (tags, builtin tool allowlist, model tier).
- ChatGroup.kind ("group" | "main", one main conversation per user) and
  ChatGroup.leadBotId.
- UserProfile and UserFact with category, confidence, source, consent
  status and per-space visibility; embeddings are stored as float arrays
  because none of the Postgres images ship pgvector.
- DelegatedTask for work the main bot hands to a specialist.
- SpaceModelTier to map a model tier to a connected model per space.

The migration backfills each user's onboarding bot in the home space as the
main bot and turns an existing "Hauptthread" group containing it into the
main conversation. CHECK and foreign key constraints on existing tables are
added NOT VALID and validated in a separate migration, as elsewhere in this
repo.

New db helpers ensure the main bot and the main conversation (idempotent and
race-safe, repairing a lost lead), add same-space specialists to the main
conversation and announce it with a group.updated event, and load the user
context a run may see. The main conversation is exempt from the two-member
floor and cannot have its members replaced through a group update. The
contracts expose the new fields and the user model and delegated task
schemas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 20:49:36 +00:00

386 lines
12 KiB
TypeScript

import type { Actor } from "@rakazo/contracts";
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "./client.js";
import { createRepos } from "./repos.js";
import { IsolationError } from "./scope.js";
const actor: Actor = {
userId: "user-1",
spaceId: "ws-1",
email: "test@example.com",
isDeploymentOwner: false,
};
const baseBot = {
id: "bot-1",
spaceId: "ws-1",
userId: "user-1",
name: "Test Bot",
title: "",
description: "",
instructions: "",
color: "#000",
notifyOnFinish: true,
pinned: false,
position: 0,
sectionId: null,
archivedAt: null,
parentBotId: null,
memoryScope: null as string | null,
createdAt: new Date("2026-08-19T00:00:00.000Z"),
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
thread: { id: "thread-1", unread: false, messages: [] },
runs: [],
computer: null,
};
function reposFor(memoryScope: string | null) {
const prisma = {
bot: {
findMany: vi.fn(async () => [{ ...baseBot, memoryScope }]),
},
run: {
findMany: vi.fn(async () => []),
},
};
return createRepos(prisma as unknown as PrismaClient);
}
describe("createRepos.listBots", () => {
it("passes memoryScope through as null when unset", async () => {
await expect(reposFor(null).listBots(actor)).resolves.toEqual([
expect.objectContaining({ memoryScope: null }),
]);
});
it("passes memoryScope through when set to shared", async () => {
await expect(reposFor("shared").listBots(actor)).resolves.toEqual([
expect.objectContaining({ memoryScope: "shared" }),
]);
});
it("classifies initial preview runs once across bots, including non-peer results", async () => {
const prisma = {
bot: {
findMany: vi.fn(async () =>
["one", "two"].map((id) => ({
...baseBot,
id,
thread: {
...baseBot.thread,
id: `thread-${id}`,
messages: [{ runId: `run-${id}`, blocks: [{ kind: "text", text: `Answer ${id}` }] }],
},
})),
),
},
run: { findMany: vi.fn(async () => []) },
};
const bots = await createRepos(prisma as unknown as PrismaClient).listBots(actor);
expect(bots.map((bot) => bot.preview)).toEqual(["Answer one", "Answer two"]);
expect(prisma.run.findMany).toHaveBeenCalledExactlyOnceWith({
where: { id: { in: ["run-one", "run-two"] }, trigger: "bot_message" },
select: { id: true },
});
});
it("classifies unseen runs in older pages and retains negative results between pages", async () => {
const prisma = {
bot: {
findMany: vi.fn(async () => [
{
...baseBot,
thread: {
...baseBot.thread,
messages: [
{ seq: 30, runId: "peer-new", blocks: [{ kind: "text", text: "Hidden" }] },
],
},
},
]),
},
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "peer-new" }])
.mockResolvedValueOnce([{ id: "peer-old" }]),
},
message: {
findMany: vi
.fn()
.mockResolvedValueOnce([
{ seq: 20, runId: "peer-old", blocks: [{ kind: "text", text: "Also hidden" }] },
{ seq: 19, runId: "user-old", blocks: [] },
])
.mockResolvedValueOnce([
{ seq: 10, runId: "peer-old", blocks: [{ kind: "text", text: "Still hidden" }] },
{ seq: 9, runId: "user-old", blocks: [{ kind: "text", text: "Visible answer" }] },
]),
},
};
const bots = await createRepos(prisma as unknown as PrismaClient).listBots(actor);
expect(bots[0]?.preview).toBe("Visible answer");
expect(prisma.run.findMany).toHaveBeenCalledTimes(2);
expect(prisma.run.findMany).toHaveBeenNthCalledWith(2, {
where: { id: { in: ["peer-old", "user-old"] }, trigger: "bot_message" },
select: { id: true },
});
expect(prisma.message.findMany).toHaveBeenCalledTimes(2);
});
it("keeps bot-to-bot run output out of sidebar previews", async () => {
const findMany = vi.fn(async () => [
{
...baseBot,
thread: {
...baseBot.thread,
messages: [
{
runId: "run-peer",
blocks: [{ kind: "text", text: "Echoed peer reply" }],
},
{
runId: "run-peer",
blocks: [
{
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Coder",
text: "Peer result",
},
],
},
{ runId: "run-user", blocks: [{ kind: "text", text: "Visible answer" }] },
],
},
},
]);
const prisma = {
bot: {
findMany,
},
run: {
findMany: vi.fn(async () => [{ id: "run-peer" }]),
},
};
await expect(createRepos(prisma as unknown as PrismaClient).listBots(actor)).resolves.toEqual([
expect.objectContaining({ preview: "Visible answer" }),
]);
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({
thread: {
include: {
messages: { orderBy: { seq: "desc" }, take: 16 },
},
},
}),
}),
);
});
it("skips a peer-run preview tail when the receipt is outside the window", async () => {
const prisma = {
bot: {
findMany: vi.fn(async () => [
{
...baseBot,
thread: {
...baseBot.thread,
messages: [
{
runId: "run-peer",
blocks: [{ kind: "text", text: "Echoed peer reply" }],
},
{ runId: "run-user", blocks: [{ kind: "text", text: "Visible answer" }] },
],
},
},
]),
},
run: {
findMany: vi.fn(async () => [{ id: "run-peer" }]),
},
message: {
findMany: vi.fn(async () => []),
},
};
await expect(createRepos(prisma as unknown as PrismaClient).listBots(actor)).resolves.toEqual([
expect.objectContaining({ preview: "Visible answer" }),
]);
});
it("scans older messages when the newest window is only peer output", async () => {
const messageFindMany = vi.fn(async () => [
{ seq: 1, runId: "run-user", blocks: [{ kind: "text", text: "Older visible answer" }] },
]);
const prisma = {
bot: {
findMany: vi.fn(async () => [
{
...baseBot,
thread: {
...baseBot.thread,
messages: [
{
seq: 20,
runId: "run-peer",
blocks: [{ kind: "text", text: "Echoed peer reply" }],
},
],
},
},
]),
},
run: {
findMany: vi.fn(async () => [{ id: "run-peer" }]),
},
message: {
findMany: messageFindMany,
},
};
await expect(createRepos(prisma as unknown as PrismaClient).listBots(actor)).resolves.toEqual([
expect.objectContaining({ preview: "Older visible answer" }),
]);
expect(messageFindMany).toHaveBeenCalledWith({
where: { threadId: "thread-1", seq: { lt: 20 } },
orderBy: { seq: "desc" },
take: 16,
});
});
it("uses a visible message from the fourth older window for preview", async () => {
const peerWindows = [
[{ seq: 80, runId: "run-peer", blocks: [{ kind: "text", text: "peer 80" }] }],
[{ seq: 60, runId: "run-peer", blocks: [{ kind: "text", text: "peer 60" }] }],
[{ seq: 40, runId: "run-peer", blocks: [{ kind: "text", text: "peer 40" }] }],
[{ seq: 20, runId: "run-peer", blocks: [{ kind: "text", text: "peer 20" }] }],
[{ seq: 1, runId: "run-user", blocks: [{ kind: "text", text: "Fourth-window answer" }] }],
];
let windowIndex = 0;
const messageFindMany = vi.fn(async () => {
windowIndex += 1;
return peerWindows[windowIndex] ?? [];
});
const prisma = {
bot: {
findMany: vi.fn(async () => [
{
...baseBot,
thread: {
...baseBot.thread,
messages: peerWindows[0],
},
},
]),
},
run: {
findMany: vi.fn(async () => [{ id: "run-peer" }]),
},
message: {
findMany: messageFindMany,
},
};
await expect(createRepos(prisma as unknown as PrismaClient).listBots(actor)).resolves.toEqual([
expect.objectContaining({ preview: "Fourth-window answer" }),
]);
expect(messageFindMany).toHaveBeenCalledTimes(4);
});
});
describe("createRepos.listSpaceBotsForSpaces", () => {
it("loads and maps only the compact cross-space sidebar fields", async () => {
const findMany = vi.fn(async (_query: { where: unknown; select: Record<string, unknown> }) => [
{
id: "bot-2",
spaceId: "ws-2",
kind: "specialist",
name: "Support",
title: "Customer support",
color: "#123456",
notifyOnFinish: false,
pinned: true,
sectionId: null,
updatedAt: new Date("2026-08-20T00:00:00.000Z"),
thread: {
unread: true,
messages: [{ blocks: [{ kind: "text", text: "Waiting for a reply" }] }],
},
runs: [{ status: "running" }],
},
]);
const repos = createRepos({ bot: { findMany } } as unknown as PrismaClient);
await expect(repos.listSpaceBotsForSpaces(actor, ["ws-2"])).resolves.toEqual([
{
id: "bot-2",
spaceId: "ws-2",
kind: "specialist",
name: "Support",
title: "Customer support",
color: "#123456",
notifyOnFinish: false,
pinned: true,
sectionId: null,
unread: true,
preview: "Waiting for a reply",
status: "running",
updatedAt: "2026-08-20T00:00:00.000Z",
},
]);
const query = findMany.mock.calls[0]![0];
expect(query.where).toEqual(
expect.objectContaining({ spaceId: { in: ["ws-2"] }, userId: actor.userId }),
);
expect(query.select).not.toHaveProperty("description");
expect(query.select).not.toHaveProperty("instructions");
expect(query.select).not.toHaveProperty("computer");
});
});
describe("createRepos.reorderBots", () => {
function reorderRepos(ids: string[]) {
const update = vi.fn().mockResolvedValue({});
const tx = {
bot: {
findMany: vi.fn().mockResolvedValue(ids.map((id) => ({ id }))),
update,
},
};
const prisma = {
$transaction: vi.fn((run: (client: typeof tx) => Promise<void>) => run(tx)),
};
return { repos: createRepos(prisma as unknown as PrismaClient), update };
}
it("writes each owned bot's requested position", async () => {
const { repos, update } = reorderRepos(["bot-1", "bot-2"]);
await repos.reorderBots(actor, ["bot-2", "bot-1"]);
expect(update).toHaveBeenNthCalledWith(1, {
where: { id: "bot-2" },
data: { position: 0 },
});
expect(update).toHaveBeenNthCalledWith(2, {
where: { id: "bot-1" },
data: { position: 1 },
});
});
it("rejects partial or foreign bot lists before writing", async () => {
const { repos, update } = reorderRepos(["bot-1", "bot-2"]);
await expect(repos.reorderBots(actor, ["bot-1"])).rejects.toBeInstanceOf(IsolationError);
await expect(repos.reorderBots(actor, ["bot-1", "foreign"])).rejects.toBeInstanceOf(
IsolationError,
);
expect(update).not.toHaveBeenCalled();
});
});