@wireai/activation 0.13.5 → 0.13.6-next.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.13.5",
3
+ "version": "0.13.6-next.0",
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";
@@ -366,7 +367,13 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
366
367
  const handleFinish = useCallback(() => {
367
368
  if (finished.current) return;
368
369
  finished.current = true;
369
- onCompleteRef.current({ answers: deriveAnswers(messages), raw: messages });
370
+ const result: OnboardingResult = { answers: deriveAnswers(messages), raw: messages };
371
+ // The plan rides a second DataPart on the AI path only; the static flow carries none. Setting
372
+ // the key ONLY when there is one keeps the no-plan result byte-identical to the pre-plan
373
+ // object — a host that inspects `Object.keys(result)` or `"plan" in result` sees no change.
374
+ const plan = readPlan(messages);
375
+ if (plan !== undefined) result.plan = plan;
376
+ onCompleteRef.current(result);
370
377
  }, [messages]);
371
378
 
372
379
  // Manual retry (the no-fallback ErrorBlock button): clear the degrade + counter so
package/src/types.ts CHANGED
@@ -53,6 +53,19 @@ 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;
56
69
  };
57
70
 
58
71
  /**
@@ -0,0 +1,74 @@
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, in wire order and
28
+ * uninterpreted. Declared HERE, structurally, because the installed `wireai-rn` peer (0.2.4) does
29
+ * not declare it on `Message` yet.
30
+ *
31
+ * TODO(re-pin): delete this local type and read the member straight off `Message` once a
32
+ * `wireai-rn` that declares it is published and the peer range is re-pinned. Until then the two
33
+ * shapes are equal by CONVENTION, not by the compiler, and `hasDataParts` below is what makes that
34
+ * safe at runtime. Deliberately NOT an `as` cast: a cast silences the compiler in BOTH directions,
35
+ * including the day the published type disagrees with this one.
36
+ */
37
+ type WithDataParts = { dataParts: readonly unknown[] };
38
+
39
+ /** The marker the server stamps on the plan DataPart. */
40
+ const PLAN_PART_KIND = "onboarding_plan";
41
+
42
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
43
+ typeof value === "object" && value !== null && !Array.isArray(value);
44
+
45
+ /** RUNTIME proof that a value carries a `dataParts` ARRAY, before anything indexes into one. */
46
+ const hasDataParts = (value: unknown): value is WithDataParts =>
47
+ isRecord(value) && Array.isArray(value.dataParts);
48
+
49
+ /** The plan a single DataPart carries, or `undefined` when that part is not a well-formed plan. */
50
+ const planFromPart = (part: unknown): unknown => {
51
+ if (!isRecord(part) || part.kind !== PLAN_PART_KIND) return undefined;
52
+ return isRecord(part.plan) ? part.plan : undefined;
53
+ };
54
+
55
+ /**
56
+ * The plan the backend sent, or `undefined` when it sent none (or sent something that is not a
57
+ * plan). Never throws.
58
+ *
59
+ * ORDERING, stated so it is not undefined behavior: the thread is scanned NEWEST TURN FIRST, so a
60
+ * thread carrying two plans resolves to the LAST one — the same turn `handleFinish` completes on.
61
+ * Within a single turn, wire order decides: the first well-formed plan part wins. A malformed part
62
+ * is skipped rather than treated as a terminator, so a broken payload can never shadow a good one.
63
+ */
64
+ export const readPlan = (messages: readonly Message[]): unknown => {
65
+ for (let i = messages.length - 1; i >= 0; i--) {
66
+ const message = messages[i];
67
+ if (!hasDataParts(message)) continue;
68
+ for (const part of message.dataParts) {
69
+ const plan = planFromPart(part);
70
+ if (plan !== undefined) return plan;
71
+ }
72
+ }
73
+ return undefined;
74
+ };