@terreno/blocks 57.10.1

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 (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/biome.jsonc +18 -0
  4. package/bunfig.toml +6 -0
  5. package/dist/asks/errors.d.ts +77 -0
  6. package/dist/asks/errors.js +364 -0
  7. package/dist/asks/files.d.ts +53 -0
  8. package/dist/asks/files.js +171 -0
  9. package/dist/asks/formValues.d.ts +32 -0
  10. package/dist/asks/formValues.js +229 -0
  11. package/dist/asks/headless.d.ts +227 -0
  12. package/dist/asks/headless.js +94 -0
  13. package/dist/asks/jsonSchema.d.ts +6 -0
  14. package/dist/asks/jsonSchema.js +62 -0
  15. package/dist/asks/limits.d.ts +58 -0
  16. package/dist/asks/limits.js +61 -0
  17. package/dist/asks/prompt.d.ts +10 -0
  18. package/dist/asks/prompt.js +144 -0
  19. package/dist/asks/schema.d.ts +826 -0
  20. package/dist/asks/schema.js +864 -0
  21. package/dist/asks/simpleCard.d.ts +85 -0
  22. package/dist/asks/simpleCard.js +279 -0
  23. package/dist/asks/validateInput.d.ts +13 -0
  24. package/dist/asks/validateInput.js +19 -0
  25. package/dist/asks/validateResponse.d.ts +13 -0
  26. package/dist/asks/validateResponse.js +278 -0
  27. package/dist/cli.d.ts +11 -0
  28. package/dist/cli.js +58 -0
  29. package/dist/errors.d.ts +51 -0
  30. package/dist/errors.js +106 -0
  31. package/dist/index.d.ts +31 -0
  32. package/dist/index.js +111 -0
  33. package/dist/jsonSchema.d.ts +5 -0
  34. package/dist/jsonSchema.js +12 -0
  35. package/dist/limits.d.ts +27 -0
  36. package/dist/limits.js +30 -0
  37. package/dist/lint.d.ts +21 -0
  38. package/dist/lint.js +388 -0
  39. package/dist/parse.d.ts +16 -0
  40. package/dist/parse.js +95 -0
  41. package/dist/parsePartial.d.ts +12 -0
  42. package/dist/parsePartial.js +105 -0
  43. package/dist/prompt.d.ts +9 -0
  44. package/dist/prompt.js +95 -0
  45. package/dist/schema.d.ts +199 -0
  46. package/dist/schema.js +277 -0
  47. package/dist/validate.d.ts +17 -0
  48. package/dist/validate.js +331 -0
  49. package/package.json +60 -0
  50. package/schemas/pendingAsksResponse.schema.json +195 -0
  51. package/schemas/simpleCard.schema.json +135 -0
  52. package/schemas/turnRequest.schema.json +108 -0
  53. package/schemas/turnResponse.schema.json +204 -0
  54. package/scripts/writeJsonSchemas.ts +11 -0
  55. package/src/asks/docParity.test.ts +65 -0
  56. package/src/asks/errors.test.ts +250 -0
  57. package/src/asks/errors.ts +449 -0
  58. package/src/asks/files.test.ts +294 -0
  59. package/src/asks/files.ts +216 -0
  60. package/src/asks/fixtures/invalid/choice-blank-text.json +20 -0
  61. package/src/asks/fixtures/invalid/choice-default-not-in-options.json +13 -0
  62. package/src/asks/fixtures/invalid/choice-default-selects-two.json +16 -0
  63. package/src/asks/fixtures/invalid/choice-description-too-long.json +24 -0
  64. package/src/asks/fixtures/invalid/choice-duplicate-ids.json +17 -0
  65. package/src/asks/fixtures/invalid/choice-label-too-long.json +23 -0
  66. package/src/asks/fixtures/invalid/choice-many-bounds-not-whole.json +17 -0
  67. package/src/asks/fixtures/invalid/choice-many-bounds-out-of-range.json +17 -0
  68. package/src/asks/fixtures/invalid/choice-many-default-duplicate.json +17 -0
  69. package/src/asks/fixtures/invalid/choice-many-default-too-many.json +15 -0
  70. package/src/asks/fixtures/invalid/choice-many-max-above-options.json +14 -0
  71. package/src/asks/fixtures/invalid/choice-many-min-above-choices.json +13 -0
  72. package/src/asks/fixtures/invalid/choice-many-min-above-max.json +15 -0
  73. package/src/asks/fixtures/invalid/choice-missing-required.json +14 -0
  74. package/src/asks/fixtures/invalid/choice-mixed-errors.json +19 -0
  75. package/src/asks/fixtures/invalid/choice-not-an-object.json +5 -0
  76. package/src/asks/fixtures/invalid/choice-one-with-many-fields.json +19 -0
  77. package/src/asks/fixtures/invalid/choice-option-id-format.json +35 -0
  78. package/src/asks/fixtures/invalid/choice-options-not-objects.json +12 -0
  79. package/src/asks/fixtures/invalid/choice-other-label-too-long.json +25 -0
  80. package/src/asks/fixtures/invalid/choice-other-label-without-allow-other.json +14 -0
  81. package/src/asks/fixtures/invalid/choice-prompt-empty.json +12 -0
  82. package/src/asks/fixtures/invalid/choice-prompt-too-long.json +23 -0
  83. package/src/asks/fixtures/invalid/choice-select-unknown.json +12 -0
  84. package/src/asks/fixtures/invalid/choice-submit-label-too-long.json +13 -0
  85. package/src/asks/fixtures/invalid/choice-title-too-long.json +24 -0
  86. package/src/asks/fixtures/invalid/choice-too-few-options.json +9 -0
  87. package/src/asks/fixtures/invalid/choice-too-many-options.json +219 -0
  88. package/src/asks/fixtures/invalid/choice-unknown-key-option.json +15 -0
  89. package/src/asks/fixtures/invalid/choice-unknown-key-root.json +13 -0
  90. package/src/asks/fixtures/invalid/choice-wrong-types.json +16 -0
  91. package/src/asks/fixtures/invalid/confirm-emoji-label-too-long.json +13 -0
  92. package/src/asks/fixtures/invalid/confirm-label-too-long.json +14 -0
  93. package/src/asks/fixtures/invalid/confirm-labels-alike-after-trim.json +14 -0
  94. package/src/asks/fixtures/invalid/confirm-labels-alike.json +13 -0
  95. package/src/asks/fixtures/invalid/confirm-missing-prompt-blank-label.json +16 -0
  96. package/src/asks/fixtures/invalid/confirm-unknown-keys.json +23 -0
  97. package/src/asks/fixtures/invalid/confirm-wrong-types.json +18 -0
  98. package/src/asks/fixtures/invalid/files-accept-duplicate.json +13 -0
  99. package/src/asks/fixtures/invalid/files-accept-empty.json +13 -0
  100. package/src/asks/fixtures/invalid/files-accept-unknown.json +13 -0
  101. package/src/asks/fixtures/invalid/files-counts-out-of-range.json +19 -0
  102. package/src/asks/fixtures/invalid/files-min-above-max.json +15 -0
  103. package/src/asks/fixtures/invalid/files-missing-accept-unknown-key.json +17 -0
  104. package/src/asks/fixtures/invalid/form-default-bad-format.json +40 -0
  105. package/src/asks/fixtures/invalid/form-default-invalid-date.json +40 -0
  106. package/src/asks/fixtures/invalid/form-default-not-in-options.json +58 -0
  107. package/src/asks/fixtures/invalid/form-default-out-of-range.json +33 -0
  108. package/src/asks/fixtures/invalid/form-default-text-bounds.json +42 -0
  109. package/src/asks/fixtures/invalid/form-default-wrong-type.json +30 -0
  110. package/src/asks/fixtures/invalid/form-duplicate-field-ids.json +24 -0
  111. package/src/asks/fixtures/invalid/form-field-id-format.json +19 -0
  112. package/src/asks/fixtures/invalid/form-field-text-limits.json +33 -0
  113. package/src/asks/fixtures/invalid/form-keys-of-another-type.json +39 -0
  114. package/src/asks/fixtures/invalid/form-length-above-cap.json +30 -0
  115. package/src/asks/fixtures/invalid/form-min-above-max.json +32 -0
  116. package/src/asks/fixtures/invalid/form-no-fields.json +13 -0
  117. package/src/asks/fixtures/invalid/form-select-options.json +44 -0
  118. package/src/asks/fixtures/invalid/form-too-many-fields.json +59 -0
  119. package/src/asks/fixtures/invalid/form-unknown-field-type.json +27 -0
  120. package/src/asks/fixtures/invalid/markdown-bounds-out-of-range.json +18 -0
  121. package/src/asks/fixtures/invalid/markdown-initial-above-cap.json +13 -0
  122. package/src/asks/fixtures/invalid/markdown-max-above-cap.json +13 -0
  123. package/src/asks/fixtures/invalid/markdown-min-above-cap.json +13 -0
  124. package/src/asks/fixtures/invalid/markdown-min-above-max.json +14 -0
  125. package/src/asks/fixtures/invalid/markdown-placeholder-too-long.json +13 -0
  126. package/src/asks/fixtures/invalid/markdown-wrong-types-unknown-key.json +27 -0
  127. package/src/asks/fixtures/valid/choice-emoji-label-cut.json +32 -0
  128. package/src/asks/fixtures/valid/choice-labels-collide-after-cut.json +21 -0
  129. package/src/asks/fixtures/valid/choice-long-text-cut.json +41 -0
  130. package/src/asks/fixtures/valid/choice-many-default-below-min.json +24 -0
  131. package/src/asks/fixtures/valid/choice-many-every-choice-with-other.json +24 -0
  132. package/src/asks/fixtures/valid/choice-many-no-default.json +21 -0
  133. package/src/asks/fixtures/valid/choice-many-optional-no-buttons.json +20 -0
  134. package/src/asks/fixtures/valid/choice-many-options-default-label-collides.json +23 -0
  135. package/src/asks/fixtures/valid/choice-many-options-no-buttons.json +21 -0
  136. package/src/asks/fixtures/valid/choice-many-options-no-default.json +22 -0
  137. package/src/asks/fixtures/valid/choice-many-options-with-default.json +30 -0
  138. package/src/asks/fixtures/valid/choice-many-required-with-default.json +29 -0
  139. package/src/asks/fixtures/valid/choice-many-single-pick-or-other.json +29 -0
  140. package/src/asks/fixtures/valid/choice-many-toppings-with-default.json +36 -0
  141. package/src/asks/fixtures/valid/choice-max-limits.json +245 -0
  142. package/src/asks/fixtures/valid/choice-plan-with-default.json +42 -0
  143. package/src/asks/fixtures/valid/choice-two-options-no-decline.json +32 -0
  144. package/src/asks/fixtures/valid/choice-two-options.json +32 -0
  145. package/src/asks/fixtures/valid/confirm-allow-decline.json +40 -0
  146. package/src/asks/fixtures/valid/confirm-default-labels.json +36 -0
  147. package/src/asks/fixtures/valid/confirm-destructive-delete.json +41 -0
  148. package/src/asks/fixtures/valid/confirm-emoji-labels.json +38 -0
  149. package/src/asks/fixtures/valid/confirm-long-text-cut.json +40 -0
  150. package/src/asks/fixtures/valid/files-every-type.json +28 -0
  151. package/src/asks/fixtures/valid/files-one-csv-no-decline.json +16 -0
  152. package/src/asks/fixtures/valid/files-receipt-photos.json +24 -0
  153. package/src/asks/fixtures/valid/form-boolean-newsletter.json +30 -0
  154. package/src/asks/fixtures/valid/form-date-due.json +44 -0
  155. package/src/asks/fixtures/valid/form-datetime-meeting.json +44 -0
  156. package/src/asks/fixtures/valid/form-datetime-minutes-offset.json +44 -0
  157. package/src/asks/fixtures/valid/form-eight-fields.json +71 -0
  158. package/src/asks/fixtures/valid/form-email-contact.json +44 -0
  159. package/src/asks/fixtures/valid/form-invoice-details.json +68 -0
  160. package/src/asks/fixtures/valid/form-multiselect-channels.json +64 -0
  161. package/src/asks/fixtures/valid/form-number-budget.json +45 -0
  162. package/src/asks/fixtures/valid/form-phone-callback.json +44 -0
  163. package/src/asks/fixtures/valid/form-select-shipping.json +58 -0
  164. package/src/asks/fixtures/valid/form-text-required-default.json +47 -0
  165. package/src/asks/fixtures/valid/form-textarea-notes.json +23 -0
  166. package/src/asks/fixtures/valid/form-time-reminder.json +44 -0
  167. package/src/asks/fixtures/valid/form-url-website.json +30 -0
  168. package/src/asks/fixtures/valid/markdown-announcement-draft.json +39 -0
  169. package/src/asks/fixtures/valid/markdown-empty-draft.json +34 -0
  170. package/src/asks/fixtures/valid/markdown-initial-blank-below-min.json +24 -0
  171. package/src/asks/fixtures/valid/markdown-initial-over-max.json +24 -0
  172. package/src/asks/fixtures/valid/markdown-long-text-cut.json +37 -0
  173. package/src/asks/fixtures/valid/markdown-no-decline.json +28 -0
  174. package/src/asks/fixtures/valid/markdown-no-initial-with-min.json +25 -0
  175. package/src/asks/formValues.ts +320 -0
  176. package/src/asks/headless.test.ts +146 -0
  177. package/src/asks/headless.ts +130 -0
  178. package/src/asks/jsonSchema.test.ts +171 -0
  179. package/src/asks/jsonSchema.ts +89 -0
  180. package/src/asks/limits.ts +58 -0
  181. package/src/asks/prompt.test.ts +200 -0
  182. package/src/asks/prompt.ts +173 -0
  183. package/src/asks/schema.test.ts +198 -0
  184. package/src/asks/schema.ts +1203 -0
  185. package/src/asks/simpleCard.test.ts +998 -0
  186. package/src/asks/simpleCard.ts +341 -0
  187. package/src/asks/validateInput.test.ts +875 -0
  188. package/src/asks/validateInput.ts +24 -0
  189. package/src/asks/validateResponse.test.ts +740 -0
  190. package/src/asks/validateResponse.ts +341 -0
  191. package/src/blocksDocParity.test.ts +21 -0
  192. package/src/cli.test.ts +134 -0
  193. package/src/cli.ts +65 -0
  194. package/src/display.test.ts +68 -0
  195. package/src/errors.test.ts +29 -0
  196. package/src/errors.ts +125 -0
  197. package/src/fixtures/invalid/column-not-found.yaml +14 -0
  198. package/src/fixtures/invalid/column-type.yaml +14 -0
  199. package/src/fixtures/invalid/dataset-not-found.yaml +7 -0
  200. package/src/fixtures/invalid/dataset-too-large.yaml +20 -0
  201. package/src/fixtures/invalid/depth-exceeded.yaml +10 -0
  202. package/src/fixtures/invalid/duplicate-id.yaml +6 -0
  203. package/src/fixtures/invalid/invalid-enum.yaml +5 -0
  204. package/src/fixtures/invalid/key-order.yaml +4 -0
  205. package/src/fixtures/invalid/missing-required.yaml +3 -0
  206. package/src/fixtures/invalid/not-a-document.txt +1 -0
  207. package/src/fixtures/invalid/row-arity.yaml +11 -0
  208. package/src/fixtures/invalid/select-target.yaml +12 -0
  209. package/src/fixtures/invalid/table-too-wide.yaml +9 -0
  210. package/src/fixtures/invalid/too-many-points.yaml +8 -0
  211. package/src/fixtures/invalid/unknown-key.yaml +5 -0
  212. package/src/fixtures/invalid/unsupported-version.yaml +4 -0
  213. package/src/fixtures/invalid/yaml-anchor.yaml +6 -0
  214. package/src/fixtures/partial/cut-after-divider.yaml +5 -0
  215. package/src/fixtures/valid/actions.yaml +32 -0
  216. package/src/fixtures/valid/chart.yaml +20 -0
  217. package/src/fixtures/valid/layout.yaml +26 -0
  218. package/src/fixtures/valid/prose.yaml +4 -0
  219. package/src/html.test.ts +52 -0
  220. package/src/index.test.ts +114 -0
  221. package/src/index.ts +175 -0
  222. package/src/jsonSchema.test.ts +33 -0
  223. package/src/jsonSchema.ts +11 -0
  224. package/src/limits.ts +27 -0
  225. package/src/lint.test.ts +289 -0
  226. package/src/lint.ts +484 -0
  227. package/src/parse.test.ts +281 -0
  228. package/src/parse.ts +104 -0
  229. package/src/parsePartial.test.ts +53 -0
  230. package/src/parsePartial.ts +116 -0
  231. package/src/prompt.test.ts +24 -0
  232. package/src/prompt.ts +104 -0
  233. package/src/schema.ts +509 -0
  234. package/src/tests/askFixtures.ts +40 -0
  235. package/src/validate.perf.test.ts +43 -0
  236. package/src/validate.ts +369 -0
  237. package/tsconfig.json +18 -0
@@ -0,0 +1,998 @@
1
+ import {describe, expect, it} from "bun:test";
2
+ import {type ValidAskFixture, validAskFixtures, validAskFixturesOf} from "../tests/askFixtures";
3
+ import {ASK_LIMITS} from "./limits";
4
+ import type {
5
+ ChoiceAskInput,
6
+ ConfirmAskInput,
7
+ FilesAskInput,
8
+ FormAskInput,
9
+ MarkdownAskInput,
10
+ } from "./schema";
11
+ import {resolveButtonAnswer, simpleCardSchema, toSimpleCard} from "./simpleCard";
12
+ import {validateAskInput} from "./validateInput";
13
+ import {validateAskResponse} from "./validateResponse";
14
+
15
+ const TOOL_CALL_ID = "call_fixture";
16
+
17
+ /**
18
+ * Rows of the `choice` rule table in docs/reference/agent-ui-asks.md. The `many` row splits on
19
+ * whether the default is a valid answer on its own, which is when the card offers Use suggested.
20
+ */
21
+ type ChoiceRow =
22
+ | "optionsFit"
23
+ | "moreOptions"
24
+ | "labelsCollide"
25
+ | "manyWithSuggestion"
26
+ | "manyWithoutSuggestion";
27
+
28
+ /** The row each valid `choice` fixture exercises, read off the rule table by hand. */
29
+ const CHOICE_ROW_BY_FIXTURE: Record<string, ChoiceRow> = {
30
+ "choice-emoji-label-cut": "optionsFit",
31
+ "choice-labels-collide-after-cut": "labelsCollide",
32
+ "choice-long-text-cut": "optionsFit",
33
+ "choice-many-default-below-min": "manyWithoutSuggestion",
34
+ "choice-many-every-choice-with-other": "manyWithoutSuggestion",
35
+ "choice-many-no-default": "manyWithoutSuggestion",
36
+ "choice-many-optional-no-buttons": "manyWithoutSuggestion",
37
+ "choice-many-options-default-label-collides": "labelsCollide",
38
+ "choice-many-options-no-buttons": "moreOptions",
39
+ "choice-many-options-no-default": "moreOptions",
40
+ "choice-many-options-with-default": "moreOptions",
41
+ "choice-many-required-with-default": "manyWithSuggestion",
42
+ "choice-many-single-pick-or-other": "manyWithSuggestion",
43
+ "choice-many-toppings-with-default": "manyWithSuggestion",
44
+ "choice-max-limits": "moreOptions",
45
+ "choice-plan-with-default": "optionsFit",
46
+ "choice-two-options": "optionsFit",
47
+ "choice-two-options-no-decline": "optionsFit",
48
+ };
49
+
50
+ const MANY_ROWS: readonly ChoiceRow[] = ["manyWithSuggestion", "manyWithoutSuggestion"];
51
+
52
+ interface ExpectedButton {
53
+ id: string;
54
+ response: unknown;
55
+ style: string;
56
+ }
57
+
58
+ const SKIP: ExpectedButton = {id: "skip", response: {action: "decline"}, style: "cancel"};
59
+
60
+ const selects = (...ids: string[]): unknown => ({action: "accept", content: {selected: ids}});
61
+
62
+ /** The buttons a row allows for an input, without their labels, written from the rule table. */
63
+ const buttonsForRow = (input: ChoiceAskInput, row: ChoiceRow): ExpectedButton[] => {
64
+ const skip = input.allowDecline === false ? [] : [SKIP];
65
+ const defaultId = input.default?.[0];
66
+ if (row === "labelsCollide" || row === "manyWithoutSuggestion") {
67
+ return skip;
68
+ }
69
+ if (row === "manyWithSuggestion") {
70
+ return [
71
+ {id: "use-default", response: selects(...(input.default ?? [])), style: "primary"},
72
+ ...skip,
73
+ ];
74
+ }
75
+ if (row === "moreOptions") {
76
+ const useDefault = defaultId
77
+ ? [{id: "use-default", response: selects(defaultId), style: "primary"}]
78
+ : [];
79
+ return [...useDefault, ...skip];
80
+ }
81
+ const others = input.options.map((option) => option.id).filter((id) => id !== defaultId);
82
+ const options = [
83
+ ...(defaultId
84
+ ? [{id: `option:${defaultId}`, response: selects(defaultId), style: "primary"}]
85
+ : []),
86
+ ...others.map((id) => ({id: `option:${id}`, response: selects(id), style: "default"})),
87
+ ];
88
+ return options.length < 3 ? [...options, ...skip] : options;
89
+ };
90
+
91
+ /** The `confirm` row: approve (destructive when the ask is) first, deny last, and never Skip. */
92
+ const confirmButtons = (input: ConfirmAskInput): ExpectedButton[] => [
93
+ {
94
+ id: "approve",
95
+ response: {action: "accept", content: {confirmed: true}},
96
+ style: input.destructive === true ? "destructive" : "primary",
97
+ },
98
+ {id: "deny", response: {action: "accept", content: {confirmed: false}}, style: "cancel"},
99
+ ];
100
+
101
+ /** Whether each valid `markdown` fixture's `initial` meets its length rules, read off by hand. */
102
+ const MARKDOWN_DRAFT_FITS_BY_FIXTURE: Record<string, boolean> = {
103
+ "markdown-announcement-draft": true,
104
+ "markdown-empty-draft": true,
105
+ "markdown-initial-blank-below-min": false,
106
+ "markdown-initial-over-max": false,
107
+ "markdown-long-text-cut": true,
108
+ "markdown-no-decline": true,
109
+ "markdown-no-initial-with-min": false,
110
+ };
111
+
112
+ /** The `markdown` row: Approve draft when the draft fits, then Cancel when the ask allows it. */
113
+ const markdownButtons = (input: MarkdownAskInput, isDraftFitting: boolean): ExpectedButton[] => [
114
+ ...(isDraftFitting
115
+ ? [
116
+ {
117
+ id: "approve",
118
+ response: {action: "accept", content: {changed: false, markdown: input.initial ?? ""}},
119
+ style: "primary",
120
+ },
121
+ ]
122
+ : []),
123
+ ...(input.allowDecline === false
124
+ ? []
125
+ : [{id: "cancel", response: {action: "decline"}, style: "cancel"}]),
126
+ ];
127
+
128
+ /**
129
+ * The defaults each valid `form` fixture's Submit defaults button sends, read off by hand, or
130
+ * null when the card has no Submit defaults (a required field without a default, or no default).
131
+ */
132
+ const FORM_SUBMIT_VALUES_BY_FIXTURE: Record<string, Record<string, unknown> | null> = {
133
+ "form-boolean-newsletter": null,
134
+ "form-date-due": {due: "2026-10-01"},
135
+ "form-datetime-meeting": {starts: "2026-10-01T16:30:00Z"},
136
+ "form-datetime-minutes-offset": {departs: "2026-10-01T09:30+02:00"},
137
+ "form-eight-fields": null,
138
+ "form-email-contact": {email: "billing@acme.test"},
139
+ "form-invoice-details": null,
140
+ "form-multiselect-channels": {channels: ["email", "slack"]},
141
+ "form-number-budget": {budget: 2500.5},
142
+ "form-phone-callback": {phone: "+1 (415) 555-2671"},
143
+ "form-select-shipping": {speed: "standard"},
144
+ "form-text-required-default": {name: "Launch plan"},
145
+ "form-textarea-notes": null,
146
+ "form-time-reminder": {at: "09:30"},
147
+ "form-url-website": null,
148
+ };
149
+
150
+ /** The `form` row: Submit defaults when every required field has a default, then Cancel. */
151
+ const formButtons = (
152
+ input: FormAskInput,
153
+ values: Record<string, unknown> | null
154
+ ): ExpectedButton[] => [
155
+ ...(values === null
156
+ ? []
157
+ : [{id: "submit-defaults", response: {action: "accept", content: {values}}, style: "primary"}]),
158
+ ...(input.allowDecline === false
159
+ ? []
160
+ : [{id: "cancel", response: {action: "decline"}, style: "cancel"}]),
161
+ ];
162
+
163
+ /** The buttons and handoff the rule table gives a valid fixture. */
164
+ const expectedCard = (fixture: ValidAskFixture): {buttons: ExpectedButton[]; handoff: boolean} => {
165
+ if (fixture.kind === "files") {
166
+ return {buttons: fixture.input.allowDecline === false ? [] : [SKIP], handoff: true};
167
+ }
168
+ if (fixture.kind === "form") {
169
+ const values = FORM_SUBMIT_VALUES_BY_FIXTURE[fixture.name];
170
+ if (values === undefined) {
171
+ throw new Error(`Add valid/${fixture.name} to FORM_SUBMIT_VALUES_BY_FIXTURE.`);
172
+ }
173
+ return {buttons: formButtons(fixture.input, values), handoff: true};
174
+ }
175
+ if (fixture.kind === "confirm") {
176
+ return {buttons: confirmButtons(fixture.input), handoff: false};
177
+ }
178
+ if (fixture.kind === "markdown") {
179
+ const isDraftFitting = MARKDOWN_DRAFT_FITS_BY_FIXTURE[fixture.name];
180
+ if (isDraftFitting === undefined) {
181
+ throw new Error(`Add valid/${fixture.name} to MARKDOWN_DRAFT_FITS_BY_FIXTURE.`);
182
+ }
183
+ return {buttons: markdownButtons(fixture.input, isDraftFitting), handoff: true};
184
+ }
185
+ const row = CHOICE_ROW_BY_FIXTURE[fixture.name];
186
+ if (!row) {
187
+ throw new Error(`Add valid/${fixture.name} to CHOICE_ROW_BY_FIXTURE.`);
188
+ }
189
+ return {buttons: buttonsForRow(fixture.input, row), handoff: row !== "optionsFit"};
190
+ };
191
+
192
+ describe("toSimpleCard golden fixtures", () => {
193
+ for (const fixture of validAskFixtures()) {
194
+ it(`derives the expected card for valid/${fixture.name}`, () => {
195
+ expect(
196
+ toSimpleCard({input: fixture.input, kind: fixture.kind, toolCallId: TOOL_CALL_ID})
197
+ ).toEqual(fixture.simple);
198
+ });
199
+ }
200
+ });
201
+
202
+ describe("toSimpleCard properties over every valid fixture", () => {
203
+ for (const fixture of validAskFixtures()) {
204
+ describe(`valid/${fixture.name}`, () => {
205
+ const card = toSimpleCard({
206
+ input: fixture.input,
207
+ kind: fixture.kind,
208
+ toolCallId: TOOL_CALL_ID,
209
+ });
210
+
211
+ it("passes simpleCardSchema", () => {
212
+ expect(simpleCardSchema.safeParse(card).success).toBe(true);
213
+ });
214
+
215
+ it("keeps the kind and toolCallId", () => {
216
+ expect(card.kind).toBe(fixture.kind);
217
+ expect(card.toolCallId).toBe(TOOL_CALL_ID);
218
+ });
219
+
220
+ it("only has buttons whose response is a valid answer to the ask", () => {
221
+ for (const button of card.buttons) {
222
+ expect(
223
+ validateAskResponse({
224
+ input: fixture.input,
225
+ kind: fixture.kind,
226
+ response: button.response,
227
+ })
228
+ ).toEqual([]);
229
+ }
230
+ });
231
+
232
+ it("has the buttons and handoff of its rule-table row", () => {
233
+ const expected = expectedCard(fixture);
234
+ expect(card.handoff).toBe(expected.handoff);
235
+ expect(card.buttons.map(({id, response, style}) => ({id, response, style}))).toEqual(
236
+ expected.buttons
237
+ );
238
+ });
239
+
240
+ it("never shows two buttons with the same label", () => {
241
+ const labels = card.buttons.map((button) => button.label);
242
+ expect(new Set(labels).size).toBe(labels.length);
243
+ });
244
+
245
+ it("puts Skip last", () => {
246
+ const skipIndex = card.buttons.findIndex((button) => button.id === "skip");
247
+ if (skipIndex >= 0) {
248
+ expect(skipIndex).toBe(card.buttons.length - 1);
249
+ }
250
+ });
251
+ });
252
+ }
253
+ });
254
+
255
+ describe("the rule-table row map", () => {
256
+ it("lists exactly the valid form fixtures", () => {
257
+ expect(Object.keys(FORM_SUBMIT_VALUES_BY_FIXTURE).sort()).toEqual(
258
+ validAskFixturesOf("form")
259
+ .map((fixture) => fixture.name)
260
+ .sort()
261
+ );
262
+ });
263
+
264
+ it("lists exactly the valid markdown fixtures", () => {
265
+ expect(Object.keys(MARKDOWN_DRAFT_FITS_BY_FIXTURE).sort()).toEqual(
266
+ validAskFixturesOf("markdown")
267
+ .map((fixture) => fixture.name)
268
+ .sort()
269
+ );
270
+ });
271
+
272
+ it("lists exactly the valid choice fixtures", () => {
273
+ expect(Object.keys(CHOICE_ROW_BY_FIXTURE).sort()).toEqual(
274
+ validAskFixturesOf("choice")
275
+ .map((fixture) => fixture.name)
276
+ .sort()
277
+ );
278
+ });
279
+
280
+ it("uses optionsFit only for at most 3 options and moreOptions only for more", () => {
281
+ for (const fixture of validAskFixturesOf("choice")) {
282
+ const row = CHOICE_ROW_BY_FIXTURE[fixture.name];
283
+ if (row === "optionsFit") {
284
+ expect(fixture.input.options.length).toBeLessThanOrEqual(3);
285
+ }
286
+ if (row === "moreOptions") {
287
+ expect(fixture.input.options.length).toBeGreaterThan(3);
288
+ }
289
+ }
290
+ });
291
+
292
+ it("uses the many rows exactly for select many", () => {
293
+ for (const fixture of validAskFixturesOf("choice")) {
294
+ const row = CHOICE_ROW_BY_FIXTURE[fixture.name] ?? "optionsFit";
295
+ expect(MANY_ROWS.includes(row)).toBe(fixture.input.select === "many");
296
+ }
297
+ });
298
+ });
299
+
300
+ describe("toSimpleCard choice many rules", () => {
301
+ const TOPPINGS = [
302
+ {id: "cheese", label: "Cheese"},
303
+ {id: "olives", label: "Olives"},
304
+ {id: "peppers", label: "Peppers"},
305
+ ];
306
+
307
+ it("offers Use suggested for a default within the bounds, then Skip", () => {
308
+ expect(
309
+ toSimpleCard({
310
+ input: {
311
+ default: ["olives", "cheese"],
312
+ options: TOPPINGS,
313
+ prompt: "Toppings?",
314
+ select: "many",
315
+ },
316
+ kind: "choice",
317
+ toolCallId: "call_1",
318
+ })
319
+ ).toEqual({
320
+ buttons: [
321
+ {
322
+ id: "use-default",
323
+ label: "Use suggested",
324
+ response: {action: "accept", content: {selected: ["olives", "cheese"]}},
325
+ style: "primary",
326
+ },
327
+ {id: "skip", label: "Skip", response: {action: "decline"}, style: "cancel"},
328
+ ],
329
+ handoff: true,
330
+ kind: "choice",
331
+ text: "Toppings?",
332
+ toolCallId: "call_1",
333
+ });
334
+ });
335
+
336
+ it("hands off even when every option would fit a button, because a tap picks only one", () => {
337
+ const card = toSimpleCard({
338
+ input: {options: TOPPINGS.slice(0, 2), prompt: "Toppings?", select: "many"},
339
+ kind: "choice",
340
+ toolCallId: "call_1",
341
+ });
342
+ expect(card.handoff).toBe(true);
343
+ expect(card.buttons.map((button) => button.id)).toEqual(["skip"]);
344
+ });
345
+
346
+ it("leaves out Use suggested when the default has fewer choices than minSelected", () => {
347
+ const card = toSimpleCard({
348
+ input: {
349
+ default: ["cheese"],
350
+ minSelected: 2,
351
+ options: TOPPINGS,
352
+ prompt: "Toppings?",
353
+ select: "many",
354
+ },
355
+ kind: "choice",
356
+ toolCallId: "call_1",
357
+ });
358
+ expect(card.buttons.map((button) => button.id)).toEqual(["skip"]);
359
+ });
360
+
361
+ it("leaves out Use suggested for an empty default, even when no choice is required", () => {
362
+ const card = toSimpleCard({
363
+ input: {default: [], minSelected: 0, options: TOPPINGS, prompt: "Toppings?", select: "many"},
364
+ kind: "choice",
365
+ toolCallId: "call_1",
366
+ });
367
+ expect(card.buttons.map((button) => button.id)).toEqual(["skip"]);
368
+ });
369
+ });
370
+
371
+ const COMPACT_LABEL_SETS = [
372
+ ["Yes", "No"],
373
+ ["Twenty characters!!!", "Exactly twenty chars"],
374
+ ["Party 🎉🎉🎉🎉🎉🎉🎉", "Quiet night 🌙"],
375
+ ["Starter", "Team", "Enterprise"],
376
+ ["A", "B", "C"],
377
+ ];
378
+
379
+ const LONG_PROMPT = Array.from({length: 100}, () => "word").join(" ");
380
+
381
+ /** Every combination of labels, default, allowDecline, title, and prompt length. */
382
+ const generatedCompactAsks = (): ChoiceAskInput[] =>
383
+ COMPACT_LABEL_SETS.flatMap((labels) => {
384
+ const options = labels.map((label, index) => ({id: `option-${index}`, label}));
385
+ const defaults = [undefined, ...options.map((option) => [option.id])];
386
+ return defaults.flatMap((defaultIds) =>
387
+ [undefined, true, false].flatMap((allowDecline) =>
388
+ [undefined, "t".repeat(80)].flatMap((title) =>
389
+ ["Pick one.", LONG_PROMPT].map(
390
+ (prompt): ChoiceAskInput => ({
391
+ options,
392
+ prompt,
393
+ select: "one",
394
+ ...(allowDecline === undefined ? {} : {allowDecline}),
395
+ ...(defaultIds === undefined ? {} : {default: defaultIds}),
396
+ ...(title === undefined ? {} : {title}),
397
+ })
398
+ )
399
+ )
400
+ )
401
+ );
402
+ });
403
+
404
+ const compactFixtureAsks = (): ChoiceAskInput[] =>
405
+ validAskFixturesOf("choice")
406
+ .filter(
407
+ (fixture) =>
408
+ validateAskInput({input: fixture.input, kind: fixture.kind, surface: "compact"}).length ===
409
+ 0
410
+ )
411
+ .map((fixture) => fixture.input);
412
+
413
+ describe("toSimpleCard properties over compact asks", () => {
414
+ const asks = [...generatedCompactAsks(), ...compactFixtureAsks()];
415
+ const cards = asks.map((input) => ({
416
+ card: toSimpleCard({input, kind: "choice", toolCallId: TOOL_CALL_ID}),
417
+ input,
418
+ }));
419
+
420
+ it("covers generated asks and the compact fixtures", () => {
421
+ expect(generatedCompactAsks().length).toBeGreaterThan(100);
422
+ expect(compactFixtureAsks().length).toBe(3);
423
+ });
424
+
425
+ it("only generates asks that pass the compact rules", () => {
426
+ for (const input of asks) {
427
+ expect(validateAskInput({input, kind: "choice", surface: "compact"})).toEqual([]);
428
+ }
429
+ });
430
+
431
+ it("derives cards that pass simpleCardSchema", () => {
432
+ for (const {card} of cards) {
433
+ expect(simpleCardSchema.safeParse(card).success).toBe(true);
434
+ }
435
+ });
436
+
437
+ it("never hands off", () => {
438
+ for (const {card} of cards) {
439
+ expect(card.handoff).toBe(false);
440
+ }
441
+ });
442
+
443
+ it("follows the optionsFit row of the rule table", () => {
444
+ for (const {card, input} of cards) {
445
+ expect(card.buttons.map(({id, response, style}) => ({id, response, style}))).toEqual(
446
+ buttonsForRow(input, "optionsFit")
447
+ );
448
+ }
449
+ });
450
+
451
+ it("shows every option's label uncut", () => {
452
+ for (const {card, input} of cards) {
453
+ for (const option of input.options) {
454
+ expect(card.buttons.find((button) => button.id === `option:${option.id}`)?.label).toBe(
455
+ option.label
456
+ );
457
+ }
458
+ }
459
+ });
460
+
461
+ it("only has buttons whose response is a valid answer to the ask", () => {
462
+ for (const {card, input} of cards) {
463
+ for (const button of card.buttons) {
464
+ expect(validateAskResponse({input, kind: "choice", response: button.response})).toEqual([]);
465
+ }
466
+ }
467
+ });
468
+ });
469
+
470
+ describe("resolveButtonAnswer", () => {
471
+ const plan = validAskFixturesOf("choice").find(
472
+ (fixture) => fixture.name === "choice-plan-with-default"
473
+ );
474
+ if (!plan) {
475
+ throw new Error("valid/choice-plan-with-default is missing.");
476
+ }
477
+ const card = toSimpleCard({input: plan.input, kind: plan.kind, toolCallId: TOOL_CALL_ID});
478
+
479
+ it("returns the response stored on each button", () => {
480
+ for (const button of card.buttons) {
481
+ expect(resolveButtonAnswer({buttonId: button.id, card})).toEqual({
482
+ errors: [],
483
+ response: button.response,
484
+ });
485
+ }
486
+ });
487
+
488
+ it("returns UNKNOWN_BUTTON with the card's button ids for any other id", () => {
489
+ expect(resolveButtonAnswer({buttonId: "option:gold", card})).toEqual({
490
+ errors: [
491
+ {
492
+ code: "UNKNOWN_BUTTON",
493
+ fix: 'Send the id of one of the card\'s buttons: "option:team", "option:starter", "option:enterprise".',
494
+ message: 'Button "option:gold" is not on the pending ask\'s simple card.',
495
+ path: "buttonId",
496
+ },
497
+ ],
498
+ });
499
+ });
500
+
501
+ it("points to a full answer when the card has no buttons", () => {
502
+ expect(resolveButtonAnswer({buttonId: "skip", card: {...card, buttons: []}}).errors).toEqual([
503
+ {
504
+ code: "UNKNOWN_BUTTON",
505
+ fix: "The card has no buttons. Send a full askResponse instead.",
506
+ message: 'Button "skip" is not on the pending ask\'s simple card.',
507
+ path: "buttonId",
508
+ },
509
+ ]);
510
+ });
511
+ });
512
+
513
+ describe("toSimpleCard choice rules", () => {
514
+ const WEEKLY_OPTIONS = [
515
+ {id: "managers", label: "Weekly summary email for managers"},
516
+ {id: "engineers", label: "Weekly summary email for engineers"},
517
+ ];
518
+
519
+ it("shows no buttons when labels collide and the ask cannot be declined", () => {
520
+ expect(
521
+ toSimpleCard({
522
+ input: {allowDecline: false, options: WEEKLY_OPTIONS, prompt: "Pick one.", select: "one"},
523
+ kind: "choice",
524
+ toolCallId: "call_1",
525
+ })
526
+ ).toEqual({
527
+ buttons: [],
528
+ handoff: true,
529
+ kind: "choice",
530
+ text: "Pick one.",
531
+ toolCallId: "call_1",
532
+ });
533
+ });
534
+
535
+ it("shows only Skip when two other options collide, even though the default's label is unique", () => {
536
+ expect(
537
+ toSimpleCard({
538
+ input: {
539
+ default: ["team"],
540
+ options: [{id: "team", label: "Team"}, ...WEEKLY_OPTIONS],
541
+ prompt: "Pick one.",
542
+ select: "one",
543
+ },
544
+ kind: "choice",
545
+ toolCallId: "call_1",
546
+ })
547
+ ).toEqual({
548
+ buttons: [{id: "skip", label: "Skip", response: {action: "decline"}, style: "cancel"}],
549
+ handoff: true,
550
+ kind: "choice",
551
+ text: "Pick one.",
552
+ toolCallId: "call_1",
553
+ });
554
+ });
555
+
556
+ it("moves a default listed last to the front, keeps the rest in order, and ends with Skip", () => {
557
+ expect(
558
+ toSimpleCard({
559
+ input: {
560
+ default: ["archive"],
561
+ options: [
562
+ {id: "keep", label: "Keep them"},
563
+ {id: "archive", label: "Archive them"},
564
+ ],
565
+ prompt: "Pick one.",
566
+ select: "one",
567
+ },
568
+ kind: "choice",
569
+ toolCallId: "call_1",
570
+ }).buttons
571
+ ).toEqual([
572
+ {
573
+ id: "option:archive",
574
+ label: "Archive them",
575
+ response: {action: "accept", content: {selected: ["archive"]}},
576
+ style: "primary",
577
+ },
578
+ {
579
+ id: "option:keep",
580
+ label: "Keep them",
581
+ response: {action: "accept", content: {selected: ["keep"]}},
582
+ style: "default",
583
+ },
584
+ {id: "skip", label: "Skip", response: {action: "decline"}, style: "cancel"},
585
+ ]);
586
+ });
587
+
588
+ it("shows labels without spaces at either end, and hands off when that makes two alike", () => {
589
+ const card = (options: {id: string; label: string}[]) =>
590
+ toSimpleCard({
591
+ input: {options, prompt: "Pick one.", select: "one"},
592
+ kind: "choice",
593
+ toolCallId: "call_1",
594
+ });
595
+ expect(
596
+ card([
597
+ {id: "go", label: " Go "},
598
+ {id: "wait", label: "Wait"},
599
+ ]).buttons.map((button) => button.label)
600
+ ).toEqual(["Go", "Wait", "Skip"]);
601
+ expect(
602
+ card([
603
+ {id: "go", label: "Go"},
604
+ {id: "go_now", label: "Go "},
605
+ ])
606
+ ).toEqual({
607
+ buttons: [{id: "skip", label: "Skip", response: {action: "decline"}, style: "cancel"}],
608
+ handoff: true,
609
+ kind: "choice",
610
+ text: "Pick one.",
611
+ toolCallId: "call_1",
612
+ });
613
+ });
614
+ });
615
+
616
+ describe("simpleCardSchema", () => {
617
+ const skip = {id: "skip", label: "Skip", response: {action: "decline"}, style: "cancel"};
618
+ const card = {
619
+ buttons: [skip],
620
+ handoff: true,
621
+ kind: "choice",
622
+ text: "Pick one.",
623
+ toolCallId: "call_1",
624
+ };
625
+
626
+ it("accepts a minimal card", () => {
627
+ expect(simpleCardSchema.safeParse(card).success).toBe(true);
628
+ });
629
+
630
+ it("rejects more than three buttons", () => {
631
+ const buttons = ["a", "b", "c", "d"].map((id) => ({...skip, id}));
632
+ expect(simpleCardSchema.safeParse({...card, buttons}).success).toBe(false);
633
+ });
634
+
635
+ it("rejects duplicate button ids", () => {
636
+ const result = simpleCardSchema.safeParse({...card, buttons: [skip, skip]});
637
+ expect(result.success).toBe(false);
638
+ expect(result.error?.issues.map((issue) => issue.path)).toEqual([["buttons", 1, "id"]]);
639
+ });
640
+
641
+ it("rejects labels over 20 characters, text over 140, and titles over 40", () => {
642
+ expect(
643
+ simpleCardSchema.safeParse({...card, buttons: [{...skip, label: "x".repeat(21)}]}).success
644
+ ).toBe(false);
645
+ expect(simpleCardSchema.safeParse({...card, text: "x".repeat(141)}).success).toBe(false);
646
+ expect(simpleCardSchema.safeParse({...card, title: "x".repeat(41)}).success).toBe(false);
647
+ });
648
+
649
+ it("rejects a button whose response is not an answer envelope", () => {
650
+ expect(
651
+ simpleCardSchema.safeParse({...card, buttons: [{...skip, response: {action: "skip"}}]})
652
+ .success
653
+ ).toBe(false);
654
+ });
655
+ });
656
+
657
+ describe("toSimpleCard text cuts", () => {
658
+ const ask = (label: string) =>
659
+ toSimpleCard({
660
+ input: {
661
+ options: [
662
+ {id: "a", label},
663
+ {id: "b", label: "Other"},
664
+ ],
665
+ prompt: "Pick one.",
666
+ select: "one",
667
+ },
668
+ kind: "choice",
669
+ toolCallId: "call_1",
670
+ });
671
+
672
+ it("keeps a label of exactly 20 characters", () => {
673
+ expect(ask("Twenty characters!!!").buttons[0]?.label).toBe("Twenty characters!!!");
674
+ });
675
+
676
+ it("cuts at the space when the cut lands right before one", () => {
677
+ expect(ask("Nineteen characters and more").buttons[0]?.label).toBe("Nineteen characters…");
678
+ });
679
+
680
+ it("cuts a single long word without looking for a boundary", () => {
681
+ expect(ask("Supercalifragilisticexpialidocious").buttons[0]?.label).toBe(
682
+ "Supercalifragilisti…"
683
+ );
684
+ });
685
+ });
686
+
687
+ const CONFIRM_LABEL_SETS: {confirmLabel?: string; denyLabel?: string}[] = [
688
+ {},
689
+ {confirmLabel: "Delete 14 todos", denyLabel: "Keep them"},
690
+ {confirmLabel: "Twenty characters!!!", denyLabel: "Exactly twenty chars"},
691
+ {confirmLabel: "Ship it 🚀", denyLabel: "Wait ⏳"},
692
+ {confirmLabel: "Send"},
693
+ {denyLabel: "Not now"},
694
+ ];
695
+
696
+ /** Every combination of labels, destructive, allowDecline, title, and prompt length. */
697
+ const generatedConfirmAsks = (): ConfirmAskInput[] =>
698
+ CONFIRM_LABEL_SETS.flatMap((labels) =>
699
+ [undefined, true, false].flatMap((destructive) =>
700
+ [undefined, true, false].flatMap((allowDecline) =>
701
+ [undefined, "t".repeat(80)].flatMap((title) =>
702
+ ["Go ahead?", LONG_PROMPT].map(
703
+ (prompt): ConfirmAskInput => ({
704
+ prompt,
705
+ ...labels,
706
+ ...(allowDecline === undefined ? {} : {allowDecline}),
707
+ ...(destructive === undefined ? {} : {destructive}),
708
+ ...(title === undefined ? {} : {title}),
709
+ })
710
+ )
711
+ )
712
+ )
713
+ )
714
+ );
715
+
716
+ describe("toSimpleCard confirm rules", () => {
717
+ const asks = [
718
+ ...generatedConfirmAsks(),
719
+ ...validAskFixturesOf("confirm").map((fixture) => fixture.input),
720
+ ];
721
+ const cards = asks.map((input) => ({
722
+ card: toSimpleCard({input, kind: "confirm", toolCallId: TOOL_CALL_ID}),
723
+ input,
724
+ }));
725
+
726
+ it("covers generated asks and every confirm fixture", () => {
727
+ expect(generatedConfirmAsks().length).toBeGreaterThan(100);
728
+ expect(validAskFixturesOf("confirm").length).toBeGreaterThanOrEqual(4);
729
+ });
730
+
731
+ it("only generates asks that pass the full and compact rules", () => {
732
+ for (const input of asks) {
733
+ expect(validateAskInput({input, kind: "confirm"})).toEqual([]);
734
+ expect(validateAskInput({input, kind: "confirm", surface: "compact"})).toEqual([]);
735
+ }
736
+ });
737
+
738
+ it("derives cards that pass simpleCardSchema and never hand off", () => {
739
+ for (const {card} of cards) {
740
+ expect(simpleCardSchema.safeParse(card).success).toBe(true);
741
+ expect(card.handoff).toBe(false);
742
+ }
743
+ });
744
+
745
+ it("puts approve first and deny last, with no Skip even when the ask allows declining", () => {
746
+ for (const {card, input} of cards) {
747
+ expect(card.buttons.map(({id, response, style}) => ({id, response, style}))).toEqual(
748
+ confirmButtons(input)
749
+ );
750
+ }
751
+ });
752
+
753
+ it("marks approve destructive exactly when the ask is, so Double Tap cannot approve it", () => {
754
+ for (const {card, input} of cards) {
755
+ const [approve] = card.buttons;
756
+ expect(approve?.style === "destructive").toBe(input.destructive === true);
757
+ expect(card.buttons.filter((button) => button.style !== "destructive").at(0)?.id).toBe(
758
+ input.destructive === true ? "deny" : "approve"
759
+ );
760
+ }
761
+ });
762
+
763
+ it("shows the labels uncut, defaulting to Confirm and Cancel", () => {
764
+ for (const {card, input} of cards) {
765
+ expect(card.buttons.map((button) => button.label)).toEqual([
766
+ input.confirmLabel ?? "Confirm",
767
+ input.denyLabel ?? "Cancel",
768
+ ]);
769
+ }
770
+ });
771
+
772
+ it("only has buttons whose response is a valid answer to the ask", () => {
773
+ for (const {card, input} of cards) {
774
+ for (const button of card.buttons) {
775
+ expect(validateAskResponse({input, kind: "confirm", response: button.response})).toEqual(
776
+ []
777
+ );
778
+ }
779
+ }
780
+ });
781
+
782
+ it("shows the labels without spaces at either end", () => {
783
+ expect(
784
+ toSimpleCard({
785
+ input: {confirmLabel: " Send it ", denyLabel: "Wait ", prompt: "Send?"},
786
+ kind: "confirm",
787
+ toolCallId: TOOL_CALL_ID,
788
+ }).buttons.map((button) => button.label)
789
+ ).toEqual(["Send it", "Wait"]);
790
+ });
791
+ });
792
+
793
+ const MARKDOWN_CAP = ASK_LIMITS.markdown.maxLength;
794
+
795
+ /** Drafts at, above, and below each length rule, with the bounds they are measured against. */
796
+ const MARKDOWN_LENGTH_CASES: {fits: boolean; input: Omit<MarkdownAskInput, "prompt">}[] = [
797
+ {fits: true, input: {}},
798
+ {fits: true, input: {initial: "x".repeat(MARKDOWN_CAP)}},
799
+ {fits: true, input: {initial: "x".repeat(10), maxLength: 10}},
800
+ {fits: false, input: {initial: "x".repeat(11), maxLength: 10}},
801
+ {fits: true, input: {initial: "😀".repeat(5), maxLength: 10}},
802
+ {fits: false, input: {initial: "😀".repeat(6), maxLength: 10}},
803
+ {fits: true, input: {initial: " twelve chars ", minLength: 12}},
804
+ {fits: false, input: {initial: " eleven chr ", minLength: 12}},
805
+ {fits: false, input: {minLength: 1}},
806
+ {fits: true, input: {initial: "Exactly", maxLength: 7, minLength: 7}},
807
+ ];
808
+
809
+ describe("toSimpleCard markdown rules", () => {
810
+ const asks = MARKDOWN_LENGTH_CASES.flatMap(({fits, input}) =>
811
+ [undefined, true, false].flatMap((allowDecline) =>
812
+ [undefined, "t".repeat(80)].map((title) => ({
813
+ fits,
814
+ input: {
815
+ prompt: "Edit the draft, then send it back.",
816
+ ...input,
817
+ ...(allowDecline === undefined ? {} : {allowDecline}),
818
+ ...(title === undefined ? {} : {title}),
819
+ } satisfies MarkdownAskInput,
820
+ }))
821
+ )
822
+ );
823
+ const cards = asks.map(({fits, input}) => ({
824
+ card: toSimpleCard({input, kind: "markdown", toolCallId: TOOL_CALL_ID}),
825
+ fits,
826
+ input,
827
+ }));
828
+
829
+ it("only generates valid asks", () => {
830
+ for (const {input} of asks) {
831
+ expect(validateAskInput({input, kind: "markdown"})).toEqual([]);
832
+ }
833
+ });
834
+
835
+ it("always hands off, because a draft cannot be edited on a small screen", () => {
836
+ for (const {card} of cards) {
837
+ expect(simpleCardSchema.safeParse(card).success).toBe(true);
838
+ expect(card.handoff).toBe(true);
839
+ }
840
+ });
841
+
842
+ it("offers Approve draft exactly when the draft meets the length rules, then Cancel", () => {
843
+ for (const {card, fits, input} of cards) {
844
+ expect(card.buttons.map(({id, response, style}) => ({id, response, style}))).toEqual(
845
+ markdownButtons(input, fits)
846
+ );
847
+ }
848
+ });
849
+
850
+ it("labels the buttons Approve draft and Cancel", () => {
851
+ const card = toSimpleCard({
852
+ input: {initial: "Hello", prompt: "Edit it."},
853
+ kind: "markdown",
854
+ toolCallId: TOOL_CALL_ID,
855
+ });
856
+ expect(card.buttons.map((button) => button.label)).toEqual(["Approve draft", "Cancel"]);
857
+ });
858
+
859
+ it("only has buttons whose response is a valid answer to the ask", () => {
860
+ for (const {card, input} of cards) {
861
+ for (const button of card.buttons) {
862
+ expect(validateAskResponse({input, kind: "markdown", response: button.response})).toEqual(
863
+ []
864
+ );
865
+ }
866
+ }
867
+ });
868
+ });
869
+
870
+ describe("toSimpleCard form rules", () => {
871
+ const OPTIONS = [
872
+ {id: "us", label: "US"},
873
+ {id: "eu", label: "EU"},
874
+ ];
875
+ const CASES: {fields: FormAskInput["fields"]; values: Record<string, unknown> | null}[] = [
876
+ {fields: [{id: "name", label: "Name", type: "text"}], values: null},
877
+ {fields: [{id: "name", label: "Name", required: true, type: "text"}], values: null},
878
+ {
879
+ fields: [{default: "Acme", id: "name", label: "Name", required: true, type: "text"}],
880
+ values: {name: "Acme"},
881
+ },
882
+ {
883
+ fields: [
884
+ {default: "Acme", id: "name", label: "Name", required: true, type: "text"},
885
+ {id: "seats", label: "Seats", required: true, type: "number"},
886
+ ],
887
+ values: null,
888
+ },
889
+ {
890
+ fields: [
891
+ {default: "Acme", id: "name", label: "Name", required: true, type: "text"},
892
+ {id: "seats", label: "Seats", type: "number"},
893
+ {default: false, id: "notify", label: "Notify", type: "boolean"},
894
+ ],
895
+ values: {name: "Acme", notify: false},
896
+ },
897
+ {
898
+ fields: [
899
+ {default: 0, id: "count", label: "Count", min: 0, required: true, type: "number"},
900
+ {default: "eu", id: "region", label: "Region", options: OPTIONS, type: "select"},
901
+ {default: ["us"], id: "regions", label: "Regions", options: OPTIONS, type: "multiselect"},
902
+ ],
903
+ values: {count: 0, region: "eu", regions: ["us"]},
904
+ },
905
+ {fields: [{id: "notify", label: "Notify", required: true, type: "boolean"}], values: null},
906
+ ];
907
+ const asks = CASES.flatMap(({fields, values}) =>
908
+ [undefined, true, false].map((allowDecline) => ({
909
+ input: {
910
+ fields,
911
+ prompt: "A few details.",
912
+ ...(allowDecline === undefined ? {} : {allowDecline}),
913
+ } satisfies FormAskInput,
914
+ values,
915
+ }))
916
+ );
917
+ const cards = asks.map(({input, values}) => ({
918
+ card: toSimpleCard({input, kind: "form", toolCallId: TOOL_CALL_ID}),
919
+ input,
920
+ values,
921
+ }));
922
+
923
+ it("only generates valid asks", () => {
924
+ for (const {input} of asks) {
925
+ expect(validateAskInput({input, kind: "form"})).toEqual([]);
926
+ }
927
+ });
928
+
929
+ it("always hands off, because fields cannot be filled in on a small screen", () => {
930
+ for (const {card} of cards) {
931
+ expect(simpleCardSchema.safeParse(card).success).toBe(true);
932
+ expect(card.handoff).toBe(true);
933
+ }
934
+ });
935
+
936
+ it("offers Submit defaults exactly when there is a default and every required field has one", () => {
937
+ for (const {card, input, values} of cards) {
938
+ expect(card.buttons.map(({id, response, style}) => ({id, response, style}))).toEqual(
939
+ formButtons(input, values)
940
+ );
941
+ }
942
+ });
943
+
944
+ it("labels the buttons Submit defaults and Cancel", () => {
945
+ const card = toSimpleCard({
946
+ input: {fields: [{default: "Acme", id: "name", label: "Name", type: "text"}], prompt: "Go."},
947
+ kind: "form",
948
+ toolCallId: TOOL_CALL_ID,
949
+ });
950
+ expect(card.buttons.map((button) => button.label)).toEqual(["Submit defaults", "Cancel"]);
951
+ });
952
+
953
+ it("only has buttons whose response is a valid answer to the ask", () => {
954
+ for (const {card, input} of cards) {
955
+ for (const button of card.buttons) {
956
+ expect(validateAskResponse({input, kind: "form", response: button.response})).toEqual([]);
957
+ }
958
+ }
959
+ });
960
+ });
961
+
962
+ describe("toSimpleCard files rules", () => {
963
+ const asks: FilesAskInput[] = [undefined, true, false].flatMap((allowDecline) =>
964
+ [{accept: ["image"]}, {accept: ["pdf", "csv"], maxFiles: 3, minFiles: 2}].map((fields) => ({
965
+ ...fields,
966
+ accept: [...fields.accept] as FilesAskInput["accept"],
967
+ prompt: "Upload the receipt.",
968
+ ...(allowDecline === undefined ? {} : {allowDecline}),
969
+ }))
970
+ );
971
+ const cards = asks.map((input) => ({
972
+ card: toSimpleCard({input, kind: "files", toolCallId: TOOL_CALL_ID}),
973
+ input,
974
+ }));
975
+
976
+ it("only generates valid asks", () => {
977
+ for (const input of asks) {
978
+ expect(validateAskInput({input, kind: "files"})).toEqual([]);
979
+ }
980
+ });
981
+
982
+ it("always hands off, because files cannot be picked on a small screen", () => {
983
+ for (const {card} of cards) {
984
+ expect(simpleCardSchema.safeParse(card).success).toBe(true);
985
+ expect(card.handoff).toBe(true);
986
+ }
987
+ });
988
+
989
+ it("offers only Skip, and only when the ask allows declining", () => {
990
+ for (const {card, input} of cards) {
991
+ expect(card.buttons).toEqual(
992
+ input.allowDecline === false
993
+ ? []
994
+ : [{id: "skip", label: "Skip", response: {action: "decline"}, style: "cancel"}]
995
+ );
996
+ }
997
+ });
998
+ });