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; +}