@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,281 @@
|
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
2
|
+
import {readdirSync, readFileSync} from "node:fs";
|
|
3
|
+
import {join} from "node:path";
|
|
4
|
+
|
|
5
|
+
import {parseBlocks} from "./parse";
|
|
6
|
+
import {wrapAsTextDocument} from "./schema";
|
|
7
|
+
import {validateBlocks} from "./validate";
|
|
8
|
+
|
|
9
|
+
const fixturesDir = join(import.meta.dir, "fixtures");
|
|
10
|
+
|
|
11
|
+
const expectedInvalid: Record<string, {code: string; path: string}[]> = {
|
|
12
|
+
"column-not-found.yaml": [{code: "COLUMN_NOT_FOUND", path: "blocks[0].x"}],
|
|
13
|
+
"column-type.yaml": [{code: "COLUMN_TYPE_MISMATCH", path: "blocks[0].y"}],
|
|
14
|
+
"dataset-not-found.yaml": [{code: "DATASET_NOT_FOUND", path: "blocks[0].data"}],
|
|
15
|
+
"dataset-too-large.yaml": [{code: "DATASET_TOO_LARGE", path: "datasets.signups.columns"}],
|
|
16
|
+
"depth-exceeded.yaml": [{code: "DEPTH_EXCEEDED", path: "blocks[0].children[0]"}],
|
|
17
|
+
"duplicate-id.yaml": [{code: "DUPLICATE_ID", path: "blocks[1].id"}],
|
|
18
|
+
"invalid-enum.yaml": [{code: "INVALID_ENUM", path: "blocks[0].size"}],
|
|
19
|
+
"key-order.yaml": [{code: "KEY_ORDER", path: "blocks"}],
|
|
20
|
+
"missing-required.yaml": [{code: "MISSING_REQUIRED", path: "blocks[0].markdown"}],
|
|
21
|
+
"not-a-document.txt": [{code: "NOT_A_DOCUMENT", path: ""}],
|
|
22
|
+
"row-arity.yaml": [{code: "ROW_ARITY_MISMATCH", path: "datasets.signups.rows[0]"}],
|
|
23
|
+
"select-target.yaml": [
|
|
24
|
+
{code: "SELECT_TARGET_INVALID", path: "blocks[0].elements[0].action.target"},
|
|
25
|
+
],
|
|
26
|
+
"table-too-wide.yaml": [{code: "TABLE_TOO_WIDE", path: "blocks[0].columns"}],
|
|
27
|
+
"too-many-points.yaml": [{code: "TOO_MANY_POINTS", path: "datasets.signups.limit"}],
|
|
28
|
+
"unknown-key.yaml": [{code: "UNKNOWN_KEY", path: "blocks[0].color"}],
|
|
29
|
+
"unsupported-version.yaml": [{code: "UNSUPPORTED_VERSION", path: "v"}],
|
|
30
|
+
"yaml-anchor.yaml": [{code: "YAML_FEATURE_DISALLOWED", path: ""}],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("parseBlocks", () => {
|
|
34
|
+
it("parses a prose-only document and validates it with zero errors", () => {
|
|
35
|
+
const proseDocument = readFileSync(join(fixturesDir, "valid", "prose.yaml"), "utf8");
|
|
36
|
+
const parsed = parseBlocks(proseDocument);
|
|
37
|
+
expect(parsed.ok).toBe(true);
|
|
38
|
+
if (!parsed.ok) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const validated = validateBlocks(parsed.value);
|
|
42
|
+
expect(validated.ok).toBe(true);
|
|
43
|
+
if (!validated.ok) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
expect(validated.doc).toEqual({
|
|
47
|
+
blocks: [
|
|
48
|
+
{
|
|
49
|
+
markdown: "I could not find any signups for that range.",
|
|
50
|
+
type: "text",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
v: 1,
|
|
54
|
+
});
|
|
55
|
+
expect(wrapAsTextDocument("Hello")).toEqual({
|
|
56
|
+
blocks: [{markdown: "Hello", type: "text"}],
|
|
57
|
+
v: 1,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("strips a surrounding fence and accepts JSON", () => {
|
|
62
|
+
for (const fence of ["yaml", "yml", "json", "YAML"]) {
|
|
63
|
+
const parsed = parseBlocks(`\`\`\`${fence}\nv: 1\nblocks:\n - type: divider\n\`\`\``);
|
|
64
|
+
expect(parsed.ok).toBe(true);
|
|
65
|
+
if (!parsed.ok) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
expect(validateBlocks(parsed.value).ok).toBe(true);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const json = parseBlocks('{"v":1,"blocks":[{"type":"heading","text":"Hi"}]}');
|
|
72
|
+
expect(json.ok).toBe(true);
|
|
73
|
+
if (!json.ok) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const validated = validateBlocks(json.value);
|
|
77
|
+
expect(validated.ok).toBe(true);
|
|
78
|
+
if (!validated.ok) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
expect(validated.doc.blocks).toEqual([{text: "Hi", type: "heading"}]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("block fixtures", () => {
|
|
86
|
+
it("validates every valid fixture with zero errors", () => {
|
|
87
|
+
const dir = join(fixturesDir, "valid");
|
|
88
|
+
const names = readdirSync(dir).filter((name) => name.endsWith(".yaml"));
|
|
89
|
+
expect(names.length).toBeGreaterThan(0);
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const parsed = parseBlocks(readFileSync(join(dir, name), "utf8"));
|
|
92
|
+
expect(parsed.ok).toBe(true);
|
|
93
|
+
if (!parsed.ok) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const validated = validateBlocks(parsed.value);
|
|
97
|
+
expect(validated.ok).toBe(true);
|
|
98
|
+
}
|
|
99
|
+
const layout = parseBlocks(readFileSync(join(dir, "layout.yaml"), "utf8"));
|
|
100
|
+
expect(layout.ok).toBe(true);
|
|
101
|
+
if (!layout.ok) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const validated = validateBlocks(layout.value);
|
|
105
|
+
expect(validated.ok).toBe(true);
|
|
106
|
+
if (!validated.ok) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
expect(validated.doc).toEqual({
|
|
110
|
+
blocks: [
|
|
111
|
+
{size: "lg", text: "Signups this quarter", type: "heading"},
|
|
112
|
+
{markdown: "Signups grew **12%** quarter over quarter.", type: "text"},
|
|
113
|
+
{
|
|
114
|
+
children: [
|
|
115
|
+
{delta: "+12%", label: "Total", trend: "up", type: "metric", value: "403"},
|
|
116
|
+
{label: "Best month", type: "metric", value: "Feb"},
|
|
117
|
+
],
|
|
118
|
+
type: "columns",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
children: [
|
|
122
|
+
{status: "info", text: "Draft", type: "badge"},
|
|
123
|
+
{text: "February was the strongest month.", type: "context"},
|
|
124
|
+
{type: "divider"},
|
|
125
|
+
],
|
|
126
|
+
title: "Notes",
|
|
127
|
+
type: "card",
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
v: 1,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("fails each invalid fixture with the expected path and code", () => {
|
|
135
|
+
const dir = join(fixturesDir, "invalid");
|
|
136
|
+
const names = readdirSync(dir);
|
|
137
|
+
expect(names.sort()).toEqual(Object.keys(expectedInvalid).sort());
|
|
138
|
+
for (const name of names) {
|
|
139
|
+
const parsed = parseBlocks(readFileSync(join(dir, name), "utf8"));
|
|
140
|
+
const errors = parsed.ok
|
|
141
|
+
? validateBlocks(parsed.value).ok
|
|
142
|
+
? []
|
|
143
|
+
: validateBlocks(parsed.value).errors
|
|
144
|
+
: parsed.errors;
|
|
145
|
+
expect(errors.map((item) => ({code: item.code, path: item.path}))).toEqual(
|
|
146
|
+
expectedInvalid[name]
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const orderedDocument = (blocks: unknown[]): Record<string, unknown> => {
|
|
153
|
+
const doc: Record<string, unknown> = {};
|
|
154
|
+
doc.v = 1;
|
|
155
|
+
doc.blocks = blocks;
|
|
156
|
+
return doc;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
describe("validateBlocks limits", () => {
|
|
160
|
+
it("accepts 50 blocks and rejects 51, including nested blocks", () => {
|
|
161
|
+
const fifty = Array.from({length: 50}, () => ({type: "divider"}));
|
|
162
|
+
expect(validateBlocks(orderedDocument(fifty)).ok).toBe(true);
|
|
163
|
+
|
|
164
|
+
const fiftyOne = Array.from({length: 51}, () => ({type: "divider"}));
|
|
165
|
+
const flat = validateBlocks(orderedDocument(fiftyOne));
|
|
166
|
+
expect(flat.ok).toBe(false);
|
|
167
|
+
if (flat.ok) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
expect(flat.errors.map((item) => ({code: item.code, path: item.path}))).toEqual([
|
|
171
|
+
{code: "TOO_MANY_BLOCKS", path: "blocks"},
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
const children = Array.from({length: 50}, (_unused, index) => ({
|
|
175
|
+
text: `note ${index}`,
|
|
176
|
+
type: "context",
|
|
177
|
+
}));
|
|
178
|
+
const nested = validateBlocks(orderedDocument([{children, type: "card"}]));
|
|
179
|
+
expect(nested.ok).toBe(false);
|
|
180
|
+
if (nested.ok) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
expect(nested.errors.map((item) => ({code: item.code, path: item.path}))).toEqual([
|
|
184
|
+
{code: "TOO_MANY_BLOCKS", path: "blocks"},
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("rejects empty input, a second document, and a mapping without v", () => {
|
|
189
|
+
expect(parseBlocks("").errors).toEqual([
|
|
190
|
+
expect.objectContaining({code: "NOT_A_DOCUMENT", path: ""}),
|
|
191
|
+
]);
|
|
192
|
+
expect(parseBlocks("v: 1\n---\nv: 1\n").errors).toEqual([
|
|
193
|
+
expect.objectContaining({code: "NOT_A_DOCUMENT", path: ""}),
|
|
194
|
+
]);
|
|
195
|
+
expect(parseBlocks("blocks:\n - type: divider\n").errors).toEqual([
|
|
196
|
+
expect.objectContaining({code: "NOT_A_DOCUMENT", path: ""}),
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("rejects a custom YAML tag", () => {
|
|
201
|
+
const parsed = parseBlocks("v: 1\nblocks: !foo []\n");
|
|
202
|
+
expect(parsed.ok).toBe(false);
|
|
203
|
+
if (parsed.ok) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
expect(parsed.errors.map((item) => item.code)).toEqual(["YAML_FEATURE_DISALLOWED"]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("describes a ref id and a zero limit with the rules those fields use", () => {
|
|
210
|
+
const refDocument: Record<string, unknown> = {v: 1};
|
|
211
|
+
refDocument.datasets = {signups: {id: "bad.id", source: "ref"}};
|
|
212
|
+
refDocument.blocks = [{type: "divider"}];
|
|
213
|
+
const refId = validateBlocks(refDocument);
|
|
214
|
+
expect(refId.ok).toBe(false);
|
|
215
|
+
if (!refId.ok) {
|
|
216
|
+
const idError = refId.errors.find((error) => error.path === "datasets.signups.id");
|
|
217
|
+
expect(idError?.code).toBe("INVALID_FORMAT");
|
|
218
|
+
expect(idError?.fix).toContain("hyphens");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const limitDocument: Record<string, unknown> = {v: 1};
|
|
222
|
+
limitDocument.datasets = {signups: {id: "ds_signups", limit: 0, source: "ref"}};
|
|
223
|
+
limitDocument.blocks = [{type: "divider"}];
|
|
224
|
+
const limit = validateBlocks(limitDocument);
|
|
225
|
+
expect(limit.ok).toBe(false);
|
|
226
|
+
if (!limit.ok) {
|
|
227
|
+
const limitError = limit.errors.find((error) => error.path === "datasets.signups.limit");
|
|
228
|
+
expect(limitError?.code).toBe("INVALID_TYPE");
|
|
229
|
+
expect(limitError?.fix).toContain("integer");
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("maps type, size, format, and union failures onto closed codes", () => {
|
|
234
|
+
const long = "x".repeat(201);
|
|
235
|
+
const cases: {code: string; doc: Record<string, unknown>}[] = [
|
|
236
|
+
{
|
|
237
|
+
code: "INVALID_TYPE",
|
|
238
|
+
doc: {blocks: [{size: "md", text: 1, type: "heading"}], v: 1},
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
code: "TOO_MANY",
|
|
242
|
+
doc: {
|
|
243
|
+
blocks: [{children: Array.from({length: 5}, () => ({type: "divider"})), type: "columns"}],
|
|
244
|
+
v: 1,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
code: "TOO_LONG",
|
|
249
|
+
doc: {blocks: [{text: long, type: "heading"}], v: 1},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
code: "TOO_FEW",
|
|
253
|
+
doc: {blocks: [{children: [{type: "divider"}], type: "columns"}], v: 1},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
code: "TOO_SHORT",
|
|
257
|
+
doc: {blocks: [{text: "", type: "heading"}], v: 1},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
code: "TOO_SHORT",
|
|
261
|
+
doc: {blocks: [{text: " ", type: "heading"}], v: 1},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
code: "INVALID_FORMAT",
|
|
265
|
+
doc: {blocks: [{id: "Bad", text: "Hi", type: "heading"}], v: 1},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
code: "INVALID_ENUM",
|
|
269
|
+
doc: {blocks: [{type: "nope"}], v: 1},
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
for (const {code, doc} of cases) {
|
|
273
|
+
const validated = validateBlocks(doc);
|
|
274
|
+
expect(validated.ok).toBe(false);
|
|
275
|
+
if (validated.ok) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
expect(validated.errors.some((error) => error.code === code)).toBe(true);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {type Document, parseAllDocuments, visit} from "yaml";
|
|
2
|
+
|
|
3
|
+
import {BLOCK_ERROR_CODES, type BlockError} from "./errors";
|
|
4
|
+
|
|
5
|
+
const FENCE = /^```(?:yaml|yml|json)?[^\n]*\n([\s\S]*?)\n?```$/i;
|
|
6
|
+
|
|
7
|
+
const notADocument = (): BlockError => ({
|
|
8
|
+
code: "NOT_A_DOCUMENT",
|
|
9
|
+
fix: "Reply with one document: v, then optional datasets, then blocks.",
|
|
10
|
+
message: BLOCK_ERROR_CODES.NOT_A_DOCUMENT,
|
|
11
|
+
path: "",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const yamlFeatureDisallowed = (): BlockError => ({
|
|
15
|
+
code: "YAML_FEATURE_DISALLOWED",
|
|
16
|
+
fix: "Remove YAML anchors, aliases, and custom tags. Use plain mappings and sequences.",
|
|
17
|
+
message: BLOCK_ERROR_CODES.YAML_FEATURE_DISALLOWED,
|
|
18
|
+
path: "",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** Drops one surrounding markdown fence so a model that adds one still parses. */
|
|
22
|
+
export const stripBlocksFence = (text: string): string => {
|
|
23
|
+
const trimmed = text.trim();
|
|
24
|
+
const match = FENCE.exec(trimmed);
|
|
25
|
+
if (match?.[1] !== undefined) {
|
|
26
|
+
return match[1];
|
|
27
|
+
}
|
|
28
|
+
return trimmed;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const hasExplicitTag = (tag: string | null | undefined): boolean =>
|
|
32
|
+
typeof tag === "string" && tag.length > 0;
|
|
33
|
+
|
|
34
|
+
const usesDisallowedYaml = (doc: Document): boolean => {
|
|
35
|
+
const hasTagError = doc.errors.some(
|
|
36
|
+
(error) => error.code === "TAG_RESOLVE_FAILED" || error.code === "BAD_ALIAS"
|
|
37
|
+
);
|
|
38
|
+
if (hasTagError) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
let disallowed = false;
|
|
42
|
+
visit(doc, {
|
|
43
|
+
Alias() {
|
|
44
|
+
disallowed = true;
|
|
45
|
+
},
|
|
46
|
+
Map(_key, node) {
|
|
47
|
+
if (node.anchor || hasExplicitTag(node.tag)) {
|
|
48
|
+
disallowed = true;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
Scalar(_key, node) {
|
|
52
|
+
if (node.anchor || hasExplicitTag(node.tag)) {
|
|
53
|
+
disallowed = true;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
Seq(_key, node) {
|
|
57
|
+
if (node.anchor || hasExplicitTag(node.tag)) {
|
|
58
|
+
disallowed = true;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
return disallowed;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const isPlainMapping = (value: unknown): value is Record<string, unknown> => {
|
|
66
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return Object.getPrototypeOf(value) === Object.prototype;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type ParseBlocksResult = {errors: BlockError[]; ok: false} | {ok: true; value: unknown};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parses one assistant reply into a plain value.
|
|
76
|
+
* A surrounding markdown fence is ignored. Anchors, aliases, and custom tags are rejected.
|
|
77
|
+
* Anything that is not a mapping with `v` is `NOT_A_DOCUMENT`.
|
|
78
|
+
*/
|
|
79
|
+
export const parseBlocks = (text: string): ParseBlocksResult => {
|
|
80
|
+
const stripped = stripBlocksFence(text);
|
|
81
|
+
if (stripped.trim() === "") {
|
|
82
|
+
return {errors: [notADocument()], ok: false};
|
|
83
|
+
}
|
|
84
|
+
const docs = parseAllDocuments(stripped, {
|
|
85
|
+
schema: "core",
|
|
86
|
+
strict: true,
|
|
87
|
+
uniqueKeys: true,
|
|
88
|
+
});
|
|
89
|
+
const doc = docs[0];
|
|
90
|
+
if (docs.length !== 1 || doc === undefined) {
|
|
91
|
+
return {errors: [notADocument()], ok: false};
|
|
92
|
+
}
|
|
93
|
+
if (usesDisallowedYaml(doc)) {
|
|
94
|
+
return {errors: [yamlFeatureDisallowed()], ok: false};
|
|
95
|
+
}
|
|
96
|
+
if (doc.errors.length > 0) {
|
|
97
|
+
return {errors: [notADocument()], ok: false};
|
|
98
|
+
}
|
|
99
|
+
const value: unknown = doc.toJS();
|
|
100
|
+
if (!isPlainMapping(value) || !Object.hasOwn(value, "v")) {
|
|
101
|
+
return {errors: [notADocument()], ok: false};
|
|
102
|
+
}
|
|
103
|
+
return {ok: true, value};
|
|
104
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
2
|
+
import {readFileSync} from "node:fs";
|
|
3
|
+
import {join} from "node:path";
|
|
4
|
+
|
|
5
|
+
import {parseBlocksPartial} from "./parsePartial";
|
|
6
|
+
|
|
7
|
+
const partialDir = join(import.meta.dir, "fixtures", "partial");
|
|
8
|
+
|
|
9
|
+
describe("parseBlocksPartial", () => {
|
|
10
|
+
it("returns every block of a finished document and pending false", () => {
|
|
11
|
+
const text = "v: 1\nblocks:\n - type: divider\n - type: heading\n text: Done\n";
|
|
12
|
+
expect(parseBlocksPartial(text)).toEqual({
|
|
13
|
+
blocks: [{type: "divider"}, {text: "Done", type: "heading"}],
|
|
14
|
+
datasets: undefined,
|
|
15
|
+
pending: false,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("keeps completed top-level blocks and drops a cut tail", () => {
|
|
20
|
+
const text = readFileSync(join(partialDir, "cut-after-divider.yaml"), "utf8");
|
|
21
|
+
const result = parseBlocksPartial(text);
|
|
22
|
+
expect(result.pending).toBe(true);
|
|
23
|
+
expect(result.blocks).toEqual([{type: "divider"}]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns datasets only after the datasets section has closed", () => {
|
|
27
|
+
const open = parseBlocksPartial("v: 1\ndatasets:\n signups:\n source: ref\n id: ds_1\n");
|
|
28
|
+
expect(open).toEqual({blocks: [], pending: true});
|
|
29
|
+
|
|
30
|
+
const closed = parseBlocksPartial(
|
|
31
|
+
[
|
|
32
|
+
"v: 1",
|
|
33
|
+
"datasets:",
|
|
34
|
+
" signups:",
|
|
35
|
+
" source: ref",
|
|
36
|
+
" id: ds_1",
|
|
37
|
+
"blocks:",
|
|
38
|
+
" - type: divider",
|
|
39
|
+
" - type: text",
|
|
40
|
+
' markdown: "still',
|
|
41
|
+
"",
|
|
42
|
+
].join("\n")
|
|
43
|
+
);
|
|
44
|
+
expect(closed.pending).toBe(true);
|
|
45
|
+
expect(closed.blocks).toEqual([{type: "divider"}]);
|
|
46
|
+
expect(closed.datasets).toEqual({signups: {id: "ds_1", source: "ref"}});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("never throws on garbage", () => {
|
|
50
|
+
expect(parseBlocksPartial("::: not yaml")).toEqual({blocks: [], pending: true});
|
|
51
|
+
expect(parseBlocksPartial("")).toEqual({blocks: [], pending: true});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {parseBlocks, stripBlocksFence} from "./parse";
|
|
2
|
+
import {validateBlocks} from "./validate";
|
|
3
|
+
|
|
4
|
+
export interface ParseBlocksPartialResult {
|
|
5
|
+
blocks: unknown[];
|
|
6
|
+
datasets?: Record<string, unknown>;
|
|
7
|
+
pending: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const emptyPending = (): ParseBlocksPartialResult => ({blocks: [], pending: true});
|
|
11
|
+
|
|
12
|
+
const isPlainMapping = (value: unknown): value is Record<string, unknown> => {
|
|
13
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return Object.getPrototypeOf(value) === Object.prototype;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const datasetsFrom = (value: unknown): Record<string, unknown> | undefined => {
|
|
20
|
+
if (!isPlainMapping(value) || !isPlainMapping(value.datasets)) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
return value.datasets;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const blocksFrom = (value: unknown): unknown[] | undefined => {
|
|
27
|
+
if (!isPlainMapping(value) || !Array.isArray(value.blocks)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return value.blocks;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Top-level block items are the lines indented exactly two spaces that start a sequence entry.
|
|
35
|
+
* Nested lists sit deeper, so they stay inside the current item.
|
|
36
|
+
*/
|
|
37
|
+
const topLevelItems = (body: string): string[] => {
|
|
38
|
+
const items: string[] = [];
|
|
39
|
+
let current: string[] | null = null;
|
|
40
|
+
for (const line of body.split("\n")) {
|
|
41
|
+
if (line.startsWith(" - ")) {
|
|
42
|
+
if (current !== null) {
|
|
43
|
+
items.push(current.join("\n"));
|
|
44
|
+
}
|
|
45
|
+
current = [line];
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (current !== null) {
|
|
49
|
+
current.push(line);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (current !== null) {
|
|
53
|
+
items.push(current.join("\n"));
|
|
54
|
+
}
|
|
55
|
+
return items;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const blockFromItem = (item: string): unknown | undefined => {
|
|
59
|
+
const parsed = parseBlocks(`v: 1\nblocks:\n${item}\n`);
|
|
60
|
+
if (!parsed.ok) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const blocks = blocksFrom(parsed.value);
|
|
64
|
+
return blocks?.[0];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reads a truncated reply while it is still streaming.
|
|
69
|
+
* A finished, valid document returns every block with `pending: false`.
|
|
70
|
+
* A cut reply returns only the top-level blocks that already ended (the next `- ` arrived)
|
|
71
|
+
* and sets `pending: true`. It never throws.
|
|
72
|
+
*/
|
|
73
|
+
export const parseBlocksPartial = (text: string): ParseBlocksPartialResult => {
|
|
74
|
+
try {
|
|
75
|
+
const stripped = stripBlocksFence(text);
|
|
76
|
+
if (stripped.trim() === "") {
|
|
77
|
+
return emptyPending();
|
|
78
|
+
}
|
|
79
|
+
const parsed = parseBlocks(stripped);
|
|
80
|
+
if (parsed.ok) {
|
|
81
|
+
const blocks = blocksFrom(parsed.value);
|
|
82
|
+
if (blocks !== undefined && validateBlocks(parsed.value).ok) {
|
|
83
|
+
return {
|
|
84
|
+
blocks,
|
|
85
|
+
datasets: datasetsFrom(parsed.value),
|
|
86
|
+
pending: false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lines = stripped.split("\n");
|
|
92
|
+
const blocksLine = lines.findIndex((line) => line === "blocks:" || line.startsWith("blocks: "));
|
|
93
|
+
if (blocksLine < 0) {
|
|
94
|
+
return emptyPending();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const prefix = lines.slice(0, blocksLine).join("\n");
|
|
98
|
+
let datasets: Record<string, unknown> | undefined;
|
|
99
|
+
if (prefix.includes("datasets:")) {
|
|
100
|
+
const closed = parseBlocks(`${prefix}\nblocks:\n - type: divider\n`);
|
|
101
|
+
if (closed.ok) {
|
|
102
|
+
datasets = datasetsFrom(closed.value);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const items = topLevelItems(lines.slice(blocksLine + 1).join("\n"));
|
|
107
|
+
const completeItems = items.slice(0, -1);
|
|
108
|
+
const blocks = completeItems.flatMap((item) => {
|
|
109
|
+
const block = blockFromItem(item);
|
|
110
|
+
return block === undefined ? [] : [block];
|
|
111
|
+
});
|
|
112
|
+
return {blocks, datasets, pending: true};
|
|
113
|
+
} catch {
|
|
114
|
+
return emptyPending();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {BLOCK_LIMITS} from "./limits";
|
|
4
|
+
import {blocksPromptSection} from "./prompt";
|
|
5
|
+
|
|
6
|
+
describe("blocksPromptSection", () => {
|
|
7
|
+
it("embeds the reply rule, the ref rule, both examples, and every limit", () => {
|
|
8
|
+
const section = blocksPromptSection({hostActions: ["exportDataset"]});
|
|
9
|
+
expect(section).toContain(
|
|
10
|
+
"Your entire reply is one document; no prose outside it; no text before tool calls."
|
|
11
|
+
);
|
|
12
|
+
expect(section).toContain("Use the datasetId a tool returned; never paste more than 500 rows.");
|
|
13
|
+
expect(section).toContain("exportDataset");
|
|
14
|
+
expect(section).toContain("Show weekly signups for this quarter");
|
|
15
|
+
expect(section).toContain("I could not find any signups for that range.");
|
|
16
|
+
for (const value of Object.values(BLOCK_LIMITS)) {
|
|
17
|
+
expect(section).toContain(String(value));
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("says no callbacks are registered when the host passes none", () => {
|
|
22
|
+
expect(blocksPromptSection()).toContain("No callback names are registered.");
|
|
23
|
+
});
|
|
24
|
+
});
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {BLOCK_LIMITS} from "./limits";
|
|
2
|
+
|
|
3
|
+
const DATA_EXAMPLE = `v: 1
|
|
4
|
+
datasets:
|
|
5
|
+
signups:
|
|
6
|
+
columns:
|
|
7
|
+
- name: month
|
|
8
|
+
type: string
|
|
9
|
+
- name: count
|
|
10
|
+
type: number
|
|
11
|
+
rows:
|
|
12
|
+
- [Jan, 120]
|
|
13
|
+
- [Feb, 180]
|
|
14
|
+
blocks:
|
|
15
|
+
- type: chart
|
|
16
|
+
id: signups_chart
|
|
17
|
+
kind: bar
|
|
18
|
+
data: signups
|
|
19
|
+
x: month
|
|
20
|
+
y: count
|
|
21
|
+
title: Signups by month
|
|
22
|
+
- type: actions
|
|
23
|
+
id: followups
|
|
24
|
+
elements:
|
|
25
|
+
- type: button
|
|
26
|
+
id: weekly
|
|
27
|
+
text: Show weekly
|
|
28
|
+
action:
|
|
29
|
+
kind: reply
|
|
30
|
+
text: Show weekly signups for this quarter`;
|
|
31
|
+
|
|
32
|
+
const PROSE_EXAMPLE = `v: 1
|
|
33
|
+
blocks:
|
|
34
|
+
- type: text
|
|
35
|
+
markdown: I could not find any signups for that range. Try a wider date window.`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The system-prompt section a host prepends when a reply must be one block document.
|
|
39
|
+
* Every cap is read from `BLOCK_LIMITS` so the prompt, the schema, and the reference stay aligned.
|
|
40
|
+
*/
|
|
41
|
+
export const blocksPromptSection = ({
|
|
42
|
+
allowHtml = false,
|
|
43
|
+
hostActions = [],
|
|
44
|
+
imageHosts = [],
|
|
45
|
+
}: {
|
|
46
|
+
allowHtml?: boolean;
|
|
47
|
+
hostActions?: readonly string[];
|
|
48
|
+
imageHosts?: readonly string[];
|
|
49
|
+
} = {}): string => {
|
|
50
|
+
const callbacks =
|
|
51
|
+
hostActions.length === 0
|
|
52
|
+
? "No callback names are registered. Do not emit kind callback."
|
|
53
|
+
: `A callback name must be one of: ${hostActions.join(", ")}.`;
|
|
54
|
+
const imageHostsLine =
|
|
55
|
+
imageHosts.length === 0
|
|
56
|
+
? "Do not emit an https image src. Use a data:image URL or a file: ref."
|
|
57
|
+
: `An https image src must use one of these hosts: ${imageHosts.join(", ")}.`;
|
|
58
|
+
return [
|
|
59
|
+
"Your entire reply is one document; no prose outside it; no text before tool calls.",
|
|
60
|
+
"The document is YAML or JSON. Keys, when present, are in this order: v, datasets, blocks.",
|
|
61
|
+
"v is 1.",
|
|
62
|
+
allowHtml
|
|
63
|
+
? "Block types: heading, text, metric, badge, divider, context, chart, table, actions, columns, card, html, callout, image, details."
|
|
64
|
+
: "Block types: heading, text, metric, badge, divider, context, chart, table, actions, columns, card, callout, image, details. Do not emit type html.",
|
|
65
|
+
`An html field is at most ${BLOCK_LIMITS.htmlMaxBytes} bytes.`,
|
|
66
|
+
...(allowHtml
|
|
67
|
+
? [
|
|
68
|
+
"html requires html and may set title and height (sm, md, or lg). It is a display preview: no scripts, no links, no network.",
|
|
69
|
+
]
|
|
70
|
+
: []),
|
|
71
|
+
"callout requires text and may set status to info, warning, or alert.",
|
|
72
|
+
"image requires alt and src. src is a data:image URL, a file: ref, or an https URL on an allowed host.",
|
|
73
|
+
imageHostsLine,
|
|
74
|
+
"details requires title and text.",
|
|
75
|
+
"heading requires text. text requires markdown. metric requires label and value. badge requires text. context requires text.",
|
|
76
|
+
"chart kind is line, bar, area, or donut. Bind it with data, x, and y, or with points of label and value.",
|
|
77
|
+
"table requires data, the name of a dataset. actions requires id and elements.",
|
|
78
|
+
"An element is a button (id, text, action) or a segmented control (id, target, options of label and data).",
|
|
79
|
+
"action.kind is reply (text), open (url or route, exactly one), select (target and data), or callback (name and optional payload).",
|
|
80
|
+
"columns has 2 to 4 children. card has children and an optional title. Do not nest columns or card inside columns or card.",
|
|
81
|
+
`At most ${BLOCK_LIMITS.maxBlocks} blocks, including nested blocks.`,
|
|
82
|
+
`Depth is at most ${BLOCK_LIMITS.maxDepth}.`,
|
|
83
|
+
`An actions block has at most ${BLOCK_LIMITS.actionElementsMax} elements.`,
|
|
84
|
+
`columns has ${BLOCK_LIMITS.columnsMin} to ${BLOCK_LIMITS.columnsMax} children.`,
|
|
85
|
+
`At most ${BLOCK_LIMITS.maxDatasets} datasets.`,
|
|
86
|
+
`An inline dataset has at most ${BLOCK_LIMITS.datasetRowMax} rows and ${BLOCK_LIMITS.datasetColumnMax} columns.`,
|
|
87
|
+
`A ref dataset limit is at most ${BLOCK_LIMITS.refLimitMax}.`,
|
|
88
|
+
`A text block has at most ${BLOCK_LIMITS.blockTextMaxLength} characters.`,
|
|
89
|
+
`A document has at most ${BLOCK_LIMITS.documentTextMaxLength} characters of text.`,
|
|
90
|
+
`Heading text is at most ${BLOCK_LIMITS.headingTextMaxLength} characters.`,
|
|
91
|
+
`Badge text is at most ${BLOCK_LIMITS.badgeTextMaxLength} characters.`,
|
|
92
|
+
`Context text is at most ${BLOCK_LIMITS.contextTextMaxLength} characters.`,
|
|
93
|
+
`A card title is at most ${BLOCK_LIMITS.cardTitleMaxLength} characters.`,
|
|
94
|
+
`A metric label is at most ${BLOCK_LIMITS.metricLabelMaxLength} characters, its value ${BLOCK_LIMITS.metricValueMaxLength}, its delta ${BLOCK_LIMITS.metricDeltaMaxLength}, and its helper ${BLOCK_LIMITS.metricHelperMaxLength}.`,
|
|
95
|
+
`A bar chart warns above ${BLOCK_LIMITS.barCategoryWarning} categories.`,
|
|
96
|
+
`A donut chart warns above ${BLOCK_LIMITS.donutSliceWarning} slices.`,
|
|
97
|
+
"Use the datasetId a tool returned; never paste more than 500 rows.",
|
|
98
|
+
callbacks,
|
|
99
|
+
"Example with data:",
|
|
100
|
+
DATA_EXAMPLE,
|
|
101
|
+
"Example with prose only:",
|
|
102
|
+
PROSE_EXAMPLE,
|
|
103
|
+
].join("\n");
|
|
104
|
+
};
|