mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
0f9504d6c5
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
456 lines
15 KiB
TypeScript
456 lines
15 KiB
TypeScript
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 tool = {
|
|
id: "test",
|
|
name: "test",
|
|
description: "Test repair",
|
|
input: inputSchema,
|
|
execute: () => Effect.succeed({ content: "unused" }),
|
|
}
|
|
const base = host()
|
|
return ToolInputRepairPlugin.Plugin.effect(
|
|
host({
|
|
tool: {
|
|
...base.tool,
|
|
list: () => Effect.succeed([tool]),
|
|
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("preserves nulls whose validity is hidden inside compositions", () =>
|
|
Effect.gen(function* () {
|
|
const input = { wrapped: null, enumerated: null, permissive: null, constant: null }
|
|
const event = yield* run(
|
|
input,
|
|
object({
|
|
wrapped: { anyOf: [{ type: ["string", "null"] }] },
|
|
enumerated: { anyOf: [{ enum: [null, "keep"] }] },
|
|
permissive: { anyOf: [{}] },
|
|
constant: { type: "string", const: "keep" },
|
|
}),
|
|
)
|
|
expect(event.input).toBe(input)
|
|
}),
|
|
)
|
|
|
|
it.effect("preserves optional-looking nulls when the parent composes requirements", () =>
|
|
Effect.gen(function* () {
|
|
const input = { value: null }
|
|
const event = yield* run(input, {
|
|
...object({ value: { type: "string" } }),
|
|
allOf: [{ required: ["value"] }],
|
|
})
|
|
expect(event.input).toBe(input)
|
|
}),
|
|
)
|
|
|
|
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)
|
|
}),
|
|
)
|
|
})
|