feat(coach): coach RPCs for goals, habits, check-ins, today and rhythm
The web app needs to show and change the user's coach data, and the check-in times need a way to change after onboarding. - coach.today, coach.goals.*, coach.habits.*, coach.checkIns.* and coach.rhythm.get/set, owner-only and not space-scoped; another user's ids read as not found, invalid input as a bad request. - coach-rhythm reuses the shared clock-time helpers. - Tests: an API suite covering ownership and validation for every procedure, the coach tools driven through the main bot (and refused for a specialist), and a morning check-in routine that fires in the main conversation and writes today's brief; the unauthenticated authorization sweep lists the new procedures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
a99e58be00
commit
1a3829031f
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 { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
||||
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
||||
import { createCoachService } from "./coach.js";
|
||||
import {
|
||||
executionBlocksUserTakeover,
|
||||
resolveBusyBotName,
|
||||
@ -531,6 +532,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
dataDir: deps.dataDir,
|
||||
});
|
||||
const agentSkills = createAgentSkillsService(deps.prisma);
|
||||
const coach = createCoachService(deps);
|
||||
|
||||
const authed = os.use(async ({ context, next }) => {
|
||||
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
|
||||
@ -576,6 +578,51 @@ export function createRouter(deps: RouterDeps) {
|
||||
forgetProfile(deps.prisma, context.actor, input),
|
||||
),
|
||||
},
|
||||
coach: {
|
||||
today: authed.coach.today.handler(({ context }) => coach.today(context.actor)),
|
||||
goals: {
|
||||
list: authed.coach.goals.list.handler(({ context, input }) =>
|
||||
coach.listGoals(context.actor, input),
|
||||
),
|
||||
create: authed.coach.goals.create.handler(({ context, input }) =>
|
||||
coach.createGoal(context.actor, input),
|
||||
),
|
||||
update: authed.coach.goals.update.handler(({ context, input }) =>
|
||||
coach.updateGoal(context.actor, input),
|
||||
),
|
||||
remove: authed.coach.goals.remove.handler(({ context, input }) =>
|
||||
coach.removeGoal(context.actor, input),
|
||||
),
|
||||
},
|
||||
habits: {
|
||||
list: authed.coach.habits.list.handler(({ context, input }) =>
|
||||
coach.listHabits(context.actor, input),
|
||||
),
|
||||
create: authed.coach.habits.create.handler(({ context, input }) =>
|
||||
coach.createHabit(context.actor, input),
|
||||
),
|
||||
update: authed.coach.habits.update.handler(({ context, input }) =>
|
||||
coach.updateHabit(context.actor, input),
|
||||
),
|
||||
remove: authed.coach.habits.remove.handler(({ context, input }) =>
|
||||
coach.removeHabit(context.actor, input),
|
||||
),
|
||||
},
|
||||
checkIns: {
|
||||
log: authed.coach.checkIns.log.handler(({ context, input }) =>
|
||||
coach.logCheckIn(context.actor, input),
|
||||
),
|
||||
list: authed.coach.checkIns.list.handler(({ context, input }) =>
|
||||
coach.listCheckIns(context.actor, input),
|
||||
),
|
||||
},
|
||||
rhythm: {
|
||||
get: authed.coach.rhythm.get.handler(({ context }) => coach.getRhythm(context.actor)),
|
||||
set: authed.coach.rhythm.set.handler(({ context, input }) =>
|
||||
coach.setRhythm(context.actor, input),
|
||||
),
|
||||
},
|
||||
},
|
||||
spaces: {
|
||||
list: authed.spaces.list.handler(async ({ context }) =>
|
||||
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
|
||||
import type { CoachRhythm } from "@rakazo/contracts";
|
||||
import { COACH_RHYTHM_OFF } from "@rakazo/contracts";
|
||||
import { COACH_RHYTHM_OFF, clockTimeToMinutes, minutesToClockTime } from "@rakazo/contracts";
|
||||
import { nextCronDate } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db";
|
||||
@ -41,19 +41,16 @@ export interface CoachRhythmDeps {
|
||||
export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null {
|
||||
const slot = key === "weekly" ? rhythm.weekly : rhythm[key];
|
||||
if (!slot) return null;
|
||||
const time = typeof slot === "string" ? slot : slot.time;
|
||||
const [hour, minute] = time.split(":").map(Number);
|
||||
return `${minute} ${hour} * * ${typeof slot === "string" ? "*" : slot.day}`;
|
||||
const minutes = clockTimeToMinutes(typeof slot === "string" ? slot : slot.time);
|
||||
return `${minutes % 60} ${Math.floor(minutes / 60)} * * ${typeof slot === "string" ? "*" : slot.day}`;
|
||||
}
|
||||
|
||||
const pad = (value: string) => value.padStart(2, "0");
|
||||
|
||||
/** Reads a check-in back from its routine; a schedule edited into another shape reads as off. */
|
||||
function rhythmSlot(key: CoachKey, crons: readonly string[]) {
|
||||
const match =
|
||||
crons.length === 1 ? /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(crons[0]!) : null;
|
||||
if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return null;
|
||||
const time = `${pad(match[2]!)}:${pad(match[1]!)}`;
|
||||
const time = minutesToClockTime(Number(match[2]) * 60 + Number(match[1]));
|
||||
if (key === "weekly") return match[3] === "*" ? null : { day: Number(match[3]), time };
|
||||
return match[3] === "*" ? time : null;
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ export * from "./cloud-agent-service.js";
|
||||
export * from "./cloud-agent-tools.js";
|
||||
export * from "./cloud-agent-tools-select.js";
|
||||
export * from "./coach-rhythm.js";
|
||||
export * from "./coach-tools.js";
|
||||
export * from "./composio-catalog-cache.js";
|
||||
export * from "./composio-connector.js";
|
||||
export * from "./composio-emulator.js";
|
||||
|
||||
@ -2,6 +2,21 @@ import { eventIterator, oc } from "@orpc/contract";
|
||||
import * as z from "zod";
|
||||
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
|
||||
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js";
|
||||
import {
|
||||
CheckInSchema,
|
||||
CoachRhythmSchema,
|
||||
CoachTodaySchema,
|
||||
CreateGoalInput,
|
||||
CreateHabitInput,
|
||||
GoalSchema,
|
||||
HabitSchema,
|
||||
ListCheckInsInput,
|
||||
ListGoalsInput,
|
||||
ListHabitsInput,
|
||||
LogCheckInInput,
|
||||
UpdateGoalInput,
|
||||
UpdateHabitInput,
|
||||
} from "./coach.js";
|
||||
import {
|
||||
ActionApprovalRuleSchema,
|
||||
ActionAutoReviewSettingsSchema,
|
||||
@ -189,6 +204,30 @@ export const appContract = {
|
||||
update: oc.input(UpdateUserProfileInput).output(UserProfileStateSchema),
|
||||
forget: oc.input(ForgetUserFactsInput).output(UserProfileStateSchema),
|
||||
},
|
||||
/** The user's goals, habits, check-ins and check-in times; owner-only, across spaces. */
|
||||
coach: {
|
||||
today: oc.output(CoachTodaySchema),
|
||||
goals: {
|
||||
list: oc.input(ListGoalsInput).output(z.array(GoalSchema)),
|
||||
create: oc.input(CreateGoalInput).output(GoalSchema),
|
||||
update: oc.input(UpdateGoalInput).output(GoalSchema),
|
||||
remove: oc.input(z.object({ goalId: Id })).output(z.object({ ok: z.literal(true) })),
|
||||
},
|
||||
habits: {
|
||||
list: oc.input(ListHabitsInput).output(z.array(HabitSchema)),
|
||||
create: oc.input(CreateHabitInput).output(HabitSchema),
|
||||
update: oc.input(UpdateHabitInput).output(HabitSchema),
|
||||
remove: oc.input(z.object({ habitId: Id })).output(z.object({ ok: z.literal(true) })),
|
||||
},
|
||||
checkIns: {
|
||||
log: oc.input(LogCheckInInput).output(CheckInSchema),
|
||||
list: oc.input(ListCheckInsInput).output(z.array(CheckInSchema)),
|
||||
},
|
||||
rhythm: {
|
||||
get: oc.output(CoachRhythmSchema),
|
||||
set: oc.input(CoachRhythmSchema).output(CoachRhythmSchema),
|
||||
},
|
||||
},
|
||||
spaces: {
|
||||
list: oc.output(SpaceNavigationSchema),
|
||||
create: oc
|
||||
|
||||
@ -64,6 +64,19 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
||||
["profile/get"],
|
||||
["profile/update", { timeZone: "UTC" }],
|
||||
["profile/forget", { all: true }],
|
||||
["coach/today"],
|
||||
["coach/goals/list", {}],
|
||||
["coach/goals/create", { title: "Nope" }],
|
||||
["coach/goals/update", { goalId: "missing-goal", title: "Nope" }],
|
||||
["coach/goals/remove", { goalId: "missing-goal" }],
|
||||
["coach/habits/list", {}],
|
||||
["coach/habits/create", { title: "Nope" }],
|
||||
["coach/habits/update", { habitId: "missing-habit", title: "Nope" }],
|
||||
["coach/habits/remove", { habitId: "missing-habit" }],
|
||||
["coach/checkIns/log", { kind: "daily", mood: 3 }],
|
||||
["coach/checkIns/list", {}],
|
||||
["coach/rhythm/get"],
|
||||
["coach/rhythm/set", { morning: null, evening: null, weekly: null }],
|
||||
["deployment/get"],
|
||||
["deployment/update", { signupsEnabled: true }],
|
||||
["updater/status"],
|
||||
|
||||
@ -106,6 +106,7 @@ async function main() {
|
||||
"packages/testkit/src/bot-secrets.test.ts",
|
||||
"packages/testkit/src/delegation.postgres.test.ts",
|
||||
"packages/testkit/src/user-profile.postgres.test.ts",
|
||||
"packages/testkit/src/coach.postgres.test.ts",
|
||||
"packages/db/src/space-membership.postgres.test.ts",
|
||||
"packages/db/src/main-agent.postgres.test.ts",
|
||||
"packages/db/src/user-profile.postgres.test.ts",
|
||||
|
||||
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