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

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