import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { bootstrapUserSpace } from "./bootstrap-user.js"; import type { PrismaClient } from "./client.js"; import { createDb } from "./client.js"; import { loadUserContext } from "./main-agent.js"; import { createSpaceForMember } from "./spaces.js"; import { forgetUserFacts, getUserProfileState, InvalidUserFactError, USER_FACT_LIMIT, UserFactNotFoundError, updateUserProfile, } from "./user-profile.js"; const databaseUrl = process.env.DATABASE_URL; const describePostgres = process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; describePostgres("user profile (PostgreSQL)", () => { let prisma: PrismaClient; let close: () => Promise; const userIds: string[] = []; async function newUser(name = "Ada") { // bootstrapUserSpace derives the organization slug from the first 12 characters. const id = `up${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("starts empty and upserts profile fields and the account name", async () => { const { userId } = await newUser(); expect(await getUserProfileState(prisma, userId)).toEqual({ profile: { timeZone: null, locale: null, language: null, communicationStyle: "", summary: "", consolidatedAt: null, updatedAt: null, }, facts: [], }); await updateUserProfile(prisma, userId, { name: "Ada L.", timeZone: "Europe/Vienna", language: "de", communicationStyle: "Direct and concise", }); await updateUserProfile(prisma, userId, { locale: "de-AT", language: null }); const { profile } = await getUserProfileState(prisma, userId); expect(profile).toMatchObject({ timeZone: "Europe/Vienna", locale: "de-AT", language: null, communicationStyle: "Direct and concise", }); expect(profile.updatedAt).not.toBeNull(); expect((await prisma.user.findUniqueOrThrow({ where: { id: userId } })).name).toBe("Ada L."); }); it("holds sensitive facts for confirmation unless the user wrote them, and stores a statement once", async () => { const { userId } = await newUser(); const added = await updateUserProfile(prisma, userId, { addFacts: [ { category: "work", value: "Works night shifts as a nurse", source: "conversation" }, { category: "health", value: "Allergic to peanuts", source: "conversation" }, { category: "values", value: "Vegetarian for ethical reasons", source: "user" }, { category: "health", value: "Has asthma", source: "onboarding" }, { category: "work", value: "works night shifts as a nurse.", source: "consolidation" }, ], }); expect(added.map(({ status, created }) => ({ status, created }))).toEqual([ { status: "active", created: true }, { status: "pending", created: true }, { status: "active", created: true }, { status: "pending", created: true }, { status: "active", created: false }, ]); expect(added[4]?.id).toBe(added[0]?.id); const rows = await prisma.userFact.findMany({ where: { userId }, orderBy: { value: "asc" } }); expect(rows).toHaveLength(4); const byValue = new Map(rows.map((row) => [row.value, row])); expect(byValue.get("Allergic to peanuts")).toMatchObject({ sensitive: true, status: "pending", confirmedAt: null, }); expect(byValue.get("Vegetarian for ethical reasons")).toMatchObject({ sensitive: true, status: "active", }); expect(byValue.get("Vegetarian for ethical reasons")?.confirmedAt).toBeInstanceOf(Date); // The user saying a pending fact themselves is consent. const [restated] = await updateUserProfile(prisma, userId, { addFacts: [{ category: "health", value: "allergic to peanuts", source: "user" }], }); expect(restated).toEqual({ id: added[1]?.id, status: "active", created: false }); const context = await loadUserContext(prisma, { userId, spaceId: "any-space" }); expect(context.facts.map((fact) => fact.value).sort()).toEqual([ "Allergic to peanuts", "Vegetarian for ethical reasons", "Works night shifts as a nurse", ]); }); it("confirms pending facts and edits them, scoped to their owner", async () => { const owner = await newUser(); const other = await newUser("Grace"); const [pending] = await updateUserProfile(prisma, owner.userId, { addFacts: [{ category: "health", value: "Migraines", source: "conversation" }], }); const factId = pending!.id; await expect( updateUserProfile(prisma, other.userId, { confirmFactIds: [factId] }), ).rejects.toBeInstanceOf(UserFactNotFoundError); await expect( updateUserProfile(prisma, other.userId, { editFacts: [{ id: factId, value: "Stolen" }] }), ).rejects.toBeInstanceOf(UserFactNotFoundError); expect((await getUserProfileState(prisma, other.userId)).facts).toEqual([]); expect(await forgetUserFacts(prisma, other.userId, { factIds: [factId] })).toBe(0); await updateUserProfile(prisma, owner.userId, { confirmFactIds: [factId] }); let row = await prisma.userFact.findUniqueOrThrow({ where: { id: factId } }); expect(row.status).toBe("active"); expect(row.confirmedAt).toBeInstanceOf(Date); await prisma.userFact.update({ where: { id: factId }, data: { embedding: [0.1, 0.2], embeddingModel: "test-embedding" }, }); await updateUserProfile(prisma, owner.userId, { editFacts: [{ id: factId, category: "habits", value: "Avoids screens late at night" }], }); row = await prisma.userFact.findUniqueOrThrow({ where: { id: factId } }); expect(row).toMatchObject({ category: "habits", sensitive: false, value: "Avoids screens late at night", embedding: [], embeddingModel: null, }); }); it("edits a fact in place, refusing a wording another fact already has", async () => { const { userId, homeSpaceId } = await newUser(); const work = await createSpaceForMember(prisma, { currentSpaceId: homeSpaceId, userId, name: "Work", }); const [tea, coffee] = await updateUserProfile(prisma, userId, { addFacts: [ { category: "preferences", value: "Prefers tea", source: "user" }, { category: "preferences", value: "Drinks coffee at work", source: "conversation" }, ], }); await prisma.userFact.update({ where: { id: tea!.id }, data: { embedding: [0.1, 0.2], embeddingModel: "test-embedding" }, }); // Same wording, new category and visibility: the embedding still fits. await updateUserProfile(prisma, userId, { editFacts: [{ id: tea!.id, value: " Prefers tea ", category: "habits", spaceIds: [work.id] }], }); expect(await prisma.userFact.findUniqueOrThrow({ where: { id: tea!.id } })).toMatchObject({ value: "Prefers tea", category: "habits", allSpaces: false, embedding: [0.1, 0.2], embeddingModel: "test-embedding", }); expect( (await getUserProfileState(prisma, userId)).facts.find((fact) => fact.id === tea!.id), ).toMatchObject({ allSpaces: false, spaceIds: [work.id] }); for (const value of ["“prefers TEA!”", "..."]) { await expect( updateUserProfile(prisma, userId, { editFacts: [ { id: coffee!.id, category: "work" }, { id: coffee!.id, value }, ], }), ).rejects.toBeInstanceOf(InvalidUserFactError); } // The whole update failed, the category change included. expect(await prisma.userFact.findUniqueOrThrow({ where: { id: coffee!.id } })).toMatchObject({ value: "Drinks coffee at work", category: "preferences", }); // A fact may take a wording that an earlier edit in the same update gave up. await updateUserProfile(prisma, userId, { editFacts: [ { id: tea!.id, value: "Prefers green tea" }, { id: coffee!.id, value: "prefers tea" }, ], }); const values = (await getUserProfileState(prisma, userId)).facts.map((fact) => fact.value); expect(values.sort()).toEqual(["Prefers green tea", "prefers tea"]); }); it("limits a fact to the user's own spaces", async () => { const owner = await newUser(); const other = await newUser("Grace"); const work = await createSpaceForMember(prisma, { currentSpaceId: owner.homeSpaceId, userId: owner.userId, name: "Work", }); await expect( updateUserProfile(prisma, owner.userId, { addFacts: [ { category: "people", value: "Reports to Lin", source: "user", spaceIds: [other.homeSpaceId], }, ], }), ).rejects.toBeInstanceOf(InvalidUserFactError); expect(await prisma.userFact.count({ where: { userId: owner.userId } })).toBe(0); const [limited] = await updateUserProfile(prisma, owner.userId, { addFacts: [ { category: "people", value: "Reports to Lin", source: "user", spaceIds: [work.id] }, ], }); const state = await getUserProfileState(prisma, owner.userId); expect(state.facts[0]).toMatchObject({ allSpaces: false, spaceIds: [work.id] }); const inWork = await loadUserContext(prisma, { userId: owner.userId, spaceId: work.id }); const inHome = await loadUserContext(prisma, { userId: owner.userId, spaceId: owner.homeSpaceId, }); expect(inWork.facts.map((fact) => fact.value)).toEqual(["Reports to Lin"]); expect(inHome.facts).toEqual([]); await updateUserProfile(prisma, owner.userId, { editFacts: [{ id: limited!.id, spaceIds: null }], }); expect((await getUserProfileState(prisma, owner.userId)).facts[0]).toMatchObject({ allSpaces: true, spaceIds: [], }); expect(await prisma.userFactSpace.count({ where: { factId: limited!.id } })).toBe(0); }); it("forgets by id, by category, and everything, so nothing can come back", async () => { const { userId } = await newUser(); const added = await updateUserProfile(prisma, userId, { timeZone: "Europe/Vienna", communicationStyle: "Casual", summary: "Nurse with peanut allergy who likes chess.", addFacts: [ { category: "work", value: "Nurse", source: "user" }, { category: "health", value: "Peanut allergy", source: "user" }, { category: "preferences", value: "Likes chess", source: "onboarding" }, { category: "preferences", value: "Likes jazz", source: "onboarding" }, ], }); expect(await forgetUserFacts(prisma, userId, { factIds: [added[1]!.id] })).toBe(1); let state = await getUserProfileState(prisma, userId); expect(state.facts.map((fact) => fact.value).sort()).toEqual([ "Likes chess", "Likes jazz", "Nurse", ]); // The summary may restate what was forgotten; unconsolidated chats must not teach it again. expect(state.profile.summary).toBe(""); expect(state.profile.consolidatedAt).not.toBeNull(); expect(state.profile.timeZone).toBe("Europe/Vienna"); expect(await forgetUserFacts(prisma, userId, { category: "preferences" })).toBe(2); state = await getUserProfileState(prisma, userId); expect(state.facts.map((fact) => fact.value)).toEqual(["Nurse"]); expect(await forgetUserFacts(prisma, userId, { all: true })).toBe(1); state = await getUserProfileState(prisma, userId); expect(state.facts).toEqual([]); expect(state.profile).toMatchObject({ timeZone: null, locale: null, language: null, communicationStyle: "", summary: "", }); }); it("caps the number of facts and serializes concurrent writers", async () => { const { userId } = await newUser(); const same = { category: "goals" as const, value: "Run a marathon", source: "conversation" as const, }; const results = await Promise.all([ updateUserProfile(prisma, userId, { addFacts: [same] }), updateUserProfile(prisma, userId, { addFacts: [same] }), updateUserProfile(prisma, userId, { addFacts: [same] }), ]); expect(new Set(results.map(([fact]) => fact?.id)).size).toBe(1); expect(await prisma.userFact.count({ where: { userId } })).toBe(1); await prisma.userFact.createMany({ data: Array.from({ length: USER_FACT_LIMIT - 1 }, (_, index) => ({ userId, category: "tools", value: `Uses tool ${index}`, source: "user", })), }); await expect( updateUserProfile(prisma, userId, { addFacts: [{ category: "tools", value: "Uses one more tool", source: "user" }], }), ).rejects.toBeInstanceOf(InvalidUserFactError); // A statement the profile already holds still resolves at the cap. const [known] = await updateUserProfile(prisma, userId, { addFacts: [same] }); expect(known?.created).toBe(false); }); });