import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { COACH_ROUTINES, ComposioEmulator, SCRIPTED_MORNING_BRIEF } from "@rakazo/adapters"; import type { CheckIn, CoachRhythm, CoachToday, Goal, Habit, Routine, SpaceNavigation, } from "@rakazo/contracts"; import { ensureMainBot, ensureMainConversation } 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("coach 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-coach-")); 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 waitForRun(runId: string) { 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, threadId: 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`); } async function sendAndWait(cookie: string, botId: string, text: string) { const { runId } = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId, text }); return waitForRun(runId); } async function toolOutcome(runId: string) { const event = await handles.prisma.event.findFirstOrThrow({ where: { runId, type: "agent.tool.completed" }, select: { payload: true }, }); return event.payload as { name: string; outcome: string; error?: string }; } it("keeps each user's goals, habits and check-ins to that user", async () => { const ada = await signup(app, `coach-ada-${stamp}@rakazo.test`, "Ada"); const bob = await signup(app, `coach-bob-${stamp}@rakazo.test`, "Bob"); const bobSpaces = await rpc(app, bob, "spaces/list"); const goal = await rpc(app, ada, "coach/goals/create", { title: "Ship the beta", targetDate: "2026-12-01", }); expect(goal).toMatchObject({ title: "Ship the beta", status: "active", source: "user" }); const habit = await rpc(app, ada, "coach/habits/create", { title: "Write 300 words", goalId: goal.id, }); expect(habit).toMatchObject({ cadence: "daily", goalId: goal.id, source: "user" }); const checkIn = await rpc(app, ada, "coach/checkIns/log", { kind: "habit", habitId: habit.id, done: true, }); await rpc(app, ada, "coach/checkIns/log", { kind: "goal", goalId: goal.id, note: "Draft done", }); await rpc(app, ada, "coach/checkIns/log", { kind: "daily", mood: 4, energy: 3 }); const today = await rpc(app, ada, "coach/today"); expect(today).toMatchObject({ date: checkIn.date, timeZone: "UTC", brief: null }); expect(today.goals.map((g) => g.id)).toEqual([goal.id]); expect(today.habits).toMatchObject([{ id: habit.id, doneToday: true, streak: 1 }]); expect(today.checkIn).toMatchObject({ mood: 4, energy: 3 }); expect(await rpc(app, ada, "coach/checkIns/list", { goalId: goal.id })).toHaveLength( 1, ); // Bob sees none of it and cannot touch it; Ada's ids read as missing to him. expect(await rpc(app, bob, "coach/goals/list", {})).toEqual([]); expect(await rpc(app, bob, "coach/habits/list", {})).toEqual([]); expect(await rpc(app, bob, "coach/checkIns/list", { habitId: habit.id })).toEqual( [], ); expect((await rpc(app, bob, "coach/today")).habits).toEqual([]); for (const [procedure, input] of [ ["coach/goals/update", { goalId: goal.id, status: "dropped" }], ["coach/goals/remove", { goalId: goal.id }], ["coach/habits/update", { habitId: habit.id, active: false }], ["coach/habits/remove", { habitId: habit.id }], ["coach/checkIns/log", { kind: "habit", habitId: habit.id, done: false }], ["coach/checkIns/log", { kind: "goal", goalId: goal.id, note: "Mine now" }], ["coach/habits/create", { title: "Borrowed", goalId: goal.id }], ] as const) { expect((await raw(app, bob, procedure, input)).status, procedure).toBe(404); } // Ada cannot file a goal under Bob's space. expect( ( await raw(app, ada, "coach/goals/create", { title: "Elsewhere", spaceId: bobSpaces.spaces[0]!.id, }) ).status, ).toBe(400); expect((await raw(app, ada, "coach/goals/create", { title: " " })).status).toBe(400); expect((await raw(app, ada, "coach/checkIns/log", { kind: "habit" })).status).toBe(400); expect( (await raw(app, ada, "coach/checkIns/log", { kind: "daily", date: "2999-01-01" })).status, ).toBe(400); expect((await rpc(app, ada, "coach/habits/list", {})).map((h) => h.active)).toEqual([ true, ]); const closed = await rpc(app, ada, "coach/goals/update", { goalId: goal.id, status: "done", }); expect(closed.completedAt).not.toBeNull(); expect((await rpc(app, ada, "coach/today")).goals).toEqual([]); await rpc(app, ada, "coach/habits/remove", { habitId: habit.id }); await rpc(app, ada, "coach/goals/remove", { goalId: goal.id }); expect(await rpc(app, ada, "coach/goals/list", {})).toEqual([]); expect(await rpc(app, ada, "coach/checkIns/list", { kind: "habit" })).toEqual([]); }); it("lets the main bot coach through its tools, and only the main bot", async () => { const cookie = await signup(app, `coach-tools-${stamp}@rakazo.test`, "Coach Tools"); 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, "set a goal: Run a 10k"); await sendAndWait(cookie, main.id, "track a habit: Walk"); await sendAndWait(cookie, main.id, "done today: walk"); const briefRun = await sendAndWait(cookie, main.id, "write today's brief: Rest, then plan."); expect(await toolOutcome(briefRun.id)).toMatchObject({ name: "write_daily_brief", outcome: "succeeded", }); const today = await rpc(app, cookie, "coach/today"); expect(today.goals).toMatchObject([ { title: "Run a 10k", source: "chat", createdByBotId: main.id }, ]); expect(today.habits).toMatchObject([ { title: "Walk", source: "chat", doneToday: true, streak: 1 }, ]); expect(today.brief).toMatchObject({ text: "Rest, then plan.", botId: main.id }); const [walked] = await rpc(app, cookie, "coach/checkIns/list", {}); expect(walked).toMatchObject({ kind: "habit", done: true, createdByBotId: main.id }); const refused = await sendAndWait(cookie, specialist.id, "set a goal: Sneak in"); expect(await toolOutcome(refused.id)).toMatchObject({ name: "update_goal", outcome: "error", error: expect.stringContaining("not available in this run"), }); expect(await rpc(app, cookie, "coach/goals/list", {})).toHaveLength(1); }); it("schedules check-ins as main-bot routines, and the morning one writes today's brief", async () => { const cookie = await signup(app, `coach-rhythm-${stamp}@rakazo.test`, "Coach Rhythm"); const me = await rpc<{ userId: string }>(app, cookie, "me"); expect(await rpc(app, cookie, "coach/rhythm/get")).toEqual({ morning: null, evening: null, weekly: null, }); const rhythm = { morning: "07:00", evening: null, weekly: { day: 0, time: "18:00" } }; expect(await rpc(app, cookie, "coach/rhythm/set", rhythm)).toEqual(rhythm); expect(await rpc(app, cookie, "coach/rhythm/get")).toEqual(rhythm); expect((await raw(app, cookie, "coach/rhythm/set", { ...rhythm, morning: "7am" })).status).toBe( 400, ); const conversation = await ensureMainConversation(handles.prisma, me.userId); const routines = await rpc(app, cookie, "routines/list", { botId: conversation.leadBotId, }); expect(routines.map((routine) => routine.name).sort()).toEqual( [COACH_ROUTINES.morning.name, COACH_ROUTINES.weekly.name].sort(), ); const morning = await handles.prisma.routine.findFirstOrThrow({ where: { botId: conversation.leadBotId, coachKey: "morning" }, }); const dueAt = new Date(Date.now() - 1_000); await handles.prisma.routine.update({ where: { id: morning.id }, data: { nextRunAt: dueAt } }); await handles.executor.wakeRoutine(morning.id, dueAt.toISOString()); const run = await handles.prisma.run.findFirstOrThrow({ where: { routineId: morning.id, trigger: "routine" }, }); expect((await waitForRun(run.id)).threadId).toBe(conversation.threadId); expect((await rpc(app, cookie, "coach/today")).brief).toMatchObject({ text: SCRIPTED_MORNING_BRIEF, botId: conversation.leadBotId, }); // The routine re-armed for the next morning. const rearmed = await handles.prisma.routine.findUniqueOrThrow({ where: { id: morning.id } }); expect(rearmed.active).toBe(true); expect(rearmed.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); }); it("moves the check-ins when the user's time zone changes or is forgotten", async () => { const cookie = await signup(app, `coach-zone-${stamp}@rakazo.test`, "Coach Zone"); const me = await rpc<{ userId: string }>(app, cookie, "me"); await rpc(app, cookie, "coach/rhythm/set", { morning: "07:00", evening: null, weekly: null }); const morning = () => handles.prisma.routine.findFirstOrThrow({ where: { userId: me.userId, coachKey: "morning" }, }); expect(await morning()).toMatchObject({ timezone: "UTC" }); await rpc(app, cookie, "profile/update", { timeZone: "Asia/Tokyo" }); const moved = await morning(); expect(moved.timezone).toBe("Asia/Tokyo"); expect( new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Tokyo", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).format(moved.nextRunAt!), ).toBe("07:00"); await rpc(app, cookie, "profile/forget", { all: true }); expect(await morning()).toMatchObject({ timezone: "UTC" }); }); }); 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; }