From d6d722e847c64a09eec3d5cac6ec659647c3e4cc Mon Sep 17 00:00:00 2001 From: dilshodbekx2005 Date: Sun, 6 Sep 2026 16:32:52 +0000 Subject: [PATCH 1/2] refactor(opencode): split json-schema normalize into named rewrite steps Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/opencode/src/tool/json-schema.ts | 75 ++++++++++++----------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/tool/json-schema.ts b/packages/opencode/src/tool/json-schema.ts index edb43e11..d498bede 100644 --- a/packages/opencode/src/tool/json-schema.ts +++ b/packages/opencode/src/tool/json-schema.ts @@ -29,10 +29,20 @@ function normalize(value: unknown, options: { stripNull?: boolean } = {}): unkno if (Array.isArray(value)) return value.map((item) => normalize(item)) if (!isRecord(value)) return value + const schema = normalizeChildren(value) + if (schema.additionalProperties === true) delete schema.additionalProperties + + const rewritten = collapseAnyOf(schema, options.stripNull === true) ?? flattenAllOf(schema) + if (rewritten) return normalize(rewritten) + + return boundIntegerRange(schema) +} + +function normalizeChildren(value: JsonObject): JsonObject { const required = Array.isArray(value.required) ? new Set(value.required.filter((item) => typeof item === "string")) : undefined - const schema = Object.fromEntries( + return Object.fromEntries( Object.entries(value).map(([key, item]) => [ key, key === "properties" && isRecord(item) @@ -45,46 +55,35 @@ function normalize(value: unknown, options: { stripNull?: boolean } = {}): unkno : 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 }) - } +// Returns a replacement schema when the `anyOf` union can be simplified, or +// undefined to leave the schema untouched. Each rewrite is re-normalized by the caller. +function collapseAnyOf(schema: JsonObject, stripNull: boolean): JsonObject | undefined { + if (!Array.isArray(schema.anyOf)) return + const items: unknown[] = schema.anyOf - if (withoutNull.length === 1 && isRecord(withoutNull[0])) { - const { anyOf: _, ...rest } = schema - return normalize({ ...withoutNull[0], ...rest }) - } - } + const withoutNull = stripNull ? items.filter((item) => !isRecord(item) || item.type !== "null") : items + if (withoutNull.length !== items.length) return { ...schema, anyOf: withoutNull } - if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) { - const { allOf, ...rest } = schema - return normalize({ ...Object.assign({}, ...allOf), ...rest }) - } + const { anyOf: _, ...rest } = schema + const number = items.find((item) => isRecord(item) && item.type === "number") + if (isRecord(number) && items.filter(isNonFiniteEnum).length === items.length - 1) return { ...number, ...rest } + if (isEmptyStructUnion(items)) return { type: "object", properties: {}, ...rest } + if (items.length === 1 && isRecord(items[0])) return { ...items[0], ...rest } +} - if (schema.type === "integer" && schema.maximum === undefined) { - return { minimum: Number.MIN_SAFE_INTEGER, ...schema, maximum: Number.MAX_SAFE_INTEGER } - } +function flattenAllOf(schema: JsonObject): JsonObject | undefined { + if (!Array.isArray(schema.allOf)) return + const items: unknown[] = schema.allOf + if (!items.every(isRecord) || !canFlattenAllOf(items, schema)) return + const { allOf: _, ...rest } = schema + return { ...Object.assign({}, ...items), ...rest } +} - return schema +function boundIntegerRange(schema: JsonObject): JsonObject { + if (schema.type !== "integer" || schema.maximum !== undefined) return schema + return { minimum: Number.MIN_SAFE_INTEGER, ...schema, maximum: Number.MAX_SAFE_INTEGER } } function isRecord(value: unknown): value is JsonObject { @@ -99,6 +98,10 @@ function isNonFiniteNumber(value: unknown) { return value === "NaN" || value === "Infinity" || value === "-Infinity" } +function isNonFiniteEnum(value: unknown) { + return isRecord(value) && Array.isArray(value.enum) && value.enum.every(isNonFiniteNumber) +} + function isEmptyStructUnion(items: unknown[]) { return ( items.length === 2 && From fa53fc4ad9613b09a61dbbb6cb7c8e2414715bc5 Mon Sep 17 00:00:00 2001 From: dilshodbekx2005 Date: Sun, 6 Sep 2026 16:32:52 +0000 Subject: [PATCH 2/2] test(opencode): cover json-schema normalize rewrite rules and run them in CI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 5 +++ .../opencode/test/tool/parameters.test.ts | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6410217e..f00e3bd3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,6 +64,11 @@ jobs: # contention issue typecheck.yml already caps for tsgo. run: GITHUB_ACTIONS=false bun turbo test --concurrency=4 + - name: Run tool JSON Schema tests + timeout-minutes: 5 + working-directory: packages/opencode + run: bun test --timeout 30000 test/tool/parameters.test.ts test/tool/registry.test.ts + - name: Check generated client timeout-minutes: 5 working-directory: packages/client diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 9c540daa..2b406e86 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -89,6 +89,46 @@ describe("tool parameters", () => { }) expect(toJsonSchema(WebFetch).properties?.format).not.toHaveProperty("anyOf") }) + + test("strips null from optional fields but keeps it inside nested unions", () => { + expect(toJsonSchema(Schema.Struct({ value: Schema.optional(Schema.NullOr(Schema.String)) }))).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { value: { anyOf: [{ type: "string" }, { type: "null" }] } }, + }) + }) + + test("collapses number unions with non-finite enum members to a plain number", () => { + expect(toJsonSchema(Schema.Struct({ value: Schema.Number })).properties).toEqual({ value: { type: "number" } }) + expect(toJsonSchema(Schema.Struct({ value: Schema.Union([Schema.String, Schema.Number]) })).properties).toEqual({ + value: { anyOf: [{ type: "string" }, { type: "number" }] }, + }) + }) + + test("collapses empty struct unions to an object with no properties", () => { + expect(toJsonSchema(Schema.Struct({ value: Schema.Struct({}) })).properties).toEqual({ + value: { type: "object", properties: {} }, + }) + }) + + test("flattens allOf when constraint keys do not collide", () => { + expect( + toJsonSchema(Schema.Struct({ value: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(5)) })) + .properties, + ).toEqual({ value: { type: "string", minLength: 1, maxLength: 5 } }) + }) + + test("keeps an explicit integer maximum instead of bounding to safe range", () => { + expect( + toJsonSchema(Schema.Struct({ value: Schema.Int.check(Schema.isLessThanOrEqualTo(10)) })).properties, + ).toEqual({ value: { type: "integer", maximum: 10 } }) + }) + + test("passes through non-object schema values untouched", () => { + expect(toJsonSchema(Schema.Struct({ value: Schema.Literals(["a", "b"]) })).properties).toEqual({ + value: { type: "string", enum: ["a", "b"] }, + }) + }) }) describe("apply_patch", () => {