fix: preserve ZodEffects preprocess and ZodType coerce in zod3tov4 (#815)

* fix(adapter): preserve ZodEffects preprocess in zod3tov4 conversion

ZodEffects with effect.type='preprocess' (z.preprocess()) were
stripped entirely, losing custom coercion transforms like
coerceJsonArray and coerceBoolean. Preserve them via z.preprocess()
in v4. Refinement/transform effects continue to be stripped.

* test(adapter): add preprocess preservation and coercion tests

Add 3 test cases covering:
- string→array preprocess preservation
- string→boolean preprocess preservation
- refinement effect stripping (unchanged)

Update existing 'strips to inner schema' test to verify preprocess survives.

* test(adapter): add E2E tests for OpenCode plugin validation chain

9 end-to-end cases simulating plugin.ts → zod3ShapeToV4 → z4.object()
wrapping. Covers stringified requests arrays, force/concurrency
string coercion, native arrays, and single-URL regression.

* test(adapter): add production test loading built zod3tov4.js

8 cases importing the actual compiled build/adapters/opencode/zod3tov4.js
with dynamic import() for cross-platform ESM compatibility. Verifies the
fix works in the compiled JavaScript that OpenCode loads.

* docs(test): fix inaccurate comment about min(1) and optional

The previous comment incorrectly stated that .optional() absorbs the
.min(1) error. The real reason '[]' passes is that the v3→v4 converter
does not preserve ZodType constraints like .min(1). The real validation
still happens via the original v3 inputSchema.parse() in plugin.ts.

---------

Co-authored-by: Mert Köseoğlu <bm.ksglu@gmail.com>
This commit is contained in:
LeoNardo
2026-06-12 15:53:43 +03:00
committed by GitHub
co-authored by Mert Köseoğlu
parent f860cf19fc
commit 56e9e9a792
4 changed files with 365 additions and 7 deletions
+29 -6
View File
@@ -32,15 +32,15 @@ function zod3ToV4(v: unknown, depth = 0): z.ZodType {
switch (def.typeName) {
case "ZodString":
result = z.string();
result = def.coerce === true ? z.coerce.string() : z.string();
break;
case "ZodNumber":
result = z.number();
result = def.coerce === true ? z.coerce.number() : z.number();
break;
case "ZodBoolean":
result = z.boolean();
result = def.coerce === true ? z.coerce.boolean() : z.boolean();
break;
case "ZodAny":
@@ -114,10 +114,33 @@ function zod3ToV4(v: unknown, depth = 0): z.ZodType {
break;
}
case "ZodEffects":
// Host schema only. Original Zod 3 schema still parses in execute().
result = zod3ToV4(def.schema, depth + 1);
case "ZodEffects": {
// Zod v3 has two patterns for coercion:
//
// 1. Native coercion (z.coerce.number()): typeName is ZodNumber
// with _def.coerce=true. Handled in the ZodNumber case above.
//
// 2. Custom preprocess (z.preprocess(fn, schema)): typeName is
// ZodEffects with effect.type="preprocess". The transform is
// plain JS and works identically in Zod v4's z.preprocess().
//
// We never map ZodEffects→native coerce here because native
// coerce is already a ZodType (not ZodEffects), and custom
// transforms can't be safely replaced (e.g., z.coerce.boolean()
// in v4 converts "false"→true via Boolean(), which is wrong).
const effect = def.effect as Record<string, unknown> | undefined;
if (effect?.type === "preprocess" && typeof effect?.transform === "function") {
const innerSchema = zod3ToV4(def.schema, depth + 1);
result = z.preprocess(
effect.transform as (val: unknown) => unknown,
innerSchema,
);
} else {
// Refinement / transform effects — host schema only.
result = zod3ToV4(def.schema, depth + 1);
}
break;
}
default:
// Never leak raw Zod 3 schemas back to a v4 host.
+138
View File
@@ -0,0 +1,138 @@
/**
* End-to-end validation: reproduces the full OpenCode in-process plugin
* validation chain for ctx_fetch_and_index-like tool schemas.
*
* OpenCode bundles Zod v4 internally. context-mode provides Zod v3 schemas,
* which plugin.ts converts to v4 via zod3ShapeToV4. OpenCode then wraps
* the v4 shape in z.object() (registry.ts:132) and validates the LLM's
* tool-call arguments.
*
* This test verifies that string→array and string→boolean coercions in
* z.preprocess() survive the Zod v3→v4 conversion.
*/
import { describe, it, expect } from "vitest";
import { z } from "zod";
import z4 from "zod/v4";
import { zod3ShapeToV4 } from "../../src/adapters/opencode/zod3tov4.js";
// Mirror coerceJsonArray from server.ts:2293
function coerceJsonArray(val: unknown): unknown {
if (typeof val === "string") {
const trimmed = val.trim();
if (trimmed.length === 0) return val;
try {
const parsed = JSON.parse(val);
if (Array.isArray(parsed)) return parsed;
} catch {}
return [val];
}
return val;
}
// Mirror coerceBoolean from server.ts:2320
function coerceBool(val: unknown): unknown {
if (typeof val === "string") {
const t = val.trim().toLowerCase();
if (t === "true") return true;
if (t === "false") return false;
}
return val;
}
// Mirror ctx_fetch_and_index inputSchema (server.ts:3223-3272)
const fetchAndIndexSchema = z.object({
url: z.string().optional(),
source: z.string().optional(),
requests: z
.preprocess(
coerceJsonArray,
z.array(
z.object({
url: z.string(),
source: z.string().optional(),
}),
).min(1),
)
.optional(),
concurrency: z.coerce.number().int().min(1).max(8).optional().default(1),
force: z.preprocess(coerceBool, z.boolean()).optional(),
});
const batchRequests = [
{ url: "https://example.com", source: "test1" },
{ url: "https://httpbin.org/html", source: "test2" },
];
describe("e2e: OpenCode plugin validation chain", () => {
// Build the schema exactly as plugin.ts → registry.ts would
const z3Shape = fetchAndIndexSchema._def.shape() as Record<string, unknown>;
const v4Shape = zod3ShapeToV4(z3Shape);
const v4Validator = z4.object(v4Shape as Record<string, z4.ZodType>);
it("accepts requests as native array", () => {
const r = v4Validator.safeParse({ requests: batchRequests, concurrency: 2 });
expect(r.success).toBe(true);
if (r.success) expect(r.data.requests).toHaveLength(2);
});
it("accepts requests as JSON-stringified array (LLM bridge case)", () => {
// This was the original bug: coerceJsonArray was stripped by zod3ShapeToV4,
// causing OpenCode to reject stringified array parameters.
const r = v4Validator.safeParse({
requests: JSON.stringify(batchRequests),
concurrency: "2",
});
expect(r.success).toBe(true);
if (r.success) {
expect(Array.isArray(r.data.requests)).toBe(true);
expect(r.data.requests).toHaveLength(2);
expect(r.data.requests[0].url).toBe("https://example.com");
}
});
it("accepts empty requests as string '[]' (min constraint not retained by converter)", () => {
// coerceJsonArray converts "[]" → []. The v3→v4 converter does not
// preserve .min(1), so the empty array passes z.array() validation.
// The real validation (v3 inputSchema.parse() inside plugin.ts) still
// enforces .min(1) and the handler catches empty batches separately.
const r = v4Validator.safeParse({ requests: "[]", concurrency: 1 });
expect(r.success).toBe(true);
});
it("accepts not sending requests at all (optional)", () => {
const r = v4Validator.safeParse({ url: "https://example.com" });
expect(r.success).toBe(true);
});
it("accepts force as 'true' string", () => {
const r = v4Validator.safeParse({ url: "https://x.com", force: "true" });
expect(r.success).toBe(true);
if (r.success) expect(r.data.force).toBe(true);
});
it("accepts force as 'false' string", () => {
const r = v4Validator.safeParse({ url: "https://x.com", force: "false" });
expect(r.success).toBe(true);
if (r.success) expect(r.data.force).toBe(false);
});
it("accepts force as native boolean", () => {
const r = v4Validator.safeParse({ url: "https://x.com", force: true });
expect(r.success).toBe(true);
});
it("handles single-URL mode (no requests field)", () => {
const r = v4Validator.safeParse({ url: "https://example.com", source: "test" });
expect(r.success).toBe(true);
});
it("concurrency '2' string is coerced to number 2", () => {
// z.coerce.number() converts strings to numbers; must survive conversion.
const r = v4Validator.safeParse({
requests: batchRequests,
concurrency: "2",
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.concurrency).toBe(2);
});
});
+163
View File
@@ -0,0 +1,163 @@
/**
* Production-level integration test.
*
* Imports the BUILT .js files (not TypeScript source) and simulates the
* EXACT path OpenCode takes when loading context-mode as an in-process plugin:
*
* 1. plugin.ts imports server.js → gets REGISTERED_CTX_TOOLS with Zod v3 schemas
* 2. plugin.ts calls zod3ShapeToV4() on each tool's args shape
* 3. OpenCode's registry.ts wraps in z.object() (Zod v4) and validates args
* 4. LLM output goes through this Zod v4 validation first
*
* This test runs against the actual build/ output, verifying the fix
* works in the compiled JavaScript that OpenCode will load.
*/
import { describe, it, expect } from "vitest";
import { z } from "zod";
import z4 from "zod/v4";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Import the BUILT zod3tov4 module (same file OpenCode loads)
const { zod3ShapeToV4 } = await import(
resolve(__dirname, "../../build/adapters/opencode/zod3tov4.js")
);
// Replicate server.ts coerce functions
function coerceJsonArray(val: unknown): unknown {
if (typeof val === "string") {
const trimmed = val.trim();
if (trimmed.length === 0) return val;
try {
const parsed = JSON.parse(val);
if (Array.isArray(parsed)) return parsed;
} catch {}
return [val];
}
return val;
}
function coerceBool(val: unknown): unknown {
if (typeof val === "string") {
const t = val.trim().toLowerCase();
if (t === "true") return true;
if (t === "false") return false;
}
return val;
}
// Exact schema from server.ts ctx_fetch_and_index (line 3223-3272)
const fetchAndIndexSchema = z.object({
url: z.string().optional().describe("Single URL to fetch and index"),
source: z.string().optional().describe("Label for indexed content"),
requests: z
.preprocess(
coerceJsonArray,
z.array(
z.object({
url: z.string().describe("URL to fetch"),
source: z.string().optional().describe("Label for this URL's indexed content"),
}),
).min(1),
)
.optional()
.describe("Batch shape: array of {url, source?} entries"),
concurrency: z.coerce.number().int().min(1).max(8).optional().default(1)
.describe("Max URLs to fetch in parallel"),
force: z
.preprocess(coerceBool, z.boolean())
.optional()
.describe("Skip cache and re-fetch"),
ttl: z.coerce.number().int().min(0).optional()
.describe("Override cache freshness window in ms"),
});
const batchRequests = [
{ url: "https://example.com", source: "test1" },
{ url: "https://httpbin.org/html", source: "test2" },
];
describe("production: built zod3tov4 + OpenCode plugin validation chain", () => {
// Simulate EXACT production flow:
// plugin.ts → zod3ShapeToV4 → registry.ts z.object() wrap
const z3Shape = fetchAndIndexSchema._def.shape() as Record<string, unknown>;
const v4ShapeFromBuilt = zod3ShapeToV4(z3Shape);
const v4Validator = z4.object(v4ShapeFromBuilt as Record<string, z4.ZodType>);
it("P1: stringified requests array → accepted via built module", () => {
const r = v4Validator.safeParse({
requests: JSON.stringify(batchRequests),
concurrency: "2",
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.requests).toHaveLength(2);
expect(r.data.requests[0].url).toBe("https://example.com");
expect(r.data.concurrency).toBe(2);
}
});
it("P2: force: 'true'/'false' strings → coerced via built module", () => {
expect(v4Validator.safeParse({ url: "x", force: "true" }).data?.force).toBe(true);
expect(v4Validator.safeParse({ url: "x", force: "false" }).data?.force).toBe(false);
expect(v4Validator.safeParse({ url: "x", force: true }).data?.force).toBe(true);
});
it("P3: native array passes through", () => {
const r = v4Validator.safeParse({
requests: batchRequests,
concurrency: 2,
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.requests).toHaveLength(2);
});
it("P4: single URL mode (no requests)", () => {
const r = v4Validator.safeParse({ url: "https://example.com", source: "test" });
expect(r.success).toBe(true);
});
it("P5: concurrency string '5' → number 5 via built coerce", () => {
const r = v4Validator.safeParse({
requests: batchRequests,
concurrency: "5",
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.concurrency).toBe(5);
});
it("P6: ttl string '60000' → number 60000 via built coerce", () => {
const r = v4Validator.safeParse({
url: "https://example.com",
ttl: "60000",
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.ttl).toBe(60000);
});
it("P7: all fields together with stringified args (end-to-end)", () => {
const r = v4Validator.safeParse({
requests: JSON.stringify(batchRequests),
concurrency: "3",
force: "true",
ttl: "0",
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.requests).toHaveLength(2);
expect(r.data.concurrency).toBe(3);
expect(r.data.force).toBe(true);
expect(r.data.ttl).toBe(0);
}
});
it("P8: no coercion regressions — plain number 'abc' rejected", () => {
const r = v4Validator.safeParse({
url: "https://example.com",
concurrency: "abc",
});
expect(r.success).toBe(false);
});
});
+35 -1
View File
@@ -80,7 +80,7 @@ describe("zod3ShapeToV4", () => {
expect((result.config as any)._zod).toBeDefined();
});
it("converts ZodEffects (strips to inner schema)", () => {
it("preserves ZodEffects preprocess — coerces string to array", () => {
const z3Shape = {
input: z.preprocess(
(val) => (typeof val === "string" ? val.split(",") : val),
@@ -89,6 +89,40 @@ describe("zod3ShapeToV4", () => {
};
const result = zod3ShapeToV4(z3Shape as Record<string, unknown>);
expect((result.input as any)._zod).toBeDefined();
// The preprocess must survive: a comma-separated string should be accepted
// and split into an array.
const parsed = (result.input as any).safeParse("a,b,c");
expect(parsed.success).toBe(true);
expect(parsed.data).toEqual(["a", "b", "c"]);
// Non-string arrays pass through unchanged.
const parsed2 = (result.input as any).safeParse(["x", "y"]);
expect(parsed2.success).toBe(true);
expect(parsed2.data).toEqual(["x", "y"]);
});
it("preserves ZodEffects preprocess — coerces string to boolean", () => {
const coerceBool = (val: unknown) =>
typeof val === "string"
? val.trim().toLowerCase() === "true"
: val;
const z3Shape = {
force: z.preprocess(coerceBool, z.boolean()),
};
const result = zod3ShapeToV4(z3Shape as Record<string, unknown>);
expect((result.force as any)._zod).toBeDefined();
expect((result.force as any).safeParse("true").data).toBe(true);
expect((result.force as any).safeParse("false").data).toBe(false);
expect((result.force as any).safeParse(true).data).toBe(true);
});
it("strips ZodEffects refinement to inner schema", () => {
const z3Shape = {
email: z.string().refine((v) => v.includes("@"), "must contain @"),
};
const result = zod3ShapeToV4(z3Shape as Record<string, unknown>);
expect((result.email as any)._zod).toBeDefined();
// Refinement is stripped — the inner string schema should accept any string
expect((result.email as any).safeParse("no-at-sign").success).toBe(true);
});
it("returns z.unknown() for null/non-object values", () => {