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

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.
@@ -35,7 +35,11 @@ declare function clampReasonDetail(reason: ClampReason): string;
35
35
  * (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
36
36
  * entries by index, which bounds a proxy however it fabricates `length` or
37
37
  * replaces its array methods; recursion itself is capped at
38
- * {@link MAX_TRAVERSAL_DEPTH}. `onClamp` fires once per level that was
38
+ * {@link MAX_TRAVERSAL_DEPTH}. Every object is returned as a plain copy whose
39
+ * `children` came from a single read, because a record that answers
40
+ * `[[HasProperty]]` and `[[Get]]`, or two `[[Get]]`s, differently would
41
+ * otherwise hand the bound one value and `normalizeSpec` another, skipping
42
+ * every bound here for that subtree. `onClamp` fires once per level that was
39
43
  * truncated, receiving the reason for that truncation: `"children"`,
40
44
  * `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
41
45
  * {@link NODE_BUDGET} nodes, so shared references cannot multiply work
@@ -1 +1 @@
1
- {"version":3,"file":"boundSpec.d.ts","names":[],"sources":["../../src/convert/boundSpec.ts"],"mappings":";;cAGa;;;;;;;cAQA;;;;;;;;cASA;;;;;;;;cASA;KAED;;iBAGI,kBAAkB,QAAQ;;;;;;;;;;;;;;;iBA0F1B,UACd,eACA,UAAU,QAAQ"}
1
+ {"version":3,"file":"boundSpec.d.ts","names":[],"sources":["../../src/convert/boundSpec.ts"],"mappings":";;cAGa;;;;;;;cAQA;;;;;;;;cASA;;;;;;;;cASA;KAED;;iBAGI,kBAAkB,QAAQ;;;;;;;;;;;;;;;;;;;iBAwF1B,UACd,eACA,UAAU,QAAQ"}
@@ -57,16 +57,21 @@ function boundNode(value, depth, onClamp, state, ancestors) {
57
57
  ancestors.delete(value);
58
58
  return result;
59
59
  }
60
- if (value !== null && typeof value === "object" && "children" in value) {
60
+ if (value !== null && typeof value === "object") {
61
+ const record = value;
62
+ const children = record["children"];
63
+ if (children === void 0) return {
64
+ ...record,
65
+ children: void 0
66
+ };
61
67
  if (ancestors.has(value)) {
62
68
  onClamp("cycle");
63
69
  return null;
64
70
  }
65
71
  ancestors.add(value);
66
- const record = value;
67
72
  const result = {
68
73
  ...record,
69
- children: boundNode(record["children"], depth + 1, onClamp, state, ancestors)
74
+ children: boundNode(children, depth + 1, onClamp, state, ancestors)
70
75
  };
71
76
  ancestors.delete(value);
72
77
  return result;
@@ -81,7 +86,11 @@ function boundNode(value, depth, onClamp, state, ancestors) {
81
86
  * (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
82
87
  * entries by index, which bounds a proxy however it fabricates `length` or
83
88
  * replaces its array methods; recursion itself is capped at
84
- * {@link MAX_TRAVERSAL_DEPTH}. `onClamp` fires once per level that was
89
+ * {@link MAX_TRAVERSAL_DEPTH}. Every object is returned as a plain copy whose
90
+ * `children` came from a single read, because a record that answers
91
+ * `[[HasProperty]]` and `[[Get]]`, or two `[[Get]]`s, differently would
92
+ * otherwise hand the bound one value and `normalizeSpec` another, skipping
93
+ * every bound here for that subtree. `onClamp` fires once per level that was
85
94
  * truncated, receiving the reason for that truncation: `"children"`,
86
95
  * `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
87
96
  * {@link NODE_BUDGET} nodes, so shared references cannot multiply work
@@ -1 +1 @@
1
- {"version":3,"file":"boundSpec.js","names":[],"sources":["../../src/convert/boundSpec.ts"],"sourcesContent":["import { copyBounded } from \"./copyBounded\";\n\n/** The maximum traversal depth, counted in visited values, not tree levels. */\nexport const MAX_TRAVERSAL_DEPTH = 64;\n\n/**\n * The element-nesting ceiling a caller observes. `boundNode` visits an element\n * and its `children` array separately, so one level of nesting spends two of\n * {@link MAX_TRAVERSAL_DEPTH}; it runs before the conversion walk and is never\n * the looser of the two. A root array costs one more level than a root object.\n */\nexport const MAX_ELEMENT_DEPTH = MAX_TRAVERSAL_DEPTH / 2;\n\n/**\n * The maximum number of entries kept at any single level (root, or a\n * `children` array at any depth) of the raw spec, before it ever reaches\n * `normalizeSpec`. Bounds a hostile array's reported `length` so the pre-pass\n * can never be made to walk further than this regardless of what the array\n * claims about itself.\n */\nexport const CHILDREN_CAP = 200;\n\n/**\n * The total number of nodes {@link boundSpec} will visit across one walk,\n * regardless of how many times a shared reference recurs. Bounds the\n * combinatorial work a DAG of shared or self-referential nodes would\n * otherwise force even though each individual array stays within\n * {@link CHILDREN_CAP}.\n */\nexport const NODE_BUDGET = 5000;\n\nexport type ClampReason = \"children\" | \"budget\" | \"cycle\" | \"depth\";\n\n/** Renders the warning detail a protocol reports for one {@link ClampReason}. */\nexport function clampReasonDetail(reason: ClampReason): string {\n if (reason === \"budget\") {\n return `the tree was truncated after ${NODE_BUDGET} nodes.`;\n }\n if (reason === \"cycle\") return \"a self-referencing node was dropped.\";\n if (reason === \"depth\") {\n return `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`;\n }\n return `children were clamped to ${CHILDREN_CAP} entries.`;\n}\n\ninterface BoundState {\n remaining: number;\n exhausted: boolean;\n}\n\nfunction boundNode(\n value: unknown,\n depth: number,\n onClamp: (reason: ClampReason) => void,\n state: BoundState,\n ancestors: WeakSet<object>,\n): unknown {\n if (state.remaining <= 0) {\n if (!state.exhausted) {\n state.exhausted = true;\n onClamp(\"budget\");\n }\n return null;\n }\n state.remaining -= 1;\n if (depth > MAX_TRAVERSAL_DEPTH) {\n onClamp(\"depth\");\n return null;\n }\n if (Array.isArray(value)) {\n if (ancestors.has(value)) {\n onClamp(\"cycle\");\n return null;\n }\n ancestors.add(value);\n const { items: bounded, truncated } = copyBounded(value, CHILDREN_CAP);\n if (truncated) onClamp(\"children\");\n const result = bounded.map((item) =>\n boundNode(item, depth + 1, onClamp, state, ancestors),\n );\n ancestors.delete(value);\n return result;\n }\n if (\n value !== null &&\n typeof value === \"object\" &&\n \"children\" in (value as Record<string, unknown>)\n ) {\n if (ancestors.has(value)) {\n onClamp(\"cycle\");\n return null;\n }\n ancestors.add(value);\n const record = value as Record<string, unknown>;\n const result = {\n ...record,\n children: boundNode(\n record[\"children\"],\n depth + 1,\n onClamp,\n state,\n ancestors,\n ),\n };\n ancestors.delete(value);\n return result;\n }\n return value;\n}\n\n/**\n * Produces a bounded plain copy of a raw generative-ui spec before it\n * reaches `normalizeSpec`, whose own traversal of the root array or any\n * `children` array walks the full reported length of a hostile proxied\n * array before any per-field cap downstream ever applies. Every array\n * (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}\n * entries by index, which bounds a proxy however it fabricates `length` or\n * replaces its array methods; recursion itself is capped at\n * {@link MAX_TRAVERSAL_DEPTH}. `onClamp` fires once per level that was\n * truncated, receiving the reason for that truncation: `\"children\"`,\n * `\"depth\"`, `\"budget\"`, or `\"cycle\"`. The walk also spends a total budget of\n * {@link NODE_BUDGET} nodes, so shared references cannot multiply work\n * exponentially, and a node that is its own ancestor is cut to `null`.\n */\nexport function boundSpec(\n spec: unknown,\n onClamp: (reason: ClampReason) => void,\n): unknown {\n return boundNode(\n spec,\n 0,\n onClamp,\n { remaining: NODE_BUDGET, exhausted: false },\n new WeakSet(),\n );\n}\n"],"mappings":";;;AAGA,MAAa,sBAAsB;;;;;;;AAQnC,MAAa,oBAAA;;;;;;;;AASb,MAAa,eAAe;;;;;;;;AAS5B,MAAa,cAAc;;AAK3B,SAAgB,kBAAkB,QAA6B;CAC7D,IAAI,WAAW,UACb,OAAO,gCAAgC,YAAY;CAErD,IAAI,WAAW,SAAS,OAAO;CAC/B,IAAI,WAAW,SACb,OAAO;CAET,OAAO;AACT;AAOA,SAAS,UACP,OACA,OACA,SACA,OACA,WACS;CACT,IAAI,MAAM,aAAa,GAAG;EACxB,IAAI,CAAC,MAAM,WAAW;GACpB,MAAM,YAAY;GAClB,QAAQ,QAAQ;EAClB;EACA,OAAO;CACT;CACA,MAAM,aAAa;CACnB,IAAI,QAAA,IAA6B;EAC/B,QAAQ,OAAO;EACf,OAAO;CACT;CACA,IAAI,MAAM,QAAQ,KAAK,GAAG;EACxB,IAAI,UAAU,IAAI,KAAK,GAAG;GACxB,QAAQ,OAAO;GACf,OAAO;EACT;EACA,UAAU,IAAI,KAAK;EACnB,MAAM,EAAE,OAAO,SAAS,cAAc,YAAY,OAAA,GAAmB;EACrE,IAAI,WAAW,QAAQ,UAAU;EACjC,MAAM,SAAS,QAAQ,KAAK,SAC1B,UAAU,MAAM,QAAQ,GAAG,SAAS,OAAO,SAAS,CACtD;EACA,UAAU,OAAO,KAAK;EACtB,OAAO;CACT;CACA,IACE,UAAU,QACV,OAAO,UAAU,YACjB,cAAe,OACf;EACA,IAAI,UAAU,IAAI,KAAK,GAAG;GACxB,QAAQ,OAAO;GACf,OAAO;EACT;EACA,UAAU,IAAI,KAAK;EACnB,MAAM,SAAS;EACf,MAAM,SAAS;GACb,GAAG;GACH,UAAU,UACR,OAAO,aACP,QAAQ,GACR,SACA,OACA,SACF;EACF;EACA,UAAU,OAAO,KAAK;EACtB,OAAO;CACT;CACA,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAgB,UACd,MACA,SACS;CACT,OAAO,UACL,MACA,GACA,SACA;EAAE,WAAW;EAAa,WAAW;CAAM,mBAC3C,IAAI,QAAQ,CACd;AACF"}
1
+ {"version":3,"file":"boundSpec.js","names":[],"sources":["../../src/convert/boundSpec.ts"],"sourcesContent":["import { copyBounded } from \"./copyBounded\";\n\n/** The maximum traversal depth, counted in visited values, not tree levels. */\nexport const MAX_TRAVERSAL_DEPTH = 64;\n\n/**\n * The element-nesting ceiling a caller observes. `boundNode` visits an element\n * and its `children` array separately, so one level of nesting spends two of\n * {@link MAX_TRAVERSAL_DEPTH}; it runs before the conversion walk and is never\n * the looser of the two. A root array costs one more level than a root object.\n */\nexport const MAX_ELEMENT_DEPTH = MAX_TRAVERSAL_DEPTH / 2;\n\n/**\n * The maximum number of entries kept at any single level (root, or a\n * `children` array at any depth) of the raw spec, before it ever reaches\n * `normalizeSpec`. Bounds a hostile array's reported `length` so the pre-pass\n * can never be made to walk further than this regardless of what the array\n * claims about itself.\n */\nexport const CHILDREN_CAP = 200;\n\n/**\n * The total number of nodes {@link boundSpec} will visit across one walk,\n * regardless of how many times a shared reference recurs. Bounds the\n * combinatorial work a DAG of shared or self-referential nodes would\n * otherwise force even though each individual array stays within\n * {@link CHILDREN_CAP}.\n */\nexport const NODE_BUDGET = 5000;\n\nexport type ClampReason = \"children\" | \"budget\" | \"cycle\" | \"depth\";\n\n/** Renders the warning detail a protocol reports for one {@link ClampReason}. */\nexport function clampReasonDetail(reason: ClampReason): string {\n if (reason === \"budget\") {\n return `the tree was truncated after ${NODE_BUDGET} nodes.`;\n }\n if (reason === \"cycle\") return \"a self-referencing node was dropped.\";\n if (reason === \"depth\") {\n return `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`;\n }\n return `children were clamped to ${CHILDREN_CAP} entries.`;\n}\n\ninterface BoundState {\n remaining: number;\n exhausted: boolean;\n}\n\nfunction boundNode(\n value: unknown,\n depth: number,\n onClamp: (reason: ClampReason) => void,\n state: BoundState,\n ancestors: WeakSet<object>,\n): unknown {\n if (state.remaining <= 0) {\n if (!state.exhausted) {\n state.exhausted = true;\n onClamp(\"budget\");\n }\n return null;\n }\n state.remaining -= 1;\n if (depth > MAX_TRAVERSAL_DEPTH) {\n onClamp(\"depth\");\n return null;\n }\n if (Array.isArray(value)) {\n if (ancestors.has(value)) {\n onClamp(\"cycle\");\n return null;\n }\n ancestors.add(value);\n const { items: bounded, truncated } = copyBounded(value, CHILDREN_CAP);\n if (truncated) onClamp(\"children\");\n const result = bounded.map((item) =>\n boundNode(item, depth + 1, onClamp, state, ancestors),\n );\n ancestors.delete(value);\n return result;\n }\n if (value !== null && typeof value === \"object\") {\n const record = value as Record<string, unknown>;\n const children = record[\"children\"];\n // The spread performs a `children` read of its own, so the copy states the\n // bounded value even when there is none.\n if (children === undefined) return { ...record, children: undefined };\n if (ancestors.has(value)) {\n onClamp(\"cycle\");\n return null;\n }\n ancestors.add(value);\n const result = {\n ...record,\n children: boundNode(children, depth + 1, onClamp, state, ancestors),\n };\n ancestors.delete(value);\n return result;\n }\n return value;\n}\n\n/**\n * Produces a bounded plain copy of a raw generative-ui spec before it\n * reaches `normalizeSpec`, whose own traversal of the root array or any\n * `children` array walks the full reported length of a hostile proxied\n * array before any per-field cap downstream ever applies. Every array\n * (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}\n * entries by index, which bounds a proxy however it fabricates `length` or\n * replaces its array methods; recursion itself is capped at\n * {@link MAX_TRAVERSAL_DEPTH}. Every object is returned as a plain copy whose\n * `children` came from a single read, because a record that answers\n * `[[HasProperty]]` and `[[Get]]`, or two `[[Get]]`s, differently would\n * otherwise hand the bound one value and `normalizeSpec` another, skipping\n * every bound here for that subtree. `onClamp` fires once per level that was\n * truncated, receiving the reason for that truncation: `\"children\"`,\n * `\"depth\"`, `\"budget\"`, or `\"cycle\"`. The walk also spends a total budget of\n * {@link NODE_BUDGET} nodes, so shared references cannot multiply work\n * exponentially, and a node that is its own ancestor is cut to `null`.\n */\nexport function boundSpec(\n spec: unknown,\n onClamp: (reason: ClampReason) => void,\n): unknown {\n return boundNode(\n spec,\n 0,\n onClamp,\n { remaining: NODE_BUDGET, exhausted: false },\n new WeakSet(),\n );\n}\n"],"mappings":";;;AAGA,MAAa,sBAAsB;;;;;;;AAQnC,MAAa,oBAAA;;;;;;;;AASb,MAAa,eAAe;;;;;;;;AAS5B,MAAa,cAAc;;AAK3B,SAAgB,kBAAkB,QAA6B;CAC7D,IAAI,WAAW,UACb,OAAO,gCAAgC,YAAY;CAErD,IAAI,WAAW,SAAS,OAAO;CAC/B,IAAI,WAAW,SACb,OAAO;CAET,OAAO;AACT;AAOA,SAAS,UACP,OACA,OACA,SACA,OACA,WACS;CACT,IAAI,MAAM,aAAa,GAAG;EACxB,IAAI,CAAC,MAAM,WAAW;GACpB,MAAM,YAAY;GAClB,QAAQ,QAAQ;EAClB;EACA,OAAO;CACT;CACA,MAAM,aAAa;CACnB,IAAI,QAAA,IAA6B;EAC/B,QAAQ,OAAO;EACf,OAAO;CACT;CACA,IAAI,MAAM,QAAQ,KAAK,GAAG;EACxB,IAAI,UAAU,IAAI,KAAK,GAAG;GACxB,QAAQ,OAAO;GACf,OAAO;EACT;EACA,UAAU,IAAI,KAAK;EACnB,MAAM,EAAE,OAAO,SAAS,cAAc,YAAY,OAAA,GAAmB;EACrE,IAAI,WAAW,QAAQ,UAAU;EACjC,MAAM,SAAS,QAAQ,KAAK,SAC1B,UAAU,MAAM,QAAQ,GAAG,SAAS,OAAO,SAAS,CACtD;EACA,UAAU,OAAO,KAAK;EACtB,OAAO;CACT;CACA,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU;EAC/C,MAAM,SAAS;EACf,MAAM,WAAW,OAAO;EAGxB,IAAI,aAAa,KAAA,GAAW,OAAO;GAAE,GAAG;GAAQ,UAAU,KAAA;EAAU;EACpE,IAAI,UAAU,IAAI,KAAK,GAAG;GACxB,QAAQ,OAAO;GACf,OAAO;EACT;EACA,UAAU,IAAI,KAAK;EACnB,MAAM,SAAS;GACb,GAAG;GACH,UAAU,UAAU,UAAU,QAAQ,GAAG,SAAS,OAAO,SAAS;EACpE;EACA,UAAU,OAAO,KAAK;EACtB,OAAO;CACT;CACA,OAAO;AACT;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,UACd,MACA,SACS;CACT,OAAO,UACL,MACA,GACA,SACA;EAAE,WAAW;EAAa,WAAW;CAAM,mBAC3C,IAAI,QAAQ,CACd;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"toTeamsAttachments.d.ts","names":[],"sources":["../../src/teams/toTeamsAttachments.ts"],"mappings":";;;;;;;;;iBAiFgB,mBACd,eACA,WAAW,wBACV"}
1
+ {"version":3,"file":"toTeamsAttachments.d.ts","names":[],"sources":["../../src/teams/toTeamsAttachments.ts"],"mappings":";;;;;;;;;iBA0EgB,mBACd,eACA,WAAW,wBACV"}
@@ -1,4 +1,5 @@
1
1
  import { normalizeSpec } from "../ir.js";
2
+ import { copyBounded } from "../convert/copyBounded.js";
2
3
  import { boundSpec, clampReasonDetail } from "../convert/boundSpec.js";
3
4
  import { isElement } from "../convert/isElement.js";
4
5
  import { PAYLOAD_SOFT_CAP, buildAttachment, utf8ByteLength } from "./constants.js";
@@ -9,10 +10,6 @@ const normalizedList = (node) => {
9
10
  if (!Array.isArray(node)) return [node];
10
11
  return node.flatMap((child) => normalizedList(child));
11
12
  };
12
- const clampArray = (value, cap) => ({
13
- items: value.slice(0, cap),
14
- truncated: value.length > cap
15
- });
16
13
  /**
17
14
  * Recognizes a root that is a single `Carousel` element, returning its `Card`
18
15
  * children. A non-card child is routed through {@link discardedChild}, which
@@ -62,7 +59,7 @@ function toTeamsAttachments(node, _options) {
62
59
  const { root } = normalizeSpec(bounded);
63
60
  const carouselCards = rootCarouselCards(root, context);
64
61
  if (carouselCards !== void 0) {
65
- const { items, truncated } = clampArray(carouselCards, 10);
62
+ const { items, truncated } = copyBounded(carouselCards, 10);
66
63
  if (truncated) warnings.push({
67
64
  code: "clamped",
68
65
  component: "Carousel",
@@ -1 +1 @@
1
- {"version":3,"file":"toTeamsAttachments.js","names":[],"sources":["../../src/teams/toTeamsAttachments.ts"],"sourcesContent":["import { boundSpec, clampReasonDetail } from \"../convert/boundSpec\";\nimport { isElement } from \"../convert/isElement\";\nimport {\n normalizeSpec,\n type NormalizedUIElement,\n type NormalizedUINode,\n} from \"../ir\";\nimport {\n CAROUSEL_ATTACHMENT_CAP,\n PAYLOAD_SOFT_CAP,\n buildAttachment,\n utf8ByteLength,\n} from \"./constants\";\nimport {\n convertRootToCard,\n discardedChild,\n type ConversionContext,\n} from \"./toAdaptiveCard\";\nimport type {\n TeamsAttachmentsResult,\n TeamsCardAttachment,\n TeamsConversionWarning,\n ToAdaptiveCardOptions,\n} from \"./types\";\n\nconst normalizedList = (\n node: NormalizedUINode | undefined,\n): NormalizedUINode[] => {\n if (node === undefined || node === null) return [];\n if (!Array.isArray(node)) return [node];\n return node.flatMap((child) => normalizedList(child));\n};\n\nconst clampArray = <T>(\n value: readonly T[],\n cap: number,\n): { readonly items: T[]; readonly truncated: boolean } => ({\n items: value.slice(0, cap),\n truncated: value.length > cap,\n});\n\n/**\n * Recognizes a root that is a single `Carousel` element, returning its `Card`\n * children. A non-card child is routed through {@link discardedChild}, which\n * reports it and forwards whatever it lost, rather than being filtered out\n * silently. `undefined` means the root is not exclusively a\n * root-level carousel, so the caller falls back to converting the whole tree\n * as one card.\n */\nfunction rootCarouselCards(\n root: NormalizedUINode | readonly NormalizedUINode[],\n context: ConversionContext,\n): NormalizedUIElement[] | undefined {\n const element = Array.isArray(root) && root.length === 1 ? root[0] : root;\n if (!isElement(element) || element.type !== \"Carousel\") return undefined;\n const cards: NormalizedUIElement[] = [];\n let discarded = 0;\n for (const child of normalizedList(element.children)) {\n if (isElement(child) && child.type === \"Card\") {\n cards.push(child);\n continue;\n }\n if (discardedChild(child, context, 1)) discarded += 1;\n }\n if (discarded > 0) {\n context.warnings.push({\n code: \"dropped\",\n component: \"Carousel\",\n detail: `${discarded} non-card ${discarded === 1 ? \"child was\" : \"children were\"} dropped.`,\n });\n }\n return cards;\n}\n\n/**\n * Converts a generative-ui tree into Teams bot-framework message attachments.\n * A root-level `Carousel` becomes one attachment per `Card` child (clamped to\n * {@link CAROUSEL_ATTACHMENT_CAP}) with `attachmentLayout: \"carousel\"`;\n * anything else converts through {@link toAdaptiveCard}'s engine into a\n * single attachment. Never throws.\n */\nexport function toTeamsAttachments(\n node: unknown,\n _options?: ToAdaptiveCardOptions,\n): TeamsAttachmentsResult {\n const warnings: TeamsConversionWarning[] = [];\n const context: ConversionContext = { warnings, usedInputIds: new Set() };\n try {\n const bounded = boundSpec(node, (reason) =>\n warnings.push({\n code: \"clamped\",\n component: \"Root\",\n detail: clampReasonDetail(reason),\n }),\n );\n const { root } = normalizeSpec(bounded as never);\n const carouselCards = rootCarouselCards(root, context);\n if (carouselCards !== undefined) {\n const { items, truncated } = clampArray(\n carouselCards,\n CAROUSEL_ATTACHMENT_CAP,\n );\n if (truncated) {\n warnings.push({\n code: \"clamped\",\n component: \"Carousel\",\n detail: `cards were clamped to ${CAROUSEL_ATTACHMENT_CAP} entries.`,\n });\n }\n const attachments: TeamsCardAttachment[] = items.map((child) =>\n buildAttachment(convertRootToCard(child, context)),\n );\n const size = utf8ByteLength(JSON.stringify(attachments));\n if (size > PAYLOAD_SOFT_CAP) {\n warnings.push({\n code: \"advisory\",\n component: \"Carousel\",\n detail: `the carousel attachments total ${size} bytes, over the ${PAYLOAD_SOFT_CAP}-byte soft budget kept below Teams' 100 KB bot message limit.`,\n });\n }\n return { attachments, attachmentLayout: \"carousel\", warnings };\n }\n const attachment = buildAttachment(convertRootToCard(root, context));\n return { attachments: [attachment], warnings };\n } catch {\n return {\n attachments: [],\n warnings: [\n {\n code: \"dropped\",\n component: \"Root\",\n detail: \"Input could not be converted.\",\n },\n ],\n };\n }\n}\n"],"mappings":";;;;;;AAyBA,MAAM,kBACJ,SACuB;CACvB,IAAI,SAAS,KAAA,KAAa,SAAS,MAAM,OAAO,CAAC;CACjD,IAAI,CAAC,MAAM,QAAQ,IAAI,GAAG,OAAO,CAAC,IAAI;CACtC,OAAO,KAAK,SAAS,UAAU,eAAe,KAAK,CAAC;AACtD;AAEA,MAAM,cACJ,OACA,SAC0D;CAC1D,OAAO,MAAM,MAAM,GAAG,GAAG;CACzB,WAAW,MAAM,SAAS;AAC5B;;;;;;;;;AAUA,SAAS,kBACP,MACA,SACmC;CACnC,MAAM,UAAU,MAAM,QAAQ,IAAI,KAAK,KAAK,WAAW,IAAI,KAAK,KAAK;CACrE,IAAI,CAAC,UAAU,OAAO,KAAK,QAAQ,SAAS,YAAY,OAAO,KAAA;CAC/D,MAAM,QAA+B,CAAC;CACtC,IAAI,YAAY;CAChB,KAAK,MAAM,SAAS,eAAe,QAAQ,QAAQ,GAAG;EACpD,IAAI,UAAU,KAAK,KAAK,MAAM,SAAS,QAAQ;GAC7C,MAAM,KAAK,KAAK;GAChB;EACF;EACA,IAAI,eAAe,OAAO,SAAS,CAAC,GAAG,aAAa;CACtD;CACA,IAAI,YAAY,GACd,QAAQ,SAAS,KAAK;EACpB,MAAM;EACN,WAAW;EACX,QAAQ,GAAG,UAAU,YAAY,cAAc,IAAI,cAAc,gBAAgB;CACnF,CAAC;CAEH,OAAO;AACT;;;;;;;;AASA,SAAgB,mBACd,MACA,UACwB;CACxB,MAAM,WAAqC,CAAC;CAC5C,MAAM,UAA6B;EAAE;EAAU,8BAAc,IAAI,IAAI;CAAE;CACvE,IAAI;EACF,MAAM,UAAU,UAAU,OAAO,WAC/B,SAAS,KAAK;GACZ,MAAM;GACN,WAAW;GACX,QAAQ,kBAAkB,MAAM;EAClC,CAAC,CACH;EACA,MAAM,EAAE,SAAS,cAAc,OAAgB;EAC/C,MAAM,gBAAgB,kBAAkB,MAAM,OAAO;EACrD,IAAI,kBAAkB,KAAA,GAAW;GAC/B,MAAM,EAAE,OAAO,cAAc,WAC3B,eAAA,EAEF;GACA,IAAI,WACF,SAAS,KAAK;IACZ,MAAM;IACN,WAAW;IACX,QAAQ;GACV,CAAC;GAEH,MAAM,cAAqC,MAAM,KAAK,UACpD,gBAAgB,kBAAkB,OAAO,OAAO,CAAC,CACnD;GACA,MAAM,OAAO,eAAe,KAAK,UAAU,WAAW,CAAC;GACvD,IAAI,OAAA,KACF,SAAS,KAAK;IACZ,MAAM;IACN,WAAW;IACX,QAAQ,kCAAkC,KAAK,mBAAmB,iBAAiB;GACrF,CAAC;GAEH,OAAO;IAAE;IAAa,kBAAkB;IAAY;GAAS;EAC/D;EAEA,OAAO;GAAE,aAAa,CADH,gBAAgB,kBAAkB,MAAM,OAAO,CAClC,CAAC;GAAG;EAAS;CAC/C,QAAQ;EACN,OAAO;GACL,aAAa,CAAC;GACd,UAAU,CACR;IACE,MAAM;IACN,WAAW;IACX,QAAQ;GACV,CACF;EACF;CACF;AACF"}
1
+ {"version":3,"file":"toTeamsAttachments.js","names":[],"sources":["../../src/teams/toTeamsAttachments.ts"],"sourcesContent":["import { boundSpec, clampReasonDetail } from \"../convert/boundSpec\";\nimport { copyBounded } from \"../convert/copyBounded\";\nimport { isElement } from \"../convert/isElement\";\nimport {\n normalizeSpec,\n type NormalizedUIElement,\n type NormalizedUINode,\n} from \"../ir\";\nimport {\n CAROUSEL_ATTACHMENT_CAP,\n PAYLOAD_SOFT_CAP,\n buildAttachment,\n utf8ByteLength,\n} from \"./constants\";\nimport {\n convertRootToCard,\n discardedChild,\n type ConversionContext,\n} from \"./toAdaptiveCard\";\nimport type {\n TeamsAttachmentsResult,\n TeamsCardAttachment,\n TeamsConversionWarning,\n ToAdaptiveCardOptions,\n} from \"./types\";\n\nconst normalizedList = (\n node: NormalizedUINode | undefined,\n): NormalizedUINode[] => {\n if (node === undefined || node === null) return [];\n if (!Array.isArray(node)) return [node];\n return node.flatMap((child) => normalizedList(child));\n};\n\n/**\n * Recognizes a root that is a single `Carousel` element, returning its `Card`\n * children. A non-card child is routed through {@link discardedChild}, which\n * reports it and forwards whatever it lost, rather than being filtered out\n * silently. `undefined` means the root is not exclusively a\n * root-level carousel, so the caller falls back to converting the whole tree\n * as one card.\n */\nfunction rootCarouselCards(\n root: NormalizedUINode | readonly NormalizedUINode[],\n context: ConversionContext,\n): NormalizedUIElement[] | undefined {\n const element = Array.isArray(root) && root.length === 1 ? root[0] : root;\n if (!isElement(element) || element.type !== \"Carousel\") return undefined;\n const cards: NormalizedUIElement[] = [];\n let discarded = 0;\n for (const child of normalizedList(element.children)) {\n if (isElement(child) && child.type === \"Card\") {\n cards.push(child);\n continue;\n }\n if (discardedChild(child, context, 1)) discarded += 1;\n }\n if (discarded > 0) {\n context.warnings.push({\n code: \"dropped\",\n component: \"Carousel\",\n detail: `${discarded} non-card ${discarded === 1 ? \"child was\" : \"children were\"} dropped.`,\n });\n }\n return cards;\n}\n\n/**\n * Converts a generative-ui tree into Teams bot-framework message attachments.\n * A root-level `Carousel` becomes one attachment per `Card` child (clamped to\n * {@link CAROUSEL_ATTACHMENT_CAP}) with `attachmentLayout: \"carousel\"`;\n * anything else converts through {@link toAdaptiveCard}'s engine into a\n * single attachment. Never throws.\n */\nexport function toTeamsAttachments(\n node: unknown,\n _options?: ToAdaptiveCardOptions,\n): TeamsAttachmentsResult {\n const warnings: TeamsConversionWarning[] = [];\n const context: ConversionContext = { warnings, usedInputIds: new Set() };\n try {\n const bounded = boundSpec(node, (reason) =>\n warnings.push({\n code: \"clamped\",\n component: \"Root\",\n detail: clampReasonDetail(reason),\n }),\n );\n const { root } = normalizeSpec(bounded as never);\n const carouselCards = rootCarouselCards(root, context);\n if (carouselCards !== undefined) {\n const { items, truncated } = copyBounded(\n carouselCards,\n CAROUSEL_ATTACHMENT_CAP,\n );\n if (truncated) {\n warnings.push({\n code: \"clamped\",\n component: \"Carousel\",\n detail: `cards were clamped to ${CAROUSEL_ATTACHMENT_CAP} entries.`,\n });\n }\n const attachments: TeamsCardAttachment[] = items.map((child) =>\n buildAttachment(convertRootToCard(child, context)),\n );\n const size = utf8ByteLength(JSON.stringify(attachments));\n if (size > PAYLOAD_SOFT_CAP) {\n warnings.push({\n code: \"advisory\",\n component: \"Carousel\",\n detail: `the carousel attachments total ${size} bytes, over the ${PAYLOAD_SOFT_CAP}-byte soft budget kept below Teams' 100 KB bot message limit.`,\n });\n }\n return { attachments, attachmentLayout: \"carousel\", warnings };\n }\n const attachment = buildAttachment(convertRootToCard(root, context));\n return { attachments: [attachment], warnings };\n } catch {\n return {\n attachments: [],\n warnings: [\n {\n code: \"dropped\",\n component: \"Root\",\n detail: \"Input could not be converted.\",\n },\n ],\n };\n }\n}\n"],"mappings":";;;;;;;AA0BA,MAAM,kBACJ,SACuB;CACvB,IAAI,SAAS,KAAA,KAAa,SAAS,MAAM,OAAO,CAAC;CACjD,IAAI,CAAC,MAAM,QAAQ,IAAI,GAAG,OAAO,CAAC,IAAI;CACtC,OAAO,KAAK,SAAS,UAAU,eAAe,KAAK,CAAC;AACtD;;;;;;;;;AAUA,SAAS,kBACP,MACA,SACmC;CACnC,MAAM,UAAU,MAAM,QAAQ,IAAI,KAAK,KAAK,WAAW,IAAI,KAAK,KAAK;CACrE,IAAI,CAAC,UAAU,OAAO,KAAK,QAAQ,SAAS,YAAY,OAAO,KAAA;CAC/D,MAAM,QAA+B,CAAC;CACtC,IAAI,YAAY;CAChB,KAAK,MAAM,SAAS,eAAe,QAAQ,QAAQ,GAAG;EACpD,IAAI,UAAU,KAAK,KAAK,MAAM,SAAS,QAAQ;GAC7C,MAAM,KAAK,KAAK;GAChB;EACF;EACA,IAAI,eAAe,OAAO,SAAS,CAAC,GAAG,aAAa;CACtD;CACA,IAAI,YAAY,GACd,QAAQ,SAAS,KAAK;EACpB,MAAM;EACN,WAAW;EACX,QAAQ,GAAG,UAAU,YAAY,cAAc,IAAI,cAAc,gBAAgB;CACnF,CAAC;CAEH,OAAO;AACT;;;;;;;;AASA,SAAgB,mBACd,MACA,UACwB;CACxB,MAAM,WAAqC,CAAC;CAC5C,MAAM,UAA6B;EAAE;EAAU,8BAAc,IAAI,IAAI;CAAE;CACvE,IAAI;EACF,MAAM,UAAU,UAAU,OAAO,WAC/B,SAAS,KAAK;GACZ,MAAM;GACN,WAAW;GACX,QAAQ,kBAAkB,MAAM;EAClC,CAAC,CACH;EACA,MAAM,EAAE,SAAS,cAAc,OAAgB;EAC/C,MAAM,gBAAgB,kBAAkB,MAAM,OAAO;EACrD,IAAI,kBAAkB,KAAA,GAAW;GAC/B,MAAM,EAAE,OAAO,cAAc,YAC3B,eAAA,EAEF;GACA,IAAI,WACF,SAAS,KAAK;IACZ,MAAM;IACN,WAAW;IACX,QAAQ;GACV,CAAC;GAEH,MAAM,cAAqC,MAAM,KAAK,UACpD,gBAAgB,kBAAkB,OAAO,OAAO,CAAC,CACnD;GACA,MAAM,OAAO,eAAe,KAAK,UAAU,WAAW,CAAC;GACvD,IAAI,OAAA,KACF,SAAS,KAAK;IACZ,MAAM;IACN,WAAW;IACX,QAAQ,kCAAkC,KAAK,mBAAmB,iBAAiB;GACrF,CAAC;GAEH,OAAO;IAAE;IAAa,kBAAkB;IAAY;GAAS;EAC/D;EAEA,OAAO;GAAE,aAAa,CADH,gBAAgB,kBAAkB,MAAM,OAAO,CAClC,CAAC;GAAG;EAAS;CAC/C,QAAQ;EACN,OAAO;GACL,aAAa,CAAC;GACd,UAAU,CACR;IACE,MAAM;IACN,WAAW;IACX,QAAQ;GACV,CACF;EACF;CACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-generative-ui",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "Generative UI components for assistant-ui",
5
5
  "keywords": [
6
6
  "generative-ui",
@@ -50,7 +50,7 @@
50
50
  ],
51
51
  "sideEffects": false,
52
52
  "dependencies": {
53
- "assistant-stream": "^0.3.40"
53
+ "assistant-stream": "^0.3.41"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@assistant-ui/react": "^0.15.0",
@@ -64,14 +64,14 @@
64
64
  }
65
65
  },
66
66
  "devDependencies": {
67
- "@assistant-ui/react": "0.15.17",
68
- "@assistant-ui/x-buildutils": "0.0.24",
67
+ "@assistant-ui/react": "0.15.18",
68
+ "@assistant-ui/x-buildutils": "0.0.25",
69
69
  "@types/json-schema": "^7.0.15",
70
70
  "@types/react": "^19.2.18",
71
71
  "react": "^19.2.8",
72
72
  "react-dom": "^19.2.8",
73
73
  "vitest": "^4.1.11",
74
- "zod": "^4.4.3"
74
+ "zod": "^4.5.4"
75
75
  },
76
76
  "publishConfig": {
77
77
  "access": "public",
@@ -20,10 +20,12 @@ const nest = (levels: number): unknown =>
20
20
  : { type: "Box", children: [nest(levels - 1)] };
21
21
 
22
22
  describe("boundSpec", () => {
23
- it("returns a value without children as-is", () => {
23
+ it("copies an object without children and passes primitives through", () => {
24
24
  const leaf = { type: "Text", props: { value: "hi" } };
25
+ const { reasons, result } = walk(leaf);
25
26
 
26
- expect(walk(leaf)).toEqual({ reasons: [], result: leaf });
27
+ expect({ reasons, result }).toEqual({ reasons: [], result: leaf });
28
+ expect(result).not.toBe(leaf);
27
29
  expect(walk("text").result).toBe("text");
28
30
  expect(walk(null).result).toBe(null);
29
31
  });
@@ -86,6 +88,49 @@ describe("boundSpec", () => {
86
88
  expect(result).toEqual([{ type: "Text" }, { type: "Text" }]);
87
89
  });
88
90
 
91
+ it("bounds a record that hides its children behind a has trap", () => {
92
+ const hostileChildren = new Proxy([{ type: "Text" }], {
93
+ get: (target, prop, receiver) =>
94
+ prop === "length"
95
+ ? Number.MAX_SAFE_INTEGER
96
+ : Reflect.get(target, prop, receiver),
97
+ });
98
+ const hostile = new Proxy(
99
+ { type: "Box", children: hostileChildren } as Record<string, unknown>,
100
+ {
101
+ has: (target, prop) => prop !== "children" && Reflect.has(target, prop),
102
+ },
103
+ );
104
+
105
+ const { reasons, result } = walk(hostile);
106
+
107
+ expect(reasons).toEqual(["children"]);
108
+ expect((result as { children: unknown[] }).children).toHaveLength(
109
+ CHILDREN_CAP,
110
+ );
111
+ });
112
+
113
+ it("bounds a record that answers a later read of children differently", () => {
114
+ let reads = 0;
115
+ const hostile = new Proxy(
116
+ { type: "Box", children: undefined } as Record<string, unknown>,
117
+ {
118
+ get: (target, prop, receiver) => {
119
+ if (prop !== "children") return Reflect.get(target, prop, receiver);
120
+ reads += 1;
121
+ return reads === 1
122
+ ? undefined
123
+ : Array(CHILDREN_CAP * 2).fill({ type: "Text" });
124
+ },
125
+ },
126
+ );
127
+
128
+ const { reasons, result } = walk(hostile);
129
+
130
+ expect(reasons).toEqual([]);
131
+ expect((result as { children: unknown }).children).toBeUndefined();
132
+ });
133
+
89
134
  it("accepts nesting exactly at the element-depth ceiling", () => {
90
135
  expect(walk(nest(MAX_ELEMENT_DEPTH)).reasons).toEqual([]);
91
136
  });
@@ -81,26 +81,20 @@ function boundNode(
81
81
  ancestors.delete(value);
82
82
  return result;
83
83
  }
84
- if (
85
- value !== null &&
86
- typeof value === "object" &&
87
- "children" in (value as Record<string, unknown>)
88
- ) {
84
+ if (value !== null && typeof value === "object") {
85
+ const record = value as Record<string, unknown>;
86
+ const children = record["children"];
87
+ // The spread performs a `children` read of its own, so the copy states the
88
+ // bounded value even when there is none.
89
+ if (children === undefined) return { ...record, children: undefined };
89
90
  if (ancestors.has(value)) {
90
91
  onClamp("cycle");
91
92
  return null;
92
93
  }
93
94
  ancestors.add(value);
94
- const record = value as Record<string, unknown>;
95
95
  const result = {
96
96
  ...record,
97
- children: boundNode(
98
- record["children"],
99
- depth + 1,
100
- onClamp,
101
- state,
102
- ancestors,
103
- ),
97
+ children: boundNode(children, depth + 1, onClamp, state, ancestors),
104
98
  };
105
99
  ancestors.delete(value);
106
100
  return result;
@@ -116,7 +110,11 @@ function boundNode(
116
110
  * (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
117
111
  * entries by index, which bounds a proxy however it fabricates `length` or
118
112
  * replaces its array methods; recursion itself is capped at
119
- * {@link MAX_TRAVERSAL_DEPTH}. `onClamp` fires once per level that was
113
+ * {@link MAX_TRAVERSAL_DEPTH}. Every object is returned as a plain copy whose
114
+ * `children` came from a single read, because a record that answers
115
+ * `[[HasProperty]]` and `[[Get]]`, or two `[[Get]]`s, differently would
116
+ * otherwise hand the bound one value and `normalizeSpec` another, skipping
117
+ * every bound here for that subtree. `onClamp` fires once per level that was
120
118
  * truncated, receiving the reason for that truncation: `"children"`,
121
119
  * `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
122
120
  * {@link NODE_BUDGET} nodes, so shared references cannot multiply work
@@ -1,4 +1,5 @@
1
1
  import { boundSpec, clampReasonDetail } from "../convert/boundSpec";
2
+ import { copyBounded } from "../convert/copyBounded";
2
3
  import { isElement } from "../convert/isElement";
3
4
  import {
4
5
  normalizeSpec,
@@ -31,14 +32,6 @@ const normalizedList = (
31
32
  return node.flatMap((child) => normalizedList(child));
32
33
  };
33
34
 
34
- const clampArray = <T>(
35
- value: readonly T[],
36
- cap: number,
37
- ): { readonly items: T[]; readonly truncated: boolean } => ({
38
- items: value.slice(0, cap),
39
- truncated: value.length > cap,
40
- });
41
-
42
35
  /**
43
36
  * Recognizes a root that is a single `Carousel` element, returning its `Card`
44
37
  * children. A non-card child is routed through {@link discardedChild}, which
@@ -96,7 +89,7 @@ export function toTeamsAttachments(
96
89
  const { root } = normalizeSpec(bounded as never);
97
90
  const carouselCards = rootCarouselCards(root, context);
98
91
  if (carouselCards !== undefined) {
99
- const { items, truncated } = clampArray(
92
+ const { items, truncated } = copyBounded(
100
93
  carouselCards,
101
94
  CAROUSEL_ATTACHMENT_CAP,
102
95
  );