Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 39 additions & 36 deletions packages/opencode/src/tool/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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 &&
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/test/tool/parameters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading