Skip to content

Commit 18552b5

Browse files
committed
fix: add circular $ref cycle detection in JSON schema to Zod conversion
Recursive JSON schemas that reference themselves via $ref would cause infinite recursion and stack overflow. This adds a visited set that tracks which $ref paths have been seen during resolution. When a cycle is detected, it breaks with z.any() and logs a console.warn so users get feedback. Also adds console.warn for the generic z.any() fallback on unsupported schema types. Adds tests for circular refs, non-circular $ref resolution, anyOf with $ref variants, integer type, null type, and unsupported type warning.
1 parent c4d50a3 commit 18552b5

2 files changed

Lines changed: 203 additions & 5 deletions

File tree

packages/shared/src/utils/__tests__/json-schema.test.ts

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect } from "vitest";
1+
import { describe, it, expect, vi } from "vitest";
22
import { z } from "zod";
33
import {
44
convertJsonSchemaToZodSchema,
@@ -191,6 +191,169 @@ describe("convertJsonSchemaToZodSchema", () => {
191191

192192
expect(resultSchemaJson).toStrictEqual(expectedSchemaJson);
193193
});
194+
195+
it("should resolve non-circular $ref definitions correctly", () => {
196+
const jsonSchema = {
197+
type: "object",
198+
properties: {
199+
address: { $ref: "#/$defs/Address" },
200+
},
201+
required: ["address"],
202+
$defs: {
203+
Address: {
204+
type: "object",
205+
properties: {
206+
street: { type: "string", description: "Street name" },
207+
city: { type: "string", description: "City name" },
208+
},
209+
required: ["street", "city"],
210+
description: "A postal address",
211+
},
212+
},
213+
};
214+
215+
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
216+
const resultJson = zodToJsonSchema(result);
217+
218+
const expectedSchema = z.object({
219+
address: z
220+
.object({
221+
street: z.string().describe("Street name"),
222+
city: z.string().describe("City name"),
223+
})
224+
.describe("A postal address"),
225+
});
226+
const expectedJson = zodToJsonSchema(expectedSchema);
227+
228+
expect(resultJson).toStrictEqual(expectedJson);
229+
});
230+
231+
it("should handle circular $ref without crashing and return z.any()", () => {
232+
// A schema where Node references itself — this would cause infinite
233+
// recursion without cycle detection.
234+
const jsonSchema = {
235+
type: "object",
236+
properties: {
237+
root: { $ref: "#/$defs/Node" },
238+
},
239+
required: ["root"],
240+
$defs: {
241+
Node: {
242+
type: "object",
243+
properties: {
244+
value: { type: "string", description: "Node value" },
245+
child: { $ref: "#/$defs/Node" },
246+
},
247+
required: ["value"],
248+
description: "A tree node",
249+
},
250+
},
251+
};
252+
253+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
254+
255+
// Must not throw or hang
256+
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
257+
expect(result).toBeDefined();
258+
259+
// The circular ref should have produced a console.warn
260+
expect(warnSpy).toHaveBeenCalledWith(
261+
expect.stringContaining("Circular $ref detected"),
262+
);
263+
264+
// The top-level shape should still have a "root" key that is an object
265+
const shape = (result as z.ZodObject<any>).shape;
266+
expect(shape.root).toBeDefined();
267+
268+
// Inside root, "value" should be a string and "child" should be z.any()
269+
// (child is optional since it's not in required[], so unwrap ZodOptional)
270+
const rootShape = (shape.root as z.ZodObject<any>).shape;
271+
expect(rootShape.value._def.typeName).toBe("ZodString");
272+
const childDef = rootShape.child._def;
273+
if (childDef.typeName === "ZodOptional") {
274+
expect(childDef.innerType._def.typeName).toBe("ZodAny");
275+
} else {
276+
expect(childDef.typeName).toBe("ZodAny");
277+
}
278+
279+
warnSpy.mockRestore();
280+
});
281+
282+
it("should handle anyOf with $ref variants", () => {
283+
const jsonSchema = {
284+
type: "object",
285+
properties: {
286+
pet: {
287+
anyOf: [{ $ref: "#/$defs/Cat" }, { $ref: "#/$defs/Dog" }],
288+
description: "A pet",
289+
},
290+
},
291+
required: ["pet"],
292+
$defs: {
293+
Cat: {
294+
type: "object",
295+
properties: { meow: { type: "boolean" } },
296+
required: ["meow"],
297+
},
298+
Dog: {
299+
type: "object",
300+
properties: { bark: { type: "boolean" } },
301+
required: ["bark"],
302+
},
303+
},
304+
};
305+
306+
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
307+
expect(result).toBeDefined();
308+
309+
// Should produce a union inside the "pet" property
310+
const petSchema = (result as z.ZodObject<any>).shape.pet;
311+
expect(petSchema._def.typeName).toBe("ZodUnion");
312+
});
313+
314+
it("should handle integer type as z.number()", () => {
315+
const jsonSchema = {
316+
type: "object",
317+
properties: {
318+
count: { type: "integer", description: "A count" },
319+
},
320+
required: ["count"],
321+
};
322+
323+
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
324+
const shape = (result as z.ZodObject<any>).shape;
325+
expect(shape.count._def.typeName).toBe("ZodNumber");
326+
});
327+
328+
it("should handle null type", () => {
329+
const jsonSchema = {
330+
type: "object",
331+
properties: {
332+
empty: { type: "null", description: "Always null" },
333+
},
334+
required: ["empty"],
335+
};
336+
337+
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
338+
const shape = (result as z.ZodObject<any>).shape;
339+
expect(shape.empty._def.typeName).toBe("ZodNull");
340+
});
341+
342+
it("should warn and return z.any() for unsupported schema types", () => {
343+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
344+
345+
const jsonSchema = { type: "custom_unsupported" };
346+
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
347+
348+
expect(result._def.typeName).toBe("ZodAny");
349+
expect(warnSpy).toHaveBeenCalledWith(
350+
expect.stringContaining(
351+
'Unsupported JSON schema type "custom_unsupported"',
352+
),
353+
);
354+
355+
warnSpy.mockRestore();
356+
});
194357
});
195358

