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 new file mode 100644 index 0000000..8a5828a --- /dev/null +++ b/apps/api/src/onboarding-intake.ts @@ -0,0 +1,288 @@ +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 { + 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. + * Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order + * the user sees. Everything here is deterministic, so no model tokens are spent. + */ + +export type ChoiceBlock = Extract; +type ChoiceOption = ChoiceBlock["options"][number]; + +/** A validated answer: an option of the card, or the user's own words. */ +export type IntakeAnswer = + | { option: ChoiceOption; text?: undefined } + | { option?: undefined; text: string }; + +/** What a card may adapt to: times are written in the user's locale. */ +export interface IntakeContext { + locale: string; + timeZone: string; + now: Date; +} + +export interface IntakeEffects { + prisma: PrismaClient; + jobs: JobPublisher; + tx: Prisma.TransactionClient; + userId: string; +} + +interface IntakeStep { + id: OnboardingStep; + /** Asked only when this holds; otherwise onboarding moves past the step. */ + asks?: (device: { canEnablePush: boolean }) => boolean; + card: (context: IntakeContext) => ChoiceBlock; + /** Records a validated answer. Runs while the main conversation is locked. */ + save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise; +} + +export interface FocusOption { + id: string; + letter: string; + label: string; + apps: string[]; + /** Stored as a `goals` fact, so it stays a plain English statement. */ + fact: string; +} + +export const FOCUS_OPTIONS: FocusOption[] = [ + { + id: "day", + letter: "A", + label: "Day-to-day work", + apps: ["slack", "gmail", "googlecalendar"], + fact: "Wants help with day-to-day work first", + }, + { + id: "inbox", + letter: "B", + label: "Inbox & email", + apps: ["gmail", "googlecalendar", "slack"], + fact: "Wants help with inbox and email first", + }, + { + id: "research", + letter: "C", + label: "Research & writing", + apps: ["hackernews", "notion", "googledocs"], + fact: "Wants help with research and writing first", + }, + { + id: "crew", + letter: "D", + label: "Specialist crew", + apps: ["slack", "gmail", "googlecalendar"], + fact: "Wants a coordinated crew of specialist bots", + }, +]; + +const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" }; + +/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */ +export function focusCard(step?: "focus"): ChoiceBlock { + return { + kind: "choice", + question: "What do you want me on first?", + options: [ + ...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })), + ...(step ? [SKIP] : []), + ], + ...(step ? { step } : {}), + }; +} + +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" } }, + none: { morning: null, evening: null, weekly: null }, +}; + +const QUIET_HOURS: Record = { + "22-07": { start: "22:00", end: "07:00" }, + "23-08": { start: "23:00", end: "08:00" }, +}; + +export const INTAKE_STEPS: IntakeStep[] = [ + { + id: "focus", + card: () => focusCard("focus"), + save: async ({ prisma, tx, userId }, answer) => { + const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id); + if (!option) return; + await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } }); + await updateUserProfile(prisma, userId, { + addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }], + }); + }, + }, + { + id: "goal", + card: () => ({ + kind: "choice", + step: "goal", + question: "What’s one thing you want to get done in the next few weeks?", + textInput: { placeholder: "Your goal" }, + options: [SKIP], + }), + save: async ({ prisma, userId }, answer) => { + if (answer.text) + await createGoal(prisma, userId, { title: answer.text, source: "onboarding" }); + }, + }, + { + id: "rhythm", + card: () => ({ + kind: "choice", + step: "rhythm", + question: "When should I check in with you?", + options: [ + { id: "morning", letter: "A", label: "Every morning" }, + { id: "morning_evening", letter: "B", label: "Morning and evening" }, + { id: "weekly", letter: "C", label: "Once a week" }, + { id: "none", letter: "", label: "Not now" }, + ], + }), + save: async ({ prisma, jobs, userId }, answer) => { + const rhythm = answer.option && RHYTHMS[answer.option.id]; + if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm }); + }, + }, + { + id: "quiet_hours", + card: (context) => ({ + kind: "choice", + step: "quiet_hours", + question: "When should I stay quiet?", + options: [ + ...Object.entries(QUIET_HOURS).map(([id, range], index) => ({ + id, + letter: String.fromCharCode(65 + index), + label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`, + })), + SKIP, + ], + }), + save: async ({ prisma, userId }, answer) => { + const quietHours = answer.option && QUIET_HOURS[answer.option.id]; + if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours }); + }, + }, + { + // The browser subscribes when the user taps "Turn on"; the answer only moves on. + id: "notifications", + asks: ({ canEnablePush }) => canEnablePush, + card: () => ({ + kind: "choice", + step: "notifications", + question: "Can I send you notifications?", + options: [ + { id: "on", letter: "A", label: "Turn on" }, + { id: "later", letter: "", label: "Not now" }, + ], + }), + save: async () => {}, + }, +]; + +export function intakeStep(id: OnboardingStep): IntakeStep { + const step = INTAKE_STEPS.find((entry) => entry.id === id); + if (!step) throw new Error(`Unknown onboarding step: ${id}`); + return step; +} + +/** The step after `id` that applies to this device, or null when onboarding is done. */ +export function nextIntakeStep( + id: OnboardingStep, + device: { canEnablePush: boolean }, +): IntakeStep | null { + const index = INTAKE_STEPS.findIndex((entry) => entry.id === id); + return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null; +} + +/** Checks an answer against the card it answers; null when the card does not offer it. */ +export function readIntakeAnswer( + card: ChoiceBlock, + optionId: string, + text: string | undefined, +): IntakeAnswer | null { + if (optionId === CHOICE_TEXT_ANSWER_ID) { + const words = text?.trim(); + return card.textInput && words ? { text: words } : null; + } + const option = card.options.find((entry) => entry.id === optionId); + return option ? { option } : null; +} + +/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */ +export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined { + return card.options.find((option) => !option.letter); +} + +/** 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, + now: Date, +): IntakeContext { + let locale = "en"; + try { + if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en"; + } catch { + // Not a language tag; keep English. + } + return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now }; +} + +function formatClock(time: string, locale: string): string { + const [hour = 0, minute = 0] = time.split(":").map(Number); + return new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + }).format(new Date(Date.UTC(2000, 0, 1, hour, minute))); +} + +function localDay(date: Date, timeZone: string): number { + const [year, month, day] = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .format(date) + .split("-") + .map(Number); + return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000; +} + +/** One line that says what happens next: the first check-in, when there is one. */ +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 = + days === 0 + ? "today" + : days === 1 + ? "tomorrow" + : `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`; + const time = new Intl.DateTimeFormat(context.locale, { + hour: "numeric", + minute: "2-digit", + timeZone: context.timeZone, + }).format(next); + return `All set. I’ll check in ${day} at ${time}.`; +} 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 b0bf565..aa570e7 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -1,5 +1,8 @@ -import type { ConnectorRegistry } from "@rakazo/adapters"; -import type { Actor, MessageBlock } from "@rakazo/contracts"; +import { ORPCError } from "@orpc/server"; +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"; import { appendEventInTransaction, @@ -10,58 +13,36 @@ import { type PrismaClient, type ThreadEvents, } from "@rakazo/db"; +import { + type ChoiceBlock, + closingLine, + FOCUS_OPTIONS, + type FocusOption, + focusCard, + type IntakeAnswer, + type IntakeContext, + intakeContext, + intakeStep, + nextIntakeStep, + readIntakeAnswer, + secondaryOption, +} from "./onboarding-intake.js"; /** - * First-run conversational onboarding, seeded deterministically into the bot's - * thread: greeting, a focus choice, and available app cards the user authorizes - * inline. Focus must not rename the bot. No model tokens are spent. The main bot - * talks in the main conversation, where onboarding lands. + * Conversational onboarding, posted deterministically into a bot's thread; no model tokens are + * spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main + * conversation, one card after each answer. Any other bot gets one focus card that suggests + * apps the user authorizes inline. Focus never renames a bot. */ type OnboardingDeps = { prisma: PrismaClient; events: ThreadEvents; connectors: ConnectorRegistry; + jobs: JobPublisher; }; -type FocusOption = { - id: string; - letter: string; - label: string; - summary: string; - apps: string[]; -}; - -const FOCUS_OPTIONS: FocusOption[] = [ - { - id: "day", - letter: "A", - label: "Day-to-day work", - summary: "Slack, calendar, and email", - apps: ["slack", "gmail", "googlecalendar"], - }, - { - id: "inbox", - letter: "B", - label: "Inbox & email", - summary: "email and calendar", - apps: ["gmail", "googlecalendar", "slack"], - }, - { - id: "research", - letter: "C", - label: "Research & writing", - summary: "the web, notes, and docs", - apps: ["hackernews", "notion", "googledocs"], - }, - { - id: "crew", - letter: "D", - label: "Specialist crew", - summary: "a coordinated team of focused Oos", - apps: ["slack", "gmail", "googlecalendar"], - }, -]; +type Target = { spaceId: string; botId: string; threadId: string }; const APP_DESCRIPTIONS: Record = { slack: "Search, read, and send messages.", @@ -85,74 +66,102 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin return { bot, thread: bot.thread }; } -async function post( - deps: OnboardingDeps, - target: { spaceId: string; botId: string; threadId: string }, +/** + * 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 NO KEY UPDATE`; +} + +async function postInTransaction( + tx: Prisma.TransactionClient, + target: Target, blocks: MessageBlock[], -): Promise { - const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - const message = await createThreadMessageInTransaction(tx, { - threadId: target.threadId, - role: "bot", - blocks, - botId: target.botId, - }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.created", - payload: { messageId: message.id, role: "bot", blocks }, - }); - return { message, event }; +): Promise<{ seq: number }> { + const message = await createThreadMessageInTransaction(tx, { + threadId: target.threadId, + role: "bot", + blocks, + botId: target.botId, + }); + return appendEventInTransaction(tx, { + spaceId: target.spaceId, + threadId: target.threadId, + botId: target.botId, + type: "thread.message.created", + payload: { messageId: message.id, role: "bot", blocks }, + }); +} + +async function updateBlocksInTransaction( + tx: Prisma.TransactionClient, + target: Target, + messageId: string, + blocks: MessageBlock[], +): Promise<{ seq: number }> { + await tx.message.update({ where: { id: messageId }, data: { blocks } }); + return appendEventInTransaction(tx, { + spaceId: target.spaceId, + threadId: target.threadId, + botId: target.botId, + type: "thread.message.updated", + payload: { messageId, role: "bot", blocks }, }); - await deps.events.notify(target.threadId, committed.event.seq); - return committed.message.id; } async function updateBlocks( deps: OnboardingDeps, - target: { spaceId: string; botId: string; threadId: string }, + target: Target, messageId: string, blocks: MessageBlock[], ): Promise { - const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.message.update({ where: { id: messageId }, data: { blocks } }); - return appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId, role: "bot", blocks }, - }); - }); + const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) => + updateBlocksInTransaction(tx, target, messageId, blocks), + ); await deps.events.notify(target.threadId, event.seq); } /** Sentinel answerId for a focus card the user dismissed without choosing. */ export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed"; +/** Checks that the caller may onboard this bot's thread; fresh threads start empty. */ export async function startOnboarding( deps: OnboardingDeps, actor: Actor, botId: string, ): Promise { - const { thread } = await requireBotThread(deps, actor, botId); - const existing = await deps.prisma.message.count({ where: { threadId: thread.id } }); - if (existing > 0) return; - // Fresh chats start empty (same as web). The focus card is posted later via - // promptFocus so non-first bots can wait ~10s for free typing, or skip if the - // user already engaged. + await requireBotThread(deps, actor, botId); } -function messageHasChoice(blocks: MessageBlock[]): boolean { - return blocks.some((block) => block.kind === "choice"); +function isChoice(block: MessageBlock): block is ChoiceBlock { + return block.kind === "choice"; } -function messageHasPendingChoice(blocks: MessageBlock[]): boolean { - return blocks.some((block) => block.kind === "choice" && !block.answerId); +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, however long ago it came. */ +async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) { + const messages = await client.message.findMany({ + where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } }, + select: { id: true, blocks: true }, + orderBy: { seq: "desc" }, + take: 20, + }); + for (const message of messages) { + const blocks = message.blocks as MessageBlock[]; + const card = pendingChoice(blocks); + if (card) return { messageId: message.id, blocks, card }; + } + return null; +} + +/** + * Posts the first card once. The main bot starts the onboarding questions, one set per user; + * any other bot posts its focus card only while its thread is still idle. + */ export async function promptFocus( deps: OnboardingDeps, actor: Actor, @@ -160,44 +169,31 @@ export async function promptFocus( ): Promise { const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; - const blocks: MessageBlock[] = [ - { - kind: "choice", - question: "What do you want me on first?", - options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })), - }, - ]; - // Check + insert + event in one transaction so concurrent promptFocus calls - // cannot duplicate cards, and a concurrent user send cannot publish first. + const main = bot.kind === "main"; + // Check + insert + event in one transaction so concurrent callers cannot duplicate cards, + // and a concurrent user send cannot publish first. const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - // Serialize concurrent promptFocus callers on this thread before the gate check. - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; + await lockThread(tx, thread.id); + if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) { + return null; + } const recent = await tx.message.findMany({ where: { threadId: thread.id }, select: { role: true, blocks: true }, - orderBy: { createdAt: "asc" }, }); - if (recent.some((message) => message.role === "user")) return null; - if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null; - const message = await createThreadMessageInTransaction(tx, { - threadId: target.threadId, - role: "bot", - blocks, - botId: target.botId, - }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.created", - payload: { messageId: message.id, role: "bot", blocks }, - }); - return { message, event }; + if (!main && recent.some((message) => message.role === "user")) return null; + if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null; + if (!main) return postInTransaction(tx, target, [focusCard()]); + const first = intakeStep("focus"); + await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } }); + return postInTransaction(tx, target, [ + first.card(await loadIntakeContext(tx, actor.userId, new Date())), + ]); }); - if (!committed) return; - await deps.events.notify(target.threadId, committed.event.seq); + if (committed) await deps.events.notify(target.threadId, committed.seq); } +/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */ export async function dismissFocus( deps: OnboardingDeps, actor: Actor, @@ -205,81 +201,131 @@ export async function dismissFocus( ): Promise { const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; - const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; - const recent = await tx.message.findMany({ - where: { threadId: thread.id }, - orderBy: { createdAt: "asc" }, - }); - const pending = recent.find((message) => - messageHasPendingChoice(message.blocks as MessageBlock[]), + const pending = await findPendingCard(deps.prisma, thread.id); + const skip = pending?.card.step ? secondaryOption(pending.card) : undefined; + if (pending?.card.step && skip) { + await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id }); + return; + } + const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await lockThread(tx, thread.id); + const current = await findPendingCard(tx, thread.id); + if (!current || current.card.step) return null; + const blocks = current.blocks.map((block) => + block === current.card ? { ...current.card, answerId: FOCUS_DISMISSED_ANSWER_ID } : block, ); - if (!pending) return null; - const blocks = (pending.blocks as MessageBlock[]).map((block) => - block.kind === "choice" && !block.answerId - ? { ...block, answerId: FOCUS_DISMISSED_ANSWER_ID } - : block, - ); - await tx.message.update({ where: { id: pending.id }, data: { blocks } }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId: pending.id, role: "bot", blocks }, - }); - return { messageId: pending.id, blocks, event }; + return updateBlocksInTransaction(tx, target, current.messageId, blocks); }); - if (!claimed) return; - await deps.events.notify(target.threadId, claimed.event.seq); + if (event) await deps.events.notify(target.threadId, event.seq); } +export interface ChooseInput { + step?: OnboardingStep; + optionId: string; + text?: string; + canEnablePush?: boolean; +} + +/** + * Answers the pending card. The answer is checked against that card, a stale answer (another + * card is pending by now) is ignored, and everything that follows is committed together with + * it: the focus card's app suggestions, then the next onboarding question or the closing line. + */ export async function chooseFocus( deps: OnboardingDeps, actor: Actor, botId: string, - optionId: string, + input: ChooseInput, ): Promise { - const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId); - if (!option) throw new IsolationError(); const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; + const pending = await findPendingCard(deps.prisma, thread.id); + if (!pending || pending.card.step !== input.step) return; + const answer = readIntakeAnswer(pending.card, input.optionId, input.text); + if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" }); + const focus = + !pending.card.step || pending.card.step === "focus" + ? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id) + : undefined; + // Fetched before the transaction: the connector catalog can take seconds. + const followUp = focus + ? await focusFollowUp(deps, actor, bot.id, focus, Boolean(pending.card.step)) + : []; - const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; - const recent = await tx.message.findMany({ - where: { threadId: thread.id }, - orderBy: { createdAt: "asc" }, - }); - const pending = recent.find((message) => - messageHasPendingChoice(message.blocks as MessageBlock[]), - ); - if (!pending) return null; - const blocks = (pending.blocks as MessageBlock[]).map((block) => - block.kind === "choice" ? { ...block, answerId: option.id } : block, - ); - await tx.message.update({ where: { id: pending.id }, data: { blocks } }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId: pending.id, role: "bot", blocks }, - }); - return { messageId: pending.id, blocks, event }; + const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await lockThread(tx, thread.id); + const current = await findPendingCard(tx, thread.id); + if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null; + const onboarding = current.card.step + ? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }) + : null; + if (current.card.step) { + if (onboarding?.step !== current.card.step) return null; + await intakeStep(current.card.step).save( + { prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId }, + answer, + ); + } + const answered = answeredCard(current.card, answer); + const blocks = current.blocks.map((block) => (block === current.card ? answered : block)); + 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(deps, tx, target, actor.userId, current.card.step, input.canEnablePush); + } + return last; }); - if (!claimed) return; - await deps.events.notify(target.threadId, claimed.event.seq); + if (event) await deps.events.notify(target.threadId, event.seq); +} - // Keep the name and title the user chose when creating the bot; the focus - // step only suggests apps, it must not rename the bot. - await post(deps, target, [ - { - kind: "text", - text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`, - }, - ]); +function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock { + return answer.option + ? { ...card, answerId: answer.option.id } + : { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text }; +} +async function loadIntakeContext( + tx: Prisma.TransactionClient, + userId: string, + now: Date, +): Promise { + const profile = await tx.userProfile.findUnique({ + where: { userId }, + select: { locale: true, timeZone: true }, + }); + return intakeContext(profile, now); +} + +/** 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, + answeredStep: OnboardingStep, + canEnablePush = false, +): Promise<{ seq: number }> { + const now = new Date(); + const context = await loadIntakeContext(tx, userId, now); + const next = nextIntakeStep(answeredStep, { canEnablePush }); + if (next) { + await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } }); + return postInTransaction(tx, target, [next.card(context)]); + } + // 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) }]); +} + +/** The messages after a focus answer: a short acknowledgement and the apps worth connecting. */ +async function focusFollowUp( + deps: OnboardingDeps, + actor: Actor, + botId: string, + option: FocusOption, + onboarding: boolean, +): Promise { const providers = deps.connectors.managedProviders(); const catalog = ( await Promise.all( @@ -290,14 +336,14 @@ export async function chooseFocus( traceId: "onboarding.choose", spaceId: actor.spaceId, userId: actor.userId, - botId: bot.id, + botId, signal: AbortSignal.timeout(15_000), }) .catch(() => []), ), ) ).flat(); - const cards: MessageBlock[] = option.apps.flatMap((slug) => { + const cards = option.apps.flatMap((slug) => { const entry = catalog.find( (item) => item.slug.toLowerCase() === slug.toLowerCase() || @@ -317,26 +363,22 @@ export async function chooseFocus( ]; }); if (!cards.length) { - await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]); - return; + // In onboarding the next question follows; another bot asks what to start with. + const text = onboarding ? "Got it." : "Got it. What would you like to work on first?"; + return [[{ kind: "text", text }]]; } - const cardNames = cards - .map((card) => (card.kind === "app_connect" ? card.name : "")) - .filter(Boolean); - const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`; - await post(deps, target, [ - { - kind: "text", - text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`, - }, - ]); - await post(deps, target, cards); - await post(deps, target, [ - { - kind: "text", - text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`, - }, - ]); + const names = cards.map((card) => card.name); + const named = + names.length > 1 ? `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}` : names[0]; + return [ + [ + { + kind: "text", + text: `Got it. ${named} ${names.length > 1 ? "are" : "is"} a good place to start.`, + }, + ], + cards, + ]; } export async function markAppConnected( @@ -376,7 +418,3 @@ export async function markAppConnected( await updateBlocks(deps, target, message.id, next); } } - -function capitalize(value: string): string { - return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value; -} diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index f43dc89..1c8f5eb 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -530,7 +530,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); @@ -3494,7 +3499,7 @@ export function createRouter(deps: RouterDeps) { return { ok: true as const }; }), choose: authed.onboarding.choose.handler(async ({ context, input }) => { - await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId); + await chooseFocus(onboardingDeps, context.actor, input.botId, input); return { ok: true as const }; }), dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => { diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index ddc8e93..053a60c 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -1857,6 +1857,25 @@ describe("mobile thread event reduction", () => { ).toBe("Slack · Alex: Hello from the group"); }); + it("reads a choice card as its question and answer", () => { + const card = { + kind: "choice" as const, + step: "goal" as const, + question: "What’s one thing you want to get done in the next few weeks?", + textInput: { placeholder: "Your goal" }, + options: [{ id: "skip", letter: "", label: "Skip" }], + }; + expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question); + expect( + blockText( + mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]), + ), + ).toBe(`${card.question}\nRun a 10K`); + expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe( + `${card.question}\nSkip`, + ); + }); + it("deduplicates durable messages and replaces matching transient subagent state", () => { const initial = snapshot([ mobileMessage("message-1", [{ kind: "text", text: "old" }]), diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index f328abc..45e5777 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -874,6 +874,12 @@ export function blockText(message: MobileMessage) { if (block.kind === "child_bot") { return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`; } + // Choice cards are answered on the web; here they read as the question and its answer. + if (block.kind === "choice") { + const answer = + block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label; + return answer ? `${block.question}\n${answer}` : block.question; + } if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`; if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`; if (block.kind === "file") { diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 32b77a3..edc646d 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -88,8 +88,9 @@ async function visible(locator: Locator, timeout: number) { } /** - * Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation` - * is set, it then opens the main bot's own thread, where most specs start. + * Walks the first-run wizard, which lands in the main conversation with the main bot's first + * onboarding question pending (the composer works regardless). Unless `mainConversation` is + * set, it then opens the main bot's own thread, where most specs start. */ export async function completeOnboarding( page: Page, @@ -101,13 +102,12 @@ export async function completeOnboarding( if (page.url().includes("/onboarding")) { await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations|scan|bot)$/, + /^(emoji|language|voice|integrations|scan|bot)$/, { timeout: 30_000 }, ); } const design = page.getByRole("heading", { name: /Design your Oo/i }); - const preferences = page.getByRole("heading", { name: /Tell us about yourself/i }); const language = page.getByRole("heading", { name: /Choose your language/i }); const voice = page.getByRole("heading", { name: /Pick a voice/i }); const integrations = page.getByRole("heading", { name: "Server integrations", exact: true }); @@ -119,8 +119,6 @@ export async function completeOnboarding( await expect(entry.first()).toBeVisible({ timeout: 20_000 }); if (await design.isVisible()) { - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(preferences).toBeVisible(); await page.getByRole("button", { name: "Continue", exact: true }).click(); await expect(language).toBeVisible(); await page.getByRole("button", { name: /Skip/i }).click(); @@ -133,8 +131,15 @@ export async function completeOnboarding( await page.getByRole("button", { name: "Skip", exact: true }).click(); } - // Scan step auto-advances to the main bot, then onboarding lands in the main conversation. - await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); + // The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding + // then lands in the main conversation. + const skipScan = page + .locator('[data-onboarding-step="scan"]') + .getByRole("button", { name: "Skip", exact: true }); + await expect(async () => { + if (await skipScan.isVisible()) await skipScan.click(); + expect(page.url()).toMatch(/\/app\/g\/[^/]+$/); + }).toPass({ timeout: 30_000 }); await expect(chief).toBeVisible({ timeout: 15_000 }); await waitForChatComposer(page); diff --git a/apps/web/e2e/message-hover-actions.spec.ts b/apps/web/e2e/message-hover-actions.spec.ts index 4667825..d35ade3 100644 --- a/apps/web/e2e/message-hover-actions.spec.ts +++ b/apps/web/e2e/message-hover-actions.spec.ts @@ -4,7 +4,8 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr /** Submit from the Send control so an in-flight prior send cannot swallow Enter. */ async function sendComposerMessage(page: Page, composer: Locator, text: string) { await composer.fill(text); - const send = page.getByRole("button", { name: "Send", exact: true }); + // An open onboarding card in the transcript has its own Send. + const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true }); await expect(send).toBeEnabled(); await send.click(); await expect(composer).toHaveValue(""); @@ -72,13 +73,20 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( const transcript = page.getByTestId("transcript"); - // Empty start: no Fresh-start greeting. Answer the focus card so a plain - // bot text bubble exists for hover layout checks. + // Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot + // row's rail sits at the row's end, so the measured reply must wrap and fill its row. await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({ timeout: 20_000, }); await page.getByTestId("focus-choice-day").click(); - const botText = page.getByText(/Got it\./); + await expect(page.getByText(/Got it\./)).toBeVisible({ timeout: 20_000 }); + const composer = page.getByRole("combobox", { name: /^Message/ }); + await sendComposerMessage( + page, + composer, + `hover-bot-${stamp} ${"please look into this ".repeat(7)}`, + ); + const botText = page.getByText(/i handled: hover-bot-/); await expect(botText).toBeVisible({ timeout: 20_000 }); const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first(); await expect(botRow).toBeVisible(); @@ -121,7 +129,6 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( const parentText = `hover-parent-${stamp}`; const replyText = `hover-reply-${stamp}`; - const composer = page.getByRole("combobox", { name: /^Message/ }); await expect(composer).toBeVisible(); await sendComposerMessage(page, composer, parentText); diff --git a/apps/web/e2e/onboarding-conversation.spec.ts b/apps/web/e2e/onboarding-conversation.spec.ts index 57bd3bb..e028c66 100644 --- a/apps/web/e2e/onboarding-conversation.spec.ts +++ b/apps/web/e2e/onboarding-conversation.spec.ts @@ -21,6 +21,9 @@ test("focus choice suggests apps and preserves a completed connection", async ({ await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work"); await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing"); await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew"); + // Onboarding questions offer a quiet way out instead of a dismiss button. + await expect(page.getByTestId("focus-choice-skip")).toBeVisible(); + await expect(page.getByRole("button", { name: "Dismiss" })).toHaveCount(0); await page.mouse.move(1, 1); await captureScreenshot(page, testInfo, "01-focus-choice"); await captureScreenshot(page, testInfo, "choice-card-onboarding"); @@ -33,6 +36,9 @@ test("focus choice suggests apps and preserves a completed connection", async ({ ).toBeVisible(); expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief"); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); + await expect( + page.getByText("Got it. Slack, Gmail, and Google Calendar are a good place to start."), + ).toBeVisible(); await expect(page.getByText("Slack", { exact: true })).toBeVisible(); await expect(page.getByText("Gmail", { exact: true })).toBeVisible(); const connectionCards = page.getByRole("group", { name: / connection$/ }); @@ -45,10 +51,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({ ); expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8); expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8); - await page - .getByTestId("transcript") - .getByText("Hit those three and I’ll start pulling the picture.") - .scrollIntoViewIfNeeded(); + // The next onboarding question follows the suggestions. + const goal = page.locator('[data-testid="choice-card"][data-step="goal"]'); + await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?"); + await slackCard(page).scrollIntoViewIfNeeded(); await page.mouse.move(1, 1); await captureScreenshot(page, testInfo, "02-app-suggestions"); const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" }); diff --git a/apps/web/e2e/onboarding-emoji-first.spec.ts b/apps/web/e2e/onboarding-emoji-first.spec.ts index 0a49936..51e1bf8 100644 --- a/apps/web/e2e/onboarding-emoji-first.spec.ts +++ b/apps/web/e2e/onboarding-emoji-first.spec.ts @@ -17,12 +17,11 @@ test.beforeEach(async ({ page }) => { }); /** - * E2E test for the new emoji-first onboarding flow: + * E2E test for the emoji-first onboarding flow: * 1. Sign up → lands on onboarding * 2. Design Oo: pick shape + color → live preview - * 3. Tell us about yourself: name, timezone, hobbies, work style, communication - * 4. Choose language - * 5. Skip integrations → bot created → land in chat + * 3. Choose language, skip the voice + * 4. Skip integrations → bot created → land in chat, where the main bot asks the rest */ test("emoji-first onboarding: shape + color selection with live preview", async ({ @@ -50,50 +49,13 @@ test("emoji-first onboarding: shape + color selection with live preview", async if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview"); - // Click Continue → goes to preferences step + // Click Continue → goes to the language step await page .getByRole("button", { name: /Continue/i }) .first() .click(); - // Step 2: Tell us about yourself - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - - if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences"); - - // Fill in name - await page.getByPlaceholder(/Your name/i).fill("Alice"); - - // Fill in timezone - await page.getByLabel(/Timezone/i).fill("Europe/Vienna"); - - // Select a hobby - const hobbyButtons = page.locator("button[aria-pressed]"); - if (await hobbyButtons.count()) { - await hobbyButtons.first().click(); // toggle first hobby - } - - // Select work style - const workStyleButtons = page.locator("button[aria-pressed]:visible"); - if ((await workStyleButtons.count()) >= 2) { - await workStyleButtons.nth(0).click(); - } - - // Select communication style - const commButtons = page.locator("button[aria-pressed]:visible"); - if ((await commButtons.count()) >= 2) { - await commButtons.nth(0).click(); - } - - if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled"); - - // Click Continue → goes to language step - await page - .getByRole("button", { name: /Continue/i }) - .first() - .click(); - - // Step 3: Language selection + // Step 2: Language selection await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); if (testInfo) await captureScreenshot(page, testInfo, "05-language-step"); @@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => { .first() .click(); - // Preferences step — just continue - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - await page - .getByRole("button", { name: /Continue/i }) - .first() - .click(); - // Language step — click Skip, then skip voice. await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await page.getByRole("button", { name: /Skip/i }).click(); diff --git a/apps/web/e2e/onboarding-intake.spec.ts b/apps/web/e2e/onboarding-intake.spec.ts new file mode 100644 index 0000000..3b7903a --- /dev/null +++ b/apps/web/e2e/onboarding-intake.spec.ts @@ -0,0 +1,83 @@ +/** + * E2E: after the wizard the main bot gets to know the user in the main conversation, one card + * after each answer, without ever blocking the composer. + */ +import { expect, type Page, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +function card(page: Page, step: string) { + return page.locator(`[data-testid="choice-card"][data-step="${step}"]`); +} + +test("the main bot asks focus, goal, check-ins and quiet hours, then says what is next", async ({ + page, +}, testInfo) => { + await signup(page, `intake-${Date.now()}@rakazo.test`, "password12", "Intake Tester"); + await completeOnboarding(page, undefined, { mainConversation: true }); + + await page.getByTestId("focus-choice-day").click(); + const goal = card(page, "goal"); + await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?"); + + // Typing instead of answering goes to the main bot, and the card stays answerable. + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill("Quick question first"); + await composer.press("Enter"); + await expect(page.getByTestId("transcript")).toContainText(/on it|handled/i, { + timeout: 60_000, + }); + const goalInput = goal.getByRole("textbox", { + name: "What’s one thing you want to get done in the next few weeks?", + }); + await goalInput.fill("Run a 10K"); + await goal.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-goal"); + await goalInput.press("Enter"); + await expect(goal.getByTestId("choice-answer")).toHaveText("Run a 10K"); + + const rhythm = card(page, "rhythm"); + await expect(rhythm).toContainText("When should I check in with you?"); + await rhythm.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-rhythm"); + await rhythm.getByTestId("rhythm-choice-morning").click(); + await expect(rhythm.getByTestId("choice-answer")).toContainText("Every morning"); + + const quiet = card(page, "quiet_hours"); + await expect(quiet).toContainText("When should I stay quiet?"); + await quiet.getByTestId("quiet_hours-choice-22-07").click(); + + // Only a browser that can still turn push on is asked about notifications. + const closing = page.getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/); + const notifications = card(page, "notifications"); + await expect(notifications.or(closing).first()).toBeVisible(); + if (await notifications.isVisible()) { + await notifications.getByTestId("notifications-choice-later").click(); + } + await expect(closing).toBeVisible(); + await expect( + page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'), + ).toHaveCount(0); + await closing.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-done"); + + const settings = await rpc<{ quietHours: { start: string; end: string } | null }>( + page, + "notifications/settings/get", + null, + ); + expect(settings.quietHours).toEqual({ start: "22:00", end: "07:00" }); + const profile = await rpc<{ facts: Array<{ value: string; source: string }> }>( + page, + "profile/get", + null, + ); + expect(profile.facts).toContainEqual( + expect.objectContaining({ + value: "Wants help with day-to-day work first", + source: "onboarding", + }), + ); +}); diff --git a/apps/web/e2e/onboarding-model-required.spec.ts b/apps/web/e2e/onboarding-model-required.spec.ts index bfd722e..224608d 100644 --- a/apps/web/e2e/onboarding-model-required.spec.ts +++ b/apps/web/e2e/onboarding-model-required.spec.ts @@ -37,7 +37,7 @@ test("onboarding shows the look picker when a model is still required", async ({ await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations)$/, + /^(emoji|language|voice|integrations)$/, { timeout: 30_000 }, ); await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ @@ -56,7 +56,7 @@ for (const unavailable of ["empty", "failed"] as const) { await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations)$/, + /^(emoji|language|voice|integrations)$/, { timeout: 30_000 }, ); await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ diff --git a/apps/web/e2e/onboarding-oo-swipe.spec.ts b/apps/web/e2e/onboarding-oo-swipe.spec.ts index 8e175f2..0789ace 100644 --- a/apps/web/e2e/onboarding-oo-swipe.spec.ts +++ b/apps/web/e2e/onboarding-oo-swipe.spec.ts @@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page } .getByRole("button", { name: /Continue/i }) .first() .click(); - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); }); diff --git a/apps/web/e2e/user-profile.spec.ts b/apps/web/e2e/user-profile.spec.ts index 9b57445..da47b22 100644 --- a/apps/web/e2e/user-profile.spec.ts +++ b/apps/web/e2e/user-profile.spec.ts @@ -1,6 +1,7 @@ /** - * E2E: onboarding answers land in the user profile, and Settings → About you shows every - * fact with its origin, confirms a pending one, and forgets one or all of them. + * E2E: onboarding fills the user profile without asking (time zone, locale and language from + * the browser) and records the focus answer as a fact; Settings → About you shows every fact + * with its origin, confirms a pending one, and forgets one or all of them. */ import { expect, test } from "@playwright/test"; import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers"; @@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } test.use({ timezoneId: "Europe/Vienna" }); test.beforeEach(async ({ page }) => { - // Walk the full emoji-first flow, including the preferences step. + // Walk the full emoji-first flow. await page.route("**/rpc/integrationSetup/get", (route) => route.fulfill({ json: { @@ -34,27 +35,23 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async }); await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna"); - await page.getByPlaceholder("Your name").fill("Pat Example"); - await page.getByRole("button", { name: "Music", exact: true }).click(); - await page.getByRole("button", { name: "Deep-dive focused" }).click(); - await page.getByRole("button", { name: "Direct & concise" }).click(); - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await page.getByRole("button", { name: "English", exact: true }).click(); await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible(); await page.getByRole("button", { name: "Skip", exact: true }).click(); - await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 }); + await page.waitForURL(/\/app\/g\/[^/]+/, { timeout: 30_000 }); await waitForChatComposer(page); + // The main bot asks the rest in the main conversation; the focus answer becomes a fact. + await page.getByTestId("focus-choice-day").click(); + await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible(); // The answers no longer overwrite a bot's instructions. const bots = await rpc>(page, "bots/list", null); expect(bots.length).toBeGreaterThan(0); for (const bot of bots) expect(bot.instructions).not.toContain("User Context"); - expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example"); + // The account name is used as is; onboarding no longer asks for it again. + expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Profile Tester"); // Sensitive facts from anywhere but the user wait for confirmation. await rpc(page, "profile/update", { @@ -67,14 +64,13 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna"); await expect(profile.getByTestId("profile-field-language")).toContainText("English"); await expect(profile.getByTestId("profile-field-locale")).toContainText("United States"); - await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText( - "Direct and concise", - ); - const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" }); - await expect(music).toContainText("Onboarding"); - await expect(profile.getByTestId("user-facts-habits")).toContainText( - "Prefers focused deep dives", + const focus = profile + .getByTestId("user-fact") + .filter({ hasText: "Wants help with day-to-day work first" }); + await expect(focus).toContainText("Onboarding"); + await expect(profile.getByTestId("user-facts-goals")).toContainText( + "Wants help with day-to-day work first", ); const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" }); @@ -84,8 +80,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async await expect(allergy).toHaveAttribute("data-status", "active"); await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0); - await music.getByRole("button", { name: "Delete" }).click(); - await expect(music).toHaveCount(0); + await focus.getByRole("button", { name: "Delete" }).click(); + await expect(focus).toHaveCount(0); await captureScreenshot(page, testInfo, "user-profile-settings"); const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>( page, @@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async null, ); expect(afterDelete.facts).toEqual( - expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]), + expect.not.arrayContaining([ + expect.objectContaining({ value: "Wants help with day-to-day work first" }), + ]), ); expect(afterDelete.facts).toContainEqual( expect.objectContaining({ value: "Allergic to peanuts", status: "active" }), diff --git a/apps/web/src/lib/onboarding-profile.test.ts b/apps/web/src/lib/onboarding-profile.test.ts index d9ce98d..ba07e54 100644 --- a/apps/web/src/lib/onboarding-profile.test.ts +++ b/apps/web/src/lib/onboarding-profile.test.ts @@ -1,56 +1,25 @@ import { describe, expect, it } from "vitest"; import { onboardingProfileUpdate } from "./onboarding-profile"; -const blank = { name: "", timezone: "", hobbies: [], workStyle: "", communication: "" }; - describe("onboardingProfileUpdate", () => { - it("turns answers into profile fields and onboarding facts", () => { + it("fills the time zone, locale and chosen language from the browser", () => { expect( onboardingProfileUpdate({ - answers: { - name: " Alice ", - timezone: "Europe/Vienna", - hobbies: ["music", "tech", "none"], - workStyle: "deep-dive", - communication: "direct", - }, - accountName: "Alice Example", - browserTimeZone: "UTC", + browserTimeZone: "Europe/Vienna", locale: "de-AT", language: "de", }), - ).toEqual({ - name: "Alice", - timeZone: "Europe/Vienna", - locale: "de-AT", - language: "de", - communicationStyle: "Direct and concise", - addFacts: [ - { category: "preferences", value: "Interested in music", source: "onboarding" }, - { category: "preferences", value: "Interested in tech and gadgets", source: "onboarding" }, - { category: "habits", value: "Prefers focused deep dives", source: "onboarding" }, - ], - }); + ).toEqual({ timeZone: "Europe/Vienna", locale: "de-AT", language: "de" }); }); - it("keeps the account name and falls back to the browser time zone", () => { + it("leaves out a language the user did not pick and malformed values", () => { expect( onboardingProfileUpdate({ - answers: { ...blank, name: "Alice", timezone: "Not/AZone" }, - accountName: "Alice", - browserTimeZone: "America/Toronto", - }), - ).toEqual({ timeZone: "America/Toronto" }); - }); - - it("leaves out a language the user did not pick and malformed tags", () => { - expect( - onboardingProfileUpdate({ - answers: blank, - browserTimeZone: "UTC", + browserTimeZone: "Not/AZone", locale: "not a tag", language: null, }), - ).toEqual({ timeZone: "UTC" }); + ).toEqual({}); + expect(onboardingProfileUpdate({ browserTimeZone: "UTC" })).toEqual({ timeZone: "UTC" }); }); }); diff --git a/apps/web/src/lib/onboarding-profile.ts b/apps/web/src/lib/onboarding-profile.ts index 78d1d58..1d7d1de 100644 --- a/apps/web/src/lib/onboarding-profile.ts +++ b/apps/web/src/lib/onboarding-profile.ts @@ -1,73 +1,19 @@ -import type { UserFactCategory } from "@rakazo/contracts"; import { isLanguageTag, isTimeZone } from "@rakazo/contracts"; -export interface OnboardingAnswers { - name: string; - timezone: string; - hobbies: string[]; - workStyle: string; - communication: string; -} - -// Stored as data for the user's bots, so these stay plain English statements. -const HOBBY_FACTS: Record = { - coding: "Interested in coding", - design: "Interested in design", - writing: "Interested in writing", - music: "Interested in music", - gaming: "Interested in gaming", - reading: "Interested in reading", - sports: "Interested in sports", - cooking: "Interested in cooking", - tech: "Interested in tech and gadgets", -}; - -const WORK_STYLE_FACTS: Record = { - structured: "Works in a structured, planned way", - flexible: "Works flexibly and spontaneously", - "deep-dive": "Prefers focused deep dives", - quick: "Works quickly and pragmatically", -}; - -const COMMUNICATION_STYLES: Record = { - direct: "Direct and concise", - detailed: "Detailed and explanatory", - balanced: "Balanced", - casual: "Casual and friendly", -}; - -/** Turns onboarding answers into one profile update; empty or unknown answers are left out. */ +/** + * The profile fields onboarding fills in without asking: the browser's time zone and locale, + * and the language the user picked for their Oos. The name comes from the account. + */ export function onboardingProfileUpdate(input: { - answers: OnboardingAnswers; - /** The account name, which only changes when the user typed a different one. */ - accountName?: string; - /** Used when the typed time zone is empty or unknown. */ browserTimeZone: string; /** Browser locale, e.g. "de-AT". */ locale?: string; /** Language the user picked for their Oos, when they picked one. */ language?: string | null; }) { - const { answers } = input; - const name = answers.name.trim(); - const typedZone = answers.timezone.trim(); - const workStyle = WORK_STYLE_FACTS[answers.workStyle]; - const facts: Array<{ category: UserFactCategory; value: string }> = [ - ...answers.hobbies.flatMap((hobby) => { - const value = HOBBY_FACTS[hobby]; - return value ? [{ category: "preferences" as const, value }] : []; - }), - ...(workStyle ? [{ category: "habits" as const, value: workStyle }] : []), - ]; - const communicationStyle = COMMUNICATION_STYLES[answers.communication]; return { - ...(name && name !== input.accountName?.trim() ? { name } : {}), - timeZone: typedZone && isTimeZone(typedZone) ? typedZone : input.browserTimeZone, + ...(isTimeZone(input.browserTimeZone) ? { timeZone: input.browserTimeZone } : {}), ...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}), ...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}), - ...(communicationStyle ? { communicationStyle } : {}), - ...(facts.length > 0 - ? { addFacts: facts.map((fact) => ({ ...fact, source: "onboarding" as const })) } - : {}), }; } diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 75b9533..f21c80d 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -1,7 +1,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts"; -import { Button, Input } from "@rakazo/ui-web"; -import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { Button } from "@rakazo/ui-web"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { IntegrationSetup } from "../components/integrations/IntegrationSetup"; import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell"; @@ -24,7 +24,6 @@ import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-loo import { onboardingProfileUpdate } from "../lib/onboarding-profile"; import { rpc, selectSpace } from "../lib/rpc"; import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale"; -import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest"; /** Selects the main conversation's space and returns its route. */ @@ -53,57 +52,11 @@ function defaultEmojiConfig(): OnboardingEmojiConfig { }; } -interface UserPreferences { - name: string; - timezone: string; - hobbies: string[]; - workStyle: string; - communication: string; -} - -const HOBBY_OPTIONS = [ - { key: "coding", label: "Coding" }, - { key: "design", label: "Design" }, - { key: "writing", label: "Writing" }, - { key: "music", label: "Music" }, - { key: "gaming", label: "Gaming" }, - { key: "reading", label: "Reading" }, - { key: "sports", label: "Sports" }, - { key: "cooking", label: "Cooking" }, - { key: "tech", label: "Tech & Gadgets" }, - { key: "none", label: Nothing specific }, -]; - -const WORK_STYLE_OPTIONS = [ - { key: "structured", label: Structured & planned }, - { key: "flexible", label: Flexible & spontaneous }, - { key: "deep-dive", label: Deep-dive focused }, - { key: "quick", label: Quick & pragmatic }, -]; - -const COMMUNICATION_OPTIONS = [ - { key: "direct", label: Direct & concise }, - { key: "detailed", label: Detailed & explanatory }, - { key: "balanced", label: Balanced }, - { key: "casual", label: Casual & friendly }, -]; - -function defaultPreferences(): UserPreferences { - return { - name: "", - timezone: localTimezone(), - hobbies: [], - workStyle: "", - communication: "", - }; -} - export function OnboardingPage() { const { t } = useLingui(); const navigate = useNavigate(); - const fieldId = useId(); const [step, setStep] = useState< - "loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot" + "loading" | "emoji" | "language" | "voice" | "integrations" | "scan" | "bot" >("emoji"); const [selectedVoiceId, setSelectedVoiceId] = useState(""); const [voiceSkipped, setVoiceSkipped] = useState(false); @@ -114,10 +67,8 @@ export function OnboardingPage() { const [bootstrapMe, setBootstrapMe] = useState> | null>(null); const [profilePersisted, setProfilePersisted] = useState(false); const [crewReady, setCrewReady] = useState(false); - const [_helpVibe, setHelpVibe] = useState(null); const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); - const [prefs, setPrefs] = useState(defaultPreferences()); const [currentLocale, setCurrentLocale] = useState(() => { try { return localStorage.getItem("rakazo.uiLocale") ?? "en"; @@ -139,7 +90,6 @@ export function OnboardingPage() { .then(async ([me, integrations]) => { if (cancelled) return; setBootstrapMe(me); - setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me))); setIntegrationSetup(integrations); // The first step renders before this resolves; never pull a user back who moved on. if (!me.needsModel && integrations?.needsSetup) { @@ -169,17 +119,10 @@ export function OnboardingPage() { }; }, [navigate]); - function toggleHobby(key: string) { - const hobbies = prefs.hobbies.includes(key) - ? prefs.hobbies.filter((h) => h !== key) - : [...prefs.hobbies, key]; - setPrefs({ ...prefs, hobbies }); - } - const discoveryPhase: OnboardingDiscoveryPhase = step === "loading" ? "loading" - : step === "emoji" || step === "preferences" || step === "language" || step === "voice" + : step === "emoji" || step === "language" || step === "voice" ? "emoji" : step === "integrations" ? "integrations" @@ -220,8 +163,6 @@ export function OnboardingPage() { await rpc.profile .update( onboardingProfileUpdate({ - answers: prefs, - accountName: bootstrapMe?.name, browserTimeZone: localTimezone(), locale: typeof navigator !== "undefined" ? navigator.language : undefined, language: languageChoice, @@ -243,22 +184,15 @@ export function OnboardingPage() { const me = await rpc.me().catch(() => null); if (me) setBootstrapMe(me); - const harvested = await persistUserProfileHarvest( - rpc, - collectLocalProfileSignals(me ?? undefined), - ).catch(() => null); - if (harvested) setHelpVibe(harvested); + await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch( + () => null, + ); setProfilePersisted(true); for (const serverId of integrationServers) { await rpc.mcp.assignments.approve({ botId: bot.id, serverId }); } - const started = await rpc.onboarding - .start({ botId: bot.id }) - .then(() => true) - .catch(() => false); - if (started) { - await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); - } + // The main bot asks the rest in the main conversation. + await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); const main = await rpc.spaces .list() .then((navigation) => navigation.main) @@ -291,11 +225,7 @@ export function OnboardingPage() { {error && step !== "bot" ?

{error}

: null} @@ -304,140 +234,10 @@ export function OnboardingPage() { setStep("preferences")} + onContinue={() => setStep("language")} /> ) : null} - {step === "preferences" ? ( -
-

- Tell us about yourself -

-

- A few quick preferences so your Oo can be better tailored to you. -

- -
- - setPrefs({ ...prefs, name: e.target.value })} - placeholder={t`Your name`} - className="mt-2" - /> -
- -
- - setPrefs({ ...prefs, timezone: e.target.value })} - placeholder={t`Auto-detected or enter manually`} - className="mt-2" - /> -
- -
- - Hobbies & interests - -

- Select all that apply -

-
- {HOBBY_OPTIONS.map((hobby) => ( - - ))} -
-
- -
- - Work style - -

- How do you like to work? -

-
- {WORK_STYLE_OPTIONS.map((ws) => ( - - ))} -
-
- -
- - Communication style - -

- How should your Oo talk to you? -

-
- {COMMUNICATION_OPTIONS.map((comm) => ( - - ))} -
-
- -
- - -
-
- ) : null} - {step === "language" ? (

@@ -468,7 +268,7 @@ export function OnboardingPage() { ))}

-
) : null} -
- {block.options - .filter((option) => !block.answerId || option.id === block.answerId) - .map((option) => ( - + ))} +
+ ) : null} + {block.textInput ? ( +
{ + event.preventDefault(); + const words = text.trim(); + if (words) void choose(CHOICE_TEXT_ANSWER_ID, words); + }} > - - {option.letter} - - - {option.label} - - {block.answerId === option.id ? ( - ✓ - ) : null} - - ))} - + + setText(event.target.value)} + placeholder={block.textInput.placeholder} + aria-label={block.question} + maxLength={200} + disabled={pending} + dir="auto" + /> + + + + + + +
+ ) : null} + {waysOut.length > 0 ? ( +
+ {waysOut.map((option) => ( + + ))} +
+ ) : null} + + )} {error ?

{error}

: null} - + ); } +function ChoiceLetter({ letter, selected = false }: { letter: string; selected?: boolean }) { + return ( + + {letter} + + ); +} + export function AppConnectCard({ botId, block, diff --git a/packages/adapters/src/executor-instructions.test.ts b/packages/adapters/src/executor-instructions.test.ts index da9dc0a..5e3cf74 100644 --- a/packages/adapters/src/executor-instructions.test.ts +++ b/packages/adapters/src/executor-instructions.test.ts @@ -24,6 +24,7 @@ interface FixtureOptions { goals?: Array>; habits?: Array>; doneCheckIns?: Array<{ habitId: string; date: Date }>; + onboarding?: { completedAt: Date | null } | null; peers?: Array<{ id: string; name: string; @@ -116,7 +117,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 @@ -400,6 +406,19 @@ describe("run instructions", () => { expect(specialist.request.tools.map((tool) => tool.name)).not.toContain("update_goal"); }); + 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 71c3f14..4570495 100644 --- a/packages/adapters/src/executor-prompt.test.ts +++ b/packages/adapters/src/executor-prompt.test.ts @@ -18,6 +18,7 @@ const user: UserContext = { { category: "work", value: "Owns a bakery", confidence: 1 }, { category: "preferences", value: "Prefers morning meetings", confidence: 0.8 }, ], + onboardingOpen: false, }; describe("formatBotIdentity", () => { @@ -96,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"); @@ -112,6 +114,7 @@ describe("formatUserContext", () => { communicationStyle: "", summary: "", facts: [], + onboardingOpen: false, }), ).toBeUndefined(); }); diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 5bdddea..3ebe394 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -68,6 +68,13 @@ function formatCoachGuidance(toolNames: ReadonlySet): Array; +/** Questions the main bot asks a new user, one card at a time, in the main conversation. */ +export const ONBOARDING_STEPS = [ + "focus", + "goal", + "rhythm", + "quiet_hours", + "notifications", +] as const; +export const OnboardingStepSchema = z.enum(ONBOARDING_STEPS); +export type OnboardingStep = z.infer; + +/** `optionId` of a choice answered in the user's own words. */ +export const CHOICE_TEXT_ANSWER_ID = "_text"; + /** A question or approval that pauses its run until the user answers. */ export const AskBlockSchema = z.object({ kind: z.literal("ask"), @@ -134,9 +148,16 @@ export const MessageBlock = z.discriminatedUnion("kind", [ kind: z.literal("choice"), question: z.string(), subtitle: z.string().optional(), + /** An option without a letter is a secondary answer such as "Skip". */ options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })), + /** The onboarding step this card asks; absent on a specialist's focus card. */ + step: OnboardingStepSchema.optional(), + /** Lets the user answer in their own words. */ + textInput: z.object({ placeholder: z.string() }).optional(), /** Set once the user picks an option; renders the picker as answered. */ answerId: z.string().optional(), + /** The user's words when they answered through the text input. */ + answerText: z.string().optional(), }), z.object({ /** Inline app authorization card (Composio-backed): logo, name, one-line diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index d9fdae9..960b36f 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 { CheckInSchema, CoachRhythmSchema, @@ -90,7 +91,7 @@ import { VoiceInfoSchema, VoiceStatusSchema, } from "./domain.js"; -import { DelegatedTaskStatusSchema, ProductEventSchema } from "./events.js"; +import { DelegatedTaskStatusSchema, OnboardingStepSchema, ProductEventSchema } from "./events.js"; import { Id, IsoDate } from "./ids.js"; import { InboxListOutputSchema } from "./inbox.js"; import { @@ -713,15 +714,31 @@ export const appContract = { }, }, onboarding: { - /** Seed the first-run greeting into the bot's thread (focus card is separate). */ + /** Checks that the caller may onboard this bot's thread. Posts nothing. */ start: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })), - /** Post the focus choice card when the thread is still idle. */ + /** + * Posts the first card: for the main bot the first onboarding question, once per user; + * for another bot its focus card while the thread is still idle. + */ promptFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })), - /** Answer the focus choice; posts the app cards. Does not rename the bot. */ + /** + * Answers the pending card and posts what follows it: the focus card's app suggestions, + * then the next onboarding question. A stale answer (another step is pending) is ignored. + */ choose: oc - .input(z.object({ botId: Id, optionId: z.string() })) + .input( + z.object({ + botId: Id, + step: OnboardingStepSchema.optional(), + optionId: z.string(), + /** 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(), + }), + ) .output(z.object({ ok: z.literal(true) })), - /** Dismiss the unanswered focus card without choosing an option. */ + /** Dismisses the pending card; an onboarding question counts as skipped. */ dismissFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })), /** Flip an app_connect card to connected after authorization completes. */ appConnected: oc diff --git a/packages/core/src/attachments.test.ts b/packages/core/src/attachments.test.ts index 0a3d661..45653a0 100644 --- a/packages/core/src/attachments.test.ts +++ b/packages/core/src/attachments.test.ts @@ -109,3 +109,27 @@ describe("peer message history", () => { ).toBe("[to Analyst] chart it"); }); }); + +describe("choice card history", () => { + const card = { + kind: "choice" as const, + question: "When should I check in with you?", + options: [ + { id: "morning", letter: "A", label: "Every morning" }, + { id: "none", letter: "", label: "Not now" }, + ], + }; + + it("keeps the question and the answer the user picked or typed", () => { + expect(blocksToAgentHistoryText([card])).toBe("When should I check in with you?"); + expect(blocksToAgentHistoryText([{ ...card, answerId: "morning" }])).toBe( + "When should I check in with you?\n[answered: Every morning]", + ); + expect( + blocksToAgentHistoryText([{ ...card, answerId: "_text", answerText: "Before lunch" }]), + ).toBe("When should I check in with you?\n[answered: Before lunch]"); + expect(blocksToAgentHistoryText([{ ...card, answerId: "_dismissed" }])).toBe( + "When should I check in with you?", + ); + }); +}); diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts index 9afe692..7a3a666 100644 --- a/packages/core/src/attachments.ts +++ b/packages/core/src/attachments.ts @@ -105,6 +105,12 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string { if (block.kind === "handoff") { return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`; } + // A card's question and answer, so a later turn knows what the user already chose. + if (block.kind === "choice") { + const answer = + block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label; + return answer ? `${block.question}\n[answered: ${answer}]` : block.question; + } if ("text" in block && typeof block.text === "string") return block.text; return ""; }) diff --git a/packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql b/packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql new file mode 100644 index 0000000..1ebd459 --- /dev/null +++ b/packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "user_onboarding" ( + "userId" TEXT NOT NULL, + "step" TEXT, + "focus" TEXT, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_onboarding_pkey" PRIMARY KEY ("userId") +); + +-- AddForeignKey +ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- The table is new and empty, so its constraint applies immediately. +ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_step_chk" CHECK ("step" IS NULL OR "step" IN ('focus', 'goal', 'rhythm', 'quiet_hours', 'notifications')); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 2ea4b8d..3cf7d37 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -46,6 +46,7 @@ model User { botSecrets BotSecret[] agentSecrets AgentSecret[] profile UserProfile? + onboarding UserOnboarding? facts UserFact[] goals UserGoal[] habits Habit[] @@ -1477,6 +1478,21 @@ model UserProfile { @@map("user_profiles") } +/// Where the user is in the onboarding questions the main bot asks in the main conversation. +model UserOnboarding { + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// The step whose card is waiting for an answer; null once onboarding is complete. + step String? + focus String? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // CHECK constraint user_onboarding_step_chk is created in SQL. + @@map("user_onboarding") +} + /// One durable statement about the user, with its origin and consent state. model UserFact { id String @id @default(cuid()) diff --git a/packages/db/src/main-agent.ts b/packages/db/src/main-agent.ts index 6546da4..dd1f606 100644 --- a/packages/db/src/main-agent.ts +++ b/packages/db/src/main-agent.ts @@ -42,6 +42,8 @@ export interface UserContext { communicationStyle: string; summary: string; facts: Array<{ category: UserFactCategory; value: string; confidence: number }>; + /** The main bot is still asking its onboarding questions on cards in the main conversation. */ + onboardingOpen: boolean; } function isUniqueViolation(error: unknown): boolean { @@ -258,7 +260,10 @@ export async function loadUserContext( input: { userId: string; spaceId: string }, ): Promise { const [user, profile, facts] = await Promise.all([ - prisma.user.findUnique({ where: { id: input.userId }, select: { name: true } }), + prisma.user.findUnique({ + where: { id: input.userId }, + select: { name: true, onboarding: { select: { completedAt: true } } }, + }), prisma.userProfile.findUnique({ where: { userId: input.userId } }), prisma.userFact.findMany({ where: { @@ -283,5 +288,6 @@ export async function loadUserContext( value: fact.value, confidence: fact.confidence, })), + onboardingOpen: Boolean(user?.onboarding && !user.onboarding.completedAt), }; } diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 3d4ca0a..b2962c2 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -108,6 +108,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/testkit/src/onboarding-discovery.postgres.test.ts", "packages/testkit/src/notifications.postgres.test.ts", "packages/db/src/space-membership.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; +}