Merge the conversational onboarding intake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
e72b169ca4
98
apps/api/src/onboarding-intake.test.ts
Normal file
98
apps/api/src/onboarding-intake.test.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
288
apps/api/src/onboarding-intake.ts
Normal file
288
apps/api/src/onboarding-intake.ts
Normal file
@ -0,0 +1,288 @@
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters";
|
||||
import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts";
|
||||
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||
import { knownTimeZone, nextCronDateAcross } from "@rakazo/core";
|
||||
import {
|
||||
createGoal,
|
||||
type Prisma,
|
||||
type PrismaClient,
|
||||
updateNotificationSettings,
|
||||
updateUserProfile,
|
||||
} from "@rakazo/db";
|
||||
|
||||
/**
|
||||
* The questions the main bot asks a new user in the main conversation, one card at a time.
|
||||
* Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order
|
||||
* the user sees. Everything here is deterministic, so no model tokens are spent.
|
||||
*/
|
||||
|
||||
export type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
|
||||
type ChoiceOption = ChoiceBlock["options"][number];
|
||||
|
||||
/** A validated answer: an option of the card, or the user's own words. */
|
||||
export type IntakeAnswer =
|
||||
| { option: ChoiceOption; text?: undefined }
|
||||
| { option?: undefined; text: string };
|
||||
|
||||
/** What a card may adapt to: times are written in the user's locale. */
|
||||
export interface IntakeContext {
|
||||
locale: string;
|
||||
timeZone: string;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface IntakeEffects {
|
||||
prisma: PrismaClient;
|
||||
jobs: JobPublisher;
|
||||
tx: Prisma.TransactionClient;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface IntakeStep {
|
||||
id: OnboardingStep;
|
||||
/** Asked only when this holds; otherwise onboarding moves past the step. */
|
||||
asks?: (device: { canEnablePush: boolean }) => boolean;
|
||||
card: (context: IntakeContext) => ChoiceBlock;
|
||||
/** Records a validated answer. Runs while the main conversation is locked. */
|
||||
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FocusOption {
|
||||
id: string;
|
||||
letter: string;
|
||||
label: string;
|
||||
apps: string[];
|
||||
/** Stored as a `goals` fact, so it stays a plain English statement. */
|
||||
fact: string;
|
||||
}
|
||||
|
||||
export const FOCUS_OPTIONS: FocusOption[] = [
|
||||
{
|
||||
id: "day",
|
||||
letter: "A",
|
||||
label: "Day-to-day work",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
fact: "Wants help with day-to-day work first",
|
||||
},
|
||||
{
|
||||
id: "inbox",
|
||||
letter: "B",
|
||||
label: "Inbox & email",
|
||||
apps: ["gmail", "googlecalendar", "slack"],
|
||||
fact: "Wants help with inbox and email first",
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
letter: "C",
|
||||
label: "Research & writing",
|
||||
apps: ["hackernews", "notion", "googledocs"],
|
||||
fact: "Wants help with research and writing first",
|
||||
},
|
||||
{
|
||||
id: "crew",
|
||||
letter: "D",
|
||||
label: "Specialist crew",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
fact: "Wants a coordinated crew of specialist bots",
|
||||
},
|
||||
];
|
||||
|
||||
const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" };
|
||||
|
||||
/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */
|
||||
export function focusCard(step?: "focus"): ChoiceBlock {
|
||||
return {
|
||||
kind: "choice",
|
||||
question: "What do you want me on first?",
|
||||
options: [
|
||||
...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
||||
...(step ? [SKIP] : []),
|
||||
],
|
||||
...(step ? { step } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const RHYTHMS: Record<string, CoachRhythm> = {
|
||||
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<string, QuietHours> = {
|
||||
"22-07": { start: "22:00", end: "07:00" },
|
||||
"23-08": { start: "23:00", end: "08:00" },
|
||||
};
|
||||
|
||||
export const INTAKE_STEPS: IntakeStep[] = [
|
||||
{
|
||||
id: "focus",
|
||||
card: () => focusCard("focus"),
|
||||
save: async ({ prisma, tx, userId }, answer) => {
|
||||
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
|
||||
if (!option) return;
|
||||
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
|
||||
await updateUserProfile(prisma, userId, {
|
||||
addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "goal",
|
||||
card: () => ({
|
||||
kind: "choice",
|
||||
step: "goal",
|
||||
question: "What’s one thing you want to get done in the next few weeks?",
|
||||
textInput: { placeholder: "Your goal" },
|
||||
options: [SKIP],
|
||||
}),
|
||||
save: async ({ prisma, userId }, answer) => {
|
||||
if (answer.text)
|
||||
await createGoal(prisma, userId, { title: answer.text, source: "onboarding" });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rhythm",
|
||||
card: () => ({
|
||||
kind: "choice",
|
||||
step: "rhythm",
|
||||
question: "When should I check in with you?",
|
||||
options: [
|
||||
{ id: "morning", letter: "A", label: "Every morning" },
|
||||
{ id: "morning_evening", letter: "B", label: "Morning and evening" },
|
||||
{ id: "weekly", letter: "C", label: "Once a week" },
|
||||
{ id: "none", letter: "", label: "Not now" },
|
||||
],
|
||||
}),
|
||||
save: async ({ prisma, jobs, userId }, answer) => {
|
||||
const rhythm = answer.option && RHYTHMS[answer.option.id];
|
||||
if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "quiet_hours",
|
||||
card: (context) => ({
|
||||
kind: "choice",
|
||||
step: "quiet_hours",
|
||||
question: "When should I stay quiet?",
|
||||
options: [
|
||||
...Object.entries(QUIET_HOURS).map(([id, range], index) => ({
|
||||
id,
|
||||
letter: String.fromCharCode(65 + index),
|
||||
label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`,
|
||||
})),
|
||||
SKIP,
|
||||
],
|
||||
}),
|
||||
save: async ({ prisma, userId }, answer) => {
|
||||
const quietHours = answer.option && QUIET_HOURS[answer.option.id];
|
||||
if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours });
|
||||
},
|
||||
},
|
||||
{
|
||||
// The browser subscribes when the user taps "Turn on"; the answer only moves on.
|
||||
id: "notifications",
|
||||
asks: ({ canEnablePush }) => canEnablePush,
|
||||
card: () => ({
|
||||
kind: "choice",
|
||||
step: "notifications",
|
||||
question: "Can I send you notifications?",
|
||||
options: [
|
||||
{ id: "on", letter: "A", label: "Turn on" },
|
||||
{ id: "later", letter: "", label: "Not now" },
|
||||
],
|
||||
}),
|
||||
save: async () => {},
|
||||
},
|
||||
];
|
||||
|
||||
export function intakeStep(id: OnboardingStep): IntakeStep {
|
||||
const step = INTAKE_STEPS.find((entry) => entry.id === id);
|
||||
if (!step) throw new Error(`Unknown onboarding step: ${id}`);
|
||||
return step;
|
||||
}
|
||||
|
||||
/** The step after `id` that applies to this device, or null when onboarding is done. */
|
||||
export function nextIntakeStep(
|
||||
id: OnboardingStep,
|
||||
device: { canEnablePush: boolean },
|
||||
): IntakeStep | null {
|
||||
const index = INTAKE_STEPS.findIndex((entry) => entry.id === id);
|
||||
return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null;
|
||||
}
|
||||
|
||||
/** Checks an answer against the card it answers; null when the card does not offer it. */
|
||||
export function readIntakeAnswer(
|
||||
card: ChoiceBlock,
|
||||
optionId: string,
|
||||
text: string | undefined,
|
||||
): IntakeAnswer | null {
|
||||
if (optionId === CHOICE_TEXT_ANSWER_ID) {
|
||||
const words = text?.trim();
|
||||
return card.textInput && words ? { text: words } : null;
|
||||
}
|
||||
const option = card.options.find((entry) => entry.id === optionId);
|
||||
return option ? { option } : null;
|
||||
}
|
||||
|
||||
/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */
|
||||
export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined {
|
||||
return card.options.find((option) => !option.letter);
|
||||
}
|
||||
|
||||
/** Card times and the closing line follow the user's profile, falling back to English and UTC. */
|
||||
export function intakeContext(
|
||||
profile: { locale: string | null; timeZone: string | null } | null,
|
||||
now: Date,
|
||||
): IntakeContext {
|
||||
let locale = "en";
|
||||
try {
|
||||
if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en";
|
||||
} catch {
|
||||
// Not a language tag; keep English.
|
||||
}
|
||||
return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now };
|
||||
}
|
||||
|
||||
function formatClock(time: string, locale: string): string {
|
||||
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(Date.UTC(2000, 0, 1, hour, minute)));
|
||||
}
|
||||
|
||||
function localDay(date: Date, timeZone: string): number {
|
||||
const [year, month, day] = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
.format(date)
|
||||
.split("-")
|
||||
.map(Number);
|
||||
return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000;
|
||||
}
|
||||
|
||||
/** One line that says what happens next: the first check-in, when there is one. */
|
||||
export function closingLine(rhythm: CoachRhythm, context: IntakeContext): string {
|
||||
const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []);
|
||||
const next = nextCronDateAcross(crons, context.now, context.timeZone);
|
||||
if (!next) return "All set. I’m here whenever you need me.";
|
||||
const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone);
|
||||
const day =
|
||||
days === 0
|
||||
? "today"
|
||||
: days === 1
|
||||
? "tomorrow"
|
||||
: `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`;
|
||||
const time = new Intl.DateTimeFormat(context.locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: context.timeZone,
|
||||
}).format(next);
|
||||
return `All set. I’ll check in ${day} at ${time}.`;
|
||||
}
|
||||
@ -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." },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import type { ConnectorRegistry } from "@rakazo/adapters";
|
||||
import type { Actor, MessageBlock } from "@rakazo/contracts";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import { type ConnectorRegistry, getCoachRhythm } from "@rakazo/adapters";
|
||||
import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts";
|
||||
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||
import { featuredConnectorProvidersMatch } from "@rakazo/core";
|
||||
import {
|
||||
appendEventInTransaction,
|
||||
@ -10,58 +13,36 @@ import {
|
||||
type PrismaClient,
|
||||
type ThreadEvents,
|
||||
} from "@rakazo/db";
|
||||
import {
|
||||
type ChoiceBlock,
|
||||
closingLine,
|
||||
FOCUS_OPTIONS,
|
||||
type FocusOption,
|
||||
focusCard,
|
||||
type IntakeAnswer,
|
||||
type IntakeContext,
|
||||
intakeContext,
|
||||
intakeStep,
|
||||
nextIntakeStep,
|
||||
readIntakeAnswer,
|
||||
secondaryOption,
|
||||
} from "./onboarding-intake.js";
|
||||
|
||||
/**
|
||||
* First-run conversational onboarding, seeded deterministically into the bot's
|
||||
* thread: greeting, a focus choice, and available app cards the user authorizes
|
||||
* inline. Focus must not rename the bot. No model tokens are spent. The main bot
|
||||
* talks in the main conversation, where onboarding lands.
|
||||
* Conversational onboarding, posted deterministically into a bot's thread; no model tokens are
|
||||
* spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main
|
||||
* conversation, one card after each answer. Any other bot gets one focus card that suggests
|
||||
* apps the user authorizes inline. Focus never renames a bot.
|
||||
*/
|
||||
|
||||
type OnboardingDeps = {
|
||||
prisma: PrismaClient;
|
||||
events: ThreadEvents;
|
||||
connectors: ConnectorRegistry;
|
||||
jobs: JobPublisher;
|
||||
};
|
||||
|
||||
type FocusOption = {
|
||||
id: string;
|
||||
letter: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
apps: string[];
|
||||
};
|
||||
|
||||
const FOCUS_OPTIONS: FocusOption[] = [
|
||||
{
|
||||
id: "day",
|
||||
letter: "A",
|
||||
label: "Day-to-day work",
|
||||
summary: "Slack, calendar, and email",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
},
|
||||
{
|
||||
id: "inbox",
|
||||
letter: "B",
|
||||
label: "Inbox & email",
|
||||
summary: "email and calendar",
|
||||
apps: ["gmail", "googlecalendar", "slack"],
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
letter: "C",
|
||||
label: "Research & writing",
|
||||
summary: "the web, notes, and docs",
|
||||
apps: ["hackernews", "notion", "googledocs"],
|
||||
},
|
||||
{
|
||||
id: "crew",
|
||||
letter: "D",
|
||||
label: "Specialist crew",
|
||||
summary: "a coordinated team of focused Oos",
|
||||
apps: ["slack", "gmail", "googlecalendar"],
|
||||
},
|
||||
];
|
||||
type Target = { spaceId: string; botId: string; threadId: string };
|
||||
|
||||
const APP_DESCRIPTIONS: Record<string, string> = {
|
||||
slack: "Search, read, and send messages.",
|
||||
@ -85,74 +66,102 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
|
||||
return { bot, thread: bot.thread };
|
||||
}
|
||||
|
||||
async function post(
|
||||
deps: OnboardingDeps,
|
||||
target: { spaceId: string; botId: string; threadId: string },
|
||||
/**
|
||||
* Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows
|
||||
* that reference the thread from another connection (the coach routines do).
|
||||
*/
|
||||
async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise<void> {
|
||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY UPDATE`;
|
||||
}
|
||||
|
||||
async function postInTransaction(
|
||||
tx: Prisma.TransactionClient,
|
||||
target: Target,
|
||||
blocks: MessageBlock[],
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const { thread } = await requireBotThread(deps, actor, botId);
|
||||
const existing = await deps.prisma.message.count({ where: { threadId: thread.id } });
|
||||
if (existing > 0) return;
|
||||
// Fresh chats start empty (same as web). The focus card is posted later via
|
||||
// promptFocus so non-first bots can wait ~10s for free typing, or skip if the
|
||||
// user already engaged.
|
||||
await requireBotThread(deps, actor, botId);
|
||||
}
|
||||
|
||||
function messageHasChoice(blocks: MessageBlock[]): boolean {
|
||||
return blocks.some((block) => block.kind === "choice");
|
||||
function isChoice(block: MessageBlock): block is ChoiceBlock {
|
||||
return block.kind === "choice";
|
||||
}
|
||||
|
||||
function messageHasPendingChoice(blocks: MessageBlock[]): boolean {
|
||||
return blocks.some((block) => block.kind === "choice" && !block.answerId);
|
||||
function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined {
|
||||
return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId);
|
||||
}
|
||||
|
||||
/** The newest card in the thread that still waits for an answer, however long ago it came. */
|
||||
async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) {
|
||||
const messages = await client.message.findMany({
|
||||
where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } },
|
||||
select: { id: true, blocks: true },
|
||||
orderBy: { seq: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
for (const message of messages) {
|
||||
const blocks = message.blocks as MessageBlock[];
|
||||
const card = pendingChoice(blocks);
|
||||
if (card) return { messageId: message.id, blocks, card };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the first card once. The main bot starts the onboarding questions, one set per user;
|
||||
* any other bot posts its focus card only while its thread is still idle.
|
||||
*/
|
||||
export async function promptFocus(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
@ -160,44 +169,31 @@ export async function promptFocus(
|
||||
): Promise<void> {
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
const blocks: MessageBlock[] = [
|
||||
{
|
||||
kind: "choice",
|
||||
question: "What do you want me on first?",
|
||||
options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
||||
},
|
||||
];
|
||||
// Check + insert + event in one transaction so concurrent promptFocus calls
|
||||
// cannot duplicate cards, and a concurrent user send cannot publish first.
|
||||
const main = bot.kind === "main";
|
||||
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards,
|
||||
// and a concurrent user send cannot publish first.
|
||||
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
// Serialize concurrent promptFocus callers on this thread before the gate check.
|
||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||
await lockThread(tx, thread.id);
|
||||
if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) {
|
||||
return null;
|
||||
}
|
||||
const recent = await tx.message.findMany({
|
||||
where: { threadId: thread.id },
|
||||
select: { role: true, blocks: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (recent.some((message) => message.role === "user")) return null;
|
||||
if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null;
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: target.threadId,
|
||||
role: "bot",
|
||||
blocks,
|
||||
botId: target.botId,
|
||||
});
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.created",
|
||||
payload: { messageId: message.id, role: "bot", blocks },
|
||||
});
|
||||
return { message, event };
|
||||
if (!main && recent.some((message) => message.role === "user")) return null;
|
||||
if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null;
|
||||
if (!main) return postInTransaction(tx, target, [focusCard()]);
|
||||
const first = intakeStep("focus");
|
||||
await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } });
|
||||
return postInTransaction(tx, target, [
|
||||
first.card(await loadIntakeContext(tx, actor.userId, new Date())),
|
||||
]);
|
||||
});
|
||||
if (!committed) return;
|
||||
await deps.events.notify(target.threadId, committed.event.seq);
|
||||
if (committed) await deps.events.notify(target.threadId, committed.seq);
|
||||
}
|
||||
|
||||
/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */
|
||||
export async function dismissFocus(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
@ -205,81 +201,131 @@ export async function dismissFocus(
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId);
|
||||
if (!option) throw new IsolationError();
|
||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||
const pending = await findPendingCard(deps.prisma, thread.id);
|
||||
if (!pending || pending.card.step !== input.step) return;
|
||||
const answer = readIntakeAnswer(pending.card, input.optionId, input.text);
|
||||
if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" });
|
||||
const focus =
|
||||
!pending.card.step || pending.card.step === "focus"
|
||||
? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id)
|
||||
: undefined;
|
||||
// Fetched before the transaction: the connector catalog can take seconds.
|
||||
const followUp = focus
|
||||
? await focusFollowUp(deps, actor, bot.id, focus, Boolean(pending.card.step))
|
||||
: [];
|
||||
|
||||
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
||||
const recent = await tx.message.findMany({
|
||||
where: { threadId: thread.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const pending = recent.find((message) =>
|
||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
||||
);
|
||||
if (!pending) return null;
|
||||
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
||||
block.kind === "choice" ? { ...block, answerId: option.id } : block,
|
||||
);
|
||||
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: target.spaceId,
|
||||
threadId: target.threadId,
|
||||
botId: target.botId,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId: pending.id, role: "bot", blocks },
|
||||
});
|
||||
return { messageId: pending.id, blocks, event };
|
||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await lockThread(tx, thread.id);
|
||||
const current = await findPendingCard(tx, thread.id);
|
||||
if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null;
|
||||
const onboarding = current.card.step
|
||||
? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } })
|
||||
: null;
|
||||
if (current.card.step) {
|
||||
if (onboarding?.step !== current.card.step) return null;
|
||||
await intakeStep(current.card.step).save(
|
||||
{ prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId },
|
||||
answer,
|
||||
);
|
||||
}
|
||||
const answered = answeredCard(current.card, answer);
|
||||
const blocks = current.blocks.map((block) => (block === current.card ? answered : block));
|
||||
let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||
for (const message of followUp) last = await postInTransaction(tx, target, message);
|
||||
if (current.card.step) {
|
||||
last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush);
|
||||
}
|
||||
return last;
|
||||
});
|
||||
if (!claimed) return;
|
||||
await deps.events.notify(target.threadId, claimed.event.seq);
|
||||
if (event) await deps.events.notify(target.threadId, event.seq);
|
||||
}
|
||||
|
||||
// Keep the name and title the user chose when creating the bot; the focus
|
||||
// step only suggests apps, it must not rename the bot.
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`,
|
||||
},
|
||||
]);
|
||||
function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock {
|
||||
return answer.option
|
||||
? { ...card, answerId: answer.option.id }
|
||||
: { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text };
|
||||
}
|
||||
|
||||
async function loadIntakeContext(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
now: Date,
|
||||
): Promise<IntakeContext> {
|
||||
const profile = await tx.userProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { locale: true, timeZone: true },
|
||||
});
|
||||
return intakeContext(profile, now);
|
||||
}
|
||||
|
||||
/** Posts the next onboarding question, or the closing line once every step is answered. */
|
||||
async function askNext(
|
||||
deps: OnboardingDeps,
|
||||
tx: Prisma.TransactionClient,
|
||||
target: Target,
|
||||
userId: string,
|
||||
answeredStep: OnboardingStep,
|
||||
canEnablePush = false,
|
||||
): Promise<{ seq: number }> {
|
||||
const now = new Date();
|
||||
const context = await loadIntakeContext(tx, userId, now);
|
||||
const next = nextIntakeStep(answeredStep, { canEnablePush });
|
||||
if (next) {
|
||||
await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } });
|
||||
return postInTransaction(tx, target, [next.card(context)]);
|
||||
}
|
||||
// The rhythm step saved its answer outside this transaction, so the routines are readable.
|
||||
const rhythm = await getCoachRhythm(deps.prisma, userId);
|
||||
await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } });
|
||||
return postInTransaction(tx, target, [{ kind: "text", text: closingLine(rhythm, context) }]);
|
||||
}
|
||||
|
||||
/** The messages after a focus answer: a short acknowledgement and the apps worth connecting. */
|
||||
async function focusFollowUp(
|
||||
deps: OnboardingDeps,
|
||||
actor: Actor,
|
||||
botId: string,
|
||||
option: FocusOption,
|
||||
onboarding: boolean,
|
||||
): Promise<MessageBlock[][]> {
|
||||
const providers = deps.connectors.managedProviders();
|
||||
const catalog = (
|
||||
await Promise.all(
|
||||
@ -290,14 +336,14 @@ export async function chooseFocus(
|
||||
traceId: "onboarding.choose",
|
||||
spaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
botId: bot.id,
|
||||
botId,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
.catch(() => []),
|
||||
),
|
||||
)
|
||||
).flat();
|
||||
const cards: MessageBlock[] = option.apps.flatMap((slug) => {
|
||||
const cards = option.apps.flatMap((slug) => {
|
||||
const entry = catalog.find(
|
||||
(item) =>
|
||||
item.slug.toLowerCase() === slug.toLowerCase() ||
|
||||
@ -317,26 +363,22 @@ export async function chooseFocus(
|
||||
];
|
||||
});
|
||||
if (!cards.length) {
|
||||
await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]);
|
||||
return;
|
||||
// In onboarding the next question follows; another bot asks what to start with.
|
||||
const text = onboarding ? "Got it." : "Got it. What would you like to work on first?";
|
||||
return [[{ kind: "text", text }]];
|
||||
}
|
||||
const cardNames = cards
|
||||
.map((card) => (card.kind === "app_connect" ? card.name : ""))
|
||||
.filter(Boolean);
|
||||
const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`;
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`,
|
||||
},
|
||||
]);
|
||||
await post(deps, target, cards);
|
||||
await post(deps, target, [
|
||||
{
|
||||
kind: "text",
|
||||
text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`,
|
||||
},
|
||||
]);
|
||||
const names = cards.map((card) => card.name);
|
||||
const named =
|
||||
names.length > 1 ? `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}` : names[0];
|
||||
return [
|
||||
[
|
||||
{
|
||||
kind: "text",
|
||||
text: `Got it. ${named} ${names.length > 1 ? "are" : "is"} a good place to start.`,
|
||||
},
|
||||
],
|
||||
cards,
|
||||
];
|
||||
}
|
||||
|
||||
export async function markAppConnected(
|
||||
@ -376,7 +418,3 @@ export async function markAppConnected(
|
||||
await updateBlocks(deps, target, message.id, next);
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
|
||||
}
|
||||
|
||||
@ -530,7 +530,12 @@ export function createRouter(deps: RouterDeps) {
|
||||
userAgent?: string;
|
||||
}>();
|
||||
const repos = createRepos(deps.prisma);
|
||||
const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors };
|
||||
const onboardingDeps = {
|
||||
prisma: deps.prisma,
|
||||
events: deps.events,
|
||||
connectors: deps.connectors,
|
||||
jobs: deps.jobs,
|
||||
};
|
||||
const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets);
|
||||
const groupRepos = createGroupRepos(deps.prisma);
|
||||
const projectGoalRepos = createProjectGoalRepos(deps.prisma);
|
||||
@ -3494,7 +3499,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
return { ok: true as const };
|
||||
}),
|
||||
choose: authed.onboarding.choose.handler(async ({ context, input }) => {
|
||||
await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId);
|
||||
await chooseFocus(onboardingDeps, context.actor, input.botId, input);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {
|
||||
|
||||
@ -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" }]),
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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();
|
||||
|
||||
83
apps/web/e2e/onboarding-intake.spec.ts
Normal file
83
apps/web/e2e/onboarding-intake.spec.ts
Normal file
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -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({
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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<Array<{ instructions: string }>>(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" }),
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 })) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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: <Trans>Nothing specific</Trans> },
|
||||
];
|
||||
|
||||
const WORK_STYLE_OPTIONS = [
|
||||
{ key: "structured", label: <Trans>Structured & planned</Trans> },
|
||||
{ key: "flexible", label: <Trans>Flexible & spontaneous</Trans> },
|
||||
{ key: "deep-dive", label: <Trans>Deep-dive focused</Trans> },
|
||||
{ key: "quick", label: <Trans>Quick & pragmatic</Trans> },
|
||||
];
|
||||
|
||||
const COMMUNICATION_OPTIONS = [
|
||||
{ key: "direct", label: <Trans>Direct & concise</Trans> },
|
||||
{ key: "detailed", label: <Trans>Detailed & explanatory</Trans> },
|
||||
{ key: "balanced", label: <Trans>Balanced</Trans> },
|
||||
{ key: "casual", label: <Trans>Casual & friendly</Trans> },
|
||||
];
|
||||
|
||||
function defaultPreferences(): UserPreferences {
|
||||
return {
|
||||
name: "",
|
||||
timezone: localTimezone(),
|
||||
hobbies: [],
|
||||
workStyle: "",
|
||||
communication: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function OnboardingPage() {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const fieldId = useId();
|
||||
const [step, setStep] = useState<
|
||||
"loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot"
|
||||
"loading" | "emoji" | "language" | "voice" | "integrations" | "scan" | "bot"
|
||||
>("emoji");
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState("");
|
||||
const [voiceSkipped, setVoiceSkipped] = useState(false);
|
||||
@ -114,10 +67,8 @@ export function OnboardingPage() {
|
||||
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
||||
const [profilePersisted, setProfilePersisted] = useState(false);
|
||||
const [crewReady, setCrewReady] = useState(false);
|
||||
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
||||
|
||||
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
||||
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
||||
const [currentLocale, setCurrentLocale] = useState<string>(() => {
|
||||
try {
|
||||
return localStorage.getItem("rakazo.uiLocale") ?? "en";
|
||||
@ -139,7 +90,6 @@ export function OnboardingPage() {
|
||||
.then(async ([me, integrations]) => {
|
||||
if (cancelled) return;
|
||||
setBootstrapMe(me);
|
||||
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
||||
setIntegrationSetup(integrations);
|
||||
// The first step renders before this resolves; never pull a user back who moved on.
|
||||
if (!me.needsModel && integrations?.needsSetup) {
|
||||
@ -169,17 +119,10 @@ export function OnboardingPage() {
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
function toggleHobby(key: string) {
|
||||
const hobbies = prefs.hobbies.includes(key)
|
||||
? prefs.hobbies.filter((h) => h !== key)
|
||||
: [...prefs.hobbies, key];
|
||||
setPrefs({ ...prefs, hobbies });
|
||||
}
|
||||
|
||||
const discoveryPhase: OnboardingDiscoveryPhase =
|
||||
step === "loading"
|
||||
? "loading"
|
||||
: step === "emoji" || step === "preferences" || step === "language" || step === "voice"
|
||||
: step === "emoji" || step === "language" || step === "voice"
|
||||
? "emoji"
|
||||
: step === "integrations"
|
||||
? "integrations"
|
||||
@ -220,8 +163,6 @@ export function OnboardingPage() {
|
||||
await rpc.profile
|
||||
.update(
|
||||
onboardingProfileUpdate({
|
||||
answers: prefs,
|
||||
accountName: bootstrapMe?.name,
|
||||
browserTimeZone: localTimezone(),
|
||||
locale: typeof navigator !== "undefined" ? navigator.language : undefined,
|
||||
language: languageChoice,
|
||||
@ -243,22 +184,15 @@ export function OnboardingPage() {
|
||||
|
||||
const me = await rpc.me().catch(() => null);
|
||||
if (me) setBootstrapMe(me);
|
||||
const harvested = await persistUserProfileHarvest(
|
||||
rpc,
|
||||
collectLocalProfileSignals(me ?? undefined),
|
||||
).catch(() => null);
|
||||
if (harvested) setHelpVibe(harvested);
|
||||
await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch(
|
||||
() => null,
|
||||
);
|
||||
setProfilePersisted(true);
|
||||
for (const serverId of integrationServers) {
|
||||
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
|
||||
}
|
||||
const started = await rpc.onboarding
|
||||
.start({ botId: bot.id })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (started) {
|
||||
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
||||
}
|
||||
// The main bot asks the rest in the main conversation.
|
||||
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
||||
const main = await rpc.spaces
|
||||
.list()
|
||||
.then((navigation) => navigation.main)
|
||||
@ -291,11 +225,7 @@ export function OnboardingPage() {
|
||||
<OnboardingDiscoveryShell
|
||||
items={discoveryItems}
|
||||
compact={
|
||||
step === "emoji" ||
|
||||
step === "preferences" ||
|
||||
step === "language" ||
|
||||
step === "voice" ||
|
||||
step === "integrations"
|
||||
step === "emoji" || step === "language" || step === "voice" || step === "integrations"
|
||||
}
|
||||
>
|
||||
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
||||
@ -304,140 +234,10 @@ export function OnboardingPage() {
|
||||
<OnboardingOoDesigner
|
||||
config={emojiConfig}
|
||||
onChange={setEmojiConfig}
|
||||
onContinue={() => setStep("preferences")}
|
||||
onContinue={() => setStep("language")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "preferences" ? (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>Tell us about yourself</Trans>
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Trans>A few quick preferences so your Oo can be better tailored to you.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<label
|
||||
htmlFor={`${fieldId}-name`}
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
<Trans>Name</Trans>
|
||||
</label>
|
||||
<Input
|
||||
id={`${fieldId}-name`}
|
||||
value={prefs.name}
|
||||
onChange={(e) => setPrefs({ ...prefs, name: e.target.value })}
|
||||
placeholder={t`Your name`}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label
|
||||
htmlFor={`${fieldId}-timezone`}
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
<Trans>Timezone</Trans>
|
||||
</label>
|
||||
<Input
|
||||
id={`${fieldId}-timezone`}
|
||||
value={prefs.timezone}
|
||||
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
|
||||
placeholder={t`Auto-detected or enter manually`}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset className="mt-6 border-0 p-0">
|
||||
<legend className="block text-sm font-medium text-foreground">
|
||||
<Trans>Hobbies & interests</Trans>
|
||||
</legend>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<Trans>Select all that apply</Trans>
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{HOBBY_OPTIONS.map((hobby) => (
|
||||
<button
|
||||
key={hobby.key}
|
||||
type="button"
|
||||
onClick={() => toggleHobby(hobby.key)}
|
||||
aria-pressed={prefs.hobbies.includes(hobby.key)}
|
||||
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
|
||||
prefs.hobbies.includes(hobby.key)
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{hobby.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="mt-6 border-0 p-0">
|
||||
<legend className="block text-sm font-medium text-foreground">
|
||||
<Trans>Work style</Trans>
|
||||
</legend>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<Trans>How do you like to work?</Trans>
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{WORK_STYLE_OPTIONS.map((ws) => (
|
||||
<button
|
||||
key={ws.key}
|
||||
type="button"
|
||||
onClick={() => setPrefs({ ...prefs, workStyle: ws.key })}
|
||||
aria-pressed={prefs.workStyle === ws.key}
|
||||
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
|
||||
prefs.workStyle === ws.key
|
||||
? "border-primary bg-primary/10 text-foreground shadow-sm"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{ws.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="mt-6 border-0 p-0">
|
||||
<legend className="block text-sm font-medium text-foreground">
|
||||
<Trans>Communication style</Trans>
|
||||
</legend>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<Trans>How should your Oo talk to you?</Trans>
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{COMMUNICATION_OPTIONS.map((comm) => (
|
||||
<button
|
||||
key={comm.key}
|
||||
type="button"
|
||||
onClick={() => setPrefs({ ...prefs, communication: comm.key })}
|
||||
aria-pressed={prefs.communication === comm.key}
|
||||
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
|
||||
prefs.communication === comm.key
|
||||
? "border-primary bg-primary/10 text-foreground shadow-sm"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{comm.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => setStep("language")}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === "language" ? (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
@ -468,7 +268,7 @@ export function OnboardingPage() {
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => setStep("voice")}>
|
||||
|
||||
@ -1,15 +1,34 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { MessageBlock } from "@rakazo/contracts";
|
||||
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||
import { abortableDelay } from "@rakazo/core";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@rakazo/ui-web";
|
||||
import { ArrowUp, Check, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 { rpc } from "../../lib/rpc";
|
||||
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
|
||||
|
||||
type ChoiceOption = Extract<MessageBlock, { kind: "choice" }>["options"][number];
|
||||
|
||||
/**
|
||||
* A question card. Lettered options are the answers; an option without a letter ("Skip",
|
||||
* "Not now") is the quiet way out, and a card without one can be dismissed instead. Onboarding
|
||||
* cards may also take an answer in the user's own words.
|
||||
*/
|
||||
export function ChoiceCard({
|
||||
botId,
|
||||
block,
|
||||
@ -22,14 +41,31 @@ export function ChoiceCard({
|
||||
const { t } = useLingui();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [locallyDismissed, setLocallyDismissed] = useState(false);
|
||||
const dismissed = locallyDismissed || block.answerId === "_dismissed";
|
||||
const answered = Boolean(block.answerId);
|
||||
const answers = block.options.filter((option) => option.letter);
|
||||
const waysOut = block.options.filter((option) => !option.letter);
|
||||
const testId = (option: ChoiceOption) => `${block.step ?? "focus"}-choice-${option.id}`;
|
||||
|
||||
async function choose(optionId: string) {
|
||||
async function choose(optionId: string, words?: string) {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await rpc.onboarding.choose({ botId, optionId });
|
||||
// Tapping "Turn on" is the gesture the browser needs before it asks for permission.
|
||||
const answer =
|
||||
block.step === "notifications" && optionId === "on" && (await enableWebPush()) !== "enabled"
|
||||
? "later"
|
||||
: optionId;
|
||||
await rpc.onboarding.choose({
|
||||
botId,
|
||||
step: block.step,
|
||||
optionId: answer,
|
||||
...(words ? { text: words } : {}),
|
||||
// The server offers the notifications card only to a browser that can still turn push on.
|
||||
canEnablePush: webPushSupported() && !(await webPushEnabled()),
|
||||
});
|
||||
await onBotChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Could not save this choice`);
|
||||
@ -52,10 +88,18 @@ export function ChoiceCard({
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const chosen = block.answerText
|
||||
? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: block.answerText }
|
||||
: block.options.find((option) => option.id === block.answerId);
|
||||
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="apple-glass-card relative w-[440px] max-w-full rounded-[24px] px-5 py-4.5 transition-all">
|
||||
{!block.answerId ? (
|
||||
<BuiCard
|
||||
data-testid="choice-card"
|
||||
data-step={block.step}
|
||||
className="relative w-[440px] max-w-full px-5 py-4"
|
||||
>
|
||||
{!answered && waysOut.length === 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@ -73,49 +117,111 @@ export function ChoiceCard({
|
||||
{block.subtitle ? (
|
||||
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
|
||||
) : null}
|
||||
<div className="mt-3.5 space-y-2.5">
|
||||
{block.options
|
||||
.filter((option) => !block.answerId || option.id === block.answerId)
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
data-testid={`focus-choice-${option.id}`}
|
||||
aria-label={option.label}
|
||||
disabled={Boolean(block.answerId) || pending}
|
||||
onClick={() => void choose(option.id)}
|
||||
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-white/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
|
||||
block.answerId === option.id
|
||||
? "bg-blue-500/20 border-blue-500/40 shadow-sm"
|
||||
: "bg-white/[0.04] hover:bg-white/[0.08] hover:border-white/10"
|
||||
}`}
|
||||
{answered ? (
|
||||
chosen ? (
|
||||
<div
|
||||
data-testid="choice-answer"
|
||||
className="mt-3 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3 text-ui-body font-medium text-foreground"
|
||||
>
|
||||
{chosen.letter ? <ChoiceLetter letter={chosen.letter} selected /> : null}
|
||||
<span className="flex-1 leading-snug" dir="auto">
|
||||
{chosen.label}
|
||||
</span>
|
||||
<Check aria-hidden strokeWidth={2.2} className="mt-0.5 size-icon-md text-primary" />
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
{answers.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{answers.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
data-testid={testId(option)}
|
||||
aria-label={option.label}
|
||||
disabled={pending}
|
||||
onClick={() => void choose(option.id)}
|
||||
className="tactile-press flex w-full items-start gap-3 rounded-xl border border-border bg-background p-3 text-start text-ui-body leading-snug text-foreground transition-colors hover:bg-muted disabled:opacity-60"
|
||||
>
|
||||
<ChoiceLetter letter={option.letter} />
|
||||
<span className="flex-1">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{block.textInput ? (
|
||||
<form
|
||||
className="mt-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const words = text.trim();
|
||||
if (words) void choose(CHOICE_TEXT_ANSWER_ID, words);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-[8px] text-ui-body-sm font-semibold ${
|
||||
block.answerId === option.id
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-white/10 text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{option.letter}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-1 text-ui-body leading-snug ${block.answerId ? "text-foreground font-medium" : "text-foreground"}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
{block.answerId === option.id ? (
|
||||
<span className="mt-0.5 text-blue-400 font-bold">✓</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<InputGroup className="h-control-lg rounded-xl bg-background">
|
||||
<InputGroupInput
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder={block.textInput.placeholder}
|
||||
aria-label={block.question}
|
||||
maxLength={200}
|
||||
disabled={pending}
|
||||
dir="auto"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
variant="default"
|
||||
aria-label={t`Send`}
|
||||
disabled={pending || !text.trim()}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ArrowUp aria-hidden />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</form>
|
||||
) : null}
|
||||
{waysOut.length > 0 ? (
|
||||
<div className="mt-2 flex justify-end gap-1">
|
||||
{waysOut.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={testId(option)}
|
||||
disabled={pending}
|
||||
onClick={() => void choose(option.id)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
</BuiCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceLetter({ letter, selected = false }: { letter: string; selected?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`mt-0.5 grid size-6 shrink-0 place-items-center rounded-lg text-ui-body-sm font-semibold ${
|
||||
selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppConnectCard({
|
||||
botId,
|
||||
block,
|
||||
|
||||
@ -24,6 +24,7 @@ interface FixtureOptions {
|
||||
goals?: Array<Record<string, unknown>>;
|
||||
habits?: Array<Record<string, unknown>>;
|
||||
doneCheckIns?: Array<{ habitId: string; date: Date }>;
|
||||
onboarding?: { completedAt: Date | null } | null;
|
||||
peers?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@ -116,7 +117,12 @@ async function runOnce(options: FixtureOptions = {}) {
|
||||
agentSecret: { findMany: vi.fn(async () => []) },
|
||||
agentSkill: { findMany: vi.fn(async () => []) },
|
||||
scratchpadItem: { findMany: vi.fn(async () => []) },
|
||||
user: { findUnique: vi.fn(async () => ({ name: "Ada Example" })) },
|
||||
user: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
name: "Ada Example",
|
||||
onboarding: options.onboarding ?? null,
|
||||
})),
|
||||
},
|
||||
userProfile: {
|
||||
findUnique: vi.fn(async () =>
|
||||
options.profile === undefined
|
||||
@ -400,6 +406,19 @@ describe("run instructions", () => {
|
||||
expect(specialist.request.tools.map((tool) => tool.name)).not.toContain("update_goal");
|
||||
});
|
||||
|
||||
it("leaves open onboarding questions to their cards, for the main bot only", async () => {
|
||||
const main = { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" };
|
||||
const open = { completedAt: null };
|
||||
const note = "still answering onboarding cards";
|
||||
|
||||
expect((await runOnce({ bot: main, onboarding: open })).request.instructions).toContain(note);
|
||||
expect(
|
||||
(await runOnce({ bot: main, onboarding: { completedAt: new Date() } })).request.instructions,
|
||||
).not.toContain(note);
|
||||
expect((await runOnce({ bot: main })).request.instructions).not.toContain(note);
|
||||
expect((await runOnce({ onboarding: open })).request.instructions).not.toContain(note);
|
||||
});
|
||||
|
||||
it("shows the main bot every specialist it can delegate to, its own space first", async () => {
|
||||
const { request, peersFindMany } = await runOnce({
|
||||
bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" },
|
||||
|
||||
@ -18,6 +18,7 @@ const user: UserContext = {
|
||||
{ category: "work", value: "Owns a bakery", confidence: 1 },
|
||||
{ category: "preferences", value: "Prefers morning meetings", confidence: 0.8 },
|
||||
],
|
||||
onboardingOpen: false,
|
||||
};
|
||||
|
||||
describe("formatBotIdentity", () => {
|
||||
@ -96,6 +97,7 @@ describe("formatUserContext", () => {
|
||||
communicationStyle: "",
|
||||
summary: " ",
|
||||
facts: [{ category: "work", value: " ", confidence: 1 }],
|
||||
onboardingOpen: false,
|
||||
});
|
||||
expect(block).toContain("<user>\nName: Ada\n</user>");
|
||||
expect(block).not.toContain("Time zone");
|
||||
@ -112,6 +114,7 @@ describe("formatUserContext", () => {
|
||||
communicationStyle: "",
|
||||
summary: "",
|
||||
facts: [],
|
||||
onboardingOpen: false,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
@ -68,6 +68,13 @@ function formatCoachGuidance(toolNames: ReadonlySet<string>): Array<string | und
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
@ -250,6 +250,7 @@ import {
|
||||
formatMainAgentRole,
|
||||
formatSubagentContext,
|
||||
formatUserContext,
|
||||
ONBOARDING_OPEN_INSTRUCTION,
|
||||
} from "./executor-prompt.js";
|
||||
import {
|
||||
isProtectedComputerLifecycleCommandReadingScripts,
|
||||
@ -3743,6 +3744,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,
|
||||
|
||||
@ -100,6 +100,20 @@ export const DELEGATED_TASK_STATUSES = [
|
||||
export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES);
|
||||
export type DelegatedTaskStatus = z.infer<typeof DelegatedTaskStatusSchema>;
|
||||
|
||||
/** 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<typeof OnboardingStepSchema>;
|
||||
|
||||
/** `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
|
||||
|
||||
@ -2,6 +2,7 @@ import { eventIterator, oc } from "@orpc/contract";
|
||||
import * as z from "zod";
|
||||
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
|
||||
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js";
|
||||
import { COACH_TITLE_MAX_LENGTH } from "./coach.js";
|
||||
import {
|
||||
CheckInSchema,
|
||||
CoachRhythmSchema,
|
||||
@ -90,7 +91,7 @@ import {
|
||||
VoiceInfoSchema,
|
||||
VoiceStatusSchema,
|
||||
} from "./domain.js";
|
||||
import { DelegatedTaskStatusSchema, ProductEventSchema } from "./events.js";
|
||||
import { DelegatedTaskStatusSchema, OnboardingStepSchema, ProductEventSchema } from "./events.js";
|
||||
import { Id, IsoDate } from "./ids.js";
|
||||
import { InboxListOutputSchema } from "./inbox.js";
|
||||
import {
|
||||
@ -713,15 +714,31 @@ export const appContract = {
|
||||
},
|
||||
},
|
||||
onboarding: {
|
||||
/** Seed the first-run greeting into the bot's thread (focus card is separate). */
|
||||
/** Checks that the caller may onboard this bot's thread. Posts nothing. */
|
||||
start: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
|
||||
/** Post the focus choice card when the thread is still idle. */
|
||||
/**
|
||||
* Posts the first card: for the main bot the first onboarding question, once per user;
|
||||
* for another bot its focus card while the thread is still idle.
|
||||
*/
|
||||
promptFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
|
||||
/** Answer the focus choice; posts the app cards. Does not rename the bot. */
|
||||
/**
|
||||
* Answers the pending card and posts what follows it: the focus card's app suggestions,
|
||||
* then the next onboarding question. A stale answer (another step is pending) is ignored.
|
||||
*/
|
||||
choose: oc
|
||||
.input(z.object({ botId: Id, optionId: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
botId: Id,
|
||||
step: OnboardingStepSchema.optional(),
|
||||
optionId: z.string(),
|
||||
/** The answer in the user's words, with `optionId` "_text"; only the goal card takes one. */
|
||||
text: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(),
|
||||
/** This device could still turn on push notifications. */
|
||||
canEnablePush: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ ok: z.literal(true) })),
|
||||
/** Dismiss the unanswered focus card without choosing an option. */
|
||||
/** Dismisses the pending card; an onboarding question counts as skipped. */
|
||||
dismissFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
|
||||
/** Flip an app_connect card to connected after authorization completes. */
|
||||
appConnected: oc
|
||||
|
||||
@ -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?",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 "";
|
||||
})
|
||||
|
||||
@ -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'));
|
||||
@ -46,6 +46,7 @@ model User {
|
||||
botSecrets BotSecret[]
|
||||
agentSecrets AgentSecret[]
|
||||
profile UserProfile?
|
||||
onboarding UserOnboarding?
|
||||
facts UserFact[]
|
||||
goals UserGoal[]
|
||||
habits Habit[]
|
||||
@ -1477,6 +1478,21 @@ model UserProfile {
|
||||
@@map("user_profiles")
|
||||
}
|
||||
|
||||
/// Where the user is in the onboarding questions the main bot asks in the main conversation.
|
||||
model UserOnboarding {
|
||||
userId String @id
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// The step whose card is waiting for an answer; null once onboarding is complete.
|
||||
step String?
|
||||
focus String?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// CHECK constraint user_onboarding_step_chk is created in SQL.
|
||||
@@map("user_onboarding")
|
||||
}
|
||||
|
||||
/// One durable statement about the user, with its origin and consent state.
|
||||
model UserFact {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@ -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<UserContext> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -108,6 +108,7 @@ async function main() {
|
||||
"packages/testkit/src/bot-secrets.test.ts",
|
||||
"packages/testkit/src/delegation.postgres.test.ts",
|
||||
"packages/testkit/src/user-profile.postgres.test.ts",
|
||||
"packages/testkit/src/onboarding-intake.postgres.test.ts",
|
||||
"packages/testkit/src/onboarding-discovery.postgres.test.ts",
|
||||
"packages/testkit/src/notifications.postgres.test.ts",
|
||||
"packages/db/src/space-membership.postgres.test.ts",
|
||||
|
||||
276
packages/testkit/src/onboarding-intake.postgres.test.ts
Normal file
276
packages/testkit/src/onboarding-intake.postgres.test.ts
Normal file
@ -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<Response> };
|
||||
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
||||
type Choice = Extract<MessageBlock, { kind: "choice" }>;
|
||||
|
||||
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<Choice[]> {
|
||||
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<string, unknown>) =>
|
||||
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<string, unknown>) =>
|
||||
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<T>(app: App, cookie: string, procedure: string, body: unknown = {}) {
|
||||
const response = await raw(app, cookie, procedure, body);
|
||||
const text = await response.text();
|
||||
const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
||||
if (response.status >= 400 || payload.error) {
|
||||
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
|
||||
}
|
||||
return payload.json as T;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user