@assistant-ui/react-generative-ui 0.0.20 → 0.0.22

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 (189) hide show
  1. package/LICENSE +1 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +30 -13
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/RadioGroupScope.d.ts +6 -0
  6. package/dist/RadioGroupScope.d.ts.map +1 -0
  7. package/dist/RadioGroupScope.js +25 -0
  8. package/dist/RadioGroupScope.js.map +1 -0
  9. package/dist/a2ui/BindingContext.d.ts +11 -0
  10. package/dist/a2ui/BindingContext.d.ts.map +1 -0
  11. package/dist/a2ui/BindingContext.js +12 -0
  12. package/dist/a2ui/BindingContext.js.map +1 -0
  13. package/dist/a2ui/PresentRenderer.d.ts +11 -0
  14. package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
  15. package/dist/a2ui/PresentRenderer.js +39 -0
  16. package/dist/a2ui/PresentRenderer.js.map +1 -0
  17. package/dist/a2ui/convert.d.ts +6 -0
  18. package/dist/a2ui/convert.d.ts.map +1 -1
  19. package/dist/a2ui/convert.js +369 -87
  20. package/dist/a2ui/convert.js.map +1 -1
  21. package/dist/a2ui/dataModel.d.ts +9 -0
  22. package/dist/a2ui/dataModel.d.ts.map +1 -0
  23. package/dist/a2ui/dataModel.js +44 -0
  24. package/dist/a2ui/dataModel.js.map +1 -0
  25. package/dist/a2ui/reducer.d.ts +5 -0
  26. package/dist/a2ui/reducer.d.ts.map +1 -1
  27. package/dist/a2ui/reducer.js +3 -3
  28. package/dist/a2ui/reducer.js.map +1 -1
  29. package/dist/a2ui/types.d.ts +8 -2
  30. package/dist/a2ui/types.d.ts.map +1 -1
  31. package/dist/a2ui/types.js.map +1 -1
  32. package/dist/a2ui/valueFunctions.d.ts +16 -0
  33. package/dist/a2ui/valueFunctions.d.ts.map +1 -0
  34. package/dist/a2ui/valueFunctions.js +332 -0
  35. package/dist/a2ui/valueFunctions.js.map +1 -0
  36. package/dist/actionRegistry.d.ts +1 -1
  37. package/dist/actionRegistry.js.map +1 -1
  38. package/dist/answeredValues.d.ts +16 -0
  39. package/dist/answeredValues.d.ts.map +1 -0
  40. package/dist/answeredValues.js +37 -0
  41. package/dist/answeredValues.js.map +1 -0
  42. package/dist/constants.d.ts +2 -0
  43. package/dist/constants.d.ts.map +1 -1
  44. package/dist/constants.js +3 -1
  45. package/dist/constants.js.map +1 -1
  46. package/dist/fieldReferences.d.ts +9 -0
  47. package/dist/fieldReferences.d.ts.map +1 -0
  48. package/dist/fieldReferences.js +53 -0
  49. package/dist/fieldReferences.js.map +1 -0
  50. package/dist/generativeUIToJSX.js +3 -3
  51. package/dist/generativeUIToJSX.js.map +1 -1
  52. package/dist/index.js +2 -2
  53. package/dist/index.server.js +2 -2
  54. package/dist/index.shared.js +2 -2
  55. package/dist/ir.d.ts +1 -1
  56. package/dist/ir.js.map +1 -1
  57. package/dist/renderGenerativeUI.d.ts.map +1 -1
  58. package/dist/renderGenerativeUI.js +12 -1
  59. package/dist/renderGenerativeUI.js.map +1 -1
  60. package/dist/slack/constants.d.ts +2 -0
  61. package/dist/slack/constants.d.ts.map +1 -1
  62. package/dist/slack/constants.js +3 -1
  63. package/dist/slack/constants.js.map +1 -1
  64. package/dist/slack/decodeBlockAction.d.ts +3 -1
  65. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  66. package/dist/slack/decodeBlockAction.js +58 -7
  67. package/dist/slack/decodeBlockAction.js.map +1 -1
  68. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  69. package/dist/slack/fromSlackBlocks.js +20 -3
  70. package/dist/slack/fromSlackBlocks.js.map +1 -1
  71. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  72. package/dist/slack/toSlackBlocks.js +187 -19
  73. package/dist/slack/toSlackBlocks.js.map +1 -1
  74. package/dist/slack/types.d.ts +4 -0
  75. package/dist/slack/types.d.ts.map +1 -1
  76. package/dist/teams/constants.d.ts +1 -1
  77. package/dist/teams/constants.d.ts.map +1 -1
  78. package/dist/teams/constants.js +1 -1
  79. package/dist/teams/constants.js.map +1 -1
  80. package/dist/teams/decodeSubmitData.d.ts +4 -0
  81. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  82. package/dist/teams/decodeSubmitData.js +6 -1
  83. package/dist/teams/decodeSubmitData.js.map +1 -1
  84. package/dist/teams/toAdaptiveCard.d.ts +5 -4
  85. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  86. package/dist/teams/toAdaptiveCard.js +56 -17
  87. package/dist/teams/toAdaptiveCard.js.map +1 -1
  88. package/dist/teams/types.d.ts +4 -1
  89. package/dist/teams/types.d.ts.map +1 -1
  90. package/dist/vocabulary/alert.d.ts +9 -2
  91. package/dist/vocabulary/alert.d.ts.map +1 -1
  92. package/dist/vocabulary/alert.js +116 -19
  93. package/dist/vocabulary/alert.js.map +1 -1
  94. package/dist/vocabulary/collectFormValues.d.ts +2 -1
  95. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  96. package/dist/vocabulary/collectFormValues.js +10 -3
  97. package/dist/vocabulary/collectFormValues.js.map +1 -1
  98. package/dist/vocabulary/data.d.ts +22 -5
  99. package/dist/vocabulary/data.d.ts.map +1 -1
  100. package/dist/vocabulary/data.js +278 -83
  101. package/dist/vocabulary/data.js.map +1 -1
  102. package/dist/vocabulary/dispatch.d.ts +3 -2
  103. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  104. package/dist/vocabulary/dispatch.js +33 -4
  105. package/dist/vocabulary/dispatch.js.map +1 -1
  106. package/dist/vocabulary/fact.d.ts +8 -1
  107. package/dist/vocabulary/fact.d.ts.map +1 -1
  108. package/dist/vocabulary/fact.js +37 -11
  109. package/dist/vocabulary/fact.js.map +1 -1
  110. package/dist/vocabulary/form.d.ts.map +1 -1
  111. package/dist/vocabulary/form.js +3 -3
  112. package/dist/vocabulary/form.js.map +1 -1
  113. package/dist/vocabulary/formatValue.d.ts +7 -0
  114. package/dist/vocabulary/formatValue.d.ts.map +1 -0
  115. package/dist/vocabulary/formatValue.js +76 -0
  116. package/dist/vocabulary/formatValue.js.map +1 -0
  117. package/dist/vocabulary/icon.d.ts +1 -1
  118. package/dist/vocabulary/interactive.d.ts +137 -8
  119. package/dist/vocabulary/interactive.d.ts.map +1 -1
  120. package/dist/vocabulary/interactive.js +404 -103
  121. package/dist/vocabulary/interactive.js.map +1 -1
  122. package/dist/vocabulary/layout.d.ts.map +1 -1
  123. package/dist/vocabulary/layout.js +4 -4
  124. package/dist/vocabulary/layout.js.map +1 -1
  125. package/dist/vocabulary/list.js +2 -2
  126. package/dist/vocabulary/list.js.map +1 -1
  127. package/dist/vocabulary/text.d.ts +1 -1
  128. package/package.json +6 -3
  129. package/src/JSONGenerativeUI.client.tsx +84 -40
  130. package/src/JSONGenerativeUI.test.tsx +964 -68
  131. package/src/RadioGroupScope.server.test.tsx +24 -0
  132. package/src/RadioGroupScope.tsx +34 -0
  133. package/src/a2ui/BindingContext.ts +26 -0
  134. package/src/a2ui/PresentRenderer.tsx +83 -0
  135. package/src/a2ui/convert.test.ts +1982 -145
  136. package/src/a2ui/convert.ts +677 -137
  137. package/src/a2ui/dataModel.ts +69 -0
  138. package/src/a2ui/reducer.test.ts +35 -0
  139. package/src/a2ui/reducer.ts +5 -4
  140. package/src/a2ui/types.ts +9 -2
  141. package/src/a2ui/valueFunctions.test.ts +271 -0
  142. package/src/a2ui/valueFunctions.ts +523 -0
  143. package/src/actionRegistry.ts +1 -1
  144. package/src/answeredValues.test.ts +33 -0
  145. package/src/answeredValues.tsx +70 -0
  146. package/src/constants.ts +4 -0
  147. package/src/fieldReferences.test.ts +66 -0
  148. package/src/fieldReferences.ts +83 -0
  149. package/src/generativeUIToJSX.test.ts +18 -0
  150. package/src/generativeUIToJSX.ts +4 -6
  151. package/src/index.server.test.ts +106 -0
  152. package/src/ir.ts +1 -1
  153. package/src/renderGenerativeUI.test.tsx +14 -5
  154. package/src/renderGenerativeUI.tsx +30 -1
  155. package/src/slack/a2uiSlack.test.ts +198 -0
  156. package/src/slack/constants.ts +3 -0
  157. package/src/slack/decodeBlockAction.test.ts +98 -0
  158. package/src/slack/decodeBlockAction.ts +110 -17
  159. package/src/slack/fromSlackBlocks.test.ts +222 -3
  160. package/src/slack/fromSlackBlocks.ts +53 -2
  161. package/src/slack/toSlackBlocks.test.ts +682 -3
  162. package/src/slack/toSlackBlocks.ts +374 -23
  163. package/src/slack/types.ts +4 -0
  164. package/src/teams/constants.ts +1 -1
  165. package/src/teams/decodeSubmitData.test.ts +33 -0
  166. package/src/teams/decodeSubmitData.ts +9 -1
  167. package/src/teams/toAdaptiveCard.test.ts +219 -0
  168. package/src/teams/toAdaptiveCard.ts +93 -19
  169. package/src/teams/types.ts +4 -1
  170. package/src/vocabulary/alert.test.tsx +288 -7
  171. package/src/vocabulary/alert.tsx +168 -26
  172. package/src/vocabulary/collectFormValues.test.ts +28 -1
  173. package/src/vocabulary/collectFormValues.ts +25 -5
  174. package/src/vocabulary/data.test.tsx +248 -6
  175. package/src/vocabulary/data.tsx +398 -100
  176. package/src/vocabulary/dispatch.ts +63 -2
  177. package/src/vocabulary/fact.test.tsx +67 -0
  178. package/src/vocabulary/fact.tsx +40 -9
  179. package/src/vocabulary/form.tsx +7 -2
  180. package/src/vocabulary/formatValue.test.ts +72 -0
  181. package/src/vocabulary/formatValue.ts +127 -0
  182. package/src/vocabulary/index.test.ts +2 -0
  183. package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
  184. package/src/vocabulary/interactive.dom.test.tsx +1699 -0
  185. package/src/vocabulary/interactive.test.tsx +60 -4
  186. package/src/vocabulary/interactive.tsx +770 -151
  187. package/src/vocabulary/layout.test.tsx +6 -4
  188. package/src/vocabulary/layout.tsx +13 -2
  189. package/src/vocabulary/list.tsx +2 -2
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { UIElement } from "../ir";
3
- import { convertSurfaceToUISpec } from "./convert";
3
+ import { resolveFieldReferences } from "../fieldReferences";
4
+ import { convertSurfaceToUISpec, createLiveSurfaceConverter } from "./convert";
4
5
  import { applyA2uiOperations } from "./reducer";
