diff --git a/packages/opencode/script/tool-schema-compatibility-matrix.ts b/packages/opencode/script/tool-schema-compatibility-matrix.ts new file mode 100644 index 0000000000..72a490880b --- /dev/null +++ b/packages/opencode/script/tool-schema-compatibility-matrix.ts @@ -0,0 +1,285 @@ +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { JSONSchema7 } from "@ai-sdk/provider" +import { Effect } from "effect" +import { jsonSchema, streamText, tool, type LanguageModel } from "ai" +import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef } from "@/effect/instance-ref" +import { Provider } from "@/provider/provider" +import { InstanceStore } from "@/project/instance-store" +import { MFJS } from "@/provider/mfjs" + +// Usage: +// bun run script/tool-schema-compatibility-matrix.ts --models=model-a,model-b +// [--provider=opencode-go] [--projection=none|mfjs] +// [--cases=tuple items,...] [--concurrency=9] [--timeout=30000] + +type JsonRecord = Record +type Case = { + name: string + schema: JsonRecord +} +type Result = { + case: string + model: string + status: "accepted" | "rejected" | "rate-limited" | "error" + code?: number + error?: string +} + +const projection = option("projection") ?? "none" +if (projection !== "none" && projection !== "mfjs") throw new Error(`Unsupported projection: ${projection}`) +const concurrency = Number(option("concurrency") ?? 9) +const timeout = Number(option("timeout") ?? 30_000) +const providerID = option("provider") ?? "opencode-go" +const models = option("models")?.split(",").filter(Boolean) ?? [] +if (models.length === 0) throw new Error("--models must contain at least one model ID") +const selectedCases = new Set(option("cases")?.split(",").filter(Boolean) ?? []) + +const matrix: Case[] = [ + property("enum/type mismatch", { type: "object", enum: ["move", "copy"] }), + property("untyped enum", { enum: ["move", "copy"] }), + property("mixed untyped enum", { enum: ["move", 1, null, true] }), + property("const", { const: "move" }), + property("tuple items", { type: "array", items: [{ type: "string" }, { type: "number" }] }), + property("prefix items", { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }), + property("typed anyOf", { + type: "string", + enum: ["move"], + anyOf: [{ type: "string" }, { type: "null" }], + }), + property("anyOf count limit", { + anyOf: Array.from({ length: 501 }, (_, index) => ({ const: `value_${index}` })), + }), + property("oneOf", { oneOf: [{ type: "string" }, { type: "integer" }] }), + property("allOf", { + allOf: [ + { type: "object", properties: { left: { type: "string" } } }, + { type: "object", properties: { right: { type: "number" } } }, + ], + }), + property("not", { type: "string", not: { enum: ["blocked"] } }), + property("if/then/else", { + type: "object", + properties: { mode: { type: "string" }, count: { type: "integer" } }, + if: { properties: { mode: { enum: ["many"] } } }, + then: { required: ["count"] }, + else: { properties: { count: { maximum: 1 } } }, + }), + property("contains", { type: "array", contains: { type: "string" } }), + objectCase("patternProperties", { + type: "object", + patternProperties: { "^extra_": { type: "number" } }, + additionalProperties: false, + }), + objectCase("dependentSchemas", { + type: "object", + properties: { key: { type: "string" }, value: { type: "string" } }, + dependentSchemas: { key: { required: ["value"] } }, + }), + objectCase("propertyNames", { + type: "object", + propertyNames: { pattern: "^[a-z]+$" }, + }), + property("uniqueItems", { type: "array", items: { type: "string" }, uniqueItems: true }), + property("boolean true schema", true), + property("boolean false schema", false), + objectCase("empty property name", { + type: "object", + properties: { "": { type: "string" } }, + required: [""], + }), + objectCase("dangling required", { + type: "object", + properties: {}, + required: ["missing"], + }), + objectCase("external ref", { + type: "object", + properties: { value: { $ref: "https://example.com/schema.json" } }, + }), + objectCase("chained ref", { + type: "object", + properties: { value: { $ref: "#/$defs/A" } }, + $defs: { A: { $ref: "#/$defs/B" }, B: { type: "string" } }, + }), + objectCase("recursive ref", { + type: "object", + properties: { node: { $ref: "#/$defs/Node" } }, + $defs: { + Node: { + type: "object", + properties: { value: { type: "string" }, next: { anyOf: [{ $ref: "#/$defs/Node" }, { type: "null" }] } }, + required: ["value"], + }, + }, + }), + objectCase("reference depth limit", { + type: "object", + properties: { value: { $ref: "#/$defs/Value" } }, + $defs: { Value: nested(30) }, + }), + objectCase("schema size limit", { + type: "object", + description: "x".repeat(120_001), + properties: {}, + }), + objectCase("schema depth limit", nested(35)), + objectCase("property count limit", { + type: "object", + properties: Object.fromEntries( + Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]), + ), + }), + property("enum count limit", { + type: "string", + enum: Array.from({ length: 1001 }, (_, index) => `value_${index}`), + }), +] +const cases = selectedCases.size === 0 ? matrix : matrix.filter((item) => selectedCases.has(item.name)) + +const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => + store.load({ directory: process.cwd() }).pipe(Effect.map((ctx) => ({ store, ctx }))), + ), +) + +try { + const languages = await AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* Effect.forEach( + models, + Effect.fnUntraced(function* (model) { + const info = yield* provider.getModel(ProviderV2.ID.make(providerID), ModelV2.ID.make(model)) + return [model, yield* provider.getLanguage(info)] as const + }), + { concurrency: "unbounded" }, + ) + }).pipe(Effect.provideService(InstanceRef, ctx)), + ) + const jobs = languages.flatMap(([model, language]) => cases.map((item) => () => run(language, model, item))) + const results = await parallel(jobs, concurrency) + print(results) + if (projection !== "none" && results.some((result) => result.status !== "accepted")) process.exitCode = 1 +} finally { + await AppRuntime.runPromise(store.dispose(ctx)) +} +process.exit(process.exitCode ?? 0) + +async function run(language: LanguageModel, model: string, item: Case): Promise { + const schema = projection === "mfjs" ? MFJS.sanitize(item.schema) : item.schema + let providerError: unknown + try { + const response = streamText({ + model: language, + prompt: "Reply with exactly OK without calling tools.", + maxOutputTokens: 16, + abortSignal: AbortSignal.timeout(timeout), + onError(event) { + providerError = event.error + }, + tools: { + probe: tool({ + description: `Tool schema probe: ${item.name}`, + inputSchema: jsonSchema(schema as JSONSchema7), + execute: async () => "ok", + }), + }, + }) + await response.text + if (providerError) throw providerError + console.error(`accepted: ${model} / ${item.name}`) + return { case: item.name, model, status: "accepted" } + } catch (error) { + const failure = providerError ?? error + const code = statusCode(failure) + const message = failure instanceof Error ? failure.message : String(failure) + const status = code === 429 || /rate limit/i.test(message) ? "rate-limited" : code === 400 ? "rejected" : "error" + console.error(`${status}: ${model} / ${item.name}`) + return { + case: item.name, + model, + status, + code, + error: message, + } + } +} + +async function parallel(jobs: Array<() => Promise>, limit: number) { + const output = new Array(jobs.length) + let next = 0 + await Promise.all( + Array.from({ length: Math.min(limit, jobs.length) }, async () => { + while (true) { + const index = next++ + const job = jobs[index] + if (!job) return + output[index] = await job() + } + }), + ) + return output +} + +function print(results: Result[]) { + const byCase = new Map>() + results.forEach((result) => { + const row = byCase.get(result.case) ?? new Map() + row.set(result.model, result) + byCase.set(result.case, row) + }) + console.log(`Projection: ${projection}`) + console.log(`Provider: ${providerID}`) + console.log(`| Case | ${models.join(" | ")} |`) + console.log(`| --- | ${models.map(() => "---").join(" | ")} |`) + cases.forEach((item) => { + const row = byCase.get(item.name) + const values = models.map((model) => { + const result = row?.get(model) + if (!result) return "missing" + return result.status === "accepted" ? "accepted" : `${result.status}${result.code ? ` (${result.code})` : ""}` + }) + console.log(`| ${item.name} | ${values.join(" | ")} |`) + }) + const rejected = results.filter((result) => result.status !== "accepted") + if (rejected.length > 0) { + console.log("\nRejected details:") + rejected.forEach((result) => console.log(`- ${result.model} / ${result.case}: ${result.error}`)) + } +} + +function property(name: string, schema: unknown): Case { + return objectCase(name, { type: "object", properties: { value: schema }, required: ["value"] }) +} + +function objectCase(name: string, schema: JsonRecord): Case { + return { name, schema } +} + +function nested(depth: number): JsonRecord { + return Array.from({ length: depth }).reduce( + (schema) => ({ type: "object", properties: { next: schema }, required: ["next"] }), + { type: "string" }, + ) +} + +function option(name: string) { + const prefix = `--${name}=` + return process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) +} + +function statusCode(error: unknown, seen = new Set()): number | undefined { + if (!isRecord(error) || seen.has(error)) return + seen.add(error) + if (typeof error.statusCode === "number") return error.statusCode + for (const value of Object.values(error)) { + const nested = statusCode(value, seen) + if (nested !== undefined) return nested + } +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/opencode/src/provider/mfjs.ts b/packages/opencode/src/provider/mfjs.ts index 8fb4e0ebf6..158d27f195 100644 --- a/packages/opencode/src/provider/mfjs.ts +++ b/packages/opencode/src/provider/mfjs.ts @@ -2,7 +2,28 @@ export * as MFJS from "./mfjs" import type { JSONSchema7 } from "@ai-sdk/provider" -// MFJS specification and reference validator: https://github.com/MoonshotAI/walle +/** + * Kimi tool-schema compatibility projection. + * + * Principles: + * - Preserve schema features accepted by Kimi without rewriting them. + * - Apply model-agnostic adaptations only for reproduced provider rejections. + * - Keep explicit types authoritative; lossy fallbacks may widen but never narrow. + * - Leave the original tool schema as the execution-time validation authority. + * + * Adapted families include enum/type conflicts, untyped enums, tuple `items`, + * typed `anyOf`, boolean schemas, dangling `required`, references, and observed + * enum/union/property/size/depth limits. Accepted keywords such as `const`, + * `oneOf`, `allOf`, conditionals, `prefixItems`, and other constraints pass + * through recursively. + * + * `script/kimi-tool-schema-matrix.ts` compares raw and projected schemas across + * Kimi 3, Kimi 2.7 Code, and Kimi 2.6. Keep this projection evidence-driven as + * provider behavior evolves. + * + * MFJS specification and reference implementation: + * https://github.com/MoonshotAI/walle + */ type JsonRecord = Record type Context = { @@ -10,416 +31,200 @@ type Context = { definitions: JsonRecord properties: number } -type Position = { - depth: number - definition: boolean - root: boolean -} const TYPES = new Set(["string", "number", "boolean", "integer", "object", "array", "null"]) -const RESERVED_PROPERTIES = new Set(["$defs", "$ref", "anyOf", "required", "additionalProperties"]) +const SCHEMA_MAPS = new Set(["patternProperties", "dependentSchemas"]) +const SCHEMA_NODES = new Set([ + "additionalProperties", + "not", + "if", + "then", + "else", + "contains", + "propertyNames", + "unevaluatedProperties", +]) +const SCHEMA_LISTS = new Set(["oneOf", "allOf", "prefixItems"]) const MAX_ANY_OF = 500 -const MAX_ENUM = 1000 const MAX_DEPTH = 30 +const MAX_ENUM = 1000 const MAX_PROPERTIES = 3000 const MAX_SCHEMA_SIZE = 120_000 -// Moonshot accepts MFJS, a strict and intentionally smaller JSON Schema dialect. -// Lower broader schemas here so one incompatible tool cannot reject the whole request. +/** + * Projects tool schemas only where Kimi rejects otherwise valid requests. + * Accepted keywords are preserved, explicit types remain authoritative, and + * lossy fallbacks only widen what the model may emit. + */ export function sanitize(value: unknown): JSONSchema7 { - const root = isPlainObject(value) ? value : {} - const definitions = collectDefinitions(root) - const context = { root, definitions: referencedDefinitions(root, definitions), properties: 0 } - const lowered = sanitizeNode(root, context, { depth: 0, definition: false, root: true }) - const rooted = forceObjectRoot(lowered, context) - const referenced = pruneInvalidRefs(rooted, rooted) - const terminating = ensureTermination(forceObjectRoot(referenced, context)) - const depthBounded = - schemaDepth(terminating, terminating, new Set()) <= MAX_DEPTH ? terminating : emptyRoot(terminating) - return fitSchemaSize(forceObjectRoot(depthBounded, context)) as JSONSchema7 -} - -function forceObjectRoot(result: JsonRecord, context: Context) { - if (result.type === "object") return result - if (typeof result.$ref === "string" && refIsObject(result.$ref, context)) return result - if (Array.isArray(result.anyOf)) { - const branches = result.anyOf.filter(isPlainObject).map((branch) => { - if (branch.type === "object" || typeof branch.$ref !== "string" || !refIsObject(branch.$ref, context)) { - return branch - } - const target = resolveRef(branch.$ref, context) - return isPlainObject(target) - ? sanitizeNode(target, context, { depth: 0, definition: false, root: false }) - : branch - }) - if (branches.length > 0 && branches.every((branch) => branch.type === "object")) { - return withMetadata(mergeObjectUnion(branches), result) - } - } - return withMetadata({ type: "object", properties: {} }, result) -} - -function sanitizeNode(value: unknown, context: Context, position: Position): JsonRecord { - if (position.depth >= MAX_DEPTH) return {} - if (value === true || value === false) return position.root ? { type: "object", properties: {} } : {} - if (!isPlainObject(value)) return position.root ? { type: "object", properties: {} } : {} - if (Object.keys(value).length === 0) return position.root ? { type: "object", properties: {} } : {} - - const source = lowerAllOf(value, context) - const ref = rewriteRef(source.$ref) - if (ref && refExists(ref, context)) { - const siblings = Object.fromEntries( - Object.entries(source).filter(([key]) => !["$ref", "description", "title", "default"].includes(key)), - ) - if (Object.keys(siblings).length === 0) return withRootMetadata({ $ref: ref }, source, context, position) - if (Object.keys(siblings).length === 1 && siblings.nullable === true) { - return withRootMetadata({ anyOf: [{ $ref: ref }, { type: "null" }] }, source, context, position) - } - const target = resolveRef(ref, context) - if (isPlainObject(target)) { - return sanitizeNode({ ...target, ...siblings }, context, position) - } - } - - const typeList = Array.isArray(source.type) - ? source.type.filter((item): item is string => typeof item === "string" && TYPES.has(item)) - : [] - const scalarType = typeof source.type === "string" && TYPES.has(source.type) ? source.type : undefined - const enumValues = enumValuesOf("const" in source ? [source.const] : source.enum) - const variants = Array.isArray(source.anyOf) ? source.anyOf : Array.isArray(source.oneOf) ? source.oneOf : undefined - - if (variants || typeList.length > 1 || source.nullable === true) { - const unionBase = Object.fromEntries( - Object.entries(source).filter( - ([key]) => !["anyOf", "oneOf", "allOf", "nullable", "$defs", "definitions", "$id"].includes(key), - ), - ) - const branches = variants - ? variants - .slice(0, MAX_ANY_OF) - .filter(isPlainObject) - .map((branch) => intersect(unionBase, branch)) - .filter((branch): branch is JsonRecord => branch !== undefined) - : (typeList.length > 0 ? typeList : [scalarType ?? inferType(source, groupEnum(enumValues))]).flatMap((type) => { - const base = Object.fromEntries( - Object.entries(unionBase).filter(([key]) => !["type", "enum", "const"].includes(key)), - ) - const matching = enumValues.filter((item) => typeMatches(item, type)) - if (enumValues.length > 0 && matching.length === 0) return [] - return [{ ...base, type, ...(matching.length > 0 ? { enum: matching } : {}) }] - }) - if (source.nullable === true && !branches.some((branch) => branch.type === "null")) branches.push({ type: "null" }) - - const sanitized = branches - .map((branch) => sanitizeNode(branch, context, child(position))) - .flatMap((branch) => (Object.keys(branch).length === 1 && Array.isArray(branch.anyOf) ? branch.anyOf : [branch])) - const unique = [...new Map(sanitized.map((branch) => [JSON.stringify(branch), branch])).values()] - const result = - unique.length === 1 - ? (unique[0] ?? {}) - : unique.length > 1 - ? { anyOf: unique.slice(0, MAX_ANY_OF) } - : sanitizeNode(unionBase, context, position) - return withRootMetadata(result, source, context, position) - } - - const explicitType = - typeof source.type === "string" && TYPES.has(source.type) - ? source.type - : typeList.length === 1 - ? typeList[0] - : undefined - const matchingEnum = explicitType ? enumValues.filter((item) => typeMatches(item, explicitType)) : enumValues - const enumGroups = groupEnum(matchingEnum.length > 0 ? matchingEnum : enumValues) - - if (enumGroups.length > 1 || (enumGroups.length === 1 && enumValues.length > 0 && matchingEnum.length === 0)) { - const base = Object.fromEntries( - Object.entries(source).filter(([key]) => !["type", "enum", "const", "$defs", "definitions", "$id"].includes(key)), - ) - const branches = enumGroups.map((group) => - sanitizeNode({ ...base, type: group.type, enum: group.values }, context, child(position)), - ) - const result = branches.length === 1 ? (branches[0] ?? {}) : { anyOf: branches } - return withRootMetadata(result, source, context, position) - } - - const type = explicitType ?? inferType(source, enumGroups) - const result: JsonRecord = { type } - if (typeof source.description === "string") result.description = source.description - if (typeof source.title === "string") result.title = source.title - if ("default" in source && !position.definition) result.default = source.default - - if (type === "object") { - if (isPlainObject(source.properties)) { - const remaining = Math.max(0, MAX_PROPERTIES - context.properties) - const entries = Object.entries(source.properties) - .filter( - ([name]) => - name.length > 0 && !RESERVED_PROPERTIES.has(name) && (!position.definition || !name.includes("/")), - ) - .slice(0, remaining) - context.properties += entries.length - result.properties = Object.fromEntries( - entries.map(([name, schema]) => [name, sanitizeNode(schema, context, child(position))]), - ) - } - const properties = isPlainObject(result.properties) ? result.properties : undefined - if (Array.isArray(source.required) && properties) { - const required = [ - ...new Set( - source.required.filter( - (item): item is string => typeof item === "string" && item.length > 0 && Object.hasOwn(properties, item), - ), - ), - ] - if (required.length > 0) result.required = required - } - if (typeof source.additionalProperties === "boolean") result.additionalProperties = source.additionalProperties - if (isPlainObject(source.additionalProperties)) { - result.additionalProperties = - Object.keys(source.additionalProperties).length === 0 - ? {} - : sanitizeNode(source.additionalProperties, context, child(position)) - } - } - - if (type === "array") { - const items = Array.isArray(source.items) - ? source.items.filter(isPlainObject) - : isPlainObject(source.items) - ? [source.items] - : Array.isArray(source.prefixItems) - ? source.prefixItems.filter(isPlainObject) - : [] - const sanitized = [ - ...new Map( - items.map((item) => sanitizeNode(item, context, child(position))).map((item) => [JSON.stringify(item), item]), - ).values(), - ] - if (sanitized.length === 1) result.items = sanitized[0] - if (sanitized.length > 1) result.items = { anyOf: sanitized.slice(0, MAX_ANY_OF) } - copyRange(result, source, "minItems", "maxItems", true, true) - } - - if (type === "string") copyRange(result, source, "minLength", "maxLength", true, true) - if (type === "number" || type === "integer") { - const minimums = [ - ...(typeof source.minimum === "number" ? [source.minimum] : []), - ...(type === "integer" && typeof source.exclusiveMinimum === "number" - ? [Math.floor(source.exclusiveMinimum) + 1] - : []), - ] - const maximums = [ - ...(typeof source.maximum === "number" ? [source.maximum] : []), - ...(type === "integer" && typeof source.exclusiveMaximum === "number" - ? [Math.ceil(source.exclusiveMaximum) - 1] - : []), - ] - const minimum = minimums.length > 0 ? Math.max(...minimums) : undefined - const maximum = maximums.length > 0 ? Math.min(...maximums) : undefined - copyRange(result, { minimum, maximum }, "minimum", "maximum", type === "integer", false) - } - - if (["string", "number", "integer", "boolean", "null"].includes(type)) { - const values = (matchingEnum.length > 0 ? matchingEnum : (enumGroups[0]?.values ?? [])).filter((item) => - typeMatches(item, type), - ) - if (values.length > 0) result.enum = values - } - - return withRootMetadata(result, source, context, position) -} - -function withRootMetadata(result: JsonRecord, source: JsonRecord, context: Context, position: Position) { - if (!position.root) return result - if (typeof source.$id === "string") result.$id = source.$id - - const sanitized = Object.fromEntries( + const root = isRecord(value) ? value : {} + const context = { root, definitions: definitions(root), properties: 0 } + const projected = project(root, context, 0) + const defs = Object.fromEntries( Object.entries(context.definitions) - .filter(([name, schema]) => name.length > 0 && !name.includes("/") && isPlainObject(schema)) - .map(([name, schema]) => [name, sanitizeNode(schema, context, { depth: 0, definition: true, root: false })]), + .filter(([name, schema]) => name.length > 0 && !name.includes("/") && isRecord(schema)) + .map(([name, schema]) => [name, project(schema, context, 0)]), ) - if (Object.keys(sanitized).length > 0) result.$defs = sanitized - return result + if (Object.keys(defs).length > 0) projected.$defs = defs + const bounded = + schemaDepth(projected, projected, new Set()) > MAX_DEPTH ? { type: "object", properties: {} } : projected + return fitSize(bounded) as JSONSchema7 } -function withMetadata(result: JsonRecord, source: JsonRecord) { - if (typeof source.$id === "string") result.$id = source.$id - if (isPlainObject(source.$defs)) result.$defs = source.$defs - return result -} +function project(value: unknown, context: Context, depth: number): JsonRecord { + if (depth >= MAX_DEPTH) return {} + if (value === true || value === false || !isRecord(value) || Object.keys(value).length === 0) return {} -function child(position: Position): Position { - return { depth: position.depth + 1, definition: position.definition, root: false } -} - -function pruneInvalidRefs(schema: JsonRecord, root: JsonRecord): JsonRecord { - if (typeof schema.$ref === "string" && !resolveOutputRef(schema.$ref, root)) return {} - - const result = { ...schema } - if (isPlainObject(schema.properties)) { - result.properties = Object.fromEntries( - Object.entries(schema.properties).map(([name, property]) => [ - name, - isPlainObject(property) ? pruneInvalidRefs(property, root) : {}, - ]), - ) - } - if (isPlainObject(schema.items)) result.items = pruneInvalidRefs(schema.items, root) - if (isPlainObject(schema.additionalProperties)) { - result.additionalProperties = pruneInvalidRefs(schema.additionalProperties, root) - } - if (Array.isArray(schema.anyOf)) { - const branches = schema.anyOf.filter(isPlainObject).map((branch) => pruneInvalidRefs(branch, root)) - if (branches.length === 1) return withMetadata(branches[0] ?? {}, result) - result.anyOf = branches - } - if (isPlainObject(schema.$defs)) { - result.$defs = Object.fromEntries( - Object.entries(schema.$defs).map(([name, definition]) => [ - name, - isPlainObject(definition) ? pruneInvalidRefs(definition, root) : {}, - ]), - ) - } - return result -} - -function resolveOutputRef(ref: string, root: JsonRecord): JsonRecord | undefined { - if (ref === "#") return root - if (!ref.startsWith("#/$defs/") || ref === "#/$defs/") return - const parts = ref - .slice("#/$defs/".length) - .split("/") - .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) - return parts.reduce((current, part, index) => { - const value = index === 0 ? (isPlainObject(root.$defs) ? root.$defs[part] : undefined) : current?.[part] - return isPlainObject(value) ? value : undefined - }, undefined) -} - -function ensureTermination(schema: JsonRecord) { - if (terminates(schema, schema, new Set())) return schema - if (schema.type === "object") { - const { required: _, ...result } = schema - return result - } - if (schema.type === "array") { - const { items: _, ...result } = schema - return result - } - return emptyRoot(schema) -} - -function emptyRoot(source: JsonRecord) { - return { - type: "object", - properties: {}, - ...(typeof source.$id === "string" ? { $id: source.$id } : {}), - } -} - -function terminates(schema: JsonRecord, root: JsonRecord, refs: Set): boolean { - const types = schemaTypes(schema.type) - if (types.some((type) => type !== "object" && type !== "array")) return true - - if (types.includes("array")) { - if (!isPlainObject(schema.items) || Object.keys(schema.items).length === 0) return true - if (terminates(schema.items, root, new Set(refs))) return true + const ref = canonicalRef(value.$ref, context) + if (ref) { + if (value.nullable === true) return { anyOf: [{ $ref: ref }, { type: "null" }] } + return { $ref: ref } } - if (types.includes("object")) { - const required = Array.isArray(schema.required) - ? schema.required.filter((item): item is string => typeof item === "string") - : [] - const properties = isPlainObject(schema.properties) ? schema.properties : undefined - if (required.length === 0 || !properties || Object.keys(properties).length === 0) return true - if ( - required.some((name) => { - const property = properties[name] - return isPlainObject(property) && terminates(property, root, new Set(refs)) - }) - ) { - return true + if (Array.isArray(value.anyOf) && schemaTypes(value.type).length > 0) { + return projectTypedAnyOf(value, context, depth) + } + + const result: JsonRecord = {} + let truncatedProperties = false + for (const [key, item] of Object.entries(value)) { + if (key === "$defs" || key === "definitions") continue + if (key === "$ref") continue + if (key === "items" && Array.isArray(item)) continue + if (key === "anyOf" && Array.isArray(item)) { + if (item.length <= MAX_ANY_OF) result.anyOf = item.map((branch) => project(branch, context, depth + 1)) + continue } + if (SCHEMA_LISTS.has(key) && Array.isArray(item)) { + result[key] = item.map((schema) => project(schema, context, depth + 1)) + continue + } + if (key === "properties" && isRecord(item)) { + const remaining = Math.max(0, MAX_PROPERTIES - context.properties) + const entries = Object.entries(item).slice(0, remaining) + context.properties += entries.length + truncatedProperties = entries.length !== Object.keys(item).length + result.properties = Object.fromEntries( + entries.map(([name, schema]) => [name, project(schema, context, depth + 1)]), + ) + continue + } + if (SCHEMA_MAPS.has(key) && isRecord(item)) { + result[key] = Object.fromEntries( + Object.entries(item).map(([name, schema]) => [name, project(schema, context, depth + 1)]), + ) + continue + } + if ((key === "items" || SCHEMA_NODES.has(key)) && isRecord(item)) { + result[key] = project(item, context, depth + 1) + continue + } + result[key] = item } - - if (Array.isArray(schema.anyOf)) { - if (schema.anyOf.some((branch) => isPlainObject(branch) && terminates(branch, root, new Set(refs)))) return true - } - - if (typeof schema.$ref === "string") { - if (refs.has(schema.$ref)) return false - const target = resolveOutputRef(schema.$ref, root) - if (!target) return false - const next = new Set(refs) - next.add(schema.$ref) - return terminates(target, root, next) - } - return Object.keys(schema).length === 0 + projectRequired(result, context) + if (truncatedProperties) delete result.additionalProperties + return projectEnum(result) } -function fitSchemaSize(schema: JsonRecord) { +function projectTypedAnyOf(source: JsonRecord, context: Context, depth: number) { + const base = omit(source, ["anyOf", "type", "enum", "const", "$defs", "definitions"]) + const parentTypes = schemaTypes(source.type) + const parentEnum = enumValues("const" in source ? [source.const] : source.enum) + const variants = Array.isArray(source.anyOf) ? source.anyOf : [] + if (variants.length > MAX_ANY_OF) return project(omit(source, ["anyOf"]), context, depth) + const branches = variants.filter(isRecord).flatMap((branch) => { + const types = intersectTypes(parentTypes, schemaTypes(branch.type)) + if (types.length === 0) return [] + const branchEnum = enumValues("const" in branch ? [branch.const] : branch.enum) + const values = intersectEnums(parentEnum, branchEnum).filter((value) => + types.some((type) => matchesType(value, type)), + ) + if ((parentEnum.length > 0 || branchEnum.length > 0) && values.length === 0) return [] + const merged = omit({ ...base, ...branch }, ["type", "enum", "const"]) + return [ + project( + { + ...merged, + type: types.length === 1 ? types[0] : types, + ...(values.length > 0 ? { enum: values } : {}), + }, + context, + depth + 1, + ), + ] + }) + return collapse(branches) +} + +function projectEnum(source: JsonRecord) { + const result = { ...source } + const values = enumValues(result.enum) + if (values.length === 0) { + delete result.enum + return result + } + + const types = schemaTypes(result.type) + if (types.length > 0) { + const compatible = values.filter((value) => types.some((type) => matchesType(value, type))) + if (compatible.length > 0) result.enum = compatible + else delete result.enum + return result + } + + const groups = groupEnum(values) + if (groups.length === 1) { + result.type = groups[0]?.type + result.enum = groups[0]?.values + return result + } + const base = omit(result, ["enum", "type"]) + return { + anyOf: groups.map((group) => ({ ...base, type: group.type, enum: group.values })), + } +} + +function projectRequired(schema: JsonRecord, context: Context) { + if (schema.type !== "object" || !Array.isArray(schema.required)) return + const properties = isRecord(schema.properties) ? { ...schema.properties } : {} + const required = [...new Set(schema.required.filter((item): item is string => typeof item === "string"))].filter( + (name) => { + if (Object.hasOwn(properties, name)) return true + if (context.properties >= MAX_PROPERTIES) return false + context.properties++ + properties[name] = {} + return true + }, + ) + schema.properties = properties + schema.required = required +} + +function fitSize(schema: JsonRecord) { if (schemaSize(schema) <= MAX_SCHEMA_SIZE) return schema const compact = stripAnnotations(schema) if (schemaSize(compact) <= MAX_SCHEMA_SIZE) return compact return { type: "object", properties: {} } } -function schemaDepth(schema: JsonRecord, root: JsonRecord, refs: Set): number { - const properties = isPlainObject(schema.properties) - ? Math.max( - 0, - ...Object.values(schema.properties).map((item) => - isPlainObject(item) ? 1 + schemaDepth(item, root, refs) : 0, - ), - ) - : 0 - const items = isPlainObject(schema.items) ? schemaDepth(schema.items, root, refs) : 0 - const additionalProperties = isPlainObject(schema.additionalProperties) - ? schemaDepth(schema.additionalProperties, root, refs) - : 0 - const anyOf = Array.isArray(schema.anyOf) - ? Math.max(0, ...schema.anyOf.map((item) => (isPlainObject(item) ? schemaDepth(item, root, refs) : 0))) - : 0 - const definitions = isPlainObject(schema.$defs) - ? Math.max( - 0, - ...Object.values(schema.$defs).map((item) => (isPlainObject(item) ? schemaDepth(item, root, refs) : 0)), - ) - : 0 - const ref = (() => { - if (typeof schema.$ref !== "string" || refs.has(schema.$ref)) return 0 - const target = resolveOutputRef(schema.$ref, root) - if (!target) return 0 - const next = new Set(refs) - next.add(schema.$ref) - return schemaDepth(target, root, next) - })() - return Math.max(properties, items, additionalProperties, anyOf, definitions, ref) -} - -function stripAnnotations(value: JsonRecord): JsonRecord { +function stripAnnotations(schema: JsonRecord): JsonRecord { return Object.fromEntries( - Object.entries(value).flatMap(([key, item]) => { - if (["description", "title", "default"].includes(key)) return [] - if ((key === "properties" || key === "$defs") && isPlainObject(item)) { + Object.entries(schema).flatMap(([key, value]) => { + if (["description", "title", "default", "examples", "$comment"].includes(key)) return [] + if ((key === "properties" || key === "$defs") && isRecord(value)) { return [ [ key, Object.fromEntries( - Object.entries(item).map(([name, schema]) => [ - name, - isPlainObject(schema) ? stripAnnotations(schema) : schema, - ]), + Object.entries(value).map(([name, item]) => [name, isRecord(item) ? stripAnnotations(item) : item]), ), ], ] } - if (Array.isArray(item)) { - return [[key, item.map((entry) => (isPlainObject(entry) ? stripAnnotations(entry) : entry))]] + if (Array.isArray(value)) { + return [[key, value.map((item) => (isRecord(item) ? stripAnnotations(item) : item))]] } - return [[key, isPlainObject(item) ? stripAnnotations(item) : item]] + return [[key, isRecord(value) ? stripAnnotations(value) : value]] }), ) } @@ -431,161 +236,55 @@ function schemaSize(schema: JsonRecord) { return new TextEncoder().encode(json).byteLength } -function mergeObjectUnion(branches: JsonRecord[]) { - const propertyNames = new Set( - branches.flatMap((branch) => (isPlainObject(branch.properties) ? Object.keys(branch.properties) : [])), +function schemaDepth(schema: JsonRecord, root: JsonRecord, refs: Set): number { + const properties = isRecord(schema.properties) + ? Math.max( + 0, + ...Object.values(schema.properties).map((item) => (isRecord(item) ? 1 + schemaDepth(item, root, refs) : 0)), + ) + : 0 + const nodes = [schema.items, schema.additionalProperties].flatMap((item) => + isRecord(item) ? [schemaDepth(item, root, refs)] : [], ) - const properties = Object.fromEntries( - [...propertyNames].map((name) => { - const schemas = [ - ...new Map( - branches - .flatMap((branch) => - isPlainObject(branch.properties) && isPlainObject(branch.properties[name]) - ? [branch.properties[name]] - : [], - ) - .map((schema) => [JSON.stringify(schema), schema]), - ).values(), - ] - return [name, schemas.length === 1 ? schemas[0] : { anyOf: schemas.slice(0, MAX_ANY_OF) }] - }), + const lists = [schema.anyOf, schema.oneOf, schema.allOf, schema.prefixItems].flatMap((items) => + Array.isArray(items) ? items.flatMap((item) => (isRecord(item) ? [schemaDepth(item, root, refs)] : [])) : [], ) - const first = Array.isArray(branches[0]?.required) - ? branches[0].required.filter((item): item is string => typeof item === "string") + const definitions = isRecord(schema.$defs) + ? Object.values(schema.$defs).flatMap((item) => (isRecord(item) ? [schemaDepth(item, root, refs)] : [])) : [] - const required = branches - .map( - (branch) => - new Set(Array.isArray(branch.required) ? branch.required.filter((item) => typeof item === "string") : []), - ) - .reduce((common, branch) => common.filter((name) => branch.has(name)), first) - return { - type: "object", - properties, - ...(required.length > 0 ? { required } : {}), - ...(branches.every((branch) => branch.additionalProperties === false) ? { additionalProperties: false } : {}), - } + const ref = (() => { + if (typeof schema.$ref !== "string" || refs.has(schema.$ref)) return 0 + const target = resolveOutputRef(schema.$ref, root) + if (!target) return 0 + const next = new Set(refs) + next.add(schema.$ref) + return schemaDepth(target, root, next) + })() + return Math.max(properties, ...nodes, ...lists, ...definitions, ref) } -function lowerAllOf(source: JsonRecord, context: Context) { - if (!Array.isArray(source.allOf)) return source - const base = Object.fromEntries(Object.entries(source).filter(([key]) => key !== "allOf")) - return source.allOf.filter(isPlainObject).reduce((result, branch) => { - const ref = rewriteRef(branch.$ref) - const target = ref ? resolveRef(ref, context) : undefined - const next = isPlainObject(target) ? { ...target, ...branch, $ref: undefined } : branch - return intersect(result, next) ?? merge(result, next) - }, base) +function resolveOutputRef(ref: string, root: JsonRecord): JsonRecord | undefined { + if (ref === "#") return root + const defs = isRecord(root.$defs) ? root.$defs : undefined + if (!ref.startsWith("#/$defs/") || !defs) return + return ref + .slice("#/$defs/".length) + .split("/") + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce((current, part, index) => { + const value = index === 0 ? defs[part] : current?.[part] + return isRecord(value) ? value : undefined + }, undefined) } -function intersect(left: JsonRecord, right: JsonRecord): JsonRecord | undefined { - const leftTypes = schemaTypes(left.type) - const rightTypes = schemaTypes(right.type) - const types = - leftTypes.length > 0 && rightTypes.length > 0 - ? [ - ...new Set([ - ...leftTypes.filter((type) => rightTypes.includes(type)), - ...((leftTypes.includes("integer") && rightTypes.includes("number")) || - (leftTypes.includes("number") && rightTypes.includes("integer")) - ? ["integer"] - : []), - ]), - ] - : leftTypes.length > 0 - ? leftTypes - : rightTypes - if (leftTypes.length > 0 && rightTypes.length > 0 && types.length === 0) return - - const result = merge(left, right) - const leftEnum = enumValuesOf("const" in left ? [left.const] : left.enum) - const rightEnum = enumValuesOf("const" in right ? [right.const] : right.enum) - if (leftEnum.length > 0 && rightEnum.length > 0) { - const intersection = leftEnum.filter((item) => rightEnum.some((other) => Object.is(item, other))) - if (intersection.length === 0) return - delete result.const - result.enum = intersection - } - if (types.length === 1) result.type = types[0] - if (types.length > 1) result.type = types - if (Array.isArray(left.enum) && Array.isArray(right.enum) && Array.isArray(result.enum) && result.enum.length === 0) { - return - } - - for (const [minimumKey, maximumKey] of [ - ["minimum", "maximum"], - ["minLength", "maxLength"], - ["minItems", "maxItems"], - ] as const) { - const minimums = [left[minimumKey], right[minimumKey]].filter( - (item): item is number => typeof item === "number" && Number.isFinite(item), - ) - const maximums = [left[maximumKey], right[maximumKey]].filter( - (item): item is number => typeof item === "number" && Number.isFinite(item), - ) - if (minimums.length > 0) result[minimumKey] = Math.max(...minimums) - if (maximums.length > 0) result[maximumKey] = Math.min(...maximums) - if ( - typeof result[minimumKey] === "number" && - typeof result[maximumKey] === "number" && - result[minimumKey] > result[maximumKey] - ) { - return - } - } - return result -} - -function merge(left: JsonRecord, right: JsonRecord): JsonRecord { - const result = { ...left, ...right } - if (isPlainObject(left.properties) || isPlainObject(right.properties)) { - const names = new Set([ - ...Object.keys(isPlainObject(left.properties) ? left.properties : {}), - ...Object.keys(isPlainObject(right.properties) ? right.properties : {}), - ]) - result.properties = Object.fromEntries( - [...names].map((name) => { - const a = - isPlainObject(left.properties) && isPlainObject(left.properties[name]) ? left.properties[name] : undefined - const b = - isPlainObject(right.properties) && isPlainObject(right.properties[name]) ? right.properties[name] : undefined - return [name, a && b ? (intersect(a, b) ?? b) : (b ?? a)] - }), - ) - } - if (Array.isArray(left.required) || Array.isArray(right.required)) { - result.required = [ - ...new Set([ - ...(Array.isArray(left.required) ? left.required : []), - ...(Array.isArray(right.required) ? right.required : []), - ]), - ] - } - if (left.additionalProperties === false || right.additionalProperties === false) result.additionalProperties = false - if (Array.isArray(left.enum) && Array.isArray(right.enum)) { - const rightEnum = right.enum - result.enum = left.enum.filter((item) => rightEnum.some((other) => Object.is(item, other))) - } - return result -} - -function rewriteRef(value: unknown) { +function canonicalRef(value: unknown, context: Context) { if (typeof value !== "string") return const ref = value.replace("#/definitions/", "#/$defs/") - if (ref === "#" || ref.startsWith("#/$defs/")) return ref -} - -function refExists(ref: string, context: Context) { - if (ref === "#") return true - return ref !== "#/$defs/" && isPlainObject(resolveRef(ref, context)) -} - -function refIsObject(ref: string, context: Context) { - const target = resolveRef(ref, context) - if (!isPlainObject(target)) return false - const lowered = lowerAllOf(target, context) - return lowered.type === "object" || isPlainObject(lowered.properties) + if (ref === "#") return ref + if (!ref.startsWith("#/$defs/") || ref === "#/$defs/") return + const name = ref.slice("#/$defs/".length).split("/", 1)[0]?.replaceAll("~1", "/").replaceAll("~0", "~") + if (!name || name.includes("/")) return + return isRecord(resolveRef(ref, context)) ? ref : undefined } function resolveRef(ref: string, context: Context): unknown { @@ -596,54 +295,46 @@ function resolveRef(ref: string, context: Context): unknown { .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) .reduce((current, part, index) => { if (index === 0) return context.definitions[part] - if (!isPlainObject(current)) return - return current[part] + return isRecord(current) ? current[part] : undefined }, undefined) } -function collectDefinitions(root: JsonRecord) { +function definitions(root: JsonRecord) { return { - ...(isPlainObject(root.definitions) ? root.definitions : {}), - ...(isPlainObject(root.$defs) ? root.$defs : {}), + ...(isRecord(root.definitions) ? root.definitions : {}), + ...(isRecord(root.$defs) ? root.$defs : {}), } } -function referencedDefinitions(root: JsonRecord, definitions: JsonRecord) { - const names = new Set() - const visit = (value: unknown) => { - if (Array.isArray(value)) { - value.forEach(visit) - return - } - if (!isPlainObject(value)) return - - const ref = rewriteRef(value.$ref) - if (ref?.startsWith("#/$defs/")) { - const name = ref.slice("#/$defs/".length).split("/", 1)[0]?.replaceAll("~1", "/").replaceAll("~0", "~") - if (name && !names.has(name) && isPlainObject(definitions[name])) { - names.add(name) - visit(definitions[name]) - } - } - Object.entries(value).forEach(([key, item]) => { - if (key !== "$defs" && key !== "definitions") visit(item) - }) - } - visit(root) - return Object.fromEntries([...names].map((name) => [name, definitions[name]])) -} - function schemaTypes(value: unknown) { if (typeof value === "string" && TYPES.has(value)) return [value] if (!Array.isArray(value)) return [] return [...new Set(value.filter((item): item is string => typeof item === "string" && TYPES.has(item)))] } -function enumValuesOf(value: unknown) { - if (!Array.isArray(value)) return [] +function intersectTypes(parent: string[], child: string[]) { + if (child.length === 0) return parent return [ - ...new Map(value.filter((item) => valueType(item)).map((item) => [JSON.stringify(item), item])).values(), - ].slice(0, MAX_ENUM) + ...new Set([ + ...parent.filter((type) => child.includes(type)), + ...((parent.includes("number") && child.includes("integer")) || + (parent.includes("integer") && child.includes("number")) + ? ["integer"] + : []), + ]), + ] +} + +function enumValues(value: unknown) { + if (!Array.isArray(value)) return [] + const values = unique(value.filter((item) => valueType(item) !== undefined)) + return values.length > MAX_ENUM ? [] : values +} + +function intersectEnums(parent: unknown[], child: unknown[]) { + if (parent.length === 0) return child + if (child.length === 0) return parent + return parent.filter((value) => child.some((item) => Object.is(value, item))) } function groupEnum(values: unknown[]) { @@ -665,42 +356,28 @@ function valueType(value: unknown) { if (typeof value === "number" && Number.isFinite(value)) return Number.isInteger(value) ? "integer" : "number" } -function typeMatches(value: unknown, type: string) { +function matchesType(value: unknown, type: string) { const actual = valueType(value) if (type === "number") return actual === "number" || actual === "integer" return actual === type } -function inferType(source: JsonRecord, enumGroups: { type: string; values: unknown[] }[]) { - if (["properties", "required", "additionalProperties"].some((key) => key in source)) return "object" - if (["items", "prefixItems", "minItems", "maxItems"].some((key) => key in source)) return "array" - if (["minLength", "maxLength", "format", "pattern"].some((key) => key in source)) return "string" - if (["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in source)) { - return "number" - } - return enumGroups[0]?.type ?? "string" +function collapse(branches: JsonRecord[]) { + const projected = unique(branches) + if (projected.length === 0) return {} + if (projected.length === 1) return projected[0] ?? {} + return { anyOf: projected } } -function copyRange( - result: JsonRecord, - source: JsonRecord, - minimumKey: "minItems" | "minLength" | "minimum", - maximumKey: "maxItems" | "maxLength" | "maximum", - integer: boolean, - nonNegative: boolean, -) { - const valid = (value: unknown) => - typeof value === "number" && - Number.isFinite(value) && - (!integer || Number.isInteger(value)) && - (!nonNegative || value >= 0) - const minimum = valid(source[minimumKey]) ? source[minimumKey] : undefined - const maximum = valid(source[maximumKey]) ? source[maximumKey] : undefined - if (typeof minimum === "number" && typeof maximum === "number" && minimum > maximum) return - if (minimum !== undefined) result[minimumKey] = minimum - if (maximum !== undefined) result[maximumKey] = maximum +function unique(values: T[]) { + return [...new Map(values.map((value) => [JSON.stringify(value), value])).values()] } -function isPlainObject(value: unknown): value is JsonRecord { +function omit(source: JsonRecord, keys: string[]) { + const omitted = new Set(keys) + return Object.fromEntries(Object.entries(source).filter(([key]) => !omitted.has(key))) +} + +function isRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/packages/opencode/test/provider/mfjs.test.ts b/packages/opencode/test/provider/mfjs.test.ts index e30af14d27..5f8f2af5ce 100644 --- a/packages/opencode/test/provider/mfjs.test.ts +++ b/packages/opencode/test/provider/mfjs.test.ts @@ -1,101 +1,141 @@ import { describe, expect, test } from "bun:test" import { MFJS } from "@/provider/mfjs" +function expectProjection(input: unknown, expected: unknown) { + expect(JSON.stringify(MFJS.sanitize(input))).toBe(JSON.stringify(expected)) +} + function asObject(value: unknown) { if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record throw new Error("expected object") } describe("MFJS.sanitize", () => { - test("removes siblings from references while preserving definitions", () => { + test("removes reference siblings and normalizes draft-07 definitions", () => { expect( MFJS.sanitize({ type: "object", properties: { - value: { - $ref: "#/$defs/Value", - description: "Moonshot rejects siblings after expanding a reference.", - }, + value: { $ref: "#/definitions/Value", description: "drop me" }, }, - $defs: { - Value: { type: "object", description: "The referenced description remains here." }, + definitions: { + Value: { type: "object", description: "keep me" }, }, }), ).toEqual({ type: "object", properties: { value: { $ref: "#/$defs/Value" } }, - $defs: { - Value: { type: "object", description: "The referenced description remains here." }, - }, + $defs: { Value: { type: "object", description: "keep me" } }, }) }) - test("repairs enum types that contradict their values", () => { + test("preserves nullable reference semantics", () => { expect( MFJS.sanitize({ type: "object", - properties: { - operation: { type: "object", enum: ["move", "copy"] }, - }, + properties: { value: { $ref: "#/$defs/Value", nullable: true } }, + $defs: { Value: { type: "object" } }, + }), + ).toEqual({ + type: "object", + properties: { value: { anyOf: [{ $ref: "#/$defs/Value" }, { type: "null" }] } }, + $defs: { Value: { type: "object" } }, + }) + }) + + test("keeps explicit types and removes incompatible enum values", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { operation: { type: "object", enum: ["move", "copy"] } }, required: ["operation"], }), ).toEqual({ type: "object", - properties: { - operation: { type: "string", enum: ["move", "copy"] }, - }, + properties: { operation: { type: "object" } }, required: ["operation"], }) }) - test("lowers type arrays and nullable enums to anyOf", () => { + test("removes only enum values excluded by an explicit type", () => { expect( MFJS.sanitize({ type: "object", - properties: { - operation: { type: ["string", "null"], enum: ["move", null] }, - }, + properties: { operation: { type: "string", enum: ["move", null] } }, + }), + ).toEqual({ + type: "object", + properties: { operation: { type: "string", enum: ["move"] } }, + }) + }) + + test("infers a type for homogeneous untyped enums", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { operation: { enum: ["move", "copy"] } }, + }), + ).toEqual({ + type: "object", + properties: { operation: { type: "string", enum: ["move", "copy"] } }, + }) + }) + + test("splits mixed untyped enums into homogeneous branches", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { value: { enum: ["move", 1] } }, }), ).toEqual({ type: "object", properties: { - operation: { + value: { anyOf: [ { type: "string", enum: ["move"] }, - { type: "null", enum: [null] }, + { type: "integer", enum: [1] }, ], }, }, }) }) - test("normalizes single-value type arrays", () => { + test("drops tuple items instead of narrowing positional schemas", () => { expect( MFJS.sanitize({ type: "object", - properties: { value: { type: ["null"] } }, + properties: { + values: { + type: "array", + items: [{ type: "string" }, { type: "number" }], + minItems: 2, + }, + }, }), ).toEqual({ type: "object", - properties: { value: { type: "null" } }, + properties: { values: { type: "array", minItems: 2 } }, }) }) - test("preserves scalar types when lowering nullable schemas", () => { - expect( - MFJS.sanitize({ + test("preserves prefixItems accepted by Kimi", () => { + expectProjection( + { type: "object", - properties: { value: { type: "string", nullable: true } }, - }), - ).toEqual({ - type: "object", - properties: { - value: { anyOf: [{ type: "string" }, { type: "null" }] }, + properties: { + values: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }, + }, }, - }) + { + type: "object", + properties: { + values: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }, + }, + }, + ) }) - test("intersects parent constraints into anyOf branches", () => { + test("moves parent types into compatible anyOf branches", () => { expect( MFJS.sanitize({ type: "object", @@ -113,378 +153,141 @@ describe("MFJS.sanitize", () => { }) }) - test("infers missing types and filters dangling required fields", () => { - expect( - MFJS.sanitize({ + test("preserves type arrays and nullable accepted by Kimi", () => { + expectProjection( + { + type: "object", properties: { - mode: { enum: ["fast", "safe"] }, - options: { properties: { retries: { type: "integer" } } }, - values: { items: { type: "number" } }, + typed: { type: ["string", "null"], enum: ["move", null] }, + nullable: { type: "string", nullable: true }, }, - required: ["mode", "missing"], - }), - ).toEqual({ - type: "object", - properties: { - mode: { type: "string", enum: ["fast", "safe"] }, - options: { type: "object", properties: { retries: { type: "integer" } } }, - values: { type: "array", items: { type: "number" } }, }, - required: ["mode"], - }) + { + type: "object", + properties: { + typed: { type: ["string", "null"], enum: ["move", null] }, + nullable: { type: "string", nullable: true }, + }, + }, + ) }) - test("converts oneOf and strips unsupported keywords", () => { + test("preserves oneOf, allOf, and other keywords accepted by Kimi", () => { + const value = { + oneOf: [ + { type: "string", format: "uri" }, + { type: "integer", multipleOf: 2 }, + ], + examples: ["https://example.com"], + } + const all = { + allOf: [ + { type: "object", properties: { left: { type: "string" } } }, + { type: "object", properties: { right: { type: "number" } } }, + ], + } + expectProjection( + { type: "object", properties: { value, all }, unevaluatedProperties: false }, + { type: "object", properties: { value, all }, unevaluatedProperties: false }, + ) + }) + + test("preserves const accepted by Kimi", () => { expect( MFJS.sanitize({ type: "object", - $schema: "https://json-schema.org/draft/2020-12/schema", - properties: { - value: { - oneOf: [ - { type: "string", format: "uri" }, - { type: "integer", multipleOf: 2 }, - ], - examples: ["https://example.com"], - }, - }, - unevaluatedProperties: false, + properties: { operation: { const: "move" } }, }), ).toEqual({ type: "object", - properties: { - value: { anyOf: [{ type: "string" }, { type: "integer" }] }, - }, + properties: { operation: { const: "move" } }, }) }) - test("widens heterogeneous tuples without narrowing item types", () => { + test("drops unresolved references", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { value: { $ref: "#/definitions/Missing" } }, + }), + ).toEqual({ + type: "object", + properties: { value: {} }, + }) + }) + + test("adds unconstrained schemas for dangling required properties", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: {}, + required: ["missing"], + }), + ).toEqual({ + type: "object", + properties: { missing: {} }, + required: ["missing"], + }) + }) + + test("preserves unconstrained schemas", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { empty: {}, truthy: true, falsy: false }, + }), + ).toEqual({ + type: "object", + properties: { empty: {}, truthy: {}, falsy: {} }, + }) + }) + + test("widens schemas that exceed Kimi limits", () => { + const properties = Object.fromEntries( + Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]), + ) + const limited = asObject(MFJS.sanitize({ type: "object", properties })) + expect(Object.keys(asObject(limited.properties))).toHaveLength(3000) + expect( MFJS.sanitize({ type: "object", properties: { - values: { - type: "array", - items: [{ type: "string" }, { type: "number" }], - }, - }, - }), - ).toEqual({ - type: "object", - properties: { - values: { - type: "array", - items: { anyOf: [{ type: "string" }, { type: "number" }] }, - }, - }, - }) - }) - - test("collapses homogeneous tuple items", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { - values: { - type: "array", - items: [{ type: "number" }, { type: "number" }], - minItems: 2, - maxItems: 2, - }, - }, - }), - ).toEqual({ - type: "object", - properties: { - values: { - type: "array", - items: { type: "number" }, - minItems: 2, - maxItems: 2, - }, - }, - }) - }) - - test("forces mixed root unions to an object parameter schema", () => { - expect( - MFJS.sanitize({ - anyOf: [{ type: "object", properties: { value: { type: "string" } } }, { type: "string" }], - }), - ).toEqual({ type: "object", properties: {} }) - }) - - test("flattens allOf object schemas", () => { - expect( - MFJS.sanitize({ - allOf: [ - { - type: "object", - properties: { source: { type: "string" } }, - required: ["source"], - }, - { - type: "object", - properties: { destination: { type: "string" } }, - required: ["destination"], - additionalProperties: false, - }, - ], - }), - ).toEqual({ - type: "object", - properties: { - source: { type: "string" }, - destination: { type: "string" }, - }, - required: ["source", "destination"], - additionalProperties: false, - }) - }) - - test("normalizes draft-07 definitions and references", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { - operation: { - $ref: "#/definitions/Operation", - description: "Moonshot rejects siblings next to refs.", - }, - }, - definitions: { - Operation: { enum: ["move", "copy"] }, - }, - }), - ).toEqual({ - type: "object", - properties: { operation: { $ref: "#/$defs/Operation" } }, - $defs: { - Operation: { type: "string", enum: ["move", "copy"] }, - }, - }) - }) - - test("omits unreferenced definitions", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { value: { type: "string" } }, - $defs: { - Unused: { type: "object", properties: { ignored: { type: "string" } } }, + value: { type: "string", enum: Array.from({ length: 1001 }, (_, index) => `value_${index}`) }, }, }), ).toEqual({ type: "object", properties: { value: { type: "string" } }, }) - }) - test("drops references to missing definitions", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { operation: { $ref: "#/$defs/Missing" } }, - }), - ).toEqual({ - type: "object", - properties: { operation: { type: "string" } }, - }) - }) - - test("keeps only valid MFJS ranges", () => { expect( MFJS.sanitize({ type: "object", properties: { - score: { type: "number", exclusiveMinimum: -1, exclusiveMaximum: 1 }, - count: { type: "integer", exclusiveMinimum: -1, exclusiveMaximum: 3 }, - label: { type: "string", minLength: 5, maxLength: 2 }, - list: { type: "array", minItems: -1, maxItems: 3 }, - }, - }), - ).toEqual({ - type: "object", - properties: { - score: { type: "number" }, - count: { type: "integer", minimum: 0, maximum: 2 }, - label: { type: "string" }, - list: { type: "array", maxItems: 3 }, - }, - }) - }) - - test("preserves unconstrained child schemas", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { - empty: {}, - truthy: true, - falsy: false, - }, - }), - ).toEqual({ - type: "object", - properties: { - empty: {}, - truthy: {}, - falsy: {}, - }, - }) - }) - - test("removes empty property names and references to filtered definitions", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { - "": { type: "string" }, - value: { $ref: "#/$defs/Bad~1Name" }, - }, - required: ["", "value"], - $defs: { - "Bad/Name": { type: "object" }, + value: { anyOf: Array.from({ length: 501 }, (_, index) => ({ const: `value_${index}` })) }, }, }), ).toEqual({ type: "object", properties: { value: {} }, - required: ["value"], }) - }) - test("makes required recursive roots terminable", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { node: { $ref: "#/$defs/Node" } }, - required: ["node"], - $defs: { - Node: { - type: "object", - properties: { next: { $ref: "#/$defs/Node" } }, - required: ["next"], - }, - }, - }), - ).toEqual({ - type: "object", - properties: { node: { $ref: "#/$defs/Node" } }, - $defs: { - Node: { - type: "object", - properties: { next: { $ref: "#/$defs/Node" } }, - required: ["next"], - }, - }, - }) - }) - - test("preserves nullable reference semantics", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { - value: { $ref: "#/$defs/Value", nullable: true }, - }, - $defs: { - Value: { type: "object", properties: { label: { type: "string" } } }, - }, - }), - ).toEqual({ - type: "object", - properties: { - value: { anyOf: [{ $ref: "#/$defs/Value" }, { type: "null" }] }, - }, - $defs: { - Value: { type: "object", properties: { label: { type: "string" } } }, - }, - }) - }) - - test("merges referenced object branches at the root", () => { - expect( - MFJS.sanitize({ - anyOf: [{ $ref: "#/$defs/Left" }, { $ref: "#/$defs/Right" }], - $defs: { - Left: { type: "object", properties: { left: { type: "string" } }, required: ["left"] }, - Right: { type: "object", properties: { right: { type: "number" } }, required: ["right"] }, - }, - }), - ).toEqual({ - type: "object", - properties: { - left: { type: "string" }, - right: { type: "number" }, - }, - $defs: { - Left: { type: "object", properties: { left: { type: "string" } }, required: ["left"] }, - Right: { type: "object", properties: { right: { type: "number" } }, required: ["right"] }, - }, - }) - }) - - test("combines inclusive and exclusive integer bounds", () => { - expect( - MFJS.sanitize({ - type: "object", - properties: { - value: { - type: "integer", - minimum: 0, - exclusiveMinimum: 5, - maximum: 10, - exclusiveMaximum: 8, - }, - }, - }), - ).toEqual({ - type: "object", - properties: { value: { type: "integer", minimum: 6, maximum: 7 } }, - }) - }) - - test("enforces Walle property and size limits", () => { - const properties = Object.fromEntries( - Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]), - ) - const limited = asObject(MFJS.sanitize({ type: "object", properties })) - - expect(Object.keys(asObject(limited.properties))).toHaveLength(3000) - expect( - MFJS.sanitize({ - type: "object", - description: "<".repeat(20_000), - properties: { - description: { type: "string" }, - default: { type: "string" }, - }, - }), - ).toEqual({ - type: "object", - properties: { - description: { type: "string" }, - default: { type: "string" }, - }, - }) - }) - - test("enforces Walle depth limits", () => { const deep = Array.from({ length: 35 }).reduce>( (schema) => ({ type: "object", properties: { next: schema }, required: ["next"] }), { type: "string" }, ) - expect(JSON.stringify(MFJS.sanitize(deep)).match(/properties/g)?.length).toBe(30) - }) - test("enforces depth limits across references", () => { + expect(MFJS.sanitize({ type: "object", description: "<".repeat(20_000), properties: {} })).toEqual({ + type: "object", + properties: {}, + }) + const definition = Array.from({ length: 30 }).reduce>( (schema) => ({ type: "object", properties: { next: schema } }), { type: "string" }, ) - expect( MFJS.sanitize({ type: "object", @@ -498,14 +301,10 @@ describe("MFJS.sanitize", () => { const once = MFJS.sanitize({ type: "object", properties: { - operation: { type: ["string", "null"], enum: ["move", null] }, + operation: { type: "object", enum: ["move"] }, values: { type: "array", items: [{ type: "string" }, { type: "number" }] }, }, - definitions: { - Metadata: { type: "object", properties: { label: { type: "string", format: "uri" } } }, - }, }) - expect(MFJS.sanitize(once)).toEqual(once) }) }) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c02798455b..dc7e503cd0 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1546,7 +1546,7 @@ describe("ProviderTransform.schema - MFJS selection", () => { ).toEqual({ type: "object", properties: { - operation: { type: "string", enum: ["move", "copy"] }, + operation: { type: "object" }, }, }) })