Quellcode durchsuchen

Remove effect-zod bridge (#26956)

Kit Langton vor 3 Monaten
Ursprung
Commit
e5aa5161f2

+ 0 - 370
packages/core/src/effect-zod.ts

@@ -1,370 +0,0 @@
-import { Effect, Option, Schema, SchemaAST } from "effect"
-import z from "zod"
-
-/**
- * Annotation key for providing a hand-crafted Zod schema that the walker
- * should use instead of re-deriving from the AST.  Attach it via
- * `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`.
- */
-export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
-
-// AST nodes are immutable and frequently shared across schemas (e.g. a single
-// Schema.Class embedded in multiple parents). Memoizing by node identity
-// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
-// by reference across callers.
-const walkCache = new WeakMap<SchemaAST.AST, z.ZodTypeAny>()
-
-// Shared empty ParseOptions for the rare callers that need one — avoids
-// allocating a fresh object per parse inside refinements and transforms.
-const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions
-
-export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
-  return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
-}
-
-/**
- * Derive a Zod value from an Effect Schema (or a Schema-backed export with a
- * `.zod` static) and narrow the result to `z.ZodObject<any>` so `.shape`,
- * `.omit`, `.extend`, and friends are accessible.
- *
- * The `zod()` walker returns `z.ZodType<T>` because not every AST node decodes
- * to an object; this helper keeps the "I started from a `Schema.Struct`" cast
- * in one place instead of sprinkling `as unknown as z.ZodObject<any>` across
- * call sites.
- *
- * The return is intentionally loose — carrying Schema field types through the
- * mapped `.omit()` / `.extend()` surface triggers brand-intersection
- * explosions for branded primitives (`string & Brand<"SessionID">` extends
- * `object` via the brand and gets walked into the prototype by `DeepPartial`,
- * mapped-schema helpers, and zod's inference through `z.ZodType<T | undefined>`
- * wrappers also can't reconstruct `T` cleanly. Consumers that care about the
- * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
- */
-export function zodObject<S extends Schema.Top>(schema: S): z.ZodObject<any> {
-  const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast)
-  return derived as unknown as z.ZodObject<any>
-}
-
-function isZodType(value: unknown): value is z.ZodTypeAny {
-  return typeof value === "object" && value !== null && "_zod" in value
-}
-
-/**
- * Emit a JSON Schema for a tool/route parameter schema — derives the zod form
- * via the walker so Effect Schema inputs flow through the same zod-openapi
- * pipeline the LLM/SDK layer already depends on.  `io: "input"` mirrors what
- * `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper.
- */
-export function toJsonSchema<S extends Schema.Top>(schema: S) {
-  return z.toJSONSchema(zod(schema), { io: "input" })
-}
-
-function walk(ast: SchemaAST.AST): z.ZodTypeAny {
-  const cached = walkCache.get(ast)
-  if (cached) return cached
-  const result = walkUncached(ast)
-  walkCache.set(ast, result)
-  return result
-}
-
-function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
-  const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
-  // `description` annotations layer on top of an override so callers can
-  // reuse a shared override schema (e.g. `SessionID`) and still add a
-  // per-field description on the outer wrapper.
-  const base = override ?? bodyWithChecks(ast)
-  const desc = SchemaAST.resolveDescription(ast)
-  const ref = SchemaAST.resolveIdentifier(ast)
-  const described = desc ? base.describe(desc) : base
-  return ref ? described.meta({ ref }) : described
-}
-
-function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny {
-  // Schema.Class wraps its fields in a Declaration AST plus an encoding that
-  // constructs the class instance. For the Zod derivation we want the plain
-  // field shape (the decoded/consumer view), not the class instance — so
-  // Declarations fall through to body(), not encoded(). User-level
-  // Schema.decodeTo / Schema.transform attach encoding to non-Declaration
-  // nodes, where we do apply the transform.
-  //
-  // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
-  // on the inner Zod rather than a transform wrapper — so optional ASTs whose
-  // encoding resolves a default from Option.none() route through body()/opt().
-  const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0)
-  const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
-  const base = hasTransform ? encoded(ast) : body(ast)
-  return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
-}
-
-// Walk the encoded side and apply each link's decode to produce the decoded
-// shape. A node `Target` produced by `from.decodeTo(Target)` carries
-// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls
-// nest the encoding via `Link.to` so walking it recursively threads all
-// prior transforms — typical encoding.length is 1.
-function encoded(ast: SchemaAST.AST): z.ZodTypeAny {
-  const encoding = ast.encoding!
-  return encoding.reduce<z.ZodTypeAny>(
-    (acc, link) => acc.transform((v) => decode(link.transformation, v)),
-    walk(encoding[0].to),
-  )
-}
-
-// Transformations built via pure `SchemaGetter.transform(fn)` (the common
-// decodeTo case) resolve synchronously, so running with no services is safe.
-// Effectful / middleware-based transforms will surface as Effect defects.
-function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown {
-  const exit = Effect.runSyncExit(
-    (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect<
-      Option.Option<unknown>
-    >,
-  )
-  if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`)
-  return Option.getOrElse(exit.value, () => value)
-}
-
-// Flatten FilterGroups and any nested variants into a linear list of Filters.
-// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are
-// translated into native Zod methods so their JSON Schema output includes
-// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …).
-// Anything else falls back to a single .superRefine layer — runtime-only,
-// emits no JSON Schema constraint.
-function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
-  const filters: SchemaAST.Filter<unknown>[] = []
-  const collect = (c: SchemaAST.Check<unknown>) => {
-    if (c._tag === "FilterGroup") c.checks.forEach(collect)
-    else filters.push(c)
-  }
-  checks.forEach(collect)
-
-  const unhandled: SchemaAST.Filter<unknown>[] = []
-  const translated = filters.reduce<z.ZodTypeAny>((acc, filter) => {
-    const next = translateFilter(acc, filter)
-    if (next) return next
-    unhandled.push(filter)
-    return acc
-  }, out)
-
-  if (unhandled.length === 0) return translated
-
-  return translated.superRefine((value, ctx) => {
-    for (const filter of unhandled) {
-      const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
-      if (!issue) continue
-      const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
-      ctx.addIssue({ code: "custom", message })
-    }
-  })
-}
-
-// Translate a well-known Effect Schema filter into a native Zod method call on
-// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every
-// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at
-// construction time. Returns `undefined` for unrecognised filters so the
-// caller can fall back to the generic .superRefine path.
-function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter<unknown>): z.ZodTypeAny | undefined {
-  const meta = (filter.annotations as { meta?: Record<string, unknown> } | undefined)?.meta
-  if (!meta || typeof meta._tag !== "string") return undefined
-  switch (meta._tag) {
-    case "isInt":
-      return call(out, "int")
-    case "isFinite":
-      return call(out, "finite")
-    case "isGreaterThan":
-      return call(out, "gt", meta.exclusiveMinimum)
-    case "isGreaterThanOrEqualTo":
-      return call(out, "gte", meta.minimum)
-    case "isLessThan":
-      return call(out, "lt", meta.exclusiveMaximum)
-    case "isLessThanOrEqualTo":
-      return call(out, "lte", meta.maximum)
-    case "isBetween": {
-      const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum)
-      if (!lo) return undefined
-      return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum)
-    }
-    case "isMultipleOf":
-      return call(out, "multipleOf", meta.divisor)
-    case "isMinLength":
-      return call(out, "min", meta.minLength)
-    case "isMaxLength":
-      return call(out, "max", meta.maxLength)
-    case "isLengthBetween": {
-      const lo = call(out, "min", meta.minimum)
-      if (!lo) return undefined
-      return call(lo, "max", meta.maximum)
-    }
-    case "isPattern":
-      return call(out, "regex", meta.regExp)
-    case "isStartsWith":
-      return call(out, "startsWith", meta.startsWith)
-    case "isEndsWith":
-      return call(out, "endsWith", meta.endsWith)
-    case "isIncludes":
-      return call(out, "includes", meta.includes)
-    case "isUUID":
-      return call(out, "uuid")
-    case "isULID":
-      return call(out, "ulid")
-    case "isBase64":
-      return call(out, "base64")
-    case "isBase64Url":
-      return call(out, "base64url")
-  }
-  return undefined
-}
-
-// Invoke a named Zod method on `target` if it exists, otherwise return
-// undefined so the caller can fall back. Using this helper instead of a
-// typed cast keeps `translateFilter` free of per-case narrowing noise.
-function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined {
-  const fn = (target as unknown as Record<string, ((...a: unknown[]) => z.ZodTypeAny) | undefined>)[method]
-  return typeof fn === "function" ? fn.apply(target, args) : undefined
-}
-
-function issueMessage(issue: any): string | undefined {
-  if (typeof issue?.annotations?.message === "string") return issue.annotations.message
-  if (typeof issue?.message === "string") return issue.message
-  return undefined
-}
-
-function body(ast: SchemaAST.AST): z.ZodTypeAny {
-  if (SchemaAST.isOptional(ast)) return opt(ast)
-
-  switch (ast._tag) {
-    case "String":
-      return z.string()
-    case "Number":
-      return z.number()
-    case "Boolean":
-      return z.boolean()
-    case "Null":
-      return z.null()
-    case "Undefined":
-      return z.undefined()
-    case "Any":
-    case "Unknown":
-      return z.unknown()
-    case "Never":
-      return z.never()
-    case "Literal":
-      return z.literal(ast.literal)
-    case "Union":
-      return union(ast)
-    case "Objects":
-      return object(ast)
-    case "Arrays":
-      return array(ast)
-    case "Declaration":
-      return decl(ast)
-    default:
-      return fail(ast)
-  }
-}
-
-function opt(ast: SchemaAST.AST): z.ZodTypeAny {
-  if (ast._tag !== "Union") return fail(ast)
-  const items = ast.types.filter((item) => item._tag !== "Undefined")
-  const inner =
-    items.length === 1
-      ? walk(items[0])
-      : items.length > 1
-        ? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
-        : z.undefined()
-  // Schema.withDecodingDefault attaches an encoding `Link` whose transformation
-  // decode Getter resolves `Option.none()` to `Option.some(default)`.  Invoke
-  // it to extract the default and emit `.default(...)` instead of `.optional()`.
-  const fallback = extractDefault(ast)
-  if (fallback !== undefined) return inner.default(fallback.value)
-  return inner.optional()
-}
-
-type DecodeLink = {
-  readonly transformation: {
-    readonly decode: {
-      readonly run: (
-        input: Option.Option<unknown>,
-        options: SchemaAST.ParseOptions,
-      ) => Effect.Effect<Option.Option<unknown>, unknown>
-    }
-  }
-}
-
-function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined {
-  const encoding = (ast as { encoding?: ReadonlyArray<DecodeLink> }).encoding
-  if (!encoding?.length) return undefined
-  // Walk the chain of encoding Links in order; the first Getter that produces
-  // a value from Option.none wins.  withDecodingDefault always puts its
-  // defaulting Link adjacent to the optional Union.
-  for (const link of encoding) {
-    const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {}))
-    if (probe._tag !== "Success") continue
-    if (Option.isSome(probe.value)) return { value: probe.value.value }
-  }
-  return undefined
-}
-
-function union(ast: SchemaAST.Union): z.ZodTypeAny {
-  // When every member is a string literal, emit z.enum() so that
-  // JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }.
-  if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) {
-    return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]])
-  }
-
-  const items = ast.types.map(walk)
-  if (items.length === 1) return items[0]
-  if (items.length < 2) return fail(ast)
-
-  const discriminator = ast.annotations?.discriminator
-  if (typeof discriminator === "string") {
-    return z.discriminatedUnion(discriminator, items as [z.ZodObject<any>, z.ZodObject<any>, ...z.ZodObject<any>[]])
-  }
-
-  return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
-}
-
-function object(ast: SchemaAST.Objects): z.ZodTypeAny {
-  // Pure record: { [k: string]: V }
-  if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
-    const sig = ast.indexSignatures[0]
-    if (sig.parameter._tag !== "String") return fail(ast)
-    return z.record(z.string(), walk(sig.type))
-  }
-
-  // Pure object with known fields and no index signatures.
-  if (ast.indexSignatures.length === 0) {
-    return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
-  }
-
-  // Struct with a catchall (StructWithRest): known fields + index signature.
-  // Only supports a single string-keyed index signature; multi-signature or
-  // symbol/number keys fall through to fail.
-  if (ast.indexSignatures.length !== 1) return fail(ast)
-  const sig = ast.indexSignatures[0]
-  if (sig.parameter._tag !== "String") return fail(ast)
-  return z
-    .object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)])))
-    .catchall(walk(sig.type))
-}
-
-function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
-  // Pure variadic arrays: { elements: [], rest: [item] }
-  if (ast.elements.length === 0) {
-    if (ast.rest.length !== 1) return fail(ast)
-    return z.array(walk(ast.rest[0]))
-  }
-  // Fixed-length tuples: { elements: [a, b, ...], rest: [] }
-  // Tuples with a variadic tail (...rest) are not yet supported.
-  if (ast.rest.length > 0) return fail(ast)
-  const items = ast.elements.map(walk)
-  return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
-}
-
-function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
-  if (ast.typeParameters.length !== 1) return fail(ast)
-  return walk(ast.typeParameters[0])
-}
-
-function fail(ast: SchemaAST.AST): never {
-  const ref = SchemaAST.resolveIdentifier(ast)
-  throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`)
-}

