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

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 (152) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +15 -17
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +28 -8
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/JSONGenerativeUI.server.d.ts +6 -9
  6. package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
  7. package/dist/JSONGenerativeUI.shared.d.ts +42 -44
  8. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  9. package/dist/a2ui/convert.d.ts +7 -8
  10. package/dist/a2ui/convert.d.ts.map +1 -1
  11. package/dist/a2ui/convert.js +115 -16
  12. package/dist/a2ui/convert.js.map +1 -1
  13. package/dist/a2ui/reducer.d.ts +2 -5
  14. package/dist/a2ui/reducer.d.ts.map +1 -1
  15. package/dist/a2ui/reducer.js +8 -0
  16. package/dist/a2ui/reducer.js.map +1 -1
  17. package/dist/a2ui/snapshot.d.ts +3 -0
  18. package/dist/a2ui/snapshot.d.ts.map +1 -0
  19. package/dist/a2ui/snapshot.js +33 -0
  20. package/dist/a2ui/snapshot.js.map +1 -0
  21. package/dist/a2ui/types.d.ts +67 -58
  22. package/dist/a2ui/types.d.ts.map +1 -1
  23. package/dist/a2ui/types.js.map +1 -1
  24. package/dist/a2ui.d.ts +5 -4
  25. package/dist/a2ui.d.ts.map +1 -0
  26. package/dist/a2ui.js +2 -1
  27. package/dist/actionRegistry.d.ts +17 -20
  28. package/dist/actionRegistry.d.ts.map +1 -1
  29. package/dist/actionRegistry.js.map +1 -1
  30. package/dist/buildPresentParameters.d.ts +3 -6
  31. package/dist/buildPresentParameters.d.ts.map +1 -1
  32. package/dist/buildPresentParameters.js +16 -4
  33. package/dist/buildPresentParameters.js.map +1 -1
  34. package/dist/constants.d.ts +2 -4
  35. package/dist/constants.d.ts.map +1 -1
  36. package/dist/constants.js +2 -1
  37. package/dist/constants.js.map +1 -1
  38. package/dist/convert/boundSpec.d.ts +7 -10
  39. package/dist/convert/boundSpec.d.ts.map +1 -1
  40. package/dist/convert/copyBounded.d.ts +3 -6
  41. package/dist/convert/copyBounded.d.ts.map +1 -1
  42. package/dist/convert/isElement.d.ts +2 -5
  43. package/dist/convert/isElement.d.ts.map +1 -1
  44. package/dist/convert/takeRun.d.ts +4 -7
  45. package/dist/convert/takeRun.d.ts.map +1 -1
  46. package/dist/defineGenerativeComponents.d.ts +2 -5
  47. package/dist/defineGenerativeComponents.d.ts.map +1 -1
  48. package/dist/generativeUIToJSX.d.ts +6 -9
  49. package/dist/generativeUIToJSX.d.ts.map +1 -1
  50. package/dist/index.d.ts +3 -13
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.server.d.ts +3 -13
  53. package/dist/index.server.d.ts.map +1 -0
  54. package/dist/index.shared.d.ts +15 -11
  55. package/dist/index.shared.d.ts.map +1 -0
  56. package/dist/ir.d.ts +45 -48
  57. package/dist/ir.d.ts.map +1 -1
  58. package/dist/renderGenerativeUI.d.ts +3 -6
  59. package/dist/renderGenerativeUI.d.ts.map +1 -1
  60. package/dist/scopeSchema.d.ts +6 -0
  61. package/dist/scopeSchema.d.ts.map +1 -0
  62. package/dist/scopeSchema.js +56 -0
  63. package/dist/scopeSchema.js.map +1 -0
  64. package/dist/slack/constants.d.ts +43 -45
  65. package/dist/slack/constants.d.ts.map +1 -1
  66. package/dist/slack/decodeBlockAction.d.ts +2 -5
  67. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  68. package/dist/slack/fromSlackBlocks.d.ts +2 -5
  69. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  70. package/dist/slack/toSlackBlocks.d.ts +2 -5
  71. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  72. package/dist/slack/toSlackBlocks.js +1 -1
  73. package/dist/slack/types.d.ts +110 -113
  74. package/dist/slack/types.d.ts.map +1 -1
  75. package/dist/slack.d.ts +5 -5
  76. package/dist/slack.d.ts.map +1 -0
  77. package/dist/teams/constants.d.ts +15 -18
  78. package/dist/teams/constants.d.ts.map +1 -1
  79. package/dist/teams/decodeSubmitData.d.ts +2 -5
  80. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  81. package/dist/teams/toAdaptiveCard.d.ts +10 -13
  82. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  83. package/dist/teams/toTeamsAttachments.d.ts +2 -5
  84. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  85. package/dist/teams/types.d.ts +136 -138
  86. package/dist/teams/types.d.ts.map +1 -1
  87. package/dist/teams.d.ts +5 -5
  88. package/dist/teams.d.ts.map +1 -0
  89. package/dist/types.d.ts +45 -47
  90. package/dist/types.d.ts.map +1 -1
  91. package/dist/vocabulary/alert.d.ts +22 -25
  92. package/dist/vocabulary/alert.d.ts.map +1 -1
  93. package/dist/vocabulary/collectFormValues.d.ts +12 -13
  94. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  95. package/dist/vocabulary/collectFormValues.js +3 -2
  96. package/dist/vocabulary/collectFormValues.js.map +1 -1
  97. package/dist/vocabulary/data.d.ts +46 -49
  98. package/dist/vocabulary/data.d.ts.map +1 -1
  99. package/dist/vocabulary/dispatch.d.ts +4 -7
  100. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  101. package/dist/vocabulary/fact.d.ts +9 -12
  102. package/dist/vocabulary/fact.d.ts.map +1 -1
  103. package/dist/vocabulary/form.d.ts +8 -11
  104. package/dist/vocabulary/form.d.ts.map +1 -1
  105. package/dist/vocabulary/icon.d.ts +38 -41
  106. package/dist/vocabulary/icon.d.ts.map +1 -1
  107. package/dist/vocabulary/index.d.ts +2 -5
  108. package/dist/vocabulary/index.d.ts.map +1 -1
  109. package/dist/vocabulary/interactive.d.ts +88 -87
  110. package/dist/vocabulary/interactive.d.ts.map +1 -1
  111. package/dist/vocabulary/interactive.js +6 -4
  112. package/dist/vocabulary/interactive.js.map +1 -1
  113. package/dist/vocabulary/layout.d.ts +76 -79
  114. package/dist/vocabulary/layout.d.ts.map +1 -1
  115. package/dist/vocabulary/list.d.ts +11 -14
  116. package/dist/vocabulary/list.d.ts.map +1 -1
  117. package/dist/vocabulary/media.d.ts +22 -25
  118. package/dist/vocabulary/media.d.ts.map +1 -1
  119. package/dist/vocabulary/text.d.ts +54 -57
  120. package/dist/vocabulary/text.d.ts.map +1 -1
  121. package/dist/vocabulary/toTextContent.d.ts +1 -4
  122. package/dist/vocabulary/toTextContent.d.ts.map +1 -1
  123. package/package.json +12 -10
  124. package/src/JSONGenerativeUI.client.tsx +57 -15
  125. package/src/JSONGenerativeUI.test.tsx +298 -4
  126. package/src/a2ui/convert.test.ts +496 -0
  127. package/src/a2ui/convert.ts +170 -25
  128. package/src/a2ui/reducer.test.ts +18 -0
  129. package/src/a2ui/reducer.ts +16 -0
  130. package/src/a2ui/snapshot.test.ts +52 -0
  131. package/src/a2ui/snapshot.ts +52 -0
  132. package/src/a2ui/types.ts +16 -0
  133. package/src/a2ui.ts +2 -0
  134. package/src/actionRegistry.ts +5 -5
  135. package/src/buildPresentParameters.test.ts +246 -0
  136. package/src/buildPresentParameters.ts +18 -3
  137. package/src/constants.ts +2 -0
  138. package/src/scopeSchema.test.ts +72 -0
  139. package/src/scopeSchema.ts +71 -0
  140. package/src/slack/fromSlackBlocks.test.ts +4 -1
  141. package/src/slack/toSlackBlocks.test.ts +8 -4
  142. package/src/teams/toAdaptiveCard.test.ts +5 -3
  143. package/src/vocabulary/collectFormValues.test.ts +38 -0
  144. package/src/vocabulary/collectFormValues.ts +11 -2
  145. package/src/vocabulary/form.test.tsx +1 -0
  146. package/src/vocabulary/interactive.dispatch.test.tsx +17 -3
  147. package/src/vocabulary/interactive.test.tsx +33 -1
  148. package/src/vocabulary/interactive.tsx +12 -2
  149. package/src/vocabulary/layout.test.tsx +1 -0
  150. package/src/vocabulary/list.test.tsx +5 -4
  151. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +0 -141
  152. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +0 -1
