@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,130 @@
1
+ import {z} from "zod";
2
+ import {
3
+ ASK_KINDS,
4
+ ASK_SURFACES,
5
+ askAcceptResponseSchema,
6
+ askCancelResponseSchema,
7
+ askDeclineResponseSchema,
8
+ } from "./schema";
9
+ import {simpleCardSchema} from "./simpleCard";
10
+
11
+ const TURN_SHAPES = "prompt, askResponse, or toolCallId with buttonId";
12
+
13
+ export const askSurfaceSchema = z
14
+ .enum(ASK_SURFACES)
15
+ .describe(
16
+ 'Where the user answers. "compact" is a watch or another small screen: the agent asks only questions whose buttons show every option, and keeps replies to two short sentences. Defaults to "full".'
17
+ );
18
+
19
+ const toolCallIdSchema = z.string().min(1).describe("The pending ask's toolCallId.");
20
+
21
+ /** An answer to a pending ask: the answer envelope plus the ask's `toolCallId`. */
22
+ export const askResponseWithToolCallIdSchema = z.discriminatedUnion("action", [
23
+ askAcceptResponseSchema.extend({toolCallId: toolCallIdSchema}),
24
+ askDeclineResponseSchema.extend({toolCallId: toolCallIdSchema}),
25
+ askCancelResponseSchema.extend({toolCallId: toolCallIdSchema}),
26
+ ]);
27
+
28
+ export type AskResponseWithToolCallId = z.infer<typeof askResponseWithToolCallIdSchema>;
29
+
30
+ type TurnRequestFields = Partial<
31
+ Record<"askResponse" | "buttonId" | "prompt" | "toolCallId", unknown>
32
+ >;
33
+
34
+ /** Puts every issue on a field, because clients show request errors next to the field they name. */
35
+ const checkTurnRequest = (body: TurnRequestFields, ctx: z.RefinementCtx): void => {
36
+ const sent = (["askResponse", "buttonId", "prompt", "toolCallId"] as const).filter(
37
+ (key) => body[key] !== undefined
38
+ );
39
+ const hasButton = sent.includes("buttonId") || sent.includes("toolCallId");
40
+ const shapeCount =
41
+ Number(sent.includes("prompt")) + Number(sent.includes("askResponse")) + Number(hasButton);
42
+ if (shapeCount === 0) {
43
+ ctx.addIssue({code: "custom", message: `Send one of ${TURN_SHAPES}.`, path: ["prompt"]});
44
+ return;
45
+ }
46
+ if (shapeCount > 1) {
47
+ for (const key of sent) {
48
+ ctx.addIssue({code: "custom", message: `Send only one of ${TURN_SHAPES}.`, path: [key]});
49
+ }
50
+ return;
51
+ }
52
+ if (hasButton && body.toolCallId === undefined) {
53
+ ctx.addIssue({code: "custom", message: "Send toolCallId with buttonId.", path: ["toolCallId"]});
54
+ }
55
+ if (hasButton && body.buttonId === undefined) {
56
+ ctx.addIssue({code: "custom", message: "Send buttonId with toolCallId.", path: ["buttonId"]});
57
+ }
58
+ };
59
+
60
+ /** The body of `POST /gpt/histories/:id/turn`. */
61
+ export const turnRequestSchema = z
62
+ .object({
63
+ askResponse: askResponseWithToolCallIdSchema
64
+ .optional()
65
+ .describe("A full answer to the pending ask, as a client that renders the ask sends it."),
66
+ buttonId: z
67
+ .string()
68
+ .min(1)
69
+ .optional()
70
+ .describe("The id of the simple card button the user pressed. Send it with toolCallId."),
71
+ prompt: z
72
+ .string()
73
+ .min(1)
74
+ .optional()
75
+ .describe("A new message from the user. It cancels the pending ask, if there is one."),
76
+ surface: askSurfaceSchema.optional(),
77
+ toolCallId: toolCallIdSchema.optional(),
78
+ })
79
+ .strict()
80
+ .superRefine(checkTurnRequest)
81
+ .describe(`Send exactly one of ${TURN_SHAPES}, plus an optional surface.`);
82
+
83
+ export type TurnRequest = z.infer<typeof turnRequestSchema>;
84
+
85
+ export const pendingAskSummarySchema = z
86
+ .object({
87
+ kind: z.enum(ASK_KINDS),
88
+ simple: simpleCardSchema,
89
+ toolCallId: z.string().min(1),
90
+ })
91
+ .strict()
92
+ .describe(
93
+ "The ask the turn paused on. Answer it with its toolCallId and the id of one of simple.buttons."
94
+ );
95
+
96
+ export type PendingAskSummary = z.infer<typeof pendingAskSummarySchema>;
97
+
98
+ /** What `POST /gpt/histories/:id/turn` returns in `data` once the turn finishes. */
99
+ export const turnResultSchema = z
100
+ .object({
101
+ error: z
102
+ .string()
103
+ .optional()
104
+ .describe(
105
+ "Set when the turn failed after it started. text holds what the agent said before the error."
106
+ ),
107
+ historyId: z.string().min(1).describe("The conversation's id."),
108
+ pendingAsk: pendingAskSummarySchema.optional(),
109
+ text: z.string().describe("The agent's reply, or an empty string when it only asked."),
110
+ title: z.string().optional().describe("The conversation's title, once it has one."),
111
+ })
112
+ .strict();
113
+
114
+ export type TurnResult = z.infer<typeof turnResultSchema>;
115
+
116
+ export const pendingAskListItemSchema = z
117
+ .object({
118
+ created: z.iso.datetime().describe("When the agent asked, as an ISO 8601 UTC timestamp."),
119
+ historyId: z.string().min(1).describe("The conversation the ask belongs to."),
120
+ kind: z.enum(ASK_KINDS),
121
+ simple: simpleCardSchema,
122
+ title: z.string().optional().describe("The conversation's title, once it has one."),
123
+ toolCallId: z.string().min(1),
124
+ })
125
+ .strict();
126
+
127
+ export type PendingAskListItem = z.infer<typeof pendingAskListItemSchema>;
128
+
129
+ /** What `GET /gpt/histories/pendingAsks` returns in `data`: the caller's pending asks, newest first. */
130
+ export const pendingAskListSchema = z.array(pendingAskListItemSchema);
@@ -0,0 +1,171 @@
1
+ import {describe, expect, it} from "bun:test";
2
+ import {readdirSync, readFileSync} from "node:fs";
3
+ import {join} from "node:path";
4
+ import {z} from "zod";
5
+ import {validAskFixtures} from "../tests/askFixtures";
6
+ import {pendingAskListSchema, turnRequestSchema, turnResultSchema} from "./headless";
7
+ import {askJsonSchemas} from "./jsonSchema";
8
+ import {simpleCardSchema, toSimpleCard} from "./simpleCard";
9
+
10
+ const SCHEMAS_DIR = join(import.meta.dir, "..", "..", "schemas");
11
+
12
+ const documents = askJsonSchemas();
13
+
14
+ /** A Zod schema rebuilt from a committed document, standing in for a client's JSON Schema validator. */
15
+ const fromDocument = (fileName: string): z.ZodType => {
16
+ const document = documents[fileName];
17
+ if (!document) {
18
+ throw new Error(`No JSON Schema document named ${fileName}.`);
19
+ }
20
+ return z.fromJSONSchema(document as Parameters<typeof z.fromJSONSchema>[0]);
21
+ };
22
+
23
+ const CARDS = validAskFixtures().map((fixture) =>
24
+ toSimpleCard({input: fixture.input, kind: fixture.kind, toolCallId: "call_fixture"})
25
+ );
26
+
27
+ const [CARD] = CARDS;
28
+ if (!CARD) {
29
+ throw new Error("The valid fixtures are missing.");
30
+ }
31
+
32
+ describe("askJsonSchemas", () => {
33
+ it("matches the files committed in blocks/schemas (run `bun run schemas` in blocks/ to update them)", () => {
34
+ const committed = Object.fromEntries(
35
+ readdirSync(SCHEMAS_DIR)
36
+ .sort()
37
+ .map((fileName) => [
38
+ fileName,
39
+ JSON.parse(readFileSync(join(SCHEMAS_DIR, fileName), "utf8")) as unknown,
40
+ ])
41
+ );
42
+ expect(committed).toEqual(documents);
43
+ });
44
+
45
+ it("writes draft-07 documents with a title and a description", () => {
46
+ expect(Object.keys(documents).sort()).toEqual([
47
+ "pendingAsksResponse.schema.json",
48
+ "simpleCard.schema.json",
49
+ "turnRequest.schema.json",
50
+ "turnResponse.schema.json",
51
+ ]);
52
+ for (const document of Object.values(documents)) {
53
+ expect(document.$schema).toBe("http://json-schema.org/draft-07/schema#");
54
+ expect(typeof document.title).toBe("string");
55
+ expect(typeof document.description).toBe("string");
56
+ }
57
+ });
58
+
59
+ it("names nested types so generated code gets readable names", () => {
60
+ const text = JSON.stringify(documents["turnResponse.schema.json"]);
61
+ for (const title of ["TurnResult", "PendingAskSummary", "SimpleCard", "SimpleCardButton"]) {
62
+ expect(text).toContain(`"title":"${title}"`);
63
+ }
64
+ });
65
+ });
66
+
67
+ describe("simpleCard.schema.json", () => {
68
+ const fromJson = fromDocument("simpleCard.schema.json");
69
+
70
+ it("accepts the card of every valid fixture, as simpleCardSchema does", () => {
71
+ for (const card of CARDS) {
72
+ expect(simpleCardSchema.safeParse(card).success).toBe(true);
73
+ expect(fromJson.safeParse(card).success).toBe(true);
74
+ }
75
+ });
76
+
77
+ it("rejects the cards simpleCardSchema rejects for their shape and limits", () => {
78
+ const button = {id: "skip", label: "Skip", response: {action: "decline"}, style: "cancel"};
79
+ const invalidCards = [
80
+ {...CARD, buttons: ["a", "b", "c", "d"].map((id) => ({...button, id}))},
81
+ {...CARD, buttons: [{...button, label: "x".repeat(21)}]},
82
+ {...CARD, buttons: [{...button, style: "danger"}]},
83
+ {...CARD, buttons: [{...button, response: {action: "skip"}}]},
84
+ {...CARD, text: "x".repeat(141)},
85
+ {...CARD, text: ""},
86
+ {...CARD, title: "x".repeat(41)},
87
+ {...CARD, kind: "signature"},
88
+ {...CARD, color: "red"},
89
+ {buttons: CARD.buttons, handoff: false, kind: "choice", toolCallId: "call_1"},
90
+ ];
91
+ for (const card of invalidCards) {
92
+ expect(simpleCardSchema.safeParse(card).success).toBe(false);
93
+ expect(fromJson.safeParse(card).success).toBe(false);
94
+ }
95
+ });
96
+ });
97
+
98
+ describe("turnRequest.schema.json", () => {
99
+ const fromJson = fromDocument("turnRequest.schema.json");
100
+
101
+ it("accepts every request shape turnRequestSchema accepts", () => {
102
+ const bodies = [
103
+ {prompt: "Help me pick a plan", surface: "compact"},
104
+ {askResponse: {action: "accept", content: {selected: ["team"]}, toolCallId: "call_1"}},
105
+ {askResponse: {action: "decline", toolCallId: "call_1"}},
106
+ {buttonId: "option:team", surface: "full", toolCallId: "call_1"},
107
+ ];
108
+ for (const body of bodies) {
109
+ expect(turnRequestSchema.safeParse(body).success).toBe(true);
110
+ expect(fromJson.safeParse(body).success).toBe(true);
111
+ }
112
+ });
113
+
114
+ it("rejects unknown fields, unknown surfaces, and answers without toolCallId", () => {
115
+ const bodies = [
116
+ {historyId: "h1", prompt: "Hi"},
117
+ {prompt: "Hi", surface: "watch"},
118
+ {askResponse: {action: "decline"}},
119
+ {askResponse: {action: "skip", toolCallId: "call_1"}},
120
+ ];
121
+ for (const body of bodies) {
122
+ expect(turnRequestSchema.safeParse(body).success).toBe(false);
123
+ expect(fromJson.safeParse(body).success).toBe(false);
124
+ }
125
+ });
126
+
127
+ it("leaves the one-shape rule to the server", () => {
128
+ expect(fromJson.safeParse({}).success).toBe(true);
129
+ expect(turnRequestSchema.safeParse({}).success).toBe(false);
130
+ });
131
+ });
132
+
133
+ describe("turnResponse.schema.json and pendingAsksResponse.schema.json", () => {
134
+ const turnResponse = fromDocument("turnResponse.schema.json");
135
+ const pendingAsksResponse = fromDocument("pendingAsksResponse.schema.json");
136
+ const result = {
137
+ historyId: "h1",
138
+ pendingAsk: {kind: "choice", simple: CARD, toolCallId: CARD.toolCallId},
139
+ text: "",
140
+ title: "Plans",
141
+ };
142
+ const item = {
143
+ created: "2026-09-27T12:00:00.000Z",
144
+ historyId: "h1",
145
+ kind: "choice",
146
+ simple: CARD,
147
+ toolCallId: CARD.toolCallId,
148
+ };
149
+
150
+ const requestId = "3f0c9a52-7d1e-4b8a-9c65-2a4e1b7d9f30";
151
+
152
+ it("accept the bodies the endpoints send, with or without the requestId TerrenoApp adds", () => {
153
+ expect(turnResultSchema.safeParse(result).success).toBe(true);
154
+ expect(pendingAskListSchema.safeParse([item]).success).toBe(true);
155
+ for (const envelope of [{}, {requestId}]) {
156
+ expect(turnResponse.safeParse({...envelope, data: result}).success).toBe(true);
157
+ expect(pendingAsksResponse.safeParse({...envelope, data: [item]}).success).toBe(true);
158
+ expect(pendingAsksResponse.safeParse({...envelope, data: []}).success).toBe(true);
159
+ }
160
+ });
161
+
162
+ it("reject bodies missing required fields, with a bad timestamp, or with an unknown field", () => {
163
+ expect(turnResponse.safeParse({data: {text: "Done."}}).success).toBe(false);
164
+ expect(turnResponse.safeParse(result).success).toBe(false);
165
+ expect(turnResponse.safeParse({data: result, requestID: requestId}).success).toBe(false);
166
+ expect(pendingAsksResponse.safeParse({data: [{...item, created: "yesterday"}]}).success).toBe(
167
+ false
168
+ );
169
+ expect(pendingAsksResponse.safeParse({data: [], requestId: 7}).success).toBe(false);
170
+ });
171
+ });
@@ -0,0 +1,89 @@
1
+ import {z} from "zod";
2
+ import {
3
+ askResponseWithToolCallIdSchema,
4
+ pendingAskListItemSchema,
5
+ pendingAskListSchema,
6
+ pendingAskSummarySchema,
7
+ turnRequestSchema,
8
+ turnResultSchema,
9
+ } from "./headless";
10
+ import {askResponseSchema} from "./schema";
11
+ import {simpleCardButtonSchema, simpleCardSchema} from "./simpleCard";
12
+
13
+ /** Titles for nested schemas, so types generated from the documents get readable names. */
14
+ const NESTED_TITLES = new Map<z.core.$ZodType, string>([
15
+ [askResponseSchema, "AskResponse"],
16
+ [askResponseWithToolCallIdSchema, "AskAnswer"],
17
+ [pendingAskListItemSchema, "PendingAsk"],
18
+ [pendingAskSummarySchema, "PendingAskSummary"],
19
+ [simpleCardButtonSchema, "SimpleCardButton"],
20
+ [simpleCardSchema, "SimpleCard"],
21
+ [turnResultSchema, "TurnResult"],
22
+ ]);
23
+
24
+ interface JsonSchemaDocument {
25
+ description: string;
26
+ schema: z.ZodType;
27
+ title: string;
28
+ }
29
+
30
+ const requestIdSchema = z
31
+ .string()
32
+ .optional()
33
+ .describe(
34
+ "The request's id. Apps built with TerrenoApp add it to every JSON response; quote it when reporting a problem."
35
+ );
36
+
37
+ const DOCUMENTS: Record<string, JsonSchemaDocument> = {
38
+ "pendingAsksResponse.schema.json": {
39
+ description:
40
+ "The response body of GET /gpt/histories/pendingAsks: the caller's pending asks, newest first.",
41
+ schema: z.object({data: pendingAskListSchema, requestId: requestIdSchema}).strict(),
42
+ title: "PendingAsksResponse",
43
+ },
44
+ "simpleCard.schema.json": {
45
+ description:
46
+ "The small-screen form of an ask: short text and at most three buttons, each carrying the exact answer it sends. Button ids are unique within a card, which JSON Schema cannot express.",
47
+ schema: simpleCardSchema,
48
+ title: "SimpleCard",
49
+ },
50
+ "turnRequest.schema.json": {
51
+ description:
52
+ "The request body of POST /gpt/histories/{id}/turn. Send exactly one of prompt, askResponse, or toolCallId with buttonId, plus an optional surface. JSON Schema cannot express that rule; the server enforces it.",
53
+ schema: turnRequestSchema,
54
+ title: "TurnRequest",
55
+ },
56
+ "turnResponse.schema.json": {
57
+ description: "The response body of POST /gpt/histories/{id}/turn, sent once the turn finishes.",
58
+ schema: z.object({data: turnResultSchema, requestId: requestIdSchema}).strict(),
59
+ title: "TurnResponse",
60
+ },
61
+ };
62
+
63
+ const toDocument = ({description, schema, title}: JsonSchemaDocument): Record<string, unknown> => {
64
+ const {
65
+ $schema,
66
+ description: _schemaDescription,
67
+ title: _schemaTitle,
68
+ ...rest
69
+ } = z.toJSONSchema(schema, {
70
+ override: ({jsonSchema, zodSchema}) => {
71
+ const nestedTitle = NESTED_TITLES.get(zodSchema);
72
+ if (nestedTitle) {
73
+ jsonSchema.title = nestedTitle;
74
+ }
75
+ },
76
+ target: "draft-7",
77
+ });
78
+ return {$schema, description, title, ...rest};
79
+ };
80
+
81
+ /**
82
+ * JSON Schema (draft-07) documents for native clients, such as a watch app generating Swift
83
+ * `Codable` types: the simple card and the bodies of the two headless endpoints. Keyed by the file
84
+ * name they are committed under in `blocks/schemas/`.
85
+ */
86
+ export const askJsonSchemas = (): Record<string, Record<string, unknown>> =>
87
+ Object.fromEntries(
88
+ Object.entries(DOCUMENTS).map(([fileName, document]) => [fileName, toDocument(document)])
89
+ );
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Hard limits for asks. The schemas, the prompt section, the docs, and the tests all read these
3
+ * values, so change them here only.
4
+ */
5
+ export const ASK_LIMITS = {
6
+ cancelReasonMaxLength: 200,
7
+ choice: {
8
+ optionDescriptionMaxLength: 280,
9
+ optionIdMaxLength: 64,
10
+ /** Must allow exactly `optionIdMaxLength` characters. */
11
+ optionIdPattern: /^[a-z0-9][a-z0-9_-]{0,63}$/,
12
+ optionLabelMaxLength: 120,
13
+ optionsMax: 50,
14
+ optionsMin: 2,
15
+ otherMaxLength: 500,
16
+ },
17
+ confirm: {
18
+ /** Every confirm label fits a simple card button uncut. */
19
+ labelMaxLength: 20,
20
+ },
21
+ files: {
22
+ /** The per-file cap when the host sets no `maxFileSizeBytes`, matching `/files/upload`. */
23
+ defaultMaxFileSizeBytes: 10 * 1024 * 1024,
24
+ filenameMaxLength: 255,
25
+ maxFiles: 10,
26
+ minFiles: 1,
27
+ /** Text, CSV, and JSON files reach the model cut to this many bytes, with a note. */
28
+ textMaxBytes: 100_000,
29
+ },
30
+ form: {
31
+ fieldsMax: 8,
32
+ fieldsMin: 1,
33
+ helperTextMaxLength: 280,
34
+ labelMaxLength: 120,
35
+ /** The fewest and most digits in a `phone` value. */
36
+ phoneDigitsMax: 15,
37
+ phoneDigitsMin: 7,
38
+ /** The cap on a `textarea` value and on a `textarea` field's `maxLength`. */
39
+ textareaMaxLength: 10_000,
40
+ /** The cap on a `text`, `email`, `url`, or `phone` value, and on a `text` field's `maxLength`. */
41
+ textMaxLength: 2000,
42
+ },
43
+ markdown: {
44
+ /** The cap on `initial`, on `maxLength`, and on every answer. */
45
+ maxLength: 20_000,
46
+ placeholderMaxLength: 120,
47
+ },
48
+ pendingAsksPerHistory: 1,
49
+ promptMaxLength: 500,
50
+ simpleCard: {
51
+ buttonLabelMaxLength: 20,
52
+ buttonsMax: 3,
53
+ textMaxLength: 140,
54
+ titleMaxLength: 40,
55
+ },
56
+ submitLabelMaxLength: 24,
57
+ titleMaxLength: 80,
58
+ } as const;
@@ -0,0 +1,200 @@
1
+ import {describe, expect, it} from "bun:test";
2
+ import {askPromptSection} from "./prompt";
3
+
4
+ const CHOICE_SECTION = `Ask tools you can call: ask_choice.
5
+
6
+ Rules for every ask:
7
+ - prompt: required. Plain text with no markdown and no links, 1-500 characters.
8
+ - title: optional, at most 80 characters.
9
+ - Each ask kind below lists its other fields, including whether the user can skip it.
10
+
11
+ ask_choice: the user picks one or more options from a list you provide.
12
+ - select: "one" for exactly one option, or "many" to let the user pick several.
13
+ - options: 2-50 items, each {id, label, description?}.
14
+ - id: 1-64 lowercase letters, digits, "_", or "-", starting with a letter or digit. Unique within the ask.
15
+ - label: at most 120 characters. description: optional, at most 280 characters.
16
+ - default: optional list of option ids to preselect, each listed once. With "one", at most one id; with "many", at most maxSelected ids.
17
+ - minSelected, maxSelected: optional whole numbers, "many" only. The user picks from minSelected (default 1, at least 0) to maxSelected (default: every choice) choices.
18
+ - allowOther: optional, "many" only. true adds a text field where the user types an answer of their own, up to 500 characters. It counts as one choice. otherLabel: optional label for that field, at most 120 characters. For one option or Other, use "many" with maxSelected 1.
19
+ - submitLabel: optional label for the submit button, at most 24 characters.
20
+ - allowDecline: optional, default true (the user sees Skip). Set it to false only when you cannot continue without an answer.
21
+ - Prefer select "one" with at most 3 options with labels of 20 characters or fewer; small screens show those as buttons.
22
+ - An accepted answer looks like {"action": "accept", "content": {"selected": ["<id>"]}}.
23
+ - With Other, it looks like {"action": "accept", "content": {"selected": ["<id>"], "other": "<text the user typed>"}}.`;
24
+
25
+ const COMPACT_CHOICE_SECTION = `Ask tools you can call: ask_choice.
26
+
27
+ Rules for every ask:
28
+ - prompt: required. Plain text with no markdown and no links, 1-500 characters.
29
+ - title: optional, at most 80 characters.
30
+ - Each ask kind below lists its other fields, including whether the user can skip it.
31
+ - The screen is small: it shows only the first 140 characters of prompt and the first 40 characters of title, so keep both within those lengths.
32
+
33
+ ask_choice: the user picks one option by tapping its button.
34
+ - select: always "one".
35
+ - options: 2-3 items, each {id, label, description?}.
36
+ - id: 1-64 lowercase letters, digits, "_", or "-", starting with a letter or digit. Unique within the ask.
37
+ - label: at most 20 characters, counting each emoji as 2 or more, and no two options share a label.
38
+ - description: optional, at most 280 characters. The buttons show only labels, so put what the user needs to decide in prompt and the labels.
39
+ - default: optional list with at most one option id to preselect.
40
+ - allowDecline: optional, default true (the user sees Skip). Set it to false only when you cannot continue without an answer.
41
+ - An accepted answer looks like {"action": "accept", "content": {"selected": ["<id>"]}}.`;
42
+
43
+ const CONFIRM_RULES = `ask_confirm: the user approves or denies one action you describe in prompt.
44
+ - confirmLabel, denyLabel: optional labels for the approve and deny buttons, at most 20 characters each, counting each emoji as 2 or more, and different from each other. They default to "Confirm" and "Cancel". Name the action, such as "Delete 14 todos" and "Keep them".
45
+ - destructive: optional, default false. Set it to true when the action deletes data or cannot be undone; the approve button then shows as destructive.
46
+ - allowDecline: optional, default false, because the deny button is the negative answer. Set it to true to show Skip as well.
47
+ - Before you call a tool that deletes data, sends something on the user's behalf, spends money, or cannot be undone, call ask_confirm and say in prompt exactly what will happen. Make the call only after {"confirmed": true}.
48
+ - An accepted answer looks like {"action": "accept", "content": {"confirmed": true}}. {"confirmed": false} means the user said no, so do not take the action.`;
49
+
50
+ const MARKDOWN_RULES = `ask_markdown: the user edits a markdown draft you write and sends it back.
51
+ - initial: optional, the draft in markdown, at most 20000 characters. Put the whole draft here, not in prompt.
52
+ - minLength, maxLength: optional whole numbers. The answer must have at least minLength (default 0) and at most maxLength (default 20000, the most allowed) characters. minLength must not be more than maxLength.
53
+ - placeholder: optional hint shown while the editor is empty, at most 120 characters.
54
+ - submitLabel: optional label for the submit button, at most 24 characters.
55
+ - allowDecline: optional, default true (the user sees Skip).
56
+ - An accepted answer looks like {"action": "accept", "content": {"markdown": "<the text>", "changed": true}}. changed is false when the user sent your draft unchanged.`;
57
+
58
+ const FORM_RULES = `ask_form: the user fills in a few fields and submits them at once.
59
+ - fields: 1-8 items, in display order, each {id, type, label, helperText?, required?, default?} plus the rules of its type. Fields are flat: no nesting, no conditional fields, and no password fields.
60
+ - id: 1-64 lowercase letters, digits, "_", or "-", starting with a letter or digit. Unique within the form. It keys the field's value in the answer.
61
+ - label: at most 120 characters. helperText: optional, at most 280 characters. required: optional, default false; true means the answer must hold a non-blank value.
62
+ - type "text" (one line) or "textarea" (several lines): optional minLength and maxLength. maxLength is at most 2000 for text and 10000 for textarea. The value is a string.
63
+ - type "email", "url" (http or https), or "phone" (7-15 digits): the value is a string in that format.
64
+ - type "number": optional min, max, and integer (true for whole numbers only). The value is a number.
65
+ - type "date" (YYYY-MM-DD), "time" (24-hour HH:mm), or "datetime" (ISO 8601 with Z or an offset; seconds are optional, such as "2026-10-01T09:30Z" or "2026-10-01T09:30:00+02:00"): the value is a string in that format.
66
+ - type "boolean": a checkbox. The value is true or false.
67
+ - type "select" (pick one) or "multiselect" (pick any): options, 2-50 items, each {id, label}. The value is an option id, or a list of option ids.
68
+ - default: optional, a value the field accepts, filled in when the form opens.
69
+ - submitLabel: optional label for the submit button, at most 24 characters.
70
+ - allowDecline: optional, default true (the user sees Skip).
71
+ - An accepted answer looks like {"action": "accept", "content": {"values": {"<field id>": <value>}}}. Optional fields the user left empty are left out.`;
72
+
73
+ const FILES_RULES = `ask_files: the user uploads one or more images or documents.
74
+ - accept: required list, each value once: "image" (JPEG, PNG, GIF, or WebP), "pdf", "text" (plain text), "csv", or "json".
75
+ - minFiles, maxFiles: optional whole numbers from 1 to 10. The user sends from minFiles (default 1) to maxFiles (default 10) files.
76
+ - submitLabel: optional label for the submit button, at most 24 characters.
77
+ - allowDecline: optional, default true (the user sees Skip).
78
+ - The answer lists each file's filename, mimeType, and size, and the tool result then shows you each file: images and PDFs as they are, and text, CSV, and JSON as text cut to 100000 bytes.`;
79
+
80
+ const sectionOf = (section: string, kindRules: string[]): string =>
81
+ [section.replace("ask_choice.", "ask_choice, ask_confirm."), ...kindRules].join("\n\n");
82
+
83
+ describe("askPromptSection", () => {
84
+ it("describes ask_choice with the limits from ASK_LIMITS", () => {
85
+ expect(askPromptSection({kinds: ["choice"]})).toBe(CHOICE_SECTION);
86
+ });
87
+
88
+ it("lists each kind once when a kind repeats", () => {
89
+ expect(askPromptSection({kinds: ["choice", "choice"]})).toBe(CHOICE_SECTION);
90
+ });
91
+
92
+ it("returns an empty string when no kinds are enabled", () => {
93
+ expect(askPromptSection({kinds: []})).toBe("");
94
+ });
95
+
96
+ it("describes the full surface by default", () => {
97
+ expect(askPromptSection({kinds: ["choice"], surface: "full"})).toBe(CHOICE_SECTION);
98
+ });
99
+
100
+ it("describes ask_confirm after ask_choice, with the button label limit", () => {
101
+ expect(askPromptSection({kinds: ["choice", "confirm"]})).toBe(
102
+ sectionOf(CHOICE_SECTION, [CONFIRM_RULES])
103
+ );
104
+ });
105
+
106
+ it("describes ask_confirm alone, with the shared rules", () => {
107
+ const section = askPromptSection({kinds: ["confirm"]});
108
+ expect(section.startsWith("Ask tools you can call: ask_confirm.\n\nRules for every ask:")).toBe(
109
+ true
110
+ );
111
+ expect(section.endsWith(`\n\n${CONFIRM_RULES}`)).toBe(true);
112
+ expect(section).not.toContain("ask_choice");
113
+ });
114
+
115
+ it("describes ask_markdown after ask_confirm, with the length limits from ASK_LIMITS", () => {
116
+ expect(askPromptSection({kinds: ["choice", "confirm", "markdown"]})).toBe(
117
+ [
118
+ CHOICE_SECTION.replace("ask_choice.", "ask_choice, ask_confirm, ask_markdown."),
119
+ CONFIRM_RULES,
120
+ MARKDOWN_RULES,
121
+ ].join("\n\n")
122
+ );
123
+ });
124
+
125
+ it("describes ask_form after ask_markdown, with the field limits from ASK_LIMITS", () => {
126
+ expect(askPromptSection({kinds: ["choice", "confirm", "markdown", "form"]})).toBe(
127
+ [
128
+ CHOICE_SECTION.replace("ask_choice.", "ask_choice, ask_confirm, ask_markdown, ask_form."),
129
+ CONFIRM_RULES,
130
+ MARKDOWN_RULES,
131
+ FORM_RULES,
132
+ ].join("\n\n")
133
+ );
134
+ });
135
+
136
+ it("describes ask_files after ask_form, with the file limits from ASK_LIMITS", () => {
137
+ expect(askPromptSection({kinds: ["choice", "confirm", "markdown", "form", "files"]})).toBe(
138
+ [
139
+ CHOICE_SECTION.replace(
140
+ "ask_choice.",
141
+ "ask_choice, ask_confirm, ask_markdown, ask_form, ask_files."
142
+ ),
143
+ CONFIRM_RULES,
144
+ MARKDOWN_RULES,
145
+ FORM_RULES,
146
+ FILES_RULES,
147
+ ].join("\n\n")
148
+ );
149
+ });
150
+
151
+ it("leaves submitLabel and the Skip default out of the shared rules, since confirm differs", () => {
152
+ const section = askPromptSection({kinds: ["confirm"]});
153
+ expect(section).not.toContain("submitLabel");
154
+ expect(section).not.toContain("default true");
155
+ expect(section).toContain("- allowDecline: optional, default false");
156
+ });
157
+ });
158
+
159
+ describe("askPromptSection on the compact surface", () => {
160
+ it("describes ask_choice with the simple card limits from ASK_LIMITS", () => {
161
+ expect(askPromptSection({kinds: ["choice"], surface: "compact"})).toBe(COMPACT_CHOICE_SECTION);
162
+ });
163
+
164
+ it("lists each kind once when a kind repeats", () => {
165
+ expect(askPromptSection({kinds: ["choice", "choice"], surface: "compact"})).toBe(
166
+ COMPACT_CHOICE_SECTION
167
+ );
168
+ });
169
+
170
+ it("returns an empty string when no kinds are enabled", () => {
171
+ expect(askPromptSection({kinds: [], surface: "compact"})).toBe("");
172
+ });
173
+
174
+ it("offers ask_confirm with the same rules, because it always fits a simple card", () => {
175
+ expect(askPromptSection({kinds: ["choice", "confirm"], surface: "compact"})).toBe(
176
+ sectionOf(COMPACT_CHOICE_SECTION, [CONFIRM_RULES])
177
+ );
178
+ });
179
+
180
+ it("leaves ask_markdown out, because a draft cannot be edited on a small screen", () => {
181
+ expect(askPromptSection({kinds: ["choice", "confirm", "markdown"], surface: "compact"})).toBe(
182
+ sectionOf(COMPACT_CHOICE_SECTION, [CONFIRM_RULES])
183
+ );
184
+ expect(askPromptSection({kinds: ["markdown"], surface: "compact"})).toBe("");
185
+ });
186
+
187
+ it("leaves ask_form out, because fields cannot be filled in on a small screen", () => {
188
+ expect(askPromptSection({kinds: ["choice", "confirm", "form"], surface: "compact"})).toBe(
189
+ sectionOf(COMPACT_CHOICE_SECTION, [CONFIRM_RULES])
190
+ );
191
+ expect(askPromptSection({kinds: ["form"], surface: "compact"})).toBe("");
192
+ });
193
+
194
+ it("leaves ask_files out, because files cannot be picked on a small screen", () => {
195
+ expect(askPromptSection({kinds: ["choice", "confirm", "files"], surface: "compact"})).toBe(
196
+ sectionOf(COMPACT_CHOICE_SECTION, [CONFIRM_RULES])
197
+ );
198
+ expect(askPromptSection({kinds: ["files"], surface: "compact"})).toBe("");
199
+ });
200
+ });