+ 0 - 2
packages/core/src/schema.ts

@@ -1,5 +1,4 @@
 import { Option, Schema, SchemaGetter } from "effect"
-import { zod, ZodOverride } from "./effect-zod"
 
 /**
  * Integer greater than zero.
@@ -21,7 +20,6 @@ export const optionalOmitUndefined = <S extends Schema.Top>(schema: S) =>
       decode: SchemaGetter.passthrough({ strict: false }),
       encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
     }),
-    Schema.annotate({ [ZodOverride]: zod(schema).optional() }),
   )
 
 /**

+ 2 - 10
packages/opencode/specs/effect/migration.md

@@ -57,17 +57,9 @@ Rules:
 - Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
 - No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
 
-## Schema → Zod interop
+## Schema boundaries
 
-When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`:
-
-```ts
-import { zod } from "@opencode-ai/core/effect-zod"
-
-export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
-```
-
-See `Auth.ZodInfo` for the canonical example.
+Use Effect Schema directly at HTTP, tool, and AI SDK boundaries. For provider-facing JSON Schema, use a boundary-specific helper such as `ToolJsonSchema.fromSchema(...)`; do not reintroduce generic Effect Schema → Zod conversion.
 
 ## InstanceState init patterns
 

+ 29 - 55
packages/opencode/specs/effect/schema.md

@@ -1,19 +1,16 @@
 # Schema migration
 
 Practical reference for migrating data types in `packages/opencode` from
-Zod-first definitions to Effect Schema with Zod compatibility shims.
+Zod-first definitions to Effect Schema.
 
 ## Goal
 
 Use Effect Schema as the source of truth for domain models, IDs, inputs,
-outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
-compatibility boundaries by exposing a `.zod` static derived from the Effect
-schema via `@opencode-ai/core/effect-zod`.
+outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and
+native JSON Schema generation at HTTP, tool, and AI SDK boundaries.
 
-The long-term driver is `specs/effect/http-api.md` — once the HTTP server
-moves to `@effect/platform`, every Schema-first DTO can flow through
-`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
-`effect-zod` walker plus every `.zod` static can be deleted.
+The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should
+flow through `HttpApi` / `HttpRouter` without a Zod translation layer.
 
 ## Preferred shapes
 
@@ -26,19 +23,16 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
   id: FooID,
   name: Schema.String,
   enabled: Schema.Boolean,
-}) {
-  static readonly zod = zod(Info)
-}
+}) {}
 ```
 
-If the class cannot reference itself cleanly during initialization, use the
-two-step `withStatics` pattern:
+If a schema needs local static helpers, use the two-step `withStatics` pattern:
 
 ```ts
 export const Info = Schema.Struct({
   id: FooID,
   name: Schema.String,
-}).pipe(withStatics((s) => ({ zod: zod(s) })))
+}).pipe(withStatics((s) => ({ decode: Schema.decodeUnknownOption(s) })))
 ```
 
 ### Errors
@@ -53,15 +47,13 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
 
 ### IDs and branded leaf types
 
-Keep branded/schema-backed IDs as Effect schemas and expose
-`static readonly zod` for compatibility when callers still expect Zod.
+Keep branded/schema-backed IDs as Effect schemas.
 
 ### Refinements
 
-Reuse named refinements instead of re-spelling `z.number().int().positive()`
-in every schema. The `effect-zod` walker translates the Effect versions into
-the corresponding zod methods, so JSON Schema output (`type: integer`,
-`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
+Reuse named refinements instead of re-spelling numeric or string constraints in
+every schema. Boundary JSON Schema helpers should normalize native Effect JSON
+Schema output only where a provider requires it.
 
 ```ts
 const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
@@ -69,18 +61,15 @@ const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreate
 const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
 ```
 
-See `test/util/effect-zod.test.ts` for the full set of translated checks.
-
 ## Compatibility rule
 
-During migration, route validators, tool parameters, and any existing
-Zod-based boundary should consume the derived `.zod` schema instead of
+During migration, route validators, tool parameters, and AI SDK schemas should
+consume Effect schemas directly or use a narrow boundary helper. Avoid
 maintaining a second hand-written Zod schema.
 
 The default should be:
 
 - Effect Schema owns the type
-- `.zod` exists only as a compatibility surface
 - new domain models should not start Zod-first unless there is a concrete
   boundary-specific need
 
@@ -89,27 +78,22 @@ The default should be:
 It is fine to keep a Zod-native schema temporarily when:
 
 - the type is only used at an HTTP or tool boundary and is not reused elsewhere
-- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
+- the validator is part of an existing public API that explicitly accepts Zod
 - the migration would force unrelated churn across a large call graph
 
 When this happens, prefer leaving a short note or TODO rather than silently
 creating a parallel schema source of truth.
 
-## Escape hatches
-
-The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for
-cases the pure-Schema path cannot express. Each one stays in the codebase
-only as long as its upstream or local dependency requires it — inline
-comments document when each can be deleted.
+## Boundary helpers
 
-### `ZodOverride` annotation
+Use narrow helpers at concrete boundaries instead of a generic Schema → Zod bridge.
 
-Replaces the entire derivation with a hand-crafted zod schema. Used when:
+- Tool parameters: `ToolJsonSchema.fromSchema(...)` and `ToolJsonSchema.fromTool(...)`
+- Public config/TUI schemas: `packages/opencode/script/schema.ts`
+- AI SDK object generation: `Schema.toStandardSchemaV1(...)` plus `Schema.toStandardJSONSchemaV1(...)`
 
-- the target carries external `$ref` metadata (e.g.
-  `config/model-id.ts` points at `https://models.dev/...`)
-- the target is a zod-only schema that cannot yet be expressed as Schema
-  (e.g. `ConfigAgent.Info`, `Log.Level`)
+Plugin tools are the main remaining intentional Zod boundary because the public
+plugin API exposes `tool.schema = z` and `args: z.ZodRawShape`.
 
 ### Local `DeepMutable<T>` in `config/config.ts`
 
@@ -133,7 +117,7 @@ Migrate in this order:
 2. Exported `Info`, `Input`, `Output`, and DTO types
 3. Tagged domain errors
 4. Service-local internal models
-5. Route and tool boundary validators that can switch to `.zod`
+5. Route and tool boundary validators that can switch to native Effect Schema helpers
 
 This keeps shared types canonical first and makes boundary updates mostly
 mechanical.
@@ -142,21 +126,18 @@ mechanical.
 
 ### `src/config/` ✅ complete
 
-All of `packages/opencode/src/config/` has been migrated. Files that still
-import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
-type annotations — the `export const <Info|Spec>` values are all Effect
-Schema at source.
+All of `packages/opencode/src/config/` has been migrated. The `export const
+<Info|Spec>` values are all Effect Schema at source.
 
 A file is considered "done" when:
 
 - its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.)
   are authored as Effect Schema
-- any remaining zod is either a derived compat bridge (via `zod()` /
-  `zodObject()`), a `z.ZodType` type annotation, or a documented
-  `ZodOverride` escape hatch — never a hand-written parallel source of truth
+- any remaining Zod is an explicit boundary compatibility choice, not a
+  hand-written parallel source of truth
 
-Files that meet this bar but still carry a compat bridge are checked off
-with an inline note describing the bridge and what unblocks its removal.
+Files that meet this bar but still carry a compatibility boundary are checked
+off with an inline note describing the boundary and what unblocks its removal.
 
 - [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
 - [x] server, layout
@@ -361,15 +342,8 @@ piecewise.
 - [ ] `src/util/update-schema.ts`
 - [ ] `src/worktree/index.ts`
 
-### Do-not-migrate
-
-- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
-  (it's what emits zod from Schema). Goes away only when the `.zod`
-  compatibility layer is no longer needed anywhere.
-
 ## Notes
 
-- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion.
 - Prefer one canonical schema definition. Avoid maintaining parallel Zod and
   Effect definitions for the same domain type.
 - Keep the migration incremental. Converting the domain model first is more

+ 1 - 1
packages/opencode/specs/openapi-translation-cleanup.md

@@ -100,7 +100,7 @@ Verification:
 
 - Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
 - Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
-- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
+- Add or fix OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
 - Delete one path override only after generated OpenAPI is unchanged for that param.
 
 Concrete first targets:

+ 1 - 4
packages/opencode/src/command/index.ts

@@ -4,8 +4,6 @@ import { EffectBridge } from "@/effect/bridge"
 import type { InstanceContext } from "@/project/instance"
 import { SessionID, MessageID } from "@/session/schema"
 import { Effect, Layer, Context, Schema } from "effect"
-import z from "zod"
-import { ZodOverride } from "@opencode-ai/core/effect-zod"
 import { Config } from "@/config/config"
 import { MCP } from "../mcp"
 import { Skill } from "../skill"
@@ -35,12 +33,11 @@ export const Info = Schema.Struct({
   model: Schema.optional(Schema.String),
   source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
   // Some command templates are lazy promises from MCP prompt resolution.
-  template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
+  template: Schema.Unknown,
   subtask: Schema.optional(Schema.Boolean),
   hints: Schema.Array(Schema.String),
 }).annotate({ identifier: "Command" })
 
-// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
 export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
 
 export function hints(template: string) {

+ 1 - 9
packages/opencode/src/config/model-id.ts

@@ -1,13 +1,5 @@
 import { Schema } from "effect"
-import z from "zod"
-import { ZodOverride } from "@opencode-ai/core/effect-zod"
 
-// The original Zod schema carried an external $ref pointing at the models.dev
-// JSON schema. That external reference is not a named SDK component — it is a
-// literal pointer to an outside schema — so the walker cannot re-derive it
-// from AST metadata. Preserve the exact original Zod via ZodOverride.
-export const ConfigModelID = Schema.String.annotate({
-  [ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
-})
+export const ConfigModelID = Schema.String
 
 export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>

+ 1 - 5
packages/opencode/src/lsp/lsp.ts

@@ -5,7 +5,6 @@ import * as LSPClient from "./client"
 import path from "path"
 import { pathToFileURL, fileURLToPath } from "url"
 import * as LSPServer from "./server"
-import z from "zod"
 import { Config } from "@/config/config"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Process } from "@/util/process"
@@ -14,7 +13,6 @@ import { Effect, Layer, Context, Schema } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { containsPath } from "@/project/instance-context"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
-import { ZodOverride } from "@opencode-ai/core/effect-zod"
 
 const log = Log.create({ service: "lsp" })
 
@@ -56,9 +54,7 @@ export const Status = Schema.Struct({
   id: Schema.String,
   name: Schema.String,
   root: Schema.String,
-  status: Schema.Literals(["connected", "error"]).annotate({
-    [ZodOverride]: z.union([z.literal("connected"), z.literal("error")]),
-  }),
+  status: Schema.Literals(["connected", "error"]),
 }).annotate({ identifier: "LSPStatus" })
 export type Status = typeof Status.Type
 

+ 2 - 2
packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts

@@ -5,8 +5,8 @@ import { InstanceState } from "@/effect/instance-state"
 import { MCP } from "@/mcp"
 import { Project } from "@/project/project"
 import { Session } from "@/session/session"
+import { ToolJsonSchema } from "@/tool/json-schema"
 import { ToolRegistry } from "@/tool/registry"
-import * as EffectZod from "@opencode-ai/core/effect-zod"
 import { Worktree } from "@/worktree"
 import { Effect, Option } from "effect"
 import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
@@ -84,7 +84,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
       return list.map((item) => ({
         id: item.id,
         description: item.description,
-        parameters: EffectZod.toJsonSchema(item.parameters),
+        parameters: ToolJsonSchema.fromTool(item),
       }))
     })
 

+ 1 - 2
packages/opencode/src/session/message-v2.ts

@@ -1,6 +1,5 @@
 import { BusEvent } from "@/bus/bus-event"
 import { SessionID, MessageID, PartID } from "./schema"
-import z from "zod"
 import { NamedError } from "@opencode-ai/core/util/error"
 import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
 import { LSP } from "@/lsp/lsp"
@@ -55,7 +54,7 @@ export const APIError = namedSchemaError("APIError", {
   responseBody: Schema.optional(Schema.String),
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
 })
-export type APIError = z.infer<typeof APIError.Schema>
+export type APIError = Schema.Schema.Type<typeof APIError.Schema>
 export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
   message: Schema.String,
   responseBody: Schema.optional(Schema.String),

+ 2 - 2
packages/opencode/src/session/prompt.ts

@@ -1,6 +1,5 @@
 import path from "path"
 import os from "os"
-import * as EffectZod from "@opencode-ai/core/effect-zod"
 import { SessionID, MessageID, PartID } from "./schema"
 import { MessageV2 } from "./message-v2"
 import * as Log from "@opencode-ai/core/util/log"
@@ -21,6 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
 import BUILD_SWITCH from "../session/prompt/build-switch.txt"
 import MAX_STEPS from "../session/prompt/max-steps.txt"
 import { ToolRegistry } from "@/tool/registry"
+import { ToolJsonSchema } from "@/tool/json-schema"
 import { MCP } from "../mcp"
 import { LSP } from "@/lsp/lsp"
 import { Flag } from "@opencode-ai/core/flag/flag"
@@ -565,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
         providerID: input.model.providerID,
         agent: input.agent,
       })) {
-        const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
+        const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
         tools[item.id] = tool({
           description: item.description,
           inputSchema: jsonSchema(schema),

+ 164 - 0
packages/opencode/src/tool/json-schema.ts

@@ -0,0 +1,164 @@
+import type { JSONSchema7 } from "@ai-sdk/provider"
+import { JsonSchema, Schema } from "effect"
+import type * as Tool from "./tool"
+
+type JsonObject = Record<string, unknown>
+const cache = new WeakMap<Schema.Top, JSONSchema7>()
+
+export function fromSchema(schema: Schema.Top): JSONSchema7 {
+  const cached = cache.get(schema)
+  if (cached) return cached
+
+  const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true })
+  const result = normalize({
+    $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
+    ...document.schema,
+    ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}),
+  })
+  const inlined = dropDefinitionsIfResolved(inlineLocalReferences(result))
+  if (!isJsonSchema(inlined)) throw new Error("tool JSON Schema helper produced a non-schema value")
+  cache.set(schema, inlined)
+  return inlined
+}
+
+export function fromTool(tool: Tool.Def): JSONSchema7 {
+  return tool.jsonSchema ?? fromSchema(tool.parameters as Schema.Top)
+}
+
+function normalize(value: unknown, options: { stripNull?: boolean } = {}): unknown {
+  if (Array.isArray(value)) return value.map((item) => normalize(item))
+  if (!isRecord(value)) return value
+
+  const required = Array.isArray(value.required)
+    ? new Set(value.required.filter((item) => typeof item === "string"))
+    : undefined
+  const schema = Object.fromEntries(
+    Object.entries(value).map(([key, item]) => [
+      key,
+      key === "properties" && isRecord(item)
+        ? Object.fromEntries(
+            Object.entries(item).map(([name, property]) => [
+              name,
+              normalize(property, { stripNull: !required?.has(name) }),
+            ]),
+          )
+        : normalize(item),
+    ]),
+  )
+
+  if (schema.additionalProperties === true) delete schema.additionalProperties
+
+  if (options.stripNull && Array.isArray(schema.anyOf)) {
+    const withoutNull = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null")
+    if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull })
+  }
+
+  if (Array.isArray(schema.anyOf)) {
+    const withoutNull = schema.anyOf
+    const number = withoutNull.find((item) => isRecord(item) && item.type === "number")
+    const nonFinite = withoutNull.filter(
+      (item) => isRecord(item) && Array.isArray(item.enum) && item.enum.every((entry) => isNonFiniteNumber(entry)),
+    )
+    if (number && nonFinite.length === withoutNull.length - 1) {
+      const { anyOf: _, ...rest } = schema
+      return normalize({ ...number, ...rest })
+    }
+
+    if (isEmptyStructUnion(withoutNull)) {
+      const { anyOf: _, ...rest } = schema
+      return normalize({ type: "object", properties: {}, ...rest })
+    }
+
+    if (withoutNull.length === 1 && isRecord(withoutNull[0])) {
+      const { anyOf: _, ...rest } = schema
+      return normalize({ ...withoutNull[0], ...rest })
+    }
+  }
+
+  if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) {
+    const { allOf, ...rest } = schema
+    return normalize({ ...Object.assign({}, ...allOf), ...rest })
+  }
+
+  if (schema.type === "integer" && schema.maximum === undefined) {
+    return { minimum: Number.MIN_SAFE_INTEGER, ...schema, maximum: Number.MAX_SAFE_INTEGER }
+  }
+
+  return schema
+}
+
+function isRecord(value: unknown): value is JsonObject {
+  return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+function isJsonSchema(value: unknown): value is JSONSchema7 {
+  return typeof value === "boolean" || isRecord(value)
+}
+
+function isNonFiniteNumber(value: unknown) {
+  return value === "NaN" || value === "Infinity" || value === "-Infinity"
+}
+
+function isEmptyStructUnion(items: unknown[]) {
+  return (
+    items.length === 2 &&
+    items.some((item) => isRecord(item) && item.type === "object" && item.properties === undefined) &&
+    items.some((item) => isRecord(item) && item.type === "array" && item.items === undefined)
+  )
+}
+
+function canFlattenAllOf(allOf: JsonObject[], parent: JsonObject) {
+  const keys = new Set(Object.keys(parent).filter((key) => key !== "allOf"))
+  return allOf.every((item) =>
+    Object.keys(item).every((key) => {
+      if (keys.has(key)) return false
+      keys.add(key)
+      return true
+    }),
+  )
+}
+
+function inlineLocalReferences(value: unknown, definitions?: JsonObject, seen = new Set<string>()): unknown {
+  if (Array.isArray(value)) return value.map((item) => inlineLocalReferences(item, definitions, seen))
+  if (!isRecord(value)) return value
+
+  const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined)
+  if (typeof value.$ref === "string" && localDefinitions) {
+    const name = value.$ref.match(/^#\/\$defs\/(.+)$/)?.[1] ?? value.$ref.match(/^#\/definitions\/(.+)$/)?.[1]
+    if (name && !seen.has(name)) {
+      const target = localDefinitions[name]
+      if (target) {
+        const { $ref: _, ...rest } = value
+        return inlineLocalReferences(
+          { ...(isRecord(target) ? target : {}), ...rest },
+          localDefinitions,
+          new Set(seen).add(name),
+        )
+      }
+    }
+  }
+
+  return Object.fromEntries(
+    Object.entries(value).map(([key, item]) => [key, inlineLocalReferences(item, localDefinitions, seen)]),
+  )
+}
+
+function dropDefinitionsIfResolved(value: unknown): unknown {
+  if (!isRecord(value) || hasLocalReference(value)) return value
+  const { $defs: _, definitions: __, ...rest } = value
+  return rest
+}
+
+function hasLocalReference(value: unknown): boolean {
+  if (Array.isArray(value)) return value.some(hasLocalReference)
+  if (!isRecord(value)) return false
+  if (
+    typeof value.$ref === "string" &&
+    (value.$ref.startsWith("#/$defs/") || value.$ref.startsWith("#/definitions/"))
+  ) {
+    return true
+  }
+  return Object.values(value).some(hasLocalReference)
+}
+
+export * as ToolJsonSchema from "./json-schema"

+ 63 - 9
packages/opencode/src/tool/registry.ts

@@ -15,9 +15,9 @@ import { SkillTool } from "./skill"
 import * as Tool from "./tool"
 import { Config } from "@/config/config"
 import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
+import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider"
 import { Schema } from "effect"
 import z from "zod"
-import { ZodOverride } from "@opencode-ai/core/effect-zod"
 import { Plugin } from "../plugin"
 import { Provider } from "@/provider/provider"
 import { ProviderID, type ModelID } from "../provider/schema"
@@ -137,17 +137,19 @@ export const layer: Layer.Layer<
         const custom: Tool.Def[] = []
 
         function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
-          // Plugin tools define their args as a raw Zod shape. Wrap the
-          // derived Zod object in a `Schema.declare` so it slots into the
-          // Schema-typed framework, and annotate with `ZodOverride` so the
-          // walker emits the original Zod object for LLM JSON Schema.
-          const zodParams = z.object(def.args)
-          const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({
-            [ZodOverride]: zodParams,
-          })
+          // Plugin tools still expose Zod args publicly; keep that compatibility
+          // boxed at the registry boundary and give the LLM the original JSON Schema.
+          const entries = Object.entries(def.args)
+          const allZod = entries.every((entry) => isZodType(entry[1]))
+          const zodParams = allZod ? z.object(def.args) : undefined
+          const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
+          const parameters = zodParams
+            ? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
+            : Schema.Unknown
           return {
             id,
             parameters,
+            jsonSchema,
             description: def.description,
             execute: (args, toolCtx) =>
               Effect.gen(function* () {
@@ -323,8 +325,13 @@ export const layer: Layer.Layer<
           const output = {
             description: tool.description,
             parameters: tool.parameters,
+            jsonSchema: tool.jsonSchema,
           }
           yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
+          const jsonSchema =
+            output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema
+              ? output.jsonSchema
+              : undefined
           return {
             id: tool.id,
             description: [
@@ -335,6 +342,7 @@ export const layer: Layer.Layer<
               .filter(Boolean)
               .join("\n"),
             parameters: output.parameters,
+            jsonSchema,
             execute: tool.execute,
             formatValidationError: tool.formatValidationError,
           }
@@ -376,4 +384,50 @@ export const defaultLayer = Layer.suspend(() =>
   ),
 )
 
+function isZodType(value: unknown): value is z.ZodType {
+  return typeof value === "object" && value !== null && "_zod" in value
+}
+
+function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition {
+  return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value))
+}
+
+function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 {
+  const properties = Object.fromEntries(
+    entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])),
+  )
+  return {
+    type: "object",
+    properties,
+    required: Object.keys(properties),
+  }
+}
+
+function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
+  const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" }))
+  if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema")
+  const { $defs, ...rest } = result
+  return (
+    $defs && isJsonSchemaObject($defs) ? { ...rest, definitions: $defs as JSONSchema7["definitions"] } : rest
+  ) as JSONSchema7
+}
+
+function normalizeZodJsonSchema(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item))
+  if (typeof value !== "object" || value === null) return value
+  return Object.fromEntries(
+    Object.entries(value)
+      .filter((entry) =>
+        (entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean"
+          ? false
+          : true,
+      )
+      .map(([key, item]) => [key, normalizeZodJsonSchema(item)]),
+  )
+}
+
+function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
 export * as ToolRegistry from "./registry"

+ 2 - 0
packages/opencode/src/tool/tool.ts

@@ -1,4 +1,5 @@
 import { Effect, Schema } from "effect"
+import type { JSONSchema7 } from "@ai-sdk/provider"
 import type { MessageV2 } from "../session/message-v2"
 import type { Permission } from "../permission"
 import type { SessionID, MessageID } from "../session/schema"
@@ -38,6 +39,7 @@ export interface Def<
   id: string
   description: string
   parameters: Parameters
+  jsonSchema?: JSONSchema7
   execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
   formatValidationError?(error: unknown): string
 }

+ 3 - 2
packages/opencode/src/tool/webfetch.ts

@@ -12,10 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes
 export const Parameters = Schema.Struct({
   url: Schema.String.annotate({ description: "The URL to fetch content from" }),
   format: Schema.Literals(["text", "markdown", "html"])
-    .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const)))
     .annotate({
       description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
-    }),
+      default: "markdown",
+    })
+    .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
   timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }),
 })
 

+ 3 - 13
packages/opencode/src/util/named-schema-error.ts

@@ -1,6 +1,4 @@
 import { Schema } from "effect"
-import z from "zod"
-import { zod } from "@opencode-ai/core/effect-zod"
 
 /**
  * Create a Schema-backed NamedError-shaped class.
@@ -11,22 +9,14 @@ import { zod } from "@opencode-ai/core/effect-zod"
  * OpenAPI/SDK output is byte-identical to the original NamedError schema.
  *
  * Preserves the existing surface:
- *   - static `Schema` (Zod schema of the wire shape)
+ *   - static `Schema` (Effect schema of the wire shape)
  *   - static `isInstance(x)`
  *   - instance `toObject()` returning `{ name, data }`
  *   - `new X({ ...data }, { cause })`
  */
 export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
