@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,9 +1,13 @@
1
+ import { convertSurfaceToUISpec } from "./a2ui/convert";
2
+ import { surfaceToOperations } from "./a2ui/snapshot";
3
+ import { applyA2uiOperations } from "./a2ui/reducer";
1
4
  /** @vitest-environment jsdom */
2
5
  import { describe, it, expect, vi } from "vitest";
3
6
  import { act } from "react";
4
7
  import { createRoot } from "react-dom/client";
5
8
  import { renderToStaticMarkup } from "react-dom/server";
6
9
  import { z } from "zod";
10
+ import { parsePartialJsonObject } from "assistant-stream/utils";
7
11
  import { JSONGenerativeUI as ClientGenUI } from "./JSONGenerativeUI.client";
8
12
  import { JSONGenerativeUI as ServerGenUI } from "./JSONGenerativeUI.server";
9
13
  import { defineGenerativeComponents } from "./defineGenerativeComponents";
@@ -11,6 +15,21 @@ import { createActionRegistry, type ActionRegistry } from "./actionRegistry";
11
15
  import type { GenerativeUIDispatch, GenerativeUILibrary } from "./types";
12
16
  import { defaultGenerativeUILibrary } from "./vocabulary";
13
17
 
18
+ const { evaluateValue } = vi.hoisted(() => ({ evaluateValue: vi.fn() }));
19
+ vi.mock("./a2ui/valueFunctions", async (importOriginal) => {
20
+ const original =
21
+ await importOriginal<typeof import("./a2ui/valueFunctions")>();
22
+ return {
23
+ ...original,
24
+ evaluateA2uiValueFunction: (
25
+ ...args: Parameters<typeof original.evaluateA2uiValueFunction>
26
+ ) => {
27
+ evaluateValue(...args);
28
+ return original.evaluateA2uiValueFunction(...args);
29
+ },
30
+ };
31
+ });
32
+
14
33
  (
15
34
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
16
35
  ).IS_REACT_ACT_ENVIRONMENT = true;
@@ -116,6 +135,270 @@ describe("JSONGenerativeUI — client build", () => {
116
135
  expect(html).toContain("<button>ok</button>");
117
136
  });
118
137
 
