@assistant-ui/react-generative-ui 0.0.17 → 0.0.19

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 (141) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +1 -2
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.server.d.ts +1 -2
  4. package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
  5. package/dist/JSONGenerativeUI.shared.d.ts +8 -9
  6. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  7. package/dist/a2ui/convert.d.ts +1 -2
  8. package/dist/a2ui/convert.d.ts.map +1 -1
  9. package/dist/a2ui/convert.js +11 -2
  10. package/dist/a2ui/convert.js.map +1 -1
  11. package/dist/a2ui/reducer.d.ts +1 -2
  12. package/dist/a2ui/reducer.d.ts.map +1 -1
  13. package/dist/a2ui/reducer.js +63 -10
  14. package/dist/a2ui/reducer.js.map +1 -1
  15. package/dist/a2ui/types.d.ts +17 -18
  16. package/dist/a2ui/types.d.ts.map +1 -1
  17. package/dist/actionRegistry.d.ts +5 -6
  18. package/dist/actionRegistry.d.ts.map +1 -1
  19. package/dist/buildPresentParameters.d.ts +2 -3
  20. package/dist/buildPresentParameters.d.ts.map +1 -1
  21. package/dist/buildPresentParameters.js +4 -3
  22. package/dist/buildPresentParameters.js.map +1 -1
  23. package/dist/constants.d.ts +2 -2
  24. package/dist/constants.d.ts.map +1 -1
  25. package/dist/constants.js +2 -1
  26. package/dist/constants.js.map +1 -1
  27. package/dist/convert/boundSpec.d.ts +7 -8
  28. package/dist/convert/boundSpec.d.ts.map +1 -1
  29. package/dist/convert/copyBounded.d.ts +1 -2
  30. package/dist/convert/copyBounded.d.ts.map +1 -1
  31. package/dist/convert/isElement.d.ts +1 -2
  32. package/dist/convert/isElement.d.ts.map +1 -1
  33. package/dist/convert/takeRun.d.ts +1 -2
  34. package/dist/convert/takeRun.d.ts.map +1 -1
  35. package/dist/defineGenerativeComponents.d.ts +1 -2
  36. package/dist/defineGenerativeComponents.d.ts.map +1 -1
  37. package/dist/generativeUIToJSX.d.ts +2 -3
  38. package/dist/generativeUIToJSX.d.ts.map +1 -1
  39. package/dist/ir.d.ts +28 -29
  40. package/dist/ir.d.ts.map +1 -1
  41. package/dist/renderGenerativeUI.d.ts +1 -2
  42. package/dist/renderGenerativeUI.d.ts.map +1 -1
  43. package/dist/renderGenerativeUI.js +1 -1
  44. package/dist/renderGenerativeUI.js.map +1 -1
  45. package/dist/slack/constants.d.ts +35 -36
  46. package/dist/slack/constants.d.ts.map +1 -1
  47. package/dist/slack/decodeBlockAction.d.ts +1 -2
  48. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  49. package/dist/slack/fromSlackBlocks.d.ts +1 -2
  50. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  51. package/dist/slack/toSlackBlocks.d.ts +1 -2
  52. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  53. package/dist/slack/types.d.ts +32 -33
  54. package/dist/slack/types.d.ts.map +1 -1
  55. package/dist/teams/constants.d.ts +14 -15
  56. package/dist/teams/constants.d.ts.map +1 -1
  57. package/dist/teams/decodeSubmitData.d.ts +1 -2
  58. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  59. package/dist/teams/toAdaptiveCard.d.ts +6 -7
  60. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  61. package/dist/teams/toTeamsAttachments.d.ts +1 -2
  62. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  63. package/dist/teams/types.d.ts +29 -30
  64. package/dist/teams/types.d.ts.map +1 -1
  65. package/dist/types.d.ts +9 -10
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/vocabulary/alert.d.ts +2 -3
  68. package/dist/vocabulary/alert.d.ts.map +1 -1
  69. package/dist/vocabulary/alert.js +21 -16
  70. package/dist/vocabulary/alert.js.map +1 -1
  71. package/dist/vocabulary/collectFormValues.d.ts +6 -5
  72. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  73. package/dist/vocabulary/collectFormValues.js +3 -2
  74. package/dist/vocabulary/collectFormValues.js.map +1 -1
  75. package/dist/vocabulary/data.d.ts +1 -2
  76. package/dist/vocabulary/data.d.ts.map +1 -1
  77. package/dist/vocabulary/data.js +20 -12
  78. package/dist/vocabulary/data.js.map +1 -1
  79. package/dist/vocabulary/dispatch.d.ts +2 -3
  80. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  81. package/dist/vocabulary/fact.d.ts +1 -2
  82. package/dist/vocabulary/fact.d.ts.map +1 -1
  83. package/dist/vocabulary/fact.js +3 -2
  84. package/dist/vocabulary/fact.js.map +1 -1
  85. package/dist/vocabulary/form.d.ts +1 -2
  86. package/dist/vocabulary/form.d.ts.map +1 -1
  87. package/dist/vocabulary/icon.d.ts +1 -2
  88. package/dist/vocabulary/icon.d.ts.map +1 -1
  89. package/dist/vocabulary/index.d.ts +1 -2
  90. package/dist/vocabulary/index.d.ts.map +1 -1
  91. package/dist/vocabulary/interactive.d.ts +10 -9
  92. package/dist/vocabulary/interactive.d.ts.map +1 -1
  93. package/dist/vocabulary/interactive.js +30 -23
  94. package/dist/vocabulary/interactive.js.map +1 -1
  95. package/dist/vocabulary/layout.d.ts +1 -2
  96. package/dist/vocabulary/layout.d.ts.map +1 -1
  97. package/dist/vocabulary/layout.js +7 -5
  98. package/dist/vocabulary/layout.js.map +1 -1
  99. package/dist/vocabulary/list.d.ts +1 -2
  100. package/dist/vocabulary/list.d.ts.map +1 -1
  101. package/dist/vocabulary/media.d.ts +1 -2
  102. package/dist/vocabulary/media.d.ts.map +1 -1
  103. package/dist/vocabulary/text.d.ts +2 -3
  104. package/dist/vocabulary/text.d.ts.map +1 -1
  105. package/dist/vocabulary/text.js +4 -3
  106. package/dist/vocabulary/text.js.map +1 -1
  107. package/dist/vocabulary/toTextContent.d.ts +4 -0
  108. package/dist/vocabulary/toTextContent.d.ts.map +1 -0
  109. package/dist/vocabulary/toTextContent.js +6 -0
  110. package/dist/vocabulary/toTextContent.js.map +1 -0
  111. package/package.json +12 -10
  112. package/src/a2ui/convert.test.ts +36 -0
  113. package/src/a2ui/convert.ts +21 -2
  114. package/src/a2ui/reducer.test.ts +223 -0
  115. package/src/a2ui/reducer.ts +76 -12
  116. package/src/buildPresentParameters.ts +7 -4
  117. package/src/constants.ts +2 -0
  118. package/src/renderGenerativeUI.test.tsx +57 -0
  119. package/src/renderGenerativeUI.tsx +3 -1
  120. package/src/slack/fromSlackBlocks.test.ts +4 -1
  121. package/src/slack/toSlackBlocks.test.ts +8 -4
  122. package/src/teams/toAdaptiveCard.test.ts +5 -3
  123. package/src/vocabulary/alert.test.tsx +10 -0
  124. package/src/vocabulary/alert.tsx +16 -7
  125. package/src/vocabulary/collectFormValues.test.ts +38 -0
  126. package/src/vocabulary/collectFormValues.ts +11 -2
  127. package/src/vocabulary/data.test.tsx +40 -0
  128. package/src/vocabulary/data.tsx +47 -26
  129. package/src/vocabulary/fact.test.tsx +12 -0
  130. package/src/vocabulary/fact.tsx +3 -2
  131. package/src/vocabulary/form.test.tsx +1 -0
  132. package/src/vocabulary/interactive.test.tsx +55 -1
  133. package/src/vocabulary/interactive.tsx +50 -29
  134. package/src/vocabulary/layout.test.tsx +17 -0
  135. package/src/vocabulary/layout.tsx +8 -4
  136. package/src/vocabulary/list.test.tsx +5 -4
  137. package/src/vocabulary/text.test.tsx +29 -0
  138. package/src/vocabulary/text.tsx +4 -3
  139. package/src/vocabulary/toTextContent.ts +2 -0
  140. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +0 -141
  141. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +0 -1