-  // Wire shape matches the original NamedError output so the SDK stays stable.
   const dataSchema = Schema.Struct(fields)
-  const wire = z
-    .object({
-      name: z.literal(tag),
-      data: zod(dataSchema),
-    })
-    .meta({ ref: tag })
-
-  // Effect Schema for the wire shape — used by HttpApi OpenAPI generation.
+  // Wire shape matches the original NamedError output so the SDK stays stable.
   const effectSchema = Schema.Struct({
     name: Schema.Literal(tag),
     data: dataSchema,
@@ -35,7 +25,7 @@ export function namedSchemaError<Tag extends string, Fields extends Schema.Struc
   type Data = Schema.Schema.Type<typeof dataSchema>
 
   class NamedSchemaError extends Error {
-    static readonly Schema = wire
+    static readonly Schema = effectSchema
     static readonly EffectSchema = effectSchema
     static readonly tag = tag
     public static isInstance(input: unknown): input is NamedSchemaError {

+ 12 - 12
packages/opencode/test/session/retry.test.ts

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
 import type { NamedError } from "@opencode-ai/core/util/error"
 import { APICallError } from "ai"
 import { setTimeout as sleep } from "node:timers/promises"
-import { Effect, Layer, Schedule } from "effect"
+import { Effect, Layer, Schedule, Schema } from "effect"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { SessionRetry } from "../../src/session/retry"
 import { MessageV2 } from "../../src/session/message-v2"
@@ -17,7 +17,7 @@ const retryProvider = "test"
 const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
 
 function apiError(headers?: Record<string, string>): MessageV2.APIError {
-  return MessageV2.APIError.Schema.parse(
+  return Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
     new MessageV2.APIError({
       message: "boom",
       isRetryable: true,
@@ -94,7 +94,7 @@ describe("session.retry.delay", () => {
         const step = yield* Schedule.toStepWithMetadata(
           SessionRetry.policy({
             provider: "test",
-            parse: (err) => MessageV2.APIError.Schema.parse(err),
+            parse: Schema.decodeUnknownSync(MessageV2.APIError.Schema),
             set: (info) =>
               status.set(sessionID, {
                 type: "retry",
@@ -173,7 +173,7 @@ describe("session.retry.retryable", () => {
   })
 
   test("retries 500 errors even when isRetryable is false", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
         message: "Internal server error",
         isRetryable: false,
@@ -186,7 +186,7 @@ describe("session.retry.retryable", () => {
   })
 
   test("retries 502 bad gateway errors", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
         message: "Bad gateway",
         isRetryable: false,
@@ -198,7 +198,7 @@ describe("session.retry.retryable", () => {
   })
 
   test("retries 503 service unavailable errors", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
         message: "Service unavailable",
         isRetryable: false,
@@ -210,7 +210,7 @@ describe("session.retry.retryable", () => {
   })
 
   test("does not retry 4xx errors when isRetryable is false", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
         message: "Bad request",
         isRetryable: false,
@@ -222,7 +222,7 @@ describe("session.retry.retryable", () => {
   })
 
   test("retries ZlibError decompression failures", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
         message: "Response decompression failed",
         isRetryable: true,
@@ -236,7 +236,7 @@ describe("session.retry.retryable", () => {
   })
 
   test("maps free limits to Go upsell action", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
         message: "Free usage exceeded",
         isRetryable: true,
@@ -262,7 +262,7 @@ describe("session.retry.retryable", () => {
   })
 
   test("maps Go subscription limits to workspace PAYG upsell", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
         message: "Subscription quota exceeded. You can continue using free models.",
         isRetryable: true,
@@ -300,7 +300,7 @@ describe("session.retry.retryable", () => {
   })
 
   test("maps Go subscription limits without limit metadata", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
         message: "Subscription quota exceeded. You can continue using free models.",
         isRetryable: true,
@@ -366,7 +366,7 @@ describe("session.message-v2.fromError", () => {
   )
 
   test("ECONNRESET socket error is retryable", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
         message: "Connection reset by server",
         isRetryable: true,

+ 15 - 9
packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap

@@ -45,6 +45,7 @@ Output: Creates directory 'foo'"
       "description": "Optional timeout in milliseconds",
       "exclusiveMinimum": 0,
       "maximum": 9007199254740991,
+      "minimum": -9007199254740991,
       "type": "integer",
     },
     "workdir": {
@@ -240,7 +241,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
                   "type": "string",
                 },
               },
-              "ref": "QuestionOption",
               "required": [
                 "label",
                 "description",
@@ -254,7 +254,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
             "type": "string",
           },
         },
-        "ref": "QuestionPrompt",
         "required": [
           "question",
           "header",
@@ -393,14 +392,21 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
   "$schema": "https://json-schema.org/draft/2020-12/schema",
   "properties": {
     "format": {
-      "default": "markdown",
-      "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
-      "enum": [
-        "text",
-        "markdown",
-        "html",
+      "anyOf": [
+        {
+          "default": "markdown",
+          "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
+          "enum": [
+            "text",
+            "markdown",
+            "html",
+          ],
+          "type": "string",
+        },
+        {
+          "type": "null",
+        },
       ],
-      "type": "string",
     },
     "timeout": {
       "description": "Optional timeout in seconds (max 120)",

+ 35 - 3
packages/opencode/test/tool/parameters.test.ts

@@ -1,13 +1,13 @@
 import { describe, expect, test } from "bun:test"
 import { Result, Schema } from "effect"
-import { toJsonSchema } from "@opencode-ai/core/effect-zod"
+import { ToolJsonSchema } from "../../src/tool/json-schema"
 
 // Each tool exports its parameters schema at module scope so this test can
 // import them without running the tool's Effect-based init. The JSON Schema
 // snapshot captures what the LLM sees; the parse assertions pin down the
-// accepts/rejects contract. `toJsonSchema` is the same helper `session/
+// accepts/rejects contract. `ToolJsonSchema.fromSchema` is the same helper `session/
 // prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
-// byte-identical regardless of whether a tool has migrated from zod to Schema.
+// provider-compatible while tools use Effect Schema internally.
 
 import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
 import { Parameters as Edit } from "../../src/tool/edit"
@@ -32,6 +32,8 @@ const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S[
 const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
   Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
 
+const toJsonSchema = ToolJsonSchema.fromSchema
+
 describe("tool parameters", () => {
   describe("JSON Schema (wire shape)", () => {
     test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
@@ -50,6 +52,36 @@ describe("tool parameters", () => {
     test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
     test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
     test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
+
+    test("inlines named child schemas for provider compatibility", () => {
+      const schema = toJsonSchema(Question)
+      expect(schema).not.toHaveProperty("$defs")
+      expect(schema).toMatchObject({
+        properties: {
+          questions: { items: { properties: { options: { items: { properties: { label: { type: "string" } } } } } } },
+        },
+      })
+    })
+
+    test("preserves required nullable fields", () => {
+      expect(toJsonSchema(Schema.Struct({ value: Schema.NullOr(Schema.String) }))).toMatchObject({
+        properties: { value: { anyOf: expect.arrayContaining([{ type: "null" }]) } },
+      })
+    })
+
+    test("keeps repeated allOf constraints instead of dropping duplicates", () => {
+      expect(
+        toJsonSchema(
+          Schema.Struct({ value: Schema.String.check(Schema.isPattern(/^a/)).check(Schema.isPattern(/z$/)) }),
+        ),
+      ).toMatchObject({ properties: { value: { allOf: [{ pattern: "^a" }, { pattern: "z$" }] } } })
+    })
+
+    test("bounds bare integer fields to safe integer range", () => {
+      expect(toJsonSchema(Schema.Struct({ value: Schema.Int }))).toMatchObject({
+        properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } },
+      })
+    })
   })
 
   describe("apply_patch", () => {

+ 88 - 2
packages/opencode/test/tool/registry.test.ts

@@ -1,7 +1,8 @@
 import { afterEach, describe, expect } from "bun:test"
 import path from "path"
 import fs from "fs/promises"
-import { Effect, Layer } from "effect"
+import { pathToFileURL } from "url"
+import { Effect, Layer, Result, Schema } from "effect"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { ToolRegistry } from "@/tool/registry"
 import { Flag } from "@opencode-ai/core/flag/flag"
@@ -26,6 +27,8 @@ import { Ripgrep } from "@/file/ripgrep"
 import * as Truncate from "@/tool/truncate"
 import { InstanceState } from "@/effect/instance-state"
 import { Reference } from "@/reference/reference"
+import { ProviderID, ModelID } from "@/provider/schema"
+import { ToolJsonSchema } from "@/tool/json-schema"
 
 const node = CrossSpawnSpawner.defaultLayer
 const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
@@ -55,7 +58,7 @@ const registryLayer = ToolRegistry.layer.pipe(
   Layer.provide(Truncate.defaultLayer),
 )
 
-const it = testEffect(Layer.mergeAll(registryLayer, node))
+const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer))
 
 afterEach(async () => {
   Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout
@@ -141,6 +144,89 @@ describe("tool.registry", () => {
     }),
   )
 
+  it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const customTools = path.join(test.directory, ".opencode", "tools")
+      const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
+      yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
+      yield* Effect.promise(() =>
+        Bun.write(
+          path.join(customTools, "sql.ts"),
+          [
+            `import { tool } from ${JSON.stringify(pluginTool)}`,
+            "export default tool({",
+            "  description: 'query database',",
+            "  args: { query: tool.schema.string().describe('SQL query to execute') },",
+            "  execute: async ({ query }) => query,",
+            "})",
+            "",
+          ].join("\n"),
+        ),
+      )
+
+      const registry = yield* ToolRegistry.Service
+      const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
+      if (!loaded) throw new Error("custom sql tool was not loaded")
+      expect(loaded?.jsonSchema).toMatchObject({
+        type: "object",
+        properties: {
+          query: { type: "string", description: "SQL query to execute" },
+        },
+        required: ["query"],
+      })
+      expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true)
+      expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
+
+      const agents = yield* Agent.Service
+      const promptTools = yield* registry.tools({
+        providerID: ProviderID.opencode,
+        modelID: ModelID.make("test"),
+        agent: yield* agents.get(yield* agents.defaultAgent()),
+      })
+      const promptTool = promptTools.find((tool) => tool.id === "sql")
+      if (!promptTool) throw new Error("custom sql tool was not returned for prompts")
+      expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({
+        properties: {
+          query: { type: "string", description: "SQL query to execute" },
+        },
+        required: ["query"],
+      })
+    }),
+  )
+
+  it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const tools = path.join(test.directory, ".opencode", "tools")
+      yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
+      yield* Effect.promise(() =>
+        Bun.write(
+          path.join(tools, "legacy.ts"),
+          [
+            "export default {",
+            "  description: 'legacy schema tool',",
+            "  args: { text: { type: 'string', description: 'Text to render' } },",
+            "  execute: async ({ text }) => text,",
+            "}",
+            "",
+          ].join("\n"),
+        ),
+      )
+
+      const registry = yield* ToolRegistry.Service
+      const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
+      if (!loaded) throw new Error("legacy custom tool was not loaded")
+      expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
+        type: "object",
+        properties: {
+          text: { type: "string", description: "Text to render" },
+        },
+        required: ["text"],
+      })
+    }),
+  )
+
   it.instance("loads tools with external dependencies without crashing", () =>
     Effect.gen(function* () {
       const test = yield* TestInstance

+ 0 - 754
packages/opencode/test/util/effect-zod.test.ts

@@ -1,754 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { Effect, Schema, SchemaGetter } from "effect"
-import z from "zod"
-
-import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
-
-function json(schema: z.ZodTypeAny) {
-  const { $schema: _, ...rest } = z.toJSONSchema(schema)
-  return rest
-}
-
-describe("util.effect-zod", () => {
-  test("converts class schemas for route dto shapes", () => {
-    class Method extends Schema.Class<Method>("ProviderAuthMethod")({
-      type: Schema.Union([Schema.Literal("oauth"), Schema.Literal("api")]),
-      label: Schema.String,
-    }) {}
-
-    const out = zod(Method)
-
-    expect(out.meta()?.ref).toBe("ProviderAuthMethod")
-    expect(
-      out.parse({
-        type: "oauth",
-        label: "OAuth",
-      }),
-    ).toEqual({
-      type: "oauth",
-      label: "OAuth",
-    })
-  })
-
-  test("converts structs with optional fields, arrays, and records", () => {
-    const out = zod(
-      Schema.Struct({
-        foo: Schema.optional(Schema.String),
-        bar: Schema.Array(Schema.Number),
-        baz: Schema.Record(Schema.String, Schema.Boolean),
-      }),
-    )
-
-    expect(
-      out.parse({
-        bar: [1, 2],
-        baz: { ok: true },
-      }),
-    ).toEqual({
-      bar: [1, 2],
-      baz: { ok: true },
-    })
-    expect(
-      out.parse({
-        foo: "hi",
-        bar: [1],
-        baz: { ok: false },
-      }),
-    ).toEqual({
-      foo: "hi",
-      bar: [1],
-      baz: { ok: false },
-    })
-  })
-
-  describe("Tuples", () => {
-    test("fixed-length tuple parses matching array", () => {
-      const out = zod(Schema.Tuple([Schema.String, Schema.Number]))
-      expect(out.parse(["a", 1])).toEqual(["a", 1])
-      expect(out.safeParse(["a"]).success).toBe(false)
-      expect(out.safeParse(["a", "b"]).success).toBe(false)
-    })
-
-    test("single-element tuple parses a one-element array", () => {
-      const out = zod(Schema.Tuple([Schema.Boolean]))
-      expect(out.parse([true])).toEqual([true])
-      expect(out.safeParse([true, false]).success).toBe(false)
-    })
-
-    test("tuple inside a union picks the right branch", () => {
-      const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])]))
-      expect(out.parse("hello")).toBe("hello")
-      expect(out.parse(["foo", 42])).toEqual(["foo", 42])
-      expect(out.safeParse(["foo"]).success).toBe(false)
-    })
-
-    test("plain arrays still work (no element positions)", () => {
-      const out = zod(Schema.Array(Schema.String))
-      expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"])
-      expect(out.parse([])).toEqual([])
-    })
-  })
-
-  test("string literal unions produce z.enum with enum in JSON Schema", () => {
-    const Action = Schema.Literals(["allow", "deny", "ask"])
-    const out = zod(Action)
-
-    expect(out.parse("allow")).toBe("allow")
-    expect(out.parse("deny")).toBe("deny")
-    expect(() => out.parse("nope")).toThrow()
-
-    // Matches native z.enum JSON Schema output
-    const bridged = json(out)
-    const native = json(z.enum(["allow", "deny", "ask"]))
-    expect(bridged).toEqual(native)
-    expect(bridged.enum).toEqual(["allow", "deny", "ask"])
-  })
-
-  test("ZodOverride annotation provides the Zod schema for branded IDs", () => {
-    const override = z.string().startsWith("per")
-    const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("TestID"))
-
-    const Parent = Schema.Struct({ id: ID, name: Schema.String })
-    const out = zod(Parent)
-
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    expect((out as any).parse({ id: "per_abc", name: "test" })).toEqual({ id: "per_abc", name: "test" })
-
-    const schema = json(out) as any
-    expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" })
-  })
-
-  test("Schema.Class nested in a parent preserves ref via identifier", () => {
-    class Inner extends Schema.Class<Inner>("MyInner")({
-      value: Schema.String,
-    }) {}
-
-    class Outer extends Schema.Class<Outer>("MyOuter")({
-      inner: Inner,
-    }) {}
-
-    const out = zod(Outer)
-    expect(out.meta()?.ref).toBe("MyOuter")
-
-    const shape = (out as any).shape ?? (out as any)._def?.shape?.()
-    expect(shape.inner.meta()?.ref).toBe("MyInner")
-  })
-
-  test("Schema.Class preserves identifier and uses enum format", () => {
-    class Rule extends Schema.Class<Rule>("PermissionRule")({
-      permission: Schema.String,
-      pattern: Schema.String,
-      action: Schema.Literals(["allow", "deny", "ask"]),
-    }) {}
-
-    const out = zod(Rule)
-    expect(out.meta()?.ref).toBe("PermissionRule")
-
-    const schema = json(out) as any
-    expect(schema.properties.action).toEqual({
-      type: "string",
-      enum: ["allow", "deny", "ask"],
-    })
-  })
-
-  test("ZodOverride on ID carries pattern through Schema.Class", () => {
-    const ID = Schema.String.annotate({
-      [ZodOverride]: z.string().startsWith("per"),
-    })
-
-    class Request extends Schema.Class<Request>("TestRequest")({
-      id: ID,
-      name: Schema.String,
-    }) {}
-
-    const schema = json(zod(Request)) as any
-    expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" })
-    expect(schema.properties.name).toEqual({ type: "string" })
-  })
-
-  test("Permission schemas match original Zod equivalents", () => {
-    const MsgID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("msg") })
-    const PerID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })
-    const SesID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("ses") })
-
-    class Tool extends Schema.Class<Tool>("PermissionTool")({
-      messageID: MsgID,
-      callID: Schema.String,
-    }) {}
-
-    class Request extends Schema.Class<Request>("PermissionRequest")({
-      id: PerID,
-      sessionID: SesID,
-      permission: Schema.String,
-      patterns: Schema.Array(Schema.String),
-      metadata: Schema.Record(Schema.String, Schema.Unknown),
-      always: Schema.Array(Schema.String),
-      tool: Schema.optional(Tool),
-    }) {}
-
-    const bridged = json(zod(Request)) as any
-    expect(bridged.properties.id).toEqual({ type: "string", pattern: "^per.*" })
-    expect(bridged.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
-    expect(bridged.properties.permission).toEqual({ type: "string" })
-    expect(bridged.required?.sort()).toEqual(["id", "sessionID", "permission", "patterns", "metadata", "always"].sort())
-
-    // Tool field is present with the ref from Schema.Class identifier
-    const toolSchema = json(zod(Tool)) as any
-    expect(toolSchema.properties.messageID).toEqual({ type: "string", pattern: "^msg.*" })
-    expect(toolSchema.properties.callID).toEqual({ type: "string" })
-  })
-
-  test("ZodOverride survives Schema.brand", () => {
-    const override = z.string().startsWith("ses")
-    const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("SessionID"))
-
-    // The branded schema's AST still has the override
-    class Parent extends Schema.Class<Parent>("Parent")({
-      sessionID: ID,
-    }) {}
-
-    const schema = json(zod(Parent)) as any
-    expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
-  })
-
-  describe("Schema.check translation", () => {
-    test("filter returning string triggers refinement with that message", () => {
-      const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number"))
-      const schema = zod(Schema.Number.check(isEven))
-
-      expect(schema.parse(4)).toBe(4)
-      const result = schema.safeParse(3)
-      expect(result.success).toBe(false)
-      expect(result.error!.issues[0].message).toBe("expected an even number")
-    })
-
-    test("filter returning false triggers refinement with fallback message", () => {
-      const nonEmpty = Schema.makeFilter((s: string) => s.length > 0)
-      const schema = zod(Schema.String.check(nonEmpty))
-
-      expect(schema.parse("hi")).toBe("hi")
-      const result = schema.safeParse("")
-      expect(result.success).toBe(false)
-      expect(result.error!.issues[0].message).toMatch(/./)
-    })
-
-    test("filter returning undefined passes validation", () => {
-      const alwaysOk = Schema.makeFilter(() => undefined)
-      const schema = zod(Schema.Number.check(alwaysOk))
-
-      expect(schema.parse(42)).toBe(42)
-    })
-
-    test("annotations.message on the filter is used when filter returns false", () => {
-      const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" })
-      const schema = zod(Schema.Number.check(positive))
-
-      const result = schema.safeParse(-1)
-      expect(result.success).toBe(false)
-      expect(result.error!.issues[0].message).toBe("must be positive")
-    })
-
-    test("cross-field check on a record flags missing key", () => {
-      const hasKey = Schema.makeFilter((data: Record<string, { enabled: boolean }>) =>
-        "required" in data ? undefined : "missing 'required' key",
-      )
-      const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey))
-
-      expect(schema.parse({ required: { enabled: true } })).toEqual({
-        required: { enabled: true },
-      })
-
-      const result = schema.safeParse({ other: { enabled: true } })
-      expect(result.success).toBe(false)
-      expect(result.error!.issues[0].message).toBe("missing 'required' key")
-    })
-  })
-
-  describe("StructWithRest / catchall", () => {
-    test("struct with a string-keyed record rest parses known AND extra keys", () => {
-      const schema = zod(
-        Schema.StructWithRest(
-          Schema.Struct({
-            apiKey: Schema.optional(Schema.String),
-            baseURL: Schema.optional(Schema.String),
-          }),
-          [Schema.Record(Schema.String, Schema.Unknown)],
-        ),
-      )
-
-      // Known fields come through as declared
-      expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" })
-
-      // Extra keys are preserved (catchall)
-      expect(
-        schema.parse({
-          apiKey: "sk-x",
-          baseURL: "https://api.example.com",
-          customField: "anything",
-          nested: { foo: 1 },
-        }),
-      ).toEqual({
-        apiKey: "sk-x",
-        baseURL: "https://api.example.com",
-        customField: "anything",
-        nested: { foo: 1 },
-      })
-    })
-
-    test("catchall value type constrains the extras", () => {
-      const schema = zod(
-        Schema.StructWithRest(
-          Schema.Struct({
-            count: Schema.Number,
-          }),
-          [Schema.Record(Schema.String, Schema.Number)],
-        ),
-      )
-
-      // Known field + numeric extras
-      expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 })
-
-      // Non-numeric extra is rejected
-      expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false)
-    })
-
-    test("JSON schema output marks additionalProperties appropriately", () => {
-      const schema = zod(
-        Schema.StructWithRest(
-          Schema.Struct({
-            id: Schema.String,
-          }),
-          [Schema.Record(Schema.String, Schema.Unknown)],
-        ),
-      )
-      const shape = json(schema) as { additionalProperties?: unknown }
-      // Presence of `additionalProperties` (truthy or a schema) signals catchall.
-      expect(shape.additionalProperties).not.toBe(false)
-      expect(shape.additionalProperties).toBeDefined()
-    })
-
-    test("plain struct without rest still emits additionalProperties unchanged (regression)", () => {
-      const schema = zod(Schema.Struct({ id: Schema.String }))
-      expect(schema.parse({ id: "x" })).toEqual({ id: "x" })
-    })
-  })
-
-  describe("transforms (Schema.decodeTo)", () => {
-    test("Number -> pseudo-Duration (seconds) applies the decode function", () => {
-      // Models the account/account.ts DurationFromSeconds pattern.
-      const SecondsToMs = Schema.Number.pipe(
-        Schema.decodeTo(Schema.Number, {
-          decode: SchemaGetter.transform((n: number) => n * 1000),
-          encode: SchemaGetter.transform((ms: number) => ms / 1000),
-        }),
-      )
-
-      const schema = zod(SecondsToMs)
-      expect(schema.parse(3)).toBe(3000)
-      expect(schema.parse(0)).toBe(0)
-    })
-
-    test("String -> Number via parseInt decode", () => {
-      const ParsedInt = Schema.String.pipe(
-        Schema.decodeTo(Schema.Number, {
-          decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
-          encode: SchemaGetter.transform((n: number) => String(n)),
-        }),
-      )
-
-      const schema = zod(ParsedInt)
-      expect(schema.parse("42")).toBe(42)
-      expect(schema.parse("0")).toBe(0)
-    })
-
-    test("transform inside a struct field applies per-field", () => {
-      const Field = Schema.Number.pipe(
-        Schema.decodeTo(Schema.Number, {
-          decode: SchemaGetter.transform((n: number) => n + 1),
-          encode: SchemaGetter.transform((n: number) => n - 1),
-        }),
-      )
-
-      const schema = zod(
-        Schema.Struct({
-          plain: Schema.Number,
-          bumped: Field,
-        }),
-      )
-
-      expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 })
-    })
-
-    test("chained decodeTo composes transforms in order", () => {
-      // String -> Number (parseInt) -> Number (doubled).
-      // Exercises the encoded() reduce, not just a single link.
-      const Chained = Schema.String.pipe(
-        Schema.decodeTo(Schema.Number, {
-          decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
-          encode: SchemaGetter.transform((n: number) => String(n)),
-        }),
-        Schema.decodeTo(Schema.Number, {
-          decode: SchemaGetter.transform((n: number) => n * 2),
-          encode: SchemaGetter.transform((n: number) => n / 2),
-        }),
-      )
-
-      const schema = zod(Chained)
-      expect(schema.parse("21")).toBe(42)
-      expect(schema.parse("0")).toBe(0)
-    })
-
-    test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => {
-      // Schema.Class uses Declaration + encoding under the hood to construct
-      // class instances. The walker must NOT apply that transform, or zod
-      // parsing would return class instances instead of plain objects.
-      class Method extends Schema.Class<Method>("TxTestMethod")({
-        type: Schema.String,
-        value: Schema.Number,
-      }) {}
-
-      const schema = zod(Method)
-      const parsed = schema.parse({ type: "oauth", value: 1 })
-      expect(parsed).toEqual({ type: "oauth", value: 1 })
-      // Guardrail: ensure we didn't get back a Method instance.
-      expect(parsed).not.toBeInstanceOf(Method)
-    })
-  })
-
-  describe("optimizations", () => {
-    test("walk() memoizes by AST identity — same AST node returns same Zod", () => {
-      const shared = Schema.Struct({ id: Schema.String, name: Schema.String })
-      const left = zod(shared)
-      const right = zod(shared)
-      expect(left).toBe(right)
-    })
-
-    test("nested reuse of the same AST reuses the cached Zod child", () => {
-      // Two different parents embed the same inner schema. The inner zod
-      // child should be identical by reference inside both parents.
-      class Inner extends Schema.Class<Inner>("MemoTestInner")({
-        value: Schema.String,
-      }) {}
-
-      class OuterA extends Schema.Class<OuterA>("MemoTestOuterA")({
-        inner: Inner,
-      }) {}
-
-      class OuterB extends Schema.Class<OuterB>("MemoTestOuterB")({
-        inner: Inner,
-      }) {}
-
-      const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.()
-      const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.()
-      expect(shapeA.inner).toBe(shapeB.inner)
-    })
-
-    test("multiple checks run in a single refinement layer (all fire on one value)", () => {
-      // Three checks attached to the same schema. All three must run and
-      // report — asserting that no check silently got dropped when we
-      // flattened into one superRefine.
-      const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
-      const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
-      const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
-
-      const schema = zod(Schema.Number.check(positive).check(even).check(under100))
-
-      const neg = schema.safeParse(-3)
-      expect(neg.success).toBe(false)
-      expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
-
-      const big = schema.safeParse(101)
-      expect(big.success).toBe(false)
-      expect(big.error!.issues.map((i) => i.message)).toContain("too big")
-
-      // Passing value satisfies all three
-      expect(schema.parse(42)).toBe(42)
-    })
-
-    test("FilterGroup flattens into the single refinement layer alongside its siblings", () => {
-      const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
-      const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
-      const group = Schema.makeFilterGroup([positive, even])
-      const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
-
-      const schema = zod(Schema.Number.check(group).check(under100))
-
-      const bad = schema.safeParse(-3)
-      expect(bad.success).toBe(false)
-      expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
-    })
-  })
-
-  describe("well-known refinement translation", () => {
-    test("Schema.isInt emits type: integer in JSON Schema", () => {
-      const schema = zod(Schema.Number.check(Schema.isInt()))
-      const native = json(z.number().int())
-      expect(json(schema)).toEqual(native)
-      expect(schema.parse(3)).toBe(3)
-      expect(schema.safeParse(1.5).success).toBe(false)
-    })
-
-    test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => {
-      const schema = zod(Schema.Number.check(Schema.isGreaterThan(0)))
-      expect((json(schema) as any).exclusiveMinimum).toBe(0)
-      expect(schema.parse(1)).toBe(1)
-      expect(schema.safeParse(0).success).toBe(false)
-      expect(schema.safeParse(-1).success).toBe(false)
-    })
-
-    test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => {
-      const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)))
-      expect((json(schema) as any).minimum).toBe(0)
-      expect(schema.parse(0)).toBe(0)
-      expect(schema.safeParse(-1).success).toBe(false)
-    })
-
-    test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => {
-      const schema = zod(Schema.Number.check(Schema.isLessThan(10)))
-      expect((json(schema) as any).exclusiveMaximum).toBe(10)
-      expect(schema.parse(9)).toBe(9)
-      expect(schema.safeParse(10).success).toBe(false)
-    })
-
-    test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => {
-      const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10)))
-      expect((json(schema) as any).maximum).toBe(10)
-      expect(schema.parse(10)).toBe(10)
-      expect(schema.safeParse(11).success).toBe(false)
-    })
-
-    test("Schema.isMultipleOf(5) emits multipleOf: 5", () => {
-      const schema = zod(Schema.Number.check(Schema.isMultipleOf(5)))
-      expect((json(schema) as any).multipleOf).toBe(5)
-      expect(schema.parse(10)).toBe(10)
-      expect(schema.safeParse(7).success).toBe(false)
-    })
-
-    test("Schema.isFinite validates at runtime", () => {
-      const schema = zod(Schema.Number.check(Schema.isFinite()))
-      expect(schema.parse(1)).toBe(1)
-      expect(schema.safeParse(Infinity).success).toBe(false)
-      expect(schema.safeParse(NaN).success).toBe(false)
-    })
-
-    test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => {
-      const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
-      const native = json(z.number().int().positive())
-      expect(json(schema)).toEqual(native)
-      expect(schema.parse(3)).toBe(3)
-      expect(schema.safeParse(0).success).toBe(false)
-      expect(schema.safeParse(1.5).success).toBe(false)
-    })
-
-    test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => {
-      const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)))
-      const native = json(z.number().int().min(0))
-      expect(json(schema)).toEqual(native)
-      expect(schema.parse(0)).toBe(0)
-      expect(schema.safeParse(-1).success).toBe(false)
-    })
-
-    test("Schema.isBetween emits both bounds", () => {
-      const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 })))
-      const shape = json(schema) as any
-      expect(shape.minimum).toBe(1)
-      expect(shape.maximum).toBe(10)
-      expect(schema.parse(5)).toBe(5)
-      expect(schema.safeParse(11).success).toBe(false)
-      expect(schema.safeParse(0).success).toBe(false)
-    })
-
-    test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => {
-      const schema = zod(
-        Schema.Number.check(
-          Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }),
-        ),
-      )
-      const shape = json(schema) as any
-      expect(shape.exclusiveMinimum).toBe(1)
-      expect(shape.exclusiveMaximum).toBe(10)
-      expect(schema.parse(5)).toBe(5)
-      expect(schema.safeParse(1).success).toBe(false)
-      expect(schema.safeParse(10).success).toBe(false)
-    })
-
-    test("Schema.isInt32 (FilterGroup) produces integer bounds", () => {
-      const schema = zod(Schema.Number.check(Schema.isInt32()))
-      const shape = json(schema) as any
-      expect(shape.type).toBe("integer")
-      expect(shape.minimum).toBe(-2147483648)
-      expect(shape.maximum).toBe(2147483647)
-      expect(schema.parse(42)).toBe(42)
-      expect(schema.safeParse(1.5).success).toBe(false)
-      expect(schema.safeParse(2147483648).success).toBe(false)
-    })
-
-    test("Schema.isMinLength on string emits minLength", () => {
-      const schema = zod(Schema.String.check(Schema.isMinLength(3)))
-      expect((json(schema) as any).minLength).toBe(3)
-      expect(schema.parse("abc")).toBe("abc")
-      expect(schema.safeParse("ab").success).toBe(false)
-    })
-
-    test("Schema.isMaxLength on string emits maxLength", () => {
-      const schema = zod(Schema.String.check(Schema.isMaxLength(5)))
-      expect((json(schema) as any).maxLength).toBe(5)
-      expect(schema.parse("abcde")).toBe("abcde")
-      expect(schema.safeParse("abcdef").success).toBe(false)
-    })
-
-    test("Schema.isLengthBetween on string emits both bounds", () => {
-      const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4)))
-      const shape = json(schema) as any
-      expect(shape.minLength).toBe(2)
-      expect(shape.maxLength).toBe(4)
-      expect(schema.parse("abc")).toBe("abc")
-      expect(schema.safeParse("a").success).toBe(false)
-      expect(schema.safeParse("abcde").success).toBe(false)
-    })
-
-    test("Schema.isMinLength on array emits minItems", () => {
-      const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1)))
-      expect((json(schema) as any).minItems).toBe(1)
-      expect(schema.parse(["x"])).toEqual(["x"])
-      expect(schema.safeParse([]).success).toBe(false)
-    })
-
-    test("Schema.isPattern emits pattern", () => {
-      const schema = zod(Schema.String.check(Schema.isPattern(/^per/)))
-      expect((json(schema) as any).pattern).toBe("^per")
-      expect(schema.parse("per_abc")).toBe("per_abc")
-      expect(schema.safeParse("abc").success).toBe(false)
-    })
-
-    test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => {
-      const schema = zod(Schema.String.check(Schema.isStartsWith("per")))
-      const native = json(z.string().startsWith("per"))
-      expect(json(schema)).toEqual(native)
-      expect(schema.parse("per_abc")).toBe("per_abc")
-      expect(schema.safeParse("abc").success).toBe(false)
-    })
-
-    test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => {
-      const schema = zod(Schema.String.check(Schema.isEndsWith(".json")))
-      const native = json(z.string().endsWith(".json"))
-      expect(json(schema)).toEqual(native)
-      expect(schema.parse("a.json")).toBe("a.json")
-      expect(schema.safeParse("a.txt").success).toBe(false)
-    })
-
-    test("Schema.isUUID emits format: uuid", () => {
-      const schema = zod(Schema.String.check(Schema.isUUID()))
-      expect((json(schema) as any).format).toBe("uuid")
-    })
-
-    test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => {
-      // isInt is well-known (translates to .int()); the anonymous filter falls
-      // back to superRefine.
-      const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed"))
-      const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven))
-
-      const shape = json(schema) as any
-      // Well-known translation is preserved — type is integer, not plain number
-      expect(shape.type).toBe("integer")
-
-      // Runtime: both constraints fire
-      expect(schema.parse(3)).toBe(3)
-      expect(schema.safeParse(1.5).success).toBe(false)
-      const seven = schema.safeParse(7)
-      expect(seven.success).toBe(false)
-      expect(seven.error!.issues[0].message).toBe("no sevens allowed")
-    })
-
-    test("inside a struct field, well-known refinements propagate through", () => {
-      // Mirrors config.ts port: z.number().int().positive().optional()
-      const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
-      const schema = zod(Schema.Struct({ port: Port }))
-      const shape = json(schema) as any
-      expect(shape.properties.port.type).toBe("integer")
-      expect(shape.properties.port.exclusiveMinimum).toBe(0)
-    })
-  })
-
-  describe("Schema.optionalWith defaults", () => {
-    test("parsing undefined returns the default value", () => {
-      const schema = zod(
-        Schema.Struct({
-          mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
-        }),
-      )
-      expect(schema.parse({})).toEqual({ mode: "ctrl-x" })
-      expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" })
-    })
-
-    test("parsing a real value returns that value (default does not fire)", () => {
-      const schema = zod(
-        Schema.Struct({
-          mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
-        }),
-      )
-      expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" })
-    })
-
-    test("default on a number field", () => {
-      const schema = zod(
-        Schema.Struct({
-          count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))),
-        }),
-      )
-      expect(schema.parse({})).toEqual({ count: 42 })
-      expect(schema.parse({ count: 7 })).toEqual({ count: 7 })
-    })
-
-    test("multiple defaulted fields inside a struct", () => {
-      const schema = zod(
-        Schema.Struct({
-          leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
-          quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))),
-          inner: Schema.String,
-        }),
-      )
-      expect(schema.parse({ inner: "hi" })).toEqual({
-        leader: "ctrl-x",
-        quit: "ctrl-c",
-        inner: "hi",
-      })
-      expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({
-        leader: "a",
-        quit: "b",
-        inner: "c",
-      })
-    })
-
-    test("JSON Schema output includes the default key", () => {
-      const schema = zod(
-        Schema.Struct({
-          mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
-        }),
-      )
-      const shape = json(schema) as any
-      expect(shape.properties.mode.default).toBe("ctrl-x")
-    })
-
-    test("default referencing a computed value resolves when evaluated", () => {
-      // Simulates `keybinds.ts` style of per-platform defaults: the default is
-      // produced by an Effect that computes a value at decode time.
-      const platform = "darwin"
-      const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k"
-      const schema = zod(
-        Schema.Struct({
-          command_palette: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.sync(() => fallback))),
-        }),
-      )
-      expect(schema.parse({})).toEqual({ command_palette: "cmd-k" })
-      const shape = json(schema) as any
-      expect(shape.properties.command_palette.default).toBe("cmd-k")
-    })
-
-    test("plain Schema.optional (no default) still emits .optional() (regression)", () => {
-      const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) }))
-      expect(schema.parse({})).toEqual({})
-      expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
-    })
-  })
-})