@@ -1,60 +1,57 @@
1
1
  import { z } from "zod";
2
- //#region src/vocabulary/text.d.ts
3
- declare const textVocabulary: {
4
- Header: {
5
- description: string;
6
- properties: z.ZodObject<{
7
- text: z.ZodString;
8
- size: z.ZodOptional<z.ZodEnum<{
9
- sm: "sm";
10
- md: "md";
11
- lg: "lg";
12
- xl: "xl";
13
- "2xl": "2xl";
14
- "3xl": "3xl";
15
- }>>;
16
- }, z.core.$strip>;
17
- render: ({ text, size, children }: any) => import("react").JSX.Element;
18
- };
19
- Text: {
20
- description: string;
21
- properties: z.ZodObject<{
22
- value: z.ZodString;
23
- size: z.ZodOptional<z.ZodEnum<{
24
- sm: "sm";
25
- md: "md";
26
- lg: "lg";
27
- xl: "xl";
28
- "2xl": "2xl";
29
- "3xl": "3xl";
30
- }>>;
31
- weight: z.ZodOptional<z.ZodEnum<{
32
- bold: "bold";
33
- normal: "normal";
34
- medium: "medium";
35
- semibold: "semibold";
36
- }>>;
37
- color: z.ZodOptional<z.ZodEnum<{
38
- emphasis: "emphasis";
39
- secondary: "secondary";
40
- "alpha-70": "alpha-70";
41
- white: "white";
42
- "white-70": "white-70";
43
- "white-50": "white-50";
44
- }>>;
45
- }, z.core.$strip>;
46
- streamProperties: true;
47
- render: ({ value, size, weight, color, children }: any) => import("react").JSX.Element;
48
- };
49
- Caption: {
50
- description: string;
51
- properties: z.ZodObject<{
52
- value: z.ZodString;
53
- }, z.core.$strip>;
54
- streamProperties: true;
55
- render: ({ value, children }: any) => import("react").JSX.Element;
56
- };
2
+ export declare const textVocabulary: {
3
+ Header: {
4
+ description: string;
5
+ properties: z.ZodObject<{
6
+ text: z.ZodString;
7
+ size: z.ZodOptional<z.ZodEnum<{
8
+ sm: "sm";
9
+ md: "md";
10
+ lg: "lg";
11
+ xl: "xl";
12
+ "2xl": "2xl";
13
+ "3xl": "3xl";
14
+ }>>;
15
+ }, z.core.$strip>;
16
+ render: ({ text, size, children }: any) => import("react").JSX.Element;
17
+ };
18
+ Text: {
19
+ description: string;
20
+ properties: z.ZodObject<{
21
+ value: z.ZodString;
22
+ size: z.ZodOptional<z.ZodEnum<{
23
+ sm: "sm";
24
+ md: "md";
25
+ lg: "lg";
26
+ xl: "xl";
27
+ "2xl": "2xl";
28
+ "3xl": "3xl";
29
+ }>>;
30
+ weight: z.ZodOptional<z.ZodEnum<{
31
+ normal: "normal";
32
+ medium: "medium";
33
+ semibold: "semibold";
34
+ bold: "bold";
35
+ }>>;
36
+ color: z.ZodOptional<z.ZodEnum<{
37
+ emphasis: "emphasis";
38
+ secondary: "secondary";
39
+ "alpha-70": "alpha-70";
40
+ white: "white";
41
+ "white-70": "white-70";
42
+ "white-50": "white-50";
43
+ }>>;
44
+ }, z.core.$strip>;
45
+ streamProperties: true;
46
+ render: ({ value, size, weight, color, children }: any) => import("react").JSX.Element;
47
+ };
48
+ Caption: {
49
+ description: string;
50
+ properties: z.ZodObject<{
51
+ value: z.ZodString;
52
+ }, z.core.$strip>;
53
+ streamProperties: true;
54
+ render: ({ value, children }: any) => import("react").JSX.Element;
55
+ };
57
56
  };
58
- //#endregion
59
- export { textVocabulary };
60
57
  //# sourceMappingURL=text.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"text.d.ts","names":[],"sources":["../../src/vocabulary/text.tsx"],"mappings":";;cAKa"}
1
+ {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/vocabulary/text.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsDI,CAAC"}
@@ -1,5 +1,2 @@
1
- //#region src/vocabulary/toTextContent.d.ts
2
- declare const toTextContent: (value: unknown) => string | number | null;
3
- //#endregion
4
- export { toTextContent };
1
+ export declare const toTextContent: (value: unknown) => string | number | null;
5
2
  //# sourceMappingURL=toTextContent.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"toTextContent.d.ts","names":[],"sources":["../../src/vocabulary/toTextContent.ts"],"mappings":";cAAa,gBAAiB"}
1
+ {"version":3,"file":"toTextContent.d.ts","sourceRoot":"","sources":["../../src/vocabulary/toTextContent.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,aAAa,GAAI,OAAO,OAAO,KAAG,MAAM,GAAG,MAAM,GAAG,IACM,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-generative-ui",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "Generative UI components for assistant-ui",
5
5
  "keywords": [
6
6
  "generative-ui",
@@ -50,7 +50,8 @@
50
50
  ],
51
51
  "sideEffects": false,
52
52
  "dependencies": {
53
- "assistant-stream": "^0.3.42"
53
+ "@types/json-schema": "^7.0.15",
54
+ "assistant-stream": "^0.3.45"
54
55
  },
55
56
  "peerDependencies": {
56
57
  "@assistant-ui/react": "^0.15.0",
@@ -64,14 +65,14 @@
64
65
  }
65
66
  },
66
67
  "devDependencies": {
67
- "@assistant-ui/react": "0.15.19",
68
- "@assistant-ui/x-buildutils": "0.0.26",
69
- "@types/json-schema": "^7.0.15",
70
- "@types/react": "^19.2.18",
71
- "react": "^19.2.8",
72
- "react-dom": "^19.2.8",
73
- "vitest": "^4.1.11",
74
- "zod": "^4.5.4"
68
+ "@assistant-ui/react": "0.15.22",
69
+ "@assistant-ui/x-buildutils": "0.0.29",
70
+ "@types/react": "^19.3.0",
71
+ "jsdom": "^30.0.1",
72
+ "react": "^19.3.0",
73
+ "react-dom": "^19.3.0",
74
+ "vitest": "^5.0.1",
75
+ "zod": "^4.6.5"
75
76
  },
76
77
  "publishConfig": {
77
78
  "access": "public",
@@ -89,6 +90,7 @@
89
90
  "scripts": {
90
91
  "build": "aui-build",
91
92
  "test": "vitest run",
93
+ "typecheck": "tsc --noEmit",
92
94
  "test:watch": "vitest"
93
95
  }
94
96
  }
@@ -1,3 +1,4 @@
1
+ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
1
2
  import type { ReactNode } from "react";
2
3
  import { buildPresentParameters } from "./buildPresentParameters";
3
4
  import {
@@ -36,6 +37,7 @@ export class JSONGenerativeUI {
36
37
  private readonly library: GenerativeUILibrary;
37
38
  private readonly parameters: PresentParameters;
38
39
  private readonly actions: ActionRegistry | undefined;
40
+ private readonly completedPromptToolCallIds = new Set<string>();
39
41
 
40
42
  constructor(options: JSONGenerativeUIOptions) {
41
43
  this.library = options.library;
@@ -48,20 +50,60 @@ export class JSONGenerativeUI {
48
50
  * between top-level blocks, which the host's message container does not
49
51
  * provide, and stays out of the way while it has nothing to show.
50
52
  */
51
- private readonly render = ({
52
- args,
53
- status,
54
- }: {
55
- args: unknown;
56
- status: { type: string };
57
- }): ReactNode => (
58
- <div data-aui="root">
59
- {renderGenerativeUI(args, this.library, {
60
- status: uiStatus(status),
61
- ...(this.actions ? { dispatch: this.actions.dispatch } : {}),
62
- })}
63
- </div>
64
- );
53
+ private readonly render = (
54
+ {
55
+ args,
56
+ status,
57
+ addResult,
58
+ result,
59
+ toolCallId,
60
+ unstable_recordInteraction,
61
+ }: ToolCallMessagePartProps<Record<string, unknown>, any>,
62
+ completesPrompt = false,
63
+ ): ReactNode => {
64
+ const actions = this.actions;
65
+ const dispatch = actions
66
+ ? (action: Parameters<ActionRegistry["dispatch"]>[0]) => {
67
+ if (unstable_recordInteraction) {
68
+ try {
69
+ void unstable_recordInteraction({
70
+ type: "action",
71
+ payload: action,
72
+ }).catch(() => {});
73
+ } catch {}
74
+ }
75
+
76
+ const actionResult = actions.dispatch(action);
77
+ if (
78
+ completesPrompt &&
79
+ actionResult !== undefined &&
80
+ result === undefined
81
+ ) {
82
+ void Promise.resolve(actionResult)
83
+ .then((response) => {
84
+ if (
85
+ response !== undefined &&
86
+ !this.completedPromptToolCallIds.has(toolCallId)
87
+ ) {
88
+ this.completedPromptToolCallIds.add(toolCallId);
89
+ addResult(response);
90
+ }
91
+ })
92
+ .catch(() => {});
93
+ }
94
+ return actionResult;
95
+ }
96
+ : undefined;
97
+
98
+ return (
99
+ <div data-aui="root">
100
+ {renderGenerativeUI(args, this.library, {
101
+ status: uiStatus(status),
102
+ ...(dispatch ? { dispatch } : {}),
103
+ })}
104
+ </div>
105
+ );
106
+ };
65
107
 
66
108
  present(options?: PresentToolOptions): PresentTool {
67
109
  return {
@@ -76,7 +118,7 @@ export class JSONGenerativeUI {
76
118
  return {
77
119
  ...promptUserToolBase(this.parameters),
78
120
  unstable_backendDefault: { parameters: true },
79
- render: this.render,
121
+ render: (props) => this.render(props, true),
80
122
  };
81
123
  }
82
124
  }
@@ -1,10 +1,19 @@
1
- import { describe, it, expect } from "vitest";
1
+ /** @vitest-environment jsdom */
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
2
5
  import { renderToStaticMarkup } from "react-dom/server";
3
6
  import { z } from "zod";
4
7
  import { JSONGenerativeUI as ClientGenUI } from "./JSONGenerativeUI.client";
5
8
  import { JSONGenerativeUI as ServerGenUI } from "./JSONGenerativeUI.server";
6
9
  import { defineGenerativeComponents } from "./defineGenerativeComponents";
7
- import type { GenerativeUILibrary } from "./types";
10
+ import { createActionRegistry, type ActionRegistry } from "./actionRegistry";
11
+ import type { GenerativeUIDispatch, GenerativeUILibrary } from "./types";
12
+ import { defaultGenerativeUILibrary } from "./vocabulary";
13
+
14
+ (
15
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
16
+ ).IS_REACT_ACT_ENVIRONMENT = true;
8
17
 
9
18
  const library: GenerativeUILibrary = {
10
19
  Card: {
@@ -21,10 +30,43 @@ const library: GenerativeUILibrary = {
21
30
  },
22
31
  };
23
32
 
24
- const renderTool = (tool: any, args: unknown) =>
33
+ const renderTool = (
34
+ tool: any,
35
+ args: unknown,
36
+ props: Record<string, unknown> = {},
37
+ ) =>
25
38
  renderToStaticMarkup(
26
- <>{tool.render({ args, status: { type: "complete" } })}</>,
39
+ <>{tool.render({ args, status: { type: "complete" }, ...props })}</>,
40
+ );
41
+
42
+ const captureActionDispatch = (
43
+ toolType: "present" | "promptUser",
44
+ actions: ActionRegistry | undefined,
45
+ props: Record<string, unknown> = {},
46
+ ): GenerativeUIDispatch => {
47
+ let dispatch: GenerativeUIDispatch | undefined;
48
+ const ui = new ClientGenUI({
49
+ library: {
50
+ Action: {
51
+ description: "An action target.",
52
+ properties: z.object({}),
53
+ render: ({ $dispatch }: any) => {
54
+ dispatch = $dispatch;
55
+ return null;
56
+ },
57
+ },
58
+ },
59
+ ...(actions ? { actions } : {}),
60
+ });
61
+
62
+ renderTool(
63
+ toolType === "present" ? ui.present() : ui.promptUser(),
64
+ { $type: "Action", $action: { type: "answer" } },
65
+ props,
27
66
  );
67
+ if (!dispatch) throw new Error("Expected an action dispatch.");
68
+ return dispatch;
69
+ };
28
70
 
29
71
  describe("JSONGenerativeUI — client build", () => {
30
72
  const ui = new ClientGenUI({ library });
@@ -73,6 +115,258 @@ describe("JSONGenerativeUI — client build", () => {
73
115
  const html = renderTool(tool, { $type: "Button", label: "ok" });
74
116
  expect(html).toContain("<button>ok</button>");
75
117
  });
118
+
119
+ it("records actions before dispatching them without waiting for recording", () => {
120
+ const recording = vi.fn(() => new Promise<void>(() => {}));
121
+ const handler = vi.fn(() => "accepted");
122
+ const dispatch = captureActionDispatch(
123
+ "present",
124
+ createActionRegistry({ answer: handler }),
125
+ { unstable_recordInteraction: recording },
126
+ );
127
+ const action = { type: "answer", $input: { choice: "yes" } };
128
+
129
+ expect(dispatch(action)).toBe("accepted");
130
+ expect(recording).toHaveBeenCalledWith({
131
+ type: "action",
132
+ payload: action,
133
+ });
134
+ expect(handler).toHaveBeenCalledWith({ payload: action });
135
+ expect(recording.mock.invocationCallOrder[0]).toBeLessThan(
136
+ handler.mock.invocationCallOrder[0]!,
137
+ );
138
+ });
139
+
140
+ it("keeps dispatch intact when action recording rejects", async () => {
141
+ const recording = vi.fn(() => Promise.reject(new Error("unavailable")));
142
+ const handler = vi.fn(() => "accepted");
143
+ const dispatch = captureActionDispatch(
144
+ "present",
145
+ createActionRegistry({ answer: handler }),
146
+ { unstable_recordInteraction: recording },
147
+ );
148
+ const action = { type: "answer" };
149
+
150
+ expect(dispatch(action)).toBe("accepted");
151
+ await Promise.resolve();
152
+ expect(handler).toHaveBeenCalledWith({ payload: action });
153
+ });
154
+
155
+ it("keeps dispatch intact when action recording is unavailable", () => {
156
+ const handler = vi.fn(() => "accepted");
157
+ const dispatch = captureActionDispatch(
158
+ "present",
159
+ createActionRegistry({ answer: handler }),
160
+ );
161
+ const action = { type: "answer" };
162
+
163
+ expect(dispatch(action)).toBe("accepted");
164
+ expect(handler).toHaveBeenCalledWith({ payload: action });
165
+ });
166
+
167
+ it("does not record actions when no registry is configured", () => {
168
+ const recording = vi.fn(() => Promise.resolve());
169
+ let dispatch: GenerativeUIDispatch | undefined;
170
+ const ui = new ClientGenUI({
171
+ library: {
172
+ Action: {
173
+ description: "An action target.",
174
+ properties: z.object({}),
175
+ render: ({ $dispatch }: any) => {
176
+ dispatch = $dispatch;
177
+ return null;
178
+ },
179
+ },
180
+ },
181
+ });
182
+
183
+ renderTool(
184
+ ui.present(),
185
+ { $type: "Action", $action: { type: "answer" } },
186
+ { unstable_recordInteraction: recording },
187
+ );
188
+
189
+ expect(dispatch).toBeUndefined();
190
+ expect(recording).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it("completes prompt_user with a synchronous action result", async () => {
194
+ const addResult = vi.fn();
195
+ const dispatch = captureActionDispatch(
196
+ "promptUser",
197
+ createActionRegistry({ answer: () => ({ choice: "yes" }) }),
198
+ { addResult },
199
+ );
200
+
201
+ expect(dispatch({ type: "answer" })).toEqual({ choice: "yes" });
202
+ await Promise.resolve();
203
+ expect(addResult).toHaveBeenCalledWith({ choice: "yes" });
204
+ });
205
+
206
+ it("completes prompt_user with an asynchronous action result", async () => {
207
+ const addResult = vi.fn();
208
+ const response = Promise.resolve({ choice: "yes" });
209
+ const dispatch = captureActionDispatch(
210
+ "promptUser",
211
+ createActionRegistry({ answer: () => response }),
212
+ { addResult },
213
+ );
214
+
215
+ expect(dispatch({ type: "answer" })).toBe(response);
216
+ await response;
217
+ await Promise.resolve();
218
+ expect(addResult).toHaveBeenCalledWith({ choice: "yes" });
219
+ });
220
+
221
+ it("completes prompt_user once when delayed action results overlap", async () => {
222
+ const addResult = vi.fn();
223
+ let resolveFirst!: (response: { choice: string }) => void;
224
+ let resolveSecond!: (response: { choice: string }) => void;
225
+ const first = new Promise<{ choice: string }>((resolve) => {
226
+ resolveFirst = resolve;
227
+ });
228
+ const second = new Promise<{ choice: string }>((resolve) => {
229
+ resolveSecond = resolve;
230
+ });
231
+ const responses = [first, second];
232
+ const dispatch = captureActionDispatch(
233
+ "promptUser",
234
+ createActionRegistry({ answer: () => responses.shift() }),
235
+ { addResult },
236
+ );
237
+
238
+ const firstAction = dispatch({ type: "answer", choice: "first" });
239
+ const secondAction = dispatch({ type: "answer", choice: "second" });
240
+ resolveFirst({ choice: "first" });
241
+ await firstAction;
242
+ await Promise.resolve();
243
+ resolveSecond({ choice: "second" });
244
+ await secondAction;
245
+ await Promise.resolve();
246
+
247
+ expect(addResult).toHaveBeenCalledTimes(1);
248
+ expect(addResult).toHaveBeenCalledWith({ choice: "first" });
249
+ });
250
+
251
+ it("does not complete prompt_user when the action result is undefined", async () => {
252
+ const addResult = vi.fn();
253
+ const dispatch = captureActionDispatch(
254
+ "promptUser",
255
+ createActionRegistry({ answer: () => undefined }),
256
+ { addResult },
257
+ );
258
+
259
+ expect(dispatch({ type: "answer" })).toBeUndefined();
260
+ await Promise.resolve();
261
+ expect(addResult).not.toHaveBeenCalled();
262
+ });
263
+
264
+ it("does not complete prompt_user when the action handler rejects", async () => {
265
+ const addResult = vi.fn();
266
+ const response = Promise.reject(new Error("rejected"));
267
+ const dispatch = captureActionDispatch(
268
+ "promptUser",
269
+ createActionRegistry({ answer: () => response }),
270
+ { addResult },
271
+ );
272
+
273
+ await expect(Promise.resolve(dispatch({ type: "answer" }))).rejects.toThrow(
274
+ "rejected",
275
+ );
276
+ await Promise.resolve();
277
+ expect(addResult).not.toHaveBeenCalled();
278
+ });
279
+
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" } },
286
+ );
287
+
288
+ dispatch({ type: "answer" });
289
+ await Promise.resolve();
290
+ expect(addResult).not.toHaveBeenCalled();
291
+ });
292
+
293
+ it("never completes present from an action result", async () => {
294
+ const addResult = vi.fn();
295
+ const dispatch = captureActionDispatch(
296
+ "present",
297
+ createActionRegistry({ answer: () => ({ choice: "yes" }) }),
298
+ { addResult },
299
+ );
300
+
301
+ dispatch({ type: "answer" });
302
+ await Promise.resolve();
303
+ expect(addResult).not.toHaveBeenCalled();
304
+ });
305
+
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
+ );
335
+ });
336
+
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
+ }
347
+
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 }),
354
+ );
355
+ await Promise.resolve();
356
+ });
357
+
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
+ });
76
370
  });
77
371
 
78
372
  describe("JSONGenerativeUI — server build", () => {