@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.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/biome.jsonc +18 -0
- package/bunfig.toml +6 -0
- package/dist/asks/errors.d.ts +77 -0
- package/dist/asks/errors.js +364 -0
- package/dist/asks/files.d.ts +53 -0
- package/dist/asks/files.js +171 -0
- package/dist/asks/formValues.d.ts +32 -0
- package/dist/asks/formValues.js +229 -0
- package/dist/asks/headless.d.ts +227 -0
- package/dist/asks/headless.js +94 -0
- package/dist/asks/jsonSchema.d.ts +6 -0
- package/dist/asks/jsonSchema.js +62 -0
- package/dist/asks/limits.d.ts +58 -0
- package/dist/asks/limits.js +61 -0
- package/dist/asks/prompt.d.ts +10 -0
- package/dist/asks/prompt.js +144 -0
- package/dist/asks/schema.d.ts +826 -0
- package/dist/asks/schema.js +864 -0
- package/dist/asks/simpleCard.d.ts +85 -0
- package/dist/asks/simpleCard.js +279 -0
- package/dist/asks/validateInput.d.ts +13 -0
- package/dist/asks/validateInput.js +19 -0
- package/dist/asks/validateResponse.d.ts +13 -0
- package/dist/asks/validateResponse.js +278 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +58 -0
- package/dist/errors.d.ts +51 -0
- package/dist/errors.js +106 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +111 -0
- package/dist/jsonSchema.d.ts +5 -0
- package/dist/jsonSchema.js +12 -0
- package/dist/limits.d.ts +27 -0
- package/dist/limits.js +30 -0
- package/dist/lint.d.ts +21 -0
- package/dist/lint.js +388 -0
- package/dist/parse.d.ts +16 -0
- package/dist/parse.js +95 -0
- package/dist/parsePartial.d.ts +12 -0
- package/dist/parsePartial.js +105 -0
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.js +95 -0
- package/dist/schema.d.ts +199 -0
- package/dist/schema.js +277 -0
- package/dist/validate.d.ts +17 -0
- package/dist/validate.js +331 -0
- package/package.json +60 -0
- package/schemas/pendingAsksResponse.schema.json +195 -0
- package/schemas/simpleCard.schema.json +135 -0
- package/schemas/turnRequest.schema.json +108 -0
- package/schemas/turnResponse.schema.json +204 -0
- package/scripts/writeJsonSchemas.ts +11 -0
- package/src/asks/docParity.test.ts +65 -0
- package/src/asks/errors.test.ts +250 -0
- package/src/asks/errors.ts +449 -0
- package/src/asks/files.test.ts +294 -0
- package/src/asks/files.ts +216 -0
- package/src/asks/fixtures/invalid/choice-blank-text.json +20 -0
- package/src/asks/fixtures/invalid/choice-default-not-in-options.json +13 -0
- package/src/asks/fixtures/invalid/choice-default-selects-two.json +16 -0
- package/src/asks/fixtures/invalid/choice-description-too-long.json +24 -0
- package/src/asks/fixtures/invalid/choice-duplicate-ids.json +17 -0
- package/src/asks/fixtures/invalid/choice-label-too-long.json +23 -0
- package/src/asks/fixtures/invalid/choice-many-bounds-not-whole.json +17 -0
- package/src/asks/fixtures/invalid/choice-many-bounds-out-of-range.json +17 -0
- package/src/asks/fixtures/invalid/choice-many-default-duplicate.json +17 -0
- package/src/asks/fixtures/invalid/choice-many-default-too-many.json +15 -0
- package/src/asks/fixtures/invalid/choice-many-max-above-options.json +14 -0
- package/src/asks/fixtures/invalid/choice-many-min-above-choices.json +13 -0
- package/src/asks/fixtures/invalid/choice-many-min-above-max.json +15 -0
- package/src/asks/fixtures/invalid/choice-missing-required.json +14 -0
- package/src/asks/fixtures/invalid/choice-mixed-errors.json +19 -0
- package/src/asks/fixtures/invalid/choice-not-an-object.json +5 -0
- package/src/asks/fixtures/invalid/choice-one-with-many-fields.json +19 -0
- package/src/asks/fixtures/invalid/choice-option-id-format.json +35 -0
- package/src/asks/fixtures/invalid/choice-options-not-objects.json +12 -0
- package/src/asks/fixtures/invalid/choice-other-label-too-long.json +25 -0
- package/src/asks/fixtures/invalid/choice-other-label-without-allow-other.json +14 -0
- package/src/asks/fixtures/invalid/choice-prompt-empty.json +12 -0
- package/src/asks/fixtures/invalid/choice-prompt-too-long.json +23 -0
- package/src/asks/fixtures/invalid/choice-select-unknown.json +12 -0
- package/src/asks/fixtures/invalid/choice-submit-label-too-long.json +13 -0
- package/src/asks/fixtures/invalid/choice-title-too-long.json +24 -0
- package/src/asks/fixtures/invalid/choice-too-few-options.json +9 -0
- package/src/asks/fixtures/invalid/choice-too-many-options.json +219 -0
- package/src/asks/fixtures/invalid/choice-unknown-key-option.json +15 -0
- package/src/asks/fixtures/invalid/choice-unknown-key-root.json +13 -0
- package/src/asks/fixtures/invalid/choice-wrong-types.json +16 -0
- package/src/asks/fixtures/invalid/confirm-emoji-label-too-long.json +13 -0
- package/src/asks/fixtures/invalid/confirm-label-too-long.json +14 -0
- package/src/asks/fixtures/invalid/confirm-labels-alike-after-trim.json +14 -0
- package/src/asks/fixtures/invalid/confirm-labels-alike.json +13 -0
- package/src/asks/fixtures/invalid/confirm-missing-prompt-blank-label.json +16 -0
- package/src/asks/fixtures/invalid/confirm-unknown-keys.json +23 -0
- package/src/asks/fixtures/invalid/confirm-wrong-types.json +18 -0
- package/src/asks/fixtures/invalid/files-accept-duplicate.json +13 -0
- package/src/asks/fixtures/invalid/files-accept-empty.json +13 -0
- package/src/asks/fixtures/invalid/files-accept-unknown.json +13 -0
- package/src/asks/fixtures/invalid/files-counts-out-of-range.json +19 -0
- package/src/asks/fixtures/invalid/files-min-above-max.json +15 -0
- package/src/asks/fixtures/invalid/files-missing-accept-unknown-key.json +17 -0
- package/src/asks/fixtures/invalid/form-default-bad-format.json +40 -0
- package/src/asks/fixtures/invalid/form-default-invalid-date.json +40 -0
- package/src/asks/fixtures/invalid/form-default-not-in-options.json +58 -0
- package/src/asks/fixtures/invalid/form-default-out-of-range.json +33 -0
- package/src/asks/fixtures/invalid/form-default-text-bounds.json +42 -0
- package/src/asks/fixtures/invalid/form-default-wrong-type.json +30 -0
- package/src/asks/fixtures/invalid/form-duplicate-field-ids.json +24 -0
- package/src/asks/fixtures/invalid/form-field-id-format.json +19 -0
- package/src/asks/fixtures/invalid/form-field-text-limits.json +33 -0
- package/src/asks/fixtures/invalid/form-keys-of-another-type.json +39 -0
- package/src/asks/fixtures/invalid/form-length-above-cap.json +30 -0
- package/src/asks/fixtures/invalid/form-min-above-max.json +32 -0
- package/src/asks/fixtures/invalid/form-no-fields.json +13 -0
- package/src/asks/fixtures/invalid/form-select-options.json +44 -0
- package/src/asks/fixtures/invalid/form-too-many-fields.json +59 -0
- package/src/asks/fixtures/invalid/form-unknown-field-type.json +27 -0
- package/src/asks/fixtures/invalid/markdown-bounds-out-of-range.json +18 -0
- package/src/asks/fixtures/invalid/markdown-initial-above-cap.json +13 -0
- package/src/asks/fixtures/invalid/markdown-max-above-cap.json +13 -0
- package/src/asks/fixtures/invalid/markdown-min-above-cap.json +13 -0
- package/src/asks/fixtures/invalid/markdown-min-above-max.json +14 -0
- package/src/asks/fixtures/invalid/markdown-placeholder-too-long.json +13 -0
- package/src/asks/fixtures/invalid/markdown-wrong-types-unknown-key.json +27 -0
- package/src/asks/fixtures/valid/choice-emoji-label-cut.json +32 -0
- package/src/asks/fixtures/valid/choice-labels-collide-after-cut.json +21 -0
- package/src/asks/fixtures/valid/choice-long-text-cut.json +41 -0
- package/src/asks/fixtures/valid/choice-many-default-below-min.json +24 -0
- package/src/asks/fixtures/valid/choice-many-every-choice-with-other.json +24 -0
- package/src/asks/fixtures/valid/choice-many-no-default.json +21 -0
- package/src/asks/fixtures/valid/choice-many-optional-no-buttons.json +20 -0
- package/src/asks/fixtures/valid/choice-many-options-default-label-collides.json +23 -0
- package/src/asks/fixtures/valid/choice-many-options-no-buttons.json +21 -0
- package/src/asks/fixtures/valid/choice-many-options-no-default.json +22 -0
- package/src/asks/fixtures/valid/choice-many-options-with-default.json +30 -0
- package/src/asks/fixtures/valid/choice-many-required-with-default.json +29 -0
- package/src/asks/fixtures/valid/choice-many-single-pick-or-other.json +29 -0
- package/src/asks/fixtures/valid/choice-many-toppings-with-default.json +36 -0
- package/src/asks/fixtures/valid/choice-max-limits.json +245 -0
- package/src/asks/fixtures/valid/choice-plan-with-default.json +42 -0
- package/src/asks/fixtures/valid/choice-two-options-no-decline.json +32 -0
- package/src/asks/fixtures/valid/choice-two-options.json +32 -0
- package/src/asks/fixtures/valid/confirm-allow-decline.json +40 -0
- package/src/asks/fixtures/valid/confirm-default-labels.json +36 -0
- package/src/asks/fixtures/valid/confirm-destructive-delete.json +41 -0
- package/src/asks/fixtures/valid/confirm-emoji-labels.json +38 -0
- package/src/asks/fixtures/valid/confirm-long-text-cut.json +40 -0
- package/src/asks/fixtures/valid/files-every-type.json +28 -0
- package/src/asks/fixtures/valid/files-one-csv-no-decline.json +16 -0
- package/src/asks/fixtures/valid/files-receipt-photos.json +24 -0
- package/src/asks/fixtures/valid/form-boolean-newsletter.json +30 -0
- package/src/asks/fixtures/valid/form-date-due.json +44 -0
- package/src/asks/fixtures/valid/form-datetime-meeting.json +44 -0
- package/src/asks/fixtures/valid/form-datetime-minutes-offset.json +44 -0
- package/src/asks/fixtures/valid/form-eight-fields.json +71 -0
- package/src/asks/fixtures/valid/form-email-contact.json +44 -0
- package/src/asks/fixtures/valid/form-invoice-details.json +68 -0
- package/src/asks/fixtures/valid/form-multiselect-channels.json +64 -0
- package/src/asks/fixtures/valid/form-number-budget.json +45 -0
- package/src/asks/fixtures/valid/form-phone-callback.json +44 -0
- package/src/asks/fixtures/valid/form-select-shipping.json +58 -0
- package/src/asks/fixtures/valid/form-text-required-default.json +47 -0
- package/src/asks/fixtures/valid/form-textarea-notes.json +23 -0
- package/src/asks/fixtures/valid/form-time-reminder.json +44 -0
- package/src/asks/fixtures/valid/form-url-website.json +30 -0
- package/src/asks/fixtures/valid/markdown-announcement-draft.json +39 -0
- package/src/asks/fixtures/valid/markdown-empty-draft.json +34 -0
- package/src/asks/fixtures/valid/markdown-initial-blank-below-min.json +24 -0
- package/src/asks/fixtures/valid/markdown-initial-over-max.json +24 -0
- package/src/asks/fixtures/valid/markdown-long-text-cut.json +37 -0
- package/src/asks/fixtures/valid/markdown-no-decline.json +28 -0
- package/src/asks/fixtures/valid/markdown-no-initial-with-min.json +25 -0
- package/src/asks/formValues.ts +320 -0
- package/src/asks/headless.test.ts +146 -0
- package/src/asks/headless.ts +130 -0
- package/src/asks/jsonSchema.test.ts +171 -0
- package/src/asks/jsonSchema.ts +89 -0
- package/src/asks/limits.ts +58 -0
- package/src/asks/prompt.test.ts +200 -0
- package/src/asks/prompt.ts +173 -0
- package/src/asks/schema.test.ts +198 -0
- package/src/asks/schema.ts +1203 -0
- package/src/asks/simpleCard.test.ts +998 -0
- package/src/asks/simpleCard.ts +341 -0
- package/src/asks/validateInput.test.ts +875 -0
- package/src/asks/validateInput.ts +24 -0
- package/src/asks/validateResponse.test.ts +740 -0
- package/src/asks/validateResponse.ts +341 -0
- package/src/blocksDocParity.test.ts +21 -0
- package/src/cli.test.ts +134 -0
- package/src/cli.ts +65 -0
- package/src/display.test.ts +68 -0
- package/src/errors.test.ts +29 -0
- package/src/errors.ts +125 -0
- package/src/fixtures/invalid/column-not-found.yaml +14 -0
- package/src/fixtures/invalid/column-type.yaml +14 -0
- package/src/fixtures/invalid/dataset-not-found.yaml +7 -0
- package/src/fixtures/invalid/dataset-too-large.yaml +20 -0
- package/src/fixtures/invalid/depth-exceeded.yaml +10 -0
- package/src/fixtures/invalid/duplicate-id.yaml +6 -0
- package/src/fixtures/invalid/invalid-enum.yaml +5 -0
- package/src/fixtures/invalid/key-order.yaml +4 -0
- package/src/fixtures/invalid/missing-required.yaml +3 -0
- package/src/fixtures/invalid/not-a-document.txt +1 -0
- package/src/fixtures/invalid/row-arity.yaml +11 -0
- package/src/fixtures/invalid/select-target.yaml +12 -0
- package/src/fixtures/invalid/table-too-wide.yaml +9 -0
- package/src/fixtures/invalid/too-many-points.yaml +8 -0
- package/src/fixtures/invalid/unknown-key.yaml +5 -0
- package/src/fixtures/invalid/unsupported-version.yaml +4 -0
- package/src/fixtures/invalid/yaml-anchor.yaml +6 -0
- package/src/fixtures/partial/cut-after-divider.yaml +5 -0
- package/src/fixtures/valid/actions.yaml +32 -0
- package/src/fixtures/valid/chart.yaml +20 -0
- package/src/fixtures/valid/layout.yaml +26 -0
- package/src/fixtures/valid/prose.yaml +4 -0
- package/src/html.test.ts +52 -0
- package/src/index.test.ts +114 -0
- package/src/index.ts +175 -0
- package/src/jsonSchema.test.ts +33 -0
- package/src/jsonSchema.ts +11 -0
- package/src/limits.ts +27 -0
- package/src/lint.test.ts +289 -0
- package/src/lint.ts +484 -0
- package/src/parse.test.ts +281 -0
- package/src/parse.ts +104 -0
- package/src/parsePartial.test.ts +53 -0
- package/src/parsePartial.ts +116 -0
- package/src/prompt.test.ts +24 -0
- package/src/prompt.ts +104 -0
- package/src/schema.ts +509 -0
- package/src/tests/askFixtures.ts +40 -0
- package/src/validate.perf.test.ts +43 -0
- package/src/validate.ts +369 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
import {z} from "zod";
|
|
2
|
+
import {askIssue, formatAskPath, quoteValue} from "./errors";
|
|
3
|
+
import {ASK_FILE_ACCEPT, type AskFileAccept, parseAskDataUrl} from "./files";
|
|
4
|
+
import {checkFormValue, formTextMaxLength} from "./formValues";
|
|
5
|
+
import {ASK_LIMITS} from "./limits";
|
|
6
|
+
|
|
7
|
+
/** Ask kinds in the catalog. Each kind is offered to the model as the tool `ask_<kind>`. */
|
|
8
|
+
export const ASK_KINDS = ["choice", "confirm", "markdown", "form", "files"] as const;
|
|
9
|
+
|
|
10
|
+
export type AskKind = (typeof ASK_KINDS)[number];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where the user answers. `compact` is a small screen, such as a watch: the model is offered only
|
|
14
|
+
* asks whose simple card shows every option, so the user never has to continue on a phone.
|
|
15
|
+
*/
|
|
16
|
+
export const ASK_SURFACES = ["full", "compact"] as const;
|
|
17
|
+
|
|
18
|
+
export type AskSurface = (typeof ASK_SURFACES)[number];
|
|
19
|
+
|
|
20
|
+
/** Ask kinds the compact surface offers, each with a narrowed input schema. */
|
|
21
|
+
export const COMPACT_ASK_KINDS = ["choice", "confirm"] as const satisfies readonly AskKind[];
|
|
22
|
+
|
|
23
|
+
export type CompactAskKind = (typeof COMPACT_ASK_KINDS)[number];
|
|
24
|
+
|
|
25
|
+
/** Reasons the server records when it answers an ask with `cancel` on the user's behalf. */
|
|
26
|
+
export const ASK_CANCEL_REASONS = {
|
|
27
|
+
oneAskAtATime: "one_ask_at_a_time",
|
|
28
|
+
userSentMessage: "user_sent_message",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
const visibleText = (maxLength: number): z.ZodString =>
|
|
32
|
+
z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.max(maxLength)
|
|
36
|
+
.refine((value) => value.length === 0 || value.trim().length > 0, {
|
|
37
|
+
message: "Must contain visible text, not only whitespace.",
|
|
38
|
+
params: {askCode: "TOO_SHORT"},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const sharedAskFields = {
|
|
42
|
+
allowDecline: z
|
|
43
|
+
.boolean()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("Show a Skip button so the user can decline to answer. Defaults to true."),
|
|
46
|
+
prompt: visibleText(ASK_LIMITS.promptMaxLength).describe(
|
|
47
|
+
`The question for the user, in plain text: no markdown and no links. 1-${ASK_LIMITS.promptMaxLength} characters.`
|
|
48
|
+
),
|
|
49
|
+
submitLabel: visibleText(ASK_LIMITS.submitLabelMaxLength)
|
|
50
|
+
.optional()
|
|
51
|
+
.describe(
|
|
52
|
+
`Label for the submit button, at most ${ASK_LIMITS.submitLabelMaxLength} characters. Defaults to "Submit".`
|
|
53
|
+
),
|
|
54
|
+
title: visibleText(ASK_LIMITS.titleMaxLength)
|
|
55
|
+
.optional()
|
|
56
|
+
.describe(
|
|
57
|
+
`A short heading above the question, at most ${ASK_LIMITS.titleMaxLength} characters.`
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const choiceOption = (labelMaxLength: number) =>
|
|
62
|
+
z
|
|
63
|
+
.object({
|
|
64
|
+
description: visibleText(ASK_LIMITS.choice.optionDescriptionMaxLength)
|
|
65
|
+
.optional()
|
|
66
|
+
.describe(
|
|
67
|
+
`One line under the label, at most ${ASK_LIMITS.choice.optionDescriptionMaxLength} characters.`
|
|
68
|
+
),
|
|
69
|
+
id: z
|
|
70
|
+
.string()
|
|
71
|
+
.regex(ASK_LIMITS.choice.optionIdPattern)
|
|
72
|
+
.describe(
|
|
73
|
+
`Stable id returned in the answer: 1-${ASK_LIMITS.choice.optionIdMaxLength} lowercase letters, digits, "_", or "-", starting with a letter or digit. Unique within the ask.`
|
|
74
|
+
),
|
|
75
|
+
label: visibleText(labelMaxLength).describe(
|
|
76
|
+
`What the user sees, at most ${labelMaxLength} characters.`
|
|
77
|
+
),
|
|
78
|
+
})
|
|
79
|
+
.strict();
|
|
80
|
+
|
|
81
|
+
export const choiceOptionSchema = choiceOption(ASK_LIMITS.choice.optionLabelMaxLength);
|
|
82
|
+
|
|
83
|
+
export type ChoiceOption = z.infer<typeof choiceOptionSchema>;
|
|
84
|
+
|
|
85
|
+
/** How many options a `choice` lets the user pick. */
|
|
86
|
+
export const CHOICE_SELECT_MODES = ["one", "many"] as const;
|
|
87
|
+
|
|
88
|
+
export type ChoiceSelectMode = (typeof CHOICE_SELECT_MODES)[number];
|
|
89
|
+
|
|
90
|
+
/** The fields of a `choice` input that its semantic rules read. */
|
|
91
|
+
interface ChoiceRuleInput {
|
|
92
|
+
allowOther?: boolean;
|
|
93
|
+
default?: string[];
|
|
94
|
+
maxSelected?: number;
|
|
95
|
+
minSelected?: number;
|
|
96
|
+
options: ChoiceOption[];
|
|
97
|
+
otherLabel?: string;
|
|
98
|
+
select: ChoiceSelectMode;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* How many choices an answer to a `choice` must make, counting a typed Other answer as one.
|
|
103
|
+
* `select: "one"` is always exactly one. `select: "many"` defaults to at least 1 and at most every
|
|
104
|
+
* choice the ask offers: each option, plus Other when `allowOther` is true.
|
|
105
|
+
*/
|
|
106
|
+
export const choiceSelectionBounds = (input: ChoiceRuleInput): {max: number; min: number} => {
|
|
107
|
+
if (input.select === "one") {
|
|
108
|
+
return {max: 1, min: 1};
|
|
109
|
+
}
|
|
110
|
+
const choiceCount = input.options.length + (input.allowOther === true ? 1 : 0);
|
|
111
|
+
return {max: input.maxSelected ?? choiceCount, min: input.minSelected ?? 1};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const pluralOptions = (count: number): string => (count === 1 ? "1 choice" : `${count} choices`);
|
|
115
|
+
|
|
116
|
+
const checkDuplicateIds = ({
|
|
117
|
+
ctx,
|
|
118
|
+
fix,
|
|
119
|
+
ids,
|
|
120
|
+
message,
|
|
121
|
+
segments,
|
|
122
|
+
}: {
|
|
123
|
+
ctx: z.RefinementCtx;
|
|
124
|
+
fix: string;
|
|
125
|
+
ids: readonly string[];
|
|
126
|
+
message: (id: string, firstIndex: number) => string;
|
|
127
|
+
segments: (index: number) => PropertyKey[];
|
|
128
|
+
}): Map<string, number> => {
|
|
129
|
+
const firstIndexById = new Map<string, number>();
|
|
130
|
+
ids.forEach((id, index) => {
|
|
131
|
+
const firstIndex = firstIndexById.get(id);
|
|
132
|
+
if (firstIndex === undefined) {
|
|
133
|
+
firstIndexById.set(id, index);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
ctx.addIssue(
|
|
137
|
+
askIssue({
|
|
138
|
+
code: "DUPLICATE_ID",
|
|
139
|
+
fix,
|
|
140
|
+
message: message(id, firstIndex),
|
|
141
|
+
segments: segments(index),
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
return firstIndexById;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** `select: "one"` picks exactly one option; count bounds and Other belong to `select: "many"`. */
|
|
149
|
+
const checkSelectOne = (input: ChoiceRuleInput, ctx: z.RefinementCtx): void => {
|
|
150
|
+
for (const field of ["minSelected", "maxSelected"] as const) {
|
|
151
|
+
const value = input[field];
|
|
152
|
+
if (value === undefined || value === 1) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
ctx.addIssue(
|
|
156
|
+
askIssue({
|
|
157
|
+
code: "RANGE_INVALID",
|
|
158
|
+
fix: `Remove ${field}, or use select "many".`,
|
|
159
|
+
message: `select "one" picks exactly one option, so ${field} must be 1, not ${value}.`,
|
|
160
|
+
segments: [field],
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (input.allowOther === true) {
|
|
165
|
+
ctx.addIssue(
|
|
166
|
+
askIssue({
|
|
167
|
+
code: "OTHER_NOT_ALLOWED",
|
|
168
|
+
fix: 'Use select "many" (with maxSelected 1 for a single pick), or remove allowOther.',
|
|
169
|
+
message: 'allowOther needs select "many".',
|
|
170
|
+
segments: ["allowOther"],
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const defaults = input.default ?? [];
|
|
175
|
+
if (defaults.length > 1) {
|
|
176
|
+
ctx.addIssue(
|
|
177
|
+
askIssue({
|
|
178
|
+
code: "SELECTION_COUNT",
|
|
179
|
+
fix: "Keep one option id in default, or remove default.",
|
|
180
|
+
message: `default lists ${defaults.length} option ids, but select "one" allows one.`,
|
|
181
|
+
segments: ["default"],
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const checkSelectMany = (input: ChoiceRuleInput, ctx: z.RefinementCtx): void => {
|
|
188
|
+
const choiceCount = input.options.length + (input.allowOther === true ? 1 : 0);
|
|
189
|
+
const {max, min} = choiceSelectionBounds(input);
|
|
190
|
+
if (input.maxSelected !== undefined && input.maxSelected > choiceCount) {
|
|
191
|
+
ctx.addIssue(
|
|
192
|
+
askIssue({
|
|
193
|
+
code: "RANGE_INVALID",
|
|
194
|
+
fix: `Set maxSelected to ${choiceCount} or fewer, or add options.`,
|
|
195
|
+
message: `maxSelected is ${input.maxSelected}, but the ask offers ${pluralOptions(choiceCount)}.`,
|
|
196
|
+
segments: ["maxSelected"],
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (min > Math.min(max, choiceCount)) {
|
|
201
|
+
const isMaxSelectedBinding =
|
|
202
|
+
input.maxSelected !== undefined && input.maxSelected <= choiceCount;
|
|
203
|
+
const limit = isMaxSelectedBinding
|
|
204
|
+
? `maxSelected (${max})`
|
|
205
|
+
: `the ${pluralOptions(choiceCount)} the ask offers`;
|
|
206
|
+
ctx.addIssue(
|
|
207
|
+
askIssue({
|
|
208
|
+
code: "RANGE_INVALID",
|
|
209
|
+
fix: "Lower minSelected, or raise maxSelected and add options.",
|
|
210
|
+
message: `minSelected (${min}) is more than ${limit}.`,
|
|
211
|
+
segments: ["minSelected"],
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const defaults = input.default ?? [];
|
|
216
|
+
if (defaults.length > max) {
|
|
217
|
+
ctx.addIssue(
|
|
218
|
+
askIssue({
|
|
219
|
+
code: "SELECTION_COUNT",
|
|
220
|
+
fix: `Keep at most ${max} option ids in default.`,
|
|
221
|
+
message: `default lists ${defaults.length} option ids, but the ask allows at most ${max}.`,
|
|
222
|
+
segments: ["default"],
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
checkDuplicateIds({
|
|
227
|
+
ctx,
|
|
228
|
+
fix: "List each option id in default once.",
|
|
229
|
+
ids: defaults,
|
|
230
|
+
message: (id, firstIndex) =>
|
|
231
|
+
`Default ${quoteValue(id)} is already listed at ${formatAskPath(["default", firstIndex])}.`,
|
|
232
|
+
segments: (index) => ["default", index],
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const checkChoiceInput = (input: ChoiceRuleInput, ctx: z.RefinementCtx): void => {
|
|
237
|
+
const firstIndexById = checkDuplicateIds({
|
|
238
|
+
ctx,
|
|
239
|
+
fix: "Give every option a unique id.",
|
|
240
|
+
ids: input.options.map((option) => option.id),
|
|
241
|
+
message: (id, firstIndex) =>
|
|
242
|
+
`Option id ${quoteValue(id)} is already used by ${formatAskPath(["options", firstIndex])}.`,
|
|
243
|
+
segments: (index) => ["options", index, "id"],
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (input.select === "many") {
|
|
247
|
+
checkSelectMany(input, ctx);
|
|
248
|
+
} else {
|
|
249
|
+
checkSelectOne(input, ctx);
|
|
250
|
+
}
|
|
251
|
+
if (input.otherLabel !== undefined && input.allowOther !== true) {
|
|
252
|
+
ctx.addIssue(
|
|
253
|
+
askIssue({
|
|
254
|
+
code: "OTHER_NOT_ALLOWED",
|
|
255
|
+
fix: "Set allowOther to true, or remove otherLabel.",
|
|
256
|
+
message: "otherLabel is set, but allowOther is not true.",
|
|
257
|
+
segments: ["otherLabel"],
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const defaults = input.default ?? [];
|
|
263
|
+
defaults.forEach((id, index) => {
|
|
264
|
+
if (firstIndexById.has(id)) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
ctx.addIssue(
|
|
268
|
+
askIssue({
|
|
269
|
+
code: "DEFAULT_NOT_IN_OPTIONS",
|
|
270
|
+
fix: "Use an id from options, or remove it from default.",
|
|
271
|
+
message: `Default ${quoteValue(id)} is not the id of any option.`,
|
|
272
|
+
segments: ["default", index],
|
|
273
|
+
})
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* A compact ask shows each option as a button with its label uncut, so two options with the same
|
|
280
|
+
* label would be two buttons the user cannot tell apart. Zod counts a label's length in code points,
|
|
281
|
+
* but a simple card cuts labels by UTF-16 units, so a label with emoji can fit Zod's limit and still
|
|
282
|
+
* be cut on its button.
|
|
283
|
+
*/
|
|
284
|
+
const checkButtonLabelLength = ({
|
|
285
|
+
ctx,
|
|
286
|
+
label,
|
|
287
|
+
segments,
|
|
288
|
+
}: {
|
|
289
|
+
ctx: z.RefinementCtx;
|
|
290
|
+
label: string;
|
|
291
|
+
segments: PropertyKey[];
|
|
292
|
+
}): void => {
|
|
293
|
+
const {buttonLabelMaxLength} = ASK_LIMITS.simpleCard;
|
|
294
|
+
if (label.length <= buttonLabelMaxLength) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const path = formatAskPath(segments);
|
|
298
|
+
ctx.addIssue(
|
|
299
|
+
askIssue({
|
|
300
|
+
code: "TOO_LONG",
|
|
301
|
+
fix: `Shorten ${path} to ${buttonLabelMaxLength} characters or fewer, or use fewer emoji.`,
|
|
302
|
+
message: `${path} is longer than ${buttonLabelMaxLength} characters, counting each emoji as 2 or more.`,
|
|
303
|
+
segments,
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A label as its button shows it. Spaces at either end don't show, so "Go " and "Go" look the same
|
|
310
|
+
* and are compared, and put on simple card buttons, without them.
|
|
311
|
+
*/
|
|
312
|
+
export const visibleLabel = (label: string): string => label.trim();
|
|
313
|
+
|
|
314
|
+
/** Says why two labels that differ only in spaces at either end count as the same. */
|
|
315
|
+
const spacesNote = (label: string, otherLabel: string): string =>
|
|
316
|
+
label === otherLabel ? "" : ", ignoring spaces at either end";
|
|
317
|
+
|
|
318
|
+
const checkCompactChoiceInput = (input: ChoiceRuleInput, ctx: z.RefinementCtx): void => {
|
|
319
|
+
checkChoiceInput(input, ctx);
|
|
320
|
+
const firstIndexByLabel = new Map<string, number>();
|
|
321
|
+
input.options.forEach((option, index) => {
|
|
322
|
+
checkButtonLabelLength({ctx, label: option.label, segments: ["options", index, "label"]});
|
|
323
|
+
const firstIndex = firstIndexByLabel.get(visibleLabel(option.label));
|
|
324
|
+
if (firstIndex === undefined) {
|
|
325
|
+
firstIndexByLabel.set(visibleLabel(option.label), index);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const note = spacesNote(option.label, input.options[firstIndex].label);
|
|
329
|
+
ctx.addIssue(
|
|
330
|
+
askIssue({
|
|
331
|
+
code: "DUPLICATE_LABEL",
|
|
332
|
+
fix: "Give every option a different label.",
|
|
333
|
+
message: `Option label ${quoteValue(option.label)} is already used by ${formatAskPath(["options", firstIndex])}${note}.`,
|
|
334
|
+
segments: ["options", index, "label"],
|
|
335
|
+
})
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const choiceBaseFields = ({
|
|
341
|
+
defaultDescription,
|
|
342
|
+
labelMaxLength,
|
|
343
|
+
optionsMax,
|
|
344
|
+
}: {
|
|
345
|
+
defaultDescription: string;
|
|
346
|
+
labelMaxLength: number;
|
|
347
|
+
optionsMax: number;
|
|
348
|
+
}) => ({
|
|
349
|
+
...sharedAskFields,
|
|
350
|
+
default: z.array(z.string()).max(optionsMax).optional().describe(defaultDescription),
|
|
351
|
+
options: z
|
|
352
|
+
.array(choiceOption(labelMaxLength))
|
|
353
|
+
.min(ASK_LIMITS.choice.optionsMin)
|
|
354
|
+
.max(optionsMax)
|
|
355
|
+
.describe(
|
|
356
|
+
`The options, in display order: ${ASK_LIMITS.choice.optionsMin}-${optionsMax} items.`
|
|
357
|
+
),
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
/** Input for `ask_choice`: pick one option, or several with `select: "many"`, from a list. */
|
|
361
|
+
export const choiceAskInputSchema = z
|
|
362
|
+
.object({
|
|
363
|
+
...choiceBaseFields({
|
|
364
|
+
defaultDescription:
|
|
365
|
+
'Option ids to preselect. With select "one", at most one id; with select "many", at most maxSelected ids.',
|
|
366
|
+
labelMaxLength: ASK_LIMITS.choice.optionLabelMaxLength,
|
|
367
|
+
optionsMax: ASK_LIMITS.choice.optionsMax,
|
|
368
|
+
}),
|
|
369
|
+
allowOther: z
|
|
370
|
+
.boolean()
|
|
371
|
+
.optional()
|
|
372
|
+
.describe(
|
|
373
|
+
`With select "many": add a free-text Other entry. The answer's other holds its text, at most ${ASK_LIMITS.choice.otherMaxLength} characters, and counts as one choice.`
|
|
374
|
+
),
|
|
375
|
+
maxSelected: z
|
|
376
|
+
.number()
|
|
377
|
+
.int()
|
|
378
|
+
.min(1)
|
|
379
|
+
.optional()
|
|
380
|
+
.describe(
|
|
381
|
+
'With select "many": the most choices the user may make. Defaults to every choice: the option count, plus 1 with allowOther.'
|
|
382
|
+
),
|
|
383
|
+
minSelected: z
|
|
384
|
+
.number()
|
|
385
|
+
.int()
|
|
386
|
+
.min(0)
|
|
387
|
+
.optional()
|
|
388
|
+
.describe(
|
|
389
|
+
'With select "many": the fewest choices the user must make. Defaults to 1. 0 lets the user submit no choice.'
|
|
390
|
+
),
|
|
391
|
+
otherLabel: visibleText(ASK_LIMITS.choice.optionLabelMaxLength)
|
|
392
|
+
.optional()
|
|
393
|
+
.describe(
|
|
394
|
+
`Label for the Other entry when allowOther is true, at most ${ASK_LIMITS.choice.optionLabelMaxLength} characters. Defaults to "Other".`
|
|
395
|
+
),
|
|
396
|
+
select: z
|
|
397
|
+
.enum(CHOICE_SELECT_MODES)
|
|
398
|
+
.describe('How many options the user picks: "one", or "many" (checkboxes).'),
|
|
399
|
+
})
|
|
400
|
+
.strict()
|
|
401
|
+
.superRefine(checkChoiceInput);
|
|
402
|
+
|
|
403
|
+
export type ChoiceAskInput = z.infer<typeof choiceAskInputSchema>;
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Input for `ask_choice` on the compact surface: pick one option by tapping its button, so the
|
|
407
|
+
* card never hands off. At most 3 options, labels of at most 20 characters (each emoji counts as 2
|
|
408
|
+
* or more), and no two labels alike. `select: "many"` and Other are not offered.
|
|
409
|
+
*/
|
|
410
|
+
export const compactChoiceAskInputSchema = z
|
|
411
|
+
.object({
|
|
412
|
+
...choiceBaseFields({
|
|
413
|
+
defaultDescription: 'Option ids to preselect. With select "one", at most one id.',
|
|
414
|
+
labelMaxLength: ASK_LIMITS.simpleCard.buttonLabelMaxLength,
|
|
415
|
+
optionsMax: ASK_LIMITS.simpleCard.buttonsMax,
|
|
416
|
+
}),
|
|
417
|
+
select: z.literal("one").describe('How many options the user picks. Only "one" is supported.'),
|
|
418
|
+
})
|
|
419
|
+
.strict()
|
|
420
|
+
.superRefine(checkCompactChoiceInput);
|
|
421
|
+
|
|
422
|
+
/** The button labels a `confirm` ask shows when it does not name its own. */
|
|
423
|
+
const CONFIRM_DEFAULT_LABELS = {confirm: "Confirm", deny: "Cancel"} as const;
|
|
424
|
+
|
|
425
|
+
/** The fields of a `confirm` input that its semantic rules read. */
|
|
426
|
+
interface ConfirmRuleInput {
|
|
427
|
+
confirmLabel?: string;
|
|
428
|
+
denyLabel?: string;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The labels of a `confirm` ask's approve and deny buttons, with the defaults filled in. */
|
|
432
|
+
export const confirmButtonLabels = (input: ConfirmRuleInput): {confirm: string; deny: string} => ({
|
|
433
|
+
confirm: input.confirmLabel ?? CONFIRM_DEFAULT_LABELS.confirm,
|
|
434
|
+
deny: input.denyLabel ?? CONFIRM_DEFAULT_LABELS.deny,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Both labels fit a simple card button uncut, so every surface shows the same words, and they
|
|
439
|
+
* differ, so the user can tell approve from deny.
|
|
440
|
+
*/
|
|
441
|
+
const checkConfirmInput = (input: ConfirmRuleInput, ctx: z.RefinementCtx): void => {
|
|
442
|
+
for (const field of ["confirmLabel", "denyLabel"] as const) {
|
|
443
|
+
const label = input[field];
|
|
444
|
+
if (label !== undefined) {
|
|
445
|
+
checkButtonLabelLength({ctx, label, segments: [field]});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const {confirm, deny} = confirmButtonLabels(input);
|
|
449
|
+
if (visibleLabel(confirm) !== visibleLabel(deny)) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const note = spacesNote(confirm, deny);
|
|
453
|
+
if (input.denyLabel === undefined) {
|
|
454
|
+
ctx.addIssue(
|
|
455
|
+
askIssue({
|
|
456
|
+
code: "DUPLICATE_LABEL",
|
|
457
|
+
fix: `Give confirmLabel a label other than ${quoteValue(deny)}, the deny button's label.`,
|
|
458
|
+
message: `confirmLabel ${quoteValue(confirm)} is the same as the deny button's label${note}.`,
|
|
459
|
+
segments: ["confirmLabel"],
|
|
460
|
+
})
|
|
461
|
+
);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
ctx.addIssue(
|
|
465
|
+
askIssue({
|
|
466
|
+
code: "DUPLICATE_LABEL",
|
|
467
|
+
fix: `Give denyLabel a label other than ${quoteValue(confirm)}, the approve button's label.`,
|
|
468
|
+
message: `denyLabel ${quoteValue(deny)} is the same as the approve button's label${note}.`,
|
|
469
|
+
segments: ["denyLabel"],
|
|
470
|
+
})
|
|
471
|
+
);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const confirmLabelField = (button: "approve" | "deny", fallback: string) =>
|
|
475
|
+
visibleText(ASK_LIMITS.confirm.labelMaxLength)
|
|
476
|
+
.optional()
|
|
477
|
+
.describe(
|
|
478
|
+
`Label for the ${button} button, at most ${ASK_LIMITS.confirm.labelMaxLength} characters. Defaults to "${fallback}".`
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Input for `ask_confirm`: approve or deny one action. It has no Submit, so no `submitLabel`, and
|
|
483
|
+
* `allowDecline` defaults to false because deny is already the negative answer.
|
|
484
|
+
*/
|
|
485
|
+
export const confirmAskInputSchema = z
|
|
486
|
+
.object({
|
|
487
|
+
allowDecline: z
|
|
488
|
+
.boolean()
|
|
489
|
+
.optional()
|
|
490
|
+
.describe(
|
|
491
|
+
"Show a Skip button as well as deny. Defaults to false, because deny is the negative answer."
|
|
492
|
+
),
|
|
493
|
+
confirmLabel: confirmLabelField("approve", CONFIRM_DEFAULT_LABELS.confirm),
|
|
494
|
+
denyLabel: confirmLabelField("deny", CONFIRM_DEFAULT_LABELS.deny),
|
|
495
|
+
destructive: z
|
|
496
|
+
.boolean()
|
|
497
|
+
.optional()
|
|
498
|
+
.describe(
|
|
499
|
+
"True when the action deletes data or cannot be undone. The approve button then shows as destructive. Defaults to false."
|
|
500
|
+
),
|
|
501
|
+
prompt: sharedAskFields.prompt,
|
|
502
|
+
title: sharedAskFields.title,
|
|
503
|
+
})
|
|
504
|
+
.strict()
|
|
505
|
+
.superRefine(checkConfirmInput);
|
|
506
|
+
|
|
507
|
+
export type ConfirmAskInput = z.infer<typeof confirmAskInputSchema>;
|
|
508
|
+
|
|
509
|
+
/** The fields of a `markdown` input that its length rules read. */
|
|
510
|
+
interface MarkdownRuleInput {
|
|
511
|
+
initial?: string;
|
|
512
|
+
maxLength?: number;
|
|
513
|
+
minLength?: number;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* How long an answer to a `markdown` ask may be. `max` counts UTF-16 code units (the `length` of a
|
|
518
|
+
* JavaScript string, so most emoji count as 2 or more) and defaults to the 20,000 cap. `min`
|
|
519
|
+
* counts the text without spaces at either end and defaults to 0.
|
|
520
|
+
*/
|
|
521
|
+
export const markdownLengthBounds = (input: MarkdownRuleInput): {max: number; min: number} => ({
|
|
522
|
+
max: input.maxLength ?? ASK_LIMITS.markdown.maxLength,
|
|
523
|
+
min: input.minLength ?? 0,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Checks a draft's length against the cap in UTF-16 code units. Zod counts code points, so a draft
|
|
528
|
+
* with emoji could otherwise pass Zod and still be longer than a text field lets the user keep.
|
|
529
|
+
*/
|
|
530
|
+
const checkMarkdownCap = ({
|
|
531
|
+
ctx,
|
|
532
|
+
segments,
|
|
533
|
+
value,
|
|
534
|
+
}: {
|
|
535
|
+
ctx: z.RefinementCtx;
|
|
536
|
+
segments: PropertyKey[];
|
|
537
|
+
value: string;
|
|
538
|
+
}): void => {
|
|
539
|
+
const cap = ASK_LIMITS.markdown.maxLength;
|
|
540
|
+
if (value.length <= cap) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const path = formatAskPath(segments);
|
|
544
|
+
ctx.addIssue(
|
|
545
|
+
askIssue({
|
|
546
|
+
code: "TOO_LONG",
|
|
547
|
+
fix: `Shorten ${path} to ${cap} characters or fewer.`,
|
|
548
|
+
message: `${path} is longer than ${cap} characters.`,
|
|
549
|
+
segments,
|
|
550
|
+
})
|
|
551
|
+
);
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* `minLength` must not be above `maxLength`, or no answer could be sent. An `initial` draft outside
|
|
556
|
+
* the bounds is valid: the user edits it to fit, and the simple card offers no Approve draft.
|
|
557
|
+
*/
|
|
558
|
+
const checkMarkdownInput = (input: MarkdownRuleInput, ctx: z.RefinementCtx): void => {
|
|
559
|
+
if (input.initial !== undefined) {
|
|
560
|
+
checkMarkdownCap({ctx, segments: ["initial"], value: input.initial});
|
|
561
|
+
}
|
|
562
|
+
const {max, min} = markdownLengthBounds(input);
|
|
563
|
+
if (min <= max) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const limit = input.maxLength === undefined ? `the ${max}-character cap` : `maxLength (${max})`;
|
|
567
|
+
ctx.addIssue(
|
|
568
|
+
askIssue({
|
|
569
|
+
code: "RANGE_INVALID",
|
|
570
|
+
fix: "Lower minLength, or raise maxLength.",
|
|
571
|
+
message: `minLength (${min}) is more than ${limit}.`,
|
|
572
|
+
segments: ["minLength"],
|
|
573
|
+
})
|
|
574
|
+
);
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
/** Input for `ask_markdown`: the user edits a markdown draft and sends it back. */
|
|
578
|
+
export const markdownAskInputSchema = z
|
|
579
|
+
.object({
|
|
580
|
+
...sharedAskFields,
|
|
581
|
+
initial: z
|
|
582
|
+
.string()
|
|
583
|
+
.max(ASK_LIMITS.markdown.maxLength)
|
|
584
|
+
.optional()
|
|
585
|
+
.describe(
|
|
586
|
+
`The draft the user starts from, in markdown, at most ${ASK_LIMITS.markdown.maxLength} characters. Defaults to empty.`
|
|
587
|
+
),
|
|
588
|
+
maxLength: z
|
|
589
|
+
.number()
|
|
590
|
+
.int()
|
|
591
|
+
.min(1)
|
|
592
|
+
.max(ASK_LIMITS.markdown.maxLength)
|
|
593
|
+
.optional()
|
|
594
|
+
.describe(
|
|
595
|
+
`The longest answer the user may send, in characters. Defaults to ${ASK_LIMITS.markdown.maxLength}, the most allowed.`
|
|
596
|
+
),
|
|
597
|
+
minLength: z
|
|
598
|
+
.number()
|
|
599
|
+
.int()
|
|
600
|
+
.min(0)
|
|
601
|
+
.max(ASK_LIMITS.markdown.maxLength)
|
|
602
|
+
.optional()
|
|
603
|
+
.describe(
|
|
604
|
+
"The shortest answer the user may send, in characters, not counting spaces at either end. Defaults to 0."
|
|
605
|
+
),
|
|
606
|
+
placeholder: visibleText(ASK_LIMITS.markdown.placeholderMaxLength)
|
|
607
|
+
.optional()
|
|
608
|
+
.describe(
|
|
609
|
+
`Hint text shown while the editor is empty, at most ${ASK_LIMITS.markdown.placeholderMaxLength} characters.`
|
|
610
|
+
),
|
|
611
|
+
})
|
|
612
|
+
.strict()
|
|
613
|
+
.superRefine(checkMarkdownInput);
|
|
614
|
+
|
|
615
|
+
export type MarkdownAskInput = z.infer<typeof markdownAskInputSchema>;
|
|
616
|
+
|
|
617
|
+
/** The field types a `form` offers. Every field is flat: no nesting and no conditional fields. */
|
|
618
|
+
export const FORM_FIELD_TYPES = [
|
|
619
|
+
"text",
|
|
620
|
+
"textarea",
|
|
621
|
+
"email",
|
|
622
|
+
"url",
|
|
623
|
+
"phone",
|
|
624
|
+
"number",
|
|
625
|
+
"date",
|
|
626
|
+
"time",
|
|
627
|
+
"datetime",
|
|
628
|
+
"boolean",
|
|
629
|
+
"select",
|
|
630
|
+
"multiselect",
|
|
631
|
+
] as const;
|
|
632
|
+
|
|
633
|
+
export type FormFieldType = (typeof FORM_FIELD_TYPES)[number];
|
|
634
|
+
|
|
635
|
+
const formFieldBase = <Type extends FormFieldType>(type: Type) => ({
|
|
636
|
+
helperText: visibleText(ASK_LIMITS.form.helperTextMaxLength)
|
|
637
|
+
.optional()
|
|
638
|
+
.describe(
|
|
639
|
+
`One line under the field, at most ${ASK_LIMITS.form.helperTextMaxLength} characters.`
|
|
640
|
+
),
|
|
641
|
+
id: z
|
|
642
|
+
.string()
|
|
643
|
+
.regex(ASK_LIMITS.choice.optionIdPattern)
|
|
644
|
+
.describe(
|
|
645
|
+
`The key of this field's value in the answer: 1-${ASK_LIMITS.choice.optionIdMaxLength} lowercase letters, digits, "_", or "-", starting with a letter or digit. Unique within the form.`
|
|
646
|
+
),
|
|
647
|
+
label: visibleText(ASK_LIMITS.form.labelMaxLength).describe(
|
|
648
|
+
`What the user sees, at most ${ASK_LIMITS.form.labelMaxLength} characters.`
|
|
649
|
+
),
|
|
650
|
+
required: z
|
|
651
|
+
.boolean()
|
|
652
|
+
.optional()
|
|
653
|
+
.describe(
|
|
654
|
+
"True when the answer must hold a non-blank value for this field. Defaults to false."
|
|
655
|
+
),
|
|
656
|
+
type: z.literal(type),
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const formTextField = <Type extends "text" | "textarea">(type: Type, cap: number) =>
|
|
660
|
+
z
|
|
661
|
+
.object({
|
|
662
|
+
...formFieldBase(type),
|
|
663
|
+
default: z.string().optional().describe("The text the field starts with."),
|
|
664
|
+
maxLength: z
|
|
665
|
+
.number()
|
|
666
|
+
.int()
|
|
667
|
+
.min(1)
|
|
668
|
+
.max(cap)
|
|
669
|
+
.optional()
|
|
670
|
+
.describe(`The most characters the value may have. Defaults to ${cap}, the most allowed.`),
|
|
671
|
+
minLength: z
|
|
672
|
+
.number()
|
|
673
|
+
.int()
|
|
674
|
+
.min(0)
|
|
675
|
+
.max(cap)
|
|
676
|
+
.optional()
|
|
677
|
+
.describe(
|
|
678
|
+
"The fewest characters a non-blank value may have, not counting spaces at either end. Defaults to 0."
|
|
679
|
+
),
|
|
680
|
+
})
|
|
681
|
+
.strict();
|
|
682
|
+
|
|
683
|
+
const formStringField = <Type extends "email" | "url" | "phone">(type: Type, example: string) =>
|
|
684
|
+
z
|
|
685
|
+
.object({
|
|
686
|
+
...formFieldBase(type),
|
|
687
|
+
default: z
|
|
688
|
+
.string()
|
|
689
|
+
.optional()
|
|
690
|
+
.describe(`The value the field starts with, such as ${example}.`),
|
|
691
|
+
})
|
|
692
|
+
.strict();
|
|
693
|
+
|
|
694
|
+
const formDateField = <Type extends "date" | "time" | "datetime">(type: Type, format: string) =>
|
|
695
|
+
z
|
|
696
|
+
.object({
|
|
697
|
+
...formFieldBase(type),
|
|
698
|
+
default: z.string().optional().describe(`The value the field starts with, as ${format}.`),
|
|
699
|
+
})
|
|
700
|
+
.strict();
|
|
701
|
+
|
|
702
|
+
const formOption = z
|
|
703
|
+
.object({
|
|
704
|
+
id: choiceOptionSchema.shape.id,
|
|
705
|
+
label: choiceOptionSchema.shape.label,
|
|
706
|
+
})
|
|
707
|
+
.strict();
|
|
708
|
+
|
|
709
|
+
const formOptions = formOption
|
|
710
|
+
.array()
|
|
711
|
+
.min(ASK_LIMITS.choice.optionsMin)
|
|
712
|
+
.max(ASK_LIMITS.choice.optionsMax)
|
|
713
|
+
.describe(
|
|
714
|
+
`The options, in display order: ${ASK_LIMITS.choice.optionsMin}-${ASK_LIMITS.choice.optionsMax} items, each {id, label}.`
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
export const formFieldSchema = z.discriminatedUnion("type", [
|
|
718
|
+
formTextField("text", ASK_LIMITS.form.textMaxLength),
|
|
719
|
+
formTextField("textarea", ASK_LIMITS.form.textareaMaxLength),
|
|
720
|
+
formStringField("email", '"ada@example.com"'),
|
|
721
|
+
formStringField("url", '"https://example.com"'),
|
|
722
|
+
formStringField("phone", '"+1 415 555 2671"'),
|
|
723
|
+
z
|
|
724
|
+
.object({
|
|
725
|
+
...formFieldBase("number"),
|
|
726
|
+
default: z.number().optional().describe("The number the field starts with."),
|
|
727
|
+
integer: z.boolean().optional().describe("True to accept whole numbers only."),
|
|
728
|
+
max: z.number().optional().describe("The largest value allowed."),
|
|
729
|
+
min: z.number().optional().describe("The smallest value allowed."),
|
|
730
|
+
})
|
|
731
|
+
.strict(),
|
|
732
|
+
formDateField("date", 'YYYY-MM-DD, such as "2026-10-01"'),
|
|
733
|
+
formDateField("time", '24-hour HH:mm, such as "09:30"'),
|
|
734
|
+
formDateField(
|
|
735
|
+
"datetime",
|
|
736
|
+
'an ISO 8601 date and time with Z or an offset, seconds optional, such as "2026-10-01T09:30Z"'
|
|
737
|
+
),
|
|
738
|
+
z
|
|
739
|
+
.object({
|
|
740
|
+
...formFieldBase("boolean"),
|
|
741
|
+
default: z.boolean().optional().describe("Whether the checkbox starts checked."),
|
|
742
|
+
})
|
|
743
|
+
.strict(),
|
|
744
|
+
z
|
|
745
|
+
.object({
|
|
746
|
+
...formFieldBase("select"),
|
|
747
|
+
default: z.string().optional().describe("The option id selected at first."),
|
|
748
|
+
options: formOptions,
|
|
749
|
+
})
|
|
750
|
+
.strict(),
|
|
751
|
+
z
|
|
752
|
+
.object({
|
|
753
|
+
...formFieldBase("multiselect"),
|
|
754
|
+
default: z
|
|
755
|
+
.array(z.string())
|
|
756
|
+
.optional()
|
|
757
|
+
.describe("The option ids selected at first, each listed once."),
|
|
758
|
+
options: formOptions,
|
|
759
|
+
})
|
|
760
|
+
.strict(),
|
|
761
|
+
]);
|
|
762
|
+
|
|
763
|
+
export type FormField = z.infer<typeof formFieldSchema>;
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Field ids are unique, length and number bounds have their minimum at or below their maximum,
|
|
767
|
+
* option ids are unique within a field, and every default is a value its field accepts.
|
|
768
|
+
*/
|
|
769
|
+
const checkFormInput = (input: {fields: FormField[]}, ctx: z.RefinementCtx): void => {
|
|
770
|
+
checkDuplicateIds({
|
|
771
|
+
ctx,
|
|
772
|
+
fix: "Give every field a unique id.",
|
|
773
|
+
ids: input.fields.map((field) => field.id),
|
|
774
|
+
message: (id, firstIndex) =>
|
|
775
|
+
`Field id ${quoteValue(id)} is already used by ${formatAskPath(["fields", firstIndex])}.`,
|
|
776
|
+
segments: (index) => ["fields", index, "id"],
|
|
777
|
+
});
|
|
778
|
+
input.fields.forEach((field, index) => {
|
|
779
|
+
if ((field.type === "text" || field.type === "textarea") && field.minLength !== undefined) {
|
|
780
|
+
const max = formTextMaxLength(field);
|
|
781
|
+
if (field.minLength > max) {
|
|
782
|
+
ctx.addIssue(
|
|
783
|
+
askIssue({
|
|
784
|
+
code: "RANGE_INVALID",
|
|
785
|
+
fix: "Lower minLength, or raise maxLength.",
|
|
786
|
+
message: `minLength (${field.minLength}) is more than maxLength (${max}).`,
|
|
787
|
+
segments: ["fields", index, "minLength"],
|
|
788
|
+
})
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (
|
|
793
|
+
field.type === "number" &&
|
|
794
|
+
field.min !== undefined &&
|
|
795
|
+
field.max !== undefined &&
|
|
796
|
+
field.min > field.max
|
|
797
|
+
) {
|
|
798
|
+
ctx.addIssue(
|
|
799
|
+
askIssue({
|
|
800
|
+
code: "RANGE_INVALID",
|
|
801
|
+
fix: "Lower min, or raise max.",
|
|
802
|
+
message: `min (${field.min}) is more than max (${field.max}).`,
|
|
803
|
+
segments: ["fields", index, "min"],
|
|
804
|
+
})
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
if (field.type === "select" || field.type === "multiselect") {
|
|
808
|
+
checkDuplicateIds({
|
|
809
|
+
ctx,
|
|
810
|
+
fix: "Give every option of the field a unique id.",
|
|
811
|
+
ids: field.options.map((option) => option.id),
|
|
812
|
+
message: (id, firstIndex) =>
|
|
813
|
+
`Option id ${quoteValue(id)} is already used by ${formatAskPath(["fields", index, "options", firstIndex])}.`,
|
|
814
|
+
segments: (optionIndex) => ["fields", index, "options", optionIndex, "id"],
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
if (field.default === undefined) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
for (const draft of checkFormValue({
|
|
821
|
+
field,
|
|
822
|
+
isDefault: true,
|
|
823
|
+
segments: ["fields", index, "default"],
|
|
824
|
+
value: field.default,
|
|
825
|
+
})) {
|
|
826
|
+
ctx.addIssue(askIssue(draft));
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
/** Input for `ask_form`: a few flat fields the user fills in and submits at once. */
|
|
832
|
+
export const formAskInputSchema = z
|
|
833
|
+
.object({
|
|
834
|
+
...sharedAskFields,
|
|
835
|
+
fields: z
|
|
836
|
+
.array(formFieldSchema)
|
|
837
|
+
.min(ASK_LIMITS.form.fieldsMin)
|
|
838
|
+
.max(ASK_LIMITS.form.fieldsMax)
|
|
839
|
+
.describe(
|
|
840
|
+
`The fields, in display order: ${ASK_LIMITS.form.fieldsMin}-${ASK_LIMITS.form.fieldsMax} items. Each has a type (${FORM_FIELD_TYPES.join(", ")}).`
|
|
841
|
+
),
|
|
842
|
+
})
|
|
843
|
+
.strict()
|
|
844
|
+
.superRefine(checkFormInput);
|
|
845
|
+
|
|
846
|
+
export type FormAskInput = z.infer<typeof formAskInputSchema>;
|
|
847
|
+
|
|
848
|
+
/** The `content` of an accepted answer from any client, before its values meet their fields. */
|
|
849
|
+
export const formAnswerEnvelopeSchema = z
|
|
850
|
+
.object({values: z.record(z.string(), z.unknown())})
|
|
851
|
+
.strict();
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* The `content` of an accepted `form` answer: one value per answered field, keyed by field id.
|
|
855
|
+
* Unanswered optional fields are left out.
|
|
856
|
+
*/
|
|
857
|
+
export const formAnswerSchema = z
|
|
858
|
+
.object({
|
|
859
|
+
values: z.record(
|
|
860
|
+
z.string(),
|
|
861
|
+
z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
|
862
|
+
),
|
|
863
|
+
})
|
|
864
|
+
.strict();
|
|
865
|
+
|
|
866
|
+
export type FormAnswer = z.infer<typeof formAnswerSchema>;
|
|
867
|
+
|
|
868
|
+
/** The fields of a `files` input that its count rules read. */
|
|
869
|
+
interface FilesRuleInput {
|
|
870
|
+
accept: AskFileAccept[];
|
|
871
|
+
maxFiles?: number;
|
|
872
|
+
minFiles?: number;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** How many files an answer to a `files` ask must carry: 1 to 10 unless the ask narrows it. */
|
|
876
|
+
export const filesCountBounds = (input: FilesRuleInput): {max: number; min: number} => ({
|
|
877
|
+
max: input.maxFiles ?? ASK_LIMITS.files.maxFiles,
|
|
878
|
+
min: input.minFiles ?? ASK_LIMITS.files.minFiles,
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
const checkFilesInput = (input: FilesRuleInput, ctx: z.RefinementCtx): void => {
|
|
882
|
+
checkDuplicateIds({
|
|
883
|
+
ctx,
|
|
884
|
+
fix: "List each accept value once.",
|
|
885
|
+
ids: input.accept,
|
|
886
|
+
message: (value, firstIndex) =>
|
|
887
|
+
`${quoteValue(value)} is already listed at ${formatAskPath(["accept", firstIndex])}.`,
|
|
888
|
+
segments: (index) => ["accept", index],
|
|
889
|
+
});
|
|
890
|
+
const {max, min} = filesCountBounds(input);
|
|
891
|
+
if (min <= max) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const limit =
|
|
895
|
+
input.maxFiles === undefined ? `the ${max}-file cap` : `maxFiles (${input.maxFiles})`;
|
|
896
|
+
ctx.addIssue(
|
|
897
|
+
askIssue({
|
|
898
|
+
code: "RANGE_INVALID",
|
|
899
|
+
fix: "Lower minFiles, or raise maxFiles.",
|
|
900
|
+
message: `minFiles (${min}) is more than ${limit}.`,
|
|
901
|
+
segments: ["minFiles"],
|
|
902
|
+
})
|
|
903
|
+
);
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
const fileCountField = (description: string) =>
|
|
907
|
+
z
|
|
908
|
+
.number()
|
|
909
|
+
.int()
|
|
910
|
+
.min(ASK_LIMITS.files.minFiles)
|
|
911
|
+
.max(ASK_LIMITS.files.maxFiles)
|
|
912
|
+
.optional()
|
|
913
|
+
.describe(description);
|
|
914
|
+
|
|
915
|
+
/** Input for `ask_files`: the user uploads one or more images or documents. */
|
|
916
|
+
export const filesAskInputSchema = z
|
|
917
|
+
.object({
|
|
918
|
+
...sharedAskFields,
|
|
919
|
+
accept: z
|
|
920
|
+
.array(z.enum(ASK_FILE_ACCEPT))
|
|
921
|
+
.min(1)
|
|
922
|
+
.max(ASK_FILE_ACCEPT.length)
|
|
923
|
+
.describe(
|
|
924
|
+
'The kinds of file the user may send, each listed once: "image" (JPEG, PNG, GIF, WebP), "pdf", "text" (plain text), "csv", or "json".'
|
|
925
|
+
),
|
|
926
|
+
maxFiles: fileCountField(
|
|
927
|
+
`The most files the user may send: ${ASK_LIMITS.files.minFiles}-${ASK_LIMITS.files.maxFiles}. Defaults to ${ASK_LIMITS.files.maxFiles}.`
|
|
928
|
+
),
|
|
929
|
+
minFiles: fileCountField(
|
|
930
|
+
`The fewest files the user must send: ${ASK_LIMITS.files.minFiles}-${ASK_LIMITS.files.maxFiles}. Defaults to ${ASK_LIMITS.files.minFiles}.`
|
|
931
|
+
),
|
|
932
|
+
})
|
|
933
|
+
.strict()
|
|
934
|
+
.superRefine(checkFilesInput);
|
|
935
|
+
|
|
936
|
+
export type FilesAskInput = z.infer<typeof filesAskInputSchema>;
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Exactly one of `fileId` and `url` names the bytes. A `url` must be a base64 `data:` URL whose
|
|
940
|
+
* media type is the declared `mimeType`, so the server never fetches a remote URL.
|
|
941
|
+
*/
|
|
942
|
+
const checkFileRef = (ref: {fileId?: string; url?: string}, ctx: z.RefinementCtx): void => {
|
|
943
|
+
if (ref.fileId === undefined && ref.url === undefined) {
|
|
944
|
+
ctx.addIssue(
|
|
945
|
+
askIssue({
|
|
946
|
+
code: "MISSING_REQUIRED",
|
|
947
|
+
fix: 'Add "fileId" (an upload from POST /files/upload) or "url" (a base64 data: URL).',
|
|
948
|
+
message: "A file needs a fileId or a url.",
|
|
949
|
+
segments: [],
|
|
950
|
+
})
|
|
951
|
+
);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (ref.fileId !== undefined && ref.url !== undefined) {
|
|
955
|
+
ctx.addIssue(
|
|
956
|
+
askIssue({
|
|
957
|
+
code: "INVALID_FORMAT",
|
|
958
|
+
fix: "Send fileId or url, not both.",
|
|
959
|
+
message: "A file has both a fileId and a url.",
|
|
960
|
+
segments: ["url"],
|
|
961
|
+
})
|
|
962
|
+
);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (ref.url !== undefined && parseAskDataUrl(ref.url) === undefined) {
|
|
966
|
+
ctx.addIssue(
|
|
967
|
+
askIssue({
|
|
968
|
+
code: "INVALID_FORMAT",
|
|
969
|
+
fix: 'Send the bytes as a base64 data: URL, such as "data:image/png;base64,...".',
|
|
970
|
+
message: "url is not a base64 data: URL.",
|
|
971
|
+
segments: ["url"],
|
|
972
|
+
})
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
/** One file in an accepted `files` answer: an upload (`fileId`) or inline bytes (`url`). */
|
|
978
|
+
export const askFileRefSchema = z
|
|
979
|
+
.object({
|
|
980
|
+
fileId: z
|
|
981
|
+
.string()
|
|
982
|
+
.min(1)
|
|
983
|
+
.max(64)
|
|
984
|
+
.optional()
|
|
985
|
+
.describe("The id of the user's upload from POST /files/upload."),
|
|
986
|
+
filename: visibleText(ASK_LIMITS.files.filenameMaxLength).describe(
|
|
987
|
+
"The file's name as the user picked it."
|
|
988
|
+
),
|
|
989
|
+
mimeType: z.string().min(1).max(127).describe('The file\'s MIME type, such as "image/png".'),
|
|
990
|
+
size: z.number().int().min(0).describe("The file's size in bytes."),
|
|
991
|
+
url: z.string().optional().describe("The file's bytes as a base64 data: URL."),
|
|
992
|
+
})
|
|
993
|
+
.strict()
|
|
994
|
+
.superRefine(checkFileRef);
|
|
995
|
+
|
|
996
|
+
export type AskFileRef = z.infer<typeof askFileRefSchema>;
|
|
997
|
+
|
|
998
|
+
/** The `content` of an accepted `files` answer: the files the user sent, in the order they chose. */
|
|
999
|
+
export const filesAnswerSchema = z.object({files: z.array(askFileRefSchema)}).strict();
|
|
1000
|
+
|
|
1001
|
+
export type FilesAnswer = z.infer<typeof filesAnswerSchema>;
|
|
1002
|
+
|
|
1003
|
+
/** The `content` of an accepted `choice` answer. */
|
|
1004
|
+
export const choiceAnswerSchema = z
|
|
1005
|
+
.object({
|
|
1006
|
+
other: visibleText(ASK_LIMITS.choice.otherMaxLength).optional(),
|
|
1007
|
+
selected: z.array(z.string()).max(ASK_LIMITS.choice.optionsMax),
|
|
1008
|
+
})
|
|
1009
|
+
.strict();
|
|
1010
|
+
|
|
1011
|
+
export type ChoiceAnswer = z.infer<typeof choiceAnswerSchema>;
|
|
1012
|
+
|
|
1013
|
+
export const askAcceptResponseSchema = z
|
|
1014
|
+
.object({
|
|
1015
|
+
action: z.literal("accept"),
|
|
1016
|
+
content: z.record(z.string(), z.unknown()),
|
|
1017
|
+
})
|
|
1018
|
+
.strict();
|
|
1019
|
+
|
|
1020
|
+
export const askDeclineResponseSchema = z.object({action: z.literal("decline")}).strict();
|
|
1021
|
+
|
|
1022
|
+
export const askCancelResponseSchema = z
|
|
1023
|
+
.object({
|
|
1024
|
+
action: z.literal("cancel"),
|
|
1025
|
+
reason: visibleText(ASK_LIMITS.cancelReasonMaxLength).optional(),
|
|
1026
|
+
})
|
|
1027
|
+
.strict();
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* The answer envelope for every ask (the MCP elicitation shape). `accept` carries the kind's
|
|
1031
|
+
* answer in `content`; `decline` means the user pressed Skip; `cancel` means the ask was dropped.
|
|
1032
|
+
*/
|
|
1033
|
+
export const askResponseSchema = z.discriminatedUnion("action", [
|
|
1034
|
+
askAcceptResponseSchema,
|
|
1035
|
+
askDeclineResponseSchema,
|
|
1036
|
+
askCancelResponseSchema,
|
|
1037
|
+
]);
|
|
1038
|
+
|
|
1039
|
+
export type AskResponse = z.infer<typeof askResponseSchema>;
|
|
1040
|
+
|
|
1041
|
+
/** The answer envelope for `ask_choice`, with `content` typed. Used as the tool's output schema. */
|
|
1042
|
+
export const choiceAskResponseSchema = z.discriminatedUnion("action", [
|
|
1043
|
+
z.object({action: z.literal("accept"), content: choiceAnswerSchema}).strict(),
|
|
1044
|
+
askDeclineResponseSchema,
|
|
1045
|
+
askCancelResponseSchema,
|
|
1046
|
+
]);
|
|
1047
|
+
|
|
1048
|
+
export type ChoiceAskResponse = z.infer<typeof choiceAskResponseSchema>;
|
|
1049
|
+
|
|
1050
|
+
/** The `content` of an accepted `confirm` answer: true approves the action, false denies it. */
|
|
1051
|
+
export const confirmAnswerSchema = z.object({confirmed: z.boolean()}).strict();
|
|
1052
|
+
|
|
1053
|
+
export type ConfirmAnswer = z.infer<typeof confirmAnswerSchema>;
|
|
1054
|
+
|
|
1055
|
+
/** The answer envelope for `ask_confirm`, with `content` typed. Used as the tool's output schema. */
|
|
1056
|
+
export const confirmAskResponseSchema = z.discriminatedUnion("action", [
|
|
1057
|
+
z.object({action: z.literal("accept"), content: confirmAnswerSchema}).strict(),
|
|
1058
|
+
askDeclineResponseSchema,
|
|
1059
|
+
askCancelResponseSchema,
|
|
1060
|
+
]);
|
|
1061
|
+
|
|
1062
|
+
export type ConfirmAskResponse = z.infer<typeof confirmAskResponseSchema>;
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* The `content` of an accepted `markdown` answer: the text the user sends back, and whether it
|
|
1066
|
+
* differs from the ask's `initial` draft.
|
|
1067
|
+
*/
|
|
1068
|
+
export const markdownAnswerSchema = z
|
|
1069
|
+
.object({
|
|
1070
|
+
changed: z.boolean(),
|
|
1071
|
+
markdown: z.string().max(ASK_LIMITS.markdown.maxLength),
|
|
1072
|
+
})
|
|
1073
|
+
.strict()
|
|
1074
|
+
.superRefine((answer, ctx) => {
|
|
1075
|
+
checkMarkdownCap({ctx, segments: ["markdown"], value: answer.markdown});
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
export type MarkdownAnswer = z.infer<typeof markdownAnswerSchema>;
|
|
1079
|
+
|
|
1080
|
+
/** The answer envelope for `ask_markdown`, with `content` typed. Used as the tool's output schema. */
|
|
1081
|
+
export const markdownAskResponseSchema = z.discriminatedUnion("action", [
|
|
1082
|
+
z.object({action: z.literal("accept"), content: markdownAnswerSchema}).strict(),
|
|
1083
|
+
askDeclineResponseSchema,
|
|
1084
|
+
askCancelResponseSchema,
|
|
1085
|
+
]);
|
|
1086
|
+
|
|
1087
|
+
export type MarkdownAskResponse = z.infer<typeof markdownAskResponseSchema>;
|
|
1088
|
+
|
|
1089
|
+
/** The answer envelope for `ask_form`, with `content` typed. Used as the tool's output schema. */
|
|
1090
|
+
export const formAskResponseSchema = z.discriminatedUnion("action", [
|
|
1091
|
+
z.object({action: z.literal("accept"), content: formAnswerSchema}).strict(),
|
|
1092
|
+
askDeclineResponseSchema,
|
|
1093
|
+
askCancelResponseSchema,
|
|
1094
|
+
]);
|
|
1095
|
+
|
|
1096
|
+
export type FormAskResponse = z.infer<typeof formAskResponseSchema>;
|
|
1097
|
+
|
|
1098
|
+
/** The answer envelope for `ask_files`, with `content` typed. Used as the tool's output schema. */
|
|
1099
|
+
export const filesAskResponseSchema = z.discriminatedUnion("action", [
|
|
1100
|
+
z.object({action: z.literal("accept"), content: filesAnswerSchema}).strict(),
|
|
1101
|
+
askDeclineResponseSchema,
|
|
1102
|
+
askCancelResponseSchema,
|
|
1103
|
+
]);
|
|
1104
|
+
|
|
1105
|
+
export type FilesAskResponse = z.infer<typeof filesAskResponseSchema>;
|
|
1106
|
+
|
|
1107
|
+
/** Input schemas by kind. */
|
|
1108
|
+
export const askInputSchemas = {
|
|
1109
|
+
choice: choiceAskInputSchema,
|
|
1110
|
+
confirm: confirmAskInputSchema,
|
|
1111
|
+
files: filesAskInputSchema,
|
|
1112
|
+
form: formAskInputSchema,
|
|
1113
|
+
markdown: markdownAskInputSchema,
|
|
1114
|
+
} as const satisfies Record<AskKind, z.ZodType>;
|
|
1115
|
+
|
|
1116
|
+
/** Input schemas by kind on the compact surface. Every compact input is also a valid full input. */
|
|
1117
|
+
export const compactAskInputSchemas = {
|
|
1118
|
+
choice: compactChoiceAskInputSchema,
|
|
1119
|
+
// A confirm always fits a simple card (two buttons with labels that show uncut).
|
|
1120
|
+
confirm: confirmAskInputSchema,
|
|
1121
|
+
} as const satisfies Record<CompactAskKind, z.ZodType>;
|
|
1122
|
+
|
|
1123
|
+
export const isCompactAskKind = (kind: AskKind): kind is CompactAskKind =>
|
|
1124
|
+
(COMPACT_ASK_KINDS as readonly AskKind[]).includes(kind);
|
|
1125
|
+
|
|
1126
|
+
/** The kinds a surface offers, in the given order. The compact surface drops kinds it cannot show. */
|
|
1127
|
+
export const askKindsForSurface = ({
|
|
1128
|
+
kinds,
|
|
1129
|
+
surface,
|
|
1130
|
+
}: {
|
|
1131
|
+
kinds: readonly AskKind[];
|
|
1132
|
+
surface: AskSurface;
|
|
1133
|
+
}): AskKind[] => (surface === "compact" ? kinds.filter(isCompactAskKind) : [...kinds]);
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* The input schema for a kind on a surface. Throws for an unknown kind or surface, and when the
|
|
1137
|
+
* compact surface does not offer the kind.
|
|
1138
|
+
*/
|
|
1139
|
+
export const askInputSchemaFor = ({
|
|
1140
|
+
kind,
|
|
1141
|
+
surface = "full",
|
|
1142
|
+
}: {
|
|
1143
|
+
kind: AskKind;
|
|
1144
|
+
surface?: AskSurface;
|
|
1145
|
+
}): (typeof askInputSchemas)[AskKind] | (typeof compactAskInputSchemas)[CompactAskKind] => {
|
|
1146
|
+
if (!Object.hasOwn(askInputSchemas, kind)) {
|
|
1147
|
+
throw new Error(`Unknown ask kind "${String(kind)}".`);
|
|
1148
|
+
}
|
|
1149
|
+
if (!ASK_SURFACES.includes(surface)) {
|
|
1150
|
+
throw new Error(`Unknown ask surface "${String(surface)}".`);
|
|
1151
|
+
}
|
|
1152
|
+
if (surface === "full") {
|
|
1153
|
+
return askInputSchemas[kind];
|
|
1154
|
+
}
|
|
1155
|
+
if (!isCompactAskKind(kind)) {
|
|
1156
|
+
throw new Error(`The compact surface does not offer ask kind "${String(kind)}".`);
|
|
1157
|
+
}
|
|
1158
|
+
return compactAskInputSchemas[kind];
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
/** Output (answer envelope) schemas by kind. */
|
|
1162
|
+
export const askOutputSchemas = {
|
|
1163
|
+
choice: choiceAskResponseSchema,
|
|
1164
|
+
confirm: confirmAskResponseSchema,
|
|
1165
|
+
files: filesAskResponseSchema,
|
|
1166
|
+
form: formAskResponseSchema,
|
|
1167
|
+
markdown: markdownAskResponseSchema,
|
|
1168
|
+
} as const satisfies Record<AskKind, z.ZodType>;
|
|
1169
|
+
|
|
1170
|
+
export interface ChoiceAsk {
|
|
1171
|
+
input: ChoiceAskInput;
|
|
1172
|
+
kind: "choice";
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
export interface ConfirmAsk {
|
|
1176
|
+
input: ConfirmAskInput;
|
|
1177
|
+
kind: "confirm";
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
export interface MarkdownAsk {
|
|
1181
|
+
input: MarkdownAskInput;
|
|
1182
|
+
kind: "markdown";
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
export interface FormAsk {
|
|
1186
|
+
input: FormAskInput;
|
|
1187
|
+
kind: "form";
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
export interface FilesAsk {
|
|
1191
|
+
input: FilesAskInput;
|
|
1192
|
+
kind: "files";
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** A validated ask: its kind and its input. */
|
|
1196
|
+
export type Ask = ChoiceAsk | ConfirmAsk | MarkdownAsk | FormAsk | FilesAsk;
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Whether the user may skip the ask. `allowDecline` defaults to true, except on `confirm`, where
|
|
1200
|
+
* deny is already the negative answer.
|
|
1201
|
+
*/
|
|
1202
|
+
export const askAllowsDecline = ({input, kind}: Ask): boolean =>
|
|
1203
|
+
kind === "confirm" ? input.allowDecline === true : input.allowDecline !== false;
|