@assistant-ui/react-generative-ui 0.0.21 → 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 +344 -83
  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 +1795 -179
  136. package/src/a2ui/convert.ts +629 -132
  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 => {
@@ -184,7 +471,7 @@ const childReferences = (node: Record<string, unknown>): unknown[] => {
184
471
 
185
472
  const childrenOf = (
186
473
  node: Record<string, unknown>,
187
- dataSource: unknown,
474
+ scope: Scope,
188
475
  context: ConversionContext,
189
476
  depth: number,
190
477
  visited: Set<string>,
@@ -197,13 +484,7 @@ const childrenOf = (
197
484
  );
198
485
  continue;
199
486
  }
200
- const child = convertComponent(
201
- childId,
202
- dataSource,
203
- context,
204
- depth + 1,
205
- visited,
206
- );
487
+ const child = convertComponent(childId, scope, context, depth + 1, visited);
207
488
  if (child) result.push(child);
208
489
  }
209
490
  return result;
@@ -236,6 +517,11 @@ const mappedAction = (
236
517
  context: ConversionContext,
237
518
  ): UIElement["$action"] | undefined => {
238
519
  const action = props["action"];
520
+ if (action === undefined) return undefined;
521
+ const source = {
522
+ surfaceId: context.surfaceId,
523
+ sourceComponentId: typeof node["id"] === "string" ? node["id"] : "",
524
+ };
239
525
  const event =
240
526
  isRecord(action) && isRecord(action["event"]) ? action["event"] : action;
241
527
  const actionName =
@@ -244,15 +530,31 @@ const mappedAction = (
244
530
  : isRecord(event) && typeof event["name"] === "string"
245
531
  ? event["name"]
246
532
  : undefined;
247
- if (!actionName) return undefined;
248
- const actionContext = isRecord(event) ? event["context"] : undefined;
249
- return {
250
- type: "a2ui:action",
251
- name: actionName,
252
- surfaceId: context.surfaceId,
253
- sourceComponentId: typeof node["id"] === "string" ? node["id"] : "",
254
- ...(actionContext !== undefined ? { context: actionContext } : {}),
255
- };
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;
256
558
  };
257
559
 
258
560
  const choiceOptions = (value: unknown): { label: string; value: string }[] => {
@@ -274,6 +576,7 @@ const mappedProps = (
274
576
  node: Record<string, unknown>,
275
577
  props: Record<string, unknown>,
276
578
  context: ConversionContext,
579
+ scope: Scope,
277
580
  ): UIElement | undefined => {
278
581
  const component = node["component"];
279
582
 
@@ -378,38 +681,63 @@ const mappedProps = (
378
681
 
379
682
  if (component === "Button") {
380
683
  const label = stringProp(props, ["label", "text"]);
381
- 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;
382
694
  const block = props["block"];
383
695
  const submit = props["submit"];
384
696
  const action = mappedAction(node, props, context);
385
697
  return {
386
698
  $type: "Button",
387
699
  ...(label !== undefined ? { label } : {}),
388
- ...(typeof buttonStyle === "string" ? { buttonStyle } : {}),
700
+ ...(buttonStyle !== undefined ? { buttonStyle } : {}),
389
701
  ...(typeof block === "boolean" ? { block } : {}),
390
702
  ...(typeof submit === "boolean" ? { submit } : {}),
391
703
  ...(action ? { $action: action } : {}),
392
704
  };
393
705
  }
394
706
 
395
- const binding = firstDefined(node, ["text", "value", "binding"]);
396
- const name = lastPointerSegment(bindingPath(binding));
707
+ const bound = bindingPath(firstDefined(node, BOUND_KEYS));
708
+ const name = bound === undefined ? undefined : pointerIn(scope, bound);
397
709
  const label = stringProp(props, ["label"]);
398
710
 
399
711
  if (component === "TextField") {
400
712
  const placeholder = stringProp(props, ["placeholder"]);
713
+ const inputType =
714
+ props["variant"] === "obscured"
715
+ ? "password"
716
+ : props["variant"] === "number"
717
+ ? "number"
718
+ : undefined;
401
719
  const multiline =
402
720
  typeof props["multiline"] === "boolean"
403
721
  ? props["multiline"]
404
- : props["textFieldType"] === "longText"
722
+ : props["variant"] === "longText" ||
723
+ props["textFieldType"] === "longText"
405
724
  ? true
406
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;
407
733
  return {
408
734
  $type: "Input",
735
+ ...(inputType !== undefined ? { inputType } : {}),
409
736
  ...(placeholder !== undefined ? { placeholder } : {}),
410
737
  ...(multiline !== undefined ? { multiline } : {}),
411
738
  ...(label !== undefined ? { label } : {}),
412
739
  ...(name !== undefined ? { name } : {}),
740
+ ...(defaultValue !== undefined ? { defaultValue } : {}),
413
741
  };
414
742
  }
415
743
 
@@ -423,43 +751,75 @@ const mappedProps = (
423
751
  };
424
752
  }
425
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
+
426
778
  if (component === "ChoicePicker") {
427
779
  const options = choiceOptions(props["options"]);
428
780
  const value = props["value"];
429
- const defaultValue =
781
+ const selected =
430
782
  typeof value === "string"
431
- ? value
432
- : Array.isArray(value) && typeof value[0] === "string"
433
- ? value[0]
434
- : undefined;
435
- const radioStyle =
436
- props["variant"] === "radio" ||
437
- props["variant"] === "single" ||
438
- props["variant"] === "singleSelection" ||
439
- props["displayStyle"] === "radio" ||
440
- (props["variant"] === "mutuallyExclusive" &&
441
- props["displayStyle"] !== "chips");
442
- if (radioStyle) {
783
+ ? [value]
784
+ : Array.isArray(value)
785
+ ? value.filter((entry): entry is string => typeof entry === "string")
786
+ : [];
787
+ if (props["variant"] === "multipleSelection") {
443
788
  return {
444
- $type: "RadioGroup",
789
+ $type: "CheckboxGroup",
445
790
  options,
446
791
  ...(label !== undefined ? { label } : {}),
447
792
  ...(name !== undefined ? { name } : {}),
448
- ...(defaultValue !== undefined ? { defaultValue } : {}),
793
+ ...(selected.length > 0 ? { defaultValue: selected } : {}),
449
794
  };
450
795
  }
451
- const placeholder = stringProp(props, ["placeholder"]);
796
+ const [defaultValue] = selected;
452
797
  return {
453
- $type: "Select",
798
+ $type: "RadioGroup",
454
799
  options,
455
- ...(placeholder !== undefined ? { placeholder } : {}),
456
800
  ...(label !== undefined ? { label } : {}),
457
801
  ...(name !== undefined ? { name } : {}),
802
+ ...(defaultValue !== undefined ? { defaultValue } : {}),
458
803
  };
459
804
  }
460
805
 
461
806
  if (component === "DateTimeInput") {
462
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
+ }
463
823
  const min = stringProp(props, ["min"]);
464
824
  const max = stringProp(props, ["max"]);
465
825
  return {
@@ -477,23 +837,21 @@ const mappedProps = (
477
837
 
478
838
  const convertTemplate = (
479
839
  node: Record<string, unknown>,
480
- templateChildren: A2uiTemplateChildren,
481
- dataSource: unknown,
840
+ template: ChildTemplate,
841
+ scope: Scope,
482
842
  context: ConversionContext,
483
843
  depth: number,
484
844
  visited: Set<string>,
485
- retained?: UIElement,
486
- mappedContainer?: UIElement,
845
+ converted: UIElement | undefined,
487
846
  ): UIElement | null => {
488
847
  if (!reserveNode(context)) return null;
489
- const horizontalList =
490
- node["component"] === "List" &&
491
- materialize(node["direction"], dataSource) === "horizontal";
492
- const container = mappedContainer ??
493
- retained ?? {
494
- $type: horizontalList ? "Row" : "ListView",
495
- };
496
- 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);
497
855
  if (!Array.isArray(list)) {
498
856
  context.warnings.push(
499
857
  `Template on component "${String(node["id"] ?? "")}" did not resolve to a list.`,
@@ -509,21 +867,21 @@ const convertTemplate = (
509
867
  }
510
868
  const children: UIElement[] = [];
511
869
  for (let index = 0; index < itemCount; index++) {
512
- if (!retained && !horizontalList && !reserveNode(context)) break;
870
+ if (itemized && !reserveNode(context)) break;
513
871
  const child = convertComponent(
514
- templateChildren.template.componentId,
515
- list[index],
872
+ template.componentId,
873
+ { data: list[index], path: `${listPointer}/${index}` },
516
874
  context,
517
875
  depth + 1,
518
876
  visited,
519
877
  );
520
- if (retained || horizontalList) {
521
- if (child) children.push(child);
522
- } else {
878
+ if (itemized) {
523
879
  children.push({
524
880
  $type: "ListViewItem",
525
881
  ...(child ? { children: child } : {}),
526
882
  });
883
+ } else if (child) {
884
+ children.push(child);
527
885
  }
528
886
  }
529
887
  return { ...container, children };
@@ -531,7 +889,7 @@ const convertTemplate = (
531
889
 
532
890
  function convertComponent(
533
891
  componentId: string,
534
- dataSource: unknown,
892
+ scope: Scope,
535
893
  context: ConversionContext,
536
894
  depth: number,
537
895
  visited: Set<string>,
@@ -562,32 +920,98 @@ function convertComponent(
562
920
  );
563
921
  return null;
564
922
  }
565
- const templateChildren = node["children"];
566
- const hasTemplate = isTemplateChildren(templateChildren);
923
+ const template = childTemplateOf(node["children"]);
567
924
  if (
568
925
  !SUPPORTED_COMPONENTS.has(component) &&
569
926
  !context.keepUnknownComponents &&
570
- !hasTemplate
927
+ !template
571
928
  ) {
572
929
  context.warnings.push(
573
930
  `Unknown A2UI component "${component}" was skipped.`,
574
931
  );
575
932
  return null;
576
933
  }
577
- const props: Record<string, unknown> = {};
578
- for (const [key, value] of Object.entries(node)) {
579
- if (
580
- key === "id" ||
581
- key === "component" ||
582
- key === "children" ||
583
- key === "child"
584
- ) {
585
- continue;
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);
586
985
  }
587
- const resolved = materialize(value, dataSource);
588
- if (resolved !== undefined) setOwnProperty(props, key, resolved);
589
986
  }
590
- const mapped = mappedProps(node, props, 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
+
591
1015
  if (!mapped && SUPPORTED_COMPONENTS.has(component)) {
592
1016
  context.warnings.push(
593
1017
  `A2UI component "${component}" could not be mapped and was skipped.`,
@@ -603,22 +1027,22 @@ function convertComponent(
603
1027
  ),
604
1028
  }
605
1029
  : undefined;
606
- if (hasTemplate) {
1030
+ if (template) {
607
1031
  return convertTemplate(
608
1032
  node,
609
- templateChildren,
610
- dataSource,
1033
+ template,
1034
+ scope,
611
1035
  context,
612
1036
  depth,
613
1037
  visited,
614
- retained,
615
- component === "List" ? mapped : undefined,
1038
+ CHILD_LIST_COMPONENTS.has(component) ? mapped : retained,
616
1039
  );
617
1040
  }
618
1041
  if (!reserveNode(context)) return null;
619
1042
  const converted = mapped ?? retained;
620
1043
  if (!converted) return null;
621
- const children = childrenOf(node, dataSource, context, depth, visited);
1044
+ recordBindings(node, props, converted, scope, context);
1045
+ const children = childrenOf(node, scope, context, depth, visited);
622
1046
  if (mapped?.$type === "Button" && mapped["label"] === undefined) {
623
1047
  const label = textLabel(node, children, context);
624
1048
  if (label !== undefined) return { ...mapped, label };
@@ -627,10 +1051,21 @@ function convertComponent(
627
1051
  component === "List" && mapped?.$type === "ListView"
628
1052
  ? children.map((child) => ({ $type: "ListViewItem", children: child }))
629
1053
  : children;
630
- 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 = {
631
1061
  ...converted,
632
1062
  ...(listChildren.length > 0 ? { children: listChildren } : {}),
633
1063
  };
1064
+ if (cached) {
1065
+ cached.children = listChildren;
1066
+ cached.result = result;
1067
+ }
1068
+ return result;
634
1069
  } finally {
635
1070
  visited.delete(componentId);
636
1071
  }
@@ -638,11 +1073,41 @@ function convertComponent(
638
1073
 
639
1074
  export function convertSurfaceToUISpec(
640
1075
  surface: A2uiSurfaceState,
641
- options: { readonly keepUnknownComponents?: boolean } = {},
1076
+ options: {
1077
+ readonly keepUnknownComponents?: boolean;
1078
+ } = {},
642
1079
  ): {
643
1080
  spec: UIElement | null;
644
1081
  warnings: string[];
645
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[] } {
646
1111
  const warnings: string[] = [];
647
1112
  if (!surface.components.has("root")) {
648
1113
  return {
@@ -652,22 +1117,54 @@ export function convertSurfaceToUISpec(
652
1117
  }
653
1118
  const context: ConversionContext = {
654
1119
  surface,
1120
+ stepModel,
655
1121
  surfaceId: sourceSurfaceId(surface),
656
1122
  warnings,
657
1123
  emittedNodes: 0,
658
1124
  depthWarned: false,
659
1125
  budgetWarned: false,
660
1126
  templateCapWarned: false,
661
- 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: "",
662
1140
  };
663
1141
  try {
664
1142
  const spec = convertComponent(
665
1143
  "root",
666
- surface.dataModel,
1144
+ { data: surface.dataModel, path: "" },
667
1145
  context,
668
1146
  0,
669
1147
  new Set(),
670
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
+ }
671
1168
  return { spec, warnings };
672
1169
  } catch {
673
1170
  warnings.push("A2UI surface conversion encountered malformed input.");