About you could only confirm or delete what the bots know about the user, so a fact that was slightly wrong or filed under the wrong category had to be deleted and taught again, and a fact could not be limited to some spaces from the app even though the profile supports it. - Each fact gets an Edit button that turns its row into an inline editor: wording, category, and (when the user has more than one space, or the fact is already limited) which spaces it shows in. Escape or Cancel leaves it unchanged; only changed fields are sent through profile.update editFacts. - A limited fact names its spaces next to its source. - The repository validates edits like new facts: a wording whose normalized form is empty or equals another fact of the user is refused, all edits of one update apply atomically, and the stored embedding is only dropped when the wording really changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
240 lines
9.2 KiB
TypeScript
240 lines
9.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 },
|
|
});
|
|
// The executor refuses a tool the specialist's run never exposed; the tool's own main-bot
|
|
// check stays as a second line and is covered by its unit tests.
|
|
expect(refusal.payload).toMatchObject({
|
|
name: "update_user_profile",
|
|
outcome: "error",
|
|
error: expect.stringContaining("not available in this run"),
|
|
});
|
|
|
|
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: "" });
|
|
});
|
|
|
|
it("edits a fact's wording, category and spaces for its owner only", async () => {
|
|
const cookie = await signup(app, `profile-edit-${stamp}@rakazo.test`, "Profile Edit");
|
|
const intruder = await signup(app, `profile-edit-other-${stamp}@rakazo.test`, "Intruder");
|
|
const side = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Side" });
|
|
const elsewhere = await rpc<{ id: string }>(app, intruder, "spaces/create", { name: "Theirs" });
|
|
const added = await rpc<UserProfileState>(app, cookie, "profile/update", {
|
|
addFacts: [
|
|
{ category: "preferences", value: "Prefers tea" },
|
|
{ category: "work", value: "Works late" },
|
|
],
|
|
});
|
|
const tea = added.facts.find((fact) => fact.value === "Prefers tea")!;
|
|
|
|
const edited = await rpc<UserProfileState>(app, cookie, "profile/update", {
|
|
editFacts: [
|
|
{ id: tea.id, value: "Prefers green tea", category: "habits", spaceIds: [side.id] },
|
|
],
|
|
});
|
|
expect(edited.facts.find((fact) => fact.id === tea.id)).toMatchObject({
|
|
value: "Prefers green tea",
|
|
category: "habits",
|
|
allSpaces: false,
|
|
spaceIds: [side.id],
|
|
});
|
|
|
|
const edit = (body: unknown, who = cookie) => raw(app, who, "profile/update", body);
|
|
expect((await edit({ editFacts: [{ id: tea.id, value: "works late!" }] })).status).toBe(400);
|
|
expect((await edit({ editFacts: [{ id: tea.id, spaceIds: [elsewhere.id] }] })).status).toBe(
|
|
400,
|
|
);
|
|
expect((await edit({ editFacts: [{ id: tea.id, value: "Stolen" }] }, intruder)).status).toBe(
|
|
404,
|
|
);
|
|
expect((await rpc<UserProfileState>(app, intruder, "profile/get")).facts).toEqual([]);
|
|
expect(
|
|
(await rpc<UserProfileState>(app, cookie, "profile/get")).facts.find(
|
|
(fact) => fact.id === tea.id,
|
|
),
|
|
).toMatchObject({ value: "Prefers green tea", spaceIds: [side.id] });
|
|
});
|
|
});
|
|
|
|
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;
|
|
}
|