Remove effect-zod bridge (#26956)

This commit is contained in:
Kit Langton
2026-05-11 21:14:55 -04:00
committed by GitHub
parent abb1ee6278
commit e5aa5161f2
21 changed files with 425 additions and 1266 deletions
-370
View File
@@ -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}`)
}
-2
View File
@@ -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
View File
@@ -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`: 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.
```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.
## InstanceState init patterns ## InstanceState init patterns
+29 -55
View File
@@ -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 outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and
compatibility boundaries by exposing a `.zod` static derived from the Effect native JSON Schema generation at HTTP, tool, and AI SDK boundaries.
schema via `@opencode-ai/core/effect-zod`.
The long-term driver is `specs/effect/http-api.md` — once the HTTP server The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should
moves to `@effect/platform`, every Schema-first DTO can flow through flow through `HttpApi` / `HttpRouter` without a Zod translation layer.
`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
`effect-zod` walker plus every `.zod` static can be deleted.
## 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 If a schema needs local static helpers, use the two-step `withStatics` pattern:
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 Keep branded/schema-backed IDs as Effect schemas.
`static readonly zod` for compatibility when callers still expect Zod.
### Refinements ### Refinements
Reuse named refinements instead of re-spelling `z.number().int().positive()` Reuse named refinements instead of re-spelling numeric or string constraints in
in every schema. The `effect-zod` walker translates the Effect versions into every schema. Boundary JSON Schema helpers should normalize native Effect JSON
the corresponding zod methods, so JSON Schema output (`type: integer`, Schema output only where a provider requires it.
`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
```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 During migration, route validators, tool parameters, and AI SDK schemas should
Zod-based boundary should consume the derived `.zod` schema instead of 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 ## Boundary helpers
The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for Use narrow helpers at concrete boundaries instead of a generic Schema → Zod bridge.
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.
### `ZodOverride` annotation - 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(...)`
Replaces the entire derivation with a hand-crafted zod schema. Used when: Plugin tools are the main remaining intentional Zod boundary because the public
plugin API exposes `tool.schema = z` and `args: z.ZodRawShape`.
- 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`)
### 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 All of `packages/opencode/src/config/` has been migrated. The `export const
import `z` do so only for local `ZodOverride` bridges or for `z.ZodType` <Info|Spec>` values are all Effect Schema at source.
type annotations — 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()` / - any remaining Zod is an explicit boundary compatibility choice, not a
`zodObject()`), a `z.ZodType` type annotation, or a documented hand-written parallel source of truth
`ZodOverride` escape hatch — never a hand-written parallel source of truth
Files that meet this bar but still carry a compat bridge are checked off Files that meet this bar but still carry a compatibility boundary are checked
with an inline note describing the bridge and what unblocks its removal. 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
@@ -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
View File
@@ -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
View File
@@ -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 export const ConfigModelID = Schema.String
// 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 type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID> export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
+1 -5
View File
@@ -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({ status: Schema.Literals(["connected", "error"]),
[ZodOverride]: z.union([z.literal("connected"), z.literal("error")]),
}),
}).annotate({ identifier: "LSPStatus" }) }).annotate({ identifier: "LSPStatus" })
export type Status = typeof Status.Type export type Status = typeof Status.Type
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 // Plugin tools still expose Zod args publicly; keep that compatibility
// derived Zod object in a `Schema.declare` so it slots into the // boxed at the registry boundary and give the LLM the original JSON Schema.
// Schema-typed framework, and annotate with `ZodOverride` so the const entries = Object.entries(def.args)
// walker emits the original Zod object for LLM JSON Schema. const allZod = entries.every((entry) => isZodType(entry[1]))
const zodParams = z.object(def.args) const zodParams = allZod ? z.object(def.args) : undefined
const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({ const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
[ZodOverride]: zodParams, 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
View File
@@ -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
View File
@@ -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)" }),
}) })
@@ -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 // Wire shape matches the original NamedError output so the SDK stays stable.
.object({
name: z.literal(tag),
data: zod(dataSchema),
})
.meta({ ref: tag })
// Effect Schema for the wire shape — used by HttpApi OpenAPI generation.
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
View File
@@ -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,
@@ -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", "anyOf": [
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.", {
"enum": [ "default": "markdown",
"text", "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
"markdown", "enum": [
"html", "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
View File
@@ -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
View File
@@ -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
@@ -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" })
})
})
})