From 3db0f0cfbd4d720553ce0bfa3478327b57c1a0e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:28:59 +0000 Subject: [PATCH] feat(onboarding): save goal, check-in rhythm, quiet hours and push from the cards With the coach and notification modules merged, the onboarding answers now take effect: - the goal card creates a goal (source onboarding) through createGoal; - the rhythm card sets the main bot's check-in routines through setCoachRhythm, and the closing line names the first check-in from the routines that were saved; - the quiet-hours card updates the notification settings; - the choice card tells the server whether this browser can still turn push on, so the notifications card is offered only there, and "Turn on" subscribes before answering. The thread lock becomes NO KEY UPDATE so a rhythm routine, which references the thread, can be written while the answer holds the lock. The goal answer is capped at the coach title length. A Postgres suite walks the questions end to end: answers, skips, a dismissed card, a stale and a doubled answer, another bot's focus card, and another user's access. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/onboarding-intake.test.ts | 98 +++++++ apps/api/src/onboarding-intake.ts | 69 ++--- apps/api/src/onboarding.test.ts | 20 +- apps/api/src/onboarding.ts | 39 +-- apps/api/src/router.ts | 7 +- apps/web/src/lib/onboarding-push.ts | 14 - apps/web/src/pages/shell/message-cards.tsx | 7 +- .../src/executor-instructions.test.ts | 21 +- packages/adapters/src/executor-prompt.test.ts | 2 + packages/contracts/src/rpc.ts | 5 +- packages/testkit/src/cli/harness.ts | 1 + .../src/onboarding-intake.postgres.test.ts | 276 ++++++++++++++++++ 12 files changed, 461 insertions(+), 98 deletions(-) create mode 100644 apps/api/src/onboarding-intake.test.ts delete mode 100644 apps/web/src/lib/onboarding-push.ts create mode 100644 packages/testkit/src/onboarding-intake.postgres.test.ts diff --git a/apps/api/src/onboarding-intake.test.ts b/apps/api/src/onboarding-intake.test.ts new file mode 100644 index 0000000..0c8ccbd --- /dev/null +++ b/apps/api/src/onboarding-intake.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + closingLine, + focusCard, + intakeContext, + intakeStep, + nextIntakeStep, + readIntakeAnswer, + secondaryOption, +} from "./onboarding-intake.js"; + +// Intl may separate the time from AM/PM with a narrow no-break space. +const plain = (text: string) => text.replace(/\s/g, " "); +const off = { morning: null, evening: null, weekly: null }; + +describe("onboarding steps", () => { + it("asks in order and offers notifications only to a device that can still turn push on", () => { + expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal"); + expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm"); + expect(nextIntakeStep("rhythm", { canEnablePush: false })?.id).toBe("quiet_hours"); + expect(nextIntakeStep("quiet_hours", { canEnablePush: true })?.id).toBe("notifications"); + expect(nextIntakeStep("quiet_hours", { canEnablePush: false })).toBeNull(); + expect(nextIntakeStep("notifications", { canEnablePush: true })).toBeNull(); + }); + + it("gives every onboarding card a quiet way out, and another bot's focus card none", () => { + const context = intakeContext(null, new Date()); + for (const id of ["focus", "goal", "rhythm", "quiet_hours", "notifications"] as const) { + const card = intakeStep(id).card(context); + expect(card.step).toBe(id); + expect(secondaryOption(card)).toBeDefined(); + } + expect(focusCard().step).toBeUndefined(); + expect(secondaryOption(focusCard())).toBeUndefined(); + }); + + it("writes quiet hours in the user's locale", () => { + const labels = (locale: string | null) => + intakeStep("quiet_hours") + .card(intakeContext({ locale, timeZone: null }, new Date())) + .options.map((option) => plain(option.label)); + expect(labels("de-AT")).toEqual(["22:00 – 7:00", "23:00 – 8:00", "Skip"]); + expect(labels("en-US")).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]); + expect(labels("not a tag")).toEqual(labels("en")); + }); +}); + +describe("readIntakeAnswer", () => { + const goal = intakeStep("goal").card(intakeContext(null, new Date())); + const rhythm = intakeStep("rhythm").card(intakeContext(null, new Date())); + + it("accepts the card's own options and words only where the card takes them", () => { + expect(readIntakeAnswer(rhythm, "weekly", undefined)).toEqual({ + option: { id: "weekly", letter: "C", label: "Once a week" }, + }); + expect(readIntakeAnswer(rhythm, "skip", undefined)).toBeNull(); + expect(readIntakeAnswer(rhythm, "_text", "Mornings")).toBeNull(); + expect(readIntakeAnswer(goal, "_text", " Run a 10K ")).toEqual({ text: "Run a 10K" }); + expect(readIntakeAnswer(goal, "_text", " ")).toBeNull(); + expect(readIntakeAnswer(goal, "skip", undefined)?.option?.label).toBe("Skip"); + }); +}); + +describe("closingLine", () => { + const vienna = (iso: string, locale = "de-AT") => + intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso)); + + it("names the first check-in in the user's time zone", () => { + const morning = { ...off, morning: "08:00" }; + expect(closingLine(morning, vienna("2026-10-07T04:00:00Z"))).toBe( + "All set. I’ll check in today at 8:00.", + ); + expect(closingLine(morning, vienna("2026-10-07T09:00:00Z"))).toBe( + "All set. I’ll check in tomorrow at 8:00.", + ); + expect(closingLine({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z"))).toBe( + "All set. I’ll check in today at 18:00.", + ); + // Wednesday in Vienna; the weekly review is on Sunday. + const weekly = { ...off, weekly: { day: 0, time: "18:00" } }; + expect(plain(closingLine(weekly, vienna("2026-10-07T09:00:00Z", "en-US")))).toBe( + "All set. I’ll check in on Sunday at 6:00 PM.", + ); + }); + + it("says the user can just write when no check-in is set", () => { + expect(closingLine(off, vienna("2026-10-07T09:00:00Z"))).toBe( + "All set. I’m here whenever you need me.", + ); + }); + + it("falls back to English and UTC for an unusable profile", () => { + expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({ + locale: "en", + timeZone: "UTC", + }); + }); +}); diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts index 036126a..a5ea131 100644 --- a/apps/api/src/onboarding-intake.ts +++ b/apps/api/src/onboarding-intake.ts @@ -1,7 +1,15 @@ -import type { MessageBlock, OnboardingStep } from "@rakazo/contracts"; +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters"; +import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts"; import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { knownTimeZone, nextCronDateAcross } from "@rakazo/core"; -import { type Prisma, type PrismaClient, updateUserProfile } from "@rakazo/db"; +import { + createGoal, + type Prisma, + type PrismaClient, + updateNotificationSettings, + updateUserProfile, +} from "@rakazo/db"; /** * The questions the main bot asks a new user in the main conversation, one card at a time. @@ -12,18 +20,6 @@ import { type Prisma, type PrismaClient, updateUserProfile } from "@rakazo/db"; export type ChoiceBlock = Extract; type ChoiceOption = ChoiceBlock["options"][number]; -/** When the main bot checks in; the same shape the coach rhythm stores. */ -export interface CheckInRhythm { - morning: string | null; - evening: string | null; - weekly: { day: number; time: string } | null; -} - -export interface QuietHours { - start: string; - end: string; -} - /** A validated answer: an option of the card, or the user's own words. */ export type IntakeAnswer = | { option: ChoiceOption; text?: undefined } @@ -38,6 +34,7 @@ export interface IntakeContext { export interface IntakeEffects { prisma: PrismaClient; + jobs: JobPublisher; tx: Prisma.TransactionClient; userId: string; } @@ -111,7 +108,7 @@ export function focusCard(step?: "focus"): ChoiceBlock { }; } -const RHYTHMS: Record = { +const RHYTHMS: Record = { morning: { morning: "08:00", evening: null, weekly: null }, morning_evening: { morning: "08:00", evening: "18:00", weekly: null }, weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } }, @@ -123,12 +120,6 @@ const QUIET_HOURS: Record = { "23-08": { start: "23:00", end: "08:00" }, }; -// Coach and notification effects. They land with the coach (goals, rhythm) and notification -// settings modules; until then the answer stays on its card. -async function saveGoal(_effects: IntakeEffects, _title: string): Promise {} -async function saveRhythm(_effects: IntakeEffects, _rhythm: CheckInRhythm): Promise {} -async function saveQuietHours(_effects: IntakeEffects, _quietHours: QuietHours): Promise {} - export const INTAKE_STEPS: IntakeStep[] = [ { id: "focus", @@ -151,8 +142,9 @@ export const INTAKE_STEPS: IntakeStep[] = [ textInput: { placeholder: "Your goal" }, options: [SKIP], }), - save: async (effects, answer) => { - if (answer.text) await saveGoal(effects, answer.text); + save: async ({ prisma, userId }, answer) => { + if (answer.text) + await createGoal(prisma, userId, { title: answer.text, source: "onboarding" }); }, }, { @@ -168,9 +160,9 @@ export const INTAKE_STEPS: IntakeStep[] = [ { id: "none", letter: "", label: "Not now" }, ], }), - save: async (effects, answer) => { + save: async ({ prisma, jobs, userId }, answer) => { const rhythm = answer.option && RHYTHMS[answer.option.id]; - if (rhythm) await saveRhythm(effects, rhythm); + if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm }); }, }, { @@ -188,9 +180,9 @@ export const INTAKE_STEPS: IntakeStep[] = [ SKIP, ], }), - save: async (effects, answer) => { + save: async ({ prisma, userId }, answer) => { const quietHours = answer.option && QUIET_HOURS[answer.option.id]; - if (quietHours) await saveQuietHours(effects, quietHours); + if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours }); }, }, { @@ -244,10 +236,6 @@ export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined { return card.options.find((option) => !option.letter); } -export function rhythmForAnswer(answerId: string | undefined): CheckInRhythm | null { - return (answerId && RHYTHMS[answerId]) || null; -} - /** Card times and the closing line follow the user's profile, falling back to English and UTC. */ export function intakeContext( profile: { locale: string | null; timeZone: string | null } | null, @@ -271,18 +259,6 @@ function formatClock(time: string, locale: string): string { }).format(new Date(Date.UTC(2000, 0, 1, hour, minute))); } -function rhythmCrons(rhythm: CheckInRhythm): string[] { - const cron = (time: string, day = "*") => { - const [hour, minute] = time.split(":").map(Number); - return `${minute} ${hour} * * ${day}`; - }; - return [ - ...(rhythm.morning ? [cron(rhythm.morning)] : []), - ...(rhythm.evening ? [cron(rhythm.evening)] : []), - ...(rhythm.weekly ? [cron(rhythm.weekly.time, String(rhythm.weekly.day))] : []), - ]; -} - function localDay(date: Date, timeZone: string): number { const [year, month, day] = new Intl.DateTimeFormat("en-CA", { timeZone, @@ -297,10 +273,9 @@ function localDay(date: Date, timeZone: string): number { } /** One line that says what happens next: the first check-in, when there is one. */ -export function closingLine(rhythm: CheckInRhythm | null, context: IntakeContext): string { - const next = rhythm - ? nextCronDateAcross(rhythmCrons(rhythm), context.now, context.timeZone) - : null; +export function closingLine(rhythm: CoachRhythm, context: IntakeContext): string { + const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []); + const next = nextCronDateAcross(crons, context.now, context.timeZone); if (!next) return "All set. I’m here whenever you need me."; const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone); const day = diff --git a/apps/api/src/onboarding.test.ts b/apps/api/src/onboarding.test.ts index 38189ea..66f8328 100644 --- a/apps/api/src/onboarding.test.ts +++ b/apps/api/src/onboarding.test.ts @@ -1,6 +1,7 @@ import type * as db from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; import { chooseFocus, markAppConnected } from "./onboarding.js"; +import { focusCard } from "./onboarding-intake.js"; const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>); vi.mock("@rakazo/db", async (original) => ({ @@ -13,16 +14,16 @@ vi.mock("@rakazo/db", async (original) => ({ })); function fixture(catalog: unknown[]) { posted.length = 0; + // A specialist's focus card, waiting for an answer. + const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]); const tx = { $executeRaw: vi.fn(), - message: { - findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]), - update: vi.fn(), - }, + message: { findMany: pending, update: vi.fn() }, }; const deps = { prisma: { bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) }, + message: { findMany: pending }, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)), }, events: { notify: vi.fn() }, @@ -39,17 +40,19 @@ function fixture(catalog: unknown[]) { describe("onboarding connection suggestions", () => { it("does not invent authorization cards when no connector has an app catalog", async () => { const { deps, actor } = fixture([]); - await chooseFocus(deps, actor, "bot", "day"); + await chooseFocus(deps, actor, "bot", { optionId: "day" }); expect(posted.flatMap((message) => message.blocks)).not.toContainEqual( expect.objectContaining({ kind: "app_connect" }), ); - expect(posted.length).toBeGreaterThan(0); + expect(posted.flatMap((message) => message.blocks)).toEqual([ + { kind: "text", text: "Got it. What would you like to work on first?" }, + ]); }); it("uses the available connector and omits unavailable apps", async () => { const { deps, actor } = fixture([ { connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null }, ]); - await chooseFocus(deps, actor, "bot", "day"); + await chooseFocus(deps, actor, "bot", { optionId: "day" }); expect( posted .flatMap((message) => message.blocks) @@ -57,6 +60,9 @@ describe("onboarding connection suggestions", () => { ).toEqual([ expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }), ]); + expect(posted[0]?.blocks).toEqual([ + { kind: "text", text: "Got it. Slack is a good place to start." }, + ]); }); }); diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index 8163991..aa570e7 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -1,5 +1,6 @@ import { ORPCError } from "@orpc/server"; -import type { ConnectorRegistry } from "@rakazo/adapters"; +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { type ConnectorRegistry, getCoachRhythm } from "@rakazo/adapters"; import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts"; import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { featuredConnectorProvidersMatch } from "@rakazo/core"; @@ -24,7 +25,6 @@ import { intakeStep, nextIntakeStep, readIntakeAnswer, - rhythmForAnswer, secondaryOption, } from "./onboarding-intake.js"; @@ -39,6 +39,7 @@ type OnboardingDeps = { prisma: PrismaClient; events: ThreadEvents; connectors: ConnectorRegistry; + jobs: JobPublisher; }; type Target = { spaceId: string; botId: string; threadId: string }; @@ -65,8 +66,12 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin return { bot, thread: bot.thread }; } +/** + * Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows + * that reference the thread from another connection (the coach routines do). + */ async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise { - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR UPDATE`; + await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY UPDATE`; } async function postInTransaction( @@ -137,13 +142,13 @@ function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined { return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId); } -/** The newest card in the thread that still waits for an answer. */ +/** The newest card in the thread that still waits for an answer, however long ago it came. */ async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) { const messages = await client.message.findMany({ - where: { threadId, role: "bot" }, + where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } }, select: { id: true, blocks: true }, orderBy: { seq: "desc" }, - take: 50, + take: 20, }); for (const message of messages) { const blocks = message.blocks as MessageBlock[]; @@ -242,9 +247,6 @@ export async function chooseFocus( !pending.card.step || pending.card.step === "focus" ? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id) : undefined; - if (!pending.card.step && !focus) { - throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" }); - } // Fetched before the transaction: the connector catalog can take seconds. const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus, Boolean(pending.card.step)) @@ -260,7 +262,7 @@ export async function chooseFocus( if (current.card.step) { if (onboarding?.step !== current.card.step) return null; await intakeStep(current.card.step).save( - { prisma: deps.prisma, tx, userId: actor.userId }, + { prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId }, answer, ); } @@ -269,7 +271,7 @@ export async function chooseFocus( let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks); for (const message of followUp) last = await postInTransaction(tx, target, message); if (current.card.step) { - last = await askNext(tx, target, actor.userId, current.card.step, input.canEnablePush); + last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush); } return last; }); @@ -296,6 +298,7 @@ async function loadIntakeContext( /** Posts the next onboarding question, or the closing line once every step is answered. */ async function askNext( + deps: OnboardingDeps, tx: Prisma.TransactionClient, target: Target, userId: string, @@ -309,18 +312,8 @@ async function askNext( await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } }); return postInTransaction(tx, target, [next.card(context)]); } - const rhythmCard = await tx.message.findFirst({ - where: { - threadId: target.threadId, - role: "bot", - blocks: { array_contains: [{ kind: "choice", step: "rhythm" }] }, - }, - orderBy: { seq: "desc" }, - select: { blocks: true }, - }); - const rhythm = rhythmForAnswer( - (rhythmCard?.blocks as MessageBlock[] | undefined)?.find(isChoice)?.answerId, - ); + // The rhythm step saved its answer outside this transaction, so the routines are readable. + const rhythm = await getCoachRhythm(deps.prisma, userId); await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } }); return postInTransaction(tx, target, [{ kind: "text", text: closingLine(rhythm, context) }]); } diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 9aeabb8..d06c813 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -518,7 +518,12 @@ export function createRouter(deps: RouterDeps) { userAgent?: string; }>(); const repos = createRepos(deps.prisma); - const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors }; + const onboardingDeps = { + prisma: deps.prisma, + events: deps.events, + connectors: deps.connectors, + jobs: deps.jobs, + }; const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets); const groupRepos = createGroupRepos(deps.prisma); const projectGoalRepos = createProjectGoalRepos(deps.prisma); diff --git a/apps/web/src/lib/onboarding-push.ts b/apps/web/src/lib/onboarding-push.ts deleted file mode 100644 index bedb187..0000000 --- a/apps/web/src/lib/onboarding-push.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Push for the onboarding notifications card. The server offers the card only when the browser - * that answered the card before it could still turn push on, and tapping "Turn on" is the user - * gesture that subscribes before the card is answered. Both wait for the web push client - * (`lib/web-push.ts`); until it lands the card is never offered. - */ -export async function canOfferPush(): Promise { - return false; -} - -/** Subscribes this browser; true when push is on afterwards. */ -export async function turnOnPush(): Promise { - return false; -} diff --git a/apps/web/src/pages/shell/message-cards.tsx b/apps/web/src/pages/shell/message-cards.tsx index db4e32e..5c12492 100644 --- a/apps/web/src/pages/shell/message-cards.tsx +++ b/apps/web/src/pages/shell/message-cards.tsx @@ -19,8 +19,8 @@ import { BuiCard, SuccessPop } from "../../components/ai/primitives"; import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open"; import { chartViewport } from "../../lib/chart-viewport"; import { connectMcpOauth } from "../../lib/mcp-connect"; -import { canOfferPush, turnOnPush } from "../../lib/onboarding-push"; import { rpc } from "../../lib/rpc"; +import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push"; type ChoiceOption = Extract["options"][number]; @@ -55,7 +55,7 @@ export function ChoiceCard({ try { // Tapping "Turn on" is the gesture the browser needs before it asks for permission. const answer = - block.step === "notifications" && optionId === "on" && !(await turnOnPush()) + block.step === "notifications" && optionId === "on" && (await enableWebPush()) !== "enabled" ? "later" : optionId; await rpc.onboarding.choose({ @@ -63,7 +63,8 @@ export function ChoiceCard({ step: block.step, optionId: answer, ...(words ? { text: words } : {}), - canEnablePush: await canOfferPush(), + // The server offers the notifications card only to a browser that can still turn push on. + canEnablePush: webPushSupported() && !(await webPushEnabled()), }); await onBotChanged(); } catch (err) { diff --git a/packages/adapters/src/executor-instructions.test.ts b/packages/adapters/src/executor-instructions.test.ts index 26c2e89..219b379 100644 --- a/packages/adapters/src/executor-instructions.test.ts +++ b/packages/adapters/src/executor-instructions.test.ts @@ -20,6 +20,7 @@ interface FixtureOptions { sender?: { name: string } | null; profile?: Record | null; facts?: Array<{ category: string; value: string; confidence: number }>; + onboarding?: { completedAt: Date | null } | null; peers?: Array<{ id: string; name: string; @@ -112,7 +113,12 @@ async function runOnce(options: FixtureOptions = {}) { agentSecret: { findMany: vi.fn(async () => []) }, agentSkill: { findMany: vi.fn(async () => []) }, scratchpadItem: { findMany: vi.fn(async () => []) }, - user: { findUnique: vi.fn(async () => ({ name: "Ada Example" })) }, + user: { + findUnique: vi.fn(async () => ({ + name: "Ada Example", + onboarding: options.onboarding ?? null, + })), + }, userProfile: { findUnique: vi.fn(async () => options.profile === undefined @@ -338,6 +344,19 @@ describe("run instructions", () => { expect(request.instructions).not.toContain("save lasting facts they share with remember"); }); + it("leaves open onboarding questions to their cards, for the main bot only", async () => { + const main = { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }; + const open = { completedAt: null }; + const note = "still answering onboarding cards"; + + expect((await runOnce({ bot: main, onboarding: open })).request.instructions).toContain(note); + expect( + (await runOnce({ bot: main, onboarding: { completedAt: new Date() } })).request.instructions, + ).not.toContain(note); + expect((await runOnce({ bot: main })).request.instructions).not.toContain(note); + expect((await runOnce({ onboarding: open })).request.instructions).not.toContain(note); + }); + it("shows the main bot every specialist it can delegate to, its own space first", async () => { const { request, peersFindMany } = await runOnce({ bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }, diff --git a/packages/adapters/src/executor-prompt.test.ts b/packages/adapters/src/executor-prompt.test.ts index b61504d..4570495 100644 --- a/packages/adapters/src/executor-prompt.test.ts +++ b/packages/adapters/src/executor-prompt.test.ts @@ -97,6 +97,7 @@ describe("formatUserContext", () => { communicationStyle: "", summary: " ", facts: [{ category: "work", value: " ", confidence: 1 }], + onboardingOpen: false, }); expect(block).toContain("\nName: Ada\n"); expect(block).not.toContain("Time zone"); @@ -113,6 +114,7 @@ describe("formatUserContext", () => { communicationStyle: "", summary: "", facts: [], + onboardingOpen: false, }), ).toBeUndefined(); }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6bec10f..78b01cb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,7 @@ 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 { COACH_TITLE_MAX_LENGTH } from "./coach.js"; import { ActionApprovalRuleSchema, ActionAutoReviewSettingsSchema, @@ -684,8 +685,8 @@ export const appContract = { botId: Id, step: OnboardingStepSchema.optional(), optionId: z.string(), - /** The answer in the user's words, with `optionId` "_text". */ - text: z.string().trim().min(1).max(200).optional(), + /** The answer in the user's words, with `optionId` "_text"; only the goal card takes one. */ + text: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(), /** This device could still turn on push notifications. */ canEnablePush: z.boolean().optional(), }), diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..58fa18e 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -106,6 +106,7 @@ async function main() { "packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts", + "packages/testkit/src/onboarding-intake.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", diff --git a/packages/testkit/src/onboarding-intake.postgres.test.ts b/packages/testkit/src/onboarding-intake.postgres.test.ts new file mode 100644 index 0000000..57aceea --- /dev/null +++ b/packages/testkit/src/onboarding-intake.postgres.test.ts @@ -0,0 +1,276 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ComposioEmulator, getCoachRhythm } from "@rakazo/adapters"; +import type { MessageBlock } from "@rakazo/contracts"; +import { getNotificationSettings, loadUserContext } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; +type Choice = Extract; + +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("onboarding questions in the main conversation", () => { + let handles: AppHandles; + let app: App; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-onboarding-")); + + 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 }); + }); + + /** A fresh user with their main bot, as the web wizard leaves them. */ + async function newUser(name: string) { + const cookie = await signup(app, `${name.toLowerCase()}-${stamp}@rakazo.test`, name); + const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); + await rpc(app, cookie, "profile/update", { timeZone: "Europe/Vienna", locale: "en-US" }); + const main = await rpc<{ id: string }>(app, cookie, "bots/ensureMain"); + const { main: conversation } = await rpc<{ main: { groupId: string } }>( + app, + cookie, + "spaces/list", + ); + const { id: threadId } = await handles.prisma.thread.findUniqueOrThrow({ + where: { groupId: conversation.groupId }, + select: { id: true }, + }); + return { cookie, userId: me.userId, spaceId: me.spaceId, botId: main.id, threadId }; + } + + async function messages(threadId: string) { + const rows = await handles.prisma.message.findMany({ + where: { threadId }, + orderBy: { seq: "asc" }, + select: { blocks: true }, + }); + return rows.flatMap((row) => row.blocks as MessageBlock[]); + } + + async function cards(threadId: string): Promise { + return (await messages(threadId)).filter((block): block is Choice => block.kind === "choice"); + } + + async function pending(threadId: string) { + return (await cards(threadId)).filter((card) => !card.answerId); + } + + const onboardingRow = (userId: string) => + handles.prisma.userOnboarding.findUniqueOrThrow({ where: { userId } }); + + it("asks one question after another and records every answer", async () => { + const ada = await newUser("Ada"); + const choose = (body: Record) => + rpc(app, ada.cookie, "onboarding/choose", { botId: ada.botId, ...body }); + + await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); + await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); + expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["focus"]); + expect(await onboardingRow(ada.userId)).toMatchObject({ step: "focus", completedAt: null }); + expect( + (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) + .onboardingOpen, + ).toBe(true); + + await choose({ step: "focus", optionId: "day" }); + expect(await onboardingRow(ada.userId)).toMatchObject({ step: "goal", focus: "day" }); + const facts = await handles.prisma.userFact.findMany({ where: { userId: ada.userId } }); + expect(facts.map(({ category, value, source }) => ({ category, value, source }))).toEqual([ + { category: "goals", value: "Wants help with day-to-day work first", source: "onboarding" }, + ]); + const afterFocus = await messages(ada.threadId); + expect(afterFocus.filter((block) => block.kind === "app_connect").length).toBeGreaterThan(0); + // The app suggestions come before the next question. + expect(afterFocus.at(-1)).toMatchObject({ kind: "choice", step: "goal" }); + + // A stale answer to a card that is no longer pending changes nothing. + await choose({ step: "focus", optionId: "inbox" }); + expect(await onboardingRow(ada.userId)).toMatchObject({ step: "goal", focus: "day" }); + await expect(choose({ step: "goal", optionId: "morning" })).rejects.toThrow(/400/); + await expect(choose({ step: "goal", optionId: "_text", text: " " })).rejects.toThrow(/400/); + + // A double submit records the goal once. + await Promise.all([ + choose({ step: "goal", optionId: "_text", text: "Run a 10K" }), + choose({ step: "goal", optionId: "_text", text: "Run a 10K" }), + ]); + const goals = await handles.prisma.userGoal.findMany({ where: { userId: ada.userId } }); + expect(goals.map(({ title, source }) => ({ title, source }))).toEqual([ + { title: "Run a 10K", source: "onboarding" }, + ]); + expect((await cards(ada.threadId)).find((card) => card.step === "goal")).toMatchObject({ + answerId: "_text", + answerText: "Run a 10K", + }); + + await choose({ step: "rhythm", optionId: "morning" }); + expect(await getCoachRhythm(handles.prisma, ada.userId)).toEqual({ + morning: "08:00", + evening: null, + weekly: null, + }); + expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["quiet_hours"]); + expect( + (await pending(ada.threadId))[0]?.options.map((option) => option.label.replace(/\s/g, " ")), + ).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]); + + // A browser that can still turn push on gets the notifications question. + await choose({ step: "quiet_hours", optionId: "22-07", canEnablePush: true }); + expect((await getNotificationSettings(handles.prisma, ada.userId)).quietHours).toEqual({ + start: "22:00", + end: "07:00", + }); + expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["notifications"]); + + await choose({ step: "notifications", optionId: "later" }); + expect(await onboardingRow(ada.userId)).toMatchObject({ + step: null, + completedAt: expect.any(Date), + }); + expect(await pending(ada.threadId)).toEqual([]); + const closing = (await messages(ada.threadId)).at(-1); + expect(closing?.kind === "text" && closing.text.replace(/\s/g, " ")).toMatch( + /^All set\. I’ll check in (today|tomorrow) at 8:00 AM\.$/, + ); + expect( + (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) + .onboardingOpen, + ).toBe(false); + + // Onboarding runs once. + const count = (await messages(ada.threadId)).length; + await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); + expect((await messages(ada.threadId)).length).toBe(count); + }); + + it("moves on when a question is skipped or dismissed, and saves nothing for it", async () => { + const bo = await newUser("Bo"); + const choose = (body: Record) => + rpc(app, bo.cookie, "onboarding/choose", { botId: bo.botId, ...body }); + + await rpc(app, bo.cookie, "onboarding/promptFocus", { botId: bo.botId }); + await rpc(app, bo.cookie, "onboarding/dismissFocus", { botId: bo.botId }); + expect((await cards(bo.threadId))[0]).toMatchObject({ step: "focus", answerId: "skip" }); + await choose({ step: "goal", optionId: "skip" }); + await choose({ step: "rhythm", optionId: "none" }); + await choose({ step: "quiet_hours", optionId: "skip" }); + + expect(await onboardingRow(bo.userId)).toMatchObject({ + step: null, + focus: null, + completedAt: expect.any(Date), + }); + expect(await handles.prisma.userFact.count({ where: { userId: bo.userId } })).toBe(0); + expect(await handles.prisma.userGoal.count({ where: { userId: bo.userId } })).toBe(0); + expect(await getCoachRhythm(handles.prisma, bo.userId)).toEqual({ + morning: null, + evening: null, + weekly: null, + }); + expect((await getNotificationSettings(handles.prisma, bo.userId)).quietHours).toBeNull(); + expect((await messages(bo.threadId)).at(-1)).toEqual({ + kind: "text", + text: "All set. I’m here whenever you need me.", + }); + + // Another bot still gets only its focus card, outside onboarding. + const helper = await rpc<{ id: string; threadId: string }>(app, bo.cookie, "bots/create", { + name: "Helper", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + const { thread } = await handles.prisma.bot.findUniqueOrThrow({ + where: { id: helper.id }, + select: { thread: { select: { id: true } } }, + }); + await rpc(app, bo.cookie, "onboarding/promptFocus", { botId: helper.id }); + const [focus] = await pending(thread!.id); + expect(focus?.step).toBeUndefined(); + expect(focus?.options.map((option) => option.id)).toEqual(["day", "inbox", "research", "crew"]); + await rpc(app, bo.cookie, "onboarding/choose", { botId: helper.id, optionId: "research" }); + expect(await pending(thread!.id)).toEqual([]); + expect(await onboardingRow(bo.userId)).toMatchObject({ focus: null }); + }); + + it("keeps each user's onboarding to themselves", async () => { + const owner = await newUser("Owner"); + const other = await newUser("Other"); + await rpc(app, owner.cookie, "onboarding/promptFocus", { botId: owner.botId }); + + for (const [procedure, body] of [ + ["onboarding/promptFocus", { botId: owner.botId }], + ["onboarding/choose", { botId: owner.botId, step: "focus", optionId: "day" }], + ["onboarding/dismissFocus", { botId: owner.botId }], + ["onboarding/start", { botId: owner.botId }], + ] as const) { + const response = await raw(app, other.cookie, procedure, body); + expect(response.status, procedure).toBeGreaterThanOrEqual(400); + } + expect(await onboardingRow(owner.userId)).toMatchObject({ step: "focus", focus: null }); + expect((await pending(owner.threadId)).map((card) => card.step)).toEqual(["focus"]); + expect( + await handles.prisma.userOnboarding.findUnique({ where: { userId: other.userId } }), + ).toBe(null); + expect(await handles.prisma.userFact.count({ where: { userId: owner.userId } })).toBe(0); + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie, + origin: "http://127.0.0.1:5173", + }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +}