Each project (space) now has a main agent and a main conversation of its own. The user talks to it, and it delegates to the project's specialists. Creating a project sets both up and opens the conversation, and deleting an otherwise empty project removes them with it. Claude-Session: https://claude.ai/code/session_01R8hSsKTB3W3e61wD6kvzur Co-authored-by: Claude <noreply@anthropic.com>
373 lines
13 KiB
TypeScript
373 lines
13 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,
|
|
PROJECT_MAIN_BOT_DEFAULTS,
|
|
} 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("gives every project its own main bot and main conversation next to the home one", async () => {
|
|
const { userId, homeSpaceId } = await newUser();
|
|
const home = await ensureMainConversation(prisma, userId);
|
|
const project = await createSpaceForMember(prisma, {
|
|
currentSpaceId: homeSpaceId,
|
|
userId,
|
|
name: "Launch",
|
|
});
|
|
const [a, b] = await Promise.all([
|
|
ensureMainConversation(prisma, userId, project.id),
|
|
ensureMainConversation(prisma, userId, project.id),
|
|
]);
|
|
expect(a.groupId).toBe(b.groupId);
|
|
expect(a).toMatchObject({ spaceId: project.id });
|
|
expect(a.groupId).not.toBe(home.groupId);
|
|
expect(a.leadBotId).not.toBe(home.leadBotId);
|
|
|
|
const lead = await prisma.bot.findUniqueOrThrow({ where: { id: a.leadBotId } });
|
|
expect(lead).toMatchObject({
|
|
kind: "main",
|
|
spaceId: project.id,
|
|
spawnKey: null,
|
|
name: PROJECT_MAIN_BOT_DEFAULTS.name,
|
|
title: PROJECT_MAIN_BOT_DEFAULTS.title,
|
|
});
|
|
expect(await findMainBotId(prisma, userId)).toBe(home.leadBotId);
|
|
expect(await findMainBotId(prisma, userId, project.id)).toBe(a.leadBotId);
|
|
// The home conversation keeps its own main bot.
|
|
expect(await ensureMainConversation(prisma, userId)).toEqual(home);
|
|
});
|
|
|
|
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(),
|
|
);
|
|
});
|
|
});
|