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
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { bootstrapUserSpace } from "./bootstrap-user.js";
|
|
import { createDb, type PrismaClient } from "./client.js";
|
|
import { createGroupRepos } from "./groups.js";
|
|
import {
|
|
addMainConversationMembers,
|
|
ensureMainBot,
|
|
ensureMainConversation,
|
|
findMainBotId,
|
|
joinMainConversation,
|
|
loadUserContext,
|
|
MAIN_BOT_SPAWN_KEY,
|
|
} from "./main-agent.js";
|
|
import { createRepos } from "./repos.js";
|
|
import { requireMembership } from "./scope.js";
|
|
import { createSpaceForMember } from "./spaces.js";
|
|
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
const describePostgres =
|
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
|
|
|
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
|
|
|
|
describePostgres("main agent and main conversation (PostgreSQL)", () => {
|
|
let prisma: PrismaClient;
|
|
let close: () => Promise<void>;
|
|
const userIds: string[] = [];
|
|
|
|
async function newUser(name = "Ada") {
|
|
// bootstrapUserSpace derives the organization slug from the first 12 characters.
|
|
const id = `ma${randomUUID().replaceAll("-", "")}`;
|
|
userIds.push(id);
|
|
await prisma.user.create({ data: { id, name, email: `${id}@rakazo.test` } });
|
|
const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, {
|
|
claimDeploymentOwner: false,
|
|
});
|
|
return { userId: id, homeSpaceId: spaceId };
|
|
}
|
|
|
|
beforeAll(() => {
|
|
const db = createDb(databaseUrl!);
|
|
prisma = db.prisma;
|
|
close = async () => {
|
|
await db.prisma.$disconnect();
|
|
await db.pool.end();
|
|
};
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!prisma) return;
|
|
const memberships = await prisma.member.findMany({
|
|
where: { userId: { in: userIds } },
|
|
select: { organizationId: true },
|
|
});
|
|
await prisma.organization.deleteMany({
|
|
where: { id: { in: memberships.map((member) => member.organizationId) } },
|
|
});
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
|
await close();
|
|
});
|
|
|
|
it("creates one main bot in the home space, even under concurrent calls", async () => {
|
|
const { userId, homeSpaceId } = await newUser();
|
|
const results = await Promise.all([
|
|
ensureMainBot(prisma, userId),
|
|
ensureMainBot(prisma, userId),
|
|
ensureMainBot(prisma, userId),
|
|
]);
|
|
expect(new Set(results.map((bot) => bot.id)).size).toBe(1);
|
|
const bots = await prisma.bot.findMany({ where: { userId } });
|
|
expect(bots).toHaveLength(1);
|
|
expect(bots[0]).toMatchObject({
|
|
kind: "main",
|
|
spaceId: homeSpaceId,
|
|
spawnKey: MAIN_BOT_SPAWN_KEY,
|
|
name: "Chief",
|
|
});
|
|
expect(await findMainBotId(prisma, userId)).toBe(bots[0]?.id);
|
|
});
|
|
|
|
it("creates the main bot without the spawn key when another user holds it in a shared space", async () => {
|
|
const owner = await newUser();
|
|
const ownerMain = await ensureMainBot(prisma, owner.userId);
|
|
const guestId = `ma${randomUUID().replaceAll("-", "")}`;
|
|
userIds.push(guestId);
|
|
await prisma.user.create({
|
|
data: { id: guestId, name: "Guest", email: `${guestId}@rakazo.test` },
|
|
});
|
|
const organization = await prisma.space.findUniqueOrThrow({
|
|
where: { id: owner.homeSpaceId },
|
|
select: { organizationId: true },
|
|
});
|
|
// The organization's default-space trigger makes the guest a member of the owner's space.
|
|
await prisma.member.create({
|
|
data: {
|
|
id: `member-${guestId}`,
|
|
organizationId: organization.organizationId,
|
|
userId: guestId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
const guestMain = await ensureMainBot(prisma, guestId);
|
|
expect(guestMain.id).not.toBe(ownerMain.id);
|
|
const guestBot = await prisma.bot.findUniqueOrThrow({ where: { id: guestMain.id } });
|
|
expect(guestBot).toMatchObject({ kind: "main", userId: guestId, spawnKey: null });
|
|
});
|
|
|
|
it("promotes the first bot onboarding already created instead of adding another", async () => {
|
|
const { userId } = await newUser();
|
|
const actor = await requireMembership(prisma, userId);
|
|
const first = await createRepos(prisma).createBot(actor, {
|
|
name: "Nova",
|
|
title: "Chief of Staff",
|
|
description: "",
|
|
instructions: "",
|
|
notifyOnFinish: true,
|
|
spawnKey: MAIN_BOT_SPAWN_KEY,
|
|
});
|
|
expect(first.kind).toBe("specialist");
|
|
const main = await ensureMainBot(prisma, userId);
|
|
expect(main).toMatchObject({ id: first.id, name: "Nova" });
|
|
expect(await prisma.bot.count({ where: { userId } })).toBe(1);
|
|
});
|
|
|
|
it("restores an archived main bot rather than retiring it", async () => {
|
|
const { userId } = await newUser();
|
|
const main = await ensureMainBot(prisma, userId);
|
|
await prisma.bot.update({ where: { id: main.id }, data: { archivedAt: new Date() } });
|
|
const again = await ensureMainBot(prisma, userId);
|
|
expect(again.id).toBe(main.id);
|
|
const row = await prisma.bot.findUniqueOrThrow({ where: { id: main.id } });
|
|
expect(row.archivedAt).toBeNull();
|
|
});
|
|
|
|
it("creates one main conversation led by the main bot that lists with a single member", async () => {
|
|
const { userId } = await newUser();
|
|
const [a, b] = await Promise.all([
|
|
ensureMainConversation(prisma, userId),
|
|
ensureMainConversation(prisma, userId),
|
|
]);
|
|
expect(a.groupId).toBe(b.groupId);
|
|
const main = await ensureMainBot(prisma, userId);
|
|
expect(a).toMatchObject({ leadBotId: main.id, spaceId: main.spaceId });
|
|
|
|
const actor = await requireMembership(prisma, userId);
|
|
const groups = await createGroupRepos(prisma).listGroups(actor);
|
|
expect(groups).toHaveLength(1);
|
|
expect(groups[0]).toMatchObject({
|
|
id: a.groupId,
|
|
kind: "main",
|
|
leadBotId: main.id,
|
|
members: [expect.objectContaining({ botId: main.id })],
|
|
});
|
|
await expect(createGroupRepos(prisma).getGroupTarget(actor, a.groupId)).resolves.toMatchObject({
|
|
id: a.groupId,
|
|
});
|
|
});
|
|
|
|
it("repairs a main conversation whose lead was lost or that was archived", async () => {
|
|
const { userId } = await newUser();
|
|
const conversation = await ensureMainConversation(prisma, userId);
|
|
await prisma.chatGroup.update({
|
|
where: { id: conversation.groupId },
|
|
data: { leadBotId: null, archivedAt: new Date() },
|
|
});
|
|
const repaired = await ensureMainConversation(prisma, userId);
|
|
expect(repaired.groupId).toBe(conversation.groupId);
|
|
const row = await prisma.chatGroup.findUniqueOrThrow({ where: { id: conversation.groupId } });
|
|
expect(row).toMatchObject({ leadBotId: conversation.leadBotId, archivedAt: null });
|
|
});
|
|
|
|
it("adds only active same-space bots of the same user, once", async () => {
|
|
const { userId, homeSpaceId } = await newUser();
|
|
const other = await newUser("Bo");
|
|
const actor = await requireMembership(prisma, userId);
|
|
const repos = createRepos(prisma);
|
|
const specialist = await repos.createBot(actor, {
|
|
name: "Researcher",
|
|
title: "",
|
|
description: "",
|
|
instructions: "",
|
|
notifyOnFinish: true,
|
|
});
|
|
const project = await createSpaceForMember(prisma, {
|
|
currentSpaceId: homeSpaceId,
|
|
userId,
|
|
name: "Project",
|
|
});
|
|
const projectActor = await requireMembership(prisma, userId, project.id);
|
|
const elsewhere = await repos.createBot(projectActor, {
|
|
name: "Designer",
|
|
title: "",
|
|
description: "",
|
|
instructions: "",
|
|
notifyOnFinish: true,
|
|
});
|
|
const foreignActor = await requireMembership(prisma, other.userId);
|
|
const foreign = await repos.createBot(foreignActor, {
|
|
name: "Intruder",
|
|
title: "",
|
|
description: "",
|
|
instructions: "",
|
|
notifyOnFinish: true,
|
|
});
|
|
|
|
const added = await addMainConversationMembers(prisma, {
|
|
userId,
|
|
botIds: [specialist.id, elsewhere.id, foreign.id, specialist.id],
|
|
});
|
|
expect(added).toEqual([specialist.id]);
|
|
expect(await addMainConversationMembers(prisma, { userId, botIds: [specialist.id] })).toEqual(
|
|
[],
|
|
);
|
|
|
|
const conversation = await ensureMainConversation(prisma, userId);
|
|
const members = await prisma.chatGroupMember.findMany({
|
|
where: { groupId: conversation.groupId },
|
|
select: { botId: true },
|
|
});
|
|
expect(members.map((member) => member.botId).sort()).toEqual(
|
|
[conversation.leadBotId, specialist.id].sort(),
|
|
);
|
|
});
|
|
|
|
it("announces joined members on the main conversation thread", async () => {
|
|
const { userId } = await newUser();
|
|
const actor = await requireMembership(prisma, userId);
|
|
const specialist = await createRepos(prisma).createBot(actor, {
|
|
name: "Analyst",
|
|
title: "",
|
|
description: "",
|
|
instructions: "",
|
|
notifyOnFinish: true,
|
|
});
|
|
const published: string[] = [];
|
|
const realtime = {
|
|
publish: async (topic: string) => {
|
|
published.push(topic);
|
|
},
|
|
subscribe: async () => () => undefined,
|
|
};
|
|
expect(
|
|
await joinMainConversation(prisma, { userId, botIds: [specialist.id] }, realtime as never),
|
|
).toEqual([specialist.id]);
|
|
expect(
|
|
await joinMainConversation(prisma, { userId, botIds: [specialist.id] }, realtime as never),
|
|
).toEqual([]);
|
|
const conversation = await ensureMainConversation(prisma, userId);
|
|
const events = await prisma.event.findMany({
|
|
where: { threadId: conversation.threadId, type: "group.updated" },
|
|
});
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]?.payload).toEqual({
|
|
groupId: conversation.groupId,
|
|
addedBotIds: [specialist.id],
|
|
});
|
|
expect(published).toHaveLength(1);
|
|
});
|
|
|
|
it("refuses to replace the main conversation's members through a group update", async () => {
|
|
const { userId } = await newUser();
|
|
const actor = await requireMembership(prisma, userId);
|
|
const repos = createRepos(prisma);
|
|
const one = await repos.createBot(actor, {
|
|
name: "One",
|
|
title: "",
|
|
description: "",
|
|
instructions: "",
|
|
notifyOnFinish: true,
|
|
});
|
|
const two = await repos.createBot(actor, {
|
|
name: "Two",
|
|
title: "",
|
|
description: "",
|
|
instructions: "",
|
|
notifyOnFinish: true,
|
|
});
|
|
const conversation = await ensureMainConversation(prisma, userId);
|
|
await expect(
|
|
createGroupRepos(prisma).updateGroup(actor, {
|
|
groupId: conversation.groupId,
|
|
botIds: [one.id, two.id],
|
|
}),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
createGroupRepos(prisma).updateGroup(actor, { groupId: conversation.groupId, pinned: true }),
|
|
).resolves.toMatchObject({ group: { kind: "main", pinned: true } });
|
|
});
|
|
|
|
it("hands runs only active facts visible in their space", async () => {
|
|
const { userId, homeSpaceId } = await newUser();
|
|
const project = await createSpaceForMember(prisma, {
|
|
currentSpaceId: homeSpaceId,
|
|
userId,
|
|
name: "Project",
|
|
});
|
|
await prisma.userProfile.create({
|
|
data: { userId, timeZone: "Europe/Berlin", locale: "de-DE", communicationStyle: "direct" },
|
|
});
|
|
await prisma.userFact.create({
|
|
data: { userId, category: "work", value: "Builds evOo", source: "onboarding" },
|
|
});
|
|
await prisma.userFact.create({
|
|
data: {
|
|
userId,
|
|
category: "health",
|
|
value: "Pending sensitive fact",
|
|
source: "conversation",
|
|
status: "pending",
|
|
sensitive: true,
|
|
},
|
|
});
|
|
await prisma.userFact.create({
|
|
data: {
|
|
userId,
|
|
category: "goals",
|
|
value: "Ship the project",
|
|
source: "user",
|
|
allSpaces: false,
|
|
visibleSpaces: { create: [{ spaceId: project.id }] },
|
|
},
|
|
});
|
|
|
|
const home = await loadUserContext(prisma, { userId, spaceId: homeSpaceId });
|
|
expect(home).toMatchObject({
|
|
name: "Ada",
|
|
timeZone: "Europe/Berlin",
|
|
locale: "de-DE",
|
|
communicationStyle: "direct",
|
|
});
|
|
expect(home.facts.map((fact) => fact.value)).toEqual(["Builds evOo"]);
|
|
|
|
const inProject = await loadUserContext(prisma, { userId, spaceId: project.id });
|
|
expect(inProject.facts.map((fact) => fact.value).sort()).toEqual(
|
|
["Builds evOo", "Ship the project"].sort(),
|
|
);
|
|
});
|
|
});
|