Merge the coach: goals, habits, check-ins, rhythm and the Heute page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
8d6ed7952f
96
apps/api/src/coach.ts
Normal file
96
apps/api/src/coach.ts
Normal file
@ -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<T>(work: () => Promise<T>): Promise<T> {
|
||||||
|
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<CoachToday> => 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<Habit[]> =>
|
||||||
|
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<CheckIn> =>
|
||||||
|
mapErrors(() => logCheckIn(prisma, actor.userId, input)),
|
||||||
|
listCheckIns: (actor: Actor, input: CheckInFilter) =>
|
||||||
|
mapErrors(() => listCheckIns(prisma, actor.userId, input)),
|
||||||
|
|
||||||
|
getRhythm: (actor: Actor): Promise<CoachRhythm> => getCoachRhythm(prisma, actor.userId),
|
||||||
|
setRhythm: (actor: Actor, rhythm: CoachRhythm): Promise<CoachRhythm> =>
|
||||||
|
setCoachRhythm(deps, { userId: actor.userId, rhythm }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -158,6 +158,7 @@ import { createAgentSkillsService } from "./agent-skills.js";
|
|||||||
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
||||||
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
||||||
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
||||||
|
import { createCoachService } from "./coach.js";
|
||||||
import {
|
import {
|
||||||
executionBlocksUserTakeover,
|
executionBlocksUserTakeover,
|
||||||
resolveBusyBotName,
|
resolveBusyBotName,
|
||||||
@ -542,6 +543,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
dataDir: deps.dataDir,
|
dataDir: deps.dataDir,
|
||||||
});
|
});
|
||||||
const agentSkills = createAgentSkillsService(deps.prisma);
|
const agentSkills = createAgentSkillsService(deps.prisma);
|
||||||
|
const coach = createCoachService(deps);
|
||||||
|
|
||||||
const authed = os.use(async ({ context, next }) => {
|
const authed = os.use(async ({ context, next }) => {
|
||||||
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
|
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
|
||||||
@ -587,6 +589,51 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
forgetProfile(deps.prisma, context.actor, input),
|
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: {
|
spaces: {
|
||||||
list: authed.spaces.list.handler(async ({ context }) =>
|
list: authed.spaces.list.handler(async ({ context }) =>
|
||||||
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
||||||
|
|||||||
@ -1,20 +1,127 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
import { expect, test } 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) => {
|
test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
|
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Heute", exact: true }).click();
|
await openHeute(page);
|
||||||
await expect(page).toHaveURL(/\/app\/[^/]+$/);
|
await expect(page).toHaveURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
const brief = page.getByTestId("heute-brief");
|
const brief = page.getByTestId("heute-brief");
|
||||||
await expect(brief).toBeVisible();
|
await expect(brief).toBeVisible();
|
||||||
await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0);
|
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.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-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");
|
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<CoachToday>(page, "coach/today", {});
|
||||||
|
const goal = await rpc<Goal>(page, "coach/goals/create", {
|
||||||
|
title: "Ship the beta",
|
||||||
|
targetDate: shiftDate(date, 30),
|
||||||
|
});
|
||||||
|
const habit = await rpc<Habit>(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<CoachRhythm>(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<Routine[]>(page, "routines/list", { botId: main.id });
|
||||||
|
expect(routines.map((routine) => routine.name).sort()).toEqual([
|
||||||
|
"Morning brief",
|
||||||
|
"Weekly review",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
214
apps/web/src/components/heute/CoachRhythmControl.tsx
Normal file
214
apps/web/src/components/heute/CoachRhythmControl.tsx
Normal file
@ -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<CoachRhythm | null>(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 (
|
||||||
|
<Popover
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) void load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger
|
||||||
|
data-testid="heute-rhythm"
|
||||||
|
aria-label={t`Check-ins`}
|
||||||
|
title={t`Check-ins`}
|
||||||
|
render={<Button variant="ghost" size="icon" />}
|
||||||
|
>
|
||||||
|
<CalendarClock strokeWidth={1.7} aria-hidden="true" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
data-testid="heute-rhythm-panel"
|
||||||
|
className="w-[min(340px,calc(100vw-2rem))] gap-3 p-3"
|
||||||
|
>
|
||||||
|
{rhythm ? (
|
||||||
|
<>
|
||||||
|
<RhythmRow
|
||||||
|
label={t`Morning`}
|
||||||
|
testId="heute-rhythm-morning"
|
||||||
|
on={rhythm.morning !== null}
|
||||||
|
onToggle={(on) => void save({ ...rhythm, morning: on ? DEFAULTS.morning : null })}
|
||||||
|
>
|
||||||
|
{(labelId) =>
|
||||||
|
rhythm.morning ? (
|
||||||
|
<TimeInput
|
||||||
|
labelId={labelId}
|
||||||
|
value={rhythm.morning}
|
||||||
|
onChange={(morning) => void save({ ...rhythm, morning })}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</RhythmRow>
|
||||||
|
<RhythmRow
|
||||||
|
label={t`Evening`}
|
||||||
|
testId="heute-rhythm-evening"
|
||||||
|
on={rhythm.evening !== null}
|
||||||
|
onToggle={(on) => void save({ ...rhythm, evening: on ? DEFAULTS.evening : null })}
|
||||||
|
>
|
||||||
|
{(labelId) =>
|
||||||
|
rhythm.evening ? (
|
||||||
|
<TimeInput
|
||||||
|
labelId={labelId}
|
||||||
|
value={rhythm.evening}
|
||||||
|
onChange={(evening) => void save({ ...rhythm, evening })}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</RhythmRow>
|
||||||
|
<RhythmRow
|
||||||
|
label={t`Weekly`}
|
||||||
|
testId="heute-rhythm-weekly"
|
||||||
|
on={rhythm.weekly !== null}
|
||||||
|
onToggle={(on) => void save({ ...rhythm, weekly: on ? DEFAULTS.weekly : null })}
|
||||||
|
>
|
||||||
|
{(labelId) => {
|
||||||
|
const weekly = rhythm.weekly;
|
||||||
|
if (!weekly) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NativeSelect
|
||||||
|
size="sm"
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
value={String(weekly.day)}
|
||||||
|
onChange={(event) =>
|
||||||
|
void save({
|
||||||
|
...rhythm,
|
||||||
|
weekly: { ...weekly, day: Number(event.target.value) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{WEEKDAYS.map((day) => (
|
||||||
|
<NativeSelectOption key={day} value={day}>
|
||||||
|
{weekdayName(day)}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
<TimeInput
|
||||||
|
labelId={labelId}
|
||||||
|
value={weekly.time}
|
||||||
|
onChange={(time) => void save({ ...rhythm, weekly: { ...weekly, time } })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</RhythmRow>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center py-3">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div data-testid={testId} className="flex min-h-control-sm flex-wrap items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={on}
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
onCheckedChange={(checked) => onToggle(checked)}
|
||||||
|
/>
|
||||||
|
<span id={labelId} className="flex-1 text-ui-body text-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">{children(labelId)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeInput({
|
||||||
|
labelId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
labelId: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.value) onChange(event.target.value.slice(0, 5));
|
||||||
|
}}
|
||||||
|
className="h-control-sm w-32 px-2 tabular-nums"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/web/src/components/heute/HeuteCoach.tsx
Normal file
105
apps/web/src/components/heute/HeuteCoach.tsx
Normal file
@ -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 ? (
|
||||||
|
<section data-testid="heute-coach-brief" className="rounded-2xl bg-muted/60 px-4 py-3.5">
|
||||||
|
<p className="whitespace-pre-line text-ui-body-lg text-foreground">{today.brief.text}</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{today.habits.length > 0 ? (
|
||||||
|
<section data-testid="heute-habits">
|
||||||
|
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||||
|
<Trans>Habits</Trans>
|
||||||
|
</h2>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{today.habits.map((habit) => (
|
||||||
|
<li key={habit.id}>
|
||||||
|
<HabitRow habit={habit} onDone={(done) => onHabitDone(habit, done)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{today.goals.length > 0 ? (
|
||||||
|
<section data-testid="heute-goals">
|
||||||
|
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||||
|
<Trans>Goals</Trans>
|
||||||
|
</h2>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{today.goals.map((goal) => (
|
||||||
|
<li
|
||||||
|
key={goal.id}
|
||||||
|
data-testid={`heute-goal-${goal.id}`}
|
||||||
|
className="flex items-baseline justify-between gap-3 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-ui-body text-foreground">{goal.title}</span>
|
||||||
|
{goal.targetDate ? (
|
||||||
|
<span className="shrink-0 text-ui-label text-muted-foreground tabular-nums">
|
||||||
|
{formatLocalDate(goal.targetDate, i18n.locale || "en", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HabitRow({ habit, onDone }: { habit: HabitToday; onDone: (done: boolean) => void }) {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
data-testid={`heute-habit-${habit.id}`}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-2xl px-3 py-2.5 transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Checkbox id={id} checked={habit.doneToday} onCheckedChange={(checked) => onDone(checked)} />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 truncate text-ui-body",
|
||||||
|
habit.doneToday ? "text-muted-foreground" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{habit.title}
|
||||||
|
</span>
|
||||||
|
{habit.streak >= STREAK_MIN ? (
|
||||||
|
<span
|
||||||
|
data-testid={`heute-habit-streak-${habit.id}`}
|
||||||
|
className="flex shrink-0 items-center gap-1 text-ui-label text-muted-foreground tabular-nums"
|
||||||
|
>
|
||||||
|
<Flame strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
|
||||||
|
{habit.cadence === "weekly" ? (
|
||||||
|
<Plural value={habit.streak} one="# week" other="# weeks" />
|
||||||
|
) : (
|
||||||
|
<Plural value={habit.streak} one="# day" other="# days" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 { 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");
|
const now = new Date("2026-09-22T12:00:00.000Z");
|
||||||
|
|
||||||
@ -142,3 +149,59 @@ describe("buildHeuteBrief", () => {
|
|||||||
expect(brief.actionCount).toBe(5);
|
expect(brief.actionCount).toBe(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function habit(overrides: Partial<HabitToday> = {}): 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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";
|
import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity";
|
||||||
|
|
||||||
export type JetztRunItem = {
|
export type JetztRunItem = {
|
||||||
@ -137,3 +137,38 @@ export function buildHeuteBrief(params: {
|
|||||||
weiter: [],
|
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" });
|
||||||
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import { i18n } from "@lingui/core";
|
import { i18n } from "@lingui/core";
|
||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
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 { botColors } from "@rakazo/ui-tokens";
|
||||||
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
|
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { CoachRhythmControl } from "../components/heute/CoachRhythmControl";
|
||||||
|
import { HeuteCoach } from "../components/heute/HeuteCoach";
|
||||||
import { runActivityLabel } from "../lib/agent-activity";
|
import { runActivityLabel } from "../lib/agent-activity";
|
||||||
|
import type { DoingItem, DoneItem, JetztItem } from "../lib/heute-brief";
|
||||||
import {
|
import {
|
||||||
buildHeuteBrief,
|
buildHeuteBrief,
|
||||||
cleanBriefPreview,
|
cleanBriefPreview,
|
||||||
type DoingItem,
|
coachRefreshSignal,
|
||||||
type DoneItem,
|
formatLocalDate,
|
||||||
type JetztItem,
|
withHabitDone,
|
||||||
} from "../lib/heute-brief";
|
} from "../lib/heute-brief";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
const MISSION_CONTROL_URL = "http://127.0.0.1:3088/";
|
|
||||||
|
|
||||||
type HeuteBriefProps = {
|
type HeuteBriefProps = {
|
||||||
bots: Bot[];
|
bots: Bot[];
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
@ -26,6 +26,8 @@ type HeuteBriefProps = {
|
|||||||
onStartChat: () => void;
|
onStartChat: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" };
|
||||||
|
|
||||||
export function HeuteBrief({
|
export function HeuteBrief({
|
||||||
bots,
|
bots,
|
||||||
groups,
|
groups,
|
||||||
@ -37,6 +39,9 @@ export function HeuteBrief({
|
|||||||
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
|
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
|
||||||
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
|
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [coach, setCoach] = useState<CoachToday | null>(null);
|
||||||
|
const [coachSettled, setCoachSettled] = useState(false);
|
||||||
|
const latestCoachRequest = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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(
|
const brief = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildHeuteBrief({
|
buildHeuteBrief({
|
||||||
@ -81,31 +130,45 @@ export function HeuteBrief({
|
|||||||
|
|
||||||
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
|
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
|
||||||
|
|
||||||
const dateLabel = formatHeuteDate(new Date());
|
const locale = i18n.locale || "de";
|
||||||
const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0;
|
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 (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
|
||||||
<div className="border-b border-border px-4 py-5 md:px-8 lg:px-10">
|
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-5 md:px-8 lg:px-10">
|
||||||
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">{dateLabel}</h1>
|
<div className="min-w-0">
|
||||||
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
|
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">
|
||||||
<StatusLine
|
{dateLabel}
|
||||||
actionCount={brief.actionCount}
|
</h1>
|
||||||
loading={loading}
|
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
|
||||||
doingCount={brief.doing.length}
|
<StatusLine
|
||||||
/>
|
actionCount={brief.actionCount}
|
||||||
</p>
|
loading={loading}
|
||||||
|
doingCount={brief.doing.length}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CoachRhythmControl />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
|
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
|
||||||
{loading && !hasContent ? (
|
{(loading || !coachSettled) && !hasContent ? (
|
||||||
<p className="text-ui-body text-muted-foreground">
|
<p className="text-ui-body text-muted-foreground">
|
||||||
<Trans>Loading…</Trans>
|
<Trans>Loading…</Trans>
|
||||||
</p>
|
</p>
|
||||||
) : !hasContent ? (
|
) : !hasContent ? (
|
||||||
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
|
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8 pb-4">
|
||||||
|
{coach ? <HeuteCoach today={coach} onHabitDone={setHabitDone} /> : null}
|
||||||
|
|
||||||
{brief.needsYou.length > 0 ? (
|
{brief.needsYou.length > 0 ? (
|
||||||
<section data-testid="heute-needs-you">
|
<section data-testid="heute-needs-you">
|
||||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||||
@ -172,19 +235,6 @@ export function HeuteBrief({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<footer className="mx-auto mt-10 flex w-full max-w-2xl justify-center pb-4">
|
|
||||||
<a
|
|
||||||
href={MISSION_CONTROL_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground/70 transition-colors hover:text-muted-foreground"
|
|
||||||
data-testid="heute-mission-control-link"
|
|
||||||
>
|
|
||||||
<Trans>Mission Control</Trans>
|
|
||||||
<ExternalLink strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -399,12 +449,3 @@ function jetztRowKey(item: JetztItem): string {
|
|||||||
if (item.kind === "run") return `run:${item.run.runId}`;
|
if (item.kind === "run") return `run:${item.run.runId}`;
|
||||||
return `unread:${item.chatKind}:${item.chat.id}`;
|
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
USER_FACT_CATEGORIES,
|
USER_FACT_CATEGORIES,
|
||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { coachTools } from "./coach-tools.js";
|
||||||
import { NOTIFY_USER_BODY_MAX_LENGTH, NOTIFY_USER_TITLE_MAX_LENGTH } from "./notify-user-tool.js";
|
import { NOTIFY_USER_BODY_MAX_LENGTH, NOTIFY_USER_TITLE_MAX_LENGTH } from "./notify-user-tool.js";
|
||||||
|
|
||||||
export const SUBAGENT_MODEL_TIER_DESCRIPTION =
|
export const SUBAGENT_MODEL_TIER_DESCRIPTION =
|
||||||
@ -429,6 +430,7 @@ export const builtinAgentTools: ConnectorTool[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...coachTools,
|
||||||
{
|
{
|
||||||
name: "notify_user",
|
name: "notify_user",
|
||||||
description:
|
description:
|
||||||
|
|||||||
182
packages/adapters/src/coach-rhythm.postgres.test.ts
Normal file
182
packages/adapters/src/coach-rhythm.postgres.test.ts
Normal file
@ -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<void>;
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||||
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
|
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
|
||||||
import type { CoachRhythm } from "@rakazo/contracts";
|
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 { nextCronDate } from "@rakazo/core";
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db";
|
import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db";
|
||||||
@ -34,26 +34,23 @@ export const COACH_ROUTINES: Record<CoachKey, { name: string; prompt: string }>
|
|||||||
|
|
||||||
export interface CoachRhythmDeps {
|
export interface CoachRhythmDeps {
|
||||||
prisma: PrismaClient;
|
prisma: PrismaClient;
|
||||||
jobs: JobPublisher;
|
jobs: Pick<JobPublisher, "enqueue" | "cancel">;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The cron for one check-in, or null when it is off. */
|
/** The cron for one check-in, or null when it is off. */
|
||||||
export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null {
|
export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null {
|
||||||
const slot = key === "weekly" ? rhythm.weekly : rhythm[key];
|
const slot = key === "weekly" ? rhythm.weekly : rhythm[key];
|
||||||
if (!slot) return null;
|
if (!slot) return null;
|
||||||
const time = typeof slot === "string" ? slot : slot.time;
|
const minutes = clockTimeToMinutes(typeof slot === "string" ? slot : slot.time);
|
||||||
const [hour, minute] = time.split(":").map(Number);
|
return `${minutes % 60} ${Math.floor(minutes / 60)} * * ${typeof slot === "string" ? "*" : slot.day}`;
|
||||||
return `${minute} ${hour} * * ${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. */
|
/** Reads a check-in back from its routine; a schedule edited into another shape reads as off. */
|
||||||
function rhythmSlot(key: CoachKey, crons: readonly string[]) {
|
function rhythmSlot(key: CoachKey, crons: readonly string[]) {
|
||||||
const match =
|
const match =
|
||||||
crons.length === 1 ? /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(crons[0]!) : null;
|
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;
|
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 };
|
if (key === "weekly") return match[3] === "*" ? null : { day: Number(match[3]), time };
|
||||||
return match[3] === "*" ? time : null;
|
return match[3] === "*" ? time : null;
|
||||||
}
|
}
|
||||||
|
|||||||
79
packages/adapters/src/coach-tools.test.ts
Normal file
79
packages/adapters/src/coach-tools.test.ts
Normal file
@ -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") });
|
||||||
|
});
|
||||||
|
});
|
||||||
354
packages/adapters/src/coach-tools.ts
Normal file
354
packages/adapters/src/coach-tools.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<Array<{ id: string }>>,
|
||||||
|
ref: string,
|
||||||
|
what: "goal" | "habit",
|
||||||
|
): Promise<string | ToolResult> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
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<string, unknown>,
|
||||||
|
) => Promise<ToolResult>
|
||||||
|
> = {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -111,6 +111,9 @@ async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record<stri
|
|||||||
user: { findUnique: vi.fn(async () => ({ name: "Ada" })) },
|
user: { findUnique: vi.fn(async () => ({ name: "Ada" })) },
|
||||||
userProfile: { findUnique: vi.fn(async () => null) },
|
userProfile: { findUnique: vi.fn(async () => null) },
|
||||||
userFact: { findMany: vi.fn(async () => []) },
|
userFact: { findMany: vi.fn(async () => []) },
|
||||||
|
// The main bot's goals and habits.
|
||||||
|
userGoal: { findMany: vi.fn(async () => []) },
|
||||||
|
habit: { findMany: vi.fn(async () => []) },
|
||||||
...models,
|
...models,
|
||||||
};
|
};
|
||||||
let request: AgentRunRequest | undefined;
|
let request: AgentRunRequest | undefined;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { AgentRunRequest } from "@rakazo/adapter-kit";
|
import type { AgentRunRequest } from "@rakazo/adapter-kit";
|
||||||
import type { MessageBlock } from "@rakazo/contracts";
|
import type { MessageBlock } from "@rakazo/contracts";
|
||||||
|
import { addDays, localDateIn } from "@rakazo/core";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
||||||
import { createRunExecutor } from "./executor.js";
|
import { createRunExecutor } from "./executor.js";
|
||||||
@ -20,6 +21,9 @@ interface FixtureOptions {
|
|||||||
sender?: { name: string } | null;
|
sender?: { name: string } | null;
|
||||||
profile?: Record<string, unknown> | null;
|
profile?: Record<string, unknown> | null;
|
||||||
facts?: Array<{ category: string; value: string; confidence: number }>;
|
facts?: Array<{ category: string; value: string; confidence: number }>;
|
||||||
|
goals?: Array<Record<string, unknown>>;
|
||||||
|
habits?: Array<Record<string, unknown>>;
|
||||||
|
doneCheckIns?: Array<{ habitId: string; date: Date }>;
|
||||||
peers?: Array<{
|
peers?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -131,6 +135,9 @@ async function runOnce(options: FixtureOptions = {}) {
|
|||||||
async () => options.facts ?? [{ category: "work", value: "Owns a bakery", confidence: 1 }],
|
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 () => []) },
|
actionApprovalRule: { findMany: vi.fn(async () => []) },
|
||||||
actionAutoReviewPreference: { findUnique: vi.fn(async () => ({ enabled: false })) },
|
actionAutoReviewPreference: { findUnique: vi.fn(async () => ({ enabled: false })) },
|
||||||
externalEffect: {
|
externalEffect: {
|
||||||
@ -297,6 +304,11 @@ describe("run instructions", () => {
|
|||||||
|
|
||||||
expect(request.tools.map((tool) => tool.name)).not.toContain("update_user_profile");
|
expect(request.tools.map((tool) => tool.name)).not.toContain("update_user_profile");
|
||||||
expect(request.instructions).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 () => {
|
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");
|
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</user>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it("shows the main bot every specialist it can delegate to, its own space first", async () => {
|
||||||
const { request, peersFindMany } = await runOnce({
|
const { request, peersFindMany } = await runOnce({
|
||||||
bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" },
|
bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" },
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import { escapePromptData, knownTimeZone } from "@rakazo/core";
|
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. */
|
/** Per-field caps so user-written profile text cannot crowd out the rest of the prompt. */
|
||||||
const USER_FIELD_MAX_CHARS = 500;
|
const USER_FIELD_MAX_CHARS = 500;
|
||||||
const USER_SUMMARY_MAX_CHARS = 2_000;
|
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 {
|
export interface BotIdentity {
|
||||||
name: string;
|
name: string;
|
||||||
@ -41,11 +44,30 @@ export function formatMainAgentRole(toolNames: ReadonlySet<string>): string {
|
|||||||
: toolNames.has("remember")
|
: 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: save lasting facts they share with remember."
|
||||||
: "Keep what you know about the user current.",
|
: "Keep what you know about the user current.",
|
||||||
|
...formatCoachGuidance(toolNames),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How the main bot coaches, naming only the coach tools this run has. */
|
||||||
|
function formatCoachGuidance(toolNames: ReadonlySet<string>): Array<string | undefined> {
|
||||||
|
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. */
|
/** One line of escaped user text, clipped to `max` characters. */
|
||||||
function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string {
|
function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string {
|
||||||
const text = (value ?? "").replace(/\s+/g, " ").trim();
|
const text = (value ?? "").replace(/\s+/g, " ").trim();
|
||||||
@ -57,12 +79,16 @@ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS):
|
|||||||
* by the user or inferred from conversations, so every value is escaped and the block says
|
* 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
|
* 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
|
* 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
|
* owner's agent over an agent connection) gets only the basics: the summary, facts, goals and
|
||||||
* with the user.
|
* habits stay with the user.
|
||||||
*/
|
*/
|
||||||
export function formatUserContext(
|
export function formatUserContext(
|
||||||
user: UserContext,
|
user: UserContext,
|
||||||
options: { basicsOnly?: boolean } = {},
|
options: {
|
||||||
|
basicsOnly?: boolean;
|
||||||
|
/** The main bot's own runs: the user's active goals and habits. */
|
||||||
|
coach?: Pick<CoachSummary, "goals" | "habits">;
|
||||||
|
} = {},
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const field = (label: string, value: string | null | undefined, max?: number) => {
|
const field = (label: string, value: string | null | undefined, max?: number) => {
|
||||||
const text = userText(value, max);
|
const text = userText(value, max);
|
||||||
@ -82,6 +108,7 @@ export function formatUserContext(
|
|||||||
field("Communication style", user.communicationStyle),
|
field("Communication style", user.communicationStyle),
|
||||||
options.basicsOnly ? undefined : field("Summary", user.summary, USER_SUMMARY_MAX_CHARS),
|
options.basicsOnly ? undefined : field("Summary", user.summary, USER_SUMMARY_MAX_CHARS),
|
||||||
...(facts.length > 0 ? ["Facts:", ...facts] : []),
|
...(facts.length > 0 ? ["Facts:", ...facts] : []),
|
||||||
|
...(options.coach && !options.basicsOnly ? coachLines(options.coach) : []),
|
||||||
].filter((line): line is string => Boolean(line));
|
].filter((line): line is string => Boolean(line));
|
||||||
if (lines.length === 0) return undefined;
|
if (lines.length === 0) return undefined;
|
||||||
return [
|
return [
|
||||||
@ -92,6 +119,30 @@ export function formatUserContext(
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listed<T>(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<CoachSummary, "goals" | "habits">): 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,
|
* Background a run_subagent helper shares with the bot that started it: who it works for,
|
||||||
* the present moment, and the user block.
|
* the present moment, and the user block.
|
||||||
|
|||||||
@ -66,8 +66,12 @@ describe("builtin tools per bot", () => {
|
|||||||
it("lists main-only tools for the main bot alone", () => {
|
it("lists main-only tools for the main bot alone", () => {
|
||||||
expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual([
|
expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual([
|
||||||
"delegate_task",
|
"delegate_task",
|
||||||
|
"log_check_in",
|
||||||
"notify_user",
|
"notify_user",
|
||||||
|
"update_goal",
|
||||||
|
"update_habit",
|
||||||
"update_user_profile",
|
"update_user_profile",
|
||||||
|
"write_daily_brief",
|
||||||
]);
|
]);
|
||||||
const specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false });
|
const specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false });
|
||||||
const main = builtinToolNamesForBot({ kind: "main", chatLinked: false });
|
const main = builtinToolNamesForBot({ kind: "main", chatLinked: false });
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { ConnectorTool } from "@rakazo/adapter-kit";
|
import type { ConnectorTool } from "@rakazo/adapter-kit";
|
||||||
import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js";
|
import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js";
|
||||||
import { selectCloudAgentTools } from "./cloud-agent-tools-select.js";
|
import { selectCloudAgentTools } from "./cloud-agent-tools-select.js";
|
||||||
|
import { COACH_TOOL_NAMES } from "./coach-tools.js";
|
||||||
import { selectMemoryTools } from "./memory-tools.js";
|
import { selectMemoryTools } from "./memory-tools.js";
|
||||||
import { filterImageReturningComputerTools } from "./model-vision.js";
|
import { filterImageReturningComputerTools } from "./model-vision.js";
|
||||||
import {
|
import {
|
||||||
@ -21,6 +22,14 @@ export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set<string>([
|
|||||||
"delegate_task",
|
"delegate_task",
|
||||||
"notify_user",
|
"notify_user",
|
||||||
"update_user_profile",
|
"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<string> = new Set<string>([
|
||||||
|
"update_user_profile",
|
||||||
|
"notify_user",
|
||||||
|
...COACH_TOOL_NAMES,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const BUILTIN_TOOL_NAMES = [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name);
|
const BUILTIN_TOOL_NAMES = [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name);
|
||||||
@ -106,19 +115,10 @@ export function selectBuiltinToolsForRun(options: {
|
|||||||
Boolean(options.cloudAgentEnabled),
|
Boolean(options.cloudAgentEnabled),
|
||||||
).filter(
|
).filter(
|
||||||
(tool) =>
|
(tool) =>
|
||||||
!(
|
!(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) &&
|
||||||
options.foreignAgentRun &&
|
|
||||||
(tool.name === "update_user_profile" || tool.name === "notify_user")
|
|
||||||
) &&
|
|
||||||
(!options.messagingChannelRun ||
|
(!options.messagingChannelRun ||
|
||||||
(![
|
(!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) &&
|
||||||
"remember",
|
!USER_MODEL_TOOLS.has(tool.name) &&
|
||||||
"save_memory",
|
|
||||||
"recall_memory",
|
|
||||||
"forget_memory",
|
|
||||||
"update_user_profile",
|
|
||||||
"notify_user",
|
|
||||||
].includes(tool.name) &&
|
|
||||||
!tool.name.startsWith("scratchpad_"))),
|
!tool.name.startsWith("scratchpad_"))),
|
||||||
);
|
);
|
||||||
return filterBuiltinToolsForBot(
|
return filterBuiltinToolsForBot(
|
||||||
|
|||||||
@ -96,6 +96,7 @@ import {
|
|||||||
InvalidSpaceNameError,
|
InvalidSpaceNameError,
|
||||||
isTooManyDatabaseConnections,
|
isTooManyDatabaseConnections,
|
||||||
listSpaceUserBotIds,
|
listSpaceUserBotIds,
|
||||||
|
loadCoachSummary,
|
||||||
loadRunHistoryMessages,
|
loadRunHistoryMessages,
|
||||||
loadUserContext,
|
loadUserContext,
|
||||||
type McpServer,
|
type McpServer,
|
||||||
@ -175,6 +176,7 @@ import { type CloudAgentConnection, cloudAgentsEnabled } from "./cloud-agent-fac
|
|||||||
import { executeCloudAgentTool } from "./cloud-agent-service.js";
|
import { executeCloudAgentTool } from "./cloud-agent-service.js";
|
||||||
import { validCloudAgentArgs } from "./cloud-agent-tools.js";
|
import { validCloudAgentArgs } from "./cloud-agent-tools.js";
|
||||||
import { selectCloudAgentTools } from "./cloud-agent-tools-select.js";
|
import { selectCloudAgentTools } from "./cloud-agent-tools-select.js";
|
||||||
|
import { executeCoachTool, isCoachToolName } from "./coach-tools.js";
|
||||||
import {
|
import {
|
||||||
collectLogIds,
|
collectLogIds,
|
||||||
mergeConnectedPlugins,
|
mergeConnectedPlugins,
|
||||||
@ -2581,6 +2583,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isCoachToolName(name)) {
|
||||||
|
return finish(
|
||||||
|
await executeCoachTool(deps.prisma, { userId: run.userId, bot }, name, args),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (name === "web_search") {
|
if (name === "web_search") {
|
||||||
return finish(await webSearchFromTool(web, context, args));
|
return finish(await webSearchFromTool(web, context, args));
|
||||||
}
|
}
|
||||||
@ -3697,8 +3704,13 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone);
|
const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone);
|
||||||
// Replies to another owner's agent, like a shared channel, only get the basics.
|
// Replies to another owner's agent, like a shared channel, only get the basics.
|
||||||
|
const basicsOnly = messagingChannelRun || foreignAgentRun;
|
||||||
const userInstruction = formatUserContext(userContext, {
|
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;
|
const userBlock = userInstruction ? redactSecrets(userInstruction, runSecrets) : undefined;
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export * from "./cloud-agent-service.js";
|
|||||||
export * from "./cloud-agent-tools.js";
|
export * from "./cloud-agent-tools.js";
|
||||||
export * from "./cloud-agent-tools-select.js";
|
export * from "./cloud-agent-tools-select.js";
|
||||||
export * from "./coach-rhythm.js";
|
export * from "./coach-rhythm.js";
|
||||||
|
export * from "./coach-tools.js";
|
||||||
export * from "./composio-catalog-cache.js";
|
export * from "./composio-catalog-cache.js";
|
||||||
export * from "./composio-connector.js";
|
export * from "./composio-connector.js";
|
||||||
export * from "./composio-emulator.js";
|
export * from "./composio-emulator.js";
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type {
|
|||||||
} from "@rakazo/adapter-kit";
|
} from "@rakazo/adapter-kit";
|
||||||
import { USER_FACT_CATEGORIES } from "@rakazo/contracts";
|
import { USER_FACT_CATEGORIES } from "@rakazo/contracts";
|
||||||
import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core";
|
import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core";
|
||||||
|
import { COACH_ROUTINES } from "./coach-rhythm.js";
|
||||||
|
|
||||||
const running = new Map<string, AbortController>();
|
const running = new Map<string, AbortController>();
|
||||||
|
|
||||||
@ -15,6 +16,56 @@ const LEARN_ABOUT_ME = new RegExp(
|
|||||||
"i",
|
"i",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** "set a goal: …", "track a habit: …", "done today: <habit>" and "write today's brief: …". */
|
||||||
|
const COACH_COMMANDS: Array<{
|
||||||
|
pattern: RegExp;
|
||||||
|
reply: string;
|
||||||
|
call: (value: string) => { name: string; args: Record<string, unknown> };
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
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<AgentRunRequest["script"]> | 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 {
|
export class ScriptedAgentRuntime implements AgentRuntime {
|
||||||
describe() {
|
describe() {
|
||||||
return {
|
return {
|
||||||
@ -207,6 +258,8 @@ export function inferScript(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
const coach = inferCoachScript(prompt);
|
||||||
|
if (coach) return coach;
|
||||||
if (lower.includes("delegate to the bot named")) {
|
if (lower.includes("delegate to the bot named")) {
|
||||||
const name = namedBot(prompt) ?? "Researcher";
|
const name = namedBot(prompt) ?? "Researcher";
|
||||||
const brief =
|
const brief =
|
||||||
|
|||||||
@ -2,6 +2,21 @@ import { eventIterator, oc } from "@orpc/contract";
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
|
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
|
||||||
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.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 {
|
import {
|
||||||
ActionApprovalRuleSchema,
|
ActionApprovalRuleSchema,
|
||||||
ActionAutoReviewSettingsSchema,
|
ActionAutoReviewSettingsSchema,
|
||||||
@ -196,6 +211,30 @@ export const appContract = {
|
|||||||
update: oc.input(UpdateUserProfileInput).output(UserProfileStateSchema),
|
update: oc.input(UpdateUserProfileInput).output(UserProfileStateSchema),
|
||||||
forget: oc.input(ForgetUserFactsInput).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: {
|
spaces: {
|
||||||
list: oc.output(SpaceNavigationSchema),
|
list: oc.output(SpaceNavigationSchema),
|
||||||
create: oc
|
create: oc
|
||||||
|
|||||||
66
packages/core/src/coach.test.ts
Normal file
66
packages/core/src/coach.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
327
packages/db/src/coach.postgres.test.ts
Normal file
327
packages/db/src/coach.postgres.test.ts
Normal file
@ -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<void>;
|
||||||
|
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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -64,6 +64,19 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
["profile/get"],
|
["profile/get"],
|
||||||
["profile/update", { timeZone: "UTC" }],
|
["profile/update", { timeZone: "UTC" }],
|
||||||
["profile/forget", { all: true }],
|
["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/get"],
|
||||||
["deployment/update", { signupsEnabled: true }],
|
["deployment/update", { signupsEnabled: true }],
|
||||||
["updater/status"],
|
["updater/status"],
|
||||||
|
|||||||
@ -114,6 +114,8 @@ async function main() {
|
|||||||
"packages/db/src/main-agent.postgres.test.ts",
|
"packages/db/src/main-agent.postgres.test.ts",
|
||||||
"packages/db/src/user-profile.postgres.test.ts",
|
"packages/db/src/user-profile.postgres.test.ts",
|
||||||
"packages/db/src/notification-settings.postgres.test.ts",
|
"packages/db/src/notification-settings.postgres.test.ts",
|
||||||
|
"packages/testkit/src/coach.postgres.test.ts",
|
||||||
|
"packages/db/src/coach.postgres.test.ts",
|
||||||
"packages/db/src/messaging.postgres.test.ts",
|
"packages/db/src/messaging.postgres.test.ts",
|
||||||
"packages/memory/src/commit.postgres.test.ts",
|
"packages/memory/src/commit.postgres.test.ts",
|
||||||
"packages/adapters/src/wakeup.postgres.test.ts",
|
"packages/adapters/src/wakeup.postgres.test.ts",
|
||||||
@ -123,6 +125,7 @@ async function main() {
|
|||||||
"packages/adapters/src/builtin-memory-provider.postgres.test.ts",
|
"packages/adapters/src/builtin-memory-provider.postgres.test.ts",
|
||||||
"packages/adapters/src/profile-consolidation.postgres.test.ts",
|
"packages/adapters/src/profile-consolidation.postgres.test.ts",
|
||||||
"packages/adapters/src/notify-user.postgres.test.ts",
|
"packages/adapters/src/notify-user.postgres.test.ts",
|
||||||
|
"packages/adapters/src/coach-rhythm.postgres.test.ts",
|
||||||
];
|
];
|
||||||
// Each app reconciles all durable work in its database, including intentionally
|
// Each app reconciles all durable work in its database, including intentionally
|
||||||
// unfinished fixture runs. Clone the pristine migrated schema so one suite
|
// unfinished fixture runs. Clone the pristine migrated schema so one suite
|
||||||
|
|||||||
284
packages/testkit/src/coach.postgres.test.ts
Normal file
284
packages/testkit/src/coach.postgres.test.ts
Normal file
@ -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<Response> };
|
||||||
|
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
||||||
|
|
||||||
|
process.env.WAKEUP_DRIVER = "memory";
|
||||||
|
process.env.SANDBOX_PROVIDER = "fake";
|
||||||
|
process.env.AGENT_RUNTIME = "scripted";
|
||||||
|
|
||||||
|
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
|
||||||
|
const describeWithDatabase = hasDb ? describe : describe.skip;
|
||||||
|
|
||||||
|
describeWithDatabase("coach through the API and the main bot", () => {
|
||||||
|
let handles: AppHandles;
|
||||||
|
let app: App;
|
||||||
|
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-coach-"));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { createApp } = await import("../../../apps/api/src/app.ts");
|
||||||
|
handles = await createApp({
|
||||||
|
databaseUrl: process.env.DATABASE_URL!,
|
||||||
|
dataDir,
|
||||||
|
sandboxProvider: "fake",
|
||||||
|
agentRuntime: "scripted",
|
||||||
|
wakeupDriver: "memory",
|
||||||
|
signupsEnabled: "true",
|
||||||
|
composio: new ComposioEmulator(),
|
||||||
|
});
|
||||||
|
app = handles.app;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await handles?.stop();
|
||||||
|
rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitForRun(runId: string) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < 20_000) {
|
||||||
|
const run = await handles.prisma.run.findUnique({
|
||||||
|
where: { id: runId },
|
||||||
|
select: { id: true, status: true, error: true, threadId: true },
|
||||||
|
});
|
||||||
|
if (run && ["completed", "failed", "cancelled"].includes(run.status)) {
|
||||||
|
expect(run, run.error ?? "").toMatchObject({ status: "completed" });
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
throw new Error(`run ${runId} did not finish`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAndWait(cookie: string, botId: string, text: string) {
|
||||||
|
const { runId } = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId, text });
|
||||||
|
return waitForRun(runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toolOutcome(runId: string) {
|
||||||
|
const event = await handles.prisma.event.findFirstOrThrow({
|
||||||
|
where: { runId, type: "agent.tool.completed" },
|
||||||
|
select: { payload: true },
|
||||||
|
});
|
||||||
|
return event.payload as { name: string; outcome: string; error?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps each user's goals, habits and check-ins to that user", async () => {
|
||||||
|
const ada = await signup(app, `coach-ada-${stamp}@rakazo.test`, "Ada");
|
||||||
|
const bob = await signup(app, `coach-bob-${stamp}@rakazo.test`, "Bob");
|
||||||
|
const bobSpaces = await rpc<SpaceNavigation>(app, bob, "spaces/list");
|
||||||
|
|
||||||
|
const goal = await rpc<Goal>(app, ada, "coach/goals/create", {
|
||||||
|
title: "Ship the beta",
|
||||||
|
targetDate: "2026-12-01",
|
||||||
|
});
|
||||||
|
expect(goal).toMatchObject({ title: "Ship the beta", status: "active", source: "user" });
|
||||||
|
const habit = await rpc<Habit>(app, ada, "coach/habits/create", {
|
||||||
|
title: "Write 300 words",
|
||||||
|
goalId: goal.id,
|
||||||
|
});
|
||||||
|
expect(habit).toMatchObject({ cadence: "daily", goalId: goal.id, source: "user" });
|
||||||
|
const checkIn = await rpc<CheckIn>(app, ada, "coach/checkIns/log", {
|
||||||
|
kind: "habit",
|
||||||
|
habitId: habit.id,
|
||||||
|
done: true,
|
||||||
|
});
|
||||||
|
await rpc<CheckIn>(app, ada, "coach/checkIns/log", {
|
||||||
|
kind: "goal",
|
||||||
|
goalId: goal.id,
|
||||||
|
note: "Draft done",
|
||||||
|
});
|
||||||
|
await rpc<CheckIn>(app, ada, "coach/checkIns/log", { kind: "daily", mood: 4, energy: 3 });
|
||||||
|
|
||||||
|
const today = await rpc<CoachToday>(app, ada, "coach/today");
|
||||||
|
expect(today).toMatchObject({ date: checkIn.date, timeZone: "UTC", brief: null });
|
||||||
|
expect(today.goals.map((g) => g.id)).toEqual([goal.id]);
|
||||||
|
expect(today.habits).toMatchObject([{ id: habit.id, doneToday: true, streak: 1 }]);
|
||||||
|
expect(today.checkIn).toMatchObject({ mood: 4, energy: 3 });
|
||||||
|
expect(await rpc<CheckIn[]>(app, ada, "coach/checkIns/list", { goalId: goal.id })).toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bob sees none of it and cannot touch it; Ada's ids read as missing to him.
|
||||||
|
expect(await rpc<Goal[]>(app, bob, "coach/goals/list", {})).toEqual([]);
|
||||||
|
expect(await rpc<Habit[]>(app, bob, "coach/habits/list", {})).toEqual([]);
|
||||||
|
expect(await rpc<CheckIn[]>(app, bob, "coach/checkIns/list", { habitId: habit.id })).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect((await rpc<CoachToday>(app, bob, "coach/today")).habits).toEqual([]);
|
||||||
|
for (const [procedure, input] of [
|
||||||
|
["coach/goals/update", { goalId: goal.id, status: "dropped" }],
|
||||||
|
["coach/goals/remove", { goalId: goal.id }],
|
||||||
|
["coach/habits/update", { habitId: habit.id, active: false }],
|
||||||
|
["coach/habits/remove", { habitId: habit.id }],
|
||||||
|
["coach/checkIns/log", { kind: "habit", habitId: habit.id, done: false }],
|
||||||
|
["coach/checkIns/log", { kind: "goal", goalId: goal.id, note: "Mine now" }],
|
||||||
|
["coach/habits/create", { title: "Borrowed", goalId: goal.id }],
|
||||||
|
] as const) {
|
||||||
|
expect((await raw(app, bob, procedure, input)).status, procedure).toBe(404);
|
||||||
|
}
|
||||||
|
// Ada cannot file a goal under Bob's space.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await raw(app, ada, "coach/goals/create", {
|
||||||
|
title: "Elsewhere",
|
||||||
|
spaceId: bobSpaces.spaces[0]!.id,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(400);
|
||||||
|
expect((await raw(app, ada, "coach/goals/create", { title: " " })).status).toBe(400);
|
||||||
|
expect((await raw(app, ada, "coach/checkIns/log", { kind: "habit" })).status).toBe(400);
|
||||||
|
expect(
|
||||||
|
(await raw(app, ada, "coach/checkIns/log", { kind: "daily", date: "2999-01-01" })).status,
|
||||||
|
).toBe(400);
|
||||||
|
expect((await rpc<Habit[]>(app, ada, "coach/habits/list", {})).map((h) => h.active)).toEqual([
|
||||||
|
true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const closed = await rpc<Goal>(app, ada, "coach/goals/update", {
|
||||||
|
goalId: goal.id,
|
||||||
|
status: "done",
|
||||||
|
});
|
||||||
|
expect(closed.completedAt).not.toBeNull();
|
||||||
|
expect((await rpc<CoachToday>(app, ada, "coach/today")).goals).toEqual([]);
|
||||||
|
await rpc(app, ada, "coach/habits/remove", { habitId: habit.id });
|
||||||
|
await rpc(app, ada, "coach/goals/remove", { goalId: goal.id });
|
||||||
|
expect(await rpc<Goal[]>(app, ada, "coach/goals/list", {})).toEqual([]);
|
||||||
|
expect(await rpc<CheckIn[]>(app, ada, "coach/checkIns/list", { kind: "habit" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the main bot coach through its tools, and only the main bot", async () => {
|
||||||
|
const cookie = await signup(app, `coach-tools-${stamp}@rakazo.test`, "Coach Tools");
|
||||||
|
const me = await rpc<{ userId: string }>(app, cookie, "me");
|
||||||
|
const main = await ensureMainBot(handles.prisma, me.userId);
|
||||||
|
const specialist = await rpc<{ id: string }>(app, cookie, "bots/create", {
|
||||||
|
name: "Researcher",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
instructions: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendAndWait(cookie, main.id, "set a goal: Run a 10k");
|
||||||
|
await sendAndWait(cookie, main.id, "track a habit: Walk");
|
||||||
|
await sendAndWait(cookie, main.id, "done today: walk");
|
||||||
|
const briefRun = await sendAndWait(cookie, main.id, "write today's brief: Rest, then plan.");
|
||||||
|
expect(await toolOutcome(briefRun.id)).toMatchObject({
|
||||||
|
name: "write_daily_brief",
|
||||||
|
outcome: "succeeded",
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = await rpc<CoachToday>(app, cookie, "coach/today");
|
||||||
|
expect(today.goals).toMatchObject([
|
||||||
|
{ title: "Run a 10k", source: "chat", createdByBotId: main.id },
|
||||||
|
]);
|
||||||
|
expect(today.habits).toMatchObject([
|
||||||
|
{ title: "Walk", source: "chat", doneToday: true, streak: 1 },
|
||||||
|
]);
|
||||||
|
expect(today.brief).toMatchObject({ text: "Rest, then plan.", botId: main.id });
|
||||||
|
const [walked] = await rpc<CheckIn[]>(app, cookie, "coach/checkIns/list", {});
|
||||||
|
expect(walked).toMatchObject({ kind: "habit", done: true, createdByBotId: main.id });
|
||||||
|
|
||||||
|
const refused = await sendAndWait(cookie, specialist.id, "set a goal: Sneak in");
|
||||||
|
expect(await toolOutcome(refused.id)).toMatchObject({
|
||||||
|
name: "update_goal",
|
||||||
|
outcome: "error",
|
||||||
|
error: expect.stringContaining("not available in this run"),
|
||||||
|
});
|
||||||
|
expect(await rpc<Goal[]>(app, cookie, "coach/goals/list", {})).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules check-ins as main-bot routines, and the morning one writes today's brief", async () => {
|
||||||
|
const cookie = await signup(app, `coach-rhythm-${stamp}@rakazo.test`, "Coach Rhythm");
|
||||||
|
const me = await rpc<{ userId: string }>(app, cookie, "me");
|
||||||
|
expect(await rpc<CoachRhythm>(app, cookie, "coach/rhythm/get")).toEqual({
|
||||||
|
morning: null,
|
||||||
|
evening: null,
|
||||||
|
weekly: null,
|
||||||
|
});
|
||||||
|
const rhythm = { morning: "07:00", evening: null, weekly: { day: 0, time: "18:00" } };
|
||||||
|
expect(await rpc<CoachRhythm>(app, cookie, "coach/rhythm/set", rhythm)).toEqual(rhythm);
|
||||||
|
expect(await rpc<CoachRhythm>(app, cookie, "coach/rhythm/get")).toEqual(rhythm);
|
||||||
|
expect((await raw(app, cookie, "coach/rhythm/set", { ...rhythm, morning: "7am" })).status).toBe(
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
|
||||||
|
const conversation = await ensureMainConversation(handles.prisma, me.userId);
|
||||||
|
const routines = await rpc<Routine[]>(app, cookie, "routines/list", {
|
||||||
|
botId: conversation.leadBotId,
|
||||||
|
});
|
||||||
|
expect(routines.map((routine) => routine.name).sort()).toEqual(
|
||||||
|
[COACH_ROUTINES.morning.name, COACH_ROUTINES.weekly.name].sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const morning = await handles.prisma.routine.findFirstOrThrow({
|
||||||
|
where: { botId: conversation.leadBotId, coachKey: "morning" },
|
||||||
|
});
|
||||||
|
const dueAt = new Date(Date.now() - 1_000);
|
||||||
|
await handles.prisma.routine.update({ where: { id: morning.id }, data: { nextRunAt: dueAt } });
|
||||||
|
await handles.executor.wakeRoutine(morning.id, dueAt.toISOString());
|
||||||
|
const run = await handles.prisma.run.findFirstOrThrow({
|
||||||
|
where: { routineId: morning.id, trigger: "routine" },
|
||||||
|
});
|
||||||
|
expect((await waitForRun(run.id)).threadId).toBe(conversation.threadId);
|
||||||
|
expect((await rpc<CoachToday>(app, cookie, "coach/today")).brief).toMatchObject({
|
||||||
|
text: SCRIPTED_MORNING_BRIEF,
|
||||||
|
botId: conversation.leadBotId,
|
||||||
|
});
|
||||||
|
// The routine re-armed for the next morning.
|
||||||
|
const rearmed = await handles.prisma.routine.findUniqueOrThrow({ where: { id: morning.id } });
|
||||||
|
expect(rearmed.active).toBe(true);
|
||||||
|
expect(rearmed.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function signup(app: App, email: string, name: string) {
|
||||||
|
const response = await app.request("/api/auth/sign-up/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
|
||||||
|
body: JSON.stringify({ email, password: "password12", name }),
|
||||||
|
});
|
||||||
|
if (response.status >= 400) {
|
||||||
|
throw new Error(`signup failed ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
return sessionCookieHeader(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) {
|
||||||
|
return app.request(`/rpc/${procedure}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie,
|
||||||
|
origin: "http://127.0.0.1:5173",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ json: body }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpc<T>(app: App, cookie: string, procedure: string, body: unknown = {}) {
|
||||||
|
const response = await raw(app, cookie, procedure, body);
|
||||||
|
const text = await response.text();
|
||||||
|
const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
||||||
|
if (response.status >= 400 || payload.error) {
|
||||||
|
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
|
||||||
|
}
|
||||||
|
return payload.json as T;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user