Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 209 additions & 1 deletion scripts/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ async function main() {
postProcess: ["prettier"],
},
plugins: [
"zod",
{
name: "zod",
"~resolvers": createDeserializationResolvers(),
},
{ bigInt: false, name: "@hey-api/transformers" },
"@hey-api/typescript",
],
Expand Down Expand Up @@ -177,6 +180,211 @@ function updateDocs(src, schemaDefs) {
return result;
}

function createDeserializationResolvers() {
return {
array(ctx) {
const base = ctx.schema["x-deserialize-skip-invalid-items"]
? vecSkipErrorExpression(ctx)
: ctx.nodes.base(ctx);

ctx.chain.current = base;
const lengthResult = ctx.nodes.length(ctx);
if (lengthResult) {
ctx.chain.current = lengthResult;
} else {
const minLengthResult = ctx.nodes.minLength(ctx);
if (minLengthResult) ctx.chain.current = minLengthResult;
const maxLengthResult = ctx.nodes.maxLength(ctx);
if (maxLengthResult) ctx.chain.current = maxLengthResult;
}

return ctx.chain.current;
},

object(ctx) {
if (!hasDefaultOnErrorProperties(ctx.schema)) return undefined;

const shape = ctx.$.object().pretty();
for (const name in ctx.schema.properties) {
const property = ctx.schema.properties[name];
const isRequired = ctx.schema.required?.includes(name) === true;
const propertyResult = ctx.walk(
property,
childContext(
{ path: ctx.path, plugin: ctx.plugin },
"properties",
name,
),
);
ctx._childResults.push(propertyResult);

const finalExpression = propertyExpression(
ctx,
name,
property,
propertyResult,
isRequired,
);

shape.prop(
name,
property["x-deserialize-default-on-error"]
? defaultOnErrorExpression(
ctx,
finalExpression,
property,
isRequired,
)
: finalExpression,
);
}

const defaultShape = ctx.nodes.shape;
ctx.nodes.shape = () => shape;
const base = ctx.nodes.base(ctx);
ctx.nodes.shape = defaultShape;
return base;
},
};
}

function childContext(ctx, ...segments) {
return {
path: ref([...fromRef(ctx.path), ...segments]),
plugin: ctx.plugin,
};
}

function ref(path) {
return { "~ref": path };
}

function fromRef(ref) {
return ref?.["~ref"];
}

function jsonPointerPath(ref) {
return `#/${fromRef(ref).map(jsonPointerSegment).join("/")}`;
}

function jsonPointerSegment(segment) {
return String(segment).replaceAll("~", "~0").replaceAll("/", "~1");
}

function hasDefaultOnErrorProperties(schema) {
return Object.values(schema.properties ?? {}).some(
(property) => property["x-deserialize-default-on-error"],
);
}

function propertyExpression(ctx, name, property, propertyResult, isRequired) {
if (!property["x-deserialize-skip-invalid-items"]) {
return ctx.applyModifiers(propertyResult, { optional: !isRequired }).chain;
}

const itemSchema = getArrayItemSchema(property);
if (!itemSchema) {
throw new Error(
`Unable to apply x-deserialize-skip-invalid-items to ${jsonPointerPath(childContext(ctx, "properties", name).path)}`,
);
}

const itemResult = ctx.walk(
itemSchema,
childContext(
{ path: ctx.path, plugin: ctx.plugin },
"properties",
name,
"items",
0,
),
);

return ctx.applyModifiers(
{
chain: ctx
.$(schemaDeserializeSymbol(ctx.plugin, "vecSkipError"))
.call(ctx.applyModifiers(itemResult, { optional: false }).chain),
meta: propertyResult.meta,
},
{ optional: !isRequired },
).chain;
}

function getArrayItemSchema(schema) {
if (schema.type === "array" && schema.items) {
return Array.isArray(schema.items) ? schema.items[0] : schema.items;
}

const items = Array.isArray(schema.items) ? schema.items : [];
for (const item of items) {
const itemSchema = getArrayItemSchema(item);
if (itemSchema) return itemSchema;
}

return undefined;
}

function vecSkipErrorExpression(ctx) {
const vecSkipError = schemaDeserializeSymbol(ctx.plugin, "vecSkipError");

if (ctx.childResults.length !== 1) {
throw new Error(
`Unable to apply x-deserialize-skip-invalid-items to ${jsonPointerPath(ctx.path)}`,
);
}

return ctx
.$(vecSkipError)
.call(ctx.applyModifiers(ctx.childResults[0], { optional: false }).chain);
}

function defaultOnErrorExpression(ctx, schemaExpression, schema, isRequired) {
const helper = schemaDeserializeSymbol(
ctx.plugin,
isRequired ? "requiredDefaultOnError" : "defaultOnError",
);

return ctx
.$(helper)
.call(
schemaExpression,
fallbackFunctionExpression(ctx, schema, isRequired),
);
}

