diff --git a/apps/api/src/coach.ts b/apps/api/src/coach.ts new file mode 100644 index 0000000..2bf3fcc --- /dev/null +++ b/apps/api/src/coach.ts @@ -0,0 +1,96 @@ +import { ORPCError } from "@orpc/server"; +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { getCoachRhythm, setCoachRhythm } from "@rakazo/adapters"; +import type { + Actor, + CheckIn, + CoachRhythm, + CoachToday, + Goal, + Habit, + LogCheckIn, +} from "@rakazo/contracts"; +import type { CheckInFilter, GoalPatch, HabitPatch, PrismaClient } from "@rakazo/db"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + listCheckIns, + listGoals, + listHabits, + loadCoachToday, + logCheckIn, + removeGoal, + removeHabit, + updateGoal, + updateHabit, +} from "@rakazo/db"; + +export interface CoachDeps { + prisma: PrismaClient; + jobs: JobPublisher; +} + +/** Another user's ids read as missing, so a caller cannot probe them. */ +async function mapErrors(work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + if (error instanceof CoachNotFoundError) { + throw new ORPCError("NOT_FOUND", { message: error.message }); + } + if (error instanceof InvalidCoachInputError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + } +} + +/** Coach procedures for the signed-in user only; nothing here is space-scoped. */ +export function createCoachService(deps: CoachDeps) { + const { prisma } = deps; + return { + today: (actor: Actor): Promise => loadCoachToday(prisma, actor.userId), + + listGoals: (actor: Actor, input: { status?: Goal["status"] }) => + listGoals(prisma, actor.userId, input), + createGoal: ( + actor: Actor, + input: { + title: string; + detail?: string; + targetDate?: string | null; + spaceId?: string | null; + }, + ) => mapErrors(() => createGoal(prisma, actor.userId, { ...input, source: "user" })), + updateGoal: (actor: Actor, { goalId, ...patch }: GoalPatch & { goalId: string }) => + mapErrors(() => updateGoal(prisma, actor.userId, goalId, patch)), + removeGoal: async (actor: Actor, input: { goalId: string }) => { + await mapErrors(() => removeGoal(prisma, actor.userId, input.goalId)); + return { ok: true as const }; + }, + + listHabits: (actor: Actor, input: { includeInactive?: boolean }): Promise => + listHabits(prisma, actor.userId, input), + createHabit: ( + actor: Actor, + input: { title: string; cadence: Habit["cadence"]; goalId?: string | null }, + ) => mapErrors(() => createHabit(prisma, actor.userId, { ...input, source: "user" })), + updateHabit: (actor: Actor, { habitId, ...patch }: HabitPatch & { habitId: string }) => + mapErrors(() => updateHabit(prisma, actor.userId, habitId, patch)), + removeHabit: async (actor: Actor, input: { habitId: string }) => { + await mapErrors(() => removeHabit(prisma, actor.userId, input.habitId)); + return { ok: true as const }; + }, + + logCheckIn: (actor: Actor, input: LogCheckIn): Promise => + mapErrors(() => logCheckIn(prisma, actor.userId, input)), + listCheckIns: (actor: Actor, input: CheckInFilter) => + mapErrors(() => listCheckIns(prisma, actor.userId, input)), + + getRhythm: (actor: Actor): Promise => getCoachRhythm(prisma, actor.userId), + setRhythm: (actor: Actor, rhythm: CoachRhythm): Promise => + setCoachRhythm(deps, { userId: actor.userId, rhythm }), + }; +} diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index b5606a4..f43dc89 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -158,6 +158,7 @@ import { createAgentSkillsService } from "./agent-skills.js"; import { aiConsentStatus, allowAiConsent } from "./ai-consent.js"; import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js"; import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js"; +import { createCoachService } from "./coach.js"; import { executionBlocksUserTakeover, resolveBusyBotName, @@ -542,6 +543,7 @@ export function createRouter(deps: RouterDeps) { dataDir: deps.dataDir, }); const agentSkills = createAgentSkillsService(deps.prisma); + const coach = createCoachService(deps); const authed = os.use(async ({ context, next }) => { if (!context.actor) throw new ORPCError("UNAUTHORIZED"); @@ -587,6 +589,51 @@ export function createRouter(deps: RouterDeps) { forgetProfile(deps.prisma, context.actor, input), ), }, + coach: { + today: authed.coach.today.handler(({ context }) => coach.today(context.actor)), + goals: { + list: authed.coach.goals.list.handler(({ context, input }) => + coach.listGoals(context.actor, input), + ), + create: authed.coach.goals.create.handler(({ context, input }) => + coach.createGoal(context.actor, input), + ), + update: authed.coach.goals.update.handler(({ context, input }) => + coach.updateGoal(context.actor, input), + ), + remove: authed.coach.goals.remove.handler(({ context, input }) => + coach.removeGoal(context.actor, input), + ), + }, + habits: { + list: authed.coach.habits.list.handler(({ context, input }) => + coach.listHabits(context.actor, input), + ), + create: authed.coach.habits.create.handler(({ context, input }) => + coach.createHabit(context.actor, input), + ), + update: authed.coach.habits.update.handler(({ context, input }) => + coach.updateHabit(context.actor, input), + ), + remove: authed.coach.habits.remove.handler(({ context, input }) => + coach.removeHabit(context.actor, input), + ), + }, + checkIns: { + log: authed.coach.checkIns.log.handler(({ context, input }) => + coach.logCheckIn(context.actor, input), + ), + list: authed.coach.checkIns.list.handler(({ context, input }) => + coach.listCheckIns(context.actor, input), + ), + }, + rhythm: { + get: authed.coach.rhythm.get.handler(({ context }) => coach.getRhythm(context.actor)), + set: authed.coach.rhythm.set.handler(({ context, input }) => + coach.setRhythm(context.actor, input), + ), + }, + }, spaces: { list: authed.spaces.list.handler(async ({ context }) => spaceNavigationDto(deps, context.actor, repos, groupRepos), diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index 28bf669..c473c98 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -1,20 +1,127 @@ +import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; -import { captureScreenshot, completeOnboarding, signup } from "./helpers"; +import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts"; +import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +/** The nav button reads "Heute" or "Heute, N need you". */ +function openHeute(page: Page) { + return page.getByRole("button", { name: /^Heute(,|$)/ }).click(); +} + +function shiftDate(date: string, days: number) { + return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); +} test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute"); await completeOnboarding(page); - await page.getByRole("button", { name: "Heute", exact: true }).click(); + await openHeute(page); await expect(page).toHaveURL(/\/app\/[^/]+$/); const brief = page.getByTestId("heute-brief"); await expect(brief).toBeVisible(); await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0); await expect(page.getByText("evOo Mission Control")).toHaveCount(0); + await expect(page.getByTestId("heute-mission-control-link")).toHaveCount(0); await expect(page.getByTestId("heute-status")).toBeVisible(); - await expect(page.getByTestId("heute-mission-control-link")).toBeVisible(); + // Nothing tracked yet: no coach sections. + await expect(page.getByTestId("heute-coach-brief")).toHaveCount(0); + await expect(page.getByTestId("heute-habits")).toHaveCount(0); await captureScreenshot(page, testInfo, "heute-native-brief"); }); + +test("Heute shows the coach's brief, habits and goals, checks habits off and sets check-ins", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `heute-coach-${stamp}@rakazo.test`, "password12", "Heute Coach"); + await completeOnboarding(page); + + // The main bot writes today's brief through its tool. + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill("write today's brief: Two calm hours on the beta, then a walk."); + await page.keyboard.press("Enter"); + await expect(page.getByText("today's brief is ready.").first()).toBeVisible({ timeout: 30_000 }); + + const { date } = await rpc(page, "coach/today", {}); + const goal = await rpc(page, "coach/goals/create", { + title: "Ship the beta", + targetDate: shiftDate(date, 30), + }); + const habit = await rpc(page, "coach/habits/create", { title: "Walk 20 minutes" }); + for (const day of [1, 2]) { + await rpc(page, "coach/checkIns/log", { + kind: "habit", + habitId: habit.id, + date: shiftDate(date, -day), + done: true, + }); + } + + await openHeute(page); + await expect(page.getByTestId("heute-coach-brief")).toHaveText( + "Two calm hours on the beta, then a walk.", + ); + await expect(page.getByTestId(`heute-goal-${goal.id}`)).toContainText("Ship the beta"); + const habitRow = page.getByTestId(`heute-habit-${habit.id}`); + await expect(habitRow).toContainText("Walk 20 minutes"); + await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("2 days"); + + const checkbox = habitRow.getByRole("checkbox"); + await expect(checkbox).not.toBeChecked(); + const logged = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/checkIns/log") && response.ok(), + ); + await checkbox.click(); + await logged; + await expect(checkbox).toBeChecked(); + await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days"); + await captureScreenshot(page, testInfo, "heute-coach"); + + await page.reload(); + await openHeute(page); + await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked(); + + // Check-in times live behind one compact control. + await page.getByTestId("heute-rhythm").click(); + const panel = page.getByTestId("heute-rhythm-panel"); + await expect(panel).toBeVisible(); + const morning = panel.getByTestId("heute-rhythm-morning"); + await expect(morning.getByRole("switch")).not.toBeChecked(); + const enabled = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await morning.getByRole("switch").click(); + await enabled; + const time = morning.locator('input[type="time"]'); + await expect(time).toHaveValue("08:00"); + const moved = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await time.fill("07:30"); + await moved; + await expect(time).toHaveValue("07:30"); + const weekly = panel.getByTestId("heute-rhythm-weekly"); + const weeklyOn = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await weekly.getByRole("switch").click(); + await weeklyOn; + await expect(weekly.getByRole("combobox")).toHaveValue("0"); + await captureScreenshot(page, testInfo, "heute-rhythm"); + + expect(await rpc(page, "coach/rhythm/get", {})).toEqual({ + morning: "07:30", + evening: null, + weekly: { day: 0, time: "18:00" }, + }); + const main = await rpc<{ id: string }>(page, "bots/ensureMain", {}); + const routines = await rpc(page, "routines/list", { botId: main.id }); + expect(routines.map((routine) => routine.name).sort()).toEqual([ + "Morning brief", + "Weekly review", + ]); +}); diff --git a/apps/web/src/components/heute/CoachRhythmControl.tsx b/apps/web/src/components/heute/CoachRhythmControl.tsx new file mode 100644 index 0000000..102c085 --- /dev/null +++ b/apps/web/src/components/heute/CoachRhythmControl.tsx @@ -0,0 +1,214 @@ +import { i18n } from "@lingui/core"; +import { useLingui } from "@lingui/react/macro"; +import type { CoachRhythm } from "@rakazo/contracts"; +import { + Button, + Input, + NativeSelect, + NativeSelectOption, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Switch, +} from "@rakazo/ui-web"; +import { CalendarClock } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCallback, useId, useRef, useState } from "react"; +import { rpc } from "../../lib/rpc"; + +/** Times a check-in starts at when it is switched on. */ +const DEFAULTS = { morning: "08:00", evening: "20:00", weekly: { day: 0, time: "18:00" } }; +const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; + +function weekdayName(day: number): string { + // 2026-10-04 was a Sunday, so day 0 maps to Sunday. + return new Date(Date.UTC(2026, 9, 4 + day)).toLocaleDateString(i18n.locale || "en", { + weekday: "long", + timeZone: "UTC", + }); +} + +/** When the main bot checks in: loaded when opened, saved on every change. */ +export function CoachRhythmControl() { + const { t } = useLingui(); + const [rhythm, setRhythm] = useState(null); + const latestRequest = useRef(0); + + const load = useCallback(async () => { + const request = ++latestRequest.current; + try { + const loaded = await rpc.coach.rhythm.get(); + if (request === latestRequest.current) setRhythm(loaded); + } catch { + // Opening the control again retries. + } + }, []); + + const save = useCallback( + async (next: CoachRhythm) => { + setRhythm(next); + const request = ++latestRequest.current; + try { + const saved = await rpc.coach.rhythm.set(next); + if (request === latestRequest.current) setRhythm(saved); + } catch { + if (request === latestRequest.current) void load(); + } + }, + [load], + ); + + return ( + { + if (open) void load(); + }} + > + } + > + + + {rhythm ? ( + <> + void save({ ...rhythm, morning: on ? DEFAULTS.morning : null })} + > + {(labelId) => + rhythm.morning ? ( + void save({ ...rhythm, morning })} + /> + ) : null + } + + void save({ ...rhythm, evening: on ? DEFAULTS.evening : null })} + > + {(labelId) => + rhythm.evening ? ( + void save({ ...rhythm, evening })} + /> + ) : null + } + + void save({ ...rhythm, weekly: on ? DEFAULTS.weekly : null })} + > + {(labelId) => { + const weekly = rhythm.weekly; + if (!weekly) return null; + return ( + <> + + void save({ + ...rhythm, + weekly: { ...weekly, day: Number(event.target.value) }, + }) + } + > + {WEEKDAYS.map((day) => ( + + {weekdayName(day)} + + ))} + + void save({ ...rhythm, weekly: { ...weekly, time } })} + /> + + ); + }} + + + ) : ( +
+ +
+ )} +
+
+ ); +} + +function RhythmRow({ + label, + testId, + on, + onToggle, + children, +}: { + label: string; + testId: string; + on: boolean; + onToggle: (on: boolean) => void; + children: (labelId: string) => ReactNode; +}) { + const labelId = useId(); + return ( +
+ onToggle(checked)} + /> + + {label} + +
{children(labelId)}
+
+ ); +} + +function TimeInput({ + labelId, + value, + onChange, +}: { + labelId: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + { + if (event.target.value) onChange(event.target.value.slice(0, 5)); + }} + className="h-control-sm w-32 px-2 tabular-nums" + /> + ); +} diff --git a/apps/web/src/components/heute/HeuteCoach.tsx b/apps/web/src/components/heute/HeuteCoach.tsx new file mode 100644 index 0000000..c88a3c9 --- /dev/null +++ b/apps/web/src/components/heute/HeuteCoach.tsx @@ -0,0 +1,105 @@ +import { i18n } from "@lingui/core"; +import { Plural, Trans } from "@lingui/react/macro"; +import type { CoachToday, HabitToday } from "@rakazo/contracts"; +import { Checkbox, cn } from "@rakazo/ui-web"; +import { Flame } from "lucide-react"; +import { useId } from "react"; +import { formatLocalDate } from "../../lib/heute-brief"; + +/** Shown once a habit has been kept at least twice in a row. */ +const STREAK_MIN = 2; + +/** Today's brief, habits to check off and active goals; each part only when it has content. */ +export function HeuteCoach({ + today, + onHabitDone, +}: { + today: CoachToday; + onHabitDone: (habit: HabitToday, done: boolean) => void; +}) { + return ( + <> + {today.brief ? ( +
+

{today.brief.text}

+
+ ) : null} + + {today.habits.length > 0 ? ( +
+

+ Habits +

+
    + {today.habits.map((habit) => ( +
  • + onHabitDone(habit, done)} /> +
  • + ))} +
+
+ ) : null} + + {today.goals.length > 0 ? ( +
+

+ Goals +

+
    + {today.goals.map((goal) => ( +
  • + {goal.title} + {goal.targetDate ? ( + + {formatLocalDate(goal.targetDate, i18n.locale || "en", { + day: "numeric", + month: "short", + })} + + ) : null} +
  • + ))} +
+
+ ) : null} + + ); +} + +function HabitRow({ habit, onDone }: { habit: HabitToday; onDone: (done: boolean) => void }) { + const id = useId(); + return ( + + ); +} diff --git a/apps/web/src/lib/heute-brief.test.ts b/apps/web/src/lib/heute-brief.test.ts index d023919..18e85e9 100644 --- a/apps/web/src/lib/heute-brief.test.ts +++ b/apps/web/src/lib/heute-brief.test.ts @@ -1,6 +1,13 @@ -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts"; import { describe, expect, it } from "vitest"; -import { buildHeuteBrief, HEUTE_NEEDS_YOU_CAP, isToday } from "./heute-brief"; +import { + buildHeuteBrief, + coachRefreshSignal, + formatLocalDate, + HEUTE_NEEDS_YOU_CAP, + isToday, + withHabitDone, +} from "./heute-brief"; const now = new Date("2026-09-22T12:00:00.000Z"); @@ -142,3 +149,59 @@ describe("buildHeuteBrief", () => { expect(brief.actionCount).toBe(5); }); }); + +function habit(overrides: Partial = {}): HabitToday { + return { + id: "habit-1", + title: "Walk", + cadence: "daily", + goalId: null, + active: true, + source: "user", + createdByBotId: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + doneToday: false, + streak: 3, + ...overrides, + }; +} + +describe("coach on the Heute page", () => { + it("reloads the coach when a run finishes, not while runs are only working", () => { + const working = [run({ runId: "run-1", status: "running" })]; + expect(coachRefreshSignal(working)).toBe(""); + expect(coachRefreshSignal([...working, run({ runId: "run-2", status: "completed" })])).toBe( + "run-2", + ); + }); + + it("checks a habit off before the server confirms, moving the streak by one", () => { + const today: CoachToday = { + date: "2026-10-03", + timeZone: "Europe/Vienna", + brief: null, + goals: [], + habits: [habit(), habit({ id: "habit-2", doneToday: true, streak: 1 })], + checkIn: null, + }; + const checked = withHabitDone(today, "habit-1", true); + expect(checked.habits.map(({ doneToday, streak }) => ({ doneToday, streak }))).toEqual([ + { doneToday: true, streak: 4 }, + { doneToday: true, streak: 1 }, + ]); + expect(withHabitDone(checked, "habit-2", false).habits[1]).toMatchObject({ + doneToday: false, + streak: 0, + }); + // Repeating the same state changes nothing. + expect(withHabitDone(checked, "habit-1", true)).toEqual(checked); + }); + + it("labels the user's calendar date without shifting it into the browser's zone", () => { + expect(formatLocalDate("2026-10-03", "en-US", { weekday: "long" })).toBe("Saturday"); + expect(formatLocalDate("2026-11-01", "en-US", { month: "short", day: "numeric" })).toBe( + "Nov 1", + ); + }); +}); diff --git a/apps/web/src/lib/heute-brief.ts b/apps/web/src/lib/heute-brief.ts index dfebb61..a102370 100644 --- a/apps/web/src/lib/heute-brief.ts +++ b/apps/web/src/lib/heute-brief.ts @@ -1,4 +1,4 @@ -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, RunActivityRow } from "@rakazo/contracts"; import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity"; export type JetztRunItem = { @@ -137,3 +137,38 @@ export function buildHeuteBrief(params: { weiter: [], }; } + +/** + * Finished runs are when the coach learns something (a check-in, today's brief), so a change in + * them is the cue to reload the coach's view of the day. + */ +export function coachRefreshSignal(recentRuns: RunActivityRow[]): string { + return recentRuns + .filter((run) => run.status === "completed") + .map((run) => run.runId) + .join(","); +} + +/** + * A habit checked or unchecked before the server confirms. The current period only counts + * toward the streak once it is done, so the streak moves by one. + */ +export function withHabitDone(today: CoachToday, habitId: string, done: boolean): CoachToday { + return { + ...today, + habits: today.habits.map((habit) => + habit.id !== habitId || habit.doneToday === done + ? habit + : { ...habit, doneToday: done, streak: Math.max(0, habit.streak + (done ? 1 : -1)) }, + ), + }; +} + +/** A calendar date ("YYYY-MM-DD") as a label, independent of the browser's time zone. */ +export function formatLocalDate( + date: string, + locale: string, + options: Intl.DateTimeFormatOptions, +): string { + return new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, { ...options, timeZone: "UTC" }); +} diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx index cdf12b7..1511736 100644 --- a/apps/web/src/pages/HeuteBrief.tsx +++ b/apps/web/src/pages/HeuteBrief.tsx @@ -1,22 +1,22 @@ import { i18n } from "@lingui/core"; import { Plural, Trans, useLingui } from "@lingui/react/macro"; -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts"; import { botColors } from "@rakazo/ui-tokens"; import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web"; -import { ExternalLink } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CoachRhythmControl } from "../components/heute/CoachRhythmControl"; +import { HeuteCoach } from "../components/heute/HeuteCoach"; import { runActivityLabel } from "../lib/agent-activity"; +import type { DoingItem, DoneItem, JetztItem } from "../lib/heute-brief"; import { buildHeuteBrief, cleanBriefPreview, - type DoingItem, - type DoneItem, - type JetztItem, + coachRefreshSignal, + formatLocalDate, + withHabitDone, } from "../lib/heute-brief"; import { rpc } from "../lib/rpc"; -const MISSION_CONTROL_URL = "http://127.0.0.1:3088/"; - type HeuteBriefProps = { bots: Bot[]; groups: Group[]; @@ -26,6 +26,8 @@ type HeuteBriefProps = { onStartChat: () => void; }; +const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" }; + export function HeuteBrief({ bots, groups, @@ -37,6 +39,9 @@ export function HeuteBrief({ const [activeRuns, setActiveRuns] = useState([]); const [recentRuns, setRecentRuns] = useState([]); const [loading, setLoading] = useState(true); + const [coach, setCoach] = useState(null); + const [coachSettled, setCoachSettled] = useState(false); + const latestCoachRequest = useRef(0); useEffect(() => { let cancelled = false; @@ -68,6 +73,50 @@ export function HeuteBrief({ }; }, []); + const refreshCoach = useCallback(async () => { + const request = ++latestCoachRequest.current; + try { + const today = await rpc.coach.today(); + if (request === latestCoachRequest.current) setCoach(today); + } catch { + // Keep the last view; the next refresh retries. + } finally { + setCoachSettled(true); + } + }, []); + + // `coachSignal` is the trigger: a finished run may have logged a check-in or written the brief. + const coachSignal = useMemo(() => coachRefreshSignal(recentRuns), [recentRuns]); + useEffect(() => { + void refreshCoach(); + }, [refreshCoach, coachSignal]); + + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === "visible") void refreshCoach(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + document.removeEventListener("visibilitychange", onVisible); + // Ignore responses that arrive after unmount. + latestCoachRequest.current += 1; + }; + }, [refreshCoach]); + + const setHabitDone = useCallback( + async (habit: HabitToday, done: boolean) => { + if (!coach) return; + setCoach((current) => (current ? withHabitDone(current, habit.id, done) : current)); + latestCoachRequest.current += 1; + try { + await rpc.coach.checkIns.log({ kind: "habit", habitId: habit.id, date: coach.date, done }); + } finally { + void refreshCoach(); + } + }, + [coach, refreshCoach], + ); + const brief = useMemo( () => buildHeuteBrief({ @@ -81,31 +130,45 @@ export function HeuteBrief({ const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]); - const dateLabel = formatHeuteDate(new Date()); - const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0; + const locale = i18n.locale || "de"; + const dateLabel = coach + ? formatLocalDate(coach.date, locale, DATE_LABEL) + : new Date().toLocaleDateString(locale, DATE_LABEL); + const hasCoachContent = Boolean( + coach && (coach.brief || coach.habits.length > 0 || coach.goals.length > 0), + ); + const hasContent = + hasCoachContent || brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0; return (
-
-

{dateLabel}

-

- -

+
+
+

+ {dateLabel} +

+

+ +

+
+
- {loading && !hasContent ? ( + {(loading || !coachSettled) && !hasContent ? (

Loading…

) : !hasContent ? ( 0} /> ) : ( -
+
+ {coach ? : null} + {brief.needsYou.length > 0 ? (

@@ -172,19 +235,6 @@ export function HeuteBrief({ ) : null}

)} - -
); @@ -399,12 +449,3 @@ function jetztRowKey(item: JetztItem): string { if (item.kind === "run") return `run:${item.run.runId}`; return `unread:${item.chatKind}:${item.chat.id}`; } - -function formatHeuteDate(date: Date): string { - const locale = i18n.locale || "de"; - return date.toLocaleDateString(locale, { - weekday: "long", - day: "numeric", - month: "long", - }); -} diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 560893f..058c065 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -8,6 +8,7 @@ import { USER_FACT_CATEGORIES, } from "@rakazo/contracts"; import { z } from "zod"; +import { coachTools } from "./coach-tools.js"; import { NOTIFY_USER_BODY_MAX_LENGTH, NOTIFY_USER_TITLE_MAX_LENGTH } from "./notify-user-tool.js"; export const SUBAGENT_MODEL_TIER_DESCRIPTION = @@ -429,6 +430,7 @@ export const builtinAgentTools: ConnectorTool[] = [ }, }, }, + ...coachTools, { name: "notify_user", description: diff --git a/packages/adapters/src/coach-rhythm.postgres.test.ts b/packages/adapters/src/coach-rhythm.postgres.test.ts new file mode 100644 index 0000000..73136e2 --- /dev/null +++ b/packages/adapters/src/coach-rhythm.postgres.test.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; +import { routineJobKey } from "@rakazo/adapter-kit"; +import type { PrismaClient } from "@rakazo/db"; +import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { COACH_ROUTINES, getCoachRhythm, setCoachRhythm } from "./coach-rhythm.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; + +describePostgres("coach rhythm (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(timeZone: string | null = "Europe/Vienna") { + const id = `cr${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false }); + if (timeZone) await prisma.userProfile.create({ data: { userId: id, timeZone } }); + return id; + } + + function fakeJobs() { + return { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }; + } + + function coachRoutines(userId: string) { + return prisma.routine.findMany({ + where: { userId, coachKey: { not: null } }, + orderBy: { coachKey: "asc" }, + }); + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("keeps one main-bot routine per check-in in the main conversation and user's time zone", async () => { + const userId = await newUser("Europe/Vienna"); + const jobs = fakeJobs(); + const rhythm = { morning: "07:30", evening: null, weekly: { day: 0, time: "18:00" } }; + + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + const conversation = await ensureMainConversation(prisma, userId); + const created = await coachRoutines(userId); + expect( + created.map( + ({ coachKey, botId, threadId, spaceId, crons, timezone, active, notify, name }) => ({ + coachKey, + botId, + threadId, + spaceId, + crons, + timezone, + active, + notify, + name, + }), + ), + ).toEqual([ + { + coachKey: "morning", + botId: conversation.leadBotId, + threadId: conversation.threadId, + spaceId: conversation.spaceId, + crons: ["30 7 * * *"], + timezone: "Europe/Vienna", + active: true, + notify: true, + name: COACH_ROUTINES.morning.name, + }, + { + coachKey: "weekly", + botId: conversation.leadBotId, + threadId: conversation.threadId, + spaceId: conversation.spaceId, + crons: ["0 18 * * 0"], + timezone: "Europe/Vienna", + active: true, + notify: true, + name: COACH_ROUTINES.weekly.name, + }, + ]); + for (const routine of created) { + expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); + expect(jobs.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + name: "routine.wakeup", + replaceKey: routineJobKey(routine.id), + availableAt: routine.nextRunAt, + }), + ); + } + expect(created[0]?.prompt).toBe(COACH_ROUTINES.morning.prompt); + + // The same rhythm again changes nothing. + jobs.enqueue.mockClear(); + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + expect(jobs.enqueue).not.toHaveBeenCalled(); + expect(await coachRoutines(userId)).toEqual(created); + + // Moving, adding and turning off check-ins. + const next = { morning: "06:45", evening: "21:00", weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: next })).toEqual(next); + const updated = await coachRoutines(userId); + expect(updated.map((routine) => [routine.coachKey, routine.crons[0]])).toEqual([ + ["evening", "0 21 * * *"], + ["morning", "45 6 * * *"], + ]); + expect(updated.find((routine) => routine.coachKey === "morning")?.id).toBe(created[0]?.id); + expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(created[1]!.id)); + expect(await getCoachRhythm(prisma, userId)).toEqual(next); + }); + + it("reads paused or reshaped routines as off and turns them back on", async () => { + const userId = await newUser("UTC"); + const jobs = fakeJobs(); + await setCoachRhythm( + { prisma, jobs }, + { userId, rhythm: { morning: "08:00", evening: "20:00", weekly: null } }, + ); + const routines = await coachRoutines(userId); + await prisma.routine.update({ where: { id: routines[0]!.id }, data: { active: false } }); + await prisma.routine.update({ + where: { id: routines[1]!.id }, + data: { crons: ["0 8 * * 1-5"] }, + }); + expect(await getCoachRhythm(prisma, userId)).toEqual({ + morning: null, + evening: null, + weekly: null, + }); + + const rhythm = { morning: "08:00", evening: "20:00", weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + expect((await coachRoutines(userId)).map((routine) => routine.id)).toEqual( + routines.map((routine) => routine.id), + ); + }); + + it("turning everything off removes the routines and never creates a main bot", async () => { + const userId = await newUser(null); + const jobs = fakeJobs(); + const off = { morning: null, evening: null, weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off); + expect(await prisma.bot.count({ where: { userId, kind: "main" } })).toBe(0); + expect(await getCoachRhythm(prisma, userId)).toEqual(off); + + await setCoachRhythm( + { prisma, jobs }, + { userId, rhythm: { morning: "09:15", evening: null, weekly: null } }, + ); + const [routine] = await coachRoutines(userId); + expect(routine).toMatchObject({ timezone: "UTC", crons: ["15 9 * * *"] }); + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off); + expect(await coachRoutines(userId)).toEqual([]); + expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(routine!.id)); + }); +}); diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index f9ee580..6606092 100644 --- a/packages/adapters/src/coach-rhythm.ts +++ b/packages/adapters/src/coach-rhythm.ts @@ -1,7 +1,7 @@ import type { JobPublisher } from "@rakazo/adapter-kit"; import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; import type { CoachRhythm } from "@rakazo/contracts"; -import { COACH_RHYTHM_OFF } from "@rakazo/contracts"; +import { COACH_RHYTHM_OFF, clockTimeToMinutes, minutesToClockTime } from "@rakazo/contracts"; import { nextCronDate } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db"; @@ -34,26 +34,23 @@ export const COACH_ROUTINES: Record export interface CoachRhythmDeps { prisma: PrismaClient; - jobs: JobPublisher; + jobs: Pick; } /** The cron for one check-in, or null when it is off. */ export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null { const slot = key === "weekly" ? rhythm.weekly : rhythm[key]; if (!slot) return null; - const time = typeof slot === "string" ? slot : slot.time; - const [hour, minute] = time.split(":").map(Number); - return `${minute} ${hour} * * ${typeof slot === "string" ? "*" : slot.day}`; + const minutes = clockTimeToMinutes(typeof slot === "string" ? slot : slot.time); + return `${minutes % 60} ${Math.floor(minutes / 60)} * * ${typeof slot === "string" ? "*" : slot.day}`; } -const pad = (value: string) => value.padStart(2, "0"); - /** Reads a check-in back from its routine; a schedule edited into another shape reads as off. */ function rhythmSlot(key: CoachKey, crons: readonly string[]) { const match = crons.length === 1 ? /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(crons[0]!) : null; if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return null; - const time = `${pad(match[2]!)}:${pad(match[1]!)}`; + const time = minutesToClockTime(Number(match[2]) * 60 + Number(match[1])); if (key === "weekly") return match[3] === "*" ? null : { day: Number(match[3]), time }; return match[3] === "*" ? time : null; } diff --git a/packages/adapters/src/coach-tools.test.ts b/packages/adapters/src/coach-tools.test.ts new file mode 100644 index 0000000..3aa7567 --- /dev/null +++ b/packages/adapters/src/coach-tools.test.ts @@ -0,0 +1,79 @@ +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it } from "vitest"; +import { COACH_TOOL_NAMES, executeCoachTool } from "./coach-tools.js"; +import { selectBuiltinToolsForRun } from "./executor-tools.js"; + +function toolNames(options: { + mainBot?: boolean; + messagingChannelRun?: boolean; + foreignAgentRun?: boolean; + trigger?: string; +}) { + return selectBuiltinToolsForRun({ + graphicalToolsAllowed: false, + groupId: null, + trigger: options.trigger ?? "user", + semanticMemoryEnabled: false, + messagingChannelRun: options.messagingChannelRun ?? false, + foreignAgentRun: options.foreignAgentRun, + bot: { + kind: options.mainBot ? "main" : "specialist", + restrictBuiltinTools: false, + allowedBuiltinTools: [], + }, + }).map((tool) => tool.name); +} + +/** Any database access fails the test: refusals and invalid input must not reach it. */ +const untouchedPrisma = new Proxy( + {}, + { + get(_target, property) { + throw new Error(`unexpected database access: ${String(property)}`); + }, + }, +) as PrismaClient; + +const context = (kind: string) => ({ userId: "user-1", bot: { id: "bot-1", kind } }); + +describe("coach tools", () => { + it("are the main bot's alone, also in its check-in routines, and never for other people", () => { + for (const name of COACH_TOOL_NAMES) { + expect(toolNames({ mainBot: true })).toContain(name); + expect(toolNames({ mainBot: true, trigger: "routine" })).toContain(name); + expect(toolNames({ mainBot: false })).not.toContain(name); + expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain(name); + expect(toolNames({ mainBot: true, foreignAgentRun: true })).not.toContain(name); + } + }); + + it("refuse specialists even when they call a tool anyway", async () => { + for (const name of COACH_TOOL_NAMES) { + await expect( + executeCoachTool(untouchedPrisma, context("specialist"), name, { title: "Run" }), + ).resolves.toEqual({ error: "Only the user's main bot can coach the user." }); + } + }); + + it("reject invalid or incomplete arguments before writing", async () => { + const main = context("main"); + await expect(executeCoachTool(untouchedPrisma, main, "update_goal", {})).resolves.toEqual({ + error: "A new goal needs a title.", + }); + await expect( + executeCoachTool(untouchedPrisma, main, "update_goal", { title: "x", target_date: "soon" }), + ).resolves.toEqual({ error: expect.stringContaining("target_date") }); + await expect( + executeCoachTool(untouchedPrisma, main, "update_habit", { title: "Walk", cadence: "hourly" }), + ).resolves.toEqual({ error: expect.stringContaining("cadence") }); + await expect( + executeCoachTool(untouchedPrisma, main, "log_check_in", { habit: "Walk", goal: "10k" }), + ).resolves.toEqual({ error: "Log a habit and a goal in separate calls." }); + await expect( + executeCoachTool(untouchedPrisma, main, "log_check_in", { mood: 9 }), + ).resolves.toEqual({ error: expect.stringContaining("mood") }); + await expect( + executeCoachTool(untouchedPrisma, main, "write_daily_brief", { text: " " }), + ).resolves.toEqual({ error: expect.stringContaining("text") }); + }); +}); diff --git a/packages/adapters/src/coach-tools.ts b/packages/adapters/src/coach-tools.ts new file mode 100644 index 0000000..39a496e --- /dev/null +++ b/packages/adapters/src/coach-tools.ts @@ -0,0 +1,354 @@ +import type { ConnectorTool } from "@rakazo/adapter-kit"; +import { + CHECK_IN_NOTE_MAX_LENGTH, + COACH_TITLE_MAX_LENGTH, + DAILY_BRIEF_MAX_LENGTH, + GOAL_DETAIL_MAX_LENGTH, + GOAL_STATUSES, + GoalStatusSchema, + HABIT_CADENCES, + HabitCadenceSchema, + LocalDateSchema, +} from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + logCheckIn, + updateGoal, + updateHabit, + upsertDailyBrief, +} from "@rakazo/db"; +import { z } from "zod"; + +/** The main bot's coach tools, in the order they appear in its tool list. */ +export const COACH_TOOL_NAMES = [ + "update_goal", + "update_habit", + "log_check_in", + "write_daily_brief", +] as const; +export type CoachToolName = (typeof COACH_TOOL_NAMES)[number]; + +export function isCoachToolName(name: string): name is CoachToolName { + return (COACH_TOOL_NAMES as readonly string[]).includes(name); +} + +const DATE_HINT = "The user's local date, YYYY-MM-DD."; + +export const coachTools: ConnectorTool[] = [ + { + name: "update_goal", + description: + "Track a goal the user committed to. Without id it creates one; with id it changes it, and status done or dropped closes it. Track only what the user stated or agreed to.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Id or exact title of the goal to change." }, + title: { type: "string", description: 'A short outcome, e.g. "Run a 10k in November".' }, + detail: { type: "string", description: "Optional context, such as why it matters." }, + status: { type: "string", enum: [...GOAL_STATUSES] }, + target_date: { type: "string", description: `${DATE_HINT} An empty string clears it.` }, + }, + }, + }, + { + name: "update_habit", + description: + "Track a habit the user wants to keep. Without id it creates one; with id it changes it, and active false stops tracking it.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Id or exact title of the habit to change." }, + title: { type: "string", description: 'A short practice, e.g. "Walk 20 minutes".' }, + cadence: { type: "string", enum: [...HABIT_CADENCES] }, + goal: { + type: "string", + description: "Id or exact title of the goal it serves. An empty string unlinks it.", + }, + active: { type: "boolean" }, + }, + }, + }, + { + name: "log_check_in", + description: + "Record what the user reported for a day: a habit kept or missed, progress on a goal, or, with neither, how the day went. Logging the same day again updates it.", + inputSchema: { + type: "object", + properties: { + habit: { type: "string", description: "Id or exact title of the habit." }, + goal: { type: "string", description: "Id or exact title of the goal." }, + date: { type: "string", description: `${DATE_HINT} Defaults to today.` }, + done: { type: "boolean", description: "Whether the habit was kept." }, + mood: { type: "integer", minimum: 1, maximum: 5 }, + energy: { type: "integer", minimum: 1, maximum: 5 }, + note: { type: "string", description: "The user's words, briefly." }, + }, + }, + }, + { + name: "write_daily_brief", + description: + "Save the user's brief for the day, shown at the top of their Today page. Plain text, two to five short lines. Writing again replaces it.", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + date: { type: "string", description: `${DATE_HINT} Defaults to today.` }, + }, + required: ["text"], + }, + }, +]; + +const Ref = z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH); +const ClearableDate = z.union([LocalDateSchema, z.literal("")]); + +const UpdateGoalArgs = z.object({ + id: Ref.optional(), + title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(), + detail: z.string().trim().max(GOAL_DETAIL_MAX_LENGTH).optional(), + status: GoalStatusSchema.optional(), + target_date: ClearableDate.optional(), +}); + +const UpdateHabitArgs = z.object({ + id: Ref.optional(), + title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(), + cadence: HabitCadenceSchema.optional(), + goal: z.union([Ref, z.literal("")]).optional(), + active: z.boolean().optional(), +}); + +const Rating = z.number().int().min(1).max(5); + +const LogCheckInArgs = z.object({ + habit: Ref.optional(), + goal: Ref.optional(), + date: LocalDateSchema.optional(), + done: z.boolean().optional(), + mood: Rating.optional(), + energy: Rating.optional(), + note: z.string().trim().max(CHECK_IN_NOTE_MAX_LENGTH).optional(), +}); + +const WriteDailyBriefArgs = z.object({ + text: z.string().trim().min(1).max(DAILY_BRIEF_MAX_LENGTH), + date: LocalDateSchema.optional(), +}); + +export interface CoachToolContext { + userId: string; + bot: { id: string; kind: string }; +} + +type ToolResult = Record; + +function invalid(name: string, error: z.ZodError): ToolResult { + const issues = error.issues + .map((issue) => `${issue.path.join(".") || "arguments"}: ${issue.message}`) + .join("; "); + return { error: `Invalid ${name} arguments: ${issues}` }; +} + +/** An id first, then an exact title (any case) among the user's items. */ +async function resolveRef( + find: (where: object) => Promise>, + ref: string, + what: "goal" | "habit", +): Promise { + const byId = await find({ id: ref }); + if (byId[0]) return byId[0].id; + const byTitle = await find({ title: { equals: ref, mode: "insensitive" } }); + if (byTitle.length === 1) return byTitle[0]!.id; + return byTitle.length > 1 + ? { error: `Several ${what}s are titled "${ref}". Use the id.` } + : { error: `No ${what} "${ref}".` }; +} + +function goalRef(prisma: PrismaClient, userId: string, ref: string) { + return resolveRef( + (where) => + prisma.userGoal.findMany({ + where: { userId, ...where }, + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + select: { id: true }, + take: 2, + }), + ref, + "goal", + ); +} + +function habitRef(prisma: PrismaClient, userId: string, ref: string) { + return resolveRef( + (where) => + prisma.habit.findMany({ + where: { userId, ...where }, + orderBy: [{ active: "desc" }, { createdAt: "desc" }], + select: { id: true }, + take: 2, + }), + ref, + "habit", + ); +} + +async function updateGoalFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = UpdateGoalArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("update_goal", parsed.error); + const args = parsed.data; + const targetDate = args.target_date === undefined ? undefined : args.target_date || null; + if (!args.id) { + if (!args.title) return { error: "A new goal needs a title." }; + const goal = await createGoal(prisma, context.userId, { + title: args.title, + detail: args.detail, + targetDate, + source: "chat", + createdByBotId: context.bot.id, + }); + return { ok: true, goal: { id: goal.id, title: goal.title, status: goal.status } }; + } + const goalId = await goalRef(prisma, context.userId, args.id); + if (typeof goalId !== "string") return goalId; + const goal = await updateGoal(prisma, context.userId, goalId, { + title: args.title, + detail: args.detail, + status: args.status, + targetDate, + }); + return { + ok: true, + goal: { id: goal.id, title: goal.title, status: goal.status, targetDate: goal.targetDate }, + }; +} + +async function updateHabitFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = UpdateHabitArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("update_habit", parsed.error); + const args = parsed.data; + let goalId: string | null | undefined; + if (args.goal) { + const resolved = await goalRef(prisma, context.userId, args.goal); + if (typeof resolved !== "string") return resolved; + goalId = resolved; + } else if (args.goal === "") { + goalId = null; + } + if (!args.id) { + if (!args.title) return { error: "A new habit needs a title." }; + const habit = await createHabit(prisma, context.userId, { + title: args.title, + cadence: args.cadence, + goalId, + source: "chat", + createdByBotId: context.bot.id, + }); + return { ok: true, habit: { id: habit.id, title: habit.title, cadence: habit.cadence } }; + } + const habitId = await habitRef(prisma, context.userId, args.id); + if (typeof habitId !== "string") return habitId; + const habit = await updateHabit(prisma, context.userId, habitId, { + title: args.title, + cadence: args.cadence, + goalId, + active: args.active, + }); + return { + ok: true, + habit: { id: habit.id, title: habit.title, cadence: habit.cadence, active: habit.active }, + }; +} + +async function logCheckInFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = LogCheckInArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("log_check_in", parsed.error); + const { habit, goal, ...fields } = parsed.data; + if (habit && goal) return { error: "Log a habit and a goal in separate calls." }; + const habitId = habit ? await habitRef(prisma, context.userId, habit) : undefined; + if (habitId !== undefined && typeof habitId !== "string") return habitId; + const goalId = goal ? await goalRef(prisma, context.userId, goal) : undefined; + if (goalId !== undefined && typeof goalId !== "string") return goalId; + const checkIn = await logCheckIn(prisma, context.userId, { + kind: habitId ? "habit" : goalId ? "goal" : "daily", + habitId, + goalId, + ...fields, + createdByBotId: context.bot.id, + }); + return { + ok: true, + checkIn: { + kind: checkIn.kind, + date: checkIn.date, + done: checkIn.done, + mood: checkIn.mood, + energy: checkIn.energy, + }, + }; +} + +async function writeDailyBriefFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = WriteDailyBriefArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("write_daily_brief", parsed.error); + const brief = await upsertDailyBrief(prisma, context.userId, { + text: parsed.data.text, + date: parsed.data.date, + botId: context.bot.id, + }); + return { ok: true, date: brief.date }; +} + +const HANDLERS: Record< + CoachToolName, + ( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, + ) => Promise +> = { + update_goal: updateGoalFromTool, + update_habit: updateHabitFromTool, + log_check_in: logCheckInFromTool, + write_daily_brief: writeDailyBriefFromTool, +}; + +/** Runs one coach tool for the user's main bot; every other bot is refused. */ +export async function executeCoachTool( + prisma: PrismaClient, + context: CoachToolContext, + name: CoachToolName, + rawArgs: Record, +): Promise { + if (context.bot.kind !== "main") { + return { error: "Only the user's main bot can coach the user." }; + } + try { + return await HANDLERS[name](prisma, context, rawArgs); + } catch (error) { + if (error instanceof CoachNotFoundError || error instanceof InvalidCoachInputError) { + return { error: error.message }; + } + throw error; + } +} diff --git a/packages/adapters/src/executor-capabilities.test.ts b/packages/adapters/src/executor-capabilities.test.ts index 57de9d8..8c953dd 100644 --- a/packages/adapters/src/executor-capabilities.test.ts +++ b/packages/adapters/src/executor-capabilities.test.ts @@ -111,6 +111,9 @@ async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record ({ name: "Ada" })) }, userProfile: { findUnique: vi.fn(async () => null) }, userFact: { findMany: vi.fn(async () => []) }, + // The main bot's goals and habits. + userGoal: { findMany: vi.fn(async () => []) }, + habit: { findMany: vi.fn(async () => []) }, ...models, }; let request: AgentRunRequest | undefined; diff --git a/packages/adapters/src/executor-instructions.test.ts b/packages/adapters/src/executor-instructions.test.ts index 26c2e89..da9dc0a 100644 --- a/packages/adapters/src/executor-instructions.test.ts +++ b/packages/adapters/src/executor-instructions.test.ts @@ -1,5 +1,6 @@ import type { AgentRunRequest } from "@rakazo/adapter-kit"; import type { MessageBlock } from "@rakazo/contracts"; +import { addDays, localDateIn } from "@rakazo/core"; import { describe, expect, it, vi } from "vitest"; import type * as ComputerLifecycleModule from "./computer-lifecycle.js"; import { createRunExecutor } from "./executor.js"; @@ -20,6 +21,9 @@ interface FixtureOptions { sender?: { name: string } | null; profile?: Record | null; facts?: Array<{ category: string; value: string; confidence: number }>; + goals?: Array>; + habits?: Array>; + doneCheckIns?: Array<{ habitId: string; date: Date }>; peers?: Array<{ id: string; name: string; @@ -131,6 +135,9 @@ async function runOnce(options: FixtureOptions = {}) { async () => options.facts ?? [{ category: "work", value: "Owns a bakery", confidence: 1 }], ), }, + userGoal: { findMany: vi.fn(async () => options.goals ?? []) }, + habit: { findMany: vi.fn(async () => options.habits ?? []) }, + checkIn: { findMany: vi.fn(async () => options.doneCheckIns ?? []) }, actionApprovalRule: { findMany: vi.fn(async () => []) }, actionAutoReviewPreference: { findUnique: vi.fn(async () => ({ enabled: false })) }, externalEffect: { @@ -297,6 +304,11 @@ describe("run instructions", () => { expect(request.tools.map((tool) => tool.name)).not.toContain("update_user_profile"); expect(request.instructions).not.toContain("update_user_profile"); + for (const name of ["update_goal", "update_habit", "log_check_in", "write_daily_brief"]) { + expect(request.tools.map((tool) => tool.name)).not.toContain(name); + expect(request.instructions).not.toContain(name); + } + expect(request.instructions).not.toContain("Active goals"); }); it("gives runs woken by the user's own bot the full user block", async () => { @@ -338,6 +350,56 @@ describe("run instructions", () => { expect(request.instructions).not.toContain("save lasting facts they share with remember"); }); + it("gives the main bot the user's goals and habits and names its coach tools", async () => { + const today = localDateIn("Europe/Berlin"); + const row = { + source: "chat", + createdByBotId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const coach = { + goals: [ + { + ...row, + id: "goal-1", + title: "Ship the beta", + detail: "", + status: "active", + targetDate: new Date(`${addDays(today, 30)}T00:00:00Z`), + spaceId: null, + completedAt: null, + }, + ], + habits: [ + { ...row, id: "habit-1", title: "Walk", cadence: "daily", goalId: null, active: true }, + { ...row, id: "habit-2", title: "Long run", cadence: "weekly", goalId: null, active: true }, + ], + doneCheckIns: [today, addDays(today, -1)].map((date) => ({ + habitId: "habit-1", + date: new Date(`${date}T00:00:00Z`), + })), + }; + const main = await runOnce({ + bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }, + ...coach, + }); + const names = main.request.tools.map((tool) => tool.name); + expect(names).toEqual( + expect.arrayContaining(["update_goal", "update_habit", "log_check_in", "write_daily_brief"]), + ); + expect(main.request.instructions).toContain("You are also their coach"); + expect(main.request.instructions).toContain("save today's brief with write_daily_brief"); + expect(main.request.instructions).toContain( + `Active goals:\n- Ship the beta, due ${addDays(today, 30)} (id: goal-1)\nHabits:\n- Walk: daily, done today, streak 2 (id: habit-1)\n- Long run: weekly, not done this week, streak 0 (id: habit-2)\n`, + ); + + const specialist = await runOnce(coach); + expect(specialist.request.instructions).not.toContain("Active goals"); + expect(specialist.request.instructions).not.toContain("You are also their coach"); + expect(specialist.request.tools.map((tool) => tool.name)).not.toContain("update_goal"); + }); + it("shows the main bot every specialist it can delegate to, its own space first", async () => { const { request, peersFindMany } = await runOnce({ bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }, diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 479f2dd..5bdddea 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -1,9 +1,12 @@ import { escapePromptData, knownTimeZone } from "@rakazo/core"; -import type { UserContext } from "@rakazo/db"; +import type { CoachSummary, UserContext } from "@rakazo/db"; +import { COACH_TOOL_NAMES } from "./coach-tools.js"; /** Per-field caps so user-written profile text cannot crowd out the rest of the prompt. */ const USER_FIELD_MAX_CHARS = 500; const USER_SUMMARY_MAX_CHARS = 2_000; +/** Goals and habits listed in the user block; the rest are counted. */ +const COACH_PROMPT_ITEM_LIMIT = 20; export interface BotIdentity { name: string; @@ -41,11 +44,30 @@ export function formatMainAgentRole(toolNames: ReadonlySet): string { : toolNames.has("remember") ? "Keep what you know about the user current: save lasting facts they share with remember." : "Keep what you know about the user current.", + ...formatCoachGuidance(toolNames), ] .filter(Boolean) .join(" "); } +/** How the main bot coaches, naming only the coach tools this run has. */ +function formatCoachGuidance(toolNames: ReadonlySet): Array { + if (!COACH_TOOL_NAMES.some((name) => toolNames.has(name))) return []; + return [ + "You are also their coach: help them reach the goals and keep the habits they chose. Be brief and encouraging, notice progress, and never nag.", + toolNames.has("update_goal") + ? "Record goals they commit to with update_goal, and close them when they are done or dropped." + : undefined, + toolNames.has("update_habit") ? "Track habits they want to keep with update_habit." : undefined, + toolNames.has("log_check_in") + ? "When they tell you how a habit, a goal or their day went, record it with log_check_in." + : undefined, + toolNames.has("write_daily_brief") + ? "At the morning check-in, save today's brief with write_daily_brief." + : undefined, + ]; +} + /** One line of escaped user text, clipped to `max` characters. */ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string { const text = (value ?? "").replace(/\s+/g, " ").trim(); @@ -57,12 +79,16 @@ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): * by the user or inferred from conversations, so every value is escaped and the block says * it never carries instructions. Empty fields are left out, and with nothing to say the block * is omitted. A run whose replies other people read (a shared messaging channel, or another - * owner's agent over an agent connection) gets only the basics: the summary and facts stay - * with the user. + * owner's agent over an agent connection) gets only the basics: the summary, facts, goals and + * habits stay with the user. */ export function formatUserContext( user: UserContext, - options: { basicsOnly?: boolean } = {}, + options: { + basicsOnly?: boolean; + /** The main bot's own runs: the user's active goals and habits. */ + coach?: Pick; + } = {}, ): string | undefined { const field = (label: string, value: string | null | undefined, max?: number) => { const text = userText(value, max); @@ -82,6 +108,7 @@ export function formatUserContext( field("Communication style", user.communicationStyle), options.basicsOnly ? undefined : field("Summary", user.summary, USER_SUMMARY_MAX_CHARS), ...(facts.length > 0 ? ["Facts:", ...facts] : []), + ...(options.coach && !options.basicsOnly ? coachLines(options.coach) : []), ].filter((line): line is string => Boolean(line)); if (lines.length === 0) return undefined; return [ @@ -92,6 +119,30 @@ export function formatUserContext( ].join("\n"); } +function listed(label: string, items: readonly T[], line: (item: T) => string): string[] { + if (items.length === 0) return []; + const shown = items.slice(0, COACH_PROMPT_ITEM_LIMIT).map(line); + const more = items.length - shown.length; + return [`${label}:`, ...shown, ...(more > 0 ? [`- and ${more} more`] : [])]; +} + +/** Active goals and habits with ids, so coach tools can name them exactly. */ +function coachLines(coach: Pick): string[] { + return [ + ...listed( + "Active goals", + coach.goals, + (goal) => + `- ${userText(goal.title)}${goal.targetDate ? `, due ${goal.targetDate}` : ""} (id: ${goal.id})`, + ), + ...listed("Habits", coach.habits, (habit) => { + const period = habit.cadence === "weekly" ? "this week" : "today"; + const done = habit.doneToday ? `done ${period}` : `not done ${period}`; + return `- ${userText(habit.title)}: ${habit.cadence}, ${done}, streak ${habit.streak} (id: ${habit.id})`; + }), + ]; +} + /** * Background a run_subagent helper shares with the bot that started it: who it works for, * the present moment, and the user block. diff --git a/packages/adapters/src/executor-tools.test.ts b/packages/adapters/src/executor-tools.test.ts index bc2d541..8f5572e 100644 --- a/packages/adapters/src/executor-tools.test.ts +++ b/packages/adapters/src/executor-tools.test.ts @@ -66,8 +66,12 @@ describe("builtin tools per bot", () => { it("lists main-only tools for the main bot alone", () => { expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual([ "delegate_task", + "log_check_in", "notify_user", + "update_goal", + "update_habit", "update_user_profile", + "write_daily_brief", ]); const specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false }); const main = builtinToolNamesForBot({ kind: "main", chatLinked: false }); diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index 671ad46..2ec6979 100644 --- a/packages/adapters/src/executor-tools.ts +++ b/packages/adapters/src/executor-tools.ts @@ -1,6 +1,7 @@ import type { ConnectorTool } from "@rakazo/adapter-kit"; import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; +import { COACH_TOOL_NAMES } from "./coach-tools.js"; import { selectMemoryTools } from "./memory-tools.js"; import { filterImageReturningComputerTools } from "./model-vision.js"; import { @@ -21,6 +22,14 @@ export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet = new Set([ "delegate_task", "notify_user", "update_user_profile", + ...COACH_TOOL_NAMES, +]); + +/** Tools that write what the user's bots know about the user, so only the user may drive them. */ +const USER_MODEL_TOOLS: ReadonlySet = new Set([ + "update_user_profile", + "notify_user", + ...COACH_TOOL_NAMES, ]); const BUILTIN_TOOL_NAMES = [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name); @@ -106,19 +115,10 @@ export function selectBuiltinToolsForRun(options: { Boolean(options.cloudAgentEnabled), ).filter( (tool) => - !( - options.foreignAgentRun && - (tool.name === "update_user_profile" || tool.name === "notify_user") - ) && + !(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) && (!options.messagingChannelRun || - (![ - "remember", - "save_memory", - "recall_memory", - "forget_memory", - "update_user_profile", - "notify_user", - ].includes(tool.name) && + (!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) && + !USER_MODEL_TOOLS.has(tool.name) && !tool.name.startsWith("scratchpad_"))), ); return filterBuiltinToolsForBot( diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 994507a..49c9446 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -96,6 +96,7 @@ import { InvalidSpaceNameError, isTooManyDatabaseConnections, listSpaceUserBotIds, + loadCoachSummary, loadRunHistoryMessages, loadUserContext, type McpServer, @@ -175,6 +176,7 @@ import { type CloudAgentConnection, cloudAgentsEnabled } from "./cloud-agent-fac import { executeCloudAgentTool } from "./cloud-agent-service.js"; import { validCloudAgentArgs } from "./cloud-agent-tools.js"; import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; +import { executeCoachTool, isCoachToolName } from "./coach-tools.js"; import { collectLogIds, mergeConnectedPlugins, @@ -2581,6 +2583,11 @@ export function createRunExecutor(deps: ExecutorDeps) { ), ); } + if (isCoachToolName(name)) { + return finish( + await executeCoachTool(deps.prisma, { userId: run.userId, bot }, name, args), + ); + } if (name === "web_search") { return finish(await webSearchFromTool(web, context, args)); } @@ -3697,8 +3704,13 @@ export function createRunExecutor(deps: ExecutorDeps) { : undefined; const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone); // Replies to another owner's agent, like a shared channel, only get the basics. + const basicsOnly = messagingChannelRun || foreignAgentRun; const userInstruction = formatUserContext(userContext, { - basicsOnly: messagingChannelRun || foreignAgentRun, + basicsOnly, + coach: + bot.kind === "main" && !basicsOnly + ? await loadCoachSummary(deps.prisma, run.userId) + : undefined, }); const userBlock = userInstruction ? redactSecrets(userInstruction, runSecrets) : undefined; diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 7312219..bbeaf86 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -24,6 +24,7 @@ export * from "./cloud-agent-service.js"; export * from "./cloud-agent-tools.js"; export * from "./cloud-agent-tools-select.js"; export * from "./coach-rhythm.js"; +export * from "./coach-tools.js"; export * from "./composio-catalog-cache.js"; export * from "./composio-connector.js"; export * from "./composio-emulator.js"; diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index b75121f..02db91e 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -6,6 +6,7 @@ import type { } from "@rakazo/adapter-kit"; import { USER_FACT_CATEGORIES } from "@rakazo/contracts"; import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core"; +import { COACH_ROUTINES } from "./coach-rhythm.js"; const running = new Map(); @@ -15,6 +16,56 @@ const LEARN_ABOUT_ME = new RegExp( "i", ); +/** "set a goal: …", "track a habit: …", "done today: " and "write today's brief: …". */ +const COACH_COMMANDS: Array<{ + pattern: RegExp; + reply: string; + call: (value: string) => { name: string; args: Record }; +}> = [ + { + pattern: /^set a goal:\s*([\s\S]+)$/i, + reply: "goal noted.", + call: (title) => ({ name: "update_goal", args: { title } }), + }, + { + pattern: /^track a habit:\s*([\s\S]+)$/i, + reply: "habit noted.", + call: (title) => ({ name: "update_habit", args: { title } }), + }, + { + pattern: /^done today:\s*([\s\S]+)$/i, + reply: "checked off.", + call: (habit) => ({ name: "log_check_in", args: { habit, done: true } }), + }, + { + pattern: /^write today's brief:\s*([\s\S]+)$/i, + reply: "today's brief is ready.", + call: (text) => ({ name: "write_daily_brief", args: { text } }), + }, +]; + +/** The morning check-in writes a fixed brief so tests can follow it to the Today page. */ +export const SCRIPTED_MORNING_BRIEF = "One thing at a time today."; + +function inferCoachScript(prompt: string): NonNullable | undefined { + if (prompt.startsWith(COACH_ROUTINES.morning.prompt)) { + return [ + { + assistant: "good morning. today's brief is ready.", + toolCalls: [{ name: "write_daily_brief", args: { text: SCRIPTED_MORNING_BRIEF } }], + complete: true, + }, + ]; + } + for (const command of COACH_COMMANDS) { + const value = command.pattern.exec(prompt.trim())?.[1]?.trim(); + if (value) { + return [{ assistant: command.reply, toolCalls: [command.call(value)], complete: true }]; + } + } + return undefined; +} + export class ScriptedAgentRuntime implements AgentRuntime { describe() { return { @@ -207,6 +258,8 @@ export function inferScript( }, ]; } + const coach = inferCoachScript(prompt); + if (coach) return coach; if (lower.includes("delegate to the bot named")) { const name = namedBot(prompt) ?? "Researcher"; const brief = diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 249f518..d9fdae9 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,21 @@ import { eventIterator, oc } from "@orpc/contract"; import * as z from "zod"; import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js"; import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js"; +import { + CheckInSchema, + CoachRhythmSchema, + CoachTodaySchema, + CreateGoalInput, + CreateHabitInput, + GoalSchema, + HabitSchema, + ListCheckInsInput, + ListGoalsInput, + ListHabitsInput, + LogCheckInInput, + UpdateGoalInput, + UpdateHabitInput, +} from "./coach.js"; import { ActionApprovalRuleSchema, ActionAutoReviewSettingsSchema, @@ -196,6 +211,30 @@ export const appContract = { update: oc.input(UpdateUserProfileInput).output(UserProfileStateSchema), forget: oc.input(ForgetUserFactsInput).output(UserProfileStateSchema), }, + /** The user's goals, habits, check-ins and check-in times; owner-only, across spaces. */ + coach: { + today: oc.output(CoachTodaySchema), + goals: { + list: oc.input(ListGoalsInput).output(z.array(GoalSchema)), + create: oc.input(CreateGoalInput).output(GoalSchema), + update: oc.input(UpdateGoalInput).output(GoalSchema), + remove: oc.input(z.object({ goalId: Id })).output(z.object({ ok: z.literal(true) })), + }, + habits: { + list: oc.input(ListHabitsInput).output(z.array(HabitSchema)), + create: oc.input(CreateHabitInput).output(HabitSchema), + update: oc.input(UpdateHabitInput).output(HabitSchema), + remove: oc.input(z.object({ habitId: Id })).output(z.object({ ok: z.literal(true) })), + }, + checkIns: { + log: oc.input(LogCheckInInput).output(CheckInSchema), + list: oc.input(ListCheckInsInput).output(z.array(CheckInSchema)), + }, + rhythm: { + get: oc.output(CoachRhythmSchema), + set: oc.input(CoachRhythmSchema).output(CoachRhythmSchema), + }, + }, spaces: { list: oc.output(SpaceNavigationSchema), create: oc diff --git a/packages/core/src/coach.test.ts b/packages/core/src/coach.test.ts new file mode 100644 index 0000000..4f8f816 --- /dev/null +++ b/packages/core/src/coach.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + addDays, + habitDoneInPeriod, + habitStreak, + localDateIn, + weekdayOf, + weekStartOf, +} from "./coach.js"; + +const dates = (...values: string[]) => new Set(values); + +describe("calendar dates", () => { + it("reads the date in the user's time zone, not the server's", () => { + const instant = new Date("2026-10-03T23:30:00Z"); + expect(localDateIn("UTC", instant)).toBe("2026-10-03"); + expect(localDateIn("Europe/Vienna", instant)).toBe("2026-10-04"); + expect(localDateIn("America/Los_Angeles", instant)).toBe("2026-10-03"); + expect(localDateIn("Pacific/Kiritimati", new Date("2026-10-03T10:30:00Z"))).toBe("2026-10-04"); + }); + + it("moves across months, years and daylight saving changes by whole days", () => { + expect(addDays("2026-10-31", 1)).toBe("2026-11-01"); + expect(addDays("2027-01-01", -1)).toBe("2026-12-31"); + expect(addDays("2026-03-28", 2)).toBe("2026-03-30"); + expect(weekdayOf("2026-10-03")).toBe(6); + expect(weekStartOf("2026-10-03")).toBe("2026-09-28"); + expect(weekStartOf("2026-10-04")).toBe("2026-09-28"); + expect(weekStartOf("2026-10-05")).toBe("2026-10-05"); + }); +}); + +describe("habit streaks", () => { + const saturday = "2026-10-03"; + + it("counts consecutive days and keeps today open until it is done", () => { + const done = dates("2026-09-30", "2026-10-01", "2026-10-02"); + expect(habitStreak("daily", done, saturday)).toBe(3); + expect(habitStreak("daily", new Set([...done, saturday]), saturday)).toBe(4); + expect(habitStreak("daily", dates("2026-09-30", "2026-10-01"), saturday)).toBe(0); + expect(habitStreak("daily", dates(), saturday)).toBe(0); + }); + + it("skips weekends for weekday habits", () => { + const monday = "2026-10-05"; + const done = dates("2026-10-01", "2026-10-02"); + expect(habitStreak("weekdays", done, saturday)).toBe(2); + expect(habitStreak("weekdays", done, monday)).toBe(2); + expect(habitStreak("weekdays", new Set([...done, monday]), monday)).toBe(3); + expect(habitStreak("weekdays", dates("2026-10-01"), monday)).toBe(0); + }); + + it("counts weeks with at least one done day for weekly habits", () => { + const done = dates("2026-09-15", "2026-09-26", "2026-09-29"); + expect(habitStreak("weekly", done, saturday)).toBe(3); + expect(habitStreak("weekly", dates("2026-09-15", "2026-09-26"), saturday)).toBe(2); + expect(habitStreak("weekly", dates("2026-09-15"), saturday)).toBe(0); + }); + + it("reads a weekly habit as done for the rest of the week", () => { + expect(habitDoneInPeriod("weekly", dates("2026-09-29"), saturday)).toBe(true); + expect(habitDoneInPeriod("weekly", dates("2026-09-27"), saturday)).toBe(false); + expect(habitDoneInPeriod("daily", dates("2026-10-02"), saturday)).toBe(false); + expect(habitDoneInPeriod("daily", dates(saturday), saturday)).toBe(true); + }); +}); diff --git a/packages/db/src/coach.postgres.test.ts b/packages/db/src/coach.postgres.test.ts new file mode 100644 index 0000000..b4bb7a1 --- /dev/null +++ b/packages/db/src/coach.postgres.test.ts @@ -0,0 +1,327 @@ +import { randomUUID } from "node:crypto"; +import { COACH_ACTIVE_GOAL_LIMIT, COACH_ACTIVE_HABIT_LIMIT } from "@rakazo/contracts"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootstrapUserSpace } from "./bootstrap-user.js"; +import type { PrismaClient } from "./client.js"; +import { createDb } from "./client.js"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + listCheckIns, + listGoals, + listHabits, + loadCoachSummary, + loadCoachToday, + logCheckIn, + removeGoal, + removeHabit, + updateGoal, + updateHabit, + upsertDailyBrief, +} from "./coach.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; +/** Saturday 2026-10-03, 23:30 UTC: already Sunday in Vienna. */ +const NOW = new Date("2026-10-03T23:30:00Z"); + +describePostgres("coach repository (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(timeZone: string | null = "Europe/Vienna") { + const id = `co${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, { + claimDeploymentOwner: false, + }); + if (timeZone) await prisma.userProfile.create({ data: { userId: id, timeZone } }); + return { userId: id, spaceId }; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("creates, completes, reopens and removes goals, scoped to their owner", async () => { + const { userId, spaceId } = await newUser(); + const other = await newUser(); + const goal = await createGoal(prisma, userId, { + title: " Ship the beta ", + detail: "Invite ten testers", + targetDate: "2026-11-01", + spaceId, + source: "onboarding", + }); + expect(goal).toMatchObject({ + title: "Ship the beta", + detail: "Invite ten testers", + status: "active", + targetDate: "2026-11-01", + spaceId, + source: "onboarding", + createdByBotId: null, + completedAt: null, + }); + + const done = await updateGoal(prisma, userId, goal.id, { status: "done" }); + expect(done.status).toBe("done"); + expect(done.completedAt).not.toBeNull(); + const reopened = await updateGoal(prisma, userId, goal.id, { + status: "active", + targetDate: null, + }); + expect(reopened).toMatchObject({ status: "active", completedAt: null, targetDate: null }); + + await expect(updateGoal(prisma, other.userId, goal.id, { title: "Mine" })).rejects.toThrow( + CoachNotFoundError, + ); + await expect(removeGoal(prisma, other.userId, goal.id)).rejects.toThrow(CoachNotFoundError); + await expect( + createGoal(prisma, other.userId, { title: "Theirs", spaceId, source: "user" }), + ).rejects.toThrow("Space not found"); + expect(await listGoals(prisma, other.userId)).toEqual([]); + expect((await listGoals(prisma, userId, { status: "active" })).map((g) => g.id)).toEqual([ + goal.id, + ]); + + await removeGoal(prisma, userId, goal.id); + expect(await listGoals(prisma, userId)).toEqual([]); + }); + + it("validates titles, dates and the active limits", async () => { + const { userId } = await newUser(); + await expect(createGoal(prisma, userId, { title: " ", source: "user" })).rejects.toThrow( + InvalidCoachInputError, + ); + await expect( + createGoal(prisma, userId, { title: "x".repeat(201), source: "user" }), + ).rejects.toThrow("longer than 200"); + await expect( + createGoal(prisma, userId, { title: "Bad date", targetDate: "2026-02-30", source: "user" }), + ).rejects.toThrow("YYYY-MM-DD"); + + await prisma.userGoal.createMany({ + data: Array.from({ length: COACH_ACTIVE_GOAL_LIMIT }, (_, index) => ({ + userId, + title: `Goal ${index}`, + source: "user", + })), + }); + await expect(createGoal(prisma, userId, { title: "One more", source: "user" })).rejects.toThrow( + `at most ${COACH_ACTIVE_GOAL_LIMIT} active goals`, + ); + const [first] = await listGoals(prisma, userId); + await updateGoal(prisma, userId, first!.id, { status: "dropped" }); + await createGoal(prisma, userId, { title: "One more", source: "user" }); + await expect(updateGoal(prisma, userId, first!.id, { status: "active" })).rejects.toThrow( + "active goals", + ); + + await prisma.habit.createMany({ + data: Array.from({ length: COACH_ACTIVE_HABIT_LIMIT }, (_, index) => ({ + userId, + title: `Habit ${index}`, + source: "user", + })), + }); + await expect( + createHabit(prisma, userId, { title: "One more", source: "user" }), + ).rejects.toThrow(`at most ${COACH_ACTIVE_HABIT_LIMIT} active habits`); + }); + + it("links habits to goals, pauses them and keeps their history", async () => { + const { userId } = await newUser(); + const other = await newUser(); + const goal = await createGoal(prisma, userId, { title: "Run a 10k", source: "chat" }); + const habit = await createHabit(prisma, userId, { + title: "Run", + cadence: "weekdays", + goalId: goal.id, + source: "chat", + createdByBotId: "bot-1", + }); + expect(habit).toMatchObject({ cadence: "weekdays", goalId: goal.id, active: true }); + await expect( + createHabit(prisma, other.userId, { title: "Steal", goalId: goal.id, source: "user" }), + ).rejects.toThrow(CoachNotFoundError); + + const paused = await updateHabit(prisma, userId, habit.id, { active: false, title: "Jog" }); + expect(paused).toMatchObject({ active: false, title: "Jog" }); + expect(await listHabits(prisma, userId)).toEqual([]); + expect((await listHabits(prisma, userId, { includeInactive: true })).length).toBe(1); + + // Removing the goal keeps the habit, unlinked. + await removeGoal(prisma, userId, goal.id); + expect((await listHabits(prisma, userId, { includeInactive: true }))[0]?.goalId).toBeNull(); + await expect(removeHabit(prisma, other.userId, habit.id)).rejects.toThrow(CoachNotFoundError); + await removeHabit(prisma, userId, habit.id); + expect(await listHabits(prisma, userId, { includeInactive: true })).toEqual([]); + }); + + it("keeps one check-in per habit, goal and day, dated in the user's time zone", async () => { + const { userId } = await newUser("Europe/Vienna"); + const other = await newUser(); + const habit = await createHabit(prisma, userId, { title: "Stretch", source: "user" }); + const goal = await createGoal(prisma, userId, { title: "Learn Spanish", source: "user" }); + + const first = await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: habit.id, done: true }, + NOW, + ); + // 23:30 UTC on Saturday is Sunday in Vienna. + expect(first).toMatchObject({ kind: "habit", date: "2026-10-04", done: true, note: "" }); + const again = await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: habit.id, note: "10 minutes", createdByBotId: "bot-1" }, + NOW, + ); + expect(again).toMatchObject({ id: first.id, done: true, note: "10 minutes" }); + // Who created the row stays as it was. + expect(again.createdByBotId).toBeNull(); + + const daily = await logCheckIn(prisma, userId, { kind: "daily", mood: 4 }, NOW); + const dailyAgain = await logCheckIn(prisma, userId, { kind: "daily", energy: 2 }, NOW); + expect(dailyAgain).toMatchObject({ id: daily.id, mood: 4, energy: 2 }); + const goalCheckIn = await logCheckIn( + prisma, + userId, + { kind: "goal", goalId: goal.id, note: "Finished unit 3", date: "2026-10-02" }, + NOW, + ); + expect(goalCheckIn.date).toBe("2026-10-02"); + + await expect( + logCheckIn(prisma, userId, { kind: "daily", date: "2026-10-05" }, NOW), + ).rejects.toThrow("future"); + await expect( + logCheckIn(prisma, userId, { kind: "daily", date: "2025-01-01" }, NOW), + ).rejects.toThrow("go back at most"); + await expect(logCheckIn(prisma, userId, { kind: "daily", mood: 6 }, NOW)).rejects.toThrow( + "1 to 5", + ); + await expect( + logCheckIn(prisma, userId, { kind: "habit", goalId: goal.id }, NOW), + ).rejects.toThrow(InvalidCoachInputError); + await expect( + logCheckIn(prisma, other.userId, { kind: "habit", habitId: habit.id, done: true }, NOW), + ).rejects.toThrow(CoachNotFoundError); + + expect((await listCheckIns(prisma, userId)).map((row) => row.kind).sort()).toEqual([ + "daily", + "goal", + "habit", + ]); + expect(await listCheckIns(prisma, userId, { from: "2026-10-03" })).toHaveLength(2); + expect(await listCheckIns(prisma, userId, { goalId: goal.id })).toHaveLength(1); + expect(await listCheckIns(prisma, other.userId)).toEqual([]); + }); + + it("reports today's brief, goals, habits with streaks and the daily check-in", async () => { + const { userId } = await newUser("Europe/Vienna"); + const daily = await createHabit(prisma, userId, { title: "Walk", source: "user" }); + const weekly = await createHabit(prisma, userId, { + title: "Long run", + cadence: "weekly", + source: "user", + }); + const later = await createGoal(prisma, userId, { title: "Later", source: "user" }); + const soon = await createGoal(prisma, userId, { + title: "Soon", + targetDate: "2026-10-10", + source: "user", + }); + await createGoal(prisma, userId, { title: "Old", source: "user" }).then((goal) => + updateGoal(prisma, userId, goal.id, { status: "done" }), + ); + for (const date of ["2026-10-01", "2026-10-02", "2026-10-03"]) { + await logCheckIn(prisma, userId, { kind: "habit", habitId: daily.id, date, done: true }, NOW); + } + await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: weekly.id, date: "2026-09-30", done: true }, + NOW, + ); + + let today = await loadCoachToday(prisma, userId, NOW); + expect(today).toMatchObject({ date: "2026-10-04", timeZone: "Europe/Vienna", brief: null }); + expect(today.checkIn).toBeNull(); + expect(today.goals.map((goal) => goal.id)).toEqual([soon.id, later.id]); + expect( + today.habits.map(({ title, doneToday, streak }) => ({ title, doneToday, streak })), + ).toEqual([ + { title: "Walk", doneToday: false, streak: 3 }, + { title: "Long run", doneToday: true, streak: 1 }, + ]); + + const brief = await upsertDailyBrief( + prisma, + userId, + { text: "Rest day. Plan the week.", botId: "bot-1" }, + NOW, + ); + expect(brief).toMatchObject({ date: "2026-10-04", botId: "bot-1" }); + const rewritten = await upsertDailyBrief(prisma, userId, { text: "Rest day." }, NOW); + expect(rewritten).toMatchObject({ id: brief.id, text: "Rest day.", botId: null }); + await expect( + upsertDailyBrief(prisma, userId, { text: "Far ahead", date: "2026-10-20" }, NOW), + ).rejects.toThrow("today or tomorrow"); + await expect(upsertDailyBrief(prisma, userId, { text: " " }, NOW)).rejects.toThrow("empty"); + + await logCheckIn(prisma, userId, { kind: "habit", habitId: daily.id, done: true }, NOW); + await logCheckIn(prisma, userId, { kind: "daily", mood: 5 }, NOW); + // Unchecking a weekly habit clears it for the whole week. + await logCheckIn(prisma, userId, { kind: "habit", habitId: weekly.id, done: false }, NOW); + today = await loadCoachToday(prisma, userId, NOW); + expect(today.brief?.text).toBe("Rest day."); + expect(today.checkIn).toMatchObject({ kind: "daily", mood: 5 }); + expect(today.habits.map(({ doneToday, streak }) => ({ doneToday, streak }))).toEqual([ + { doneToday: true, streak: 4 }, + { doneToday: false, streak: 0 }, + ]); + + const summary = await loadCoachSummary(prisma, userId, NOW); + expect(summary).toMatchObject({ date: "2026-10-04", timeZone: "Europe/Vienna" }); + expect(summary.habits).toEqual(today.habits); + expect(summary.goals).toEqual(today.goals); + }); + + it("uses UTC dates when the user has no time zone", async () => { + const { userId } = await newUser(null); + expect(await loadCoachToday(prisma, userId, NOW)).toMatchObject({ + date: "2026-10-03", + timeZone: "UTC", + goals: [], + habits: [], + }); + }); +}); diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index cd4f117..d866402 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -64,6 +64,19 @@ describeWithDatabase("API authorization and resource isolation", () => { ["profile/get"], ["profile/update", { timeZone: "UTC" }], ["profile/forget", { all: true }], + ["coach/today"], + ["coach/goals/list", {}], + ["coach/goals/create", { title: "Nope" }], + ["coach/goals/update", { goalId: "missing-goal", title: "Nope" }], + ["coach/goals/remove", { goalId: "missing-goal" }], + ["coach/habits/list", {}], + ["coach/habits/create", { title: "Nope" }], + ["coach/habits/update", { habitId: "missing-habit", title: "Nope" }], + ["coach/habits/remove", { habitId: "missing-habit" }], + ["coach/checkIns/log", { kind: "daily", mood: 3 }], + ["coach/checkIns/list", {}], + ["coach/rhythm/get"], + ["coach/rhythm/set", { morning: null, evening: null, weekly: null }], ["deployment/get"], ["deployment/update", { signupsEnabled: true }], ["updater/status"], diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 38487bc..3d4ca0a 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -114,6 +114,8 @@ async function main() { "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", "packages/db/src/notification-settings.postgres.test.ts", + "packages/testkit/src/coach.postgres.test.ts", + "packages/db/src/coach.postgres.test.ts", "packages/db/src/messaging.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts", "packages/adapters/src/wakeup.postgres.test.ts", @@ -123,6 +125,7 @@ async function main() { "packages/adapters/src/builtin-memory-provider.postgres.test.ts", "packages/adapters/src/profile-consolidation.postgres.test.ts", "packages/adapters/src/notify-user.postgres.test.ts", + "packages/adapters/src/coach-rhythm.postgres.test.ts", ]; // Each app reconciles all durable work in its database, including intentionally // unfinished fixture runs. Clone the pristine migrated schema so one suite diff --git a/packages/testkit/src/coach.postgres.test.ts b/packages/testkit/src/coach.postgres.test.ts new file mode 100644 index 0000000..7f86a1a --- /dev/null +++ b/packages/testkit/src/coach.postgres.test.ts @@ -0,0 +1,284 @@ +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()); + }); +}); + +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; +}