@assistant-ui/react-generative-ui 0.0.19 → 0.0.21

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.
Files changed (137) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +14 -15
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +28 -8
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/JSONGenerativeUI.server.d.ts +5 -7
  6. package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
  7. package/dist/JSONGenerativeUI.shared.d.ts +35 -36
  8. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  9. package/dist/a2ui/convert.d.ts +7 -7
  10. package/dist/a2ui/convert.d.ts.map +1 -1
  11. package/dist/a2ui/convert.js +148 -28
  12. package/dist/a2ui/convert.js.map +1 -1
  13. package/dist/a2ui/reducer.d.ts +1 -3
  14. package/dist/a2ui/reducer.d.ts.map +1 -1
  15. package/dist/a2ui/reducer.js +8 -0
  16. package/dist/a2ui/reducer.js.map +1 -1
  17. package/dist/a2ui/snapshot.d.ts +3 -0
  18. package/dist/a2ui/snapshot.d.ts.map +1 -0
  19. package/dist/a2ui/snapshot.js +33 -0
  20. package/dist/a2ui/snapshot.js.map +1 -0
  21. package/dist/a2ui/types.d.ts +50 -40
  22. package/dist/a2ui/types.d.ts.map +1 -1
  23. package/dist/a2ui/types.js.map +1 -1
  24. package/dist/a2ui.d.ts +5 -4
  25. package/dist/a2ui.d.ts.map +1 -0
  26. package/dist/a2ui.js +2 -1
  27. package/dist/actionRegistry.d.ts +12 -14
  28. package/dist/actionRegistry.d.ts.map +1 -1
  29. package/dist/actionRegistry.js.map +1 -1
  30. package/dist/buildPresentParameters.d.ts +2 -4
  31. package/dist/buildPresentParameters.d.ts.map +1 -1
  32. package/dist/buildPresentParameters.js +16 -4
  33. package/dist/buildPresentParameters.js.map +1 -1
  34. package/dist/constants.d.ts +0 -2
  35. package/dist/constants.d.ts.map +1 -1
  36. package/dist/convert/boundSpec.d.ts +0 -2
  37. package/dist/convert/boundSpec.d.ts.map +1 -1
  38. package/dist/convert/copyBounded.d.ts +2 -4
  39. package/dist/convert/copyBounded.d.ts.map +1 -1
  40. package/dist/convert/isElement.d.ts +1 -3
  41. package/dist/convert/isElement.d.ts.map +1 -1
  42. package/dist/convert/takeRun.d.ts +3 -5
  43. package/dist/convert/takeRun.d.ts.map +1 -1
  44. package/dist/defineGenerativeComponents.d.ts +1 -3
  45. package/dist/defineGenerativeComponents.d.ts.map +1 -1
  46. package/dist/generativeUIToJSX.d.ts +4 -6
  47. package/dist/generativeUIToJSX.d.ts.map +1 -1
  48. package/dist/index.d.ts +3 -13
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.server.d.ts +3 -13
  51. package/dist/index.server.d.ts.map +1 -0
  52. package/dist/index.shared.d.ts +15 -11
  53. package/dist/index.shared.d.ts.map +1 -0
  54. package/dist/ir.d.ts +17 -19
  55. package/dist/ir.d.ts.map +1 -1
  56. package/dist/renderGenerativeUI.d.ts +2 -4
  57. package/dist/renderGenerativeUI.d.ts.map +1 -1
  58. package/dist/scopeSchema.d.ts +6 -0
  59. package/dist/scopeSchema.d.ts.map +1 -0
  60. package/dist/scopeSchema.js +56 -0
  61. package/dist/scopeSchema.js.map +1 -0
  62. package/dist/slack/constants.d.ts +8 -9
  63. package/dist/slack/constants.d.ts.map +1 -1
  64. package/dist/slack/decodeBlockAction.d.ts +1 -3
  65. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  66. package/dist/slack/fromSlackBlocks.d.ts +1 -3
  67. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  68. package/dist/slack/toSlackBlocks.d.ts +1 -3
  69. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  70. package/dist/slack/toSlackBlocks.js +1 -1
  71. package/dist/slack/types.d.ts +78 -80
  72. package/dist/slack/types.d.ts.map +1 -1
  73. package/dist/slack.d.ts +5 -5
  74. package/dist/slack.d.ts.map +1 -0
  75. package/dist/teams/constants.d.ts +1 -3
  76. package/dist/teams/constants.d.ts.map +1 -1
  77. package/dist/teams/decodeSubmitData.d.ts +1 -3
  78. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  79. package/dist/teams/toAdaptiveCard.d.ts +4 -6
  80. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  81. package/dist/teams/toTeamsAttachments.d.ts +1 -3
  82. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  83. package/dist/teams/types.d.ts +108 -109
  84. package/dist/teams/types.d.ts.map +1 -1
  85. package/dist/teams.d.ts +5 -5
  86. package/dist/teams.d.ts.map +1 -0
  87. package/dist/types.d.ts +36 -37
  88. package/dist/types.d.ts.map +1 -1
  89. package/dist/vocabulary/alert.d.ts +21 -23
  90. package/dist/vocabulary/alert.d.ts.map +1 -1
  91. package/dist/vocabulary/collectFormValues.d.ts +8 -10
  92. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  93. package/dist/vocabulary/data.d.ts +45 -47
  94. package/dist/vocabulary/data.d.ts.map +1 -1
  95. package/dist/vocabulary/dispatch.d.ts +2 -4
  96. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  97. package/dist/vocabulary/fact.d.ts +8 -10
  98. package/dist/vocabulary/fact.d.ts.map +1 -1
  99. package/dist/vocabulary/form.d.ts +7 -9
  100. package/dist/vocabulary/form.d.ts.map +1 -1
  101. package/dist/vocabulary/icon.d.ts +37 -39
  102. package/dist/vocabulary/icon.d.ts.map +1 -1
  103. package/dist/vocabulary/index.d.ts +1 -3
  104. package/dist/vocabulary/index.d.ts.map +1 -1
  105. package/dist/vocabulary/interactive.d.ts +87 -86
  106. package/dist/vocabulary/interactive.d.ts.map +1 -1
  107. package/dist/vocabulary/interactive.js +4 -4
  108. package/dist/vocabulary/interactive.js.map +1 -1
  109. package/dist/vocabulary/layout.d.ts +75 -77
  110. package/dist/vocabulary/layout.d.ts.map +1 -1
  111. package/dist/vocabulary/list.d.ts +10 -12
  112. package/dist/vocabulary/list.d.ts.map +1 -1
  113. package/dist/vocabulary/media.d.ts +21 -23
  114. package/dist/vocabulary/media.d.ts.map +1 -1
  115. package/dist/vocabulary/text.d.ts +53 -55
  116. package/dist/vocabulary/text.d.ts.map +1 -1
  117. package/dist/vocabulary/toTextContent.d.ts +0 -2
  118. package/dist/vocabulary/toTextContent.d.ts.map +1 -1
  119. package/package.json +4 -4
  120. package/src/JSONGenerativeUI.client.tsx +57 -15
  121. package/src/JSONGenerativeUI.test.tsx +298 -4
  122. package/src/a2ui/convert.test.ts +717 -0
  123. package/src/a2ui/convert.ts +229 -41
  124. package/src/a2ui/reducer.test.ts +18 -0
  125. package/src/a2ui/reducer.ts +16 -0
  126. package/src/a2ui/snapshot.test.ts +52 -0
  127. package/src/a2ui/snapshot.ts +52 -0
  128. package/src/a2ui/types.ts +16 -0
  129. package/src/a2ui.ts +2 -0
  130. package/src/actionRegistry.ts +5 -5
  131. package/src/buildPresentParameters.test.ts +246 -0
  132. package/src/buildPresentParameters.ts +18 -3
  133. package/src/scopeSchema.test.ts +72 -0
  134. package/src/scopeSchema.ts +71 -0
  135. package/src/vocabulary/interactive.dispatch.test.tsx +17 -3
  136. package/src/vocabulary/interactive.test.tsx +19 -0
  137. package/src/vocabulary/interactive.tsx +4 -1
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import type { UIElement } from "../ir";
3
3
  import { convertSurfaceToUISpec } from "./convert";
