Skip to content

Commit 5864e73

Browse files
authored
feat: Add resilient schema deserialization (#167)
This allows for deserialization to recover when we get certain fields that are invalid, but still allows us to recover as much as possible.
1 parent fa6e302 commit 5864e73

4 files changed

Lines changed: 534 additions & 87 deletions

File tree

scripts/generate.js

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ async function main() {
3636
postProcess: ["prettier"],
3737
},
3838
plugins: [
39-
"zod",
39+
{
40+
name: "zod",
41+
"~resolvers": createDeserializationResolvers(),
42+
},
4043
{ bigInt: false, name: "@hey-api/transformers" },
4144
"@hey-api/typescript",
4245
],
@@ -177,6 +180,211 @@ function updateDocs(src, schemaDefs) {
177180
return result;
178181
}
179182

183+
function createDeserializationResolvers() {
184+
return {
185+
array(ctx) {
186+
const base = ctx.schema["x-deserialize-skip-invalid-items"]
187+
? vecSkipErrorExpression(ctx)
188+
: ctx.nodes.base(ctx);
189+
190+
ctx.chain.current = base;
191+
const lengthResult = ctx.nodes.length(ctx);
192+
if (lengthResult) {
193+
ctx.chain.current = lengthResult;
194+
} else {
195+
const minLengthResult = ctx.nodes.minLength(ctx);
196+
if (minLengthResult) ctx.chain.current = minLengthResult;
197+
const maxLengthResult = ctx.nodes.maxLength(ctx);
198+
if (maxLengthResult) ctx.chain.current = maxLengthResult;
199+
}
200+
201+
return ctx.chain.current;
202+
},
203+
204+
object(ctx) {
205+
if (!hasDefaultOnErrorProperties(ctx.schema)) return undefined;
206+
207+
const shape = ctx.$.object().pretty();
208+
for (const name in ctx.schema.properties) {
209+
const property = ctx.schema.properties[name];
210+
const isRequired = ctx.schema.required?.includes(name) === true;
211+
const propertyResult = ctx.walk(
212+
property,
213+
childContext(
214+
{ path: ctx.path, plugin: ctx.plugin },
215+
"properties",
216+
name,
217+
),
218+
);
219+
ctx._childResults.push(propertyResult);
220+
221+
const finalExpression = propertyExpression(
222+
ctx,
223+
name,
224+
property,
225+
propertyResult,
226+
isRequired,
227+
);
228+
229+
shape.prop(
230+
name,
231+
property["x-deserialize-default-on-error"]
232+
? defaultOnErrorExpression(
233+
ctx,
234+
finalExpression,
235+
property,
236+
isRequired,
237+
)
238+
: finalExpression,
239+
);
240+
}
241+
242+
const defaultShape = ctx.nodes.shape;
243+
ctx.nodes.shape = () => shape;
244+
const base = ctx.nodes.base(ctx);
245+
ctx.nodes.shape = defaultShape;
246+
return base;
247+
},
248+
};
249+
}
250+
251+
function childContext(ctx, ...segments) {
252+
return {
253+
path: ref([...fromRef(ctx.path), ...segments]),
254+
plugin: ctx.plugin,
255+
};
256+
}
257+
258+
function ref(path) {
259+
return { "~ref": path };
260+
}
261+
262+
function fromRef(ref) {
263+
return ref?.["~ref"];
264+
}
265+
266+
function jsonPointerPath(ref) {
267+
return `#/${fromRef(ref).map(jsonPointerSegment).join("/")}`;
268+
}
269+
270+
function jsonPointerSegment(segment) {
271+
return String(segment).replaceAll("~", "~0").replaceAll("/", "~1");
272+
}
273+
274+
function hasDefaultOnErrorProperties(schema) {
275+
return Object.values(schema.properties ?? {}).some(
276+
(property) => property["x-deserialize-default-on-error"],
277+
);
278+
}
279+
280+
function propertyExpression(ctx, name, property, propertyResult, isRequired) {
281+
if (!property["x-deserialize-skip-invalid-items"]) {
282+
return ctx.applyModifiers(propertyResult, { optional: !isRequired }).chain;
283+
}
284+
285+
const itemSchema = getArrayItemSchema(property);
286+
if (!itemSchema) {
287+
throw new Error(
288+
`Unable to apply x-deserialize-skip-invalid-items to ${jsonPointerPath(childContext(ctx, "properties", name).path)}`,
289+
);
290+
}
291+
292+
const itemResult = ctx.walk(
293+
itemSchema,
294+
childContext(
295+
{ path: ctx.path, plugin: ctx.plugin },
296+
"properties",
297+
name,
298+
"items",
299+
0,
300+
),
301+
);
302+
303+
return ctx.applyModifiers(
304+
{
305+
chain: ctx
306+
.$(schemaDeserializeSymbol(ctx.plugin, "vecSkipError"))
307+
.call(ctx.applyModifiers(itemResult, { optional: false }).chain),
308+
meta: propertyResult.meta,
309+
},
310+
{ optional: !isRequired },
311+
).chain;
312+
}
313+
314+
function getArrayItemSchema(schema) {
315+
if (schema.type === "array" && schema.items) {
316+
return Array.isArray(schema.items) ? schema.items[0] : schema.items;
317+
}
318+
319+
const items = Array.isArray(schema.items) ? schema.items : [];
320+
for (const item of items) {
321+
const itemSchema = getArrayItemSchema(item);
322+
if (itemSchema) return itemSchema;
323+
}
324+
325+
return undefined;
326+
}
327+
328+
function vecSkipErrorExpression(ctx) {
329+
const vecSkipError = schemaDeserializeSymbol(ctx.plugin, "vecSkipError");
330+
331+
if (ctx.childResults.length !== 1) {
332+
throw new Error(
333+
`Unable to apply x-deserialize-skip-invalid-items to ${jsonPointerPath(ctx.path)}`,
334+
);
335+
}
336+
337+
return ctx
338+
.$(vecSkipError)
339+
.call(ctx.applyModifiers(ctx.childResults[0], { optional: false }).chain);
340+
}
341+
342+
function defaultOnErrorExpression(ctx, schemaExpression, schema, isRequired) {
343+
const helper = schemaDeserializeSymbol(
344+
ctx.plugin,
345+
isRequired ? "requiredDefaultOnError" : "defaultOnError",
346+
);
347+
348+
return ctx
349+
.$(helper)
350+
.call(
351+
schemaExpression,
352+
fallbackFunctionExpression(ctx, schema, isRequired),
353+
);
354+
}
355+
356+
function schemaDeserializeSymbol(plugin, name) {
357+
return plugin.symbolOnce(name, {
358+
external: "../schema-deserialize.js",
359+
});
360+
}
361+
362+
function fallbackFunctionExpression(ctx, schema, isRequired) {
363+
return ctx.$.func().do(
364+
ctx.$.return(fallbackValueExpression(ctx, schema, isRequired)),
365+
);
366+
}
367+
368+
function fallbackValueExpression(ctx, schema, isRequired) {
369+
if (Object.hasOwn(schema, "default")) {
370+
return ctx.$.fromValue(schema.default);
371+
}
372+
373+
if (isArraySchema(schema) && (isRequired || !isNullableSchema(schema))) {
374+
return ctx.$.array();
375+
}
376+
377+
return ctx.$.id("undefined");
378+
}
379+
380+
function isArraySchema(schema) {
381+
return schema.type === "array";
382+
}
383+
384+
function isNullableSchema(schema) {
385+
return schema.nullable === true;
386+
}
387+
180388
function injectDocIfMissing(src, exportStr, description) {
181389
const idx = src.indexOf(exportStr);
182390
if (idx === -1) return src;

src/acp.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { describe, it, expect, beforeEach, vi } from "vitest";
22
import {
3+
zAnnotations,
4+
zClientCapabilities,
35
zCreateElicitationRequest,
46
zCreateElicitationResponse,
7+
zInitializeResponse,
8+
zPlan,
9+
zToolCall,
510
} from "./schema/zod.gen.js";
611
import {
712
Agent,
@@ -2852,3 +2857,83 @@ describe("CreateElicitationResponse schema", () => {
28522857
expect(result.success).toBe(false);
28532858
});
28542859
});
2860+
2861+
describe("Schema deserialization compatibility", () => {
2862+
it("defaults invalid optional values to undefined", () => {
2863+
const response = zInitializeResponse.parse({
2864+
protocolVersion: 1,
2865+
agentInfo: "invalid",
2866+
});
2867+
2868+
expect(response.agentInfo).toBeUndefined();
2869+
});
2870+
2871+
it("keeps explicit schema defaults and skips invalid array items", () => {
2872+
const response = zInitializeResponse.parse({
2873+
protocolVersion: 1,
2874+
authMethods: [
2875+
{ id: "agent-auth", name: "Agent auth" },
2876+
{ type: "terminal", id: "missing-name" },
2877+
],
2878+
});
2879+
2880+
expect(response.authMethods).toEqual([
2881+
{ id: "agent-auth", name: "Agent auth" },
2882+
]);
2883+
expect(
2884+
zInitializeResponse.parse({
2885+
protocolVersion: 1,
2886+
authMethods: "invalid",
2887+
}).authMethods,
2888+
).toEqual([]);
2889+
});
2890+
2891+
it("keeps required default-on-error arrays required when missing", () => {
2892+
expect(zPlan.safeParse({}).success).toBe(false);
2893+
2894+
expect(zPlan.parse({ entries: "invalid" }).entries).toEqual([]);
2895+
expect(
2896+
zPlan.parse({
2897+
entries: [
2898+
{ content: "done", priority: "high", status: "completed" },
2899+
{ content: "missing status", priority: "low" },
2900+
],
2901+
}).entries,
2902+
).toEqual([{ content: "done", priority: "high", status: "completed" }]);
2903+
});
2904+
2905+
it("defaults optional non-null arrays to [] only for invalid present values", () => {
2906+
expect(zClientCapabilities.parse({}).positionEncodings).toBeUndefined();
2907+
expect(
2908+
zClientCapabilities.parse({ positionEncodings: "invalid" })
2909+
.positionEncodings,
2910+
).toEqual([]);
2911+
});
2912+
2913+
it("defaults optional nullable arrays to undefined for invalid present values", () => {
2914+
expect(
2915+
zAnnotations.parse({ audience: "invalid" }).audience,
2916+
).toBeUndefined();
2917+
expect(
2918+
zAnnotations.parse({ audience: ["user", "invalid", "assistant"] })
2919+
.audience,
2920+
).toEqual(["user", "assistant"]);
2921+
});
2922+
2923+
it("skips invalid nested tool call content and locations", () => {
2924+
const toolCall = zToolCall.parse({
2925+
content: [
2926+
{ type: "content", content: { type: "text", text: "hello" } },
2927+
{ type: "terminal" },
2928+
],
2929+
locations: [{ path: "/tmp/file.ts", line: 1 }, { path: 1 }],
2930+
title: "Read file",
2931+
toolCallId: "tool-call-1",
2932+
});
2933+
2934+
expect(toolCall.content).toEqual([
2935+
{ type: "content", content: { type: "text", text: "hello" } },
2936+
]);
2937+
expect(toolCall.locations).toEqual([{ path: "/tmp/file.ts", line: 1 }]);
2938+
});
2939+
});

src/schema-deserialize.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { z } from "zod/v4";
2+
3+
type Fallback<Output> = () => Output;
4+
const skippedItem = Symbol("skippedItem");
5+
6+
export function defaultOnError<Schema extends z.ZodType>(
7+
schema: Schema,
8+
fallback: Fallback<z.output<Schema>>,
9+
) {
10+
return schema.catch(fallback as () => never);
11+
}
12+
13+
export function requiredDefaultOnError<Schema extends z.ZodType>(
14+
schema: Schema,
15+
fallback: Fallback<z.output<Schema>>,
16+
) {
17+
const schemaWithCatch = schema.catch(fallback as () => never);
18+
19+
return z.unknown().transform((value, context): z.output<Schema> => {
20+
if (value !== undefined) return schemaWithCatch.parse(value);
21+
context.addIssue({
22+
code: "custom",
23+
message: "Required value is missing",
24+
});
25+
return z.NEVER;
26+
});
27+
}
28+
29+
export function vecSkipError<ItemSchema extends z.ZodType>(
30+
itemSchema: ItemSchema,
31+
) {
32+
return z
33+
.array(itemSchema.catch(skippedItem as never))
34+
.transform(
35+
(items): Array<z.output<ItemSchema>> =>
36+
items.filter((item) => item !== skippedItem),
37+
);
38+
}

0 commit comments

Comments
 (0)