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
+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
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
## Schema → Zod interop
## Schema boundaries
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`:
```ts
import { zod } from "@opencode-ai/core/effect-zod"
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
```
See `Auth.ZodInfo` for the canonical example.
Use Effect Schema directly at HTTP, tool, and AI SDK boundaries. For provider-facing JSON Schema, use a boundary-specific helper such as `ToolJsonSchema.fromSchema(...)`; do not reintroduce generic Effect Schema → Zod conversion.
## InstanceState init patterns
+29 -55
View File
@@ -1,19 +1,16 @@
# Schema migration
Practical reference for migrating data types in `packages/opencode` from
Zod-first definitions to Effect Schema with Zod compatibility shims.
Zod-first definitions to Effect Schema.
## Goal
Use Effect Schema as the source of truth for domain models, IDs, inputs,
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
compatibility boundaries by exposing a `.zod` static derived from the Effect
schema via `@opencode-ai/core/effect-zod`.
outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and
native JSON Schema generation at HTTP, tool, and AI SDK boundaries.
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
moves to `@effect/platform`, every Schema-first DTO can flow through
`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
`effect-zod` walker plus every `.zod` static can be deleted.
The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should
flow through `HttpApi` / `HttpRouter` without a Zod translation layer.
## Preferred shapes
@@ -26,19 +23,16 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
id: FooID,
name: Schema.String,
enabled: Schema.Boolean,
}) {
static readonly zod = zod(Info)
}
}) {}
```
If the class cannot reference itself cleanly during initialization, use the
two-step `withStatics` pattern:
If a schema needs local static helpers, use the two-step `withStatics` pattern:
```ts
export const Info = Schema.Struct({
id: FooID,
name: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
}).pipe(withStatics((s) => ({ decode: Schema.decodeUnknownOption(s) })))
```
### Errors
@@ -53,15 +47,13 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
### IDs and branded leaf types
Keep branded/schema-backed IDs as Effect schemas and expose
`static readonly zod` for compatibility when callers still expect Zod.
Keep branded/schema-backed IDs as Effect schemas.
### Refinements
Reuse named refinements instead of re-spelling `z.number().int().positive()`
in every schema. The `effect-zod` walker translates the Effect versions into
the corresponding zod methods, so JSON Schema output (`type: integer`,
`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
Reuse named refinements instead of re-spelling numeric or string constraints in
every schema. Boundary JSON Schema helpers should normalize native Effect JSON
Schema output only where a provider requires it.
```ts
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
@@ -69,18 +61,15 @@ const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreate
const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
```
See `test/util/effect-zod.test.ts` for the full set of translated checks.
## Compatibility rule
During migration, route validators, tool parameters, and any existing
Zod-based boundary should consume the derived `.zod` schema instead of
During migration, route validators, tool parameters, and AI SDK schemas should
consume Effect schemas directly or use a narrow boundary helper. Avoid
maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- new domain models should not start Zod-first unless there is a concrete
boundary-specific need
@@ -89,27 +78,22 @@ The default should be:
It is fine to keep a Zod-native schema temporarily when:
- the type is only used at an HTTP or tool boundary and is not reused elsewhere
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
- the validator is part of an existing public API that explicitly accepts Zod
- the migration would force unrelated churn across a large call graph
When this happens, prefer leaving a short note or TODO rather than silently
creating a parallel schema source of truth.
## Escape hatches
## Boundary helpers
The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for
cases the pure-Schema path cannot express. Each one stays in the codebase
only as long as its upstream or local dependency requires it — inline
comments document when each can be deleted.
Use narrow helpers at concrete boundaries instead of a generic Schema → Zod bridge.
### `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:
- the target carries external `$ref` metadata (e.g.
`config/model-id.ts` points at `https://models.dev/...`)
- the target is a zod-only schema that cannot yet be expressed as Schema
(e.g. `ConfigAgent.Info`, `Log.Level`)
Plugin tools are the main remaining intentional Zod boundary because the public
plugin API exposes `tool.schema = z` and `args: z.ZodRawShape`.
### Local `DeepMutable<T>` in `config/config.ts`
@@ -133,7 +117,7 @@ Migrate in this order:
2. Exported `Info`, `Input`, `Output`, and DTO types
3. Tagged domain errors
4. Service-local internal models
5. Route and tool boundary validators that can switch to `.zod`
5. Route and tool boundary validators that can switch to native Effect Schema helpers
This keeps shared types canonical first and makes boundary updates mostly
mechanical.
@@ -142,21 +126,18 @@ mechanical.
### `src/config/` ✅ complete
All of `packages/opencode/src/config/` has been migrated. Files that still
import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
type annotations — the `export const <Info|Spec>` values are all Effect
Schema at source.
All of `packages/opencode/src/config/` has been migrated. The `export const
<Info|Spec>` values are all Effect Schema at source.
A file is considered "done" when:
- its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.)
are authored as Effect Schema
- any remaining zod is either a derived compat bridge (via `zod()` /
`zodObject()`), a `z.ZodType` type annotation, or a documented
`ZodOverride` escape hatch — never a hand-written parallel source of truth
- any remaining Zod is an explicit boundary compatibility choice, not a
hand-written parallel source of truth
Files that meet this bar but still carry a compat bridge are checked off
with an inline note describing the bridge and what unblocks its removal.
Files that meet this bar but still carry a compatibility boundary are checked
off with an inline note describing the boundary and what unblocks its removal.
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
- [x] server, layout
@@ -361,15 +342,8 @@ piecewise.
- [ ] `src/util/update-schema.ts`
- [ ] `src/worktree/index.ts`
### Do-not-migrate
- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
(it's what emits zod from Schema). Goes away only when the `.zod`
compatibility layer is no longer needed anywhere.
## Notes
- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more
@@ -100,7 +100,7 @@ Verification:
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
- Add or fix OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
- Delete one path override only after generated OpenAPI is unchanged for that param.
Concrete first targets:
+1 -4
View File
@@ -4,8 +4,6 @@ import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context, Schema } from "effect"
import z from "zod"
import { ZodOverride } from "@opencode-ai/core/effect-zod"
import { Config } from "@/config/config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
@@ -35,12 +33,11 @@ export const Info = Schema.Struct({
model: Schema.optional(Schema.String),
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
// Some command templates are lazy promises from MCP prompt resolution.
template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
template: Schema.Unknown,
subtask: Schema.optional(Schema.Boolean),
hints: Schema.Array(Schema.String),
}).annotate({ identifier: "Command" })
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
export function hints(template: string) {
+1 -9
View File
@@ -1,13 +1,5 @@
import { Schema } from "effect"
import z from "zod"
import { ZodOverride } from "@opencode-ai/core/effect-zod"
// The original Zod schema carried an external $ref pointing at the models.dev
// JSON schema. That external reference is not a named SDK component — it is a
// literal pointer to an outside schema — so the walker cannot re-derive it
// from AST metadata. Preserve the exact original Zod via ZodOverride.
export const ConfigModelID = Schema.String.annotate({
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
})
export const ConfigModelID = Schema.String
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
+1 -5
View File
@@ -5,7 +5,6 @@ import * as LSPClient from "./client"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import * as LSPServer from "./server"
import z from "zod"
import { Config } from "@/config/config"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Process } from "@/util/process"
@@ -14,7 +13,6 @@ import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { containsPath } from "@/project/instance-context"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { ZodOverride } from "@opencode-ai/core/effect-zod"
const log = Log.create({ service: "lsp" })
@@ -56,9 +54,7 @@ export const Status = Schema.Struct({
id: Schema.String,
name: Schema.String,
root: Schema.String,
status: Schema.Literals(["connected", "error"]).annotate({
[ZodOverride]: z.union([z.literal("connected"), z.literal("error")]),
}),
status: Schema.Literals(["connected", "error"]),
}).annotate({ identifier: "LSPStatus" })
export type Status = typeof Status.Type
@@ -5,8 +5,8 @@ import { InstanceState } from "@/effect/instance-state"
import { MCP } from "@/mcp"
import { Project } from "@/project/project"
import { Session } from "@/session/session"
import { ToolJsonSchema } from "@/tool/json-schema"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@opencode-ai/core/effect-zod"
import { Worktree } from "@/worktree"
import { Effect, Option } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
@@ -84,7 +84,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
return list.map((item) => ({
id: item.id,
description: item.description,
parameters: EffectZod.toJsonSchema(item.parameters),
parameters: ToolJsonSchema.fromTool(item),
}))
})
+1 -2
View File
@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID, PartID } from "./schema"
import z from "zod"
import { NamedError } from "@opencode-ai/core/util/error"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { LSP } from "@/lsp/lsp"
@@ -55,7 +54,7 @@ export const APIError = namedSchemaError("APIError", {
responseBody: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type APIError = z.infer<typeof APIError.Schema>
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
message: Schema.String,
responseBody: Schema.optional(Schema.String),
+2 -2
View File
@@ -1,6 +1,5 @@
import path from "path"
import os from "os"
import * as EffectZod from "@opencode-ai/core/effect-zod"
import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2"
import * as Log from "@opencode-ai/core/util/log"
@@ -21,6 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { ToolRegistry } from "@/tool/registry"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MCP } from "../mcp"
import { LSP } from "@/lsp/lsp"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -565,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
providerID: input.model.providerID,
agent: input.agent,
})) {
const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({
description: item.description,
inputSchema: jsonSchema(schema),
+164
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 { Config } from "@/config/config"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider"
import { Schema } from "effect"
import z from "zod"
import { ZodOverride } from "@opencode-ai/core/effect-zod"
import { Plugin } from "../plugin"
import { Provider } from "@/provider/provider"
import { ProviderID, type ModelID } from "../provider/schema"
@@ -137,17 +137,19 @@ export const layer: Layer.Layer<
const custom: Tool.Def[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
// Plugin tools define their args as a raw Zod shape. Wrap the
// derived Zod object in a `Schema.declare` so it slots into the
// Schema-typed framework, and annotate with `ZodOverride` so the
// walker emits the original Zod object for LLM JSON Schema.
const zodParams = z.object(def.args)
const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({
[ZodOverride]: zodParams,
})
// Plugin tools still expose Zod args publicly; keep that compatibility
// boxed at the registry boundary and give the LLM the original JSON Schema.
const entries = Object.entries(def.args)
const allZod = entries.every((entry) => isZodType(entry[1]))
const zodParams = allZod ? z.object(def.args) : undefined
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
const parameters = zodParams
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
: Schema.Unknown
return {
id,
parameters,
jsonSchema,
description: def.description,
execute: (args, toolCtx) =>
Effect.gen(function* () {
@@ -323,8 +325,13 @@ export const layer: Layer.Layer<
const output = {
description: tool.description,
parameters: tool.parameters,
jsonSchema: tool.jsonSchema,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
const jsonSchema =
output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema
? output.jsonSchema
: undefined
return {
id: tool.id,
description: [
@@ -335,6 +342,7 @@ export const layer: Layer.Layer<
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
jsonSchema,
execute: tool.execute,
formatValidationError: tool.formatValidationError,
}
@@ -376,4 +384,50 @@ export const defaultLayer = Layer.suspend(() =>
),
)
function isZodType(value: unknown): value is z.ZodType {
return typeof value === "object" && value !== null && "_zod" in value
}
function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition {
return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value))
}
function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 {
const properties = Object.fromEntries(
entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])),
)
return {
type: "object",
properties,
required: Object.keys(properties),
}
}
function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" }))
if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema")
const { $defs, ...rest } = result
return (
$defs && isJsonSchemaObject($defs) ? { ...rest, definitions: $defs as JSONSchema7["definitions"] } : rest
) as JSONSchema7
}
function normalizeZodJsonSchema(value: unknown): unknown {
if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item))
if (typeof value !== "object" || value === null) return value
return Object.fromEntries(
Object.entries(value)
.filter((entry) =>
(entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean"
? false
: true,
)
.map(([key, item]) => [key, normalizeZodJsonSchema(item)]),
)
}
function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export * as ToolRegistry from "./registry"
+2
View File
@@ -1,4 +1,5 @@
import { Effect, Schema } from "effect"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type { MessageV2 } from "../session/message-v2"
import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
@@ -38,6 +39,7 @@ export interface Def<
id: string
description: string
parameters: Parameters
jsonSchema?: JSONSchema7
execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: unknown): string
}
+3 -2
View File
@@ -12,10 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const Parameters = Schema.Struct({
url: Schema.String.annotate({ description: "The URL to fetch content from" }),
format: Schema.Literals(["text", "markdown", "html"])
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const)))
.annotate({
description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
}),
default: "markdown",
})
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }),
})
@@ -1,6 +1,4 @@
import { Schema } from "effect"
import z from "zod"
import { zod } from "@opencode-ai/core/effect-zod"
/**
* Create a Schema-backed NamedError-shaped class.
@@ -11,22 +9,14 @@ import { zod } from "@opencode-ai/core/effect-zod"
* OpenAPI/SDK output is byte-identical to the original NamedError schema.
*
* Preserves the existing surface:
* - static `Schema` (Zod schema of the wire shape)
* - static `Schema` (Effect schema of the wire shape)
* - static `isInstance(x)`
* - instance `toObject()` returning `{ name, data }`
* - `new X({ ...data }, { cause })`
*/
export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
// Wire shape matches the original NamedError output so the SDK stays stable.
const dataSchema = Schema.Struct(fields)
const wire = z
.object({
name: z.literal(tag),
data: zod(dataSchema),
})
.meta({ ref: tag })
// Effect Schema for the wire shape — used by HttpApi OpenAPI generation.
// Wire shape matches the original NamedError output so the SDK stays stable.
const effectSchema = Schema.Struct({
name: Schema.Literal(tag),
data: dataSchema,
@@ -35,7 +25,7 @@ export function namedSchemaError<Tag extends string, Fields extends Schema.Struc
type Data = Schema.Schema.Type<typeof dataSchema>
class NamedSchemaError extends Error {
static readonly Schema = wire
static readonly Schema = effectSchema
static readonly EffectSchema = effectSchema
static readonly tag = tag
public static isInstance(input: unknown): input is NamedSchemaError {
+12 -12
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/core/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { Effect, Layer, Schedule } from "effect"
import { Effect, Layer, Schedule, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
@@ -17,7 +17,7 @@ const retryProvider = "test"
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
function apiError(headers?: Record<string, string>): MessageV2.APIError {
return MessageV2.APIError.Schema.parse(
return Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
new MessageV2.APIError({
message: "boom",
isRetryable: true,
@@ -94,7 +94,7 @@ describe("session.retry.delay", () => {
const step = yield* Schedule.toStepWithMetadata(
SessionRetry.policy({
provider: "test",
parse: (err) => MessageV2.APIError.Schema.parse(err),
parse: Schema.decodeUnknownSync(MessageV2.APIError.Schema),
set: (info) =>
status.set(sessionID, {
type: "retry",
@@ -173,7 +173,7 @@ describe("session.retry.retryable", () => {
})
test("retries 500 errors even when isRetryable is false", () => {
const error = MessageV2.APIError.Schema.parse(
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
new MessageV2.APIError({
message: "Internal server error",
isRetryable: false,
@@ -186,7 +186,7 @@ describe("session.retry.retryable", () => {
})
test("retries 502 bad gateway errors", () => {
const error = MessageV2.APIError.Schema.parse(
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
new MessageV2.APIError({
message: "Bad gateway",
isRetryable: false,
@@ -198,7 +198,7 @@ describe("session.retry.retryable", () => {
})
test("retries 503 service unavailable errors", () => {
const error = MessageV2.APIError.Schema.parse(
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
new MessageV2.APIError({
message: "Service unavailable",
isRetryable: false,
@@ -210,7 +210,7 @@ describe("session.retry.retryable", () => {
})
test("does not retry 4xx errors when isRetryable is false", () => {
const error = MessageV2.APIError.Schema.parse(
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
new MessageV2.APIError({
message: "Bad request",
isRetryable: false,
@@ -222,7 +222,7 @@ describe("session.retry.retryable", () => {
})
test("retries ZlibError decompression failures", () => {
const error = MessageV2.APIError.Schema.parse(
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
new MessageV2.APIError({
message: "Response decompression failed",
isRetryable: true,
@@ -236,7 +236,7 @@ describe("session.retry.retryable", () => {
})
test("maps free limits to Go upsell action", () => {
const error = MessageV2.APIError.Schema.parse(
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
new MessageV2.APIError({
message: "Free usage exceeded",
isRetryable: true,
@@ -262,7 +262,7 @@ describe("session.retry.retryable", () => {
})
test("maps Go subscription limits to workspace PAYG upsell", () => {
const error = MessageV2.APIError.Schema.parse(
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
new MessageV2.APIError({
message: "Subscription quota exceeded. You can continue using free models.",
isRetryable: true,
@@ -300,7 +300,7 @@ describe("session.retry.retryable", () => {
})
test("maps Go subscription limits without limit metadata", () => {
const error = MessageV2.APIError.Schema.parse(
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
new MessageV2.APIError({
message: "Subscription quota exceeded. You can continue using free models.",
isRetryable: true,
@@ -366,7 +366,7 @@ describe("session.message-v2.fromError", () => {
)
test("ECONNRESET socket error is retryable", () => {
const error = MessageV2.APIError.Schema.parse(
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
new MessageV2.APIError({
message: "Connection reset by server",
isRetryable: true,
@@ -45,6 +45,7 @@ Output: Creates directory 'foo'"
"description": "Optional timeout in milliseconds",
"exclusiveMinimum": 0,
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer",
},
"workdir": {
@@ -240,7 +241,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
"type": "string",
},
},
"ref": "QuestionOption",
"required": [
"label",
"description",
@@ -254,7 +254,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
"type": "string",
},
},
"ref": "QuestionPrompt",
"required": [
"question",
"header",
@@ -393,14 +392,21 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"format": {
"default": "markdown",
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
"enum": [
"text",
"markdown",
"html",
"anyOf": [
{
"default": "markdown",
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
"enum": [
"text",
"markdown",
"html",
],
"type": "string",
},
{
"type": "null",
},
],
"type": "string",
},
"timeout": {
"description": "Optional timeout in seconds (max 120)",
+35 -3
View File
@@ -1,13 +1,13 @@
import { describe, expect, test } from "bun:test"
import { Result, Schema } from "effect"
import { toJsonSchema } from "@opencode-ai/core/effect-zod"
import { ToolJsonSchema } from "../../src/tool/json-schema"
// Each tool exports its parameters schema at module scope so this test can
// import them without running the tool's Effect-based init. The JSON Schema
// snapshot captures what the LLM sees; the parse assertions pin down the
// accepts/rejects contract. `toJsonSchema` is the same helper `session/
// accepts/rejects contract. `ToolJsonSchema.fromSchema` is the same helper `session/
// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
// byte-identical regardless of whether a tool has migrated from zod to Schema.
// provider-compatible while tools use Effect Schema internally.
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
import { Parameters as Edit } from "../../src/tool/edit"
@@ -32,6 +32,8 @@ const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S[
const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
const toJsonSchema = ToolJsonSchema.fromSchema
describe("tool parameters", () => {
describe("JSON Schema (wire shape)", () => {
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
@@ -50,6 +52,36 @@ describe("tool parameters", () => {
test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
test("inlines named child schemas for provider compatibility", () => {
const schema = toJsonSchema(Question)
expect(schema).not.toHaveProperty("$defs")
expect(schema).toMatchObject({
properties: {
questions: { items: { properties: { options: { items: { properties: { label: { type: "string" } } } } } } },
},
})
})
test("preserves required nullable fields", () => {
expect(toJsonSchema(Schema.Struct({ value: Schema.NullOr(Schema.String) }))).toMatchObject({
properties: { value: { anyOf: expect.arrayContaining([{ type: "null" }]) } },
})
})
test("keeps repeated allOf constraints instead of dropping duplicates", () => {
expect(
toJsonSchema(
Schema.Struct({ value: Schema.String.check(Schema.isPattern(/^a/)).check(Schema.isPattern(/z$/)) }),
),
).toMatchObject({ properties: { value: { allOf: [{ pattern: "^a" }, { pattern: "z$" }] } } })
})
test("bounds bare integer fields to safe integer range", () => {
expect(toJsonSchema(Schema.Struct({ value: Schema.Int }))).toMatchObject({
properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } },
})
})
})
describe("apply_patch", () => {
+88 -2
View File
@@ -1,7 +1,8 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Effect, Layer } from "effect"
import { pathToFileURL } from "url"
import { Effect, Layer, Result, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -26,6 +27,8 @@ import { Ripgrep } from "@/file/ripgrep"
import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state"
import { Reference } from "@/reference/reference"
import { ProviderID, ModelID } from "@/provider/schema"
import { ToolJsonSchema } from "@/tool/json-schema"
const node = CrossSpawnSpawner.defaultLayer
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
@@ -55,7 +58,7 @@ const registryLayer = ToolRegistry.layer.pipe(
Layer.provide(Truncate.defaultLayer),
)
const it = testEffect(Layer.mergeAll(registryLayer, node))
const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer))
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout
@@ -141,6 +144,89 @@ describe("tool.registry", () => {
}),
)
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "sql.ts"),
[
`import { tool } from ${JSON.stringify(pluginTool)}`,
"export default tool({",
" description: 'query database',",
" args: { query: tool.schema.string().describe('SQL query to execute') },",
" execute: async ({ query }) => query,",
"})",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
if (!loaded) throw new Error("custom sql tool was not loaded")
expect(loaded?.jsonSchema).toMatchObject({
type: "object",
properties: {
query: { type: "string", description: "SQL query to execute" },
},
required: ["query"],
})
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true)
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
const agents = yield* Agent.Service
const promptTools = yield* registry.tools({
providerID: ProviderID.opencode,
modelID: ModelID.make("test"),
agent: yield* agents.get(yield* agents.defaultAgent()),
})
const promptTool = promptTools.find((tool) => tool.id === "sql")
if (!promptTool) throw new Error("custom sql tool was not returned for prompts")
expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({
properties: {
query: { type: "string", description: "SQL query to execute" },
},
required: ["query"],
})
}),
)
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const tools = path.join(test.directory, ".opencode", "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "legacy.ts"),
[
"export default {",
" description: 'legacy schema tool',",
" args: { text: { type: 'string', description: 'Text to render' } },",
" execute: async ({ text }) => text,",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
if (!loaded) throw new Error("legacy custom tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
type: "object",
properties: {
text: { type: "string", description: "Text to render" },
},
required: ["text"],
})
}),
)
it.instance("loads tools with external dependencies without crashing", () =>
Effect.gen(function* () {
const test = yield* TestInstance
@@ -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" })
})
})
})