@@ -82,6 +82,229 @@ describe("applyA2uiOperations", () => {
82
82
  });
83
83
  });
84
84
 
85
+ it.each(["10001", "4294967294"])(
86
+ "rejects array pointer index %s beyond the auto-vivification limit",
87
+ (index) => {
88
+ const dataModel = { items: [] };
89
+ const state: A2uiState = new Map([
90
+ ["main", { components: new Map(), dataModel }],
91
+ ]);
92
+
93
+ const result = applyA2uiOperations(state, [
94
+ {
95
+ version: "v1.0",
96
+ updateDataModel: {
97
+ surfaceId: "main",
98
+ path: `/items/${index}`,
99
+ value: "unsafe",
100
+ },
101
+ },
102
+ ]);
103
+
104
+ expect(result.warnings).toEqual([
105
+ "Operation at index 0 has an invalid JSON Pointer path.",
106
+ ]);
107
+ expect(result.state.get("main")?.dataModel).toBe(dataModel);
108
+ expect(dataModel.items).toHaveLength(0);
109
+ },
110
+ );
111
+
112
+ it("allows bounded expansion, existing large indices, and appends", () => {
113
+ const items = new Array<string | undefined>(10_002);
114
+ items[10_001] = "existing";
115
+ const state: A2uiState = new Map([
116
+ ["main", { components: new Map(), dataModel: { bounded: [], items } }],
117
+ ]);
118
+
119
+ const result = applyA2uiOperations(state, [
120
+ {
121
+ version: "v1.0",
122
+ updateDataModel: {
123
+ surfaceId: "main",
124
+ path: "/bounded/10000",
125
+ value: "limit",
126
+ },
127
+ },
128
+ {
129
+ version: "v1.0",
130
+ updateDataModel: {
131
+ surfaceId: "main",
132
+ path: "/items/10001",
133
+ value: "updated",
134
+ },
135
+ },
136
+ {
137
+ version: "v1.0",
138
+ updateDataModel: {
139
+ surfaceId: "main",
140
+ path: "/items/-",
141
+ value: "appended",
142
+ },
143
+ },
144
+ ]);
145
+
146
+ const resultModel = result.state.get("main")!.dataModel as {
147
+ bounded: string[];
148
+ items: string[];
149
+ };
150
+ expect(result.warnings).toEqual([]);
151
+ expect(resultModel.bounded).toHaveLength(10_001);
152
+ expect(resultModel.bounded[10_000]).toBe("limit");
153
+ expect(resultModel.items).toHaveLength(10_003);
154
+ expect(resultModel.items[10_001]).toBe("updated");
155
+ expect(resultModel.items[10_002]).toBe("appended");
156
+ });
157
+
158
+ it("stores prototype-named JSON Pointer segments as own properties", () => {
159
+ const state: A2uiState = new Map([
160
+ ["main", { components: new Map(), dataModel: {} }],
161
+ ]);
162
+
163
+ const result = applyA2uiOperations(state, [
164
+ {
165
+ version: "v1.0",
166
+ updateDataModel: {
167
+ surfaceId: "main",
168
+ path: "/__proto__/admin",
169
+ value: true,
170
+ },
171
+ },
172
+ ]);
173
+
174
+ const resultModel = result.state.get("main")?.dataModel as Record<
175
+ string,
176
+ unknown
177
+ >;
178
+ expect(result.warnings).toEqual([]);
179
+ expect(Object.getPrototypeOf(resultModel)).toBe(Object.prototype);
180
+ expect(Object.hasOwn(resultModel, "__proto__")).toBe(true);
181
+ expect(resultModel["__proto__"]).toEqual({ admin: true });
182
+ expect(JSON.stringify(resultModel)).toBe('{"__proto__":{"admin":true}}');
183
+ });
184
+
185
+ it("deletes object properties updated to null", () => {
186
+ const dataModel = {
187
+ profile: { name: "Ada", role: "admin" },
188
+ tags: ["first", "second"],
189
+ };
190
+ const state: A2uiState = new Map([
191
+ ["main", { components: new Map(), dataModel }],
192
+ ]);
193
+
194
+ const result = applyA2uiOperations(state, [
195
+ {
196
+ version: "v1.0",
197
+ updateDataModel: {
198
+ surfaceId: "main",
199
+ path: "/profile/name",
200
+ value: null,
201
+ },
202
+ },
203
+ {
204
+ version: "v1.0",
205
+ updateDataModel: {
206
+ surfaceId: "main",
207
+ path: "/tags/1",
208
+ value: null,
209
+ },
210
+ },
211
+ ]);
212
+
213
+ expect(result.warnings).toEqual([]);
214
+ const resultModel = result.state.get("main")?.dataModel as typeof dataModel;
215
+ expect(resultModel.profile).toEqual({ role: "admin" });
216
+ expect(resultModel.tags).toHaveLength(2);
217
+ expect(resultModel.tags[0]).toBe("first");
218
+ expect(Object.prototype.hasOwnProperty.call(resultModel.tags, 1)).toBe(
219
+ false,
220
+ );
221
+ expect(dataModel).toEqual({
222
+ profile: { name: "Ada", role: "admin" },
223
+ tags: ["first", "second"],
224
+ });
225
+ });
226
+
227
+ it("preserves null values in v0.9 data model updates", () => {
228
+ const state: A2uiState = new Map([
229
+ [
230
+ "main",
231
+ { components: new Map(), dataModel: { profile: { name: "Ada" } } },
232
+ ],
233
+ ]);
234
+
235
+ const result = applyA2uiOperations(state, [
236
+ {
237
+ version: "v0.9",
238
+ updateDataModel: {
239
+ surfaceId: "main",
240
+ path: "/profile/name",
241
+ value: null,
242
+ },
243
+ },
244
+ ]);
245
+
246
+ expect(result.warnings).toEqual([]);
247
+ expect(result.state.get("main")?.dataModel).toEqual({
248
+ profile: { name: null },
249
+ });
250
+ });
251
+
252
+ it("does not create missing paths for null updates", () => {
253
+ const dataModel = { profile: { name: "Ada" }, tags: ["first", "second"] };
254
+ const state: A2uiState = new Map([
255
+ ["main", { components: new Map(), dataModel }],
256
+ ]);
257
+
258
+ const result = applyA2uiOperations(state, [
259
+ {
260
+ version: "v1.0",
261
+ updateDataModel: {
262
+ surfaceId: "main",
263
+ path: "/missing/value",
264
+ value: null,
265
+ },
266
+ },
267
+ {
268
+ version: "v1.0",
269
+ updateDataModel: {
270
+ surfaceId: "main",
271
+ path: "/tags/-",
272
+ value: null,
273
+ },
274
+ },
275
+ {
276
+ version: "v1.0",
277
+ updateDataModel: {
278
+ surfaceId: "main",
279
+ path: "/tags/7",
280
+ value: null,
281
+ },
282
+ },
283
+ ]);
284
+
285
+ expect(result.warnings).toEqual([]);
286
+ expect(result.state.get("main")?.dataModel).toBe(dataModel);
287
+ });
288
+
289
+ it("allows null to replace the data model root", () => {
290
+ const state: A2uiState = new Map([
291
+ [
292
+ "main",
293
+ { components: new Map(), dataModel: { profile: { name: "Ada" } } },
294
+ ],
295
+ ]);
296
+
297
+ const result = applyA2uiOperations(state, [
298
+ {
299
+ version: "v1.0",
300
+ updateDataModel: { surfaceId: "main", value: null },
301
+ },
302
+ ]);
303
+
304
+ expect(result.warnings).toEqual([]);
305
+ expect(result.state.get("main")?.dataModel).toBeNull();
306
+ });
307
+
85
308
  it("clones the touched surface and component map", () => {
86
309
  const components = new Map<string, Record<string, unknown>>([
87
310
  ["root", { id: "root", component: "Divider" }],
@@ -11,6 +11,9 @@ const OPERATION_KEYS = new Set([
11
11
  "updateDataModel",
12
12
  "deleteSurface",
13
13
  ]);
14
+ // This defensive ceiling is well above the renderer's displayed-item limit.
15
+ const MAX_AUTO_VIVIFY_ARRAY_INDEX = 10_000;
16
+ const INVALID_POINTER = Symbol("invalidPointer");
14
17
 
15
18
  const isRecord = (value: unknown): value is Record<string, unknown> =>
16
19
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -104,30 +107,82 @@ const setAtPointer = (
104
107
  model: unknown,
105
108
  path: string,
106
109
  value: unknown,
110
+ nullDeletes: boolean,
107
111
  ): { readonly ok: boolean; readonly value: unknown } => {
108
112
  const segments = decodePointer(path);
109
113
  if (!segments) return { ok: false, value: model };
110
114
  if (segments.length === 0) return { ok: true, value };
111
115
 
116
+ const remove = (current: unknown, index: number): unknown => {
117
+ const segment = segments[index]!;
118
+ const isLast = index === segments.length - 1;
119
+
120
+ if (Array.isArray(current)) {
121
+ if (segment !== "-" && !isArrayIndex(segment)) return current;
122
+ const targetIndex = segment === "-" ? current.length : Number(segment);
123
+ if (isLast) {
124
+ if (
125
+ segment === "-" ||
126
+ !Object.prototype.hasOwnProperty.call(current, targetIndex)
127
+ ) {
128
+ return current;
129
+ }
130
+ const clone = current.slice();
131
+ // Preserve indices referenced by other JSON Pointers.
132
+ delete clone[targetIndex];
133
+ return clone;
134
+ }
135
+ const child = current[targetIndex];
136
+ if (!Array.isArray(child) && !isRecord(child)) return current;
137
+ const next = remove(child, index + 1);
138
+ if (next === child) return current;
139
+ const clone = current.slice();
140
+ clone[targetIndex] = next;
141
+ return clone;
142
+ }
143
+
144
+ if (!isRecord(current)) return current;
145
+ if (!Object.prototype.hasOwnProperty.call(current, segment)) return current;
146
+ if (isLast) {
147
+ const clone = { ...current };
148
+ delete clone[segment];
149
+ return clone;
150
+ }
151
+ const child = current[segment];
152
+ if (!Array.isArray(child) && !isRecord(child)) return current;
153
+ const next = remove(child, index + 1);
154
+ if (next === child) return current;
155
+ return { ...current, [segment]: next };
156
+ };
157
+
158
+ if (nullDeletes && value === null) {
159
+ return { ok: true, value: remove(model, 0) };
160
+ }
161
+
112
162
  const update = (current: unknown, index: number): unknown => {
113
163
  const segment = segments[index]!;
114
164
  const isLast = index === segments.length - 1;
115
165
 
116
166
  if (Array.isArray(current)) {
117
167
  if (segment !== "-" && !isArrayIndex(segment)) return current;
168
+ const targetIndex = segment === "-" ? current.length : Number(segment);
169
+ if (
170
+ segment !== "-" &&
171
+ targetIndex >= current.length &&
172
+ targetIndex > MAX_AUTO_VIVIFY_ARRAY_INDEX
173
+ ) {
174
+ return INVALID_POINTER;
175
+ }
176
+ const next = isLast ? value : update(current[targetIndex], index + 1);
177
+ if (next === INVALID_POINTER) return INVALID_POINTER;
118
178
  const clone = current.slice();
119
- const targetIndex = segment === "-" ? clone.length : Number(segment);
120
- clone[targetIndex] = isLast
121
- ? value
122
- : update(clone[targetIndex], index + 1);
179
+ clone[targetIndex] = next;
123
180
  return clone;
124
181
  }
125
182
 
126
- const clone: Record<string, unknown> = isRecord(current)
127
- ? { ...current }
128
- : {};
129
- const child = clone[segment];
130
- clone[segment] = isLast
183
+ const record: Record<string, unknown> = isRecord(current) ? current : {};
184
+ const child = Object.hasOwn(record, segment) ? record[segment] : undefined;
185
+ const next = isLast
131
186
  ? value
132
187
  : update(
133
188
  child ??
@@ -137,10 +192,14 @@ const setAtPointer = (
137
192
  : {}),
138
193
  index + 1,
139
194
  );
140
- return clone;
195
+ if (next === INVALID_POINTER) return INVALID_POINTER;
196
+ return { ...record, [segment]: next };
141
197
  };
142
198
 
143
- return { ok: true, value: update(model, 0) };
199
+ const result = update(model, 0);
200
+ return result === INVALID_POINTER
201
+ ? { ok: false, value: model }
202
+ : { ok: true, value: result };
144
203
  };
145
204
 
146
205
  const dataModelValue = (
@@ -264,7 +323,12 @@ export function applyA2uiOperations(
264
323
  );
265
324
  continue;
266
325
  }
267
- const result = setAtPointer(surface.dataModel, path, update.value);
326
+ const result = setAtPointer(
327
+ surface.dataModel,
328
+ path,
329
+ update.value,
330
+ version === "v1.0",
331
+ );
268
332
  if (!result.ok) {
269
333
  warnings.push(
270
334
  `Operation at index ${index} has an invalid JSON Pointer path.`,
@@ -29,7 +29,7 @@ export function buildPresentParameters(
29
29
  // `children` are framework-reserved (see ir.ts), so drop any author-declared
30
30
  // copies. On a name clash the first component's schema wins — props are an
31
31
  // advisory hint here, not a strict per-component contract.
32
- const props: Record<string, JSONSchema7Definition> = {};
32
+ const props = new Map<string, JSONSchema7Definition>();
33
33
  const propOwners = new Map<string, string[]>();
34
34
  for (const name of names) {
35
35
  const propsSchema = toJSONSchema(library[name]!.properties);
@@ -41,8 +41,11 @@ export function buildPresentParameters(
41
41
  }
42
42
  for (const [key, schema] of Object.entries(propsSchema.properties ?? {})) {
43
43
  if (key.startsWith("$") || key === "children") continue;
44
- if (!(key in props)) {
45
- props[key] = schema;
44
+ // secure-json-parse rejects the whole tool-argument payload on this key,
45
+ // so advertising it would cost the model the node rather than one prop.
46
+ if (key === "__proto__") continue;
47
+ if (!props.has(key)) {
48
+ props.set(key, schema);
46
49
  }
47
50
  propOwners.set(key, [...(propOwners.get(key) ?? []), name]);
48
51
  }
@@ -78,7 +81,7 @@ export function buildPresentParameters(
78
81
  "Stable identity for this UI node. Use it for list items that may reorder.",
79
82
  anyOf: [{ type: "string" }, { type: "number" }],
80
83
  },
81
- ...props,
84
+ ...Object.fromEntries(props),
82
85
  children: { $ref: "#/$defs/children" },
83
86
  },
84
87
  required: [TYPE_KEY],
package/src/constants.ts CHANGED
@@ -8,3 +8,5 @@
8
8
  * convention used by JSON polymorphism elsewhere.
9
9
  */
10
10
  export const TYPE_KEY = "$type";
11
+
12
+ export const GENERATED_NAME_ATTR = "data-aui-generated-name";
@@ -114,6 +114,32 @@ describe("renderGenerativeUI", () => {
114
114
  expect(html).toBe("");
115
115
  });
116
116
 
117
+ it.each(["toString", "constructor"])(
118
+ "treats inherited %s as an unknown component",
119
+ (type) => {
120
+ const html = renderToStaticMarkup(
121
+ <>{renderGenerativeUI({ $type: type }, library)}</>,
122
+ );
123
+ expect(html).toBe("");
124
+ },
125
+ );
126
+
127
+ it("renders an explicitly registered prototype-named component", () => {
128
+ const prototypeNamedLibrary: GenerativeUILibrary = {
129
+ ...library,
130
+ toString: {
131
+ description: "An explicitly registered component.",
132
+ properties: z.object({}),
133
+ render: () => <span>registered</span>,
134
+ },
135
+ };
136
+
137
+ const html = renderToStaticMarkup(
138
+ <>{renderGenerativeUI({ $type: "toString" }, prototypeNamedLibrary)}</>,
139
+ );
140
+ expect(html).toBe("<span>registered</span>");
141
+ });
142
+
117
143
  it("holds back a node whose `$type` is still streaming", () => {
118
144
  const error = vi.spyOn(console, "error").mockImplementation(() => {});
119
145
  try {
@@ -263,6 +289,37 @@ describe("buildPresentParameters", () => {
263
289
  expect(schema.required).toEqual(["$type"]);
264
290
  });
265
291
 
292
+ it("keeps props whose names are inherited from Object.prototype", () => {
293
+ const schema = buildPresentParameters({
294
+ PrototypeProps: {
295
+ description: "Declares props that shadow Object.prototype members.",
296
+ properties: z.object({
297
+ toString: z.string(),
298
+ valueOf: z.number(),
299
+ constructor: z.boolean(),
300
+ }),
301
+ render: () => null,
302
+ },
303
+ }) as any;
304
+
305
+ expect(schema.properties.toString.type).toBe("string");
306
+ expect(schema.properties.valueOf.type).toBe("number");
307
+ expect(schema.properties.constructor.type).toBe("boolean");
308
+ });
309
+
310
+ it("omits __proto__, which the tool-argument decoder rejects", () => {
311
+ const schema = buildPresentParameters({
312
+ PrototypeProps: {
313
+ description: "Declares an undeliverable prop name.",
314
+ properties: z.object({ ["__proto__"]: z.number(), label: z.string() }),
315
+ render: () => null,
316
+ },
317
+ }) as any;
318
+
319
+ expect(Object.hasOwn(schema.properties, "__proto__")).toBe(false);
320
+ expect(schema.properties.label.type).toBe("string");
321
+ });
322
+
266
323
  it("names every component that declares the same prop in the dev warning", () => {
267
324
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
268
325
  try {
@@ -61,7 +61,9 @@ function renderElement(
61
61
  library: GenerativeUILibrary,
62
62
  context: GenerativeUIRenderContext,
63
63
  ): ReactNode {
64
- const entry = library[element.type];
64
+ const entry = Object.hasOwn(library, element.type)
65
+ ? library[element.type]
66
+ : undefined;
65
67
  if (!entry) {
66
68
  reportUnknownComponent(element.type, Object.keys(library));
67
69
  return null;
@@ -1107,7 +1107,10 @@ describe("fromSlackBlocks checkbox and radio caps", () => {
1107
1107
  ],
1108
1108
  },
1109
1109
  ]);
1110
- const radio = nodes[0] as { options: unknown[] };
1110
+ const radio = nodes[0];
1111
+ if (radio?.$type !== "RadioGroup" || !Array.isArray(radio.options)) {
1112
+ throw new Error("Expected a RadioGroup with options.");
1113
+ }
1111
1114
  expect(radio.options).toHaveLength(10);
1112
1115
  expect(
1113
1116
  warnings.some(
@@ -2346,9 +2346,10 @@ describe("toSlackBlocks data_table integrity", () => {
2346
2346
  columns: [{ label: "A" }, { label: "B" }],
2347
2347
  rows: [[{ nested: true }, "kept"]],
2348
2348
  });
2349
- const table = blocks[0] as {
2350
- rows: { type: string; text: string }[][];
2351
- };
2349
+ const table = blocks[0];
2350
+ if (table?.type !== "data_table") {
2351
+ throw new Error("Expected a data table block.");
2352
+ }
2352
2353
  expect(table.rows[1]).toEqual([
2353
2354
  { type: "raw_text", text: "" },
2354
2355
  { type: "raw_text", text: "kept" },
@@ -2361,7 +2362,10 @@ describe("toSlackBlocks data_table integrity", () => {
2361
2362
  columns: [{ label: "A" }, { label: "B" }, { label: "C" }],
2362
2363
  rows: [["x"], ["x", "y", "z"]],
2363
2364
  });
2364
- const table = blocks[0] as { rows: unknown[][] };
2365
+ const table = blocks[0];
2366
+ if (table?.type !== "data_table") {
2367
+ throw new Error("Expected a data table block.");
2368
+ }
2365
2369
  expect(new Set(table.rows.map((row) => row.length))).toEqual(new Set([3]));
2366
2370
  });
2367
2371
 
@@ -1038,9 +1038,11 @@ describe("toAdaptiveCard", () => {
1038
1038
  name: "s",
1039
1039
  options: [{ label: "ok", value: "a" }, { label: "bad" }, "nope"],
1040
1040
  });
1041
- expect((card.body[0] as { choices: unknown[] }).choices).toEqual([
1042
- { title: "ok", value: "a" },
1043
- ]);
1041
+ const select = card.body[0];
1042
+ if (select?.type !== "Input.ChoiceSet") {
1043
+ throw new Error("Expected an input choice set.");
1044
+ }
1045
+ expect(select.choices).toEqual([{ title: "ok", value: "a" }]);
1044
1046
  expect(warnings).toContainEqual({
1045
1047
  code: "dropped",
1046
1048
  component: "Select",
@@ -26,6 +26,16 @@ describe("alertVocabulary", () => {
26
26
  ).toContain('data-aui-tone="danger"');
27
27
  });
28
28
 
29
+ it("Alert ignores malformed text properties", () => {
30
+ expect(
31
+ render({
32
+ $type: "Alert",
33
+ title: { unexpected: true },
34
+ description: { unexpected: true },
35
+ }),
36
+ ).toBe('<div data-aui="alert" data-aui-tone="info" role="alert"></div>');
37
+ });
38
+
29
39
  it("Carousel wraps each child Card in a slide, capped at 10", () => {
30
40
  const children = Array.from({ length: 12 }, (_, i) => ({
31
41
  $type: "Card",
@@ -2,6 +2,7 @@ import { Children } from "react";
2
2
  import { z } from "zod";
3
3
  import type { GenerativeUILibrary } from "../types";
4
4
  import { ALERT_TONES } from "../ir";
5
+ import { toTextContent } from "./toTextContent";
5
6
 
6
7
  const MAX_CARDS = 10;
7
8
 
@@ -20,13 +21,21 @@ export const alertVocabulary = {
20
21
  .optional()
21
22
  .describe("Severity tone; defaults to `info`."),
22
23
  }),
23
- render: ({ title, description, tone, children }) => (
24
- <div data-aui="alert" data-aui-tone={tone ?? "info"} role="alert">
25
- {title ? <header data-aui="alert-title">{title}</header> : null}
26
- {description ? <p data-aui="alert-desc">{description}</p> : null}
27
- {children}
28
- </div>
29
- ),
24
+ render: ({ title, description, tone, children }) => {
25
+ const alertTitle = toTextContent(title);
26
+ const alertDescription = toTextContent(description);
27
+ return (
28
+ <div data-aui="alert" data-aui-tone={tone ?? "info"} role="alert">
29
+ {alertTitle ? (
30
+ <header data-aui="alert-title">{alertTitle}</header>
31
+ ) : null}
32
+ {alertDescription ? (
33
+ <p data-aui="alert-desc">{alertDescription}</p>
34
+ ) : null}
35
+ {children}
36
+ </div>
37
+ );
38
+ },
30
39
  },
31
40
  Carousel: {
32
41
  description:
@@ -1,8 +1,11 @@
1
+ // @vitest-environment jsdom
2
+
1
3
  import { describe, it, expect } from "vitest";
2
4
  import {
3
5
  collectFormValues,
4
6
  type FormControlElementLike,
5
7
  } from "./collectFormValues";
8
+ import { GENERATED_NAME_ATTR } from "../constants";
6
9
 
7
10
  const el = (
8
11
  partial: Partial<FormControlElementLike>,
@@ -11,6 +14,7 @@ const el = (
11
14
  type: "text",
12
15
  value: "",
13
16
  disabled: false,
17
+ hasAttribute: () => false,
14
18
  ...partial,
15
19
  });
16
20
 
@@ -82,6 +86,20 @@ describe("collectFormValues", () => {
82
86
  ).toEqual({ kept: "yes" });
83
87
  });
84
88
 
89
+ it("skips radio groups whose name is generated only for native grouping", () => {
90
+ expect(
91
+ collectFormValues([
92
+ el({
93
+ name: "_R_1_",
94
+ type: "radio",
95
+ value: "sm",
96
+ checked: true,
97
+ hasAttribute: (name) => name === GENERATED_NAME_ATTR,
98
+ }),
99
+ ]),
100
+ ).toEqual({});
101
+ });
102
+
85
103
  it("skips disabled controls", () => {
86
104
  expect(
87
105
  collectFormValues([
@@ -106,6 +124,26 @@ describe("collectFormValues", () => {
106
124
  ).toEqual({ size: undefined });
107
125
  });
108
126
 
127
+ it("skips controls disabled by a fieldset while preserving its first legend", () => {
128
+ const form = document.createElement("form");
129
+ form.innerHTML = `
130
+ <fieldset disabled>
131
+ <legend><input name="legend" value="kept" /></legend>
132
+ <input name="blocked" value="old" />
133
+ </fieldset>
134
+ <input name="enabled" value="yes" />
135
+ `;
136
+ const blocked = form.elements.namedItem("blocked") as HTMLInputElement;
137
+
138
+ expect(blocked.disabled).toBe(false);
139
+
140
+ expect(
141
+ collectFormValues(
142
+ form.elements as unknown as ArrayLike<FormControlElementLike>,
143
+ ),
144
+ ).toEqual({ legend: "kept", enabled: "yes" });
145
+ });
146
+
109
147
  it("returns an empty object for no elements", () => {
110
148
  expect(collectFormValues([])).toEqual({});
111
149
  });