5
6
  import { surfaceToOperations } from "./snapshot";
6
7
  import type { A2uiSurfaceState } from "./types";
@@ -19,6 +20,82 @@ const surfaceFrom = (
19
20
  });
20
21
 
21
22
  describe("convertSurfaceToUISpec", () => {
23
+ it("keeps absolute bindings rooted while resolving relative template bindings locally", () => {
24
+ const surface = surfaceFrom(
25
+ [
26
+ {
27
+ id: "root",
28
+ component: "List",
29
+ children: { componentId: "row", path: "/items" },
30
+ },
31
+ {
32
+ id: "row",
33
+ component: "Row",
34
+ children: ["absolute", "relative", "send", "nested"],
35
+ },
36
+ {
37
+ id: "absolute",
38
+ component: "TextField",
39
+ value: { path: "/currency" },
40
+ },
41
+ { id: "relative", component: "TextField", value: { path: "currency" } },
42
+ {
43
+ id: "send",
44
+ component: "Button",
45
+ label: "Send",
46
+ action: {
47
+ event: {
48
+ name: "send",
49
+ context: {
50
+ absolute: { path: "/currency" },
51
+ relative: { path: "currency" },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ {
57
+ id: "nested",
58
+ component: "List",
59
+ children: { componentId: "label", path: "/labels" },
60
+ },
61
+ { id: "label", component: "Text", text: { path: "name" } },
62
+ ],
63
+ {
64
+ currency: "USD",
65
+ labels: [{ name: "Root label" }],
66
+ items: [{ currency: "EUR", labels: [{ name: "Wrong label" }] }],
67
+ },
68
+ );
69
+
70
+ const result = convertSurfaceToUISpec(surface);
71
+ expect(result.warnings).toEqual([]);
72
+ expect(result.spec).toMatchObject({
73
+ children: [
74
+ {
75
+ children: {
76
+ children: [
77
+ { $type: "Input", name: "/currency", defaultValue: "USD" },
78
+ {
79
+ $type: "Input",
80
+ name: "/items/0/currency",
81
+ defaultValue: "EUR",
82
+ },
83
+ {
84
+ $action: {
85
+ context: {
86
+ absolute: { $field: "/currency", fallback: "USD" },
87
+ relative: { $field: "/items/0/currency", fallback: "EUR" },
88
+ },
89
+ },
90
+ },
91
+ { children: [{ children: { value: "Root label" } }] },
92
+ ],
93
+ },
94
+ },
95
+ ],
96
+ });
97
+ });
98
+
22
99
  it("replays a surface with bindings, templates, and custom components", () => {
23
100
  const initial = applyA2uiOperations(new Map(), [
24
101
  {
@@ -38,14 +115,12 @@ describe("convertSurfaceToUISpec", () => {
38
115
  {
39
116
  id: "items",
40
117
  component: "List",
41
- children: {
42
- template: { componentId: "item", path: "/items" },
43
- },
118
+ children: { componentId: "item", path: "/items" },
44
119
  },
45
120
  {
46
121
  id: "item",
47
122
  component: "Text",
48
- text: { path: "/name" },
123
+ text: { path: "name" },
49
124
  },
50
125
  {
51
126
  id: "custom",
@@ -262,12 +337,13 @@ describe("convertSurfaceToUISpec", () => {
262
337
  $type: "Input",
263
338
  label: "Email",
264
339
  placeholder: "you@example.com",
265
- name: "email",
340
+ name: "/form/email",
341
+ defaultValue: "ada@example.com",
266
342
  },
267
343
  {
268
344
  $type: "Checkbox",
269
345
  label: "Accepted",
270
- name: "accepted",
346
+ name: "/form/accepted",
271
347
  defaultChecked: true,
272
348
  },
273
349
  ],
@@ -305,7 +381,6 @@ describe("convertSurfaceToUISpec", () => {
305
381
  id: "select",
306
382
  component: "ChoicePicker",
307
383
  label: "Delivery",
308
- placeholder: "Choose delivery",
309
384
  variant: "multipleSelection",
310
385
  value: { path: "/form/delivery" },
311
386
  options: [
@@ -363,14 +438,14 @@ describe("convertSurfaceToUISpec", () => {
363
438
  children: [{ $type: "Markdown", value: "Horizontal item" }],
364
439
  },
365
440
  {
366
- $type: "Select",
441
+ $type: "CheckboxGroup",
367
442
  options: [
368
443
  { label: "Express", value: "express" },
369
444
  { label: "Standard", value: "standard" },
370
445
  ],
371
- placeholder: "Choose delivery",
372
446
  label: "Delivery",
373
- name: "delivery",
447
+ name: "/form/delivery",
448
+ defaultValue: ["express"],
374
449
  },
375
450
  {
376
451
  $type: "RadioGroup",
@@ -379,7 +454,7 @@ describe("convertSurfaceToUISpec", () => {
379
454
  { label: "Pro", value: "pro" },
380
455
  ],
381
456
  label: "Plan",
382
- name: "plan",
457
+ name: "/form/plan",
383
458
  defaultValue: "pro",
384
459
  },
385
460
  {
@@ -388,7 +463,65 @@ describe("convertSurfaceToUISpec", () => {
388
463
  min: "2026-01-01",
389
464
  max: "2026-12-31",
390
465
  label: "Start date",
391
- name: "startDate",
466
+ name: "/form/startDate",
467
+ },
468
+ ],
469
+ },
470
+ warnings: [],
471
+ });
472
+ });
473
+
474
+ it("maps an omitted ChoicePicker variant as mutuallyExclusive and multiple selection chips as a CheckboxGroup", () => {
475
+ const surface = surfaceFrom(
476
+ [
477
+ { id: "root", component: "Column", children: ["plan", "toppings"] },
478
+ {
479
+ id: "plan",
480
+ component: "ChoicePicker",
481
+ options: [
482
+ { label: "Free", value: "free" },
483
+ { label: "Pro", value: "pro" },
484
+ ],
485
+ value: { path: "/plan" },
486
+ },
487
+ {
488
+ id: "toppings",
489
+ component: "ChoicePicker",
490
+ variant: "multipleSelection",
491
+ displayStyle: "chips",
492
+ options: [
493
+ { label: "Basil", value: "basil" },
494
+ { label: "Olives", value: "olives" },
495
+ { label: "Onion", value: "onion" },
496
+ ],
497
+ value: { path: "/toppings" },
498
+ },
499
+ ],
500
+ { plan: ["pro"], toppings: ["basil", "onion"] },
501
+ );
502
+
503
+ expect(convertSurfaceToUISpec(surface)).toEqual({
504
+ spec: {
505
+ $type: "Col",
506
+ children: [
507
+ {
508
+ $type: "RadioGroup",
509
+ options: [
510
+ { label: "Free", value: "free" },
511
+ { label: "Pro", value: "pro" },
512
+ ],
513
+ name: "/plan",
514
+ defaultValue: "pro",
515
+ },
516
+ {
517
+ $type: "CheckboxGroup",
518
+ options: [
519
+ { label: "Basil", value: "basil" },
520
+ { label: "Olives", value: "olives" },
521
+ { label: "Onion", value: "onion" },
522
+ ],
523
+ name: "/toppings",
524
+ defaultValue: ["basil", "onion"],
392
525
  },
393
526
  ],
394
527
  },
@@ -396,13 +529,14 @@ describe("convertSurfaceToUISpec", () => {
396
529
  });
397
530
  });
398
531
 
399
- it("does not pass a ChoicePicker value to Select without an initial-value prop", () => {
532
+ it("passes a chips ChoicePicker value to RadioGroup as its initial value", () => {
400
533
  const result = convertSurfaceToUISpec(
401
534
  surfaceFrom([
402
535
  {
403
536
  id: "root",
404
537
  component: "ChoicePicker",
405
- value: "express",
538
+ displayStyle: "chips",
539
+ value: ["express"],
406
540
  options: [{ label: "Express", value: "express" }],
407
541
  },
408
542
  ]),
@@ -410,13 +544,267 @@ describe("convertSurfaceToUISpec", () => {
410
544
 
411
545
  expect(result).toEqual({
412
546
  spec: {
413
- $type: "Select",
547
+ $type: "RadioGroup",
414
548
  options: [{ label: "Express", value: "express" }],
549
+ defaultValue: "express",
415
550
  },
416
551
  warnings: [],
417
552
  });
418
553
  });
419
554
 
555
+ it.each([
556
+ ["obscured", "password", "s3cret"],
557
+ ["obscured", "password", ""],
558
+ ["number", "number", "12.5"],
559
+ ["number", "number", ""],
560
+ ])(
561
+ "maps a %s TextField to a %s Input holding %j",
562
+ (variant, inputType, value) => {
563
+ const result = convertSurfaceToUISpec(
564
+ surfaceFrom(
565
+ [
566
+ { id: "root", component: "Column", children: ["field", "send"] },
567
+ {
568
+ id: "field",
569
+ component: "TextField",
570
+ variant,
571
+ label: "Value",
572
+ value: { path: "/form/value" },
573
+ },
574
+ {
575
+ id: "send",
576
+ component: "Button",
577
+ action: {
578
+ event: { name: "send", context: { form: { path: "/form" } } },
579
+ },
580
+ },
581
+ ],
582
+ { form: { value } },
583
+ ),
584
+ );
585
+
586
+ expect(result.warnings).toEqual([]);
587
+ expect(result.spec?.children).toEqual([
588
+ {
589
+ $type: "Input",
590
+ inputType,
591
+ label: "Value",
592
+ name: "/form/value",
593
+ defaultValue: value,
594
+ },
595
+ {
596
+ $type: "Button",
597
+ $action: {
598
+ type: "a2ui:action",
599
+ name: "send",
600
+ surfaceId: "",
601
+ sourceComponentId: "send",
602
+ context: {
603
+ form: { value: { $field: "/form/value", fallback: value } },
604
+ },
605
+ },
606
+ },
607
+ ]);
608
+ },
609
+ );
610
+
611
+ it.each([
612
+ { mode: {}, value: "2025-12-15", type: "DatePicker" },
613
+ {
614
+ mode: { enableDate: false, enableTime: false },
615
+ value: "2025-12-15",
616
+ type: "DatePicker",
617
+ },
618
+ { mode: { enableDate: true }, value: "2025-12-15", type: "DatePicker" },
619
+ {
620
+ mode: { enableDate: true, enableTime: false },
621
+ value: "2025-12-15",
622
+ type: "DatePicker",
623
+ },
624
+ { mode: { enableDate: true }, value: "", type: "DatePicker" },
625
+ { mode: { enableDate: true }, value: undefined, type: "DatePicker" },
626
+ { mode: {}, value: undefined, type: "DatePicker" },
627
+ { mode: {}, value: "2025-12-15T17:00:00Z", type: "Input" },
628
+ {
629
+ mode: { enableDate: true },
630
+ value: "2025-12-15T17:00:00Z",
631
+ type: "Input",
632
+ },
633
+ {
634
+ mode: { enableDate: true, enableTime: true },
635
+ value: "2025-12-15T17:00:00Z",
636
+ type: "Input",
637
+ },
638
+ {
639
+ mode: { enableDate: true, enableTime: true },
640
+ value: "2025-12-15",
641
+ type: "Input",
642
+ },
643
+ { mode: { enableTime: true }, value: "17:00:00", type: "Input" },
644
+ {
645
+ mode: { enableDate: false, enableTime: true },
646
+ value: "17:00:00",
647
+ type: "Input",
648
+ },
649
+ {
650
+ mode: { enableDate: true },
651
+ value: "2025-12-15T17:00:00+02:00",
652
+ type: "Input",
653
+ },
654
+ ])(
655
+ "maps DateTimeInput $mode holding $value to $type with live field references",
656
+ ({ mode, value, type }) => {
657
+ const reference = {
658
+ $field: "/form/due",
659
+ ...(value !== undefined ? { fallback: value } : {}),
660
+ };
661
+ const result = convertSurfaceToUISpec(
662
+ surfaceFrom(
663
+ [
664
+ {
665
+ id: "root",
666
+ component: "Column",
667
+ children: ["due", "send", "call"],
668
+ },
669
+ {
670
+ id: "due",
671
+ component: "DateTimeInput",
672
+ ...mode,
673
+ label: "Due",
674
+ value: { path: "/form/due" },
675
+ min: "2025-01-01",
676
+ max: "2025-12-31",
677
+ },
678
+ {
679
+ id: "send",
680
+ component: "Button",
681
+ action: {
682
+ event: {
683
+ name: "send",
684
+ context: {
685
+ due: { path: "/form/due" },
686
+ form: { path: "/form" },
687
+ },
688
+ userMessage: { path: "/form/due" },
689
+ },
690
+ },
691
+ },
692
+ {
693
+ id: "call",
694
+ component: "Button",
695
+ action: {
696
+ functionCall: {
697
+ call: "save",
698
+ args: { due: { path: "/form/due" } },
699
+ },
700
+ },
701
+ },
702
+ ],
703
+ { form: value === undefined ? {} : { due: value } },
704
+ ),
705
+ );
706
+
707
+ expect(result.warnings).toEqual([]);
708
+ expect(result.spec?.children).toEqual([
709
+ {
710
+ $type: type,
711
+ label: "Due",
712
+ name: "/form/due",
713
+ ...(value !== undefined
714
+ ? { [type === "DatePicker" ? "value" : "defaultValue"]: value }
715
+ : {}),
716
+ ...(type === "DatePicker"
717
+ ? { min: "2025-01-01", max: "2025-12-31" }
718
+ : {}),
719
+ },
720
+ {
721
+ $type: "Button",
722
+ $action: {
723
+ type: "a2ui:action",
724
+ name: "send",
725
+ surfaceId: "",
726
+ sourceComponentId: "send",
727
+ context: { due: reference, form: { due: reference } },
728
+ userMessage: reference,
729
+ },
730
+ },
731
+ {
732
+ $type: "Button",
733
+ $action: {
734
+ type: "a2ui:functionCall",
735
+ call: "save",
736
+ surfaceId: "",
737
+ sourceComponentId: "call",
738
+ args: { due: reference },
739
+ },
740
+ },
741
+ ]);
742
+ },
743
+ );
744
+
745
+ it.each([undefined, "mutuallyExclusive"])(
746
+ "keeps chips with variant %s list-valued when empty and selected",
747
+ (variant) => {
748
+ for (const value of [[], ["b"]]) {
749
+ const options = [
750
+ { label: "A", value: "a" },
751
+ { label: "B", value: "b" },
752
+ ];
753
+ const result = convertSurfaceToUISpec(
754
+ surfaceFrom(
755
+ [
756
+ { id: "root", component: "Column", children: ["choice", "send"] },
757
+ {
758
+ id: "choice",
759
+ component: "ChoicePicker",
760
+ variant,
761
+ displayStyle: "chips",
762
+ options,
763
+ value: { path: "/form/choice" },
764
+ },
765
+ {
766
+ id: "send",
767
+ component: "Button",
768
+ action: {
769
+ name: "send",
770
+ context: { choice: { path: "/form/choice" } },
771
+ },
772
+ },
773
+ ],
774
+ { form: { choice: value } },
775
+ ),
776
+ );
777
+ const reference = {
778
+ $field: "/form/choice",
779
+ ...(value[0] !== undefined ? { fallback: value[0] } : {}),
780
+ };
781
+ expect(result.warnings).toEqual([]);
782
+ expect(result.spec?.children).toEqual([
783
+ {
784
+ $type: "RadioGroup",
785
+ name: "/form/choice",
786
+ options,
787
+ ...(value[0] !== undefined ? { defaultValue: value[0] } : {}),
788
+ },
789
+ {
790
+ $type: "Button",
791
+ $action: {
792
+ type: "a2ui:action",
793
+ name: "send",
794
+ surfaceId: "",
795
+ sourceComponentId: "send",
796
+ context: { choice: [reference] },
797
+ },
798
+ },
799
+ ]);
800
+ expect(resolveFieldReferences([reference], {})).toEqual(value);
801
+ expect(
802
+ resolveFieldReferences([reference], { "/form/choice": "b" }),
803
+ ).toEqual(["b"]);
804
+ }
805
+ },
806
+ );
807
+
420
808
  it("drops an unresolvable bound prop without throwing", () => {
421
809
  const surface = surfaceFrom([
422
810
  {
@@ -432,186 +820,1543 @@ describe("convertSurfaceToUISpec", () => {
432
820
  });
433
821
  });
434
822
 
435
- it("preserves prototype-named action context fields", () => {
436
- const context = JSON.parse(
437
- '{"__proto__":{"admin":true},"literal":"kept"}',
438
- ) as Record<string, unknown>;
439
- const surface = surfaceFrom([
823
+ it("evaluates v1.0 formatString values against the data model", () => {
824
+ const { state } = applyA2uiOperations(new Map(), [
440
825
  {
441
- id: "root",
442
- component: "Button",
443
- label: "Run",
444
- action: { name: "run", context },
826
+ version: "v1.0",
827
+ createSurface: {
828
+ surfaceId: "functions",
829
+ components: [
830
+ {
831
+ id: "root",
832
+ component: "Text",
833
+ text: {
834
+ call: "formatString",
835
+ args: { value: "Hello ${/name}" },
836
+ },
837
+ },
838
+ ],
839
+ dataModel: { name: "Ada" },
840
+ },
445
841
  },
446
842
  ]);
447
843
 
448
- const result = convertSurfaceToUISpec(surface);
449
- const convertedContext = (result.spec as UIElement).$action
450
- ?.context as Record<string, unknown>;
451
-
452
- expect(Object.getPrototypeOf(convertedContext)).toBe(Object.prototype);
453
- expect(Object.hasOwn(convertedContext, "__proto__")).toBe(true);
454
- expect(convertedContext["__proto__"]).toEqual({ admin: true });
455
- expect(JSON.stringify(convertedContext)).toBe(
456
- '{"__proto__":{"admin":true},"literal":"kept"}',
457
- );
458
- });
459
-
460
- it("does not read component fields through a prototype-named prop", () => {
461
- const component = JSON.parse(
462
- '{"id":"root","component":"Text","__proto__":{"text":"injected"}}',
463
- ) as Record<string, unknown>;
464
-
465
- expect(convertSurfaceToUISpec(surfaceFrom([component]))).toEqual({
466
- spec: { $type: "Markdown" },
844
+ expect(convertSurfaceToUISpec(state.get("functions")!)).toEqual({
845
+ spec: { $type: "Markdown", value: "Hello Ada" },
467
846
  warnings: [],
468
847
  });
469
848
  });
470
849
 
471
- it("expands template children relative to each bound item", () => {
850
+ it("evaluates nested value calls and warns when a value function is unknown", () => {
472
851
  const surface = surfaceFrom(
473
852
  [
474
853
  {
475
854
  id: "root",
476
855
  component: "Column",
477
- children: {
478
- template: { componentId: "item", path: "/items" },
856
+ children: ["message", "enabled", "unknown"],
857
+ },
858
+ {
859
+ id: "message",
860
+ component: "Text",
861
+ text: {
862
+ call: "formatString",
863
+ args: {
864
+ value:
865
+ "${formatNumber(value:${/count}, decimals:0, grouping:false)} items",
866
+ },
479
867
  },
480
868
  },
481
869
  {
482
- id: "item",
870
+ id: "enabled",
871
+ component: "CheckBox",
872
+ label: "Enabled",
873
+ value: { call: "not", args: { value: { path: "/disabled" } } },
874
+ },
875
+ {
876
+ id: "unknown",
483
877
  component: "Text",
484
- text: { path: "/name" },
878
+ text: { call: "customValue", args: { value: "ignored" } },
485
879
  },
486
880
  ],
487
- { items: [{ name: "one" }, { name: "two" }] },
881
+ { count: 12, disabled: false },
488
882
  );
489
883
 
490
884
  expect(convertSurfaceToUISpec(surface)).toEqual({
491
885
  spec: {
492
- $type: "ListView",
886
+ $type: "Col",
493
887
  children: [
888
+ { $type: "Markdown", value: "12 items" },
494
889
  {
495
- $type: "ListViewItem",
496
- children: { $type: "Markdown", value: "one" },
497
- },
498
- {
499
- $type: "ListViewItem",
500
- children: { $type: "Markdown", value: "two" },
890
+ $type: "Checkbox",
891
+ label: "Enabled",
892
+ defaultChecked: true,
501
893
  },
894
+ { $type: "Markdown" },
502
895
  ],
503
896
  },
504
- warnings: [],
897
+ warnings: [
898
+ 'A2UI function "customValue" is not supported and was skipped.',
899
+ ],
505
900
  });
506
901
  });
507
902
 
508
- it("preserves a horizontal list container when expanding templates", () => {
903
+ it("maps a bound Slider with the spec's min default and a continuous step", () => {
509
904
  const surface = surfaceFrom(
510
905
  [
511
906
  {
512
907
  id: "root",
513
- component: "List",
514
- direction: "horizontal",
515
- align: "center",
516
- children: {
517
- template: { componentId: "item", path: "/items" },
518
- },
908
+ component: "Slider",
909
+ label: "Volume",
910
+ max: 1,
911
+ value: { path: "/volume" },
519
912
  },
520
- { id: "item", component: "Text", text: { path: "/label" } },
521
913
  ],
522
- { items: [{ label: "One" }, { label: "Two" }] },
914
+ { volume: 0.35 },
523
915
  );
524
916
 
525
917
  expect(convertSurfaceToUISpec(surface)).toEqual({
526
918
  spec: {
527
- $type: "Row",
528
- align: "center",
529
- children: [
530
- { $type: "Markdown", value: "One" },
531
- { $type: "Markdown", value: "Two" },
532
- ],
919
+ $type: "Slider",
920
+ min: 0,
921
+ max: 1,
922
+ step: 0.01,
923
+ defaultValue: 0.35,
924
+ label: "Volume",
925
+ name: "/volume",
533
926
  },
534
927
  warnings: [],
535
928
  });
536
929
  });
537
930
 
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" },
931
+ it("divides a Slider range by its steps and keeps integer values exact", () => {
932
+ const stepped = surfaceFrom([
933
+ { id: "root", component: "Slider", max: 100, steps: 4, value: 50 },
934
+ ]);
935
+ const wide = surfaceFrom([
936
+ { id: "root", component: "Slider", min: 0, max: 1000, value: 537 },
937
+ ]);
938
+
939
+ expect(convertSurfaceToUISpec(stepped).spec).toEqual({
940
+ $type: "Slider",
941
+ min: 0,
942
+ max: 100,
943
+ step: 25,
944
+ defaultValue: 50,
945
+ });
946
+ expect(convertSurfaceToUISpec(wide).spec).toEqual({
947
+ $type: "Slider",
948
+ min: 0,
949
+ max: 1000,
950
+ step: 1,
951
+ defaultValue: 537,
952
+ });
953
+ });
954
+
955
+ it("sends a Slider's live value to an action bound to its path", () => {
956
+ const surface = surfaceFrom(
957
+ [
958
+ { id: "root", component: "Column", children: ["volume", "save"] },
959
+ {
960
+ id: "volume",
961
+ component: "Slider",
962
+ max: 10,
963
+ value: { path: "/volume" },
964
+ },
965
+ {
966
+ id: "save",
967
+ component: "Button",
968
+ label: "Save",
969
+ action: {
970
+ event: { name: "save", context: { volume: { path: "/volume" } } },
971
+ },
972
+ },
973
+ ],
974
+ { volume: 4 },
975
+ );
976
+
977
+ const result = convertSurfaceToUISpec(surface);
978
+ expect(result.warnings).toEqual([]);
979
+ expect(result.spec).toMatchObject({
980
+ children: [
981
+ { $type: "Slider", name: "/volume", defaultValue: 4 },
982
+ {
983
+ $action: {
984
+ context: { volume: { $field: "/volume", fallback: 4 } },
985
+ },
986
+ },
987
+ ],
988
+ });
989
+ });
990
+
991
+ it("skips a Slider without a numeric max", () => {
992
+ const surface = surfaceFrom([
993
+ { id: "root", component: "Slider", value: 3 },
994
+ ]);
995
+
996
+ expect(convertSurfaceToUISpec(surface).warnings).toEqual([
997
+ 'A2UI component "Slider" could not be mapped and was skipped.',
998
+ ]);
999
+ });
1000
+
1001
+ it("warns and omits a malformed formatString template", () => {
1002
+ const surface = surfaceFrom([
1003
+ {
1004
+ id: "root",
1005
+ component: "Text",
1006
+ text: { call: "formatString", args: { value: "Hi ${name" } },
1007
+ },
1008
+ ]);
1009
+
1010
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1011
+ spec: { $type: "Markdown" },
1012
+ warnings: [
1013
+ "A2UI formatString template is malformed: an interpolation is not closed.",
1014
+ ],
1015
+ });
1016
+ });
1017
+
1018
+ it("resolves relative paths in bindings and function arguments against the current item", () => {
1019
+ const usd = (value: number) =>
1020
+ new Intl.NumberFormat(undefined, {
1021
+ style: "currency",
1022
+ currency: "USD",
1023
+ }).format(value);
1024
+ const surface = surfaceFrom(
1025
+ [
1026
+ { id: "root", component: "Column", children: ["total", "rows"] },
1027
+ {
1028
+ id: "total",
1029
+ component: "Text",
1030
+ text: {
1031
+ call: "formatCurrency",
1032
+ args: { value: { path: "total" }, currency: "USD" },
1033
+ },
1034
+ },
1035
+ {
1036
+ id: "rows",
1037
+ component: "Column",
1038
+ children: { componentId: "row", path: "/items" },
1039
+ },
1040
+ {
1041
+ id: "row",
1042
+ component: "Text",
1043
+ text: {
1044
+ call: "formatString",
1045
+ args: {
1046
+ value:
1047
+ "${name}: ${formatCurrency(value: ${price}, currency: 'USD')}",
1048
+ },
1049
+ },
1050
+ },
1051
+ ],
1052
+ { total: 12, items: [{ name: "Tea", price: 3 }] },
1053
+ );
1054
+
1055
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1056
+ spec: {
1057
+ $type: "Col",
1058
+ children: [
1059
+ { $type: "Markdown", value: usd(12) },
1060
+ {
1061
+ $type: "Col",
1062
+ children: [{ $type: "Markdown", value: `Tea: ${usd(3)}` }],
1063
+ },
1064
+ ],
1065
+ },
1066
+ warnings: [],
1067
+ });
1068
+ });
1069
+
1070
+ it("evaluates values inside actions but leaves a functionCall for the host to run", () => {
1071
+ const surface = surfaceFrom(
1072
+ [
1073
+ { id: "root", component: "Row", children: ["save", "docs"] },
1074
+ {
1075
+ id: "save",
1076
+ component: "Button",
1077
+ label: "Save",
1078
+ action: {
1079
+ event: {
1080
+ name: "save",
1081
+ context: {
1082
+ summary: {
1083
+ call: "formatString",
1084
+ args: { value: "${/count} items" },
1085
+ },
1086
+ },
1087
+ },
1088
+ },
1089
+ },
1090
+ {
1091
+ id: "docs",
1092
+ component: "Button",
1093
+ label: "Docs",
1094
+ action: {
1095
+ functionCall: {
1096
+ call: "openUrl",
1097
+ args: {
1098
+ url: {
1099
+ call: "formatString",
1100
+ args: { value: "https://example.com/${/slug}" },
1101
+ },
1102
+ },
1103
+ },
1104
+ },
1105
+ },
1106
+ ],
1107
+ { count: 2, slug: "a2ui" },
1108
+ );
1109
+
1110
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1111
+ spec: {
1112
+ $type: "Row",
1113
+ children: [
1114
+ {
1115
+ $type: "Button",
1116
+ label: "Save",
1117
+ $action: {
1118
+ type: "a2ui:action",
1119
+ name: "save",
1120
+ surfaceId: "",
1121
+ sourceComponentId: "save",
1122
+ context: { summary: "2 items" },
1123
+ },
1124
+ },
1125
+ {
1126
+ $type: "Button",
1127
+ label: "Docs",
1128
+ $action: {
1129
+ type: "a2ui:functionCall",
1130
+ call: "openUrl",
1131
+ surfaceId: "",
1132
+ sourceComponentId: "docs",
1133
+ args: { url: "https://example.com/a2ui" },
1134
+ },
1135
+ },
1136
+ ],
1137
+ },
1138
+ warnings: [],
1139
+ });
1140
+ });
1141
+
1142
+ it("reads a function's arguments entry by entry, even when they look like a binding or a call", () => {
1143
+ const surface = surfaceFrom([
1144
+ { id: "root", component: "Column", children: ["bound", "nested", "ok"] },
1145
+ {
1146
+ id: "bound",
1147
+ component: "Text",
1148
+ text: { call: "formatNumber", args: { path: "/missing" } },
1149
+ },
1150
+ {
1151
+ id: "nested",
1152
+ component: "Text",
1153
+ text: {
1154
+ call: "formatString",
1155
+ args: { value: "${formatNumber(call: 'x')}" },
1156
+ },
1157
+ },
1158
+ { id: "ok", component: "Text", text: "still here" },
1159
+ ]);
1160
+
1161
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1162
+ spec: {
1163
+ $type: "Col",
1164
+ children: [
1165
+ { $type: "Markdown", value: "" },
1166
+ { $type: "Markdown", value: "" },
1167
+ { $type: "Markdown", value: "still here" },
1168
+ ],
1169
+ },
1170
+ warnings: [],
1171
+ });
1172
+ });
1173
+
1174
+ it("keeps a missing operand in and and or instead of dropping it", () => {
1175
+ const operands = [{ path: "/agreed" }, { path: "/verified" }];
1176
+ const surface = surfaceFrom(
1177
+ [
1178
+ { id: "root", component: "Column", children: ["all", "any"] },
1179
+ {
1180
+ id: "all",
1181
+ component: "CheckBox",
1182
+ label: "All",
1183
+ value: { call: "and", args: { values: operands } },
1184
+ },
1185
+ {
1186
+ id: "any",
1187
+ component: "CheckBox",
1188
+ label: "Any",
1189
+ value: { call: "or", args: { values: operands } },
1190
+ },
1191
+ ],
1192
+ { verified: true },
1193
+ );
1194
+
1195
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1196
+ spec: {
1197
+ $type: "Col",
1198
+ children: [
1199
+ { $type: "Checkbox", label: "All", defaultChecked: false },
1200
+ { $type: "Checkbox", label: "Any", defaultChecked: true },
1201
+ ],
1202
+ },
1203
+ warnings: [],
1204
+ });
1205
+ });
1206
+
1207
+ it("stops evaluating functions once the evaluation budget is spent", () => {
1208
+ const surface = surfaceFrom(
1209
+ [
1210
+ {
1211
+ id: "root",
1212
+ component: "Text",
1213
+ text: { call: "formatString", args: { value: { path: "/t" } } },
1214
+ },
1215
+ ],
1216
+ { t: "${formatString(value: /t)}".repeat(380) },
1217
+ );
1218
+
1219
+ const result = convertSurfaceToUISpec(surface);
1220
+
1221
+ expect(result.spec).toEqual({ $type: "Markdown", value: "" });
1222
+ expect(result.warnings).toEqual([
1223
+ "A2UI function nesting cap of 32 was reached.",
1224
+ "A2UI function evaluation budget of 20000 was reached.",
1225
+ ]);
1226
+ });
1227
+
1228
+ it("keeps an object with a call key and other fields as data", () => {
1229
+ const surface = surfaceFrom([
1230
+ {
1231
+ id: "root",
1232
+ component: "ContactCard",
1233
+ phone: { call: "+1 555 0100", label: "Office" },
1234
+ },
1235
+ ]);
1236
+
1237
+ expect(
1238
+ convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
1239
+ ).toEqual({
1240
+ spec: {
1241
+ $type: "ContactCard",
1242
+ phone: { call: "+1 555 0100", label: "Office" },
1243
+ },
1244
+ warnings: [],
1245
+ });
1246
+ });
1247
+
1248
+ it("leaves validation check calls outside value evaluation", () => {
1249
+ const surface = surfaceFrom([
1250
+ {
1251
+ id: "root",
1252
+ component: "TextField",
1253
+ label: "Name",
1254
+ checks: [{ call: "required", args: { value: { path: "/name" } } }],
1255
+ },
1256
+ ]);
1257
+
1258
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1259
+ spec: { $type: "Input", label: "Name" },
1260
+ warnings: [],
1261
+ });
1262
+ });
1263
+
1264
+ it("preserves prototype-named action context fields", () => {
1265
+ const context = JSON.parse(
1266
+ '{"__proto__":{"admin":true},"literal":"kept"}',
1267
+ ) as Record<string, unknown>;
1268
+ const surface = surfaceFrom([
1269
+ {
1270
+ id: "root",
1271
+ component: "Button",
1272
+ label: "Run",
1273
+ action: { name: "run", context },
1274
+ },
1275
+ ]);
1276
+
1277
+ const result = convertSurfaceToUISpec(surface);
1278
+ const convertedContext = (result.spec as UIElement).$action
1279
+ ?.context as Record<string, unknown>;
1280
+
1281
+ expect(Object.getPrototypeOf(convertedContext)).toBe(Object.prototype);
1282
+ expect(Object.hasOwn(convertedContext, "__proto__")).toBe(true);
1283
+ expect(convertedContext["__proto__"]).toEqual({ admin: true });
1284
+ expect(JSON.stringify(convertedContext)).toBe(
1285
+ '{"__proto__":{"admin":true},"literal":"kept"}',
1286
+ );
1287
+ });
1288
+
1289
+ it("does not read component fields through a prototype-named prop", () => {
1290
+ const component = JSON.parse(
1291
+ '{"id":"root","component":"Text","__proto__":{"text":"injected"}}',
1292
+ ) as Record<string, unknown>;
1293
+
1294
+ expect(convertSurfaceToUISpec(surfaceFrom([component]))).toEqual({
1295
+ spec: { $type: "Markdown" },
1296
+ warnings: [],
1297
+ });
1298
+ });
1299
+
1300
+ it("expands template children relative to each bound item", () => {
1301
+ const surface = surfaceFrom(
1302
+ [
1303
+ {
1304
+ id: "root",
1305
+ component: "List",
1306
+ children: { componentId: "item", path: "/items" },
1307
+ },
1308
+ {
1309
+ id: "item",
1310
+ component: "Text",
1311
+ text: { path: "name" },
1312
+ },
1313
+ ],
1314
+ { items: [{ name: "one" }, { name: "two" }] },
1315
+ );
1316
+
1317
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1318
+ spec: {
1319
+ $type: "ListView",
1320
+ children: [
1321
+ {
1322
+ $type: "ListViewItem",
1323
+ children: { $type: "Markdown", value: "one" },
1324
+ },
1325
+ {
1326
+ $type: "ListViewItem",
1327
+ children: { $type: "Markdown", value: "two" },
1328
+ },
1329
+ ],
1330
+ },
1331
+ warnings: [],
1332
+ });
1333
+ });
1334
+
1335
+ it("keeps a Row or Column template in its own container", () => {
1336
+ const surface = surfaceFrom(
1337
+ [
1338
+ { id: "root", component: "Column", children: ["days", "hours"] },
1339
+ {
1340
+ id: "days",
1341
+ component: "Row",
1342
+ justify: "spaceBetween",
1343
+ children: { componentId: "day", path: "/days" },
1344
+ },
1345
+ {
1346
+ id: "hours",
1347
+ component: "Column",
1348
+ align: "center",
1349
+ children: { componentId: "day", path: "/hours" },
1350
+ },
1351
+ { id: "day", component: "Text", text: { path: "label" } },
1352
+ ],
1353
+ {
1354
+ days: [{ label: "Tue" }, { label: "Wed" }],
1355
+ hours: [{ label: "9am" }],
1356
+ },
1357
+ );
1358
+
1359
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1360
+ spec: {
1361
+ $type: "Col",
1362
+ children: [
1363
+ {
1364
+ $type: "Row",
1365
+ justify: "spaceBetween",
1366
+ children: [
1367
+ { $type: "Markdown", value: "Tue" },
1368
+ { $type: "Markdown", value: "Wed" },
1369
+ ],
1370
+ },
1371
+ {
1372
+ $type: "Col",
1373
+ align: "center",
1374
+ children: [{ $type: "Markdown", value: "9am" }],
1375
+ },
1376
+ ],
1377
+ },
1378
+ warnings: [],
1379
+ });
1380
+ });
1381
+
1382
+ it("expands a template on a component without a child list into a ListView", () => {
1383
+ const surface = surfaceFrom(
1384
+ [
1385
+ {
1386
+ id: "root",
1387
+ component: "Divider",
1388
+ children: { componentId: "item", path: "/items" },
1389
+ },
1390
+ { id: "item", component: "Text", text: { path: "name" } },
1391
+ ],
1392
+ { items: [{ name: "one" }] },
1393
+ );
1394
+
1395
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1396
+ spec: {
1397
+ $type: "ListView",
1398
+ children: [
1399
+ {
1400
+ $type: "ListViewItem",
1401
+ children: { $type: "Markdown", value: "one" },
1402
+ },
1403
+ ],
1404
+ },
1405
+ warnings: [],
1406
+ });
1407
+ });
1408
+
1409
+ it("reads template children wrapped in a template key", () => {
1410
+ const surface = surfaceFrom(
1411
+ [
1412
+ {
1413
+ id: "root",
1414
+ component: "List",
1415
+ children: { template: { componentId: "item", path: "/items" } },
1416
+ },
1417
+ { id: "item", component: "Text", text: { path: "name" } },
1418
+ ],
1419
+ { items: [{ name: "one" }] },
1420
+ );
1421
+
1422
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1423
+ spec: {
1424
+ $type: "ListView",
1425
+ children: [
1426
+ {
1427
+ $type: "ListViewItem",
1428
+ children: { $type: "Markdown", value: "one" },
1429
+ },
1430
+ ],
1431
+ },
1432
+ warnings: [],
1433
+ });
1434
+ });
1435
+
1436
+ it("preserves a horizontal list container when expanding templates", () => {
1437
+ const surface = surfaceFrom(
1438
+ [
1439
+ {
1440
+ id: "root",
1441
+ component: "List",
1442
+ direction: "horizontal",
1443
+ align: "center",
1444
+ children: { componentId: "item", path: "/items" },
1445
+ },
1446
+ { id: "item", component: "Text", text: { path: "label" } },
1447
+ ],
1448
+ { items: [{ label: "One" }, { label: "Two" }] },
1449
+ );
1450
+
1451
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1452
+ spec: {
1453
+ $type: "Row",
1454
+ align: "center",
1455
+ children: [
1456
+ { $type: "Markdown", value: "One" },
1457
+ { $type: "Markdown", value: "Two" },
1458
+ ],
1459
+ },
1460
+ warnings: [],
1461
+ });
1462
+ });
1463
+
1464
+ it("counts only emitted template nodes", () => {
1465
+ const components = (root: Record<string, unknown>) => [
1466
+ root,
1467
+ {
1468
+ id: "item",
1469
+ component: "Row",
1470
+ children: Array.from({ length: 49 }, (_, index) => `divider-${index}`),
1471
+ },
1472
+ ...Array.from({ length: 49 }, (_, index) => ({
1473
+ id: `divider-${index}`,
1474
+ component: "Divider",
1475
+ })),
1476
+ ];
1477
+ const dataModel = { items: Array.from({ length: 100 }, () => ({})) };
1478
+ const results = [
1479
+ convertSurfaceToUISpec(
1480
+ surfaceFrom(
1481
+ components({
1482
+ id: "root",
1483
+ component: "List",
1484
+ direction: "horizontal",
1485
+ children: { componentId: "item", path: "/items" },
1486
+ }),
1487
+ dataModel,
1488
+ ),
1489
+ ),
1490
+ convertSurfaceToUISpec(
1491
+ surfaceFrom(
1492
+ components({
1493
+ id: "root",
1494
+ component: "CustomList",
1495
+ children: { componentId: "item", path: "/items" },
1496
+ }),
1497
+ dataModel,
1498
+ ),
1499
+ { keepUnknownComponents: true },
1500
+ ),
1501
+ ];
1502
+
1503
+ for (const result of results) {
1504
+ const children = (result.spec as UIElement).children as UIElement[];
1505
+ expect(children).toHaveLength(100);
1506
+ expect(children.at(-1)?.children as UIElement[]).toHaveLength(48);
1507
+ expect(result.warnings).toEqual([
1508
+ "A2UI node budget of 5000 was reached.",
1509
+ ]);
1510
+ }
1511
+ });
1512
+
1513
+ it("caps template expansion at 100 items", () => {
1514
+ const surface = surfaceFrom(
1515
+ [
1516
+ {
1517
+ id: "root",
1518
+ component: "Column",
1519
+ children: { componentId: "item", path: "/items" },
1520
+ },
1521
+ {
1522
+ id: "item",
1523
+ component: "Text",
1524
+ text: { path: "/" },
1525
+ },
1526
+ ],
1527
+ { items: Array.from({ length: 101 }, (_, index) => `item-${index}`) },
1528
+ );
1529
+
1530
+ const result = convertSurfaceToUISpec(surface);
1531
+ expect((result.spec as UIElement).children).toHaveLength(100);
1532
+ expect(result.warnings).toContain(
1533
+ "A2UI template expansion was capped at 100 items.",
1534
+ );
1535
+ });
1536
+
1537
+ it("follows single child references and event actions", () => {
1538
+ const surface = surfaceFrom(
1539
+ [
1540
+ { id: "root", component: "Card", child: "column" },
1541
+ {
1542
+ id: "column",
1543
+ component: "Column",
1544
+ children: ["submit", "close"],
1545
+ },
1546
+ {
1547
+ id: "submit",
1548
+ component: "Button",
1549
+ child: "submit-text",
1550
+ action: {
1551
+ event: {
1552
+ name: "submit",
1553
+ context: { carrier: { path: "/carrier" } },
1554
+ },
1555
+ },
1556
+ },
1557
+ { id: "submit-text", component: "Text", text: { path: "/label" } },
1558
+ {
1559
+ id: "close",
1560
+ component: "Button",
1561
+ child: "close-icon",
1562
+ action: { event: { name: "close" } },
1563
+ },
1564
+ { id: "close-icon", component: "Icon", name: "x" },
1565
+ ],
1566
+ { carrier: ["dhl"], label: "Create label" },
1567
+ );
1568
+
1569
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1570
+ spec: {
1571
+ $type: "Card",
1572
+ children: [
1573
+ {
1574
+ $type: "Col",
1575
+ children: [
1576
+ {
1577
+ $type: "Button",
1578
+ label: "Create label",
1579
+ $action: {
1580
+ type: "a2ui:action",
1581
+ name: "submit",
1582
+ surfaceId: "",
1583
+ sourceComponentId: "submit",
1584
+ context: { carrier: ["dhl"] },
1585
+ },
1586
+ },
1587
+ {
1588
+ $type: "Button",
1589
+ $action: {
1590
+ type: "a2ui:action",
1591
+ name: "close",
1592
+ surfaceId: "",
1593
+ sourceComponentId: "close",
1594
+ },
1595
+ children: [{ $type: "Icon", name: "x" }],
1596
+ },
1597
+ ],
1598
+ },
1599
+ ],
1600
+ },
1601
+ warnings: [],
1602
+ });
1603
+ });
1604
+
1605
+ it("carries an event's userMessage when it resolves to a string", () => {
1606
+ const surface = surfaceFrom(
1607
+ [
1608
+ { id: "root", component: "Row", children: ["greet", "count"] },
1609
+ {
1610
+ id: "greet",
1611
+ component: "Button",
1612
+ label: "Greet",
1613
+ action: {
1614
+ event: {
1615
+ name: "greet",
1616
+ userMessage: {
1617
+ call: "formatString",
1618
+ args: { value: "Greet ${/name}" },
1619
+ },
1620
+ },
1621
+ },
1622
+ },
1623
+ {
1624
+ id: "count",
1625
+ component: "Button",
1626
+ label: "Count",
1627
+ action: { event: { name: "count", userMessage: { path: "/count" } } },
1628
+ },
1629
+ ],
1630
+ { name: "Ada", count: 3 },
1631
+ );
1632
+
1633
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1634
+ spec: {
1635
+ $type: "Row",
1636
+ children: [
1637
+ {
1638
+ $type: "Button",
1639
+ label: "Greet",
1640
+ $action: {
1641
+ type: "a2ui:action",
1642
+ name: "greet",
1643
+ surfaceId: "",
1644
+ sourceComponentId: "greet",
1645
+ userMessage: "Greet Ada",
1646
+ },
1647
+ },
1648
+ {
1649
+ $type: "Button",
1650
+ label: "Count",
1651
+ $action: {
1652
+ type: "a2ui:action",
1653
+ name: "count",
1654
+ surfaceId: "",
1655
+ sourceComponentId: "count",
1656
+ },
1657
+ },
1658
+ ],
1659
+ },
1660
+ warnings: [],
1661
+ });
1662
+ });
1663
+
1664
+ it("points a userMessage only at a lone text input bound to its exact path", () => {
1665
+ const surface = surfaceFrom(
1666
+ [
1667
+ {
1668
+ id: "root",
1669
+ component: "Column",
1670
+ children: [
1671
+ "email",
1672
+ "volume",
1673
+ "note",
1674
+ "qty",
1675
+ "qty-level",
1676
+ "form",
1677
+ "level",
1678
+ "draft",
1679
+ "copy",
1680
+ ],
1681
+ },
1682
+ { id: "email", component: "TextField", value: { path: "/form/email" } },
1683
+ {
1684
+ id: "volume",
1685
+ component: "Slider",
1686
+ max: 10,
1687
+ value: { path: "/volume" },
1688
+ },
1689
+ { id: "note", component: "TextField", value: { path: "/note" } },
1690
+ { id: "qty", component: "TextField", value: { path: "/qty" } },
1691
+ {
1692
+ id: "qty-level",
1693
+ component: "Slider",
1694
+ max: 10,
1695
+ value: { path: "/qty" },
1696
+ },
1697
+ {
1698
+ id: "form",
1699
+ component: "Button",
1700
+ label: "Form",
1701
+ action: { event: { name: "form", userMessage: { path: "/form" } } },
1702
+ },
1703
+ {
1704
+ id: "level",
1705
+ component: "Button",
1706
+ label: "Level",
1707
+ action: {
1708
+ event: { name: "level", userMessage: { path: "/volume" } },
1709
+ },
1710
+ },
1711
+ {
1712
+ id: "draft",
1713
+ component: "Button",
1714
+ label: "Draft",
1715
+ action: { event: { name: "draft", userMessage: { path: "/note" } } },
1716
+ },
1717
+ {
1718
+ id: "copy",
1719
+ component: "Button",
1720
+ label: "Copy",
1721
+ action: { event: { name: "copy", userMessage: { path: "/qty" } } },
1722
+ },
1723
+ ],
1724
+ { form: { email: "ada@example.com" }, volume: 4, qty: "2" },
1725
+ );
1726
+
1727
+ const { spec, warnings } = convertSurfaceToUISpec(surface);
1728
+
1729
+ expect(warnings).toEqual([]);
1730
+ expect(spec?.["children"]).toContainEqual({
1731
+ $type: "Button",
1732
+ label: "Form",
1733
+ $action: {
1734
+ type: "a2ui:action",
1735
+ name: "form",
1736
+ surfaceId: "",
1737
+ sourceComponentId: "form",
1738
+ },
1739
+ });
1740
+ expect(spec?.["children"]).toContainEqual({
1741
+ $type: "Button",
1742
+ label: "Level",
1743
+ $action: {
1744
+ type: "a2ui:action",
1745
+ name: "level",
1746
+ surfaceId: "",
1747
+ sourceComponentId: "level",
1748
+ },
1749
+ });
1750
+ expect(spec?.["children"]).toContainEqual({
1751
+ $type: "Button",
1752
+ label: "Draft",
1753
+ $action: {
1754
+ type: "a2ui:action",
1755
+ name: "draft",
1756
+ surfaceId: "",
1757
+ sourceComponentId: "draft",
1758
+ userMessage: { $field: "/note" },
1759
+ },
1760
+ });
1761
+ expect(spec?.["children"]).toContainEqual({
1762
+ $type: "Button",
1763
+ label: "Copy",
1764
+ $action: {
1765
+ type: "a2ui:action",
1766
+ name: "copy",
1767
+ surfaceId: "",
1768
+ sourceComponentId: "copy",
1769
+ userMessage: "2",
1770
+ },
1771
+ });
1772
+ });
1773
+
1774
+ it("takes a button label only from a Text child and keeps action data as bound", () => {
1775
+ const surface = surfaceFrom(
1776
+ [
1777
+ {
1778
+ id: "root",
1779
+ component: "Column",
1780
+ children: ["custom-label", "bound-context"],
1781
+ },
1782
+ { id: "custom-label", component: "Button", child: "note" },
1783
+ { id: "note", component: "Markdown", value: "Read me", tone: "loud" },
1784
+ {
1785
+ id: "bound-context",
1786
+ component: "Button",
1787
+ label: "Go",
1788
+ action: {
1789
+ event: { name: "go", context: { target: { path: "/target" } } },
1790
+ },
1791
+ },
1792
+ ],
1793
+ { target: { path: "/secret" }, secret: "hidden" },
1794
+ );
1795
+
1796
+ expect(
1797
+ convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
1798
+ ).toEqual({
1799
+ spec: {
1800
+ $type: "Col",
1801
+ children: [
1802
+ {
1803
+ $type: "Button",
1804
+ children: [{ $type: "Markdown", value: "Read me", tone: "loud" }],
1805
+ },
1806
+ {
1807
+ $type: "Button",
1808
+ label: "Go",
1809
+ $action: {
1810
+ type: "a2ui:action",
1811
+ name: "go",
1812
+ surfaceId: "",
1813
+ sourceComponentId: "bound-context",
1814
+ context: { target: { path: "/secret" } },
1815
+ },
1816
+ },
1817
+ ],
1818
+ },
1819
+ warnings: [],
1820
+ });
1821
+ });
1822
+
1823
+ it("points action context bound to an input at that input, so it resolves when the action fires", () => {
1824
+ const surface = surfaceFrom(
1825
+ [
1826
+ {
1827
+ id: "root",
1828
+ component: "Column",
1829
+ children: ["name", "plan", "size", "agree", "start", "due", "send"],
1830
+ },
1831
+ {
1832
+ id: "name",
1833
+ component: "TextField",
1834
+ label: "Name",
1835
+ value: { path: "/form/name" },
1836
+ },
1837
+ {
1838
+ id: "plan",
1839
+ component: "ChoicePicker",
1840
+ options: [{ label: "Pro", value: "pro" }],
1841
+ value: { path: "/form/plan" },
1842
+ },
1843
+ {
1844
+ id: "size",
1845
+ component: "ChoicePicker",
1846
+ displayStyle: "chips",
1847
+ options: [{ label: "Medium", value: "m" }],
1848
+ value: { path: "/form/size" },
1849
+ },
1850
+ {
1851
+ id: "agree",
1852
+ component: "CheckBox",
1853
+ label: "Agree",
1854
+ value: { path: "/form/agree" },
1855
+ },
1856
+ {
1857
+ id: "start",
1858
+ component: "DateTimeInput",
1859
+ value: { path: "/form/start" },
1860
+ },
1861
+ {
1862
+ id: "due",
1863
+ component: "DateTimeInput",
1864
+ value: { path: "/form/due" },
1865
+ },
1866
+ {
1867
+ id: "send",
1868
+ component: "Button",
1869
+ label: "Send",
1870
+ action: {
1871
+ event: {
1872
+ name: "send",
1873
+ context: {
1874
+ name: { path: "/form/name" },
1875
+ form: { path: "/form" },
1876
+ owner: { path: "/owner" },
1877
+ literal: "kept",
1878
+ },
1879
+ },
1880
+ },
1881
+ },
1882
+ ],
1883
+ {
1884
+ form: {
1885
+ name: "Ada",
1886
+ plan: ["pro"],
1887
+ size: "m",
1888
+ agree: false,
1889
+ start: "2026-01-02",
1890
+ due: "2025-12-15T17:00:00Z",
1891
+ id: 7,
1892
+ },
1893
+ owner: "u1",
1894
+ },
1895
+ );
1896
+
1897
+ const { spec, warnings } = convertSurfaceToUISpec(surface);
1898
+
1899
+ expect(warnings).toEqual([]);
1900
+ expect(spec?.["children"]).toContainEqual({
1901
+ $type: "Button",
1902
+ label: "Send",
1903
+ $action: {
1904
+ type: "a2ui:action",
1905
+ name: "send",
1906
+ surfaceId: "",
1907
+ sourceComponentId: "send",
1908
+ context: {
1909
+ name: { $field: "/form/name", fallback: "Ada" },
1910
+ form: {
1911
+ name: { $field: "/form/name", fallback: "Ada" },
1912
+ plan: [{ $field: "/form/plan", fallback: "pro" }],
1913
+ size: { $field: "/form/size", fallback: "m" },
1914
+ agree: { $field: "/form/agree", fallback: false },
1915
+ start: { $field: "/form/start", fallback: "2026-01-02" },
1916
+ due: { $field: "/form/due", fallback: "2025-12-15T17:00:00Z" },
1917
+ id: 7,
1918
+ },
1919
+ owner: "u1",
1920
+ literal: "kept",
1921
+ },
1922
+ },
1923
+ });
1924
+ expect(surface.dataModel).toEqual({
1925
+ form: {
1926
+ name: "Ada",
1927
+ plan: ["pro"],
1928
+ size: "m",
1929
+ agree: false,
1930
+ start: "2026-01-02",
1931
+ due: "2025-12-15T17:00:00Z",
1932
+ id: 7,
1933
+ },
1934
+ owner: "u1",
1935
+ });
1936
+ });
1937
+
1938
+ it("names an input inside a template item by the item's path", () => {
1939
+ const surface = surfaceFrom(
1940
+ [
1941
+ { id: "root", component: "Column", children: ["lines", "order"] },
1942
+ {
1943
+ id: "lines",
1944
+ component: "List",
1945
+ children: { componentId: "line", path: "/items" },
1946
+ },
1947
+ { id: "line", component: "Row", children: ["qty", "remove"] },
1948
+ {
1949
+ id: "qty",
1950
+ component: "TextField",
1951
+ label: "Quantity",
1952
+ value: { path: "qty" },
1953
+ },
1954
+ {
1955
+ id: "remove",
1956
+ component: "Button",
1957
+ label: "Remove",
1958
+ action: {
1959
+ event: {
1960
+ name: "remove",
1961
+ context: { qty: { path: "qty" }, sku: { path: "sku" } },
573
1962
  },
574
- }),
575
- dataModel,
576
- ),
577
- { keepUnknownComponents: true },
578
- ),
579
- ];
1963
+ },
1964
+ },
1965
+ {
1966
+ id: "order",
1967
+ component: "Button",
1968
+ label: "Order",
1969
+ action: {
1970
+ event: { name: "order", context: { items: { path: "/items" } } },
1971
+ },
1972
+ },
1973
+ ],
1974
+ {
1975
+ items: [
1976
+ { sku: "a", qty: 1 },
1977
+ { sku: "b", qty: 2 },
1978
+ ],
1979
+ },
1980
+ );
580
1981
 
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
- }
1982
+ const line = (index: number, sku: string, qty: string) => ({
1983
+ $type: "ListViewItem",
1984
+ children: {
1985
+ $type: "Row",
1986
+ children: [
1987
+ {
1988
+ $type: "Input",
1989
+ label: "Quantity",
1990
+ name: `/items/${index}/qty`,
1991
+ defaultValue: qty,
1992
+ },
1993
+ {
1994
+ $type: "Button",
1995
+ label: "Remove",
1996
+ $action: {
1997
+ type: "a2ui:action",
1998
+ name: "remove",
1999
+ surfaceId: "",
2000
+ sourceComponentId: "remove",
2001
+ context: {
2002
+ qty: { $field: `/items/${index}/qty`, fallback: Number(qty) },
2003
+ sku,
2004
+ },
2005
+ },
2006
+ },
2007
+ ],
2008
+ },
2009
+ });
2010
+ expect(convertSurfaceToUISpec(surface)).toEqual({
2011
+ spec: {
2012
+ $type: "Col",
2013
+ children: [
2014
+ {
2015
+ $type: "ListView",
2016
+ children: [line(0, "a", "1"), line(1, "b", "2")],
2017
+ },
2018
+ {
2019
+ $type: "Button",
2020
+ label: "Order",
2021
+ $action: {
2022
+ type: "a2ui:action",
2023
+ name: "order",
2024
+ surfaceId: "",
2025
+ sourceComponentId: "order",
2026
+ context: {
2027
+ items: [
2028
+ { sku: "a", qty: { $field: "/items/0/qty", fallback: 1 } },
2029
+ { sku: "b", qty: { $field: "/items/1/qty", fallback: 2 } },
2030
+ ],
2031
+ },
2032
+ },
2033
+ },
2034
+ ],
2035
+ },
2036
+ warnings: [],
2037
+ });
589
2038
  });
590
2039
 
591
- it("caps template expansion at 100 items", () => {
2040
+ it("grows a bound list for a field reference the way the data model grows", () => {
592
2041
  const surface = surfaceFrom(
593
2042
  [
594
2043
  {
595
2044
  id: "root",
596
2045
  component: "Column",
597
- children: {
598
- template: { componentId: "item", path: "/items" },
2046
+ children: ["far", "next", "row", "cleared", "send"],
2047
+ },
2048
+ {
2049
+ id: "far",
2050
+ component: "TextField",
2051
+ value: { path: "/items/999999999/name" },
2052
+ },
2053
+ {
2054
+ id: "next",
2055
+ component: "TextField",
2056
+ value: { path: "/items/0/name" },
2057
+ },
2058
+ { id: "row", component: "TextField", value: { path: "/rows/0/name" } },
2059
+ {
2060
+ id: "cleared",
2061
+ component: "TextField",
2062
+ value: { path: "/cleared/0/name" },
2063
+ },
2064
+ {
2065
+ id: "send",
2066
+ component: "Button",
2067
+ label: "Send",
2068
+ action: {
2069
+ event: {
2070
+ name: "send",
2071
+ context: {
2072
+ items: { path: "/items" },
2073
+ rows: { path: "/rows" },
2074
+ cleared: { path: "/cleared" },
2075
+ },
2076
+ },
2077
+ },
2078
+ },
2079
+ ],
2080
+ { items: [], cleared: null },
2081
+ );
2082
+
2083
+ expect(convertSurfaceToUISpec(surface).spec?.["children"]).toContainEqual({
2084
+ $type: "Button",
2085
+ label: "Send",
2086
+ $action: {
2087
+ type: "a2ui:action",
2088
+ name: "send",
2089
+ surfaceId: "",
2090
+ sourceComponentId: "send",
2091
+ context: {
2092
+ items: [{ name: { $field: "/items/0/name" } }],
2093
+ rows: [{ name: { $field: "/rows/0/name" } }],
2094
+ cleared: [{ name: { $field: "/cleared/0/name" } }],
2095
+ },
2096
+ },
2097
+ });
2098
+ });
2099
+
2100
+ it("points function call arguments and paths the data model does not hold yet at their inputs", () => {
2101
+ const surface = surfaceFrom([
2102
+ { id: "root", component: "Column", children: ["link", "open", "save"] },
2103
+ {
2104
+ id: "link",
2105
+ component: "TextField",
2106
+ label: "Link",
2107
+ value: { path: "/draft/link" },
2108
+ },
2109
+ {
2110
+ id: "open",
2111
+ component: "Button",
2112
+ label: "Open",
2113
+ action: {
2114
+ functionCall: {
2115
+ call: "openUrl",
2116
+ args: { url: { path: "/draft/link" } },
599
2117
  },
600
2118
  },
2119
+ },
2120
+ {
2121
+ id: "save",
2122
+ component: "Button",
2123
+ label: "Save",
2124
+ action: {
2125
+ event: { name: "save", context: { draft: { path: "/draft" } } },
2126
+ },
2127
+ },
2128
+ ]);
2129
+
2130
+ expect(convertSurfaceToUISpec(surface)).toEqual({
2131
+ spec: {
2132
+ $type: "Col",
2133
+ children: [
2134
+ { $type: "Input", label: "Link", name: "/draft/link" },
2135
+ {
2136
+ $type: "Button",
2137
+ label: "Open",
2138
+ $action: {
2139
+ type: "a2ui:functionCall",
2140
+ call: "openUrl",
2141
+ surfaceId: "",
2142
+ sourceComponentId: "open",
2143
+ args: { url: { $field: "/draft/link" } },
2144
+ },
2145
+ },
2146
+ {
2147
+ $type: "Button",
2148
+ label: "Save",
2149
+ $action: {
2150
+ type: "a2ui:action",
2151
+ name: "save",
2152
+ surfaceId: "",
2153
+ sourceComponentId: "save",
2154
+ context: { draft: { link: { $field: "/draft/link" } } },
2155
+ },
2156
+ },
2157
+ ],
2158
+ },
2159
+ warnings: [],
2160
+ });
2161
+ });
2162
+
2163
+ it("maps button and text field variants", () => {
2164
+ const surface = surfaceFrom(
2165
+ [
601
2166
  {
602
- id: "item",
603
- component: "Text",
604
- text: { path: "/" },
2167
+ id: "root",
2168
+ component: "Column",
2169
+ children: ["save", "skip", "plain", "styled", "notes", "code"],
2170
+ },
2171
+ { id: "save", component: "Button", label: "Save", variant: "primary" },
2172
+ {
2173
+ id: "skip",
2174
+ component: "Button",
2175
+ label: "Skip",
2176
+ variant: "borderless",
2177
+ },
2178
+ { id: "plain", component: "Button", label: "Back", variant: "default" },
2179
+ {
2180
+ id: "styled",
2181
+ component: "Button",
2182
+ label: "Delete",
2183
+ variant: "primary",
2184
+ buttonStyle: "danger",
2185
+ },
2186
+ {
2187
+ id: "notes",
2188
+ component: "TextField",
2189
+ label: "Notes",
2190
+ variant: "longText",
2191
+ value: { path: "/notes" },
2192
+ },
2193
+ {
2194
+ id: "code",
2195
+ component: "TextField",
2196
+ label: "Code",
2197
+ variant: "shortText",
2198
+ value: { path: "/code" },
605
2199
  },
606
2200
  ],
607
- { items: Array.from({ length: 101 }, (_, index) => `item-${index}`) },
2201
+ { notes: "", code: "" },
608
2202
  );
609
2203
 
610
- const result = convertSurfaceToUISpec(surface);
611
- expect((result.spec as UIElement).children).toHaveLength(100);
612
- expect(result.warnings).toContain(
613
- "A2UI template expansion was capped at 100 items.",
2204
+ expect(convertSurfaceToUISpec(surface)).toEqual({
2205
+ spec: {
2206
+ $type: "Col",
2207
+ children: [
2208
+ { $type: "Button", label: "Save", buttonStyle: "primary" },
2209
+ { $type: "Button", label: "Skip", buttonStyle: "ghost" },
2210
+ { $type: "Button", label: "Back" },
2211
+ { $type: "Button", label: "Delete", buttonStyle: "danger" },
2212
+ {
2213
+ $type: "Input",
2214
+ multiline: true,
2215
+ label: "Notes",
2216
+ name: "/notes",
2217
+ defaultValue: "",
2218
+ },
2219
+ { $type: "Input", label: "Code", name: "/code", defaultValue: "" },
2220
+ ],
2221
+ },
2222
+ warnings: [],
2223
+ });
2224
+ });
2225
+
2226
+ it("maps function call actions and warns about an action it cannot read", () => {
2227
+ const surface = surfaceFrom(
2228
+ [
2229
+ { id: "root", component: "Column", children: ["docs", "broken"] },
2230
+ {
2231
+ id: "docs",
2232
+ component: "Button",
2233
+ child: "docs-text",
2234
+ action: {
2235
+ functionCall: {
2236
+ call: "openUrl",
2237
+ args: { url: { path: "/docsUrl" } },
2238
+ },
2239
+ },
2240
+ },
2241
+ { id: "docs-text", component: "Text", text: "Docs" },
2242
+ {
2243
+ id: "broken",
2244
+ component: "Button",
2245
+ label: "Broken",
2246
+ action: { functionCall: { args: { url: "https://a2ui.org" } } },
2247
+ },
2248
+ ],
2249
+ { docsUrl: "https://a2ui.org" },
614
2250
  );
2251
+
2252
+ expect(convertSurfaceToUISpec(surface)).toEqual({
2253
+ spec: {
2254
+ $type: "Col",
2255
+ children: [
2256
+ {
2257
+ $type: "Button",
2258
+ label: "Docs",
2259
+ $action: {
2260
+ type: "a2ui:functionCall",
2261
+ call: "openUrl",
2262
+ surfaceId: "",
2263
+ sourceComponentId: "docs",
2264
+ args: { url: "https://a2ui.org" },
2265
+ },
2266
+ },
2267
+ { $type: "Button", label: "Broken" },
2268
+ ],
2269
+ },
2270
+ warnings: ['Component "broken" has a malformed action.'],
2271
+ });
2272
+ });
2273
+
2274
+ it("warns about a child reference it cannot follow", () => {
2275
+ const surface = surfaceFrom([
2276
+ { id: "root", component: "Column", children: ["missing", "malformed"] },
2277
+ { id: "missing", component: "Card", child: "gone" },
2278
+ { id: "malformed", component: "Card", child: 7 },
2279
+ ]);
2280
+
2281
+ expect(convertSurfaceToUISpec(surface)).toEqual({
2282
+ spec: {
2283
+ $type: "Col",
2284
+ children: [{ $type: "Card" }, { $type: "Card" }],
2285
+ },
2286
+ warnings: [
2287
+ 'A2UI component "gone" was not found.',
2288
+ 'Component "malformed" has a malformed child reference.',
2289
+ ],
2290
+ });
2291
+ });
2292
+
2293
+ it("keeps the content of kept Modal, Tabs, and single child components", () => {
2294
+ const tabs = [
2295
+ { title: "Pickup", child: "pickup" },
2296
+ { title: "Drop off", child: "drop-off" },
2297
+ ];
2298
+ const surface = surfaceFrom([
2299
+ {
2300
+ id: "root",
2301
+ component: "Column",
2302
+ children: ["modal", "tabs", "panel"],
2303
+ },
2304
+ { id: "modal", component: "Modal", trigger: "open", content: "details" },
2305
+ {
2306
+ id: "open",
2307
+ component: "Button",
2308
+ child: "open-text",
2309
+ action: { event: { name: "open" } },
2310
+ },
2311
+ { id: "open-text", component: "Text", text: "Details" },
2312
+ { id: "details", component: "Text", text: "Ships in two days." },
2313
+ { id: "tabs", component: "Tabs", tabs },
2314
+ { id: "pickup", component: "Text", text: "A courier collects it." },
2315
+ { id: "drop-off", component: "Text", text: "Any service point." },
2316
+ { id: "panel", component: "ReturnPanel", child: "note" },
2317
+ { id: "note", component: "Text", text: "Returns are free." },
2318
+ ]);
2319
+
2320
+ expect(
2321
+ convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
2322
+ ).toEqual({
2323
+ spec: {
2324
+ $type: "Col",
2325
+ children: [
2326
+ {
2327
+ $type: "Modal",
2328
+ trigger: "open",
2329
+ content: "details",
2330
+ children: [
2331
+ {
2332
+ $type: "Button",
2333
+ label: "Details",
2334
+ $action: {
2335
+ type: "a2ui:action",
2336
+ name: "open",
2337
+ surfaceId: "",
2338
+ sourceComponentId: "open",
2339
+ },
2340
+ },
2341
+ { $type: "Markdown", value: "Ships in two days." },
2342
+ ],
2343
+ },
2344
+ {
2345
+ $type: "Tabs",
2346
+ tabs,
2347
+ children: [
2348
+ { $type: "Markdown", value: "A courier collects it." },
2349
+ { $type: "Markdown", value: "Any service point." },
2350
+ ],
2351
+ },
2352
+ {
2353
+ $type: "ReturnPanel",
2354
+ children: [{ $type: "Markdown", value: "Returns are free." }],
2355
+ },
2356
+ ],
2357
+ },
2358
+ warnings: [],
2359
+ });
615
2360
  });
616
2361
 
617
2362
  it("truncates a component cycle", () => {
@@ -693,6 +2438,22 @@ describe("convertSurfaceToUISpec", () => {
693
2438
  expect(result.warnings).toContain("A2UI node budget of 5000 was reached.");
694
2439
  });
695
2440
 
2441
+ it("converts a children list longer than the call argument limit", () => {
2442
+ const result = convertSurfaceToUISpec(
2443
+ surfaceFrom([
2444
+ {
2445
+ id: "root",
2446
+ component: "Row",
2447
+ children: Array.from({ length: 500_000 }, () => "leaf"),
2448
+ },
2449
+ { id: "leaf", component: "Divider" },
2450
+ ]),
2451
+ );
2452
+
2453
+ expect((result.spec as UIElement).children).toHaveLength(4999);
2454
+ expect(result.warnings).toEqual(["A2UI node budget of 5000 was reached."]);
2455
+ });
2456
+
696
2457
  it("skips an unknown component with a warning naming it", () => {
697
2458
  const result = convertSurfaceToUISpec(
698
2459
  surfaceFrom([{ id: "root", component: "VideoPlayer" }]),
@@ -777,11 +2538,9 @@ describe("convertSurfaceToUISpec", () => {
777
2538
  $action: { type: "injected" },
778
2539
  $key: "injected",
779
2540
  $status: "injected",
780
- children: {
781
- template: { componentId: "item", path: "/items" },
782
- },
2541
+ children: { componentId: "item", path: "/items" },
783
2542
  },
784
- { id: "item", component: "Text", text: { path: "/label" } },
2543
+ { id: "item", component: "Text", text: { path: "label" } },
785
2544
  ],
786
2545
  { items: [{ label: "One" }] },
787
2546
  );
@@ -810,11 +2569,9 @@ describe("convertSurfaceToUISpec", () => {
810
2569
  {
811
2570
  id: "root",
812
2571
  component: "CustomList",
813
- children: {
814
- template: { componentId: "item", path: "/items" },
815
- },
2572
+ children: { componentId: "item", path: "/items" },
816
2573
  },
817
- { id: "item", component: "Text", text: { path: "/label" } },
2574
+ { id: "item", component: "Text", text: { path: "label" } },
818
2575
  ],
819
2576
  { items: [{ label: "One" }] },
820
2577
  );
@@ -840,11 +2597,9 @@ describe("convertSurfaceToUISpec", () => {
840
2597
  id: "root",
841
2598
  component: "CustomList",
842
2599
  title: { path: "/title" },
843
- children: {
844
- template: { componentId: "item", path: "/items" },
845
- },
2600
+ children: { componentId: "item", path: "/items" },
846
2601
  },
847
- { id: "item", component: "Text", text: { path: "/label" } },
2602
+ { id: "item", component: "Text", text: { path: "label" } },
848
2603
  ],
849
2604
  { title: "Tasks", items: [{ label: "One" }, { label: "Two" }] },
850
2605
  );
@@ -875,3 +2630,85 @@ describe("convertSurfaceToUISpec", () => {
875
2630
  });
876
2631
  });
877
2632
  });
2633
+
2634
+ it("keeps live controls internal and action-free while plain conversion stays uncontrolled", () => {
2635
+ const surface = applyA2uiOperations(new Map(), [
2636
+ {
2637
+ version: "v1.0",
2638
+ createSurface: {
2639
+ surfaceId: "main",
2640
+ components: [
2641
+ {
2642
+ id: "root",
2643
+ component: "Column",
2644
+ children: ["field", "pick", "slider"],
2645
+ },
2646
+ { id: "field", component: "TextField", text: { path: "/name" } },
2647
+ {
2648
+ id: "pick",
2649
+ component: "ChoicePicker",
2650
+ value: { path: "/choices" },
2651
+ options: [{ label: "A", value: "a" }],
2652
+ },
2653
+ {
2654
+ id: "slider",
2655
+ component: "Slider",
2656
+ value: { path: "/count" },
2657
+ min: 0,
2658
+ max: 10,
2659
+ },
2660
+ ],
2661
+ dataModel: { name: "Ada", choices: ["a"], count: 2 },
2662
+ },
2663
+ },
2664
+ ]).state.get("main")!;
2665
+ expect(convertSurfaceToUISpec(surface).spec).toMatchObject({
2666
+ children: [
2667
+ { $type: "Input", defaultValue: "Ada" },
2668
+ { $type: "RadioGroup", defaultValue: "a" },
2669
+ { $type: "Slider", defaultValue: 2 },
2670
+ ],
2671
+ });
2672
+ const convert = createLiveSurfaceConverter(surface);
2673
+ const live = convert(surface.dataModel);
2674
+ expect(live.spec).toEqual({
2675
+ $type: "Col",
2676
+ children: [
2677
+ {
2678
+ $type: "Input",
2679
+ value: "Ada",
2680
+ name: "/name",
2681
+ },
2682
+ {
2683
+ $type: "RadioGroup",
2684
+ value: "a",
2685
+ name: "/choices",
2686
+ options: [{ label: "A", value: "a" }],
2687
+ },
2688
+ {
2689
+ $type: "Slider",
2690
+ value: 2,
2691
+ name: "/count",
2692
+ min: 0,
2693
+ max: 10,
2694
+ step: 0.1,
2695
+ },
2696
+ ],
2697
+ });
2698
+ expect(live.bindings).toEqual(
2699
+ new Map([
2700
+ ["/name", { value: "Ada", arrayValue: false }],
2701
+ ["/choices", { value: "a", arrayValue: true }],
2702
+ ["/count", { value: 2, arrayValue: false }],
2703
+ ]),
2704
+ );
2705
+ const updated = convert({ name: "Grace", choices: ["a"], count: 2 });
2706
+ expect((updated.spec!.children as UIElement[])[0]).toEqual({
2707
+ $type: "Input",
2708
+ name: "/name",
2709
+ value: "Grace",
2710
+ });
2711
+ expect((updated.spec!.children as UIElement[])[2]).toBe(
2712
+ (live.spec!.children as UIElement[])[2],
2713
+ );
2714
+ });