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 }; type AppHandles = Awaited>; 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(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(app, cookie, "profile/update", { confirmFactIds: [state.facts[0]!.id], }); expect(state.facts.map((fact) => fact.status)).toEqual(["active", "active"]); state = await rpc(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(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(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(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; }