evOo-local/packages/testkit/src/user-profile.postgres.test.ts
Claude 492de63c77
feat(profile): readable, writable and forgettable user model
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
2026-10-02 22:41:07 +00:00

197 lines
7.2 KiB
TypeScript

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { ComposioEmulator } from "@rakazo/adapters";
import type { UserProfileState } from "@rakazo/contracts";
import { ensureMainBot } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { createApp } from "../../../apps/api/src/app.ts";
import { sessionCookieHeader } from "./index.js";
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
type AppHandles = Awaited<ReturnType<typeof createApp>>;
process.env.WAKEUP_DRIVER = "memory";
process.env.SANDBOX_PROVIDER = "fake";
process.env.AGENT_RUNTIME = "scripted";
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
const describeWithDatabase = hasDb ? describe : describe.skip;
describeWithDatabase("user profile through the API and the main bot", () => {
let handles: AppHandles;
let app: App;
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-profile-"));
beforeAll(async () => {
const { createApp } = await import("../../../apps/api/src/app.ts");
handles = await createApp({
databaseUrl: process.env.DATABASE_URL!,
dataDir,
sandboxProvider: "fake",
agentRuntime: "scripted",
wakeupDriver: "memory",
signupsEnabled: "true",
composio: new ComposioEmulator(),
});
app = handles.app;
});
afterAll(async () => {
await handles?.stop();
rmSync(dataDir, { recursive: true, force: true });
});
async function sendAndWait(cookie: string, botId: string, text: string) {
const { runId } = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId, text });
const start = Date.now();
while (Date.now() - start < 20_000) {
const run = await handles.prisma.run.findUnique({
where: { id: runId },
select: { id: true, status: true, error: true, sourceMessageId: true },
});
if (run && ["completed", "failed", "cancelled"].includes(run.status)) {
expect(run, run.error ?? "").toMatchObject({ status: "completed" });
return run;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`run ${runId} did not finish`);
}
it("lets the main bot record what it learns, and only the main bot", async () => {
const cookie = await signup(app, `profile-tool-${stamp}@rakazo.test`, "Profile Tool");
const me = await rpc<{ userId: string }>(app, cookie, "me");
const main = await ensureMainBot(handles.prisma, me.userId);
const specialist = await rpc<{ id: string }>(app, cookie, "bots/create", {
name: "Researcher",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
await sendAndWait(cookie, main.id, "learn about me: work: Works night shifts as a nurse");
const healthRun = await sendAndWait(
cookie,
main.id,
"learn about me: health: Allergic to peanuts",
);
const specialistRun = await sendAndWait(
cookie,
specialist.id,
"learn about me: tools: Uses a paper planner",
);
const refusal = await handles.prisma.event.findFirstOrThrow({
where: { runId: specialistRun.id, type: "agent.tool.completed" },
select: { payload: true },
});
expect(refusal.payload).toMatchObject({
name: "update_user_profile",
outcome: "error",
error: expect.stringContaining("main bot"),
});
let state = await rpc<UserProfileState>(app, cookie, "profile/get");
expect(
state.facts.map(({ category, value, source, status, sourceBotId }) => ({
category,
value,
source,
status,
sourceBotId,
})),
).toEqual([
{
category: "health",
value: "Allergic to peanuts",
source: "conversation",
status: "pending",
sourceBotId: main.id,
},
{
category: "work",
value: "Works night shifts as a nurse",
source: "conversation",
status: "active",
sourceBotId: main.id,
},
]);
expect(state.facts[0]?.sourceThreadId).toBe(main.threadId);
expect(healthRun.sourceMessageId).toBeTruthy();
expect(
await handles.prisma.userFact.findUniqueOrThrow({
where: { id: state.facts[0]!.id },
select: { sourceMessageId: true },
}),
).toEqual({ sourceMessageId: healthRun.sourceMessageId });
state = await rpc<UserProfileState>(app, cookie, "profile/update", {
confirmFactIds: [state.facts[0]!.id],
});
expect(state.facts.map((fact) => fact.status)).toEqual(["active", "active"]);
state = await rpc<UserProfileState>(app, cookie, "profile/forget", { category: "health" });
expect(state.facts.map((fact) => fact.value)).toEqual(["Works night shifts as a nurse"]);
});
it("validates profile input and reports unknown facts", async () => {
const cookie = await signup(app, `profile-input-${stamp}@rakazo.test`, "Profile Input");
const state = await rpc<UserProfileState>(app, cookie, "profile/update", {
name: "Renamed",
timeZone: "europe/vienna",
language: "de",
addFacts: [{ category: "goals", value: "Run a marathon" }],
});
expect(state.profile).toMatchObject({ timeZone: "Europe/Vienna", language: "de" });
expect(state.facts).toMatchObject([
{ category: "goals", value: "Run a marathon", source: "user", status: "active" },
]);
expect((await rpc<{ name: string }>(app, cookie, "me")).name).toBe("Renamed");
expect((await raw(app, cookie, "profile/update", { timeZone: "Vienna" })).status).toBe(400);
expect(
(await raw(app, cookie, "profile/update", { confirmFactIds: ["missing-fact"] })).status,
).toBe(404);
const forgotten = await rpc<UserProfileState>(app, cookie, "profile/forget", { all: true });
expect(forgotten.facts).toEqual([]);
expect(forgotten.profile).toMatchObject({ timeZone: null, language: null, summary: "" });
});
});
async function signup(app: App, email: string, name: string) {
const response = await app.request("/api/auth/sign-up/email", {
method: "POST",
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
body: JSON.stringify({ email, password: "password12", name }),
});
if (response.status >= 400) {
throw new Error(`signup failed ${response.status}: ${await response.text()}`);
}
return sessionCookieHeader(response);
}
async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) {
return app.request(`/rpc/${procedure}`, {
method: "POST",
headers: {
"content-type": "application/json",
cookie,
origin: "http://127.0.0.1:5173",
},
body: JSON.stringify({ json: body }),
});
}
async function rpc<T>(app: App, cookie: string, procedure: string, body: unknown = {}) {
const response = await raw(app, cookie, procedure, body);
const text = await response.text();
const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
if (response.status >= 400 || payload.error) {
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
}
return payload.json as T;
}