@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,13 +1,17 @@
1
1
  import { ICON_NAMES, type UIElement } from "../ir";
2
+ import { A2UI_SURFACE_ID, type A2uiSurfaceState } from "./types";
2
3
  import {
3
- A2UI_SURFACE_ID,
4
- type A2uiSurfaceState,
5
- type A2uiTemplateChildren,
6
- } from "./types";
4
+ evaluateA2uiValueFunction,
5
+ type ExpressionPart,
6
+ } from "./valueFunctions";
7
+ import { MAX_AUTO_VIVIFY_ARRAY_INDEX } from "./reducer";
8
+ import type { A2uiBinding } from "./BindingContext";
9
+ import { decodePointer, resolvePointer } from "./dataModel";
7
10
 
8
11
  const DEPTH_CAP = 32;
9
12
  const TEMPLATE_ITEM_CAP = 100;
10
13
  const NODE_BUDGET = 5000;
14
+ const EVALUATION_BUDGET = 20_000;
11
15
 
12
16
  const SUPPORTED_COMPONENTS = new Set([
13
17
  "Text",
@@ -23,6 +27,13 @@ const SUPPORTED_COMPONENTS = new Set([
23
27
  "CheckBox",
24
28
  "ChoicePicker",
25
29
  "DateTimeInput",
30
+ "Slider",
31
+ ]);
32
+
33
+ const CHILD_LIST_COMPONENTS: ReadonlySet<string> = new Set([
34
+ "Row",
35
+ "Column",
36
+ "List",
26
37
  ]);
27
38
 
28
39
  const ICON_NAME_SET: ReadonlySet<string> = new Set(ICON_NAMES);
@@ -54,66 +65,130 @@ const setOwnProperty = (
54
65
  }
55
66
  };
56
67
 
68
+ const FUNCTION_CALL_KEYS: ReadonlySet<string> = new Set([
69
+ "call",
70
+ "args",
71
+ "returnType",
72
+ "catalogId",
73
+ ]);
74
+
75
+ const isFunctionCall = (
76
+ value: unknown,
77
+ ): value is {
78
+ readonly call: string;
79
+ readonly args?: Record<string, unknown>;
80
+ } =>
81
+ isPlainObject(value) &&
82
+ typeof value["call"] === "string" &&
83
+ (value["args"] === undefined || isPlainObject(value["args"])) &&
84
+ Object.keys(value).every((key) => FUNCTION_CALL_KEYS.has(key));
85
+
57
86
  const isBinding = (value: unknown): value is { readonly path: string } =>
58
87
  isPlainObject(value) &&
59
88
  Object.keys(value).length === 1 &&
60
89
  typeof value["path"] === "string";
61
90
 
62
- const isTemplateChildren = (value: unknown): value is A2uiTemplateChildren =>
63
- isPlainObject(value) &&
64
- Object.keys(value).length === 1 &&
65
- isPlainObject(value["template"]) &&
66
- Object.keys(value["template"]).length === 2 &&
67
- typeof value["template"]["componentId"] === "string" &&
68
- typeof value["template"]["path"] === "string";
69
-
70
- const decodePointer = (path: string): string[] | undefined => {
71
- if (path === "" || path === "/") return [];
72
- if (!path.startsWith("/")) return undefined;
73
- return path
74
- .slice(1)
75
- .split("/")
76
- .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
77
- };
91
+ type ChildTemplate = { readonly componentId: string; readonly path: string };
78
92
 
79
- const resolvePointer = (source: unknown, path: string): unknown => {
80
- const segments = decodePointer(path);
81
- if (!segments) return undefined;
82
- let current = source;
83
- for (const segment of segments) {
84
- if (Array.isArray(current)) {
85
- if (!/^(0|[1-9]\d*)$/.test(segment)) return undefined;
86
- current = current[Number(segment)];
87
- continue;
88
- }
89
- if (!isRecord(current) || !Object.hasOwn(current, segment)) {
90
- return undefined;
91
- }
92
- current = current[segment];
93
- }
94
- return current;
93
+ const childTemplateOf = (children: unknown): ChildTemplate | undefined => {
94
+ const template =
95
+ isPlainObject(children) &&
96
+ Object.keys(children).length === 1 &&
97
+ isPlainObject(children["template"])
98
+ ? children["template"]
99
+ : children;
100
+ return isPlainObject(template) &&
101
+ Object.keys(template).length === 2 &&
102
+ typeof template["componentId"] === "string" &&
103
+ typeof template["path"] === "string"
104
+ ? { componentId: template["componentId"], path: template["path"] }
105
+ : undefined;
95
106
  };
96
107
 
97
108
  const bindingPath = (value: unknown): string | undefined =>
98
109
  isBinding(value) ? value.path : undefined;
99
110
 
100
- const lastPointerSegment = (path: string | undefined): string | undefined => {
101
- if (!path) return undefined;
102
- const segments = decodePointer(path);
103
- return segments?.at(-1);
111
+ type Scope = { readonly data: unknown; readonly path: string };
112
+
113
+ const pointerIn = (scope: Scope, path: string): string =>
114
+ (path.startsWith("/") ? "" : scope.path) +
115
+ decodePointer(path)
116
+ .map((segment) => `/${segment.replaceAll("~", "~0").replaceAll("/", "~1")}`)
117
+ .join("");
118
+
119
+ const setIn = (
120
+ value: unknown,
121
+ segments: readonly string[],
122
+ leaf: unknown,
123
+ ): unknown => {
124
+ const [head, ...rest] = segments;
125
+ if (head === undefined) return leaf;
126
+ if (
127
+ /^(0|[1-9]\d*)$/.test(head) &&
128
+ (Array.isArray(value) || value === undefined || value === null)
129
+ ) {
130
+ const list = Array.isArray(value) ? value : [];
131
+ const index = Number(head);
132
+ if (index >= list.length && index > MAX_AUTO_VIVIFY_ARRAY_INDEX) {
133
+ return value;
134
+ }
135
+ const copy = [...list];
136
+ copy[index] = setIn(copy[index], rest, leaf);
137
+ return copy;
138
+ }
139
+ const copy: Record<string, unknown> = isRecord(value) ? { ...value } : {};
140
+ setOwnProperty(
141
+ copy,
142
+ head,
143
+ setIn(Object.hasOwn(copy, head) ? copy[head] : undefined, rest, leaf),
144
+ );
145
+ return copy;
104
146
  };
105
147
 
106
- const materialize = (value: unknown, source: unknown): unknown => {
107
- if (isBinding(value)) return resolvePointer(source, value.path);
108
- if (Array.isArray(value)) {
109
- return value
110
- .map((entry) => materialize(entry, source))
111
- .filter((entry) => entry !== undefined);
148
+ const withFieldReferences = (
149
+ value: unknown,
150
+ pointer: string,
151
+ fields: ReadonlyMap<string, unknown>,
152
+ ): unknown => {
153
+ if (fields.has(pointer)) return fields.get(pointer);
154
+ let result = value;
155
+ for (const [name, field] of fields) {
156
+ if (name.startsWith(`${pointer}/`)) {
157
+ result = setIn(result, decodePointer(name.slice(pointer.length)), field);
158
+ }
112
159
  }
113
- if (!isPlainObject(value)) return value;
160
+ return result;
161
+ };
162
+
163
+ const materializeEntries = (
164
+ value: Record<string, unknown>,
165
+ source: unknown,
166
+ context: ConversionContext,
167
+ evaluate: boolean,
168
+ depth: number,
169
+ positional = false,
170
+ ): Record<string, unknown> => {
114
171
  const result: Record<string, unknown> = {};
115
172
  for (const [key, entry] of Object.entries(value)) {
116
- const resolved = materialize(entry, source);
173
+ // An action's functionCall runs when the button fires, so only its arguments resolve here.
174
+ const resolved =
175
+ key === "functionCall" && isFunctionCall(entry)
176
+ ? {
177
+ ...entry,
178
+ ...(entry.args !== undefined
179
+ ? {
180
+ args: materializeEntries(
181
+ entry.args,
182
+ source,
183
+ context,
184
+ evaluate,
185
+ depth,
186
+ true,
187
+ ),
188
+ }
189
+ : {}),
190
+ }
191
+ : materialize(entry, source, context, evaluate, depth, positional);
117
192
  if (resolved !== undefined) {
118
193
  setOwnProperty(result, key, resolved);
119
194
  }
@@ -121,6 +196,73 @@ const materialize = (value: unknown, source: unknown): unknown => {
121
196
  return result;
122
197
  };
123
198
 
199
+ function materialize(
200
+ value: unknown,
201
+ source: unknown,
202
+ context: ConversionContext,
203
+ evaluate = true,
204
+ depth = 0,
205
+ positional = false,
206
+ ): unknown {
207
+ if (isBinding(value)) {
208
+ const pointer = pointerIn(
209
+ { data: source, path: context.scopePath },
210
+ value.path,
211
+ );
212
+ const resolved = resolvePointer(context.surface.dataModel, pointer);
213
+ context.dependencies?.set(pointer, resolved);
214
+ return resolved;
215
+ }
216
+ if (Array.isArray(value)) {
217
+ const entries = value.map((entry) =>
218
+ materialize(entry, source, context, evaluate, depth, positional),
219
+ );
220
+ return positional
221
+ ? entries
222
+ : entries.filter((entry) => entry !== undefined);
223
+ }
224
+ if (!isPlainObject(value)) return value;
225
+ if (!evaluate || !isFunctionCall(value)) {
226
+ return materializeEntries(
227
+ value,
228
+ source,
229
+ context,
230
+ evaluate,
231
+ depth,
232
+ positional,
233
+ );
234
+ }
235
+ if (!spendEvaluation(context)) return undefined;
236
+ if (depth >= DEPTH_CAP) {
237
+ if (!context.functionDepthWarned) {
238
+ context.warnings.push(
239
+ `A2UI function nesting cap of ${DEPTH_CAP} was reached.`,
240
+ );
241
+ context.functionDepthWarned = true;
242
+ }
243
+ return undefined;
244
+ }
245
+ return evaluateA2uiValueFunction(
246
+ value.call,
247
+ materializeEntries(
248
+ value.args ?? {},
249
+ source,
250
+ context,
251
+ true,
252
+ depth + 1,
253
+ true,
254
+ ),
255
+ {
256
+ resolve: (part) =>
257
+ spendEvaluation(context)
258
+ ? materialize(part, source, context, true, depth + 1)
259
+ : undefined,
260
+ warn: (message) => context.warnings.push(message),
261
+ templates: context.templates,
262
+ },
263
+ );
264
+ }
265
+
124
266
  const firstDefined = (
125
267
  props: Record<string, unknown>,
126
268
  keys: readonly string[],
@@ -146,13 +288,158 @@ const sourceSurfaceId = (surface: A2uiSurfaceState): string =>
146
288
 
147
289
  type ConversionContext = {
148
290
  readonly surface: A2uiSurfaceState;
291
+ readonly stepModel: unknown;
149
292
  readonly surfaceId: string;
150
293
  readonly warnings: string[];
151
294
  emittedNodes: number;
152
295
  depthWarned: boolean;
153
296
  budgetWarned: boolean;
154
297
  templateCapWarned: boolean;
298
+ evaluations: number;
299
+ evaluationBudgetWarned: boolean;
300
+ functionDepthWarned: boolean;
301
+ readonly templates: Map<string, ExpressionPart[] | null>;
302
+ readonly inputFields: Map<string, unknown>;
303
+ readonly textFields: Map<string, boolean>;
304
+ readonly boundActionEntries: {
305
+ readonly target: Record<string, unknown>;
306
+ readonly key: string;
307
+ readonly pointer: string;
308
+ }[];
309
+ readonly boundUserMessages: {
310
+ readonly action: Record<string, unknown>;
311
+ readonly pointer: string;
312
+ }[];
155
313
  readonly keepUnknownComponents: boolean;
314
+ readonly bindings: Map<string, A2uiBinding> | undefined;
315
+ readonly cache: Map<string, CachedComponent> | undefined;
316
+ dependencies: Map<string, unknown> | undefined;
317
+ scopePath: string;
318
+ };
319
+
320
+ type CachedComponent = {
321
+ readonly props: Record<string, unknown>;
322
+ readonly mapped: UIElement | undefined;
323
+ readonly dependencies: ReadonlyMap<string, unknown>;
324
+ readonly evaluations: number;
325
+ children?: readonly UIElement[];
326
+ result?: UIElement;
327
+ };
328
+
329
+ const BOUND_KEYS = ["text", "value", "binding"] as const;
330
+
331
+ const INPUT_COMPONENTS: ReadonlySet<string> = new Set([
332
+ "TextField",
333
+ "CheckBox",
334
+ "ChoicePicker",
335
+ "DateTimeInput",
336
+ "Slider",
337
+ ]);
338
+
339
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
340
+
341
+ const finiteNumber = (value: unknown): number | undefined =>
342
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
343
+
344
+ const decimalPlaces = (value: number): number => {
345
+ const [mantissa = "", exponent = "0"] = String(value).split("e");
346
+ return Math.max(0, (mantissa.split(".")[1]?.length ?? 0) - Number(exponent));
347
+ };
348
+
349
+ // A spec slider without `steps` is continuous, so its step leaves at least a hundred divisions and keeps every bound value on the grid.
350
+ const sliderStep = (
351
+ min: number,
352
+ max: number,
353
+ value: number | undefined,
354
+ steps: unknown,
355
+ ): number | undefined => {
356
+ if (max <= min) return undefined;
357
+ if (typeof steps === "number" && Number.isInteger(steps) && steps >= 1) {
358
+ return (max - min) / steps;
359
+ }
360
+ const precision = Math.max(
361
+ decimalPlaces(min),
362
+ value === undefined ? 0 : decimalPlaces(value),
363
+ );
364
+ return Math.min(
365
+ 10 ** Math.floor(Math.log10((max - min) / 100)),
366
+ 10 ** -precision,
367
+ );
368
+ };
369
+
370
+ const recordBindings = (
371
+ node: Record<string, unknown>,
372
+ props: Record<string, unknown>,
373
+ mapped: UIElement,
374
+ scope: Scope,
375
+ context: ConversionContext,
376
+ ) => {
377
+ const component = node["component"];
378
+ const name = mapped["name"];
379
+ const boundKey = BOUND_KEYS.find((key) => node[key] !== undefined);
380
+ const value = boundKey === undefined ? undefined : props[boundKey];
381
+ if (INPUT_COMPONENTS.has(String(component)) && typeof name === "string") {
382
+ context.textFields.set(
383
+ name,
384
+ (!context.textFields.has(name) || context.bindings !== undefined) &&
385
+ (component === "TextField" || component === "DateTimeInput"),
386
+ );
387
+ const field = (fallback: unknown) =>
388
+ fallback === undefined ? { $field: name } : { $field: name, fallback };
389
+ // A single-choice picker collects one string, while the spec binds it to a string list.
390
+ const listValued =
391
+ component === "ChoicePicker" &&
392
+ mapped.$type !== "CheckboxGroup" &&
393
+ typeof value !== "string";
394
+ context.inputFields.set(
395
+ name,
396
+ listValued
397
+ ? [field(Array.isArray(value) ? value[0] : undefined)]
398
+ : field(value),
399
+ );
400
+ }
401
+ const action = mapped.$action;
402
+ const raw = node["action"];
403
+ if (!action || !isRecord(raw)) return;
404
+ const functionCall = action.type === "a2ui:functionCall";
405
+ const event = isRecord(raw["event"]) ? raw["event"] : raw;
406
+ const userMessage = event["userMessage"];
407
+ if (!functionCall && isBinding(userMessage)) {
408
+ context.boundUserMessages.push({
409
+ action,
410
+ pointer: pointerIn(scope, userMessage.path),
411
+ });
412
+ }
413
+ const rawEntries = functionCall
414
+ ? isRecord(raw["functionCall"])
415
+ ? raw["functionCall"]["args"]
416
+ : undefined
417
+ : event["context"];
418
+ const target = functionCall ? action["args"] : action["context"];
419
+ if (!isRecord(rawEntries) || !isRecord(target)) return;
420
+ for (const [key, entry] of Object.entries(rawEntries)) {
421
+ if (isBinding(entry)) {
422
+ context.boundActionEntries.push({
423
+ target,
424
+ key,
425
+ pointer: pointerIn(scope, entry.path),
426
+ });
427
+ }
428
+ }
429
+ };
430
+
431
+ const spendEvaluation = (context: ConversionContext): boolean => {
432
+ if (context.evaluations < EVALUATION_BUDGET) {
433
+ context.evaluations++;
434
+ return true;
435
+ }
436
+ if (!context.evaluationBudgetWarned) {
437
+ context.warnings.push(
438
+ `A2UI function evaluation budget of ${EVALUATION_BUDGET} was reached.`,
439
+ );
440
+ context.evaluationBudgetWarned = true;
441
+ }
442
+ return false;
156
443
  };
157
444
 
158
445
  const reserveNode = (context: ConversionContext): boolean => {
@@ -167,59 +454,107 @@ const reserveNode = (context: ConversionContext): boolean => {
167
454
  return true;
168
455
  };
169
456
 
457
+ const childReferences = (node: Record<string, unknown>): unknown[] => {
458
+ const children = node["children"];
459
+ const references: unknown[] = Array.isArray(children) ? [...children] : [];
460
+ if (node["child"] !== undefined) references.push(node["child"]);
461
+ if (node["component"] === "Modal") {
462
+ references.push(node["trigger"], node["content"]);
463
+ }
464
+ if (node["component"] === "Tabs" && Array.isArray(node["tabs"])) {
465
+ for (const tab of node["tabs"]) {
466
+ references.push(isRecord(tab) ? tab["child"] : undefined);
467
+ }
468
+ }
469
+ return references;
470
+ };
471
+
170
472
  const childrenOf = (
171
473
  node: Record<string, unknown>,
172
- dataSource: unknown,
474
+ scope: Scope,
173
475
  context: ConversionContext,
174
476
  depth: number,
175
477
  visited: Set<string>,
176
478
  ): UIElement[] => {
177
- const children = node["children"];
178
- if (!Array.isArray(children)) return [];
179
479
  const result: UIElement[] = [];
180
- for (const childId of children) {
480
+ for (const childId of childReferences(node)) {
181
481
  if (typeof childId !== "string") {
182
482
  context.warnings.push(
183
483
  `Component "${String(node["id"] ?? "")}" has a malformed child reference.`,
184
484
  );
185
485
  continue;
186
486
  }
187
- const child = convertComponent(
188
- childId,
189
- dataSource,
190
- context,
191
- depth + 1,
192
- visited,
193
- );
487
+ const child = convertComponent(childId, scope, context, depth + 1, visited);
194
488
  if (child) result.push(child);
195
489
  }
196
490
  return result;
197
491
  };
198
492
 
493
+ const textLabel = (
494
+ node: Record<string, unknown>,
495
+ children: readonly UIElement[],
496
+ context: ConversionContext,
497
+ ): string | undefined => {
498
+ const references = childReferences(node);
499
+ const [reference] = references;
500
+ const [child] = children;
501
+ if (
502
+ references.length !== 1 ||
503
+ children.length !== 1 ||
504
+ typeof reference !== "string" ||
505
+ !child ||
506
+ context.surface.components.get(reference)?.["component"] !== "Text"
507
+ ) {
508
+ return undefined;
509
+ }
510
+ const text = child.$type === "Header" ? child["text"] : child["value"];
511
+ return typeof text === "string" ? text : undefined;
512
+ };
513
+
199
514
  const mappedAction = (
200
515
  node: Record<string, unknown>,
201
516
  props: Record<string, unknown>,
202
- dataSource: unknown,
203
517
  context: ConversionContext,
204
518
  ): UIElement["$action"] | undefined => {
205
519
  const action = props["action"];
206
- const actionName =
207
- typeof action === "string"
208
- ? action
209
- : isRecord(action) && typeof action["name"] === "string"
210
- ? action["name"]
211
- : undefined;
212
- if (!actionName) return undefined;
213
- const rawContext = isRecord(action) ? action["context"] : undefined;
214
- const resolvedContext =
215
- rawContext === undefined ? undefined : materialize(rawContext, dataSource);
216
- return {
217
- type: "a2ui:action",
218
- name: actionName,
520
+ if (action === undefined) return undefined;
521
+ const source = {
219
522
  surfaceId: context.surfaceId,
220
523
  sourceComponentId: typeof node["id"] === "string" ? node["id"] : "",
221
- ...(resolvedContext !== undefined ? { context: resolvedContext } : {}),
222
524
  };
525
+ const event =
526
+ isRecord(action) && isRecord(action["event"]) ? action["event"] : action;
527
+ const actionName =
528
+ typeof event === "string"
529
+ ? event
530
+ : isRecord(event) && typeof event["name"] === "string"
531
+ ? event["name"]
532
+ : undefined;
533
+ if (actionName) {
534
+ const actionContext = isRecord(event) ? event["context"] : undefined;
535
+ const userMessage = isRecord(event) ? event["userMessage"] : undefined;
536
+ return {
537
+ type: "a2ui:action",
538
+ name: actionName,
539
+ ...source,
540
+ ...(typeof userMessage === "string" ? { userMessage } : {}),
541
+ ...(actionContext !== undefined ? { context: actionContext } : {}),
542
+ };
543
+ }
544
+ const functionCall = isRecord(action) ? action["functionCall"] : undefined;
545
+ if (isRecord(functionCall) && typeof functionCall["call"] === "string") {
546
+ const args = functionCall["args"];
547
+ return {
548
+ type: "a2ui:functionCall",
549
+ call: functionCall["call"],
550
+ ...source,
551
+ ...(isRecord(args) ? { args } : {}),
552
+ };
553
+ }
554
+ context.warnings.push(
555
+ `Component "${String(node["id"] ?? "")}" has a malformed action.`,
556
+ );
557
+ return undefined;
223
558
  };
224
559
 
225
560
  const choiceOptions = (value: unknown): { label: string; value: string }[] => {
@@ -240,8 +575,8 @@ const choiceOptions = (value: unknown): { label: string; value: string }[] => {
240
575
  const mappedProps = (
241
576
  node: Record<string, unknown>,
242
577
  props: Record<string, unknown>,
243
- dataSource: unknown,
244
578
  context: ConversionContext,
579
+ scope: Scope,
245
580
  ): UIElement | undefined => {
246
581
  const component = node["component"];
247
582
 
@@ -346,38 +681,63 @@ const mappedProps = (
346
681
 
347
682
  if (component === "Button") {
348
683
  const label = stringProp(props, ["label", "text"]);
349
- const buttonStyle = props["buttonStyle"];
684
+ const variantStyle =
685
+ props["variant"] === "primary"
686
+ ? "primary"
687
+ : props["variant"] === "borderless"
688
+ ? "ghost"
689
+ : undefined;
690
+ const buttonStyle =
691
+ typeof props["buttonStyle"] === "string"
692
+ ? props["buttonStyle"]
693
+ : variantStyle;
350
694
  const block = props["block"];
351
695
  const submit = props["submit"];
352
- const action = mappedAction(node, props, dataSource, context);
696
+ const action = mappedAction(node, props, context);
353
697
  return {
354
698
  $type: "Button",
355
699
  ...(label !== undefined ? { label } : {}),
356
- ...(typeof buttonStyle === "string" ? { buttonStyle } : {}),
700
+ ...(buttonStyle !== undefined ? { buttonStyle } : {}),
357
701
  ...(typeof block === "boolean" ? { block } : {}),
358
702
  ...(typeof submit === "boolean" ? { submit } : {}),
359
703
  ...(action ? { $action: action } : {}),
360
704
  };
361
705
  }
362
706
 
363
- const binding = firstDefined(node, ["text", "value", "binding"]);
364
- const name = lastPointerSegment(bindingPath(binding));
707
+ const bound = bindingPath(firstDefined(node, BOUND_KEYS));
708
+ const name = bound === undefined ? undefined : pointerIn(scope, bound);
365
709
  const label = stringProp(props, ["label"]);
366
710
 
367
711
  if (component === "TextField") {
368
712
  const placeholder = stringProp(props, ["placeholder"]);
713
+ const inputType =
714
+ props["variant"] === "obscured"
715
+ ? "password"
716
+ : props["variant"] === "number"
717
+ ? "number"
718
+ : undefined;
369
719
  const multiline =
370
720
  typeof props["multiline"] === "boolean"
371
721
  ? props["multiline"]
372
- : props["textFieldType"] === "longText"
722
+ : props["variant"] === "longText" ||
723
+ props["textFieldType"] === "longText"
373
724
  ? true
374
725
  : undefined;
726
+ const initial = firstDefined(props, ["value", "text"]);
727
+ const defaultValue =
728
+ typeof initial === "string"
729
+ ? initial
730
+ : typeof initial === "number" && Number.isFinite(initial)
731
+ ? String(initial)
732
+ : undefined;
375
733
  return {
376
734
  $type: "Input",
735
+ ...(inputType !== undefined ? { inputType } : {}),
377
736
  ...(placeholder !== undefined ? { placeholder } : {}),
378
737
  ...(multiline !== undefined ? { multiline } : {}),
379
738
  ...(label !== undefined ? { label } : {}),
380
739
  ...(name !== undefined ? { name } : {}),
740
+ ...(defaultValue !== undefined ? { defaultValue } : {}),
381
741
  };
382
742
  }
383
743
 
@@ -391,43 +751,75 @@ const mappedProps = (
391
751
  };
392
752
  }
393
753
 
754
+ if (component === "Slider") {
755
+ const min = finiteNumber(props["min"]) ?? 0;
756
+ const max = finiteNumber(props["max"]);
757
+ if (max === undefined || max < min) return undefined;
758
+ const value = finiteNumber(props["value"]);
759
+ const step = sliderStep(
760
+ min,
761
+ max,
762
+ name === undefined
763
+ ? value
764
+ : finiteNumber(resolvePointer(context.stepModel, name)),
765
+ props["steps"],
766
+ );
767
+ return {
768
+ $type: "Slider",
769
+ min,
770
+ max,
771
+ ...(step !== undefined ? { step } : {}),
772
+ ...(value !== undefined ? { defaultValue: value } : {}),
773
+ ...(label !== undefined ? { label } : {}),
774
+ ...(name !== undefined ? { name } : {}),
775
+ };
776
+ }
777
+
394
778
  if (component === "ChoicePicker") {
395
779
  const options = choiceOptions(props["options"]);
396
780
  const value = props["value"];
397
- const defaultValue =
781
+ const selected =
398
782
  typeof value === "string"
399
- ? value
400
- : Array.isArray(value) && typeof value[0] === "string"
401
- ? value[0]
402
- : undefined;
403
- const radioStyle =
404
- props["variant"] === "radio" ||
405
- props["variant"] === "single" ||
406
- props["variant"] === "singleSelection" ||
407
- props["displayStyle"] === "radio" ||
408
- (props["variant"] === "mutuallyExclusive" &&
409
- props["displayStyle"] !== "chips");
410
- if (radioStyle) {
783
+ ? [value]
784
+ : Array.isArray(value)
785
+ ? value.filter((entry): entry is string => typeof entry === "string")
786
+ : [];
787
+ if (props["variant"] === "multipleSelection") {
411
788
  return {
412
- $type: "RadioGroup",
789
+ $type: "CheckboxGroup",
413
790
  options,
414
791
  ...(label !== undefined ? { label } : {}),
415
792
  ...(name !== undefined ? { name } : {}),
416
- ...(defaultValue !== undefined ? { defaultValue } : {}),
793
+ ...(selected.length > 0 ? { defaultValue: selected } : {}),
417
794
  };
418
795
  }
419
- const placeholder = stringProp(props, ["placeholder"]);
796
+ const [defaultValue] = selected;
420
797
  return {
421
- $type: "Select",
798
+ $type: "RadioGroup",
422
799
  options,
423
- ...(placeholder !== undefined ? { placeholder } : {}),
424
800
  ...(label !== undefined ? { label } : {}),
425
801
  ...(name !== undefined ? { name } : {}),
802
+ ...(defaultValue !== undefined ? { defaultValue } : {}),
426
803
  };
427
804
  }
428
805
 
429
806
  if (component === "DateTimeInput") {
430
807
  const value = stringProp(props, ["value"]);
808
+ const shapeValue =
809
+ name === undefined ? value : resolvePointer(context.stepModel, name);
810
+ if (
811
+ props["enableTime"] === true ||
812
+ (typeof shapeValue === "string" &&
813
+ shapeValue !== "" &&
814
+ !DATE_PATTERN.test(shapeValue))
815
+ ) {
816
+ return {
817
+ $type: "Input",
818
+ ...(value !== undefined ? { defaultValue: value } : {}),
819
+ ...(label !== undefined ? { label } : {}),
820
+ ...(name !== undefined ? { name } : {}),
821
+ };
822
+ }
431
823
  const min = stringProp(props, ["min"]);
432
824
  const max = stringProp(props, ["max"]);
433
825
  return {
@@ -445,23 +837,21 @@ const mappedProps = (
445
837
 
446
838
  const convertTemplate = (
447
839
  node: Record<string, unknown>,
448
- templateChildren: A2uiTemplateChildren,
449
- dataSource: unknown,
840
+ template: ChildTemplate,
841
+ scope: Scope,
450
842
  context: ConversionContext,
451
843
  depth: number,
452
844
  visited: Set<string>,
453
- retained?: UIElement,
454
- mappedContainer?: UIElement,
845
+ converted: UIElement | undefined,
455
846
  ): UIElement | null => {
456
847
  if (!reserveNode(context)) return null;
457
- const horizontalList =
458
- node["component"] === "List" &&
459
- materialize(node["direction"], dataSource) === "horizontal";
460
- const container = mappedContainer ??
461
- retained ?? {
462
- $type: horizontalList ? "Row" : "ListView",
463
- };
464
- const list = resolvePointer(dataSource, templateChildren.template.path);
848
+ const container = converted ?? { $type: "ListView" };
849
+ const itemized = container.$type === "ListView";
850
+ const list = resolvePointer(
851
+ context.surface.dataModel,
852
+ pointerIn(scope, template.path),
853
+ );
854
+ const listPointer = pointerIn(scope, template.path);
465
855
  if (!Array.isArray(list)) {
466
856
  context.warnings.push(
467
857
  `Template on component "${String(node["id"] ?? "")}" did not resolve to a list.`,
@@ -477,21 +867,21 @@ const convertTemplate = (
477
867
  }
478
868
  const children: UIElement[] = [];
479
869
  for (let index = 0; index < itemCount; index++) {
480
- if (!retained && !horizontalList && !reserveNode(context)) break;
870
+ if (itemized && !reserveNode(context)) break;
481
871
  const child = convertComponent(
482
- templateChildren.template.componentId,
483
- list[index],
872
+ template.componentId,
873
+ { data: list[index], path: `${listPointer}/${index}` },
484
874
  context,
485
875
  depth + 1,
486
876
  visited,
487
877
  );
488
- if (retained || horizontalList) {
489
- if (child) children.push(child);
490
- } else {
878
+ if (itemized) {
491
879
  children.push({
492
880
  $type: "ListViewItem",
493
881
  ...(child ? { children: child } : {}),
494
882
  });
883
+ } else if (child) {
884
+ children.push(child);
495
885
  }
496
886
  }
497
887
  return { ...container, children };
@@ -499,7 +889,7 @@ const convertTemplate = (
499
889
 
500
890
  function convertComponent(
501
891
  componentId: string,
502
- dataSource: unknown,
892
+ scope: Scope,
503
893
  context: ConversionContext,
504
894
  depth: number,
505
895
  visited: Set<string>,
@@ -530,25 +920,98 @@ function convertComponent(
530
920
  );
531
921
  return null;
532
922
  }
533
- const templateChildren = node["children"];
534
- const hasTemplate = isTemplateChildren(templateChildren);
923
+ const template = childTemplateOf(node["children"]);
535
924
  if (
536
925
  !SUPPORTED_COMPONENTS.has(component) &&
537
926
  !context.keepUnknownComponents &&
538
- !hasTemplate
927
+ !template
539
928
  ) {
540
929
  context.warnings.push(
541
930
  `Unknown A2UI component "${component}" was skipped.`,
542
931
  );
543
932
  return null;
544
933
  }
545
- const props: Record<string, unknown> = {};
546
- for (const [key, value] of Object.entries(node)) {
547
- if (key === "id" || key === "component" || key === "children") continue;
548
- const resolved = materialize(value, dataSource);
549
- if (resolved !== undefined) setOwnProperty(props, key, resolved);
934
+ const cacheKey = context.cache
935
+ ? JSON.stringify([componentId, scope.path, depth])
936
+ : "";
937
+ let cached = context.cache?.get(cacheKey);
938
+ if (
939
+ cached &&
940
+ (context.evaluations + cached.evaluations > EVALUATION_BUDGET ||
941
+ [...cached.dependencies].some(
942
+ ([path, value]) =>
943
+ !Object.is(resolvePointer(context.surface.dataModel, path), value),
944
+ ))
945
+ )
946
+ cached = undefined;
947
+ let props: Record<string, unknown>;
948
+ let mapped: UIElement | undefined;
949
+ if (cached) {
950
+ props = cached.props;
951
+ mapped = cached.mapped;
952
+ context.evaluations += cached.evaluations;
953
+ } else {
954
+ props = {};
955
+ const dependencies = new Map<string, unknown>();
956
+ const evaluations = context.evaluations;
957
+ context.dependencies = dependencies;
958
+ context.scopePath = scope.path;
959
+ for (const [key, value] of Object.entries(node)) {
960
+ if (
961
+ key === "id" ||
962
+ key === "component" ||
963
+ key === "children" ||
964
+ key === "child"
965
+ )
966
+ continue;
967
+ const resolved = materialize(
968
+ value,
969
+ scope.data,
970
+ context,
971
+ key !== "checks",
972
+ );
973
+ if (resolved !== undefined) setOwnProperty(props, key, resolved);
974
+ }
975
+ context.dependencies = undefined;
976
+ mapped = mappedProps(node, props, context, scope);
977
+ if (context.cache && context.warnings.length === 0 && !mapped?.$action) {
978
+ cached = {
979
+ props,
980
+ mapped,
981
+ dependencies,
982
+ evaluations: context.evaluations - evaluations,
983
+ };
984
+ context.cache.set(cacheKey, cached);
985
+ }
550
986
  }
551
- const mapped = mappedProps(node, props, dataSource, context);
987
+ if (
988
+ mapped &&
989
+ context.bindings &&
990
+ typeof mapped["name"] === "string" &&
991
+ INPUT_COMPONENTS.has(component)
992
+ ) {
993
+ const field = mapped.$type === "Checkbox" ? "checked" : "value";
994
+ const defaultField =
995
+ mapped.$type === "Checkbox" ? "defaultChecked" : "defaultValue";
996
+ const fallback =
997
+ mapped.$type === "Checkbox"
998
+ ? false
999
+ : mapped.$type === "CheckboxGroup"
1000
+ ? []
1001
+ : mapped.$type === "Slider"
1002
+ ? mapped["min"]
1003
+ : "";
1004
+ const { [defaultField]: initialValue, ...rest } = mapped;
1005
+ mapped = { ...rest, [field]: initialValue ?? mapped[field] ?? fallback };
1006
+ context.bindings.set(mapped["name"] as string, {
1007
+ value: mapped[field],
1008
+ arrayValue:
1009
+ component === "ChoicePicker" &&
1010
+ mapped.$type !== "CheckboxGroup" &&
1011
+ typeof props["value"] !== "string",
1012
+ });
1013
+ }
1014
+
552
1015
  if (!mapped && SUPPORTED_COMPONENTS.has(component)) {
553
1016
  context.warnings.push(
554
1017
  `A2UI component "${component}" could not be mapped and was skipped.`,
@@ -564,30 +1027,45 @@ function convertComponent(
564
1027
  ),
565
1028
  }
566
1029
  : undefined;
567
- if (hasTemplate) {
1030
+ if (template) {
568
1031
  return convertTemplate(
569
1032
  node,
570
- templateChildren,
571
- dataSource,
1033
+ template,
1034
+ scope,
572
1035
  context,
573
1036
  depth,
574
1037
  visited,
575
- retained,
576
- component === "List" ? mapped : undefined,
1038
+ CHILD_LIST_COMPONENTS.has(component) ? mapped : retained,
577
1039
  );
578
1040
  }
579
1041
  if (!reserveNode(context)) return null;
580
1042
  const converted = mapped ?? retained;
581
1043
  if (!converted) return null;
582
- const children = childrenOf(node, dataSource, context, depth, visited);
1044
+ recordBindings(node, props, converted, scope, context);
1045
+ const children = childrenOf(node, scope, context, depth, visited);
1046
+ if (mapped?.$type === "Button" && mapped["label"] === undefined) {
1047
+ const label = textLabel(node, children, context);
1048
+ if (label !== undefined) return { ...mapped, label };
1049
+ }
583
1050
  const listChildren =
584
1051
  component === "List" && mapped?.$type === "ListView"
585
1052
  ? children.map((child) => ({ $type: "ListViewItem", children: child }))
586
1053
  : children;
587
- return {
1054
+ if (
1055
+ cached?.result &&
1056
+ cached.children?.length === listChildren.length &&
1057
+ cached.children.every((child, index) => child === listChildren[index])
1058
+ )
1059
+ return cached.result;
1060
+ const result = {
588
1061
  ...converted,
589
1062
  ...(listChildren.length > 0 ? { children: listChildren } : {}),
590
1063
  };
1064
+ if (cached) {
1065
+ cached.children = listChildren;
1066
+ cached.result = result;
1067
+ }
1068
+ return result;
591
1069
  } finally {
592
1070
  visited.delete(componentId);
593
1071
  }
@@ -595,11 +1073,41 @@ function convertComponent(
595
1073
 
596
1074
  export function convertSurfaceToUISpec(
597
1075
  surface: A2uiSurfaceState,
598
- options: { readonly keepUnknownComponents?: boolean } = {},
1076
+ options: {
1077
+ readonly keepUnknownComponents?: boolean;
1078
+ } = {},
599
1079
  ): {
600
1080
  spec: UIElement | null;
601
1081
  warnings: string[];
602
1082
  } {
1083
+ return convertSurface(surface, options.keepUnknownComponents === true);
1084
+ }
1085
+
1086
+ export const createLiveSurfaceConverter = (surface: A2uiSurfaceState) => {
1087
+ const cache = new Map<string, CachedComponent>();
1088
+ const templates = new Map<string, ExpressionPart[] | null>();
1089
+ return (dataModel: unknown) => {
1090
+ const bindings = new Map<string, A2uiBinding>();
1091
+ const converted = convertSurface(
1092
+ { ...surface, dataModel, [A2UI_SURFACE_ID]: sourceSurfaceId(surface) },
1093
+ false,
1094
+ bindings,
1095
+ cache,
1096
+ templates,
1097
+ surface.dataModel,
1098
+ );
1099
+ return { ...converted, bindings };
1100
+ };
1101
+ };
1102
+
1103
+ function convertSurface(
1104
+ surface: A2uiSurfaceState & { [A2UI_SURFACE_ID]?: string },
1105
+ keepUnknownComponents: boolean,
1106
+ bindings?: Map<string, A2uiBinding>,
1107
+ cache?: Map<string, CachedComponent>,
1108
+ templates = new Map<string, ExpressionPart[] | null>(),
1109
+ stepModel: unknown = surface.dataModel,
1110
+ ): { spec: UIElement | null; warnings: string[] } {
603
1111
  const warnings: string[] = [];
604
1112
  if (!surface.components.has("root")) {
605
1113
  return {
@@ -609,22 +1117,54 @@ export function convertSurfaceToUISpec(
609
1117
  }
610
1118
  const context: ConversionContext = {
611
1119
  surface,
1120
+ stepModel,
612
1121
  surfaceId: sourceSurfaceId(surface),
613
1122
  warnings,
614
1123
  emittedNodes: 0,
615
1124
  depthWarned: false,
616
1125
  budgetWarned: false,
617
1126
  templateCapWarned: false,
618
- keepUnknownComponents: options.keepUnknownComponents === true,
1127
+ evaluations: 0,
1128
+ evaluationBudgetWarned: false,
1129
+ functionDepthWarned: false,
1130
+ templates,
1131
+ inputFields: new Map(),
1132
+ textFields: new Map(),
1133
+ boundActionEntries: [],
1134
+ boundUserMessages: [],
1135
+ keepUnknownComponents,
1136
+ bindings,
1137
+ cache,
1138
+ dependencies: undefined,
1139
+ scopePath: "",
619
1140
  };
620
1141
  try {
621
1142
  const spec = convertComponent(
622
1143
  "root",
623
- surface.dataModel,
1144
+ { data: surface.dataModel, path: "" },
624
1145
  context,
625
1146
  0,
626
1147
  new Set(),
627
1148
  );
1149
+ for (const { target, key, pointer } of context.boundActionEntries) {
1150
+ const value = withFieldReferences(
1151
+ target[key],
1152
+ pointer,
1153
+ context.inputFields,
1154
+ );
1155
+ if (value !== undefined) setOwnProperty(target, key, value);
1156
+ }
1157
+ for (const { action, pointer } of context.boundUserMessages) {
1158
+ if (!context.textFields.get(pointer)) continue;
1159
+ const fallback = action["userMessage"];
1160
+ setOwnProperty(
1161
+ action,
1162
+ "userMessage",
1163
+ typeof fallback === "string"
1164
+ ? { $field: pointer, fallback }
1165
+ : { $field: pointer },
1166
+ );
1167
+ }
628
1168
  return { spec, warnings };
629
1169
  } catch {
630
1170
  warnings.push("A2UI surface conversion encountered malformed input.");