From f4e02eb2485f99f841757672b2f74ba8efadeda0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:17:06 +0000 Subject: [PATCH 1/3] feat(onboarding): ask the onboarding questions in the main conversation The wizard asked for the account name again and a "Tell us about yourself" form, and the focus answer in the main conversation was never saved. The main bot now gets to know the user in the main conversation instead, one card after each answer. - UserOnboarding keeps the pending step, the focus and when onboarding finished. - onboarding-intake.ts declares the steps (focus, goal, rhythm, quiet hours, notifications) as cards plus what their answers change; onboarding.ts commits an answer, the focus app suggestions and the next card or the closing line in one transaction. - The choice block gains a step, an optional text input and the typed answer; choose takes the step, the text and whether the device can still turn push on. - The focus answer becomes a goals fact; the goal, rhythm and quiet-hours effects and the push seam are placeholders until the coach and notification modules are merged. - The wizard drops the preferences step and fills time zone, locale and language from the browser; the main bot's prompt notes open onboarding cards; agent history shows a card's question and answer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/onboarding-intake.ts | 318 +++++++++++++ apps/api/src/onboarding.ts | 443 ++++++++++-------- apps/api/src/router.ts | 2 +- apps/web/src/lib/onboarding-profile.test.ts | 45 +- apps/web/src/lib/onboarding-profile.ts | 64 +-- apps/web/src/lib/onboarding-push.ts | 14 + apps/web/src/pages/Onboarding.tsx | 224 +-------- apps/web/src/pages/shell/message-cards.tsx | 191 ++++++-- packages/adapters/src/executor-prompt.test.ts | 1 + packages/adapters/src/executor-prompt.ts | 7 + packages/adapters/src/executor.ts | 6 + packages/contracts/src/events.ts | 21 + packages/contracts/src/rpc.ts | 28 +- packages/core/src/attachments.test.ts | 24 + packages/core/src/attachments.ts | 6 + .../migration.sql | 17 + packages/db/prisma/schema.prisma | 16 + packages/db/src/main-agent.ts | 8 +- 18 files changed, 876 insertions(+), 559 deletions(-) create mode 100644 apps/api/src/onboarding-intake.ts create mode 100644 apps/web/src/lib/onboarding-push.ts create mode 100644 packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts new file mode 100644 index 0000000..036126a --- /dev/null +++ b/apps/api/src/onboarding-intake.ts @@ -0,0 +1,318 @@ +import type { MessageBlock, OnboardingStep } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; +import { knownTimeZone, nextCronDateAcross } from "@rakazo/core"; +import { type Prisma, type PrismaClient, updateUserProfile } from "@rakazo/db"; + +/** + * 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]; + +/** When the main bot checks in; the same shape the coach rhythm stores. */ +export interface CheckInRhythm { + morning: string | null; + evening: string | null; + weekly: { day: number; time: string } | null; +} + +export interface QuietHours { + start: string; + end: string; +} + +/** A validated answer: an option of the card, or the user's own words. */ +export type IntakeAnswer = + | { option: ChoiceOption; text?: undefined } + | { 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; + 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; + summary: 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", + summary: "Slack, calendar, and email", + apps: ["slack", "gmail", "googlecalendar"], + fact: "Wants help with day-to-day work first", + }, + { + id: "inbox", + letter: "B", + label: "Inbox & email", + summary: "email and calendar", + apps: ["gmail", "googlecalendar", "slack"], + fact: "Wants help with inbox and email first", + }, + { + id: "research", + letter: "C", + label: "Research & writing", + summary: "the web, notes, and docs", + apps: ["hackernews", "notion", "googledocs"], + fact: "Wants help with research and writing first", + }, + { + id: "crew", + letter: "D", + label: "Specialist crew", + summary: "a coordinated team of focused Oos", + 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" }, +}; + +// Coach and notification effects. They land with the coach (goals, rhythm) and notification +// settings modules; until then the answer stays on its card. +async function saveGoal(_effects: IntakeEffects, _title: string): Promise {} +async function saveRhythm(_effects: IntakeEffects, _rhythm: CheckInRhythm): Promise {} +async function saveQuietHours(_effects: IntakeEffects, _quietHours: QuietHours): Promise {} + +export const INTAKE_STEPS: IntakeStep[] = [ + { + id: "focus", + 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 (effects, answer) => { + if (answer.text) await saveGoal(effects, answer.text); + }, + }, + { + 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 (effects, answer) => { + const rhythm = answer.option && RHYTHMS[answer.option.id]; + if (rhythm) await saveRhythm(effects, 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 (effects, answer) => { + const quietHours = answer.option && QUIET_HOURS[answer.option.id]; + if (quietHours) await saveQuietHours(effects, 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); +} + +export function rhythmForAnswer(answerId: string | undefined): CheckInRhythm | null { + return (answerId && RHYTHMS[answerId]) || null; +} + +/** Card times and the closing line follow the user's profile, falling back to English and UTC. */ +export function intakeContext( + profile: { locale: string | null; timeZone: string | null } | null, + 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 rhythmCrons(rhythm: CheckInRhythm): string[] { + const cron = (time: string, day = "*") => { + const [hour, minute] = time.split(":").map(Number); + return `${minute} ${hour} * * ${day}`; + }; + return [ + ...(rhythm.morning ? [cron(rhythm.morning)] : []), + ...(rhythm.evening ? [cron(rhythm.evening)] : []), + ...(rhythm.weekly ? [cron(rhythm.weekly.time, String(rhythm.weekly.day))] : []), + ]; +} + +function localDay(date: Date, timeZone: string): number { + const [year, month, day] = new Intl.DateTimeFormat("en-CA", { + timeZone, + 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: CheckInRhythm | null, context: IntakeContext): string { + const next = rhythm + ? nextCronDateAcross(rhythmCrons(rhythm), context.now, context.timeZone) + : null; + 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.ts b/apps/api/src/onboarding.ts index b0bf565..8163991 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -1,5 +1,7 @@ +import { ORPCError } from "@orpc/server"; import type { ConnectorRegistry } from "@rakazo/adapters"; -import type { Actor, MessageBlock } from "@rakazo/contracts"; +import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { featuredConnectorProvidersMatch } from "@rakazo/core"; import { appendEventInTransaction, @@ -10,12 +12,27 @@ 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, + rhythmForAnswer, + 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 = { @@ -24,44 +41,7 @@ type OnboardingDeps = { connectors: ConnectorRegistry; }; -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 +65,98 @@ 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 }, +async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise { + await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR 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. */ +async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) { + const messages = await client.message.findMany({ + where: { threadId, role: "bot" }, + select: { id: true, blocks: true }, + orderBy: { seq: "desc" }, + take: 50, + }); + 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 +164,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 +196,143 @@ 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; + if (!pending.card.step && !focus) { + throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" }); + } + // Fetched before the transaction: the connector catalog can take seconds. + const followUp = focus + ? await focusFollowUp(deps, actor, bot.id, focus, Boolean(pending.card.step)) + : []; - 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, 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(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( + 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)]); + } + const rhythmCard = await tx.message.findFirst({ + where: { + threadId: target.threadId, + role: "bot", + blocks: { array_contains: [{ kind: "choice", step: "rhythm" }] }, }, - ]); + orderBy: { seq: "desc" }, + select: { blocks: true }, + }); + const rhythm = rhythmForAnswer( + (rhythmCard?.blocks as MessageBlock[] | undefined)?.find(isChoice)?.answerId, + ); + 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 +343,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 +370,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 +425,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 2baf21e..a5b59dc 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -3417,7 +3417,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/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/lib/onboarding-push.ts b/apps/web/src/lib/onboarding-push.ts new file mode 100644 index 0000000..bedb187 --- /dev/null +++ b/apps/web/src/lib/onboarding-push.ts @@ -0,0 +1,14 @@ +/** + * Push for the onboarding notifications card. The server offers the card only when the browser + * that answered the card before it could still turn push on, and tapping "Turn on" is the user + * gesture that subscribes before the card is answered. Both wait for the web push client + * (`lib/web-push.ts`); until it lands the card is never offered. + */ +export async function canOfferPush(): Promise { + return false; +} + +/** Subscribes this browser; true when push is on afterwards. */ +export async function turnOnPush(): Promise { + return false; +} diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 0423eaa..d740dfe 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,11 +67,9 @@ 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 [scanBotId, setScanBotId] = useState(null); const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); - const [prefs, setPrefs] = useState(defaultPreferences()); const [currentLocale, setCurrentLocale] = useState(() => { try { return localStorage.getItem("rakazo.uiLocale") ?? "en"; @@ -140,7 +91,6 @@ export function OnboardingPage() { .then(async ([me, integrations]) => { if (cancelled) return; setBootstrapMe(me); - setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me))); // Get bot ID for scan phase try { const botList = await rpc.bots.list(); @@ -178,17 +128,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" @@ -229,8 +172,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, @@ -252,22 +193,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) @@ -300,11 +234,7 @@ export function OnboardingPage() { {error && step !== "bot" ?

{error}

: null} @@ -313,140 +243,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" ? (

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