diff --git a/packages/core/src/effect-zod.ts b/packages/core/src/effect-zod.ts deleted file mode 100644 index 42d89ec7d5..0000000000 --- a/packages/core/src/effect-zod.ts +++ /dev/null @@ -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() - -// 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(schema: S): z.ZodType> { - return walk(schema.ast) as z.ZodType> -} - -/** - * Derive a Zod value from an Effect Schema (or a Schema-backed export with a - * `.zod` static) and narrow the result to `z.ZodObject` so `.shape`, - * `.omit`, `.extend`, and friends are accessible. - * - * The `zod()` walker returns `z.ZodType` 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` 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` - * 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(schema: S): z.ZodObject { - const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast) - return derived as unknown as z.ZodObject -} - -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(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( - (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 - >, - ) - 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[] = [] - const collect = (c: SchemaAST.Check) => { - if (c._tag === "FilterGroup") c.checks.forEach(collect) - else filters.push(c) - } - checks.forEach(collect) - - const unhandled: SchemaAST.Filter[] = [] - const translated = filters.reduce((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): z.ZodTypeAny | undefined { - const meta = (filter.annotations as { meta?: Record } | 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 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.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, - options: SchemaAST.ParseOptions, - ) => Effect.Effect, unknown> - } - } -} - -function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined { - const encoding = (ast as { encoding?: ReadonlyArray }).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, z.ZodObject, ...z.ZodObject[]]) - } - - return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) -} - -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]) -} - -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}`) -} diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 2a6c02349f..5b4042c736 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,5 +1,4 @@ import { Option, Schema, SchemaGetter } from "effect" -import { zod, ZodOverride } from "./effect-zod" /** * Integer greater than zero. @@ -21,7 +20,6 @@ export const optionalOmitUndefined = (schema: S) => decode: SchemaGetter.passthrough({ strict: false }), encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), }), - Schema.annotate({ [ZodOverride]: zod(schema).optional() }), ) /** diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 01af9da6ce..13838e833d 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -57,17 +57,9 @@ Rules: - Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase - No `Layer.fresh` for normal per-directory isolation; use `InstanceState` -## Schema → Zod interop +## Schema boundaries -When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: - -```ts -import { zod } from "@opencode-ai/core/effect-zod" - -export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union -``` - -See `Auth.ZodInfo` for the canonical example. +Use Effect Schema directly at HTTP, tool, and AI SDK boundaries. For provider-facing JSON Schema, use a boundary-specific helper such as `ToolJsonSchema.fromSchema(...)`; do not reintroduce generic Effect Schema → Zod conversion. ## InstanceState init patterns diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index 20b3e70e7b..1fc6a44783 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -1,19 +1,16 @@ # Schema migration Practical reference for migrating data types in `packages/opencode` from -Zod-first definitions to Effect Schema with Zod compatibility shims. +Zod-first definitions to Effect Schema. ## Goal Use Effect Schema as the source of truth for domain models, IDs, inputs, -outputs, and typed errors. Keep Zod available at existing HTTP, tool, and -compatibility boundaries by exposing a `.zod` static derived from the Effect -schema via `@opencode-ai/core/effect-zod`. +outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and +native JSON Schema generation at HTTP, tool, and AI SDK boundaries. -The long-term driver is `specs/effect/http-api.md` — once the HTTP server -moves to `@effect/platform`, every Schema-first DTO can flow through -`HttpApi` / `HttpRouter` without a zod translation layer, and the entire -`effect-zod` walker plus every `.zod` static can be deleted. +The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should +flow through `HttpApi` / `HttpRouter` without a Zod translation layer. ## Preferred shapes @@ -26,19 +23,16 @@ export class Info extends Schema.Class("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()("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` 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 ` values are all Effect -Schema at source. +All of `packages/opencode/src/config/` has been migrated. The `export const +` 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 diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index 255c09644f..5be155d1b8 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -100,7 +100,7 @@ Verification: - Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. - Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. -- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Add or fix OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. - Delete one path override only after generated OpenAPI is unchanged for that param. Concrete first targets: diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 54cfe4fcc5..3da260ea64 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -4,8 +4,6 @@ import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" -import z from "zod" -import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" @@ -35,12 +33,11 @@ export const Info = Schema.Struct({ model: Schema.optional(Schema.String), source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])), // Some command templates are lazy promises from MCP prompt resolution. - template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }), + template: Schema.Unknown, subtask: Schema.optional(Schema.Boolean), hints: Schema.Array(Schema.String), }).annotate({ identifier: "Command" }) -// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it export type Info = Omit, "template"> & { template: Promise | string } export function hints(template: string) { diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 6cba3ecd2a..ba763f9991 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,13 +1,5 @@ import { Schema } from "effect" -import z from "zod" -import { ZodOverride } from "@opencode-ai/core/effect-zod" -// The original Zod schema carried an external $ref pointing at the models.dev -// JSON schema. That external reference is not a named SDK component — it is a -// literal pointer to an outside schema — so the walker cannot re-derive it -// from AST metadata. Preserve the exact original Zod via ZodOverride. -export const ConfigModelID = Schema.String.annotate({ - [ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }), -}) +export const ConfigModelID = Schema.String export type ConfigModelID = Schema.Schema.Type diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 12ce5f5811..0249721c44 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -5,7 +5,6 @@ import * as LSPClient from "./client" import path from "path" import { pathToFileURL, fileURLToPath } from "url" import * as LSPServer from "./server" -import z from "zod" import { Config } from "@/config/config" import { Flag } from "@opencode-ai/core/flag/flag" import { Process } from "@/util/process" @@ -14,7 +13,6 @@ import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" import { NonNegativeInt } from "@opencode-ai/core/schema" -import { ZodOverride } from "@opencode-ai/core/effect-zod" const log = Log.create({ service: "lsp" }) @@ -56,9 +54,7 @@ export const Status = Schema.Struct({ id: Schema.String, name: Schema.String, root: Schema.String, - status: Schema.Literals(["connected", "error"]).annotate({ - [ZodOverride]: z.union([z.literal("connected"), z.literal("error")]), - }), + status: Schema.Literals(["connected", "error"]), }).annotate({ identifier: "LSPStatus" }) export type Status = typeof Status.Type diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 55272fc2f2..360daf54a5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -5,8 +5,8 @@ import { InstanceState } from "@/effect/instance-state" import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" +import { ToolJsonSchema } from "@/tool/json-schema" import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@opencode-ai/core/effect-zod" import { Worktree } from "@/worktree" import { Effect, Option } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" @@ -84,7 +84,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper return list.map((item) => ({ id: item.id, description: item.description, - parameters: EffectZod.toJsonSchema(item.parameters), + parameters: ToolJsonSchema.fromTool(item), })) }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2d1d05e155..4dae820382 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,6 +1,5 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID, MessageID, PartID } from "./schema" -import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { LSP } from "@/lsp/lsp" @@ -55,7 +54,7 @@ export const APIError = namedSchemaError("APIError", { responseBody: Schema.optional(Schema.String), metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) -export type APIError = z.infer +export type APIError = Schema.Schema.Type export const ContextOverflowError = namedSchemaError("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2de4bbd308..15246dac39 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,5 @@ import path from "path" import os from "os" -import * as EffectZod from "@opencode-ai/core/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import * as Log from "@opencode-ai/core/util/log" @@ -21,6 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { ToolRegistry } from "@/tool/registry" +import { ToolJsonSchema } from "@/tool/json-schema" import { MCP } from "../mcp" import { LSP } from "@/lsp/lsp" import { Flag } from "@opencode-ai/core/flag/flag" @@ -565,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: input.model.providerID, agent: input.agent, })) { - const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters)) + const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item)) tools[item.id] = tool({ description: item.description, inputSchema: jsonSchema(schema), diff --git a/packages/opencode/src/tool/json-schema.ts b/packages/opencode/src/tool/json-schema.ts new file mode 100644 index 0000000000..edb43e11ca --- /dev/null +++ b/packages/opencode/src/tool/json-schema.ts @@ -0,0 +1,164 @@ +import type { JSONSchema7 } from "@ai-sdk/provider" +import { JsonSchema, Schema } from "effect" +import type * as Tool from "./tool" + +type JsonObject = Record +const cache = new WeakMap() + +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()): 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" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 68251c342c..a7411a077b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -15,9 +15,9 @@ import { SkillTool } from "./skill" import * as Tool from "./tool" import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" +import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider" import { Schema } from "effect" import z from "zod" -import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" @@ -137,17 +137,19 @@ export const layer: Layer.Layer< const custom: Tool.Def[] = [] function fromPlugin(id: string, def: ToolDefinition): Tool.Def { - // Plugin tools define their args as a raw Zod shape. Wrap the - // derived Zod object in a `Schema.declare` so it slots into the - // Schema-typed framework, and annotate with `ZodOverride` so the - // walker emits the original Zod object for LLM JSON Schema. - const zodParams = z.object(def.args) - const parameters = Schema.declare((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((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 { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + export * as ToolRegistry from "./registry" diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 4b9ea8774a..a26422d04c 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from "effect" +import type { JSONSchema7 } from "@ai-sdk/provider" import type { MessageV2 } from "../session/message-v2" import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" @@ -38,6 +39,7 @@ export interface Def< id: string description: string parameters: Parameters + jsonSchema?: JSONSchema7 execute(args: Schema.Schema.Type, ctx: Context): Effect.Effect> formatValidationError?(error: unknown): string } diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index d2561a1301..8c2be44e99 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -12,10 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes export const Parameters = Schema.Struct({ url: Schema.String.annotate({ description: "The URL to fetch content from" }), format: Schema.Literals(["text", "markdown", "html"]) - .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))) .annotate({ description: "The format to return the content in (text, markdown, or html). Defaults to markdown.", - }), + default: "markdown", + }) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))), timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }), }) diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index d87e1dcdb5..a5ff0828ea 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,6 +1,4 @@ import { Schema } from "effect" -import z from "zod" -import { zod } from "@opencode-ai/core/effect-zod" /** * Create a Schema-backed NamedError-shaped class. @@ -11,22 +9,14 @@ import { zod } from "@opencode-ai/core/effect-zod" * OpenAPI/SDK output is byte-identical to the original NamedError schema. * * Preserves the existing surface: - * - static `Schema` (Zod schema of the wire shape) + * - static `Schema` (Effect schema of the wire shape) * - static `isInstance(x)` * - instance `toObject()` returning `{ name, data }` * - `new X({ ...data }, { cause })` */ export function namedSchemaError(tag: 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 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 { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 9da45c9112..22ff6cde81 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Layer, Schedule } from "effect" +import { Effect, Layer, Schedule, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -17,7 +17,7 @@ const retryProvider = "test" const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) function apiError(headers?: Record): 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, diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 601f07cb3a..d6c1bc45d8 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -45,6 +45,7 @@ Output: Creates directory 'foo'" "description": "Optional timeout in milliseconds", "exclusiveMinimum": 0, "maximum": 9007199254740991, + "minimum": -9007199254740991, "type": "integer", }, "workdir": { @@ -240,7 +241,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = ` "type": "string", }, }, - "ref": "QuestionOption", "required": [ "label", "description", @@ -254,7 +254,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = ` "type": "string", }, }, - "ref": "QuestionPrompt", "required": [ "question", "header", @@ -393,14 +392,21 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = ` "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "format": { - "default": "markdown", - "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.", - "enum": [ - "text", - "markdown", - "html", + "anyOf": [ + { + "default": "markdown", + "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.", + "enum": [ + "text", + "markdown", + "html", + ], + "type": "string", + }, + { + "type": "null", + }, ], - "type": "string", }, "timeout": { "description": "Optional timeout in seconds (max 120)", diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 17af7b983e..8b2dc9a74d 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from "bun:test" import { Result, Schema } from "effect" -import { toJsonSchema } from "@opencode-ai/core/effect-zod" +import { ToolJsonSchema } from "../../src/tool/json-schema" // Each tool exports its parameters schema at module scope so this test can // import them without running the tool's Effect-based init. The JSON Schema // snapshot captures what the LLM sees; the parse assertions pin down the -// accepts/rejects contract. `toJsonSchema` is the same helper `session/ +// accepts/rejects contract. `ToolJsonSchema.fromSchema` is the same helper `session/ // prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay -// byte-identical regardless of whether a tool has migrated from zod to Schema. +// provider-compatible while tools use Effect Schema internally. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" import { Parameters as Edit } from "../../src/tool/edit" @@ -32,6 +32,8 @@ const parse = >(schema: S, input: unknown): S[ const accepts = (schema: Schema.Decoder, 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", () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index dc66c308ac..37cb7a43d8 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Effect, Layer } from "effect" +import { pathToFileURL } from "url" +import { Effect, Layer, Result, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" import { Flag } from "@opencode-ai/core/flag/flag" @@ -26,6 +27,8 @@ import { Ripgrep } from "@/file/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" import { Reference } from "@/reference/reference" +import { ProviderID, ModelID } from "@/provider/schema" +import { ToolJsonSchema } from "@/tool/json-schema" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT @@ -55,7 +58,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Truncate.defaultLayer), ) -const it = testEffect(Layer.mergeAll(registryLayer, node)) +const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer)) afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout @@ -141,6 +144,89 @@ describe("tool.registry", () => { }), ) + it.instance("loads Zod-schema custom tools with JSON Schema and validation", () => + Effect.gen(function* () { + const test = yield* TestInstance + const customTools = path.join(test.directory, ".opencode", "tools") + const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href + yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(customTools, "sql.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'query database',", + " args: { query: tool.schema.string().describe('SQL query to execute') },", + " execute: async ({ query }) => query,", + "})", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "sql") + if (!loaded) throw new Error("custom sql tool was not loaded") + expect(loaded?.jsonSchema).toMatchObject({ + type: "object", + properties: { + query: { type: "string", description: "SQL query to execute" }, + }, + required: ["query"], + }) + expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true) + expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false) + + const agents = yield* Agent.Service + const promptTools = yield* registry.tools({ + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + agent: yield* agents.get(yield* agents.defaultAgent()), + }) + const promptTool = promptTools.find((tool) => tool.id === "sql") + if (!promptTool) throw new Error("custom sql tool was not returned for prompts") + expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({ + properties: { + query: { type: "string", description: "SQL query to execute" }, + }, + required: ["query"], + }) + }), + ) + + it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () => + Effect.gen(function* () { + const test = yield* TestInstance + const tools = path.join(test.directory, ".opencode", "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "legacy.ts"), + [ + "export default {", + " description: 'legacy schema tool',", + " args: { text: { type: 'string', description: 'Text to render' } },", + " execute: async ({ text }) => text,", + "}", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy") + if (!loaded) throw new Error("legacy custom tool was not loaded") + expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ + type: "object", + properties: { + text: { type: "string", description: "Text to render" }, + }, + required: ["text"], + }) + }), + ) + it.instance("loads tools with external dependencies without crashing", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts deleted file mode 100644 index ab3923d8e0..0000000000 --- a/packages/opencode/test/util/effect-zod.test.ts +++ /dev/null @@ -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("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("MyInner")({ - value: Schema.String, - }) {} - - class Outer extends Schema.Class("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("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("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("PermissionTool")({ - messageID: MsgID, - callID: Schema.String, - }) {} - - class Request extends Schema.Class("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")({ - 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) => - "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("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("MemoTestInner")({ - value: Schema.String, - }) {} - - class OuterA extends Schema.Class("MemoTestOuterA")({ - inner: Inner, - }) {} - - class OuterB extends Schema.Class("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" }) - }) - }) -})