138
+ it("leaves present output live when the tool call has a result", () => {
139
+ const interactive = new ClientGenUI({
140
+ library: defaultGenerativeUILibrary,
141
+ });
142
+ const html = renderTool(
143
+ interactive.present(),
144
+ { $type: "Input", name: "note", defaultValue: "model default" },
145
+ {
146
+ result: { submitted: true },
147
+ unstable_interactions: {
148
+ entries: [
149
+ {
150
+ type: "action",
151
+ occurredAt: 1,
152
+ payload: { $input: { note: "submitted" } },
153
+ },
154
+ ],
155
+ },
156
+ },
157
+ );
158
+
159
+ expect(html).not.toContain('data-aui="answered"');
160
+ expect(html).toContain('value="model default"');
161
+ });
162
+
163
+ it("locks an answered prompt without recorded actions at its defaults", () => {
164
+ const interactive = new ClientGenUI({
165
+ library: defaultGenerativeUILibrary,
166
+ });
167
+ const html = renderTool(
168
+ interactive.promptUser(),
169
+ { $type: "Input", name: "note", defaultValue: "model default" },
170
+ { result: { submitted: true } },
171
+ );
172
+
173
+ expect(html).toContain('<fieldset disabled="" data-aui="answered">');
174
+ expect(html).toContain('value="model default"');
175
+ });
176
+
177
+ it("locks an answered prompt and restores submitted Form values", async () => {
178
+ const handler = vi.fn();
179
+ const interactive = new ClientGenUI({
180
+ library: defaultGenerativeUILibrary,
181
+ actions: createActionRegistry({ save: handler }),
182
+ });
183
+ const container = document.createElement("div");
184
+ const root = createRoot(container);
185
+
186
+ try {
187
+ await act(async () => {
188
+ root.render(
189
+ (interactive.promptUser() as any).render({
190
+ args: {
191
+ $type: "Form",
192
+ $action: { type: "save" },
193
+ children: [
194
+ {
195
+ $type: "Input",
196
+ name: "message",
197
+ defaultValue: "model message",
198
+ },
199
+ {
200
+ $type: "Select",
201
+ name: "role",
202
+ defaultValue: "viewer",
203
+ options: [
204
+ { label: "Viewer", value: "viewer" },
205
+ { label: "Editor", value: "editor" },
206
+ ],
207
+ },
208
+ {
209
+ $type: "DatePicker",
210
+ name: "date",
211
+ value: "2026-01-01",
212
+ },
213
+ {
214
+ $type: "Checkbox",
215
+ name: "updates",
216
+ label: "Updates",
217
+ defaultChecked: false,
218
+ },
219
+ {
220
+ $type: "RadioGroup",
221
+ name: "plan",
222
+ defaultValue: "basic",
223
+ options: [
224
+ { label: "Basic", value: "basic" },
225
+ { label: "Pro", value: "pro" },
226
+ ],
227
+ },
228
+ {
229
+ $type: "CheckboxGroup",
230
+ name: "toppings",
231
+ defaultValue: ["olives"],
232
+ options: [
233
+ { label: "Basil", value: "basil" },
234
+ { label: "Olives", value: "olives" },
235
+ { label: "Onion", value: "onion" },
236
+ ],
237
+ },
238
+ { $type: "Button", label: "Save", submit: true },
239
+ ],
240
+ },
241
+ status: { type: "complete" },
242
+ toolCallId: "answered-form",
243
+ toolName: "prompt_user",
244
+ argsText: "",
245
+ addResult: vi.fn(),
246
+ result: { saved: true },
247
+ unstable_interactions: {
248
+ entries: [
249
+ {
250
+ type: "action",
251
+ occurredAt: 1,
252
+ payload: { $input: { message: "older value" } },
253
+ },
254
+ {
255
+ type: "action",
256
+ occurredAt: 2,
257
+ payload: {
258
+ $input: {
259
+ message: "submitted message",
260
+ role: "editor",
261
+ date: "2026-02-03",
262
+ updates: true,
263
+ plan: "pro",
264
+ toppings: ["basil", "onion"],
265
+ },
266
+ },
267
+ },
268
+ ],
269
+ },
270
+ }),
271
+ );
272
+ });
273
+
274
+ const answered = container.querySelector('fieldset[data-aui="answered"]');
275
+ const form = container.querySelector("form");
276
+ const message = container.querySelector<HTMLInputElement>(
277
+ 'input[name="message"]',
278
+ );
279
+ const role = container.querySelector<HTMLSelectElement>(
280
+ 'select[name="role"]',
281
+ );
282
+ const date =
283
+ container.querySelector<HTMLInputElement>('input[name="date"]');
284
+ const updates = container.querySelector<HTMLInputElement>(
285
+ 'input[name="updates"]',
286
+ );
287
+ const pro = container.querySelector<HTMLInputElement>(
288
+ 'input[type="radio"][value="pro"]',
289
+ );
290
+ const toppings = Array.from(
291
+ container.querySelectorAll<HTMLInputElement>('input[name="toppings"]'),
292
+ );
293
+ const button = container.querySelector("button");
294
+ if (
295
+ !answered ||
296
+ !form ||
297
+ !message ||
298
+ !role ||
299
+ !date ||
300
+ !updates ||
301
+ !pro ||
302
+ !button
303
+ ) {
304
+ throw new Error("Expected answered prompt controls to render.");
305
+ }
306
+
307
+ expect(message.value).toBe("submitted message");
308
+ expect(role.value).toBe("editor");
309
+ expect(date.value).toBe("2026-02-03");
310
+ expect(updates.checked).toBe(true);
311
+ expect(pro.checked).toBe(true);
312
+ expect(toppings.map((input) => input.checked)).toEqual([
313
+ true,
314
+ false,
315
+ true,
316
+ ]);
317
+ for (const control of [
318
+ ...container.querySelectorAll("input, select, textarea, button"),
319
+ ]) {
320
+ expect(control.matches(":disabled")).toBe(true);
321
+ }
322
+
323
+ await act(async () => {
324
+ button.click();
325
+ form.dispatchEvent(
326
+ new Event("submit", { bubbles: true, cancelable: true }),
327
+ );
328
+ });
329
+
330
+ expect(handler).not.toHaveBeenCalled();
331
+ } finally {
332
+ await act(async () => root.unmount());
333
+ }
334
+ });
335
+
336
+ it("keeps an unanswered prompt live", async () => {
337
+ const handler = vi.fn();
338
+ const interactive = new ClientGenUI({
339
+ library: defaultGenerativeUILibrary,
340
+ actions: createActionRegistry({ save: handler }),
341
+ });
342
+ const container = document.createElement("div");
343
+ const root = createRoot(container);
344
+
345
+ try {
346
+ await act(async () => {
347
+ root.render(
348
+ (interactive.promptUser() as any).render({
349
+ args: {
350
+ $type: "Button",
351
+ label: "Save",
352
+ $action: { type: "save" },
353
+ },
354
+ status: { type: "complete" },
355
+ toolCallId: "unanswered-button",
356
+ toolName: "prompt_user",
357
+ argsText: "",
358
+ addResult: vi.fn(),
359
+ }),
360
+ );
361
+ });
362
+
363
+ const button = container.querySelector("button");
364
+ if (!button) throw new Error("Expected an unanswered prompt button.");
365
+ expect(container.querySelector('[data-aui="answered"]')).toBeNull();
366
+ await act(async () => button.click());
367
+ expect(handler).toHaveBeenCalledWith({ payload: { type: "save" } });
368
+ } finally {
369
+ await act(async () => root.unmount());
370
+ }
371
+ });
372
+
373
+ it.each(["running", "incomplete"])(
374
+ "holds prompt controls back for %s arguments",
375
+ (type) => {
376
+ expect(
377
+ renderTool(
378
+ ui.promptUser(),
379
+ { $type: "Button", label: "Answer" },
380
+ { status: { type } },
381
+ ),
382
+ ).toBe('<div data-aui="root"></div>');
383
+ },
384
+ );
385
+
386
+ it("holds back a pending prompt whose cancelled argument stream is partial", () => {
387
+ const args = parsePartialJsonObject('{"$type":"Button","label":"Ans');
388
+ expect(
389
+ renderTool(ui.promptUser(), args, {
390
+ status: { type: "requires-action", reason: "tool-calls" },
391
+ }),
392
+ ).toBe('<div data-aui="root"></div>');
393
+ expect(
394
+ renderTool(
395
+ ui.promptUser(),
396
+ parsePartialJsonObject('{"$type":"Button","label":"Answer"}'),
397
+ { status: { type: "requires-action", reason: "tool-calls" } },
398
+ ),
399
+ ).toContain("<button>Answer</button>");
400
+ });
401
+
119
402
  it("records actions before dispatching them without waiting for recording", () => {
120
403
  const recording = vi.fn(() => new Promise<void>(() => {}));
121
404
  const handler = vi.fn(() => "accepted");
@@ -277,17 +560,29 @@ describe("JSONGenerativeUI — client build", () => {
277
560
  expect(addResult).not.toHaveBeenCalled();
278
561
  });
279
562
 
280
- it("does not complete prompt_user when the tool call already has a result", async () => {
281
- const addResult = vi.fn();
282
- const dispatch = captureActionDispatch(
283
- "promptUser",
284
- createActionRegistry({ answer: () => ({ choice: "new" }) }),
285
- { addResult, result: { choice: "existing" } },
563
+ it("does not provide an action dispatch when prompt_user already has a result", () => {
564
+ let dispatch: GenerativeUIDispatch | undefined;
565
+ const ui = new ClientGenUI({
566
+ library: {
567
+ Action: {
568
+ description: "An action target.",
569
+ properties: z.object({}),
570
+ render: ({ $dispatch }: any) => {
571
+ dispatch = $dispatch;
572
+ return null;
573
+ },
574
+ },
575
+ },
576
+ actions: createActionRegistry({ answer: vi.fn() }),
577
+ });
578
+
579
+ renderTool(
580
+ ui.promptUser(),
581
+ { $type: "Action", $action: { type: "answer" } },
582
+ { result: { choice: "existing" } },
286
583
  );
287
584
 
288
- dispatch({ type: "answer" });
289
- await Promise.resolve();
290
- expect(addResult).not.toHaveBeenCalled();
585
+ expect(dispatch).toBeUndefined();
291
586
  });
292
587
 
293
588
  it("never completes present from an action result", async () => {
@@ -303,70 +598,76 @@ describe("JSONGenerativeUI — client build", () => {
303
598
  expect(addResult).not.toHaveBeenCalled();
304
599
  });
305
600
 
306
- it("completes prompt_user form submissions with named field values", async () => {
307
- const addResult = vi.fn();
308
- const handler = vi.fn(() => ({ submitted: true }));
309
- const ui = new ClientGenUI({
310
- library: defaultGenerativeUILibrary,
311
- actions: createActionRegistry({ submit: handler }),
312
- });
313
- const container = document.createElement("div");
314
- const root = createRoot(container);
315
-
316
- try {
317
- await act(async () => {
318
- root.render(
319
- (ui.promptUser() as any).render({
320
- args: {
321
- $type: "Form",
322
- $action: { type: "submit" },
323
- children: [
324
- { $type: "Input", name: "email" },
325
- { $type: "Checkbox", name: "updates", label: "Updates" },
326
- ],
327
- },
328
- status: { type: "complete" },
329
- toolCallId: "prompt-form",
330
- toolName: "prompt_user",
331
- argsText: "",
332
- addResult,
333
- }),
334
- );
601
+ it.each(["complete", "requires-action"])(
602
+ "completes %s prompt_user form submissions with named field values",
603
+ async (type) => {
604
+ const addResult = vi.fn();
605
+ const handler = vi.fn(() => ({ submitted: true }));
606
+ const ui = new ClientGenUI({
607
+ library: defaultGenerativeUILibrary,
608
+ actions: createActionRegistry({ submit: handler }),
335
609
  });
610
+ const container = document.createElement("div");
611
+ const root = createRoot(container);
336
612
 
337
- const form = container.querySelector("form");
338
- const email = container.querySelector<HTMLInputElement>(
339
- 'input[name="email"]',
340
- );
341
- const updates = container.querySelector<HTMLInputElement>(
342
- 'input[name="updates"]',
343
- );
344
- if (!form || !email || !updates) {
345
- throw new Error("Expected the prompt form fields to render.");
346
- }
613
+ try {
614
+ await act(async () => {
615
+ root.render(
616
+ (ui.promptUser() as any).render({
617
+ args: {
618
+ $type: "Form",
619
+ $action: { type: "submit" },
620
+ children: [
621
+ { $type: "Input", name: "email" },
622
+ { $type: "Checkbox", name: "updates", label: "Updates" },
623
+ ],
624
+ },
625
+ status: {
626
+ type,
627
+ ...(type === "requires-action" ? { reason: "tool-calls" } : {}),
628
+ },
629
+ toolCallId: "prompt-form",
630
+ toolName: "prompt_user",
631
+ argsText: "",
632
+ addResult,
633
+ }),
634
+ );
635
+ });
347
636
 
348
- email.value = "ada@example.com";
349
- updates.checked = true;
350
- let submitted = true;
351
- await act(async () => {
352
- submitted = form.dispatchEvent(
353
- new Event("submit", { bubbles: true, cancelable: true }),
637
+ const form = container.querySelector("form");
638
+ const email = container.querySelector<HTMLInputElement>(
639
+ 'input[name="email"]',
354
640
  );
355
- await Promise.resolve();
356
- });
641
+ const updates = container.querySelector<HTMLInputElement>(
642
+ 'input[name="updates"]',
643
+ );
644
+ if (!form || !email || !updates) {
645
+ throw new Error("Expected the prompt form fields to render.");
646
+ }
357
647
 
358
- expect(submitted).toBe(false);
359
- expect(handler).toHaveBeenCalledWith({
360
- payload: {
361
- type: "submit",
362
- $input: { email: "ada@example.com", updates: true },
363
- },
364
- });
365
- expect(addResult).toHaveBeenCalledWith({ submitted: true });
366
- } finally {
367
- await act(async () => root.unmount());
368
- }
369
- });
648
+ email.value = "ada@example.com";
649
+ updates.checked = true;
650
+ let submitted = true;
651
+ await act(async () => {
652
+ submitted = form.dispatchEvent(
653
+ new Event("submit", { bubbles: true, cancelable: true }),
654
+ );
655
+ await Promise.resolve();
656
+ });
657
+
658
+ expect(submitted).toBe(false);
659
+ expect(handler).toHaveBeenCalledWith({
660
+ payload: {
661
+ type: "submit",
662
+ $input: { email: "ada@example.com", updates: true },
663
+ },
664
+ });
665
+ expect(addResult).toHaveBeenCalledWith({ submitted: true });
666
+ } finally {
667
+ await act(async () => root.unmount());
668
+ }
669
+ },
670
+ );
370
671
  });
371
672
 
372
673
  describe("JSONGenerativeUI — server build", () => {
@@ -400,3 +701,598 @@ describe("defineGenerativeComponents", () => {
400
701
  );
401
702
  });
402
703
  });
704
+
705
+ describe("live A2UI surfaces", () => {
706
+ const bindingFixture = ({
707
+ inputCount = 2,
708
+ customInput = false,
709
+ path = "/profile/name",
710
+ dataModel = { profile: { name: "Initial" }, other: "Other" },
711
+ template = false,
712
+ }: {
713
+ inputCount?: number;
714
+ customInput?: boolean;
715
+ path?: string;
716
+ dataModel?: unknown;
717
+ template?: boolean;
718
+ } = {}) => {
719
+ const handler = vi.fn();
720
+ const ui = new ClientGenUI({
721
+ library: {
722
+ ...defaultGenerativeUILibrary,
723
+ ...(customInput
724
+ ? {
725
+ Input: {
726
+ ...defaultGenerativeUILibrary["Input"]!,
727
+ render: ({ name, value, defaultValue }: any) => (
728
+ <input name={name} defaultValue={value ?? defaultValue} />
729
+ ),
730
+ },
731
+ }
732
+ : {}),
733
+ },
734
+ actions: createActionRegistry({ "a2ui:action": handler }),
735
+ });
736
+ const fields = Array.from({ length: inputCount }, (_, index) => ({
737
+ id: `field${index}`,
738
+ component: "TextField",
739
+ text: { path },
740
+ }));
741
+ const surface = applyA2uiOperations(new Map(), [
742
+ {
743
+ version: "v1.0",
744
+ createSurface: {
745
+ surfaceId: "s",
746
+ components: [
747
+ {
748
+ id: "root",
749
+ component: "Column",
750
+ children: template
751
+ ? { componentId: "row", path: "/items" }
752
+ : [...fields.map(({ id }) => id), "preview", "submit"],
753
+ },
754
+ {
755
+ id: "row",
756
+ component: "Column",
757
+ children: [...fields.map(({ id }) => id), "preview", "submit"],
758
+ },
759
+ ...fields,
760
+ {
761
+ id: "preview",
762
+ component: "Text",
763
+ text: { call: "formatString", args: { value: "${/other}" } },
764
+ },
765
+ {
766
+ id: "submit",
767
+ component: "Button",
768
+ label: "Submit",
769
+ action: {
770
+ name: "submit",
771
+ context: { name: { path } },
772
+ },
773
+ },
774
+ ],
775
+ dataModel,
776
+ },
777
+ },
778
+ ]).state.get("s")!;
779
+ const container = document.createElement("div");
780
+ document.body.append(container);
781
+ const root = createRoot(container);
782
+ const render = async (dataModel = surface.dataModel) => {
783
+ const next = { ...surface, dataModel };
784
+ await act(async () => {
785
+ root.render(
786
+ (ui.present() as any).render({
787
+ args: convertSurfaceToUISpec(next).spec,
788
+ status: { type: "complete" },
789
+ toolCallId: "a2ui:s",
790
+ artifact: {
791
+ a2ui: structuredClone(surfaceToOperations(next, "s")),
792
+ },
793
+ }),
794
+ );
795
+ });
796
+ };
797
+ const inputs = () => [
798
+ ...container.querySelectorAll<HTMLInputElement>("input"),
799
+ ];
800
+ const edit = async (value: string, notify = true, index = 0) => {
801
+ await act(async () => {
802
+ const input = inputs()[index]!;
803
+ Object.getOwnPropertyDescriptor(
804
+ HTMLInputElement.prototype,
805
+ "value",
806
+ )!.set!.call(input, value);
807
+ if (notify) input.dispatchEvent(new Event("input", { bubbles: true }));
808
+ });
809
+ };
810
+ const close = async () => {
811
+ await act(async () => root.unmount());
812
+ container.remove();
813
+ };
814
+ return { handler, container, render, inputs, edit, close };
815
+ };
816
+
817
+ it("preserves edits across unchanged snapshots with new operation identities", async () => {
818
+ const fixture = bindingFixture();
819
+ try {
820
+ await fixture.render();
821
+ await fixture.edit("Edited");
822
+ await fixture.render();
823
+ await fixture.render();
824
+ expect(fixture.inputs().map((input) => input.value)).toEqual([
825
+ "Edited",
826
+ "Edited",
827
+ ]);
828
+ } finally {
829
+ await fixture.close();
830
+ }
831
+ });
832
+
833
+ it("preserves edits when the agent updates a different path", async () => {
834
+ const fixture = bindingFixture();
835
+ try {
836
+ await fixture.render();
837
+ await fixture.edit("Edited");
838
+ await fixture.render({ profile: { name: "Initial" }, other: "Remote" });
839
+ expect(fixture.inputs().map((input) => input.value)).toEqual([
840
+ "Edited",
841
+ "Edited",
842
+ ]);
843
+ expect(
844
+ fixture.container.querySelector('[data-aui="markdown"]')?.textContent,
845
+ ).toBe("Remote");
846
+ } finally {
847
+ await fixture.close();
848
+ }
849
+ });
850
+
851
+ it.each([
852
+ {
853
+ label: "array index",
854
+ path: "/names/0",
855
+ dataModel: { names: ["Initial", "Other"] },
856
+ incoming: { names: ["Initial", "Remote"] },
857
+ },
858
+ {
859
+ label: "escaped pointer",
860
+ path: "/profile/na~1me~0",
861
+ dataModel: { profile: { "na/me~": "Initial", other: "Other" } },
862
+ incoming: { profile: { "na/me~": "Initial", other: "Remote" } },
863
+ },
864
+ {
865
+ label: "missing data",
866
+ path: "/profile/name",
867
+ dataModel: null,
868
+ incoming: { other: "Remote" },
869
+ },
870
+ {
871
+ label: "template scope",
872
+ path: "name",
873
+ template: true,
874
+ dataModel: { items: [{ name: "Initial", other: "Other" }] },
875
+ incoming: { items: [{ name: "Initial", other: "Remote" }] },
876
+ },
877
+ ])(
878
+ "preserves edits at a $label during unrelated agent updates",
879
+ async ({ incoming, ...options }) => {
880
+ const fixture = bindingFixture(options);
881
+ try {
882
+ await fixture.render();
883
+ await fixture.edit("Edited");
884
+ await fixture.render(incoming);
885
+ expect(fixture.inputs().map((input) => input.value)).toEqual([
886
+ "Edited",
887
+ "Edited",
888
+ ]);
889
+ await act(async () =>
890
+ fixture.container.querySelector("button")!.click(),
891
+ );
892
+ expect(fixture.handler).toHaveBeenCalledWith({
893
+ payload: {
894
+ type: "a2ui:action",
895
+ name: "submit",
896
+ surfaceId: "s",
897
+ sourceComponentId: "submit",
898
+ context: { name: "Edited" },
899
+ },
900
+ });
901
+ } finally {
902
+ await fixture.close();
903
+ }
904
+ },
905
+ );
906
+
907
+ it("lets an agent change to the edited path win after unrelated updates", async () => {
908
+ const fixture = bindingFixture();
909
+ try {
910
+ await fixture.render();
911
+ await fixture.edit("Edited");
912
+ await fixture.render({ profile: { name: "Initial" }, other: "Remote" });
913
+ expect(fixture.inputs().map((input) => input.value)).toEqual([
914
+ "Edited",
915
+ "Edited",
916
+ ]);
917
+ await fixture.render({ profile: { name: "Agent" }, other: "Remote" });
918
+ expect(fixture.inputs().map((input) => input.value)).toEqual([
919
+ "Agent",
920
+ "Agent",
921
+ ]);
922
+ await fixture.edit("Again");
923
+ await fixture.render({ profile: { name: "Agent" }, other: "Remote" });
924
+ expect(fixture.inputs().map((input) => input.value)).toEqual([
925
+ "Again",
926
+ "Again",
927
+ ]);
928
+ } finally {
929
+ await fixture.close();
930
+ }
931
+ });
932
+
933
+ it.each([
934
+ { count: 1, customInput: false },
935
+ { count: 2, customInput: false },
936
+ { count: 1, customInput: true },
937
+ { count: 2, customInput: true },
938
+ ])(
939
+ "resolves a DOM-only edit through $field with $count bound inputs (custom: $customInput)",
940
+ async ({ count, customInput }) => {
941
+ const fixture = bindingFixture({ inputCount: count, customInput });
942
+ try {
943
+ await fixture.render();
944
+ await fixture.edit("Autofilled", false, count - 1);
945
+ await act(async () =>
946
+ fixture.container.querySelector("button")!.click(),
947
+ );
948
+ expect(fixture.handler).toHaveBeenCalledWith({
949
+ payload: {
950
+ type: "a2ui:action",
951
+ name: "submit",
952
+ surfaceId: "s",
953
+ sourceComponentId: "submit",
954
+ context: { name: "Autofilled" },
955
+ },
956
+ });
957
+ } finally {
958
+ await fixture.close();
959
+ }
960
+ },
961
+ );
962
+
963
+ it("renders bound input names without actions and retains button field references", async () => {
964
+ const fixture = bindingFixture();
965
+ try {
966
+ await fixture.render();
967
+ expect(fixture.inputs().map((input) => input.name)).toEqual([
968
+ "/profile/name",
969
+ "/profile/name",
970
+ ]);
971
+ expect(
972
+ fixture
973
+ .inputs()
974
+ .every((input) => !input.hasAttribute("data-aui-action")),
975
+ ).toBe(true);
976
+ expect(
977
+ JSON.parse(
978
+ fixture.container
979
+ .querySelector("button")!
980
+ .getAttribute("data-aui-action")!,
981
+ ),
982
+ ).toMatchObject({
983
+ context: { name: { $field: "/profile/name", fallback: "Initial" } },
984
+ });
985
+ } finally {
986
+ await fixture.close();
987
+ }
988
+ });
989
+
990
+ it("does not reevaluate unrelated function values while typing", async () => {
991
+ const fixture = bindingFixture();
992
+ try {
993
+ const before = evaluateValue.mock.calls.length;
994
+ await fixture.render();
995
+ expect(evaluateValue.mock.calls.length - before).toBe(2);
996
+ await fixture.edit("One");
997
+ await fixture.edit("Two");
998
+ expect(evaluateValue.mock.calls.length - before).toBe(2);
999
+ expect(fixture.inputs().map((input) => input.value)).toEqual([
1000
+ "Two",
1001
+ "Two",
1002
+ ]);
1003
+ expect(
1004
+ fixture.container.querySelector('[data-aui="markdown"]')?.textContent,
1005
+ ).toBe("Other");
1006
+ } finally {
1007
+ await fixture.close();
1008
+ }
1009
+ });
1010
+
1011
+ it("keeps choice arrays and slider focus while updating live bindings", async () => {
1012
+ const handler = vi.fn();
1013
+ const ui = new ClientGenUI({
1014
+ library: defaultGenerativeUILibrary,
1015
+ actions: createActionRegistry({ "a2ui:action": handler }),
1016
+ });
1017
+ const { state } = applyA2uiOperations(new Map(), [
1018
+ {
1019
+ version: "v1.0",
1020
+ createSurface: {
1021
+ surfaceId: "controls",
1022
+ components: [
1023
+ {
1024
+ id: "root",
1025
+ component: "Column",
1026
+ children: ["plan", "volume", "submit"],
1027
+ },
1028
+ {
1029
+ id: "plan",
1030
+ component: "ChoicePicker",
1031
+ value: { path: "/plan" },
1032
+ options: [
1033
+ { label: "Free", value: "free" },
1034
+ { label: "Pro", value: "pro" },
1035
+ ],
1036
+ },
1037
+ {
1038
+ id: "volume",
1039
+ component: "Slider",
1040
+ min: 0,
1041
+ max: 10,
1042
+ steps: 10,
1043
+ value: { path: "/volume" },
1044
+ },
1045
+ {
1046
+ id: "submit",
1047
+ component: "Button",
1048
+ label: "Submit",
1049
+ action: {
1050
+ name: "submit",
1051
+ context: {
1052
+ plan: { path: "/plan" },
1053
+ volume: { path: "/volume" },
1054
+ },
1055
+ },
1056
+ },
1057
+ ],
1058
+ dataModel: { plan: ["free"], volume: 2 },
1059
+ },
1060
+ },
1061
+ ]);
1062
+ const surface = state.get("controls")!;
1063
+ const container = document.createElement("div");
1064
+ document.body.append(container);
1065
+ const root = createRoot(container);
1066
+ try {
1067
+ await act(async () => {
1068
+ root.render(
1069
+ (ui.present() as any).render({
1070
+ args: convertSurfaceToUISpec(surface).spec,
1071
+ status: { type: "complete" },
1072
+ toolCallId: "a2ui:controls",
1073
+ toolName: "present",
1074
+ argsText: "",
1075
+ artifact: { a2ui: surfaceToOperations(surface) },
1076
+ }),
1077
+ );
1078
+ });
1079
+ const pro =
1080
+ container.querySelector<HTMLInputElement>('input[value="pro"]')!;
1081
+ await act(async () => pro.click());
1082
+ expect(pro.checked).toBe(true);
1083
+ const slider = container.querySelector<HTMLInputElement>(
1084
+ 'input[type="range"]',
1085
+ )!;
1086
+ slider.focus();
1087
+ await act(async () => {
1088
+ Object.getOwnPropertyDescriptor(
1089
+ HTMLInputElement.prototype,
1090
+ "value",
1091
+ )!.set!.call(slider, "6");
1092
+ slider.dispatchEvent(new Event("input", { bubbles: true }));
1093
+ });
1094
+ expect(container.querySelector('input[type="range"]')).toBe(slider);
1095
+ expect(document.activeElement).toBe(slider);
1096
+ expect(slider.value).toBe("6");
1097
+ expect(container.querySelector("output")?.textContent).toBe("6");
1098
+ await act(async () => container.querySelector("button")!.click());
1099
+ expect(handler).toHaveBeenCalledWith({
1100
+ payload: {
1101
+ type: "a2ui:action",
1102
+ name: "submit",
1103
+ surfaceId: "controls",
1104
+ sourceComponentId: "submit",
1105
+ context: { plan: ["pro"], volume: 6 },
1106
+ },
1107
+ });
1108
+ } finally {
1109
+ await act(async () => root.unmount());
1110
+ container.remove();
1111
+ }
1112
+ });
1113
+
1114
+ it("keeps a continuous bound slider mounted as its value becomes an integer", async () => {
1115
+ const ui = new ClientGenUI({ library: defaultGenerativeUILibrary });
1116
+ const surface = applyA2uiOperations(new Map(), [
1117
+ {
1118
+ version: "v1.0",
1119
+ createSurface: {
1120
+ surfaceId: "slider",
1121
+ components: [
1122
+ {
1123
+ id: "root",
1124
+ component: "Slider",
1125
+ min: 0,
1126
+ max: 100,
1127
+ value: { path: "/volume" },
1128
+ },
1129
+ ],
1130
+ dataModel: { volume: 37.5 },
1131
+ },
1132
+ },
1133
+ ]).state.get("slider")!;
1134
+ const container = document.createElement("div");
1135
+ document.body.append(container);
1136
+ const root = createRoot(container);
1137
+ try {
1138
+ await act(async () => {
1139
+ root.render(
1140
+ (ui.present() as any).render({
1141
+ args: convertSurfaceToUISpec(surface).spec,
1142
+ status: { type: "complete" },
1143
+ toolCallId: "a2ui:slider",
1144
+ artifact: { a2ui: surfaceToOperations(surface) },
1145
+ }),
1146
+ );
1147
+ });
1148
+ const slider = container.querySelector<HTMLInputElement>(
1149
+ 'input[type="range"]',
1150
+ )!;
1151
+ expect(slider.step).toBe("0.1");
1152
+ slider.focus();
1153
+ await act(async () => {
1154
+ Object.getOwnPropertyDescriptor(
1155
+ HTMLInputElement.prototype,
1156
+ "value",
1157
+ )!.set!.call(slider, "40");
1158
+ slider.dispatchEvent(new Event("input", { bubbles: true }));
1159
+ });
1160
+ expect(container.querySelector('input[type="range"]')).toBe(slider);
1161
+ expect(document.activeElement).toBe(slider);
1162
+ expect(slider.value).toBe("40");
1163
+ expect(slider.step).toBe("0.1");
1164
+ expect(container.querySelector("output")?.textContent).toBe("40");
1165
+ } finally {
1166
+ await act(async () => root.unmount());
1167
+ container.remove();
1168
+ }
1169
+ });
1170
+
1171
+ it("keeps A2UI bindings live while rendering a present surface", async () => {
1172
+ const handler = vi.fn();
1173
+ const ui = new ClientGenUI({
1174
+ library: defaultGenerativeUILibrary,
1175
+ actions: createActionRegistry({ "a2ui:action": handler }),
1176
+ });
1177
+ const { state } = applyA2uiOperations(new Map(), [
1178
+ {
1179
+ version: "v1.0",
1180
+ createSurface: {
1181
+ surfaceId: "s",
1182
+ components: [
1183
+ {
1184
+ id: "root",
1185
+ component: "Column",
1186
+ children: ["first", "second", "preview", "derived", "submit"],
1187
+ },
1188
+ { id: "first", component: "TextField", text: { path: "/name" } },
1189
+ { id: "second", component: "TextField", text: { path: "/name" } },
1190
+ { id: "preview", component: "Text", text: { path: "/name" } },
1191
+ {
1192
+ id: "derived",
1193
+ component: "Text",
1194
+ text: {
1195
+ call: "formatString",
1196
+ args: { value: "Hello ${/name}" },
1197
+ },
1198
+ },
1199
+ {
1200
+ id: "submit",
1201
+ component: "Button",
1202
+ label: "Submit",
1203
+ action: { name: "submit", context: { name: { path: "/name" } } },
1204
+ },
1205
+ ],
1206
+ dataModel: { name: "Initial" },
1207
+ },
1208
+ },
1209
+ ]);
1210
+ const surface = state.get("s")!;
1211
+ const container = document.createElement("div");
1212
+ document.body.append(container);
1213
+ const root = createRoot(container);
1214
+
1215
+ try {
1216
+ await act(async () => {
1217
+ root.render(
1218
+ (ui.present() as any).render({
1219
+ args: convertSurfaceToUISpec(surface).spec,
1220
+ status: { type: "complete" },
1221
+ toolCallId: "a2ui:s",
1222
+ toolName: "present",
1223
+ argsText: "",
1224
+ artifact: { a2ui: surfaceToOperations(surface) },
1225
+ }),
1226
+ );
1227
+ });
1228
+
1229
+ const fields =
1230
+ container.querySelectorAll<HTMLInputElement>('[data-aui="input"]');
1231
+ expect([...fields].map((field) => field.value)).toEqual([
1232
+ "Initial",
1233
+ "Initial",
1234
+ ]);
1235
+
1236
+ await act(async () => {
1237
+ Object.getOwnPropertyDescriptor(
1238
+ HTMLInputElement.prototype,
1239
+ "value",
1240
+ )!.set!.call(fields[0]!, "Edited");
1241
+ fields[0]!.dispatchEvent(new Event("input", { bubbles: true }));
1242
+ });
1243
+
1244
+ expect([...fields].map((field) => field.value)).toEqual([
1245
+ "Edited",
1246
+ "Edited",
1247
+ ]);
1248
+ expect(
1249
+ container.querySelector('[data-aui="markdown"]')?.textContent,
1250
+ ).toBe("Edited");
1251
+ expect(
1252
+ container.querySelectorAll('[data-aui="markdown"]')[1]?.textContent,
1253
+ ).toBe("Hello Edited");
1254
+
1255
+ await act(async () => container.querySelector("button")!.click());
1256
+
1257
+ expect(handler).toHaveBeenCalledWith({
1258
+ payload: {
1259
+ type: "a2ui:action",
1260
+ name: "submit",
1261
+ surfaceId: "s",
1262
+ sourceComponentId: "submit",
1263
+ context: { name: "Edited" },
1264
+ },
1265
+ });
1266
+
1267
+ const remoteSurface = applyA2uiOperations(state, [
1268
+ {
1269
+ version: "v0.9",
1270
+ updateDataModel: {
1271
+ surfaceId: "s",
1272
+ path: "/name",
1273
+ contents: "Remote",
1274
+ },
1275
+ },
1276
+ ]).state.get("s")!;
1277
+ await act(async () => {
1278
+ root.render(
1279
+ (ui.present() as any).render({
1280
+ args: convertSurfaceToUISpec(remoteSurface).spec,
1281
+ status: { type: "complete" },
1282
+ toolCallId: "a2ui:s",
1283
+ toolName: "present",
1284
+ argsText: "",
1285
+ artifact: { a2ui: surfaceToOperations(remoteSurface) },
1286
+ }),
1287
+ );
1288
+ });
1289
+ expect([...fields].map((field) => field.value)).toEqual([
1290
+ "Remote",
1291
+ "Remote",
1292
+ ]);
1293
+ } finally {
1294
+ await act(async () => root.unmount());
1295
+ container.remove();
1296
+ }
1297
+ });
1298
+ });