mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 02:16:23 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
740ed94bd5 | ||
|
|
48404b8c2c | ||
|
|
a5455378e3 | ||
|
|
a4990731cc | ||
|
|
7d6c95d42a | ||
|
|
2bc3f735ba | ||
|
|
c9ea0215ec | ||
|
|
3dee00df23 | ||
|
|
e18730b013 | ||
|
|
d3a2132232 | ||
|
|
4a081f7e00 | ||
|
|
bf292b7367 | ||
|
|
1a4e772df5 | ||
|
|
7368f3d5d5 |
@@ -88,6 +88,7 @@ import { ProviderPlugins } from "./provider.js"
|
||||
import { WebSearchPlugins } from "./websearch/index.js"
|
||||
import { SkillPlugin } from "./skill.js"
|
||||
import { VcsHgPlugin } from "./vcs/hg.js"
|
||||
import { ToolInputRepairPlugin } from "./tool-input-repair.js"
|
||||
import { OptimizePlugin } from "./optimize.js"
|
||||
import { VariantPlugin } from "./variant.js"
|
||||
import { VcsGitPlugin } from "./vcs/git.js"
|
||||
@@ -193,6 +194,7 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ToolInputRepairPlugin.Plugin,
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
|
||||
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { ToolEditor } from "@opencode/plugin/effect/tool"
|
||||
import { CodeMode } from "@opencode/codemode"
|
||||
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
|
||||
import { definition } from "../tool/runtime.js"
|
||||
|
||||
// Repairs apply only when the input schema unambiguously supports them:
|
||||
// - Stringified root or nested object: '{"limit":"20"}' -> { limit: 20 }
|
||||
// - Closed object: { limit: "20", extra: true } -> { limit: 20 }
|
||||
// - Optional null or empty-object placeholder: { limit: null } -> {}
|
||||
// - Numeric or boolean string: { limit: "20", enabled: "false" } -> { limit: 20, enabled: false }
|
||||
// - Nullable field: { count: "2" } -> { count: 2 }
|
||||
// - Stringified array or compatible item: { tags: '["a"]', count: "2" } -> { tags: ["a"], count: [2] }
|
||||
// - Positional tuple: { pair: ["2", "false"] } -> { pair: [2, false] }
|
||||
// - Typed dictionary: { counts: { first: "2" } } -> { counts: { first: 2 } }
|
||||
// - Nested fields and local references: { items: [{ count: "2" }] } -> { items: [{ count: 2 }] }
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const maxDepth = 6
|
||||
const executeSchema = Schema.toJsonSchemaDocument(CodeMode.Input).schema
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.tool.input.repair",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
let get: ToolEditor["get"] = () => undefined
|
||||
yield* ctx.tool.transform((draft) => {
|
||||
// The draft sees later tool transforms too; reload replaces this lookup.
|
||||
get = draft.get
|
||||
})
|
||||
yield* ctx.tool.hook("execute.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
const tool = get(event.tool)
|
||||
// The outer Code Mode tool is synthesized by snapshots, not registered in the draft.
|
||||
const schema = tool ? definition(tool).inputSchema : event.tool === "execute" ? executeSchema : undefined
|
||||
if (schema?.type !== "object") return
|
||||
event.input = repair(event.input, schema, schema, 0)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
|
||||
if (depth > maxDepth) return value
|
||||
|
||||
if (typeof schema.$ref === "string") {
|
||||
const definitions = /^#\/\$defs\/[^/]+$/.test(schema.$ref)
|
||||
? root.$defs
|
||||
: /^#\/definitions\/[^/]+$/.test(schema.$ref)
|
||||
? root.definitions
|
||||
: undefined
|
||||
if (!Predicate.isObject(definitions)) return value
|
||||
const target = Object.fromEntries(
|
||||
Object.entries(definitions).filter((entry): entry is [string, JsonSchema.JsonSchema] =>
|
||||
Predicate.isObject(entry[1]),
|
||||
),
|
||||
)[
|
||||
schema.$ref
|
||||
.slice(schema.$ref.lastIndexOf("/") + 1)
|
||||
.replaceAll("~1", "/")
|
||||
.replaceAll("~0", "~")
|
||||
]
|
||||
return target ? repair(value, target, root, depth + 1) : value
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.type)) {
|
||||
if (value === null && schema.type.includes("null")) return value
|
||||
if (schema.type.includes(typeof value)) return value
|
||||
const types = schema.type.filter((type) => type !== "null")
|
||||
return types.length === 1 ? repair(value, { ...schema, type: types[0] }, root, depth + 1) : value
|
||||
}
|
||||
|
||||
if (schema.type === undefined) {
|
||||
if (Array.isArray(schema.anyOf) && Array.isArray(schema.oneOf)) return value
|
||||
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : schema.oneOf
|
||||
if (!Array.isArray(branches) || value === null) return value
|
||||
if (branches.some((branch) => !Predicate.isObject(branch) || branch.type === typeof value)) return value
|
||||
const candidates = branches.filter((branch) => Predicate.isObject(branch) && branch.type !== "null")
|
||||
return candidates.length === 1 ? repair(value, candidates[0], root, depth + 1) : value
|
||||
}
|
||||
|
||||
if (schema.type === "number" || schema.type === "integer") {
|
||||
if (typeof value !== "string" || value.trim() === "") return value
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && (schema.type !== "integer" || Number.isSafeInteger(parsed)) ? parsed : value
|
||||
}
|
||||
if (schema.type === "boolean") return value === "true" ? true : value === "false" ? false : value
|
||||
if (schema.type === "object") return repairObject(value, schema, root, depth)
|
||||
if (schema.type === "array") return repairArray(value, schema, root, depth)
|
||||
return value
|
||||
}
|
||||
|
||||
function repairObject(
|
||||
value: unknown,
|
||||
schema: JsonSchema.JsonSchema,
|
||||
root: JsonSchema.JsonSchema,
|
||||
depth: number,
|
||||
): unknown {
|
||||
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
|
||||
if (!Predicate.isObject(parsed)) return value
|
||||
|
||||
const properties = Predicate.isObject(schema.properties) ? schema.properties : {}
|
||||
const required = Array.isArray(schema.required) ? schema.required : []
|
||||
const patterned = Predicate.isObject(schema.patternProperties)
|
||||
const composed = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)
|
||||
|
||||
return Object.keys(parsed).reduce<Record<string, unknown>>((result, key) => {
|
||||
const current = result[key]
|
||||
const declared = Object.hasOwn(properties, key)
|
||||
const property = declared ? properties[key] : !patterned ? schema.additionalProperties : undefined
|
||||
|
||||
if (!declared && schema.additionalProperties === false && !patterned && !composed) {
|
||||
const next = { ...result }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
if (!Predicate.isObject(property)) return result
|
||||
|
||||
if (declared && !required.includes(key)) {
|
||||
const branches = Array.isArray(property.anyOf)
|
||||
? property.anyOf
|
||||
: Array.isArray(property.oneOf)
|
||||
? property.oneOf
|
||||
: []
|
||||
const nullable =
|
||||
property.nullable === true ||
|
||||
property.type === "null" ||
|
||||
(Array.isArray(property.type) && property.type.includes("null")) ||
|
||||
branches.some(
|
||||
(branch) =>
|
||||
branch === true ||
|
||||
(Predicate.isObject(branch) &&
|
||||
(branch.type === "null" || branch.const === null || typeof branch.$ref === "string")),
|
||||
)
|
||||
const placeholder =
|
||||
Predicate.isObject(current) &&
|
||||
Object.keys(current).length === 0 &&
|
||||
typeof property.type === "string" &&
|
||||
property.type !== "object" &&
|
||||
branches.length === 0
|
||||
if ((current === null && !nullable && (property.type !== undefined || branches.length > 0)) || placeholder) {
|
||||
const next = { ...result }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
const repaired = repair(current, property, root, depth + 1)
|
||||
return repaired === current ? result : { ...result, [key]: repaired }
|
||||
}, parsed)
|
||||
}
|
||||
|
||||
function repairArray(
|
||||
value: unknown,
|
||||
schema: JsonSchema.JsonSchema,
|
||||
root: JsonSchema.JsonSchema,
|
||||
depth: number,
|
||||
): unknown {
|
||||
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
|
||||
const tuple = Array.isArray(schema.prefixItems)
|
||||
? schema.prefixItems
|
||||
: Array.isArray(schema.items)
|
||||
? schema.items
|
||||
: undefined
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
const repaired = parsed.map((item, index) => {
|
||||
const member = tuple
|
||||
? (tuple[index] ?? (Array.isArray(schema.prefixItems) ? schema.items : schema.additionalItems))
|
||||
: schema.items
|
||||
return Predicate.isObject(member) ? repair(item, member, root, depth + 1) : item
|
||||
})
|
||||
return repaired.every((item, index) => item === parsed[index]) ? parsed : repaired
|
||||
}
|
||||
|
||||
if (tuple || !Predicate.isObject(schema.items)) return value
|
||||
const repaired = repair(value, schema.items, root, depth + 1)
|
||||
const type = schema.items.type
|
||||
const compatible =
|
||||
type === "object"
|
||||
? Predicate.isObject(repaired)
|
||||
: type === "array"
|
||||
? Array.isArray(repaired)
|
||||
: type === "integer"
|
||||
? typeof repaired === "number" && Number.isSafeInteger(repaired)
|
||||
: type === "number"
|
||||
? typeof repaired === "number" && Number.isFinite(repaired)
|
||||
: (type === "string" || type === "boolean") && typeof repaired === type
|
||||
return compatible ? [repaired] : value
|
||||
}
|
||||
@@ -702,6 +702,34 @@ it.effect("does not treat SSE comment heartbeats as model progress", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves valid stringified object tool arguments for execution hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const input = '{"count":"2"}'
|
||||
const raw = JSON.stringify(input)
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = {
|
||||
languageModel: () =>
|
||||
streamModel([
|
||||
{ type: "tool-input-start", id: "call_1", toolName: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", delta: raw },
|
||||
{ type: "tool-input-end", id: "call_1" },
|
||||
{ type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: raw },
|
||||
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage },
|
||||
]),
|
||||
}
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Lookup" })).pipe(
|
||||
Effect.provide(client),
|
||||
)
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ id: "call_1", name: "lookup", input })
|
||||
expect(response.events.some(LLMEvent.is.toolInputError)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const identity = {
|
||||
sessionID: Session.ID.make("ses_repair"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_repair"),
|
||||
}
|
||||
|
||||
it.effect("repairs tool input before validating its original schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
yield* plugins.activate([
|
||||
{ ...ToolInputRepairPlugin.Plugin, revision: "1" },
|
||||
{
|
||||
id: "repairable-tool",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.tool.transform((draft) =>
|
||||
draft.add({
|
||||
name: "repairable",
|
||||
options: { codemode: false },
|
||||
description: "Repairable",
|
||||
input: Schema.Struct({ count: Schema.Int, enabled: Schema.Boolean }),
|
||||
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
|
||||
}),
|
||||
),
|
||||
},
|
||||
])
|
||||
const snapshot = yield* registry.snapshot()
|
||||
yield* snapshot.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-repair",
|
||||
name: "repairable",
|
||||
input: '{"count":"2","enabled":"true","extra":true}',
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ count: 2, enabled: true }])
|
||||
|
||||
yield* registry.transform((draft) => {
|
||||
draft.update("repairable", (tool) => {
|
||||
tool.input = Schema.Struct({ count: Schema.Boolean, enabled: Schema.Boolean })
|
||||
})
|
||||
})
|
||||
const updated = yield* registry.snapshot()
|
||||
yield* updated.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-updated",
|
||||
name: "repairable",
|
||||
input: { count: "false", enabled: "true" },
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([
|
||||
{ count: 2, enabled: true },
|
||||
{ count: false, enabled: true },
|
||||
])
|
||||
|
||||
yield* registry.transform((draft) => draft.remove("repairable"))
|
||||
const removed = yield* registry.snapshot()
|
||||
expect(
|
||||
(yield* removed
|
||||
.execute({
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-removed", name: "repairable", input: {} },
|
||||
})
|
||||
.pipe(Effect.flip)).message,
|
||||
).toBe("Unknown tool: repairable")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs outer Code Mode input and looks up namespaced inner tool schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
yield* plugins.activate([{ ...ToolInputRepairPlugin.Plugin, revision: "1" }])
|
||||
yield* registry.transform((draft) =>
|
||||
draft.add({
|
||||
name: "count",
|
||||
options: { namespace: "example" },
|
||||
description: "Record a count",
|
||||
input: Schema.Struct({ count: Schema.Int }),
|
||||
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
|
||||
}),
|
||||
)
|
||||
|
||||
const snapshot = yield* registry.snapshot()
|
||||
yield* snapshot.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-codemode-repair",
|
||||
name: "execute",
|
||||
input: JSON.stringify({ code: 'return await tools.example.count({ count: "3" })' }),
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ count: 3 }])
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,439 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import type { ToolHooks } from "@opencode/plugin/effect/tool"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, type JsonSchema } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
|
||||
const event: ToolHooks["execute.before"] = {
|
||||
tool: "test",
|
||||
input,
|
||||
sessionID: Session.ID.make("ses_repair"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_repair"),
|
||||
id: Tool.CallID.make("call_repair"),
|
||||
}
|
||||
const events: ToolHooks = {
|
||||
"execute.before": event,
|
||||
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
|
||||
}
|
||||
const base = host()
|
||||
return ToolInputRepairPlugin.Plugin.effect(
|
||||
host({
|
||||
tool: {
|
||||
...base.tool,
|
||||
transform: (callback) =>
|
||||
Effect.sync(() => {
|
||||
const tool = {
|
||||
id: "test",
|
||||
name: "test",
|
||||
description: "Test repair",
|
||||
input: inputSchema,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
callback({
|
||||
list: () => [tool],
|
||||
get: (id) => (id === tool.id ? tool : undefined),
|
||||
add: () => {},
|
||||
namespace: () => {},
|
||||
update: () => {},
|
||||
remove: () => {},
|
||||
})
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
},
|
||||
}),
|
||||
).pipe(Effect.as(event))
|
||||
}
|
||||
|
||||
const object = (properties: Record<string, unknown>, required?: string[]) => ({
|
||||
type: "object" as const,
|
||||
properties,
|
||||
...(required ? { required } : {}),
|
||||
})
|
||||
|
||||
describe("tool input repair plugin", () => {
|
||||
it.effect("preserves valid input identity, nested containers, and unknown properties", () =>
|
||||
Effect.gen(function* () {
|
||||
const nested = { enabled: true }
|
||||
const items = [2, 3]
|
||||
const input = { count: 2, nested, items, extra: "keep" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
count: { type: "integer" },
|
||||
nested: object({ enabled: { type: "boolean" } }),
|
||||
items: { type: "array", items: { type: "number" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((event.input as typeof input).nested).toBe(nested)
|
||||
expect((event.input as typeof input).items).toBe(items)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses root objects and repairs nested stringified containers", () =>
|
||||
Effect.gen(function* () {
|
||||
const schema = object({
|
||||
count: { type: "integer" },
|
||||
item: object({ enabled: { type: "boolean" } }),
|
||||
list: { type: "array", items: { type: "integer" } },
|
||||
})
|
||||
|
||||
expect(
|
||||
(yield* run('{"count":"2","item":"{\\"enabled\\":\\"false\\"}","list":"[\\"3\\"]"}', schema)).input,
|
||||
).toEqual({
|
||||
count: 2,
|
||||
item: { enabled: false },
|
||||
list: [3],
|
||||
})
|
||||
expect((yield* run("{broken", schema)).input).toBe("{broken")
|
||||
expect((yield* run("[]", schema)).input).toBe("[]")
|
||||
expect((yield* run(null, schema)).input).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes extras only from explicitly closed objects without mutating inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
known: "2",
|
||||
extra: true,
|
||||
closed: { keep: "3", extra: true },
|
||||
open: { keep: "4", extra: true },
|
||||
items: [{ keep: "5", extra: true }],
|
||||
}
|
||||
const event = yield* run(input, {
|
||||
...object({
|
||||
known: { type: "integer" },
|
||||
closed: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
|
||||
open: object({ keep: { type: "integer" } }),
|
||||
items: {
|
||||
type: "array",
|
||||
items: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
|
||||
},
|
||||
}),
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect(event.input).toEqual({
|
||||
known: 2,
|
||||
closed: { keep: 3 },
|
||||
open: { keep: 4, extra: true },
|
||||
items: [{ keep: 5 }],
|
||||
})
|
||||
expect(input.extra).toBeTrue()
|
||||
expect(input.closed.extra).toBeTrue()
|
||||
expect(input.items[0]?.extra).toBeTrue()
|
||||
expect((yield* run({ extra: true }, { ...object({}), additionalProperties: false })).input).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unknown keys when patterned ownership cannot be determined", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { known: 1, match: "2", extra: true }
|
||||
const event = yield* run(input, {
|
||||
...object({ known: { type: "integer" } }),
|
||||
additionalProperties: false,
|
||||
patternProperties: { "^match$": { type: "integer" } },
|
||||
})
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((event.input as typeof input).match).toBe("2")
|
||||
expect((event.input as typeof input).extra).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves properties that may belong to composed object schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { name: "example", extra: true }
|
||||
|
||||
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
||||
const event = yield* run(input, {
|
||||
type: "object",
|
||||
[keyword]: [object({ name: { type: "string" } })],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes only optional nonnullable nulls and non-object empty placeholders", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
optional: null,
|
||||
required: null,
|
||||
nullable: null,
|
||||
union: null,
|
||||
constant: null,
|
||||
permissive: null,
|
||||
referenced: null,
|
||||
placeholder: {},
|
||||
array: {},
|
||||
requiredPlaceholder: {},
|
||||
object: {},
|
||||
unknown: null,
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object(
|
||||
{
|
||||
optional: { type: "string" },
|
||||
required: { type: "string" },
|
||||
nullable: { type: "string", nullable: true },
|
||||
union: { anyOf: [{ type: "integer" }, { type: "null" }] },
|
||||
constant: { anyOf: [{ type: "integer" }, { const: null }] },
|
||||
permissive: { anyOf: [{ type: "integer" }, true] },
|
||||
referenced: { anyOf: [{ type: "integer" }, { $ref: "#/$defs/nullable" }] },
|
||||
placeholder: { type: "integer" },
|
||||
array: { type: "array", items: { type: "string" } },
|
||||
requiredPlaceholder: { type: "boolean" },
|
||||
object: { type: "object" },
|
||||
unknown: {},
|
||||
},
|
||||
["required", "requiredPlaceholder"],
|
||||
),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
required: null,
|
||||
nullable: null,
|
||||
union: null,
|
||||
constant: null,
|
||||
permissive: null,
|
||||
referenced: null,
|
||||
requiredPlaceholder: {},
|
||||
object: {},
|
||||
unknown: null,
|
||||
})
|
||||
expect(input.optional).toBeNull()
|
||||
expect(input.placeholder).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coerces numeric and boolean strings while preserving invalid and existing values", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
number: "1.5",
|
||||
integer: "42",
|
||||
enabled: "true",
|
||||
disabled: "false",
|
||||
valid: 3,
|
||||
empty: " ",
|
||||
infinite: "Infinity",
|
||||
fractional: "1.5",
|
||||
unsafe: "9007199254740992",
|
||||
uppercase: "TRUE",
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
number: { type: "number" },
|
||||
integer: { type: "integer" },
|
||||
enabled: { type: "boolean" },
|
||||
disabled: { type: "boolean" },
|
||||
valid: { type: "integer" },
|
||||
empty: { type: "number" },
|
||||
infinite: { type: "number" },
|
||||
fractional: { type: "integer" },
|
||||
unsafe: { type: "integer" },
|
||||
uppercase: { type: "boolean" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ ...input, number: 1.5, integer: 42, enabled: true, disabled: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("wraps compatible scalars after repairing array items", () =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* run(
|
||||
{
|
||||
text: "one",
|
||||
integer: "42",
|
||||
boolean: "false",
|
||||
item: '{"count":"2"}',
|
||||
incompatible: 2,
|
||||
fractional: 1.5,
|
||||
unconstrained: "4",
|
||||
},
|
||||
object({
|
||||
text: { type: "array", items: { type: "string" } },
|
||||
integer: { type: "array", items: { type: "integer" } },
|
||||
boolean: { type: "array", items: { type: "boolean" } },
|
||||
item: { type: "array", items: object({ count: { type: "integer" } }) },
|
||||
incompatible: { type: "array", items: { type: "string" } },
|
||||
fractional: { type: "array", items: { type: "integer" } },
|
||||
unconstrained: { type: "array" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
text: ["one"],
|
||||
integer: [42],
|
||||
boolean: [false],
|
||||
item: [{ count: 2 }],
|
||||
incompatible: 2,
|
||||
fractional: 1.5,
|
||||
unconstrained: "4",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs nested question-like inputs without mutating original containers", () =>
|
||||
Effect.gen(function* () {
|
||||
const question = { question: "Pick one", multiple: "false", options: { label: "First", description: null } }
|
||||
const input = { questions: [question] }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
questions: {
|
||||
type: "array",
|
||||
items: object({
|
||||
question: { type: "string" },
|
||||
multiple: { type: "boolean" },
|
||||
options: {
|
||||
type: "array",
|
||||
items: object({ label: { type: "string" }, description: { type: "string" } }, ["label"]),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
questions: [{ question: "Pick one", multiple: false, options: [{ label: "First" }] }],
|
||||
})
|
||||
expect(input).toEqual({ questions: [question] })
|
||||
expect(question.options.description).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs unique nullable alternatives while preserving accepted union values", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
number: "2",
|
||||
boolean: "false",
|
||||
nullable: null,
|
||||
typed: "3",
|
||||
typedBoolean: "true",
|
||||
accepted: "4",
|
||||
valid: 5,
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
number: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
boolean: { oneOf: [{ type: "boolean" }, { type: "null" }] },
|
||||
nullable: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
typed: { type: ["integer", "null"] },
|
||||
typedBoolean: { type: ["boolean", "null"] },
|
||||
accepted: { anyOf: [{ type: "string" }, { type: "number" }] },
|
||||
valid: { type: ["number", "null"] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ ...input, number: 2, boolean: false, typed: 3, typedBoolean: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs tuple positions and rest items while preserving valid array identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const valid = [2, false]
|
||||
const input = { prefix: ["2", "false", "3"], draft: '["4","true"]', valid, scalar: "5" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
prefix: {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "integer" }, { type: "boolean" }],
|
||||
items: { type: "number" },
|
||||
},
|
||||
draft: { type: "array", items: [{ type: "integer" }, { type: "boolean" }] },
|
||||
valid: { type: "array", prefixItems: [{ type: "integer" }, { type: "boolean" }] },
|
||||
scalar: { type: "array", prefixItems: [{ type: "integer" }] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ prefix: [2, false, 3], draft: [4, true], valid, scalar: "5" })
|
||||
expect((event.input as typeof input).valid).toBe(valid)
|
||||
expect(input.prefix).toEqual(["2", "false", "3"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs typed dictionaries and straightforward local references", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
modern: "2",
|
||||
legacy: "false",
|
||||
nested: { count: "3" },
|
||||
dictionary: { first: "4" },
|
||||
missing: "5",
|
||||
pointer: "6",
|
||||
escaped: "7",
|
||||
}
|
||||
const event = yield* run(input, {
|
||||
...object({
|
||||
modern: { $ref: "#/$defs/integer" },
|
||||
legacy: { $ref: "#/definitions/boolean" },
|
||||
nested: { $ref: "#/$defs/nested" },
|
||||
dictionary: { type: "object", additionalProperties: { $ref: "#/$defs/integer" } },
|
||||
missing: { $ref: "#/$defs/missing" },
|
||||
pointer: { $ref: "#/$defs/nested/properties/count" },
|
||||
escaped: { $ref: "#/$defs/a~1b~0c" },
|
||||
}),
|
||||
$defs: {
|
||||
integer: { type: "integer" },
|
||||
"a/b~c": { type: "integer" },
|
||||
nested: object({ count: { $ref: "#/$defs/integer" } }),
|
||||
},
|
||||
definitions: { boolean: { type: "boolean" } },
|
||||
})
|
||||
|
||||
expect(event.input).toEqual({
|
||||
modern: 2,
|
||||
legacy: false,
|
||||
nested: { count: 3 },
|
||||
dictionary: { first: 4 },
|
||||
missing: "5",
|
||||
pointer: "6",
|
||||
escaped: 7,
|
||||
})
|
||||
expect(input.nested.count).toBe("3")
|
||||
expect(input.dictionary.first).toBe("4")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves ambiguous unions, compositions, and unsupported roots unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { numeric: "2", objects: { value: "3" }, both: "4", composed: "5", unknown: "6" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
numeric: { anyOf: [{ type: "number" }, { type: "integer" }] },
|
||||
objects: {
|
||||
oneOf: [
|
||||
object({ value: { type: "integer" } }, ["value"]),
|
||||
object({ value: { type: "number" } }, ["value"]),
|
||||
],
|
||||
},
|
||||
both: { anyOf: [{ type: "integer" }], oneOf: [{ type: "integer" }] },
|
||||
composed: { allOf: [{ type: "integer" }] },
|
||||
unknown: {},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((yield* run(input, { properties: { numeric: { type: "integer" } } })).input).toBe(input)
|
||||
expect((yield* run(input, { allOf: [object({ numeric: { type: "integer" } })] })).input).toBe(input)
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user