From e1ce6f4090e68e6e5ab62f8a1f37bd9f18a974b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:15:06 +0000 Subject: [PATCH 1/4] test(coach): repository, streak and rhythm suites Covers what the coach interfaces promise before anything builds on them: goal and habit ownership and limits, one check-in per habit, goal and day dated in the user's time zone, streaks across weekends and weeks, and a rhythm that keeps exactly one main-bot routine per check-in and is idempotent when set again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- .../src/coach-rhythm.postgres.test.ts | 182 ++++++++++ packages/core/src/coach.test.ts | 66 ++++ packages/db/src/coach.postgres.test.ts | 327 ++++++++++++++++++ packages/testkit/src/cli/harness.ts | 2 + 4 files changed, 577 insertions(+) create mode 100644 packages/adapters/src/coach-rhythm.postgres.test.ts create mode 100644 packages/core/src/coach.test.ts create mode 100644 packages/db/src/coach.postgres.test.ts 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/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/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..ffe58e9 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -109,6 +109,7 @@ async function main() { "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.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", @@ -117,6 +118,7 @@ async function main() { "packages/adapters/src/cloud-agent.postgres.test.ts", "packages/adapters/src/builtin-memory-provider.postgres.test.ts", "packages/adapters/src/profile-consolidation.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 From 98b0798f60d528adb8f62bdaed6e0831e7cd8111 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:20:45 +0000 Subject: [PATCH 2/4] feat(coach): main-bot coach tools and goals in its prompt The main agent had no way to record what the user wants to achieve or how their day went, and its prompt never showed their goals or habits. - update_goal, update_habit, log_check_in and write_daily_brief, main-bot only and, like update_user_profile, withheld from shared channels and from runs that answer another owner's agent. Goals and habits can be named by id or exact title. - The main bot's own runs list active goals and habits (with ids, done state and streak) in the block; the basics-only block and every other bot never see them. The main agent role adds a short coaching stance and names each coach tool only when the run has it, so the check-in routine prompts stay tool-agnostic. - The scripted runtime understands a few coach commands and the morning check-in, so tests can drive the tools end to end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/builtin-tools.ts | 2 + packages/adapters/src/coach-rhythm.ts | 2 +- packages/adapters/src/coach-tools.test.ts | 79 ++++ packages/adapters/src/coach-tools.ts | 354 ++++++++++++++++++ .../src/executor-capabilities.test.ts | 3 + .../src/executor-instructions.test.ts | 62 +++ packages/adapters/src/executor-prompt.ts | 55 ++- packages/adapters/src/executor-tools.test.ts | 9 +- packages/adapters/src/executor-tools.ts | 19 +- packages/adapters/src/executor.ts | 14 +- packages/adapters/src/scripted-runtime.ts | 53 +++ 11 files changed, 639 insertions(+), 13 deletions(-) create mode 100644 packages/adapters/src/coach-tools.test.ts create mode 100644 packages/adapters/src/coach-tools.ts diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 0ff52fa..a6da4d1 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"; export const SUBAGENT_MODEL_TIER_DESCRIPTION = "Optional model tier: fast for quick simple work, code for programming, standard otherwise. Uses the model this space maps to the tier, or your own model when it maps none. Ignored when model_provider and model_id are set."; @@ -422,6 +423,7 @@ export const builtinAgentTools: ConnectorTool[] = [ }, }, }, + ...coachTools, { name: "web_search", description: diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index f9ee580..348e26a 100644 --- a/packages/adapters/src/coach-rhythm.ts +++ b/packages/adapters/src/coach-rhythm.ts @@ -34,7 +34,7 @@ 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. */ 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..09704a4 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(); @@ -62,7 +84,11 @@ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): */ 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 d987728..d7b60eb 100644 --- a/packages/adapters/src/executor-tools.test.ts +++ b/packages/adapters/src/executor-tools.test.ts @@ -64,7 +64,14 @@ describe("builtin tools per bot", () => { }); it("lists main-only tools for the main bot alone", () => { - expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual(["delegate_task", "update_user_profile"]); + expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual([ + "delegate_task", + "log_check_in", + "update_goal", + "update_habit", + "update_user_profile", + "write_daily_brief", + ]); const specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false }); const main = builtinToolNamesForBot({ kind: "main", chatLinked: false }); for (const name of MAIN_ONLY_BUILTIN_TOOLS) { diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index 2e819b8..d19477b 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 { @@ -20,6 +21,13 @@ export const PAGE_BROWSER_TOOL_NAMES = new Set([ export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet = new Set([ "delegate_task", "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", + ...COACH_TOOL_NAMES, ]); const BUILTIN_TOOL_NAMES = [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name); @@ -105,15 +113,10 @@ export function selectBuiltinToolsForRun(options: { Boolean(options.cloudAgentEnabled), ).filter( (tool) => - !(options.foreignAgentRun && tool.name === "update_user_profile") && + !(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) && (!options.messagingChannelRun || - (![ - "remember", - "save_memory", - "recall_memory", - "forget_memory", - "update_user_profile", - ].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 5cb9181..24899ff 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, @@ -2573,6 +2575,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)); } @@ -3689,8 +3696,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/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index 6a19544..6d66c41 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 = From 1a3829031fb47f6b3ee56583d74c1d61893b51e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:50 +0000 Subject: [PATCH 3/4] feat(coach): coach RPCs for goals, habits, check-ins, today and rhythm The web app needs to show and change the user's coach data, and the check-in times need a way to change after onboarding. - coach.today, coach.goals.*, coach.habits.*, coach.checkIns.* and coach.rhythm.get/set, owner-only and not space-scoped; another user's ids read as not found, invalid input as a bad request. - coach-rhythm reuses the shared clock-time helpers. - Tests: an API suite covering ownership and validation for every procedure, the coach tools driven through the main bot (and refused for a specialist), and a morning check-in routine that fires in the main conversation and writes today's brief; the unauthenticated authorization sweep lists the new procedures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/coach.ts | 96 +++++++ apps/api/src/router.ts | 47 ++++ packages/adapters/src/coach-rhythm.ts | 11 +- packages/adapters/src/index.ts | 1 + packages/contracts/src/rpc.ts | 39 +++ packages/testkit/src/authorization.test.ts | 13 + packages/testkit/src/cli/harness.ts | 1 + packages/testkit/src/coach.postgres.test.ts | 284 ++++++++++++++++++++ 8 files changed, 485 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/coach.ts create mode 100644 packages/testkit/src/coach.postgres.test.ts 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 b6c7bc6..d0cc995 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, @@ -531,6 +532,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"); @@ -576,6 +578,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/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index 348e26a..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"; @@ -41,19 +41,16 @@ export interface CoachRhythmDeps { 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/index.ts b/packages/adapters/src/index.ts index 2a841cc..972d83a 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/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1dc59cb..4808b29 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, @@ -189,6 +204,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/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index c640e4d..4b75880 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 ffe58e9..21a5f03 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -106,6 +106,7 @@ async function main() { "packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts", + "packages/testkit/src/coach.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", 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; +} From 74eb27447e327801d9025921d434635b7c37c365 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:09:51 +0000 Subject: [PATCH 4/4] feat(web): coach on the Heute page Heute only listed runs and linked to a hard-coded local Mission Control address, so the coach's work had nowhere to show. - Today's brief at the top, habits with a check-off and their streak, and active goals, each only when there is something to show; then the existing needs-you, doing and done lists. - One icon button opens the check-in times (morning, evening, weekly), loaded when opened and saved on every change. - The page reads coach.today, refreshes it when a run finishes or the tab becomes visible, and checks habits off optimistically; the date header uses the user's time zone from the profile. - Removes the Mission Control link and its constant. - Tests: helper unit tests and a web E2E that writes the brief through the main bot, checks a habit off across a reload and sets check-in times (screenshots heute-coach and heute-rhythm). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/heute-brief.spec.ts | 113 ++++++++- .../components/heute/CoachRhythmControl.tsx | 214 ++++++++++++++++++ apps/web/src/components/heute/HeuteCoach.tsx | 105 +++++++++ apps/web/src/lib/heute-brief.test.ts | 67 +++++- apps/web/src/lib/heute-brief.ts | 37 ++- apps/web/src/pages/HeuteBrief.tsx | 127 +++++++---- packages/adapters/src/executor-prompt.ts | 4 +- 7 files changed, 616 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/components/heute/CoachRhythmControl.tsx create mode 100644 apps/web/src/components/heute/HeuteCoach.tsx 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/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 09704a4..5bdddea 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -79,8 +79,8 @@ 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,