@wireai/activation 0.13.5 → 0.13.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms.txt CHANGED
@@ -14,7 +14,7 @@
14
14
  - **Render:** drop `<WireOnboarding config={wireConfigFromEnv({ appId })} theme={...} onComplete={persist} fallbackFlow={<YourStaticOnboarding/>} />` into the signup flow.
15
15
  - **The join key.** Pass `userContext={activationJoinContext(deviceKey)}` on `<WireOnboarding>` (or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` when the app owns no device id). `user_context.device_key` is the ONLY thing that joins an onboarding session to the app's later events; omit it and the `activated` funnel reads zero with no error.
16
16
  - **Lifecycle (mount it once):** `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the persisted auto `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`.
17
- - **Capture is deterministic.** `onComplete` returns `{ answers, raw }`; no second LLM call. Persist all answers through the app's normal profile-update path.
17
+ - **Capture is deterministic.** `onComplete` returns an `OnboardingResult`: `answers` and `raw` always, plus (since 0.13.6) `plan` when the backend sent an onboarding plan and `variant` when the tenant runs an experiment and the backend assigned an arm. No second LLM call. Persist all answers through the app's normal profile-update path. Those two extra keys are set ONLY when the backend actually sent them, so a run without them returns exactly the object earlier versions returned, and the kit interprets neither: validate `plan` before applying it.
18
18
  - **It can never break onboarding.** Pass `fallbackFlow` (your existing static onboarding) so a backend error/timeout degrades instead of dead-ending.
19
19
 
20
20
  ## Helpers (the reusable substrate)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
4
4
  "private": false,
5
5
  "description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
6
6
  "author": "Malik Chohra <malik@getwireai.com>",
@@ -33,6 +33,7 @@ import { ErrorBlock } from "./components/ErrorBlock";
33
33
  import { CompletionView } from "./components/CompletionView";
34
34
  import { CardHandoff } from "./components/CardHandoff";
35
35
  import { deriveAnswers } from "./utils/deriveAnswers";
36
+ import { readPlan } from "./utils/readPlan";
36
37
  import { readProgress } from "./utils/readProgress";
37
38
  import { reportClientEvent, type ClientEventTarget } from "./analytics/reportClientEvent";
38
39
  import { sendPreview } from "./analytics/sendPreview";
@@ -176,6 +177,12 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
176
177
  // Last 0-based screen index we showed the user (matches the server's `screen_shown`
177
178
  // index). Used as the `dropped` event's screen_index. -1 = nothing shown yet.
178
179
  const lastScreenIndexRef = useRef(-1);
180
+ // The backend's experiment arm for this session, LATCHED at first sight and never cleared.
181
+ // The server's assignment is sticky per session but it only rides the render envelope, so a
182
+ // later card that omits `progress.variant` must not un-assign what an earlier card declared.
183
+ // `undefined` = no arm seen, which is every turn of every tenant running no experiment.
184
+ // The kit stores the string and nothing else: it is not parsed, branded, defaulted or logged.
185
+ const variantRef = useRef<string | undefined>(undefined);
179
186
  // Trailing-edge debounce timer for prefetch-on-select (see previewOnSelect below).
180
187
  const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
181
188
 
@@ -268,15 +275,22 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
268
275
  // A new card arrived → clear any prior inline validation error + emit a turn event.
269
276
  useEffect(() => {
270
277
  setValidationError(undefined);
278
+ // Latch the arm BEFORE the turn event fires, so the card that first declares one already
279
+ // carries it. An empty string is not an assignment, so it is ignored like an absent key.
280
+ if (variantRef.current === undefined && progress.variant) variantRef.current = progress.variant;
271
281
  if (!lastCard?.id) return;
272
282
  const step = progress.step ?? renderedCount;
273
283
  // Remember the latest screen we showed (0-based) for a possible `dropped` event.
274
284
  lastScreenIndexRef.current = Math.max(0, step - 1);
275
- onEventRef.current?.({
285
+ const turn: Extract<OnboardingEvent, { type: "turn" }> = {
276
286
  type: "turn",
277
287
  step,
278
288
  component: lastCard.response?.component,
279
- });
289
+ };
290
+ // Set the key ONLY when an arm was seen, for the same reason `handleFinish` does it on the
291
+ // result: a host reading `Object.keys(event)` sees no change when no experiment is running.
292
+ if (variantRef.current !== undefined) turn.variant = variantRef.current;
293
+ onEventRef.current?.(turn);
280
294
  // Prefetch: an InterstitialCard's answer is always "continue", so we can warm the next
281
295
  // turn's cache during the momentum window — by the time the user taps Continue the following
282
296
  // card is already generated. The preview MUST use the exact text the real Continue tap sends,
@@ -366,7 +380,17 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
366
380
  const handleFinish = useCallback(() => {
367
381
  if (finished.current) return;
368
382
  finished.current = true;
369
- onCompleteRef.current({ answers: deriveAnswers(messages), raw: messages });
383
+ const result: OnboardingResult = { answers: deriveAnswers(messages), raw: messages };
384
+ // The plan rides a second DataPart on the AI path only; the static flow carries none. Setting
385
+ // the key ONLY when there is one keeps the no-plan result byte-identical to the pre-plan
386
+ // object — a host that inspects `Object.keys(result)` or `"plan" in result` sees no change.
387
+ const plan = readPlan(messages);
388
+ if (plan !== undefined) result.plan = plan;
389
+ // The experiment arm, latched off the render envelopes during the flow. Set under exactly the
390
+ // same rule as the plan: no arm seen → no key, so the result stays byte-identical for the
391
+ // tenants (most of them) that run no experiment.
392
+ if (variantRef.current !== undefined) result.variant = variantRef.current;
393
+ onCompleteRef.current(result);
370
394
  }, [messages]);
371
395
 
372
396
  // Manual retry (the no-fallback ErrorBlock button): clear the degrade + counter so
package/src/types.ts CHANGED
@@ -53,6 +53,29 @@ export type OnboardingResult = {
53
53
  answers: Record<string, unknown>;
54
54
  /** The raw message thread, for custom downstream parsing. */
55
55
  raw: Message[];
56
+ /**
57
+ * The backend's onboarding plan, when it sent one. Present ONLY on the AI path: a tenant running
58
+ * the static flow, or any run the server finished without a plan, leaves this `undefined` AND
59
+ * leaves the key off the result object entirely — so a host written before plans existed sees
60
+ * byte-identically what it always saw.
61
+ *
62
+ * ⚠️ The kit does NOT interpret this and does NOT validate it. It checks one structural fact (a
63
+ * plan is an object) and hands the payload straight through, unread, unlogged, and never attached
64
+ * to an analytics event. THE HOST MUST VALIDATE IT before applying it: the fields are
65
+ * backend-authored, they may gain new ones without a kit release, and what any of them mean is
66
+ * the host's decision, not the kit's.
67
+ */
68
+ plan?: unknown;
69
+ /**
70
+ * The experiment ARM the backend assigned this session, when it is running one. Absent when
71
+ * the tenant runs no experiment, which is the common case, and absent is NOT an error.
72
+ * The kit does NOT interpret it, does not log it, does not brand it and never attaches it
73
+ * to an analytics event. What an arm key MEANS is the host's decision.
74
+ *
75
+ * Like {@link plan}, the KEY is set only when an arm was actually seen, so a run with no
76
+ * experiment leaves the result byte-identical to what every host already reads.
77
+ */
78
+ variant?: string;
56
79
  };
57
80
 
58
81
  /**
@@ -65,7 +88,11 @@ export type OnboardingResult = {
65
88
  * - `resumed`: a persisted session was restored after an app kill (fires INSTEAD of
66
89
  * `started`, so host funnels don't double-count the same session). Also carries
67
90
  * `contextId`. Requires the `storage` prop.
68
- * - `turn`: a new assistant card arrived (`step` = 1-based index of cards seen).
91
+ * - `turn`: a new assistant card arrived (`step` = 1-based index of cards seen). Carries
92
+ * `variant`, the backend's experiment arm for this session, from the first card that
93
+ * declared one onwards — this is how the arm is available DURING the flow rather than
94
+ * only at completion. The key is absent whenever no arm has been seen, which is every
95
+ * turn of every tenant running no experiment.
69
96
  * - `error`: the backend errored or the first-card watchdog timed out.
70
97
  * - `retry`: a transient failure is being auto-retried (`attempt` = 1-based).
71
98
  * - `fallback`: retries are exhausted; the kit degraded to the static `fallbackFlow`
@@ -79,7 +106,7 @@ export type OnboardingResult = {
79
106
  export type OnboardingEvent =
80
107
  | { type: "started"; contextId: string }
81
108
  | { type: "resumed"; contextId: string }
82
- | { type: "turn"; step: number; component?: string }
109
+ | { type: "turn"; step: number; component?: string; variant?: string }
83
110
  | { type: "error"; reason: "backend" | "timeout" }
84
111
  | { type: "retry"; reason: "backend" | "timeout"; attempt: number }
85
112
  | { type: "fallback"; reason: "backend" | "timeout" }
@@ -350,4 +377,15 @@ export type OnboardingProgress = {
350
377
  slot_id?: string;
351
378
  /** Whether the CURRENT screen may be skipped (backend-marked; default false → no Skip shown). */
352
379
  skippable?: boolean;
380
+ /**
381
+ * The EXPERIMENT ARM the backend assigned this session, when the tenant is running one. Rides the
382
+ * render envelope as a sibling of {@link step}/{@link total}/{@link key}/{@link slot_id}, and is
383
+ * OMITTED entirely for a tenant running no experiment — which is the common case, and is not an
384
+ * error. The assignment is sticky for the session, so a later card that omits it does not
385
+ * un-assign it.
386
+ *
387
+ * The kit does NOT interpret this. It is whitelisted, latched and handed to the host exactly as
388
+ * received; what an arm key MEANS is the host's decision. See {@link OnboardingResult.variant}.
389
+ */
390
+ variant?: string;
353
391
  };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * readPlan — lift the backend's onboarding plan off the message thread.
3
+ *
4
+ * WHY IT EXISTS. On the AI path the server appends a SECOND A2A DataPart to the turn it finishes
5
+ * on: `{ kind: "onboarding_plan", plan: {...} }`, alongside the component envelope the renderer
6
+ * already consumes. The kit carries that payload out through `OnboardingResult.plan` and stops
7
+ * there. It does NOT interpret it, does NOT validate its fields, does NOT log it and does NOT
8
+ * attach it to any event — the plan is user-derived content, and deciding what it MEANS is the
9
+ * host's job (the kit/host boundary in `ai_rules/context_map.md`: the kit ends at the completion
10
+ * CTA).
11
+ *
12
+ * THE ONE THING IT IS ALLOWED TO REJECT is a structural fact: a plan is an object. A scalar, an
13
+ * array, or a missing payload is not a plan, and yields `undefined` so the host falls back to
14
+ * exactly its pre-plan behavior. Nothing below that line is checked: no field is required and no
15
+ * unknown field is stripped (the server's lenient path adds its own markers), because a kit-side
16
+ * schema would make the kit the thing that rejects a plan the host could have used.
17
+ *
18
+ * MALFORMED INPUT NEVER THROWS. This runs inside `handleFinish`, the single path to `onComplete`,
19
+ * so a throw here would cost the user the completion of an onboarding they already finished. Every
20
+ * step below is a runtime-guarded read.
21
+ *
22
+ * The static (non-AI) flow carries no plan at all. That path is unchanged and fully supported.
23
+ */
24
+ import type { Message } from "wireai-rn";
25
+
26
+ /**
27
+ * The read-back member the SDK surfaces for every A2A DataPart past the first, uninterpreted.
28
+ *
29
+ * NOT in wire order, and nothing here may assume it is. The SDK FLATTENS a task's parts across
30
+ * agent messages LATEST-FIRST (and then artifacts latest-first) before dropping the first one, so
31
+ * this array is a cross-message collection, not one turn's parts in the order they arrived. On a
32
+ * task carrying history, a previous turn's component envelope lands here beside this turn's plan.
33
+ * `readPlan` is safe from that because it matches the `kind` MARKER below, never a position.
34
+ *
35
+ * Declared HERE, structurally, because the installed `wireai-rn` peer (0.2.4) does not declare it
36
+ * on `Message` yet.
37
+ *
38
+ * TODO(re-pin): delete this local type and read the member straight off `Message` once a
39
+ * `wireai-rn` that declares it is published and the peer range is re-pinned. Until then the two
40
+ * shapes are equal by CONVENTION, not by the compiler, and `hasDataParts` below is what makes that
41
+ * safe at runtime. Deliberately NOT an `as` cast: a cast silences the compiler in BOTH directions,
42
+ * including the day the published type disagrees with this one.
43
+ */
44
+ type WithDataParts = { dataParts: readonly unknown[] };
45
+
46
+ /** The marker the server stamps on the plan DataPart. */
47
+ const PLAN_PART_KIND = "onboarding_plan";
48
+
49
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
50
+ typeof value === "object" && value !== null && !Array.isArray(value);
51
+
52
+ /** RUNTIME proof that a value carries a `dataParts` ARRAY, before anything indexes into one. */
53
+ const hasDataParts = (value: unknown): value is WithDataParts =>
54
+ isRecord(value) && Array.isArray(value.dataParts);
55
+
56
+ /** The plan a single DataPart carries, or `undefined` when that part is not a well-formed plan. */
57
+ const planFromPart = (part: unknown): unknown => {
58
+ if (!isRecord(part) || part.kind !== PLAN_PART_KIND) return undefined;
59
+ return isRecord(part.plan) ? part.plan : undefined;
60
+ };
61
+
62
+ /**
63
+ * The plan the backend sent, or `undefined` when it sent none (or sent something that is not a
64
+ * plan). Never throws.
65
+ *
66
+ * ORDERING, stated so it is not undefined behavior: the thread is scanned NEWEST TURN FIRST, so a
67
+ * thread carrying two plans resolves to the LAST one — the same turn `handleFinish` completes on.
68
+ *
69
+ * Inside one message's `dataParts` the first WELL-FORMED plan part wins, and that is a scan order,
70
+ * NOT a wire-order guarantee. Wire order does not hold even within a single turn: `dataParts` is
71
+ * the SDK's flattened, cross-agent-message collection (latest-first, see `WithDataParts` above), so
72
+ * on a task carrying history a previous turn's parts sit in the same array. This is exactly why the
73
+ * match is on the `onboarding_plan` MARKER and never on a position. A part that is not a
74
+ * well-formed plan is skipped rather than treated as a terminator, so neither a stale envelope nor
75
+ * a broken payload can shadow a good plan.
76
+ */
77
+ export const readPlan = (messages: readonly Message[]): unknown => {
78
+ for (let i = messages.length - 1; i >= 0; i--) {
79
+ const message = messages[i];
80
+ if (!hasDataParts(message)) continue;
81
+ for (const part of message.dataParts) {
82
+ const plan = planFromPart(part);
83
+ if (plan !== undefined) return plan;
84
+ }
85
+ }
86
+ return undefined;
87
+ };
@@ -18,6 +18,8 @@ type PartialProgress = {
18
18
  /** The stable per-slot identity, when the backend sends one. See `OnboardingProgress.slot_id`. */
19
19
  slot_id?: string;
20
20
  skippable?: boolean;
21
+ /** The experiment arm assigned to this session, when the tenant runs one. See `OnboardingProgress.variant`. */
22
+ variant?: string;
21
23
  };
22
24
 
23
25
  export const readProgress = (response?: WireAIResponse): PartialProgress => {
@@ -33,5 +35,8 @@ export const readProgress = (response?: WireAIResponse): PartialProgress => {
33
35
  // Whitelisted the same way as every other field: an old backend simply omits it.
34
36
  slot_id: typeof p.slot_id === "string" ? p.slot_id : undefined,
35
37
  skippable: typeof p.skippable === "boolean" ? p.skippable : undefined,
38
+ // Whitelisted like the rest: a non-string (or absent) arm key reads as "no experiment", never
39
+ // as an error. The kit does not interpret the value — see `OnboardingProgress.variant`.
40
+ variant: typeof p.variant === "string" ? p.variant : undefined,
36
41
  };
37
42
  };