Kit Langton пре 3 месеци
родитељ
комит
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 { Option, Schema, SchemaGetter } from "effect"
-import { zod, ZodOverride } from "./effect-zod"
 
 
 /**
 /**
  * Integer greater than zero.
  * Integer greater than zero.
@@ -21,7 +20,6 @@ export const optionalOmitUndefined = <S extends Schema.Top>(schema: S) =>
       decode: SchemaGetter.passthrough({ strict: false }),
       decode: SchemaGetter.passthrough({ strict: false }),
       encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
       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
 - 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`
 - 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
 ## InstanceState init patterns
 
 

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

@@ -1,19 +1,16 @@
 # Schema migration
 # Schema migration
 
 
 Practical reference for migrating data types in `packages/opencode` from
 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
 ## Goal
 
 
 Use Effect Schema as the source of truth for domain models, IDs, inputs,
 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
 ## Preferred shapes
 
 
@@ -26,19 +23,16 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
   id: FooID,
   id: FooID,
   name: Schema.String,
   name: Schema.String,
   enabled: Schema.Boolean,
   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
 ```ts
 export const Info = Schema.Struct({
 export const Info = Schema.Struct({
   id: FooID,
   id: FooID,
   name: Schema.String,
   name: Schema.String,
-}).pipe(withStatics((s) => ({ zod: zod(s) })))
+}).pipe(withStatics((s) => ({ decode: Schema.decodeUnknownOption(s) })))
 ```
 ```
 
 
 ### Errors
 ### Errors
@@ -53,15 +47,13 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
 
 
 ### IDs and branded leaf types
 ### 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
 ### 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
 ```ts
 const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
 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}$/))
 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
 ## 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.
 maintaining a second hand-written Zod schema.
 
 
 The default should be:
 The default should be:
 
 
 - Effect Schema owns the type
 - 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
 - new domain models should not start Zod-first unless there is a concrete
   boundary-specific need
   boundary-specific need
 
 
@@ -89,27 +78,22 @@ The default should be:
 It is fine to keep a Zod-native schema temporarily when:
 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 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
 - the migration would force unrelated churn across a large call graph
 
 
 When this happens, prefer leaving a short note or TODO rather than silently
 When this happens, prefer leaving a short note or TODO rather than silently
 creating a parallel schema source of truth.
 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`
 ### Local `DeepMutable<T>` in `config/config.ts`
 
 
@@ -133,7 +117,7 @@ Migrate in this order:
 2. Exported `Info`, `Input`, `Output`, and DTO types
 2. Exported `Info`, `Input`, `Output`, and DTO types
 3. Tagged domain errors
 3. Tagged domain errors
 4. Service-local internal models
 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
 This keeps shared types canonical first and makes boundary updates mostly
 mechanical.
 mechanical.
@@ -142,21 +126,18 @@ mechanical.
 
 
 ### `src/config/` ✅ complete
 ### `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:
 A file is considered "done" when:
 
 
 - its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.)
 - its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.)
   are authored as Effect Schema
   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] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
 - [x] server, layout
 - [x] server, layout
@@ -361,15 +342,8 @@ piecewise.
 - [ ] `src/util/update-schema.ts`
 - [ ] `src/util/update-schema.ts`
 - [ ] `src/worktree/index.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
 ## Notes
 
 
-- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion.
 - Prefer one canonical schema definition. Avoid maintaining parallel Zod and
 - Prefer one canonical schema definition. Avoid maintaining parallel Zod and
   Effect definitions for the same domain type.
   Effect definitions for the same domain type.
 - Keep the migration incremental. Converting the domain model first is more
 - 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`.
 - 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.
 - 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.
 - Delete one path override only after generated OpenAPI is unchanged for that param.
 
 
 Concrete first targets:
 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 type { InstanceContext } from "@/project/instance"
 import { SessionID, MessageID } from "@/session/schema"
 import { SessionID, MessageID } from "@/session/schema"
 import { Effect, Layer, Context, Schema } from "effect"
 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 { Config } from "@/config/config"
 import { MCP } from "../mcp"
 import { MCP } from "../mcp"
 import { Skill } from "../skill"
 import { Skill } from "../skill"
@@ -35,12 +33,11 @@ export const Info = Schema.Struct({
   model: Schema.optional(Schema.String),
   model: Schema.optional(Schema.String),
   source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
   source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
   // Some command templates are lazy promises from MCP prompt resolution.
   // 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),
   subtask: Schema.optional(Schema.Boolean),
   hints: Schema.Array(Schema.String),
   hints: Schema.Array(Schema.String),
 }).annotate({ identifier: "Command" })
 }).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 type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
 
 
 export function hints(template: string) {
 export function hints(template: string) {

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

@@ -1,13 +1,5 @@
 import { Schema } from "effect"
 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>
 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 path from "path"
 import { pathToFileURL, fileURLToPath } from "url"
 import { pathToFileURL, fileURLToPath } from "url"
 import * as LSPServer from "./server"
 import * as LSPServer from "./server"
-import z from "zod"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Process } from "@/util/process"
 import { Process } from "@/util/process"
@@ -14,7 +13,6 @@ import { Effect, Layer, Context, Schema } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 import { containsPath } from "@/project/instance-context"
 import { containsPath } from "@/project/instance-context"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
-import { ZodOverride } from "@opencode-ai/core/effect-zod"
 
 
 const log = Log.create({ service: "lsp" })
 const log = Log.create({ service: "lsp" })
 
 
@@ -56,9 +54,7 @@ export const Status = Schema.Struct({
   id: Schema.String,
   id: Schema.String,
   name: Schema.String,
   name: Schema.String,
   root: 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" })
 }).annotate({ identifier: "LSPStatus" })
 export type Status = typeof Status.Type
 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 { MCP } from "@/mcp"
 import { Project } from "@/project/project"
 import { Project } from "@/project/project"
 import { Session } from "@/session/session"
 import { Session } from "@/session/session"
+import { ToolJsonSchema } from "@/tool/json-schema"
 import { ToolRegistry } from "@/tool/registry"
 import { ToolRegistry } from "@/tool/registry"
-import * as EffectZod from "@opencode-ai/core/effect-zod"
 import { Worktree } from "@/worktree"
 import { Worktree } from "@/worktree"
 import { Effect, Option } from "effect"
 import { Effect, Option } from "effect"
 import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
 import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
@@ -84,7 +84,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
       return list.map((item) => ({
       return list.map((item) => ({
         id: item.id,
         id: item.id,
         description: item.description,
         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 { BusEvent } from "@/bus/bus-event"
 import { SessionID, MessageID, PartID } from "./schema"
 import { SessionID, MessageID, PartID } from "./schema"
-import z from "zod"
 import { NamedError } from "@opencode-ai/core/util/error"
 import { NamedError } from "@opencode-ai/core/util/error"
 import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
 import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
 import { LSP } from "@/lsp/lsp"
 import { LSP } from "@/lsp/lsp"
@@ -55,7 +54,7 @@ export const APIError = namedSchemaError("APIError", {
   responseBody: Schema.optional(Schema.String),
   responseBody: Schema.optional(Schema.String),
   metadata: Schema.optional(Schema.Record(Schema.String, 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", {
 export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
   message: Schema.String,
   message: Schema.String,
   responseBody: Schema.optional(Schema.String),
   responseBody: Schema.optional(Schema.String),

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

@@ -1,6 +1,5 @@
 import path from "path"
 import path from "path"
 import os from "os"
 import os from "os"
-import * as EffectZod from "@opencode-ai/core/effect-zod"
 import { SessionID, MessageID, PartID } from "./schema"
 import { SessionID, MessageID, PartID } from "./schema"
 import { MessageV2 } from "./message-v2"
 import { MessageV2 } from "./message-v2"
 import * as Log from "@opencode-ai/core/util/log"
 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 BUILD_SWITCH from "../session/prompt/build-switch.txt"
 import MAX_STEPS from "../session/prompt/max-steps.txt"
 import MAX_STEPS from "../session/prompt/max-steps.txt"
 import { ToolRegistry } from "@/tool/registry"
 import { ToolRegistry } from "@/tool/registry"
+import { ToolJsonSchema } from "@/tool/json-schema"
 import { MCP } from "../mcp"
 import { MCP } from "../mcp"
 import { LSP } from "@/lsp/lsp"
 import { LSP } from "@/lsp/lsp"
 import { Flag } from "@opencode-ai/core/flag/flag"
 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,
         providerID: input.model.providerID,
         agent: input.agent,
         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({
         tools[item.id] = tool({
           description: item.description,
           description: item.description,
           inputSchema: jsonSchema(schema),
           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 * as Tool from "./tool"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
 import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
 import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
+import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider"
 import { Schema } from "effect"
 import { Schema } from "effect"
 import z from "zod"
 import z from "zod"
-import { ZodOverride } from "@opencode-ai/core/effect-zod"
 import { Plugin } from "../plugin"
 import { Plugin } from "../plugin"
 import { Provider } from "@/provider/provider"
 import { Provider } from "@/provider/provider"
 import { ProviderID, type ModelID } from "../provider/schema"
 import { ProviderID, type ModelID } from "../provider/schema"
@@ -137,17 +137,19 @@ export const layer: Layer.Layer<
         const custom: Tool.Def[] = []
         const custom: Tool.Def[] = []
 
 
         function fromPlugin(id: string, def: ToolDefinition): 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 {
           return {
             id,
             id,
             parameters,
             parameters,
+            jsonSchema,
             description: def.description,
             description: def.description,
             execute: (args, toolCtx) =>
             execute: (args, toolCtx) =>
               Effect.gen(function* () {
               Effect.gen(function* () {
@@ -323,8 +325,13 @@ export const layer: Layer.Layer<
           const output = {
           const output = {
             description: tool.description,
             description: tool.description,
             parameters: tool.parameters,
             parameters: tool.parameters,
+            jsonSchema: tool.jsonSchema,
           }
           }
           yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
           yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
+          const jsonSchema =
+            output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema
+              ? output.jsonSchema
+              : undefined
           return {
           return {
             id: tool.id,
             id: tool.id,
             description: [
             description: [
@@ -335,6 +342,7 @@ export const layer: Layer.Layer<
               .filter(Boolean)
               .filter(Boolean)
               .join("\n"),
               .join("\n"),
             parameters: output.parameters,
             parameters: output.parameters,
+            jsonSchema,
             execute: tool.execute,
             execute: tool.execute,
             formatValidationError: tool.formatValidationError,
             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"
 export * as ToolRegistry from "./registry"

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

@@ -1,4 +1,5 @@
 import { Effect, Schema } from "effect"
 import { Effect, Schema } from "effect"
+import type { JSONSchema7 } from "@ai-sdk/provider"
 import type { MessageV2 } from "../session/message-v2"
 import type { MessageV2 } from "../session/message-v2"
 import type { Permission } from "../permission"
 import type { Permission } from "../permission"
 import type { SessionID, MessageID } from "../session/schema"
 import type { SessionID, MessageID } from "../session/schema"
@@ -38,6 +39,7 @@ export interface Def<
   id: string
   id: string
   description: string
   description: string
   parameters: Parameters
   parameters: Parameters
+  jsonSchema?: JSONSchema7
   execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
   execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
   formatValidationError?(error: unknown): string
   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({
 export const Parameters = Schema.Struct({
   url: Schema.String.annotate({ description: "The URL to fetch content from" }),
   url: Schema.String.annotate({ description: "The URL to fetch content from" }),
   format: Schema.Literals(["text", "markdown", "html"])
   format: Schema.Literals(["text", "markdown", "html"])
-    .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const)))
     .annotate({
     .annotate({
       description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
       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)" }),
   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 { Schema } from "effect"
-import z from "zod"
-import { zod } from "@opencode-ai/core/effect-zod"
 
 
 /**
 /**
  * Create a Schema-backed NamedError-shaped class.
  * 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.
  * OpenAPI/SDK output is byte-identical to the original NamedError schema.
  *
  *
  * Preserves the existing surface:
  * Preserves the existing surface:
- *   - static `Schema` (Zod schema of the wire shape)
+ *   - static `Schema` (Effect schema of the wire shape)
  *   - static `isInstance(x)`
  *   - static `isInstance(x)`
  *   - instance `toObject()` returning `{ name, data }`
  *   - instance `toObject()` returning `{ name, data }`
  *   - `new X({ ...data }, { cause })`
  *   - `new X({ ...data }, { cause })`
  */
  */
 export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
 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 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({
   const effectSchema = Schema.Struct({
     name: Schema.Literal(tag),
     name: Schema.Literal(tag),
     data: dataSchema,
     data: dataSchema,
@@ -35,7 +25,7 @@ export function namedSchemaError<Tag extends string, Fields extends Schema.Struc
   type Data = Schema.Schema.Type<typeof dataSchema>
   type Data = Schema.Schema.Type<typeof dataSchema>
 
 
   class NamedSchemaError extends Error {
   class NamedSchemaError extends Error {
-    static readonly Schema = wire
+    static readonly Schema = effectSchema
     static readonly EffectSchema = effectSchema
     static readonly EffectSchema = effectSchema
     static readonly tag = tag
     static readonly tag = tag
     public static isInstance(input: unknown): input is NamedSchemaError {
     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 type { NamedError } from "@opencode-ai/core/util/error"
 import { APICallError } from "ai"
 import { APICallError } from "ai"
 import { setTimeout as sleep } from "node:timers/promises"
 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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { SessionRetry } from "../../src/session/retry"
 import { SessionRetry } from "../../src/session/retry"
 import { MessageV2 } from "../../src/session/message-v2"
 import { MessageV2 } from "../../src/session/message-v2"
@@ -17,7 +17,7 @@ const retryProvider = "test"
 const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
 const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
 
 
 function apiError(headers?: Record<string, string>): MessageV2.APIError {
 function apiError(headers?: Record<string, string>): MessageV2.APIError {
-  return MessageV2.APIError.Schema.parse(
+  return Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
     new MessageV2.APIError({
     new MessageV2.APIError({
       message: "boom",
       message: "boom",
       isRetryable: true,
       isRetryable: true,
@@ -94,7 +94,7 @@ describe("session.retry.delay", () => {
         const step = yield* Schedule.toStepWithMetadata(
         const step = yield* Schedule.toStepWithMetadata(
           SessionRetry.policy({
           SessionRetry.policy({
             provider: "test",
             provider: "test",
-            parse: (err) => MessageV2.APIError.Schema.parse(err),
+            parse: Schema.decodeUnknownSync(MessageV2.APIError.Schema),
             set: (info) =>
             set: (info) =>
               status.set(sessionID, {
               status.set(sessionID, {
                 type: "retry",
                 type: "retry",
@@ -173,7 +173,7 @@ describe("session.retry.retryable", () => {
   })
   })
 
 
   test("retries 500 errors even when isRetryable is false", () => {
   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({
       new MessageV2.APIError({
         message: "Internal server error",
         message: "Internal server error",
         isRetryable: false,
         isRetryable: false,
@@ -186,7 +186,7 @@ describe("session.retry.retryable", () => {
   })
   })
 
 
   test("retries 502 bad gateway errors", () => {
   test("retries 502 bad gateway errors", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
       new MessageV2.APIError({
         message: "Bad gateway",
         message: "Bad gateway",
         isRetryable: false,
         isRetryable: false,
@@ -198,7 +198,7 @@ describe("session.retry.retryable", () => {
   })
   })
 
 
   test("retries 503 service unavailable errors", () => {
   test("retries 503 service unavailable errors", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
       new MessageV2.APIError({
         message: "Service unavailable",
         message: "Service unavailable",
         isRetryable: false,
         isRetryable: false,
@@ -210,7 +210,7 @@ describe("session.retry.retryable", () => {
   })
   })
 
 
   test("does not retry 4xx errors when isRetryable is false", () => {
   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({
       new MessageV2.APIError({
         message: "Bad request",
         message: "Bad request",
         isRetryable: false,
         isRetryable: false,
@@ -222,7 +222,7 @@ describe("session.retry.retryable", () => {
   })
   })
 
 
   test("retries ZlibError decompression failures", () => {
   test("retries ZlibError decompression failures", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
       new MessageV2.APIError({
         message: "Response decompression failed",
         message: "Response decompression failed",
         isRetryable: true,
         isRetryable: true,
@@ -236,7 +236,7 @@ describe("session.retry.retryable", () => {
   })
   })
 
 
   test("maps free limits to Go upsell action", () => {
   test("maps free limits to Go upsell action", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
       new MessageV2.APIError({
         message: "Free usage exceeded",
         message: "Free usage exceeded",
         isRetryable: true,
         isRetryable: true,
@@ -262,7 +262,7 @@ describe("session.retry.retryable", () => {
   })
   })
 
 
   test("maps Go subscription limits to workspace PAYG upsell", () => {
   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({
       new MessageV2.APIError({
         message: "Subscription quota exceeded. You can continue using free models.",
         message: "Subscription quota exceeded. You can continue using free models.",
         isRetryable: true,
         isRetryable: true,
@@ -300,7 +300,7 @@ describe("session.retry.retryable", () => {
   })
   })
 
 
   test("maps Go subscription limits without limit metadata", () => {
   test("maps Go subscription limits without limit metadata", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
       new MessageV2.APIError({
         message: "Subscription quota exceeded. You can continue using free models.",
         message: "Subscription quota exceeded. You can continue using free models.",
         isRetryable: true,
         isRetryable: true,
@@ -366,7 +366,7 @@ describe("session.message-v2.fromError", () => {
   )
   )
 
 
   test("ECONNRESET socket error is retryable", () => {
   test("ECONNRESET socket error is retryable", () => {
-    const error = MessageV2.APIError.Schema.parse(
+    const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
       new MessageV2.APIError({
       new MessageV2.APIError({
         message: "Connection reset by server",
         message: "Connection reset by server",
         isRetryable: true,
         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",
       "description": "Optional timeout in milliseconds",
       "exclusiveMinimum": 0,
       "exclusiveMinimum": 0,
       "maximum": 9007199254740991,
       "maximum": 9007199254740991,
+      "minimum": -9007199254740991,
       "type": "integer",
       "type": "integer",
     },
     },
     "workdir": {
     "workdir": {
@@ -240,7 +241,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
                   "type": "string",
                   "type": "string",
                 },
                 },
               },
               },
-              "ref": "QuestionOption",
               "required": [
               "required": [
                 "label",
                 "label",
                 "description",
                 "description",
@@ -254,7 +254,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
             "type": "string",
             "type": "string",
           },
           },
         },
         },
-        "ref": "QuestionPrompt",
         "required": [
         "required": [
           "question",
           "question",
           "header",
           "header",
@@ -393,14 +392,21 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
   "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$schema": "https://json-schema.org/draft/2020-12/schema",
   "properties": {
   "properties": {
     "format": {
     "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": {
     "timeout": {
       "description": "Optional timeout in seconds (max 120)",
       "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 { describe, expect, test } from "bun:test"
 import { Result, Schema } from "effect"
 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
 // 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
 // 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
 // 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
 // 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 ApplyPatch } from "../../src/tool/apply_patch"
 import { Parameters as Edit } from "../../src/tool/edit"
 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 =>
 const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
   Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
   Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
 
 
+const toJsonSchema = ToolJsonSchema.fromSchema
+
 describe("tool parameters", () => {
 describe("tool parameters", () => {
   describe("JSON Schema (wire shape)", () => {
   describe("JSON Schema (wire shape)", () => {
     test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
     test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
@@ -50,6 +52,36 @@ describe("tool parameters", () => {
     test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
     test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
     test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
     test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
     test("write", () => expect(toJsonSchema(Write)).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", () => {
   describe("apply_patch", () => {

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

@@ -1,7 +1,8 @@
 import { afterEach, describe, expect } from "bun:test"
 import { afterEach, describe, expect } from "bun:test"
 import path from "path"
 import path from "path"
 import fs from "fs/promises"
 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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { ToolRegistry } from "@/tool/registry"
 import { ToolRegistry } from "@/tool/registry"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
@@ -26,6 +27,8 @@ import { Ripgrep } from "@/file/ripgrep"
 import * as Truncate from "@/tool/truncate"
 import * as Truncate from "@/tool/truncate"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 import { Reference } from "@/reference/reference"
 import { Reference } from "@/reference/reference"
+import { ProviderID, ModelID } from "@/provider/schema"
+import { ToolJsonSchema } from "@/tool/json-schema"
 
 
 const node = CrossSpawnSpawner.defaultLayer
 const node = CrossSpawnSpawner.defaultLayer
 const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
 const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
@@ -55,7 +58,7 @@ const registryLayer = ToolRegistry.layer.pipe(
   Layer.provide(Truncate.defaultLayer),
   Layer.provide(Truncate.defaultLayer),
 )
 )
 
 
-const it = testEffect(Layer.mergeAll(registryLayer, node))
+const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer))
 
 
 afterEach(async () => {
 afterEach(async () => {
   Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout
   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", () =>
   it.instance("loads tools with external dependencies without crashing", () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       const test = yield* TestInstance
       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" })
-    })
-  })
-})