function schemaDeserializeSymbol(plugin, name) {
return plugin.symbolOnce(name, {
external: "../schema-deserialize.js",
});
}

function fallbackFunctionExpression(ctx, schema, isRequired) {
return ctx.$.func().do(
ctx.$.return(fallbackValueExpression(ctx, schema, isRequired)),
);
}

function fallbackValueExpression(ctx, schema, isRequired) {
if (Object.hasOwn(schema, "default")) {
return ctx.$.fromValue(schema.default);
}

if (isArraySchema(schema) && (isRequired || !isNullableSchema(schema))) {
return ctx.$.array();
}

return ctx.$.id("undefined");
}

function isArraySchema(schema) {
return schema.type === "array";
}

function isNullableSchema(schema) {
return schema.nullable === true;
}

function injectDocIfMissing(src, exportStr, description) {
const idx = src.indexOf(exportStr);
if (idx === -1) return src;
Expand Down
85 changes: 85 additions & 0 deletions src/acp.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
zAnnotations,
zClientCapabilities,
zCreateElicitationRequest,
zCreateElicitationResponse,
zInitializeResponse,
zPlan,
zToolCall,
} from "./schema/zod.gen.js";
import {
Agent,
Expand Down Expand Up @@ -2852,3 +2857,83 @@ describe("CreateElicitationResponse schema", () => {
expect(result.success).toBe(false);
});
});

describe("Schema deserialization compatibility", () => {
it("defaults invalid optional values to undefined", () => {
const response = zInitializeResponse.parse({
protocolVersion: 1,
agentInfo: "invalid",
});

expect(response.agentInfo).toBeUndefined();
});

it("keeps explicit schema defaults and skips invalid array items", () => {
const response = zInitializeResponse.parse({
protocolVersion: 1,
authMethods: [
{ id: "agent-auth", name: "Agent auth" },
{ type: "terminal", id: "missing-name" },
],
});

expect(response.authMethods).toEqual([
{ id: "agent-auth", name: "Agent auth" },
]);
expect(
zInitializeResponse.parse({
protocolVersion: 1,
authMethods: "invalid",
}).authMethods,
).toEqual([]);
});

it("keeps required default-on-error arrays required when missing", () => {
expect(zPlan.safeParse({}).success).toBe(false);

expect(zPlan.parse({ entries: "invalid" }).entries).toEqual([]);
expect(
zPlan.parse({
entries: [
{ content: "done", priority: "high", status: "completed" },
{ content: "missing status", priority: "low" },
],
}).entries,
).toEqual([{ content: "done", priority: "high", status: "completed" }]);
});

it("defaults optional non-null arrays to [] only for invalid present values", () => {
expect(zClientCapabilities.parse({}).positionEncodings).toBeUndefined();
expect(
zClientCapabilities.parse({ positionEncodings: "invalid" })
.positionEncodings,
).toEqual([]);
});

it("defaults optional nullable arrays to undefined for invalid present values", () => {
expect(
zAnnotations.parse({ audience: "invalid" }).audience,
).toBeUndefined();
expect(
zAnnotations.parse({ audience: ["user", "invalid", "assistant"] })
.audience,
).toEqual(["user", "assistant"]);
});

it("skips invalid nested tool call content and locations", () => {
const toolCall = zToolCall.parse({
content: [
{ type: "content", content: { type: "text", text: "hello" } },
{ type: "terminal" },
],
locations: [{ path: "/tmp/file.ts", line: 1 }, { path: 1 }],
title: "Read file",
toolCallId: "tool-call-1",
});

expect(toolCall.content).toEqual([
{ type: "content", content: { type: "text", text: "hello" } },
]);
expect(toolCall.locations).toEqual([{ path: "/tmp/file.ts", line: 1 }]);
});
});
38 changes: 38 additions & 0 deletions src/schema-deserialize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from "zod/v4";

type Fallback<Output> = () => Output;
const skippedItem = Symbol("skippedItem");

export function defaultOnError<Schema extends z.ZodType>(
schema: Schema,
fallback: Fallback<z.output<Schema>>,
) {
return schema.catch(fallback as () => never);
}

export function requiredDefaultOnError<Schema extends z.ZodType>(
schema: Schema,
fallback: Fallback<z.output<Schema>>,
) {
const schemaWithCatch = schema.catch(fallback as () => never);

return z.unknown().transform((value, context): z.output<Schema> => {
if (value !== undefined) return schemaWithCatch.parse(value);
context.addIssue({
code: "custom",
message: "Required value is missing",
});
return z.NEVER;
});
}

export function vecSkipError<ItemSchema extends z.ZodType>(
itemSchema: ItemSchema,
) {
return z
.array(itemSchema.catch(skippedItem as never))
.transform(
(items): Array<z.output<ItemSchema>> =>
items.filter((item) => item !== skippedItem),
);
}
Loading