mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-02 04:14:38 +08:00
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:
co-authored by
Mert Köseoğlu
parent
f860cf19fc
commit
56e9e9a792
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user