The main agent is meant to know the user, but nothing could read or change the user model the foundation added. Onboarding pasted the user's answers into the first bot's instructions as a "User Context" block, and the user had no way to see or remove what their bots knew about them. This makes the user model a user-controlled resource: - db: a profile repository scoped by user. One atomic update writes the account name, profile fields, new facts (deduplicated by normalized text, capped per user), fact edits including per-space visibility, and confirmations. Health and values facts wait for confirmation unless the user wrote them. Forgetting hard-deletes facts by id, category or all, clears the summary that may restate them and marks earlier chats as consolidated so they cannot teach them again; forgetting everything also clears the profile fields. Writes take a per-user advisory lock. - api/contracts: profile.get, profile.update and profile.forget. Unknown fact ids are a 404 on update and ignored on forget; spaces outside the user's memberships are rejected. - adapters: an update_user_profile tool for the main bot only, hidden from specialists and shared messaging channels and refused if called anyway. Facts carry source "conversation" and the bot, thread and message they came from. The scripted runtime records "learn about me: [category:] statement" for tests. - web: onboarding writes the name, time zone (browser default, now prefilled and editable), locale, chosen language, communication style and the hobby and work-style answers to the profile instead of overwriting the bot's instructions, and the harvested memory section no longer repeats them. A new "About you" settings section lists profile fields and facts by category with their origin, confirms pending facts, and forgets one fact or everything. Tests cover the contracts, the repository (unit and PostgreSQL), tool selection and refusal, the scripted trigger, the onboarding mapping and view helpers, an API suite that runs the tool through the executor with a real main bot, authorization for the new procedures, and an E2E spec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
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<void>;
|
|
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("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);
|
|
});
|
|
});
|