diff --git a/.gitignore b/.gitignore index 31c0ccfe..e4d3a351 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ src/*.js.map # TypeDoc generated documentation src/docs/ + +# Transient dirs of scripts/generate.js (left behind only on failure) +src/.schema-*/ diff --git a/.prettierignore b/.prettierignore index 7ec938b3..97dd45d8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,5 @@ src/examples/README.md .release-please-manifest.json release-please-config.json CHANGELOG.md + +src/.schema-*/ diff --git a/eslint.config.js b/eslint.config.js index 5085967a..c96c212b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,7 @@ export default [ "*.config.js", ".github/", "src/schema.ts", + "src/.schema-*/", ], }, js.configs.recommended, @@ -46,6 +47,11 @@ export default [ ], "no-console": "off", "no-constant-condition": "off", + // TS checks redeclaration itself (ts2451), and the core rule false-positives + // on same-name type + value declarations (schema/guards.gen.ts merges a type + // and a const per extensible union). Matches typescript-eslint's own + // eslint-recommended override; the TS-aware variant also flags this pattern. + "no-redeclare": "off", "default-case": "error", "prefer-const": "error", "no-var": "error", diff --git a/scripts/generate.js b/scripts/generate.js index 40cb4f95..41e56782 100644 --- a/scripts/generate.js +++ b/scripts/generate.js @@ -12,6 +12,45 @@ import * as prettier from "prettier"; const CURRENT_SCHEMA_RELEASE = "schema-v1.19.0"; +// ── Extensible-union pipeline ──────────────────────────────────────────────── +// Several schemas model forward compatibility as an "extensible union": known +// const-tagged variants plus a catch-all whose normative `not` clause reserves +// the known tags, and whose `additionalProperties`/`unevaluatedProperties: +// true` makes a custom variant's extra keys its payload. hey-api drops both +// halves of that contract, so this script reconstructs them in stages that +// must be understood together: +// 1. annotateExtensibleUnions reads each `not` clause and stamps +// x-exclude-known-tags on the catch-all (x- attrs survive into hey-api's +// IR), translating declared openness into `additionalProperties` so the +// generated *types* carry the index signature. +// 2. The union/intersection $resolvers wrap the emitted zod validators: +// excludeKnownTags on the catch-all member (reject malformed known +// variants) and preserveCustomPayload around each annotated def (keep +// vendor payloads). Both helpers live in src/schema-deserialize.ts. +// 3. emitExtensibleUnionGuards writes src/schema/guards.gen.ts — validated, +// declaration-merged type guards consumers use to narrow the unions. +// Drift protection, each assertion guarding a different failure mode: +// - EXPECTED_EXTENSIBLE_UNIONS (below): detection missed a union in the raw +// schema, or found an unexpected one — update this list, the guard +// re-exports in src/acp.ts, and intentionallyNotExported in +// src/typedoc.json together. +// - The marker-count checks in main(): the annotation was lost in hey-api's +// IR, or a resolver misfired on a member it shouldn't touch. +// - The guards re-export test in src/acp.test.ts: a generated guard isn't +// reachable from the package entry. +const EXPECTED_EXTENSIBLE_UNIONS = [ + "CreateElicitationRequest", + "CreateElicitationResponse", + "ElicitationPropertySchema", + "MultiSelectItems", +]; + +// The x- attribute recording a catch-all variant's `not` exclusion; x- +// attributes survive into hey-api's IR, where the union resolver reads it. +// Declared before `await main()` below — anything main() calls must already +// be initialized. +const EXCLUDE_KNOWN_TAGS_ATTR = "x-exclude-known-tags"; + await main(); async function main() { @@ -27,8 +66,21 @@ async function main() { ); addExperimentalTags(jsonSchema); stripAnyOfDiscriminators(jsonSchema); + const defExclusions = annotateExtensibleUnions(jsonSchema.$defs); const schemaDefs = jsonSchema.$defs; + // Generate into a staging directory and swap into place only after every + // step (including the drift assertions below) has succeeded: hey-api wipes + // its output directory at the start of a run, so generating in place would + // leave src/schema wiped or half-written whenever anything here throws. + const schemaDir = "./src/schema"; + const stagingDir = "./src/.schema-staging"; + const previousDir = "./src/.schema-previous"; + // Belt-and-braces: hey-api's default clean also wipes its output directory; + // this covers leftovers if that default ever changes. + await fs.rm(stagingDir, { recursive: true, force: true }); + await fs.rm(previousDir, { recursive: true, force: true }); + await createClient({ input: { openapi: "3.1.0", @@ -40,30 +92,58 @@ async function main() { schemas: jsonSchema.$defs, }, }, + // No prettier postProcess: the spawned CLI would skip the staging dir + // (it is .prettierignore'd so a failed run's leftovers don't break + // format:check); formatStable below formats every file instead. output: { - path: "./src/schema", - postProcess: ["prettier"], + path: stagingDir, }, plugins: [ zodPlugin({ compatibilityVersion: 4, - $resolvers: createDeserializationResolvers(), + $resolvers: createDeserializationResolvers(defExclusions), }), transformers({ bigInt: false }), typescriptPlugin(), ], }); - const zodPath = "./src/schema/zod.gen.ts"; + const zodPath = `${stagingDir}/zod.gen.ts`; const zodSrc = await fs.readFile(zodPath, "utf8"); - const zod = await prettier.format(updateDocs(zodSrc, schemaDefs), { - parser: "typescript", - }); + // Consumers reading the wrapped validators' docs need the SDK-specific + // behavior that isn't in the schema descriptions: custom variants bypass + // the lenient-field salvage known variants get. + let zodDocs = updateDocs(zodSrc, schemaDefs); + for (const [name, exclusion] of defExclusions) { + zodDocs = appendDocNote( + zodDocs, + `export const z${name} =`, + `Custom variants (unknown \`${exclusion.key}\` values) keep their extra\n` + + `properties exactly as received; unlike known variants, those keys\n` + + `bypass lenient-field salvage and arrive unvalidated.`, + ); + } + const zod = await formatStable(zodDocs); + // The resolvers apply the catch-all exclusions and payload preservation + // annotated from the schema's `not` clauses. Too few means the annotation + // was lost in hey-api's IR (the union silently reverts to accepting + // malformed known variants as custom); too many means a resolver misfired + // on a member it shouldn't touch. Both directions fail here. + for (const marker of ["excludeKnownTags", "preserveCustomPayload"]) { + const found = (zod.match(new RegExp(`${marker}\\(`, "g")) ?? []).length; + if (found !== EXPECTED_EXTENSIBLE_UNIONS.length) { + throw new Error( + `Expected exactly ${EXPECTED_EXTENSIBLE_UNIONS.length} ${marker} ` + + `call sites in zod.gen.ts, found ${found}; the resolvers' ` + + `extensible-union handling may have drifted from the schema`, + ); + } + } await fs.writeFile(zodPath, zod); - const tsPath = "./src/schema/types.gen.ts"; + const tsPath = `${stagingDir}/types.gen.ts`; const tsSrc = await fs.readFile(tsPath, "utf8"); - const ts = await prettier.format( + const ts = await formatStable( updateDocs( tsSrc.replace( `export type ClientOptions`, @@ -71,26 +151,54 @@ async function main() { ), schemaDefs, ), - { parser: "typescript" }, ); await fs.writeFile(tsPath, ts); - const meta = await prettier.format( - `export const AGENT_METHODS = ${JSON.stringify(metadata.agentMethods, null, 2)} as const; + // Always write the file: the staging swap replaces the whole directory, so + // skipping the write here would silently delete guards.gen.ts. + const guardsSrc = emitExtensibleUnionGuards(schemaDefs); + const guards = await formatStable(guardsSrc); + await fs.writeFile(`${stagingDir}/guards.gen.ts`, guards); + + const meta = `export const AGENT_METHODS = ${JSON.stringify(metadata.agentMethods, null, 2)} as const; export const CLIENT_METHODS = ${JSON.stringify(metadata.clientMethods, null, 2)} as const; export const PROTOCOL_METHODS = ${JSON.stringify(metadata.protocolMethods, null, 2)} as const; export const PROTOCOL_VERSION = ${metadata.version}; -`, - { parser: "typescript" }, - ); - const indexPath = "./src/schema/index.ts"; +`; + const indexPath = `${stagingDir}/index.ts`; const indexSrc = await fs.readFile(indexPath, "utf8"); await fs.writeFile( indexPath, - `${indexSrc.replace(/\s*ClientOptions,/, "")}\n${meta}`, + await formatStable(`${indexSrc.replace(/\s*ClientOptions,/, "")}\n${meta}`), + ); + + // Rename-aside swap: a valid src/schema exists at every instant, so an + // interruption strands at worst an ignored dot-directory, never a missing + // schema. (ENOENT: a fresh checkout may have no schema dir to set aside.) + await fs.rename(schemaDir, previousDir).catch((error) => { + if (error.code !== "ENOENT") throw error; + }); + await fs.rename(stagingDir, schemaDir); + await fs.rm(previousDir, { recursive: true, force: true }); +} + +// Formats until prettier reaches a fixed point. Prettier's member-chain +// heuristic keeps chains that arrive pre-broken, so formatting hey-api's raw +// output once can produce a string that `prettier --check` would still +// reformat; one more pass converges. +async function formatStable(source) { + let current = source; + for (let i = 0; i < 3; i++) { + const formatted = await prettier.format(current, { parser: "typescript" }); + if (formatted === current) return formatted; + current = formatted; + } + throw new Error( + "prettier did not reach a formatting fixed point after 3 passes; " + + "the generated output would fail format:check", ); } @@ -163,25 +271,27 @@ function updateDocs(src, schemaDefs) { return result; } -function addExperimentalTags(value) { +// Pre-order recursive walk over a JSON schema document (objects and arrays). +function walkSchema(value, visit) { if (Array.isArray(value)) { - for (const item of value) addExperimentalTags(item); + for (const item of value) walkSchema(item, visit); return; } - if (!value || typeof value !== "object") return; + visit(value); + for (const child of Object.values(value)) walkSchema(child, visit); +} - if ( - typeof value.description === "string" && - value.description.includes("**UNSTABLE**") && - !value.description.includes("@experimental") - ) { - value.description += "\n\n@experimental"; - } - - for (const child of Object.values(value)) { - addExperimentalTags(child); - } +function addExperimentalTags(value) { + walkSchema(value, (node) => { + if ( + typeof node.description === "string" && + node.description.includes("**UNSTABLE**") && + !node.description.includes("@experimental") + ) { + node.description += "\n\n@experimental"; + } + }); } // hey-api treats `anyOf` + `discriminator` (without a `mapping`) as the OpenAPI @@ -192,23 +302,415 @@ function addExperimentalTags(value) { // is redundant for these schemas — their variants carry inline `const` tags — so // drop it before generation. function stripAnyOfDiscriminators(value) { - if (Array.isArray(value)) { - for (const item of value) stripAnyOfDiscriminators(item); - return; + walkSchema(value, (node) => { + if (node.anyOf && node.discriminator) { + delete node.discriminator; + } + }); +} + +// The catch-all variant of an extensible union carries a normative `not` +// clause excluding the known variants' discriminant tags, and declares itself +// open to arbitrary extra properties — the custom variant's vendor payload — +// via `additionalProperties: true` or, for variants that flatten another enum +// (where schemars must use the draft 2020-12 keyword that sees through +// subschemas), `unevaluatedProperties: true`. hey-api drops `not` from its IR +// and understands neither openness keyword on objects with `properties`, so +// both halves of the catch-all's contract are lost. Record the `not` +// exclusion as an x- attribute for the union resolver, and translate declared +// openness into `additionalProperties: true` so the generated *types* carry +// the index signature (runtime preservation happens in preserveCustomPayload, +// applied per named def via the returned map). +function annotateExtensibleUnions(schemaDefs) { + const defExclusions = new Map(); + for (const [name, def] of Object.entries(schemaDefs)) { + const exclusion = annotateUnionNode(def); + if (exclusion) defExclusions.set(name, exclusion); + for (const child of Object.values(def)) { + walkSchema(child, annotateUnionNode); + } } + return defExclusions; +} - if (!value || typeof value !== "object") return; +function annotateUnionNode(node) { + const variants = node.anyOf ?? node.oneOf; + if (!Array.isArray(variants)) return undefined; + + let exclusion; + for (const variant of variants) { + const found = notClauseExclusion(variant.not); + if (!found) continue; + variant[EXCLUDE_KNOWN_TAGS_ATTR] = found; + if ( + variant.properties && + (variant.additionalProperties === true || + variant.unevaluatedProperties === true) + ) { + variant.additionalProperties = true; + } + exclusion = found; + } + return exclusion; +} + +// Reads a catch-all variant's `not` clause: an anyOf of matchers, each pinning +// the shared discriminant to one known variant's const tag. +function notClauseExclusion(not) { + if (!not) return undefined; + const clauses = not.anyOf ?? [not]; + let key; + const tags = []; + for (const clause of clauses) { + for (const [prop, propSchema] of Object.entries(clause.properties ?? {})) { + if (typeof propSchema?.const !== "string") continue; + if (key && key !== prop) return undefined; + key = prop; + tags.push(propSchema.const); + } + } + return key ? { key, tags: tags.sort() } : undefined; +} + +// Several schemas model forward compatibility with an "extensible union": an +// `anyOf` of known, discriminant-tagged variants plus a catch-all variant +// (identified by its normative `not` clause — see annotateExtensibleUnions) +// whose discriminant is an untyped `string`. TypeScript +// cannot narrow these — a positive discriminant check (`x.action === "accept"`) +// never removes the catch-all, because its `action: string` is a supertype of the +// tested literal, and its `[key: string]: unknown` index signature then drags +// every property read down to `unknown`. There is no consumer-side check that +// fixes this (TS has no negated-literal types). +// +// A discriminant-only guard would also be *unsound*: responses are not validated +// by the SDK, so a malformed known variant (e.g. `{action:"accept"}` with a bad +// `content`) can reach consumers. Guards therefore validate the variant payload, +// not just its tag. +// +// For each such union we emit a companion value bound to the union's type name +// (declaration merging), exposing one validated guard per variant: +// +// if (CreateElicitationResponse.isAccept(response)) { +// response.content; // fully typed, not `unknown` +// } +// +// `isCustom` narrows to the catch-all: a tag no known variant uses (with a valid +// payload where the catch-all carries structure). A malformed known variant +// matches no guard — the same classification the wire validators apply via +// excludeKnownTags (see createDeserializationResolvers' union resolver). +function emitExtensibleUnionGuards(schemaDefs) { + const unions = []; + for (const [name, def] of Object.entries(schemaDefs)) { + const union = analyzeExtensibleUnion(name, def); + if (union) unions.push(union); + } - if (value.anyOf && value.discriminator) { - delete value.discriminator; + const detected = unions.map((union) => union.name).sort(); + const expected = [...EXPECTED_EXTENSIBLE_UNIONS].sort(); + if (JSON.stringify(detected) !== JSON.stringify(expected)) { + throw new Error( + `Extensible-union detection drifted from EXPECTED_EXTENSIBLE_UNIONS.\n` + + ` expected: ${expected.join(", ") || "(none)"}\n` + + ` detected: ${detected.join(", ") || "(none)"}\n` + + `If the schema legitimately changed, update EXPECTED_EXTENSIBLE_UNIONS in ` + + `scripts/generate.js, the guard re-exports in src/acp.ts, and ` + + `intentionallyNotExported in src/typedoc.json.`, + ); } + if (unions.length === 0) + return "// This file is auto-generated by scripts/generate.js\nexport {};\n"; + + const hoisted = []; + const namespaces = []; + + for (const union of unions) { + const methods = []; + + for (const variant of union.known) { + hoisted.push(`const ${variant.schemaConst} = ${variant.zodExpr};`); + methods.push( + ` /** Narrow to the \`${variant.label}\` variant, validating its payload. */\n` + + ` is${variant.pascal}(value: types.${union.name}): value is ${variant.tsType} {\n` + + ` return ${variant.checkExpr};\n` + + ` },`, + ); + } + + // isCustom mirrors wire validation: the catch-all variant excludes the + // known variants' tags, so a malformed known variant (right tag, wrong + // payload) matches no guard rather than being classified as custom. + const customChecks = [ + `typeof tag === "string"`, + `!${JSON.stringify(union.knownTags)}.includes(tag)`, + ]; + const customZodParts = [union.catchAll.zodExpr, union.commonZodExpr].filter( + Boolean, + ); + const customZodExpr = customZodParts.length + ? chainAnd(customZodParts) + : null; + if (customZodExpr) { + const schemaConst = `zGuard${union.name}Custom`; + hoisted.push(`const ${schemaConst} = ${customZodExpr};`); + customChecks.push(`${schemaConst}.safeParse(value).success`); + } + methods.push( + ` /**\n` + + ` * Narrow to a custom or future variant: the \`${union.discriminant}\` tag matches no known variant` + + `${customZodExpr ? ", with a valid payload" : ""}.\n` + + ` *\n` + + ` * TypeScript keeps the known variants in the narrowed union (they are\n` + + ` * structural subtypes of the catch-all), so read vendor payload keys\n` + + ` * via a widening cast: \`(value as Record).someKey\`.\n` + + ` */\n` + + ` isCustom(value: types.${union.name}): value is ${union.catchAll.tsType} {\n` + + ` const tag = tagOf(value, ${JSON.stringify(union.discriminant)});\n` + + ` return (\n ${customChecks.join(" &&\n ")}\n );\n` + + ` },`, + ); + + const guardDoc = + `Validated type guards for \`${union.name}\`'s known variants.\n\n` + + `Each guard validates the variant's payload, not just its discriminant\n` + + `tag: a malformed known variant (right tag, wrong payload) matches no\n` + + `guard — mirroring wire validation, which rejects such values instead\n` + + `of classifying them as custom.\n\n` + + `Guards check the value as given: fields that wire deserialization\n` + + `salvages to a default (e.g. a malformed \`_meta\`) are only normalized\n` + + `by parsing, and for ambiguous raw shapes (a known tag combined with\n` + + `another variant's payload) guards are conservative where wire parsing\n` + + `may still accept the value — narrow wire-parsed values when exact\n` + + `parity matters.` + + (union.description?.includes("@experimental") ? `\n\n@experimental` : ""); + + // Re-export the type next to the companion value so the two merge into one + // name. acp.ts re-exports this pair explicitly; that explicit re-export + // shadows the wildcard `export type *` of the same name, so the type must + // originate here too (a same-module `export type *` + `export type {}` would + // otherwise be a duplicate identifier). The alias carries the schema doc + // because the shadowed types.gen.ts declaration's JSDoc doesn't follow it. + namespaces.push( + `${formatJsdoc(union.description)}export type ${union.name} = types.${union.name};\n` + + `${formatJsdoc(guardDoc)}export const ${union.name} = {\n${methods.join("\n\n")}\n} as const;`, + ); + } + + return ( + `// This file is auto-generated by scripts/generate.js\n\n` + + `import * as z from "zod/v4";\n\n` + + `import type * as types from "./types.gen.js";\n` + + `import * as validate from "./zod.gen.js";\n\n` + + // Each guard checks the discriminant tag before running the variant's zod + // schema: it short-circuits non-matching variants cheaply, and it keeps + // known variants that carry no tag (e.g. titled multi-select items, whose + // schema would accept a custom-tagged value's extra keys) from swallowing + // custom variants. + `function tagOf(value: unknown, key: string): unknown {\n` + + ` return typeof value === "object" && value !== null\n` + + ` ? (value as Record)[key]\n` + + ` : undefined;\n` + + `}\n\n` + + `${hoisted.join("\n")}\n\n` + + `${namespaces.join("\n\n")}\n` + ); +} + +function analyzeExtensibleUnion(name, def) { + const variants = def.anyOf ?? def.oneOf; + if (!Array.isArray(variants)) return undefined; + + // The catch-all is the variant annotateExtensibleUnions marked from its + // normative `not` clause, which also names the discriminant and the tags the + // known variants reserve. A schema change that stops the annotation from + // applying skips the union here — and the EXPECTED_EXTENSIBLE_UNIONS + // assertion turns that skip into a generation failure. + const catchAllVariant = variants.find((v) => v[EXCLUDE_KNOWN_TAGS_ATTR]); + if (!catchAllVariant) return undefined; + const { key: discriminant, tags: knownTags } = + catchAllVariant[EXCLUDE_KNOWN_TAGS_ATTR]; + + const commonKeys = Object.keys(def.properties ?? {}); + const commonPick = commonKeys.length + ? ` & Pick JSON.stringify(k)).join(" | ")}>` + : ""; + // The guards' predicate types claim the def-level common properties via + // commonPick, so required (non-salvaged) ones must be validated too — a + // guard returning true for a `message`-less request while narrowing to + // `{ message: string }` would be unsound. + const commonZodExpr = requiredCommonPropsExpr(name, def); + + const known = variants + .filter((variant) => variant !== catchAllVariant) + .map((variant) => { + const refs = allOfRefs(variant); + const constValue = variant.properties?.[discriminant]?.const; + const label = + constValue !== undefined + ? String(constValue) + : (variant.title ?? refs[0] ?? discriminant); + + if (constValue === undefined && variant.properties?.[discriminant]) { + throw new Error( + `${name}: known variant "${label}" declares "${discriminant}" ` + + `without a const tag; analyzeExtensibleUnion cannot emit a sound guard for it`, + ); + } + // The `not` clause is the source of truth for which tags are reserved; + // a const-tagged variant it doesn't cover would make wire validation + // and the guards disagree about what counts as custom. + if (constValue !== undefined && !knownTags.includes(constValue)) { + throw new Error( + `${name}: known variant tag ${JSON.stringify(constValue)} is not ` + + `excluded by the catch-all's \`not\` clause (${knownTags.join(", ")})`, + ); + } - for (const child of Object.values(value)) { - stripAnyOfDiscriminators(child); + const typeParts = refs.map((ref) => `types.${ref}`); + const zodParts = refs.map((ref) => `validate.z${ref}`); + if (constValue !== undefined) { + typeParts.push(`{ ${discriminant}: ${JSON.stringify(constValue)} }`); + zodParts.push( + `z.object({ ${discriminant}: z.literal(${JSON.stringify(constValue)}) })`, + ); + } + if (zodParts.length === 0) { + throw new Error( + `${name}: known variant "${label}" has neither allOf $refs nor ` + + `a const-tagged "${discriminant}"; nothing to validate against`, + ); + } + if (commonZodExpr) zodParts.push(commonZodExpr); + + const pascal = pascalCase(label); + const schemaConst = `zGuard${name}${pascal}`; + // Known variants without a const tag (e.g. titled multi-select items) + // are identified by the *absence* of the discriminant — their zod schema + // alone would also accept custom-tagged values, since z.object ignores + // unknown keys. + const tagLiteral = + constValue !== undefined ? JSON.stringify(constValue) : "undefined"; + + return { + label, + pascal, + schemaConst, + tsType: `(${typeParts.join(" & ")})${commonPick}`, + zodExpr: chainAnd(zodParts), + checkExpr: + `tagOf(value, ${JSON.stringify(discriminant)}) === ${tagLiteral} &&\n` + + ` ${schemaConst}.safeParse(value).success`, + }; + }); + + // Reconstruct the catch-all's TS type so `isCustom`'s predicate stays assignable + // to the union, plus a zod expression for its payload when it carries structure + // beyond an open bag of properties (e.g. a nested scope union). + const catchAll = analyzeCatchAll(catchAllVariant, discriminant); + catchAll.tsType += commonPick; + + return { + name, + description: def.description, + discriminant, + knownTags, + known, + catchAll, + commonZodExpr, + }; +} + +// Zod for the def-level common properties that are required and not salvaged +// by deserialization defaults (e.g. CreateElicitationRequest's `message`). +// Salvaged (x-deserialize-default-on-error) props are deliberately excluded: +// wire parsing is lenient about them too, so a guard skipping them matches +// post-parse reality — the generated guard doc covers this. Only shapes the +// current schema uses get a mapping; anything else must fail loudly rather +// than emit a guard laxer than the wire schema (e.g. z.number() where the +// wire uses z.int()). +function requiredCommonPropsExpr(name, def) { + const required = (def.required ?? []).filter( + (prop) => !def.properties?.[prop]?.["x-deserialize-default-on-error"], + ); + if (required.length === 0) return null; + + const props = required.map((prop) => { + const schema = def.properties?.[prop]; + const expr = schema?.$ref + ? `validate.z${refName(schema.$ref)}` + : schema?.type === "string" + ? "z.string()" + : undefined; + if (!expr) { + throw new Error( + `${name}: required common property "${prop}" has an unsupported ` + + `shape for guard emission`, + ); + } + return `${prop}: ${expr}`; + }); + return `z.object({ ${props.join(", ")} })`; +} + +function analyzeCatchAll(variant, discriminant) { + const nested = variant.anyOf ?? variant.oneOf; + const refUnion = Array.isArray(nested) ? nested.flatMap(allOfRefs) : []; + const directRefs = allOfRefs(variant); + + if (directRefs.length === 0 && refUnion.length === 0) { + // Open bag of properties — matches the generated index-signature variant, + // and the discriminant check in isCustom is the whole validation. + return { + tsType: `({ ${discriminant}: string; [key: string]: unknown })`, + zodExpr: null, + }; } + + const tsRefs = + refUnion.length > 0 + ? `(${refUnion.map((ref) => `types.${ref}`).join(" | ")})` + : directRefs.map((ref) => `types.${ref}`).join(" & "); + const zodParts = + refUnion.length > 0 + ? [`z.union([${refUnion.map((ref) => `validate.z${ref}`).join(", ")}])`] + : directRefs.map((ref) => `validate.z${ref}`); + return { + // The index signature matches the generated union member: a custom + // variant's extra keys are its payload and survive parsing. + tsType: `(${tsRefs} & { ${discriminant}: string; [key: string]: unknown })`, + zodExpr: chainAnd(zodParts), + }; +} + +function allOfRefs(schema) { + return (schema.allOf ?? []) + .map((entry) => entry.$ref) + .filter(Boolean) + .map(refName); +} + +function chainAnd(zodParts) { + return ( + zodParts[0] + + zodParts + .slice(1) + .map((part) => `.and(${part})`) + .join("") + ); } -function createDeserializationResolvers() { +function refName(ref) { + return ref.split("/").pop(); +} + +function pascalCase(value) { + return String(value) + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} + +function createDeserializationResolvers(defExclusions) { return { array(ctx) { const base = ctx.schema["x-deserialize-skip-invalid-items"] @@ -282,9 +784,101 @@ function createDeserializationResolvers() { ctx.nodes.shape = defaultShape; return base; }, + + // Restores the catch-all exclusion recorded by annotateExtensibleUnions: + // without it the emitted union is laxer than the schema, and a malformed + // known variant (right tag, wrong payload) would parse via the catch-all + // as a "custom" variant. When the union IS an annotated named def (a pure + // union like MultiSelectItems), also wrap it in preserveCustomPayload so + // a custom variant's vendor payload survives the stripping parse. + union(ctx) { + let excluded = false; + ctx.schemas.forEach((member, index) => { + const exclusion = memberExclusion(member); + if (!exclusion) return; + const child = ctx.childResults[index]; + child.chain = deserializeWrap( + ctx, + "excludeKnownTags", + child.chain, + exclusion, + ); + excluded = true; + }); + + const defLevel = annotatedDefExclusion(ctx, defExclusions); + if (!excluded && !defLevel) return undefined; + + ctx.chain.current = ctx.nodes.base(ctx); + if (defLevel) { + ctx.chain.current = deserializeWrap( + ctx, + "preserveCustomPayload", + ctx.chain.current, + defLevel, + ); + } + return ctx.chain.current; + }, + + // Annotated defs that hey-api emits as intersections (a variant union + // combined with shared properties, e.g. CreateElicitationRequest) get + // their payload preservation here, at the def's outermost expression. + // Preservation must wrap the WHOLE def: re-attaching raw keys inside a + // union member would make zod's intersection merge throw on keys that a + // sibling schema parses to a different value (e.g. a salvaged `_meta`). + intersection(ctx) { + const defLevel = annotatedDefExclusion(ctx, defExclusions); + if (!defLevel) return undefined; + + ctx.chain.current = deserializeWrap( + ctx, + "preserveCustomPayload", + ctx.nodes.base(ctx), + defLevel, + ); + return ctx.chain.current; + }, }; } +// The exclusion annotation on a union member in hey-api's IR. When the +// catch-all composes a payload with its tag (allOf), the IR splits it into an +// `and` composite and copies peripheral attributes onto the items. +function memberExclusion(member) { + if (member[EXCLUDE_KNOWN_TAGS_ATTR]) return member[EXCLUDE_KNOWN_TAGS_ATTR]; + if (member.logicalOperator === "and" && Array.isArray(member.items)) { + return member.items.find((item) => item[EXCLUDE_KNOWN_TAGS_ATTR])?.[ + EXCLUDE_KNOWN_TAGS_ATTR + ]; + } + return undefined; +} + +// The exclusion for the extensible union a resolver is emitting, but only +// when it is the named def itself (path `components/schemas/`) — nested +// occurrences of the same shapes reference the def by $ref and must not be +// double-wrapped. +function annotatedDefExclusion(ctx, defExclusions) { + const segments = fromRef(ctx.path) ?? ctx.path; + if (!Array.isArray(segments) || segments.length !== 3) return undefined; + if (segments[0] !== "components" || segments[1] !== "schemas") { + return undefined; + } + return defExclusions.get(segments[2]); +} + +// Both schema-deserialize helpers share the (schema, key, knownTags) contract. +function deserializeWrap(ctx, helperName, expression, exclusion) { + return ctx + .$(schemaDeserializeSymbol(ctx.plugin, helperName)) + .call( + expression, + ctx.$.fromValue(exclusion.key), + ctx.$.fromValue(exclusion.tags), + ); +} + function shouldEmitNumberForBigIntFormat(format) { return format === "int64" || format === "uint64"; } @@ -444,9 +1038,27 @@ function injectDocIfMissing(src, exportStr, description) { const before = src.substring(0, idx); if (/\*\/\s*$/.test(before)) return src; + return src.slice(0, idx) + formatJsdoc(description) + src.slice(idx); +} + +function formatJsdoc(description) { + if (!description) return ""; const lines = description.split("\n"); - const jsdoc = - "/**\n" + lines.map((l) => (l ? ` * ${l}` : " *")).join("\n") + "\n */\n"; + return ( + "/**\n" + lines.map((l) => (l ? ` * ${l}` : " *")).join("\n") + "\n */\n" + ); +} + +// Appends a note to the doc block directly preceding `exportStr`, creating +// the block when none exists. +function appendDocNote(src, exportStr, note) { + const idx = src.indexOf(exportStr); + if (idx === -1) return src; + + const before = src.slice(0, idx); + if (!/\*\/\s*$/.test(before)) return injectDocIfMissing(src, exportStr, note); - return src.slice(0, idx) + jsdoc + src.slice(idx); + const closing = before.lastIndexOf("*/"); + const lines = note.split("\n").map((l) => (l ? ` * ${l}` : " *")); + return `${src.slice(0, closing)}*\n${lines.join("\n")}\n ${src.slice(closing)}`; } diff --git a/src/acp.test.ts b/src/acp.test.ts index e97dd8e1..d4b15dd2 100644 --- a/src/acp.test.ts +++ b/src/acp.test.ts @@ -11,6 +11,8 @@ import { zToolCall, } from "./schema/zod.gen.js"; import type { + zElicitationPropertySchema, + zMultiSelectItems, zNewSessionResponse, zSessionModeState, zSessionNotification, @@ -68,17 +70,22 @@ import { DisableProviderResponse, CreateElicitationRequest, CreateElicitationResponse, + ElicitationPropertySchema, + MultiSelectItems, CompleteElicitationNotification, RequestError, agent as createAgent, client as createClient, methods, } from "./acp.js"; +import * as sdk from "./acp.js"; +import * as guards from "./schema/guards.gen.js"; import { Connection } from "./jsonrpc.js"; import type { AgentContext, AnyMessage, ClientContext, + ElicitationContentValue, Plan, SessionModeState, SessionUpdate, @@ -6130,6 +6137,18 @@ describe("CreateElicitationRequest schema", () => { expect(result.success).toBe(true); }); + it("rejects malformed form-mode request instead of classifying it as custom", () => { + // The catch-all variant excludes known mode tags (the schema's `not` + // clause, restored by excludeKnownTags), so a form request missing its + // requestedSchema fails the union instead of parsing as a custom mode. + const result = zCreateElicitationRequest.safeParse({ + sessionId: "sess-1", + mode: "form", + message: "Enter your name", + }); + expect(result.success).toBe(false); + }); + it("rejects request without message", () => { const result = zCreateElicitationRequest.safeParse({ sessionId: "sess-1", @@ -6168,6 +6187,41 @@ describe("CreateElicitationRequest schema", () => { expect((result.data as any).customField).toBeUndefined(); } }); + + it("preserves a custom mode's vendor payload", () => { + // Known variants strip unknown keys (above), but a custom variant's extra + // properties are its payload (the schema's unevaluatedProperties: true) + // and must survive parsing. + const result = zCreateElicitationRequest.safeParse({ + sessionId: "sess-1", + mode: "_vendorMode", + message: "hi", + vendorForm: { fields: ["a", "b"] }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as Record).vendorForm).toEqual({ + fields: ["a", "b"], + }); + } + }); + + it("still salvages malformed lenient fields on custom modes", () => { + // Regression: payload preservation must not fight defaultOnError salvage + // in the scope union (previously threw from zod's intersection merge). + const result = zCreateElicitationRequest.safeParse({ + sessionId: "sess-1", + toolCallId: 123, + mode: "_vendorMode", + message: "hi", + }); + expect(result.success).toBe(true); + if (result.success) { + expect( + (result.data as Record).toolCallId, + ).toBeUndefined(); + } + }); }); describe("CreateElicitationResponse schema", () => { @@ -6206,6 +6260,36 @@ describe("CreateElicitationResponse schema", () => { }); expect(result.success).toBe(true); }); + + it("preserves a custom action's vendor payload", () => { + // Custom variants allow arbitrary extra properties (the schema's + // additionalProperties: true) — that's where the vendor payload lives, so + // parsing must not strip it. + const result = zCreateElicitationResponse.safeParse({ + action: "_vendor", + vendorData: { x: 1 }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as Record).vendorData).toEqual({ + x: 1, + }); + } + }); + + it("still salvages malformed lenient fields on custom actions", () => { + // Regression: payload preservation must not fight the intersection's + // defaultOnError salvage (zod's intersection merge throws if a member + // re-attaches a raw value another member parsed differently). + const result = zCreateElicitationResponse.safeParse({ + action: "_vendor", + _meta: 5, + }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as Record)._meta).toBeUndefined(); + } + }); }); describe("Schema deserialization compatibility", () => { @@ -6216,9 +6300,24 @@ describe("Schema deserialization compatibility", () => { AssertSchemaAssignable, AssertSchemaAssignable, AssertSchemaAssignable, - ] = [true, true, true, true, true]; - - expect(checks).toEqual([true, true, true, true, true]); + // The preserveCustomPayload wrapper severs zod inference behind a cast; + // these pin the wrapped schemas' declared types to types.gen.ts. + AssertSchemaAssignable< + typeof zCreateElicitationRequest, + CreateElicitationRequest + >, + AssertSchemaAssignable< + typeof zCreateElicitationResponse, + CreateElicitationResponse + >, + AssertSchemaAssignable< + typeof zElicitationPropertySchema, + ElicitationPropertySchema + >, + AssertSchemaAssignable, + ] = [true, true, true, true, true, true, true, true, true]; + + expect(checks.every(Boolean)).toBe(true); }); it("defaults invalid optional values to undefined", () => { @@ -6299,3 +6398,176 @@ describe("Schema deserialization compatibility", () => { expect(toolCall.locations).toEqual([{ path: "/tmp/file.ts", line: 1 }]); }); }); + +describe("extensible union narrowing helpers", () => { + // Generated by scripts/generate.js (guards.gen.ts). Extensible unions carry a + // catch-all variant that TypeScript cannot narrow past, so these guards + // validate the variant payload rather than just its discriminant tag. A + // malformed known variant (right tag, wrong payload) matches no guard, + // mirroring wire validation (excludeKnownTags), which rejects such values + // instead of classifying them as custom. + + describe("CreateElicitationResponse", () => { + it("narrows the accept variant and exposes typed content", () => { + const response: CreateElicitationResponse = { + action: "accept", + content: { name: "Ada" }, + }; + + expect(CreateElicitationResponse.isAccept(response)).toBe(true); + if (CreateElicitationResponse.isAccept(response)) { + // Compile-time proof that narrowing works: were the catch-all still in + // the union, `content` would be `unknown` and this assignment would fail. + const content: + { [key: string]: ElicitationContentValue } | null | undefined = + response.content; + expect(content).toEqual({ name: "Ada" }); + } + }); + + it("rejects a malformed accept payload (validates payload, not just the tag)", () => { + // `action` is the accept tag, but `content` holds a value that is not an + // ElicitationContentValue. A tag-only guard would wrongly accept this; + // it is not a custom variant either (the tag is reserved by `accept`), + // so no guard matches — the value is protocol-invalid. + const malformed: CreateElicitationResponse = { + action: "accept", + content: { bad: { nested: "object" } }, + }; + + expect(CreateElicitationResponse.isAccept(malformed)).toBe(false); + expect(CreateElicitationResponse.isCustom(malformed)).toBe(false); + + // Wire validation classifies it the same way: the catch-all excludes + // known tags, so the malformed accept fails the whole union. + expect(zCreateElicitationResponse.safeParse(malformed).success).toBe( + false, + ); + }); + + it("narrows decline and cancel", () => { + const decline: CreateElicitationResponse = { action: "decline" }; + const cancel: CreateElicitationResponse = { action: "cancel" }; + + expect(CreateElicitationResponse.isDecline(decline)).toBe(true); + expect(CreateElicitationResponse.isCancel(decline)).toBe(false); + expect(CreateElicitationResponse.isCancel(cancel)).toBe(true); + }); + + it("treats unknown/future actions as custom", () => { + const custom: CreateElicitationResponse = { + action: "_vendorAction", + extra: 1, + }; + + expect(CreateElicitationResponse.isAccept(custom)).toBe(false); + expect(CreateElicitationResponse.isDecline(custom)).toBe(false); + expect(CreateElicitationResponse.isCancel(custom)).toBe(false); + expect(CreateElicitationResponse.isCustom(custom)).toBe(true); + }); + }); + + describe("CreateElicitationRequest", () => { + it("narrows form and url modes and treats unknown modes as custom", () => { + const form: CreateElicitationRequest = { + sessionId: "sess-1", + mode: "form", + message: "Enter your name", + requestedSchema: { type: "object", properties: {} }, + }; + const custom: CreateElicitationRequest = { + sessionId: "sess-1", + mode: "_vendorMode", + message: "hi", + }; + + expect(CreateElicitationRequest.isForm(form)).toBe(true); + expect(CreateElicitationRequest.isUrl(form)).toBe(false); + expect(CreateElicitationRequest.isCustom(form)).toBe(false); + expect(CreateElicitationRequest.isCustom(custom)).toBe(true); + }); + + it("requires def-level common properties, not just the variant payload", () => { + // The predicate narrows to `& Pick<..., "message" | "_meta">`, so the + // guard must validate required common properties like `message` too. + const messageless = { + sessionId: "sess-1", + mode: "form", + requestedSchema: { type: "object", properties: {} }, + } as unknown as CreateElicitationRequest; + + expect(CreateElicitationRequest.isForm(messageless)).toBe(false); + expect(CreateElicitationRequest.isCustom(messageless)).toBe(false); + }); + + it("validates the catch-all's own payload, not just its unknown tag", () => { + // The catch-all still requires a session or request scope; isCustom's + // predicate narrows to that structure, so it must validate it. The cast + // is deliberate: this value is not representable in the union. + const scopeless = { + mode: "_vendorMode", + message: "hi", + } as CreateElicitationRequest; + + expect(CreateElicitationRequest.isCustom(scopeless)).toBe(false); + }); + }); + + describe("ElicitationPropertySchema", () => { + it("narrows by JSON Schema type and validates the payload", () => { + const stringProp: ElicitationPropertySchema = { type: "string" }; + const arrayProp: ElicitationPropertySchema = { + type: "array", + items: { type: "string", enum: ["a", "b"] }, + }; + + expect(ElicitationPropertySchema.isString(stringProp)).toBe(true); + expect(ElicitationPropertySchema.isArray(stringProp)).toBe(false); + expect(ElicitationPropertySchema.isArray(arrayProp)).toBe(true); + const customProp: ElicitationPropertySchema = { type: "_vendor" }; + expect(ElicitationPropertySchema.isCustom(customProp)).toBe(true); + }); + }); + + describe("MultiSelectItems", () => { + it("narrows string, titled, and custom variants", () => { + const string: MultiSelectItems = { type: "string", enum: ["a"] }; + const titled: MultiSelectItems = { + anyOf: [{ const: "a", title: "A" }], + }; + + const custom: MultiSelectItems = { type: "_vendor" }; + expect(MultiSelectItems.isString(string)).toBe(true); + expect(MultiSelectItems.isTitled(string)).toBe(false); + expect(MultiSelectItems.isTitled(titled)).toBe(true); + expect(MultiSelectItems.isCustom(custom)).toBe(true); + }); + + it("treats a custom-tagged value carrying titled-shaped keys as custom", () => { + // TitledMultiSelectItems declares no `type` at all, and its zod schema + // ignores unknown keys — so without the discriminant-absence check, a + // custom-tagged value that happens to carry `anyOf` would be + // misclassified as titled. + const vendor: MultiSelectItems = { + type: "_vendor", + anyOf: [{ const: "a", title: "A" }], + }; + + expect(MultiSelectItems.isTitled(vendor)).toBe(false); + expect(MultiSelectItems.isCustom(vendor)).toBe(true); + }); + }); + + it("re-exports every generated guard namespace from the package entry", () => { + // Safety net: guards must be listed explicitly in acp.ts (not `export *`) so + // each value merges with its type. This fails if a new extensible union is + // generated but its guard namespace is not re-exported. + const guardNames = Object.keys(guards); + expect(guardNames.length).toBeGreaterThan(0); + for (const name of guardNames) { + expect((sdk as Record)[name]).toBe( + (guards as Record)[name], + ); + } + }); +}); diff --git a/src/acp.ts b/src/acp.ts index 3b364446..0fb42c42 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -1,6 +1,19 @@ import * as schema from "./schema/index.js"; import * as validate from "./schema/zod.gen.js"; export type * from "./schema/types.gen.js"; +// Runtime narrowing helpers for extensible unions, exposed as companion values +// that merge (declaration merging) with the like-named types — e.g. +// `CreateElicitationResponse.isAccept(response)`. See schema/guards.gen.ts. +// +// Listed explicitly (not `export *`) so each value+type pair merges rather than +// colliding with the `export type *` above. The guards.gen re-export test +// asserts this list stays in sync as new extensible unions are added. +export { + CreateElicitationRequest, + CreateElicitationResponse, + ElicitationPropertySchema, + MultiSelectItems, +} from "./schema/guards.gen.js"; export { AGENT_METHODS, CLIENT_METHODS, diff --git a/src/schema-deserialize.test.ts b/src/schema-deserialize.test.ts new file mode 100644 index 00000000..1b988424 --- /dev/null +++ b/src/schema-deserialize.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod/v4"; + +import { + excludeKnownTags, + preserveCustomPayload, +} from "./schema-deserialize.js"; + +describe("excludeKnownTags", () => { + const catchAll = excludeKnownTags(z.object({ mode: z.string() }), "mode", [ + "form", + "url", + ]); + + it("accepts values with a custom tag", () => { + expect(catchAll.safeParse({ mode: "_vendor" }).success).toBe(true); + }); + + it("rejects values whose tag is reserved by a known variant", () => { + const result = catchAll.safeParse({ mode: "form" }); + expect(result.success).toBe(false); + if (!result.success) { + // The issue names the offending tag and points at the discriminant, so + // union-level errors (which surface only this issue) stay actionable. + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0]).toMatchObject({ + code: "custom", + path: ["mode"], + }); + expect(result.error.issues[0].message).toContain('"form"'); + } + }); +}); + +describe("preserveCustomPayload", () => { + const inner = z.union([ + z.object({ mode: z.literal("form"), value: z.number() }), + excludeKnownTags(z.object({ mode: z.string() }), "mode", ["form"]), + ]); + const schema = preserveCustomPayload(inner, "mode", ["form"]); + + it("re-attaches unevaluated keys for custom-tagged values", () => { + const result = schema.parse({ mode: "_vendor", payload: { x: 1 } }); + expect(result).toEqual({ mode: "_vendor", payload: { x: 1 } }); + }); + + it("re-attaches payload keys that shadow Object.prototype members", () => { + // `in`-style presence checks see inherited members; the re-attach must + // use an own-key check or these vendor keys silently vanish. + const result = schema.parse( + JSON.parse('{"mode":"_vendor","constructor":"tag","toString":"x"}'), + ) as Record; + expect(Object.hasOwn(result, "constructor")).toBe(true); + expect(result.constructor).toBe("tag"); + expect(result.toString).toBe("x"); + }); + + it("never re-attaches __proto__", () => { + const result = schema.parse( + JSON.parse('{"mode":"_vendor","__proto__":{"polluted":true}}'), + ) as Record; + expect(Object.hasOwn(result, "__proto__")).toBe(false); + expect((result as { polluted?: unknown }).polluted).toBeUndefined(); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + }); + + it("does not re-attach keys for known-tagged values", () => { + const result = schema.parse({ mode: "form", value: 1, extra: "dropped" }); + expect(result).toEqual({ mode: "form", value: 1 }); + }); + + it("skips re-attachment when the tag is not a string", () => { + const lax = preserveCustomPayload(z.object({ other: z.string() }), "mode", [ + "form", + ]); + expect(lax.parse({ other: "x", extra: 1 })).toEqual({ other: "x" }); + }); + + it("passes non-object values through the inner schema untouched", () => { + const passthrough = preserveCustomPayload(z.string(), "mode", ["form"]); + expect(passthrough.parse("plain")).toBe("plain"); + }); + + it("forwards the inner schema's issues with their paths on failure", () => { + const result = schema.safeParse({ mode: "form", value: "not-a-number" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.length).toBeGreaterThan(0); + const paths = result.error.issues.map((issue: z.core.$ZodIssue) => + issue.path.join("."), + ); + expect(paths).toContain("mode"); + } + }); + + it("keeps keys the winning member evaluated, even when salvaged", () => { + // Keys evaluated by the winning member keep their parsed results; keys + // only a LOSING member would have salvaged arrive raw. This asymmetry is + // faithful to the schema's unevaluatedProperties semantics — this test + // pins it so a change is a conscious decision. + const salvaging = preserveCustomPayload( + z.union([ + z.object({ mode: z.literal("form"), meta: z.number().catch(0) }), + excludeKnownTags(z.object({ mode: z.string() }), "mode", ["form"]), + ]), + "mode", + ["form"], + ); + expect(salvaging.parse({ mode: "form", meta: "bad" })).toEqual({ + mode: "form", + meta: 0, + }); + expect(salvaging.parse({ mode: "_vendor", meta: "raw" })).toEqual({ + mode: "_vendor", + meta: "raw", + }); + }); +}); diff --git a/src/schema-deserialize.ts b/src/schema-deserialize.ts index c75f9efb..7ab1b028 100644 --- a/src/schema-deserialize.ts +++ b/src/schema-deserialize.ts @@ -26,6 +26,92 @@ export function requiredDefaultOnError( }) as z.ZodType, z.input>; } +/** + * A wire value's discriminant tag, when present as a string. excludeKnownTags + * and preserveCustomPayload are always applied as a pair, so what counts as + * "the tag" is single-sourced here. + */ +function stringTag(value: unknown, key: string): string | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const tag = (value as Record)[key]; + return typeof tag === "string" ? tag : undefined; +} + +/** + * Restores the JSON Schema `not` semantics of an extensible union's catch-all + * variant, which the code generator drops: the catch-all formally excludes the + * known variants' discriminant tags, so a malformed known variant (right tag, + * wrong payload) must fail validation instead of parsing as a custom variant. + * + * When this fires inside a union, zod surfaces only this issue (the catch-all + * is the closest match), so the message names the offending tag; the known + * variant's own field-level issues are not available here. + * TODO: re-run the matching known variant's schema and forward its issues so + * consumers can see which field actually failed. + */ +export function excludeKnownTags( + schema: Schema, + key: string, + knownTags: ReadonlyArray, +): Schema { + return schema.superRefine((value, context) => { + const tag = stringTag(value, key); + if (tag !== undefined && knownTags.includes(tag)) { + context.addIssue({ + code: "custom", + path: [key], + message: + `${key} ${JSON.stringify(tag)} is reserved by a known variant, ` + + `but the value does not match that variant's schema`, + }); + } + }); +} + +/** + * Restores the `unevaluatedProperties`/`additionalProperties: true` semantics + * of an extensible union's custom catch-all variant, which the generated + * object schemas drop: a custom variant's extra properties are its payload, + * so after a normal (stripping) parse the raw keys the subschemas didn't + * produce are re-attached. Keys the winning variant evaluated — including + * invalid values salvaged to defaults — keep their parsed results; keys it + * did NOT evaluate arrive raw, even ones a losing variant would have salvaged + * (e.g. a malformed `_meta` on a custom multi-select item). + */ +export function preserveCustomPayload( + schema: Schema, + key: string, + knownTags: ReadonlyArray, +): z.ZodType, z.input> { + return z.unknown().transform((value, context): z.output => { + const result = schema.safeParse(value); + if (!result.success) { + for (const issue of result.error.issues) { + // Finished issues carry everything a raw issue needs except `input`. + context.addIssue({ ...issue, input: value } as z.core.$ZodRawIssue); + } + return z.NEVER; + } + + const output = result.data as Record; + const tag = stringTag(value, key); + if (tag !== undefined && !knownTags.includes(tag)) { + const raw = value as Record; + for (const [property, rawValue] of Object.entries(raw)) { + // Own-key check: `in` would see inherited Object.prototype members + // and silently drop payload keys named `constructor`, `toString`, + // etc. `__proto__` must never be re-attached — the computed + // assignment would rewrite the output object's prototype. + if (property === "__proto__") continue; + if (!Object.hasOwn(output, property)) output[property] = rawValue; + } + } + return output as z.output; + }) as z.ZodType, z.input>; +} + export function vecSkipError( itemSchema: ItemSchema, ) { diff --git a/src/schema/guards.gen.ts b/src/schema/guards.gen.ts new file mode 100644 index 00000000..2c0499ac --- /dev/null +++ b/src/schema/guards.gen.ts @@ -0,0 +1,356 @@ +// This file is auto-generated by scripts/generate.js + +import * as z from "zod/v4"; + +import type * as types from "./types.gen.js"; +import * as validate from "./zod.gen.js"; + +function tagOf(value: unknown, key: string): unknown { + return typeof value === "object" && value !== null + ? (value as Record)[key] + : undefined; +} + +const zGuardCreateElicitationRequestForm = validate.zElicitationFormMode + .and(z.object({ mode: z.literal("form") })) + .and(z.object({ message: z.string() })); +const zGuardCreateElicitationRequestUrl = validate.zElicitationUrlMode + .and(z.object({ mode: z.literal("url") })) + .and(z.object({ message: z.string() })); +const zGuardCreateElicitationRequestCustom = z + .union([validate.zElicitationSessionScope, validate.zElicitationRequestScope]) + .and(z.object({ message: z.string() })); +const zGuardElicitationPropertySchemaString = + validate.zStringPropertySchema.and(z.object({ type: z.literal("string") })); +const zGuardElicitationPropertySchemaNumber = + validate.zNumberPropertySchema.and(z.object({ type: z.literal("number") })); +const zGuardElicitationPropertySchemaInteger = + validate.zIntegerPropertySchema.and(z.object({ type: z.literal("integer") })); +const zGuardElicitationPropertySchemaBoolean = + validate.zBooleanPropertySchema.and(z.object({ type: z.literal("boolean") })); +const zGuardElicitationPropertySchemaArray = + validate.zMultiSelectPropertySchema.and( + z.object({ type: z.literal("array") }), + ); +const zGuardMultiSelectItemsString = validate.zStringMultiSelectItems.and( + z.object({ type: z.literal("string") }), +); +const zGuardMultiSelectItemsTitled = validate.zTitledMultiSelectItems; +const zGuardCreateElicitationResponseAccept = + validate.zElicitationAcceptAction.and( + z.object({ action: z.literal("accept") }), + ); +const zGuardCreateElicitationResponseDecline = z.object({ + action: z.literal("decline"), +}); +const zGuardCreateElicitationResponseCancel = z.object({ + action: z.literal("cancel"), +}); + +/** + * **UNSTABLE** + * + * This capability is not part of the spec yet, and may be removed or changed at any point. + * + * Request from the agent to elicit structured user input. + * + * The agent sends this to the client to request information from the user, + * either via a form or by directing them to a URL. + * Elicitations are tied to a session (optionally a tool call) or a request. + * + * @experimental + */ +export type CreateElicitationRequest = types.CreateElicitationRequest; +/** + * Validated type guards for `CreateElicitationRequest`'s known variants. + * + * Each guard validates the variant's payload, not just its discriminant + * tag: a malformed known variant (right tag, wrong payload) matches no + * guard — mirroring wire validation, which rejects such values instead + * of classifying them as custom. + * + * Guards check the value as given: fields that wire deserialization + * salvages to a default (e.g. a malformed `_meta`) are only normalized + * by parsing, and for ambiguous raw shapes (a known tag combined with + * another variant's payload) guards are conservative where wire parsing + * may still accept the value — narrow wire-parsed values when exact + * parity matters. + * + * @experimental + */ +export const CreateElicitationRequest = { + /** Narrow to the `form` variant, validating its payload. */ + isForm( + value: types.CreateElicitationRequest, + ): value is (types.ElicitationFormMode & { mode: "form" }) & + Pick { + return ( + tagOf(value, "mode") === "form" && + zGuardCreateElicitationRequestForm.safeParse(value).success + ); + }, + + /** Narrow to the `url` variant, validating its payload. */ + isUrl( + value: types.CreateElicitationRequest, + ): value is (types.ElicitationUrlMode & { mode: "url" }) & + Pick { + return ( + tagOf(value, "mode") === "url" && + zGuardCreateElicitationRequestUrl.safeParse(value).success + ); + }, + + /** + * Narrow to a custom or future variant: the `mode` tag matches no known variant, with a valid payload. + * + * TypeScript keeps the known variants in the narrowed union (they are + * structural subtypes of the catch-all), so read vendor payload keys + * via a widening cast: `(value as Record).someKey`. + */ + isCustom( + value: types.CreateElicitationRequest, + ): value is (( + types.ElicitationSessionScope | types.ElicitationRequestScope + ) & { mode: string; [key: string]: unknown }) & + Pick { + const tag = tagOf(value, "mode"); + return ( + typeof tag === "string" && + !["form", "url"].includes(tag) && + zGuardCreateElicitationRequestCustom.safeParse(value).success + ); + }, +} as const; + +/** + * Property schema for elicitation form fields. + * + * Each variant corresponds to a JSON Schema `"type"` value. + * Single-select enums use the `String` variant with `enum` or `oneOf` set. + * Multi-select enums use the `Array` variant. + */ +export type ElicitationPropertySchema = types.ElicitationPropertySchema; +/** + * Validated type guards for `ElicitationPropertySchema`'s known variants. + * + * Each guard validates the variant's payload, not just its discriminant + * tag: a malformed known variant (right tag, wrong payload) matches no + * guard — mirroring wire validation, which rejects such values instead + * of classifying them as custom. + * + * Guards check the value as given: fields that wire deserialization + * salvages to a default (e.g. a malformed `_meta`) are only normalized + * by parsing, and for ambiguous raw shapes (a known tag combined with + * another variant's payload) guards are conservative where wire parsing + * may still accept the value — narrow wire-parsed values when exact + * parity matters. + */ +export const ElicitationPropertySchema = { + /** Narrow to the `string` variant, validating its payload. */ + isString( + value: types.ElicitationPropertySchema, + ): value is types.StringPropertySchema & { type: "string" } { + return ( + tagOf(value, "type") === "string" && + zGuardElicitationPropertySchemaString.safeParse(value).success + ); + }, + + /** Narrow to the `number` variant, validating its payload. */ + isNumber( + value: types.ElicitationPropertySchema, + ): value is types.NumberPropertySchema & { type: "number" } { + return ( + tagOf(value, "type") === "number" && + zGuardElicitationPropertySchemaNumber.safeParse(value).success + ); + }, + + /** Narrow to the `integer` variant, validating its payload. */ + isInteger( + value: types.ElicitationPropertySchema, + ): value is types.IntegerPropertySchema & { type: "integer" } { + return ( + tagOf(value, "type") === "integer" && + zGuardElicitationPropertySchemaInteger.safeParse(value).success + ); + }, + + /** Narrow to the `boolean` variant, validating its payload. */ + isBoolean( + value: types.ElicitationPropertySchema, + ): value is types.BooleanPropertySchema & { type: "boolean" } { + return ( + tagOf(value, "type") === "boolean" && + zGuardElicitationPropertySchemaBoolean.safeParse(value).success + ); + }, + + /** Narrow to the `array` variant, validating its payload. */ + isArray( + value: types.ElicitationPropertySchema, + ): value is types.MultiSelectPropertySchema & { type: "array" } { + return ( + tagOf(value, "type") === "array" && + zGuardElicitationPropertySchemaArray.safeParse(value).success + ); + }, + + /** + * Narrow to a custom or future variant: the `type` tag matches no known variant. + * + * TypeScript keeps the known variants in the narrowed union (they are + * structural subtypes of the catch-all), so read vendor payload keys + * via a widening cast: `(value as Record).someKey`. + */ + isCustom( + value: types.ElicitationPropertySchema, + ): value is { type: string; [key: string]: unknown } { + const tag = tagOf(value, "type"); + return ( + typeof tag === "string" && + !["array", "boolean", "integer", "number", "string"].includes(tag) + ); + }, +} as const; + +/** + * Items for a multi-select (array) property schema. + */ +export type MultiSelectItems = types.MultiSelectItems; +/** + * Validated type guards for `MultiSelectItems`'s known variants. + * + * Each guard validates the variant's payload, not just its discriminant + * tag: a malformed known variant (right tag, wrong payload) matches no + * guard — mirroring wire validation, which rejects such values instead + * of classifying them as custom. + * + * Guards check the value as given: fields that wire deserialization + * salvages to a default (e.g. a malformed `_meta`) are only normalized + * by parsing, and for ambiguous raw shapes (a known tag combined with + * another variant's payload) guards are conservative where wire parsing + * may still accept the value — narrow wire-parsed values when exact + * parity matters. + */ +export const MultiSelectItems = { + /** Narrow to the `string` variant, validating its payload. */ + isString( + value: types.MultiSelectItems, + ): value is types.StringMultiSelectItems & { type: "string" } { + return ( + tagOf(value, "type") === "string" && + zGuardMultiSelectItemsString.safeParse(value).success + ); + }, + + /** Narrow to the `titled` variant, validating its payload. */ + isTitled( + value: types.MultiSelectItems, + ): value is types.TitledMultiSelectItems { + return ( + tagOf(value, "type") === undefined && + zGuardMultiSelectItemsTitled.safeParse(value).success + ); + }, + + /** + * Narrow to a custom or future variant: the `type` tag matches no known variant. + * + * TypeScript keeps the known variants in the narrowed union (they are + * structural subtypes of the catch-all), so read vendor payload keys + * via a widening cast: `(value as Record).someKey`. + */ + isCustom( + value: types.MultiSelectItems, + ): value is { type: string; [key: string]: unknown } { + const tag = tagOf(value, "type"); + return typeof tag === "string" && !["string"].includes(tag); + }, +} as const; + +/** + * **UNSTABLE** + * + * This capability is not part of the spec yet, and may be removed or changed at any point. + * + * Response from the client to an elicitation request. + * + * @experimental + */ +export type CreateElicitationResponse = types.CreateElicitationResponse; +/** + * Validated type guards for `CreateElicitationResponse`'s known variants. + * + * Each guard validates the variant's payload, not just its discriminant + * tag: a malformed known variant (right tag, wrong payload) matches no + * guard — mirroring wire validation, which rejects such values instead + * of classifying them as custom. + * + * Guards check the value as given: fields that wire deserialization + * salvages to a default (e.g. a malformed `_meta`) are only normalized + * by parsing, and for ambiguous raw shapes (a known tag combined with + * another variant's payload) guards are conservative where wire parsing + * may still accept the value — narrow wire-parsed values when exact + * parity matters. + * + * @experimental + */ +export const CreateElicitationResponse = { + /** Narrow to the `accept` variant, validating its payload. */ + isAccept( + value: types.CreateElicitationResponse, + ): value is (types.ElicitationAcceptAction & { action: "accept" }) & + Pick { + return ( + tagOf(value, "action") === "accept" && + zGuardCreateElicitationResponseAccept.safeParse(value).success + ); + }, + + /** Narrow to the `decline` variant, validating its payload. */ + isDecline( + value: types.CreateElicitationResponse, + ): value is { action: "decline" } & Pick< + types.CreateElicitationResponse, + "_meta" + > { + return ( + tagOf(value, "action") === "decline" && + zGuardCreateElicitationResponseDecline.safeParse(value).success + ); + }, + + /** Narrow to the `cancel` variant, validating its payload. */ + isCancel( + value: types.CreateElicitationResponse, + ): value is { action: "cancel" } & Pick< + types.CreateElicitationResponse, + "_meta" + > { + return ( + tagOf(value, "action") === "cancel" && + zGuardCreateElicitationResponseCancel.safeParse(value).success + ); + }, + + /** + * Narrow to a custom or future variant: the `action` tag matches no known variant. + * + * TypeScript keeps the known variants in the narrowed union (they are + * structural subtypes of the catch-all), so read vendor payload keys + * via a widening cast: `(value as Record).someKey`. + */ + isCustom( + value: types.CreateElicitationResponse, + ): value is { action: string; [key: string]: unknown } & Pick< + types.CreateElicitationResponse, + "_meta" + > { + const tag = tagOf(value, "action"); + return ( + typeof tag === "string" && !["accept", "cancel", "decline"].includes(tag) + ); + }, +} as const; diff --git a/src/schema/types.gen.ts b/src/schema/types.gen.ts index c85c40ad..f26ea4ac 100644 --- a/src/schema/types.gen.ts +++ b/src/schema/types.gen.ts @@ -889,6 +889,7 @@ export type CreateElicitationRequest = ( * future ACP variants. */ mode: string; + [key: string]: unknown; }) ) & { /** diff --git a/src/schema/zod.gen.ts b/src/schema/zod.gen.ts index bafd7ef1..642f250b 100644 --- a/src/schema/zod.gen.ts +++ b/src/schema/zod.gen.ts @@ -2,6 +2,8 @@ import { defaultOnError, + excludeKnownTags, + preserveCustomPayload, requiredDefaultOnError, vecSkipError, } from "../schema-deserialize.js"; @@ -696,18 +698,30 @@ export const zTitledMultiSelectItems = z.object({ /** * Items for a multi-select (array) property schema. + * + * Custom variants (unknown `type` values) keep their extra + * properties exactly as received; unlike known variants, those keys + * bypass lenient-field salvage and arrive unvalidated. */ -export const zMultiSelectItems = z.union([ - zStringMultiSelectItems.and( - z.object({ - type: z.literal("string"), - }), - ), - z.object({ - type: z.string(), - }), - zTitledMultiSelectItems, -]); +export const zMultiSelectItems = preserveCustomPayload( + z.union([ + zStringMultiSelectItems.and( + z.object({ + type: z.literal("string"), + }), + ), + excludeKnownTags( + z.object({ + type: z.string(), + }), + "type", + ["string"], + ), + zTitledMultiSelectItems, + ]), + "type", + ["string"], +); /** * Schema for multi-select (array) properties in an elicitation form. @@ -731,37 +745,49 @@ export const zMultiSelectPropertySchema = z.object({ * Each variant corresponds to a JSON Schema `"type"` value. * Single-select enums use the `String` variant with `enum` or `oneOf` set. * Multi-select enums use the `Array` variant. + * + * Custom variants (unknown `type` values) keep their extra + * properties exactly as received; unlike known variants, those keys + * bypass lenient-field salvage and arrive unvalidated. */ -export const zElicitationPropertySchema = z.union([ - zStringPropertySchema.and( - z.object({ - type: z.literal("string"), - }), - ), - zNumberPropertySchema.and( - z.object({ - type: z.literal("number"), - }), - ), - zIntegerPropertySchema.and( - z.object({ - type: z.literal("integer"), - }), - ), - zBooleanPropertySchema.and( - z.object({ - type: z.literal("boolean"), - }), - ), - zMultiSelectPropertySchema.and( - z.object({ - type: z.literal("array"), - }), - ), - z.object({ - type: z.string(), - }), -]); +export const zElicitationPropertySchema = preserveCustomPayload( + z.union([ + zStringPropertySchema.and( + z.object({ + type: z.literal("string"), + }), + ), + zNumberPropertySchema.and( + z.object({ + type: z.literal("number"), + }), + ), + zIntegerPropertySchema.and( + z.object({ + type: z.literal("integer"), + }), + ), + zBooleanPropertySchema.and( + z.object({ + type: z.literal("boolean"), + }), + ), + zMultiSelectPropertySchema.and( + z.object({ + type: z.literal("array"), + }), + ), + excludeKnownTags( + z.object({ + type: z.string(), + }), + "type", + ["array", "boolean", "integer", "number", "string"], + ), + ]), + "type", + ["array", "boolean", "integer", "number", "string"], +); /** * Type-safe elicitation schema for requesting structured user input. @@ -843,33 +869,45 @@ export const zElicitationUrlMode = z.intersection( * Elicitations are tied to a session (optionally a tool call) or a request. * * @experimental - */ -export const zCreateElicitationRequest = z.intersection( - z.union([ - zElicitationFormMode.and( - z.object({ - mode: z.literal("form"), - }), - ), - zElicitationUrlMode.and( - z.object({ - mode: z.literal("url"), - }), - ), - z.intersection( - z.union([zElicitationSessionScope, zElicitationRequestScope]), - z.object({ - mode: z.string(), - }), - ), - ]), - z.object({ - message: z.string(), - _meta: defaultOnError( - z.record(z.string(), z.unknown()).nullish(), - () => undefined, - ), - }), + * + * Custom variants (unknown `mode` values) keep their extra + * properties exactly as received; unlike known variants, those keys + * bypass lenient-field salvage and arrive unvalidated. + */ +export const zCreateElicitationRequest = preserveCustomPayload( + z.intersection( + z.union([ + zElicitationFormMode.and( + z.object({ + mode: z.literal("form"), + }), + ), + zElicitationUrlMode.and( + z.object({ + mode: z.literal("url"), + }), + ), + excludeKnownTags( + z.intersection( + z.union([zElicitationSessionScope, zElicitationRequestScope]), + z.object({ + mode: z.string(), + }), + ), + "mode", + ["form", "url"], + ), + ]), + z.object({ + message: z.string(), + _meta: defaultOnError( + z.record(z.string(), z.unknown()).nullish(), + () => undefined, + ), + }), + ), + "mode", + ["form", "url"], ); /** @@ -3967,30 +4005,42 @@ export const zElicitationAcceptAction = z.object({ * Response from the client to an elicitation request. * * @experimental - */ -export const zCreateElicitationResponse = z.intersection( - z.union([ - zElicitationAcceptAction.and( + * + * Custom variants (unknown `action` values) keep their extra + * properties exactly as received; unlike known variants, those keys + * bypass lenient-field salvage and arrive unvalidated. + */ +export const zCreateElicitationResponse = preserveCustomPayload( + z.intersection( + z.union([ + zElicitationAcceptAction.and( + z.object({ + action: z.literal("accept"), + }), + ), z.object({ - action: z.literal("accept"), + action: z.literal("decline"), }), - ), - z.object({ - action: z.literal("decline"), - }), - z.object({ - action: z.literal("cancel"), - }), + z.object({ + action: z.literal("cancel"), + }), + excludeKnownTags( + z.object({ + action: z.string(), + }), + "action", + ["accept", "cancel", "decline"], + ), + ]), z.object({ - action: z.string(), + _meta: defaultOnError( + z.record(z.string(), z.unknown()).nullish(), + () => undefined, + ), }), - ]), - z.object({ - _meta: defaultOnError( - z.record(z.string(), z.unknown()).nullish(), - () => undefined, - ), - }), + ), + "action", + ["accept", "cancel", "decline"], ); /** diff --git a/src/typedoc.json b/src/typedoc.json index 208238f6..c147a369 100644 --- a/src/typedoc.json +++ b/src/typedoc.json @@ -40,6 +40,12 @@ "externalPattern": ["**/node_modules/**"], "excludeNotDocumented": false, "excludeReferences": false, + "intentionallyNotExported": [ + "schema/types.gen.ts:CreateElicitationRequest", + "schema/types.gen.ts:CreateElicitationResponse", + "schema/types.gen.ts:ElicitationPropertySchema", + "schema/types.gen.ts:MultiSelectItems" + ], "validation": { "notExported": true, "invalidLink": true, diff --git a/tsconfig.json b/tsconfig.json index 10b0ae4d..949ca9cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ "paths": {} }, "include": ["src/**/*.ts"], - "exclude": ["**/dist/**", "tests/**"] + "exclude": ["**/dist/**", "tests/**", "src/.schema-*/**"] }