4
4
  import { applyA2uiOperations } from "./reducer";
5
+ import { surfaceToOperations } from "./snapshot";
5
6
  import type { A2uiSurfaceState } from "./types";
6
7
 
7
8
  const surfaceFrom = (
@@ -18,6 +19,93 @@ const surfaceFrom = (
18
19
  });
19
20
 
20
21
  describe("convertSurfaceToUISpec", () => {
22
+ it("replays a surface with bindings, templates, and custom components", () => {
23
+ const initial = applyA2uiOperations(new Map(), [
24
+ {
25
+ version: "v0.9",
26
+ createSurface: { surfaceId: "replay", catalogId: "basic" },
27
+ },
28
+ {
29
+ version: "v0.9",
30
+ updateComponents: {
31
+ surfaceId: "replay",
32
+ components: [
33
+ {
34
+ id: "root",
35
+ component: "Column",
36
+ children: ["items", "custom"],
37
+ },
38
+ {
39
+ id: "items",
40
+ component: "List",
41
+ children: {
42
+ template: { componentId: "item", path: "/items" },
43
+ },
44
+ },
45
+ {
46
+ id: "item",
47
+ component: "Text",
48
+ text: { path: "/name" },
49
+ },
50
+ {
51
+ id: "custom",
52
+ component: "StatusPill",
53
+ label: { path: "/status" },
54
+ },
55
+ ],
56
+ },
57
+ },
58
+ {
59
+ version: "v0.9",
60
+ updateDataModel: {
61
+ surfaceId: "replay",
62
+ contents: {
63
+ status: "ready",
64
+ items: [{ name: "one" }, { name: "two" }],
65
+ },
66
+ },
67
+ },
68
+ ]);
69
+ const surface = initial.state.get("replay");
70
+ expect(surface).toBeDefined();
71
+
72
+ const operations = surfaceToOperations(surface!);
73
+ const replayed = applyA2uiOperations(
74
+ new Map(),
75
+ JSON.parse(JSON.stringify(operations)),
76
+ );
77
+ const replayedSurface = replayed.state.get("replay");
78
+
79
+ expect(operations).toEqual([
80
+ {
81
+ version: "v0.9",
82
+ createSurface: { surfaceId: "replay", catalogId: "basic" },
83
+ },
84
+ expect.objectContaining({
85
+ version: "v0.9",
86
+ updateComponents: expect.objectContaining({ surfaceId: "replay" }),
87
+ }),
88
+ {
89
+ version: "v0.9",
90
+ updateDataModel: expect.objectContaining({
91
+ surfaceId: "replay",
92
+ path: "/",
93
+ }),
94
+ },
95
+ ]);
96
+ expect(replayed.warnings).toEqual([]);
97
+ expect(replayedSurface).toMatchObject({
98
+ catalogId: "basic",
99
+ dataModel: surface!.dataModel,
100
+ });
101
+ expect([...replayedSurface!.components.entries()]).toEqual([
102
+ ...surface!.components.entries(),
103
+ ]);
104
+ expect(convertSurfaceToUISpec(replayedSurface!)).toEqual(
105
+ convertSurfaceToUISpec(surface!),
106
+ );
107
+ });
108
+
21
109
  it("converts an operation-built A2UI surface to the exact canonical UISpec", () => {
22
110
  const result = applyA2uiOperations(new Map(), [
23
111
  {
@@ -186,6 +274,149 @@ describe("convertSurfaceToUISpec", () => {
186
274
  });
187
275
  });
188
276
 
277
+ it("maps basic catalog icons, lists, choice pickers, and date inputs", () => {
278
+ const surface = surfaceFrom(
279
+ [
280
+ {
281
+ id: "root",
282
+ component: "Column",
283
+ children: ["icon", "list", "horizontal", "select", "radio", "date"],
284
+ },
285
+ { id: "icon", component: "Icon", name: "calendar", size: "lg" },
286
+ {
287
+ id: "list",
288
+ component: "List",
289
+ children: ["list-item"],
290
+ },
291
+ { id: "list-item", component: "Text", text: "Vertical item" },
292
+ {
293
+ id: "horizontal",
294
+ component: "List",
295
+ direction: "horizontal",
296
+ align: "center",
297
+ children: ["horizontal-item"],
298
+ },
299
+ {
300
+ id: "horizontal-item",
301
+ component: "Text",
302
+ text: "Horizontal item",
303
+ },
304
+ {
305
+ id: "select",
306
+ component: "ChoicePicker",
307
+ label: "Delivery",
308
+ placeholder: "Choose delivery",
309
+ variant: "multipleSelection",
310
+ value: { path: "/form/delivery" },
311
+ options: [
312
+ { label: { path: "/labels/express" }, value: "express" },
313
+ { label: "Standard", value: "standard" },
314
+ ],
315
+ },
316
+ {
317
+ id: "radio",
318
+ component: "ChoicePicker",
319
+ label: "Plan",
320
+ variant: "mutuallyExclusive",
321
+ value: { path: "/form/plan" },
322
+ options: [
323
+ { label: "Free", value: "free" },
324
+ { label: "Pro", value: "pro" },
325
+ ],
326
+ },
327
+ {
328
+ id: "date",
329
+ component: "DateTimeInput",
330
+ label: "Start date",
331
+ value: { path: "/form/startDate" },
332
+ min: "2026-01-01",
333
+ max: "2026-12-31",
334
+ },
335
+ ],
336
+ {
337
+ labels: { express: "Express" },
338
+ form: {
339
+ delivery: ["express"],
340
+ plan: "pro",
341
+ startDate: "2026-06-01",
342
+ },
343
+ },
344
+ );
345
+
346
+ expect(convertSurfaceToUISpec(surface)).toEqual({
347
+ spec: {
348
+ $type: "Col",
349
+ children: [
350
+ { $type: "Icon", name: "calendar", size: "lg" },
351
+ {
352
+ $type: "ListView",
353
+ children: [
354
+ {
355
+ $type: "ListViewItem",
356
+ children: { $type: "Markdown", value: "Vertical item" },
357
+ },
358
+ ],
359
+ },
360
+ {
361
+ $type: "Row",
362
+ align: "center",
363
+ children: [{ $type: "Markdown", value: "Horizontal item" }],
364
+ },
365
+ {
366
+ $type: "Select",
367
+ options: [
368
+ { label: "Express", value: "express" },
369
+ { label: "Standard", value: "standard" },
370
+ ],
371
+ placeholder: "Choose delivery",
372
+ label: "Delivery",
373
+ name: "delivery",
374
+ },
375
+ {
376
+ $type: "RadioGroup",
377
+ options: [
378
+ { label: "Free", value: "free" },
379
+ { label: "Pro", value: "pro" },
380
+ ],
381
+ label: "Plan",
382
+ name: "plan",
383
+ defaultValue: "pro",
384
+ },
385
+ {
386
+ $type: "DatePicker",
387
+ value: "2026-06-01",
388
+ min: "2026-01-01",
389
+ max: "2026-12-31",
390
+ label: "Start date",
391
+ name: "startDate",
392
+ },
393
+ ],
394
+ },
395
+ warnings: [],
396
+ });
397
+ });
398
+
399
+ it("does not pass a ChoicePicker value to Select without an initial-value prop", () => {
400
+ const result = convertSurfaceToUISpec(
401
+ surfaceFrom([
402
+ {
403
+ id: "root",
404
+ component: "ChoicePicker",
405
+ value: "express",
406
+ options: [{ label: "Express", value: "express" }],
407
+ },
408
+ ]),
409
+ );
410
+
411
+ expect(result).toEqual({
412
+ spec: {
413
+ $type: "Select",
414
+ options: [{ label: "Express", value: "express" }],
415
+ },
416
+ warnings: [],
417
+ });
418
+ });
419
+
189
420
  it("drops an unresolvable bound prop without throwing", () => {
190
421
  const surface = surfaceFrom([
191
422
  {
@@ -274,6 +505,89 @@ describe("convertSurfaceToUISpec", () => {
274
505
  });
275
506
  });
276
507
 
508
+ it("preserves a horizontal list container when expanding templates", () => {
509
+ const surface = surfaceFrom(
510
+ [
511
+ {
512
+ id: "root",
513
+ component: "List",
514
+ direction: "horizontal",
515
+ align: "center",
516
+ children: {
517
+ template: { componentId: "item", path: "/items" },
518
+ },
519
+ },
520
+ { id: "item", component: "Text", text: { path: "/label" } },
521
+ ],
522
+ { items: [{ label: "One" }, { label: "Two" }] },
523
+ );
524
+
525
+ expect(convertSurfaceToUISpec(surface)).toEqual({
526
+ spec: {
527
+ $type: "Row",
528
+ align: "center",
529
+ children: [
530
+ { $type: "Markdown", value: "One" },
531
+ { $type: "Markdown", value: "Two" },
532
+ ],
533
+ },
534
+ warnings: [],
535
+ });
536
+ });
537
+
538
+ it("counts only emitted template nodes", () => {
539
+ const components = (root: Record<string, unknown>) => [
540
+ root,
541
+ {
542
+ id: "item",
543
+ component: "Row",
544
+ children: Array.from({ length: 49 }, (_, index) => `divider-${index}`),
545
+ },
546
+ ...Array.from({ length: 49 }, (_, index) => ({
547
+ id: `divider-${index}`,
548
+ component: "Divider",
549
+ })),
550
+ ];
551
+ const dataModel = { items: Array.from({ length: 100 }, () => ({})) };
552
+ const results = [
553
+ convertSurfaceToUISpec(
554
+ surfaceFrom(
555
+ components({
556
+ id: "root",
557
+ component: "List",
558
+ direction: "horizontal",
559
+ children: {
560
+ template: { componentId: "item", path: "/items" },
561
+ },
562
+ }),
563
+ dataModel,
564
+ ),
565
+ ),
566
+ convertSurfaceToUISpec(
567
+ surfaceFrom(
568
+ components({
569
+ id: "root",
570
+ component: "CustomList",
571
+ children: {
572
+ template: { componentId: "item", path: "/items" },
573
+ },
574
+ }),
575
+ dataModel,
576
+ ),
577
+ { keepUnknownComponents: true },
578
+ ),
579
+ ];
580
+
581
+ for (const result of results) {
582
+ const children = (result.spec as UIElement).children as UIElement[];
583
+ expect(children).toHaveLength(100);
584
+ expect(children.at(-1)?.children as UIElement[]).toHaveLength(48);
585
+ expect(result.warnings).toEqual([
586
+ "A2UI node budget of 5000 was reached.",
587
+ ]);
588
+ }
589
+ });
590
+
277
591
  it("caps template expansion at 100 items", () => {
278
592
  const surface = surfaceFrom(
279
593
  [
@@ -300,6 +614,211 @@ describe("convertSurfaceToUISpec", () => {
300
614
  );
301
615
  });
302
616
 
617
+ it("follows single child references and event actions", () => {
618
+ const surface = surfaceFrom(
619
+ [
620
+ { id: "root", component: "Card", child: "column" },
621
+ {
622
+ id: "column",
623
+ component: "Column",
624
+ children: ["submit", "close"],
625
+ },
626
+ {
627
+ id: "submit",
628
+ component: "Button",
629
+ child: "submit-text",
630
+ action: {
631
+ event: {
632
+ name: "submit",
633
+ context: { carrier: { path: "/carrier" } },
634
+ },
635
+ },
636
+ },
637
+ { id: "submit-text", component: "Text", text: { path: "/label" } },
638
+ {
639
+ id: "close",
640
+ component: "Button",
641
+ child: "close-icon",
642
+ action: { event: { name: "close" } },
643
+ },
644
+ { id: "close-icon", component: "Icon", name: "x" },
645
+ ],
646
+ { carrier: ["dhl"], label: "Create label" },
647
+ );
648
+
649
+ expect(convertSurfaceToUISpec(surface)).toEqual({
650
+ spec: {
651
+ $type: "Card",
652
+ children: [
653
+ {
654
+ $type: "Col",
655
+ children: [
656
+ {
657
+ $type: "Button",
658
+ label: "Create label",
659
+ $action: {
660
+ type: "a2ui:action",
661
+ name: "submit",
662
+ surfaceId: "",
663
+ sourceComponentId: "submit",
664
+ context: { carrier: ["dhl"] },
665
+ },
666
+ },
667
+ {
668
+ $type: "Button",
669
+ $action: {
670
+ type: "a2ui:action",
671
+ name: "close",
672
+ surfaceId: "",
673
+ sourceComponentId: "close",
674
+ },
675
+ children: [{ $type: "Icon", name: "x" }],
676
+ },
677
+ ],
678
+ },
679
+ ],
680
+ },
681
+ warnings: [],
682
+ });
683
+ });
684
+
685
+ it("takes a button label only from a Text child and keeps action data as bound", () => {
686
+ const surface = surfaceFrom(
687
+ [
688
+ {
689
+ id: "root",
690
+ component: "Column",
691
+ children: ["custom-label", "bound-context"],
692
+ },
693
+ { id: "custom-label", component: "Button", child: "note" },
694
+ { id: "note", component: "Markdown", value: "Read me", tone: "loud" },
695
+ {
696
+ id: "bound-context",
697
+ component: "Button",
698
+ label: "Go",
699
+ action: {
700
+ event: { name: "go", context: { target: { path: "/target" } } },
701
+ },
702
+ },
703
+ ],
704
+ { target: { path: "/secret" }, secret: "hidden" },
705
+ );
706
+
707
+ expect(
708
+ convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
709
+ ).toEqual({
710
+ spec: {
711
+ $type: "Col",
712
+ children: [
713
+ {
714
+ $type: "Button",
715
+ children: [{ $type: "Markdown", value: "Read me", tone: "loud" }],
716
+ },
717
+ {
718
+ $type: "Button",
719
+ label: "Go",
720
+ $action: {
721
+ type: "a2ui:action",
722
+ name: "go",
723
+ surfaceId: "",
724
+ sourceComponentId: "bound-context",
725
+ context: { target: { path: "/secret" } },
726
+ },
727
+ },
728
+ ],
729
+ },
730
+ warnings: [],
731
+ });
732
+ });
733
+
734
+ it("warns about a child reference it cannot follow", () => {
735
+ const surface = surfaceFrom([
736
+ { id: "root", component: "Column", children: ["missing", "malformed"] },
737
+ { id: "missing", component: "Card", child: "gone" },
738
+ { id: "malformed", component: "Card", child: 7 },
739
+ ]);
740
+
741
+ expect(convertSurfaceToUISpec(surface)).toEqual({
742
+ spec: {
743
+ $type: "Col",
744
+ children: [{ $type: "Card" }, { $type: "Card" }],
745
+ },
746
+ warnings: [
747
+ 'A2UI component "gone" was not found.',
748
+ 'Component "malformed" has a malformed child reference.',
749
+ ],
750
+ });
751
+ });
752
+
753
+ it("keeps the content of kept Modal, Tabs, and single child components", () => {
754
+ const tabs = [
755
+ { title: "Pickup", child: "pickup" },
756
+ { title: "Drop off", child: "drop-off" },
757
+ ];
758
+ const surface = surfaceFrom([
759
+ {
760
+ id: "root",
761
+ component: "Column",
762
+ children: ["modal", "tabs", "panel"],
763
+ },
764
+ { id: "modal", component: "Modal", trigger: "open", content: "details" },
765
+ {
766
+ id: "open",
767
+ component: "Button",
768
+ child: "open-text",
769
+ action: { event: { name: "open" } },
770
+ },
771
+ { id: "open-text", component: "Text", text: "Details" },
772
+ { id: "details", component: "Text", text: "Ships in two days." },
773
+ { id: "tabs", component: "Tabs", tabs },
774
+ { id: "pickup", component: "Text", text: "A courier collects it." },
775
+ { id: "drop-off", component: "Text", text: "Any service point." },
776
+ { id: "panel", component: "ReturnPanel", child: "note" },
777
+ { id: "note", component: "Text", text: "Returns are free." },
778
+ ]);
779
+
780
+ expect(
781
+ convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
782
+ ).toEqual({
783
+ spec: {
784
+ $type: "Col",
785
+ children: [
786
+ {
787
+ $type: "Modal",
788
+ trigger: "open",
789
+ content: "details",
790
+ children: [
791
+ {
792
+ $type: "Button",
793
+ label: "Details",
794
+ $action: {
795
+ type: "a2ui:action",
796
+ name: "open",
797
+ surfaceId: "",
798
+ sourceComponentId: "open",
799
+ },
800
+ },
801
+ { $type: "Markdown", value: "Ships in two days." },
802
+ ],
803
+ },
804
+ {
805
+ $type: "Tabs",
806
+ tabs,
807
+ children: [
808
+ { $type: "Markdown", value: "A courier collects it." },
809
+ { $type: "Markdown", value: "Any service point." },
810
+ ],
811
+ },
812
+ {
813
+ $type: "ReturnPanel",
814
+ children: [{ $type: "Markdown", value: "Returns are free." }],
815
+ },
816
+ ],
817
+ },
818
+ warnings: [],
819
+ });
820
+ });
821
+
303
822
  it("truncates a component cycle", () => {
304
823
  const surface = surfaceFrom([
305
824
  {
@@ -335,6 +854,28 @@ describe("convertSurfaceToUISpec", () => {
335
854
  expect(result.warnings).toContain("A2UI depth cap of 32 was reached.");
336
855
  });
337
856
 
857
+ it("applies the depth cap to kept unknown components", () => {
858
+ const components = Array.from({ length: 34 }, (_, index) => ({
859
+ id: index === 0 ? "root" : `node-${index}`,
860
+ component: "CustomContainer",
861
+ ...(index < 33
862
+ ? { children: [index === 0 ? "node-1" : `node-${index + 1}`] }
863
+ : {}),
864
+ }));
865
+ const result = convertSurfaceToUISpec(surfaceFrom(components), {
866
+ keepUnknownComponents: true,
867
+ });
868
+ let node = result.spec as UIElement;
869
+ let count = 1;
870
+ while (Array.isArray(node.children) && node.children.length > 0) {
871
+ node = node.children[0] as UIElement;
872
+ count++;
873
+ }
874
+
875
+ expect(count).toBe(32);
876
+ expect(result.warnings).toContain("A2UI depth cap of 32 was reached.");
877
+ });
878
+
338
879
  it("enforces the total emitted node budget", () => {
339
880
  const childCount = 5001;
340
881
  const components = [
@@ -357,6 +898,22 @@ describe("convertSurfaceToUISpec", () => {
357
898
  expect(result.warnings).toContain("A2UI node budget of 5000 was reached.");
358
899
  });
359
900
 
901
+ it("converts a children list longer than the call argument limit", () => {
902
+ const result = convertSurfaceToUISpec(
903
+ surfaceFrom([
904
+ {
905
+ id: "root",
906
+ component: "Row",
907
+ children: Array.from({ length: 500_000 }, () => "leaf"),
908
+ },
909
+ { id: "leaf", component: "Divider" },
910
+ ]),
911
+ );
912
+
913
+ expect((result.spec as UIElement).children).toHaveLength(4999);
914
+ expect(result.warnings).toEqual(["A2UI node budget of 5000 was reached."]);
915
+ });
916
+
360
917
  it("skips an unknown component with a warning naming it", () => {
361
918
  const result = convertSurfaceToUISpec(
362
919
  surfaceFrom([{ id: "root", component: "VideoPlayer" }]),
@@ -368,6 +925,166 @@ describe("convertSurfaceToUISpec", () => {
368
925
  ]);
369
926
  });
370
927
 
928
+ it("skips a supported component that cannot be mapped", () => {
929
+ const surface = surfaceFrom([
930
+ { id: "root", component: "Icon", name: "not-in-the-vocabulary" },
931
+ ]);
932
+ const expected = {
933
+ spec: null,
934
+ warnings: ['A2UI component "Icon" could not be mapped and was skipped.'],
935
+ };
936
+
937
+ expect(convertSurfaceToUISpec(surface)).toEqual(expected);
938
+ expect(
939
+ convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
940
+ ).toEqual(expected);
941
+ });
942
+
943
+ it("optionally keeps unknown components with resolved props and children", () => {
944
+ const surface = surfaceFrom(
945
+ [
946
+ {
947
+ id: "root",
948
+ component: "StatusPill",
949
+ title: { path: "/status/title" },
950
+ details: { level: { path: "/status/level" } },
951
+ children: ["text"],
952
+ },
953
+ { id: "text", component: "Text", text: { path: "/status/body" } },
954
+ ],
955
+ {
956
+ status: {
957
+ title: "Deployment",
958
+ level: "success",
959
+ body: "Ready",
960
+ },
961
+ },
962
+ );
963
+
964
+ expect(convertSurfaceToUISpec(surface)).toEqual({
965
+ spec: null,
966
+ warnings: ['Unknown A2UI component "StatusPill" was skipped.'],
967
+ });
968
+ expect(
969
+ convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
970
+ ).toEqual({
971
+ spec: {
972
+ $type: "StatusPill",
973
+ title: "Deployment",
974
+ details: { level: "success" },
975
+ children: [{ $type: "Markdown", value: "Ready" }],
976
+ },
977
+ warnings: [],
978
+ });
979
+ });
980
+
981
+ it("drops framework props from kept unknown components", () => {
982
+ const staticSurface = surfaceFrom([
983
+ {
984
+ id: "root",
985
+ component: "StatusPill",
986
+ label: "Ready",
987
+ $action: { type: "injected" },
988
+ $key: "injected",
989
+ $status: "injected",
990
+ },
991
+ ]);
992
+ const templateSurface = surfaceFrom(
993
+ [
994
+ {
995
+ id: "root",
996
+ component: "CustomList",
997
+ title: "Tasks",
998
+ $action: { type: "injected" },
999
+ $key: "injected",
1000
+ $status: "injected",
1001
+ children: {
1002
+ template: { componentId: "item", path: "/items" },
1003
+ },
1004
+ },
1005
+ { id: "item", component: "Text", text: { path: "/label" } },
1006
+ ],
1007
+ { items: [{ label: "One" }] },
1008
+ );
1009
+
1010
+ expect(
1011
+ convertSurfaceToUISpec(staticSurface, { keepUnknownComponents: true }),
1012
+ ).toEqual({
1013
+ spec: { $type: "StatusPill", label: "Ready" },
1014
+ warnings: [],
1015
+ });
1016
+ expect(
1017
+ convertSurfaceToUISpec(templateSurface, { keepUnknownComponents: true }),
1018
+ ).toEqual({
1019
+ spec: {
1020
+ $type: "CustomList",
1021
+ title: "Tasks",
1022
+ children: [{ $type: "Markdown", value: "One" }],
1023
+ },
1024
+ warnings: [],
1025
+ });
1026
+ });
1027
+
1028
+ it("expands an unknown component with template children into a list by default", () => {
1029
+ const surface = surfaceFrom(
1030
+ [
1031
+ {
1032
+ id: "root",
1033
+ component: "CustomList",
1034
+ children: {
1035
+ template: { componentId: "item", path: "/items" },
1036
+ },
1037
+ },
1038
+ { id: "item", component: "Text", text: { path: "/label" } },
1039
+ ],
1040
+ { items: [{ label: "One" }] },
1041
+ );
1042
+
1043
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1044
+ spec: {
1045
+ $type: "ListView",
1046
+ children: [
1047
+ {
1048
+ $type: "ListViewItem",
1049
+ children: { $type: "Markdown", value: "One" },
1050
+ },
1051
+ ],
1052
+ },
1053
+ warnings: [],
1054
+ });
1055
+ });
1056
+
1057
+ it("keeps unknown template components as resolved nodes", () => {
1058
+ const surface = surfaceFrom(
1059
+ [
1060
+ {
1061
+ id: "root",
1062
+ component: "CustomList",
1063
+ title: { path: "/title" },
1064
+ children: {
1065
+ template: { componentId: "item", path: "/items" },
1066
+ },
1067
+ },
1068
+ { id: "item", component: "Text", text: { path: "/label" } },
1069
+ ],
1070
+ { title: "Tasks", items: [{ label: "One" }, { label: "Two" }] },
1071
+ );
1072
+
1073
+ expect(
1074
+ convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
1075
+ ).toEqual({
1076
+ spec: {
1077
+ $type: "CustomList",
1078
+ title: "Tasks",
1079
+ children: [
1080
+ { $type: "Markdown", value: "One" },
1081
+ { $type: "Markdown", value: "Two" },
1082
+ ],
1083
+ },
1084
+ warnings: [],
1085
+ });
1086
+ });
1087
+
371
1088
  it("returns null with a warning when root is missing", () => {
372
1089
  expect(
373
1090
  convertSurfaceToUISpec(