evOo-local/packages/testkit/src/coach.postgres.test.ts
Claude 1a3829031f
feat(coach): coach RPCs for goals, habits, check-ins, today and rhythm
The web app needs to show and change the user's coach data, and the
check-in times need a way to change after onboarding.

- coach.today, coach.goals.*, coach.habits.*, coach.checkIns.* and
  coach.rhythm.get/set, owner-only and not space-scoped; another user's
  ids read as not found, invalid input as a bad request.
- coach-rhythm reuses the shared clock-time helpers.
- Tests: an API suite covering ownership and validation for every
  procedure, the coach tools driven through the main bot (and refused
  for a specialist), and a morning check-in routine that fires in the
  main conversation and writes today's brief; the unauthenticated
  authorization sweep lists the new procedures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:27:50 +00:00

285 lines
12 KiB
TypeScript

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<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("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<SpaceNavigation>(app, bob, "spaces/list");
const goal = await rpc<Goal>(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<Habit>(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<CheckIn>(app, ada, "coach/checkIns/log", {
kind: "habit",
habitId: habit.id,
done: true,
});
await rpc<CheckIn>(app, ada, "coach/checkIns/log", {
kind: "goal",
goalId: goal.id,
note: "Draft done",
});
await rpc<CheckIn>(app, ada, "coach/checkIns/log", { kind: "daily", mood: 4, energy: 3 });
const today = await rpc<CoachToday>(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<CheckIn[]>(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<Goal[]>(app, bob, "coach/goals/list", {})).toEqual([]);
expect(await rpc<Habit[]>(app, bob, "coach/habits/list", {})).toEqual([]);
expect(await rpc<CheckIn[]>(app, bob, "coach/checkIns/list", { habitId: habit.id })).toEqual(
[],
);
expect((await rpc<CoachToday>(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<Habit[]>(app, ada, "coach/habits/list", {})).map((h) => h.active)).toEqual([
true,
]);
const closed = await rpc<Goal>(app, ada, "coach/goals/update", {
goalId: goal.id,
status: "done",
});
expect(closed.completedAt).not.toBeNull();
expect((await rpc<CoachToday>(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<Goal[]>(app, ada, "coach/goals/list", {})).toEqual([]);
expect(await rpc<CheckIn[]>(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<CoachToday>(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<CheckIn[]>(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<Goal[]>(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<CoachRhythm>(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<CoachRhythm>(app, cookie, "coach/rhythm/set", rhythm)).toEqual(rhythm);
expect(await rpc<CoachRhythm>(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<Routine[]>(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<CoachToday>(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());
});
});
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;
}