196359
describe("jsonSchemaToActionParameters", () => {

packages/shared/src/utils/json-schema.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,16 +242,37 @@ export function convertJsonSchemaToZodSchema(
242242
jsonSchema: any,
243243
required: boolean,
244244
definitions?: Record<string, any>,
245+
visitedRefs?: Set<string>,
245246
): z.ZodSchema {
246247
// Resolve $ref references
247248
if (jsonSchema.$ref && definitions) {
248249
const refPath = jsonSchema.$ref.replace(
249250
/^#\/\$defs\/|^#\/definitions\//,
250251
"",
251252
);
253+
254+
// Detect circular $ref cycles
255+
const refs = visitedRefs ?? new Set<string>();
256+
if (refs.has(refPath)) {
257+
console.warn(
258+
`[CopilotKit] Circular $ref detected for "${refPath}" — falling back to z.any()`,
259+
);
260+
let schema = z.any();
261+
if (jsonSchema.description) {
262+
schema = schema.describe(jsonSchema.description);
263+
}
264+
return required ? schema : schema.optional();
265+
}
266+
252267
const resolved = definitions[refPath];
253268
if (resolved) {
254-
return convertJsonSchemaToZodSchema(resolved, required, definitions);
269+
refs.add(refPath);
270+
return convertJsonSchemaToZodSchema(
271+
resolved,
272+
required,
273+
definitions,
274+
refs,
275+
);
255276
}
256277
}
257278

@@ -262,10 +283,15 @@ export function convertJsonSchemaToZodSchema(
262283
const unionVariants = jsonSchema.anyOf ?? jsonSchema.oneOf;
263284
if (Array.isArray(unionVariants) && unionVariants.length > 0) {
264285
if (unionVariants.length === 1) {
265-
return convertJsonSchemaToZodSchema(unionVariants[0], required, defs);
286+
return convertJsonSchemaToZodSchema(
287+
unionVariants[0],
288+
required,
289+
defs,
290+
visitedRefs,
291+
);
266292
}
267293
const schemas = unionVariants.map((v: any) =>
268-
convertJsonSchemaToZodSchema(v, true, defs),
294+
convertJsonSchemaToZodSchema(v, true, defs, visitedRefs),
269295
);
270296
let schema = z.union(
271297
schemas as [z.ZodSchema, z.ZodSchema, ...z.ZodSchema[]],
@@ -288,6 +314,7 @@ export function convertJsonSchemaToZodSchema(
288314
value,
289315
jsonSchema.required ? jsonSchema.required.includes(key) : false,
290316
defs,
317+
visitedRefs,
291318
);
292319
}
293320
let schema = z.object(spec).describe(jsonSchema.description);
@@ -308,7 +335,12 @@ export function convertJsonSchemaToZodSchema(
308335
let schema = z.boolean().describe(jsonSchema.description);
309336
return required ? schema : schema.optional();
310337
} else if (jsonSchema.type === "array") {
311-
let itemSchema = convertJsonSchemaToZodSchema(jsonSchema.items, true, defs);
338+
let itemSchema = convertJsonSchemaToZodSchema(
339+
jsonSchema.items,
340+
true,
341+
defs,
342+
visitedRefs,
343+
);
312344
let schema = z.array(itemSchema).describe(jsonSchema.description);
313345
return required ? schema : schema.optional();
314346
} else if (jsonSchema.type === "null") {
@@ -317,6 +349,9 @@ export function convertJsonSchemaToZodSchema(
317349
}
318350

319351
// Fallback: accept any value rather than throwing
352+
console.warn(
353+
`[CopilotKit] Unsupported JSON schema type "${jsonSchema.type ?? "unknown"}" — falling back to z.any()`,
354+
);
320355
let schema = z.any();
321356
if (jsonSchema.description) {
322357
schema = schema.describe(jsonSchema.description);

0 commit comments

Comments
 (0)