mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-01 19:05:38 +00:00
fix(tool): tolerate plugin tool defs with missing args (#28357)
This commit is contained in:
@@ -145,9 +145,12 @@ export const layer: Layer.Layer<
|
|||||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
|
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
|
||||||
// Plugin tools still expose Zod args publicly; keep that compatibility
|
// Plugin tools still expose Zod args publicly; keep that compatibility
|
||||||
// boxed at the registry boundary and give the LLM the original JSON Schema.
|
// boxed at the registry boundary and give the LLM the original JSON Schema.
|
||||||
const entries = Object.entries(def.args)
|
// Normalize missing args to `{}` once — pre-1.14.49 the code was
|
||||||
|
// `z.object(def.args)` and Zod silently tolerated undefined (#27451, #27630).
|
||||||
|
const args = def.args ?? {}
|
||||||
|
const entries = Object.entries(args)
|
||||||
const allZod = entries.every((entry) => isZodType(entry[1]))
|
const allZod = entries.every((entry) => isZodType(entry[1]))
|
||||||
const zodParams = allZod ? z.object(def.args) : undefined
|
const zodParams = allZod ? z.object(args) : undefined
|
||||||
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
|
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
|
||||||
const parameters = zodParams
|
const parameters = zodParams
|
||||||
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
|
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
|
||||||
|
|||||||
@@ -40,11 +40,16 @@ const configLayer = TestConfig.layer({
|
|||||||
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
|
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
|
||||||
})
|
})
|
||||||
|
|
||||||
const registryLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
type RegistryLayerOptions = {
|
||||||
|
flags?: Partial<RuntimeFlags.Info>
|
||||||
|
plugin?: Layer.Layer<Plugin.Service>
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryLayer = (opts: RegistryLayerOptions = {}) =>
|
||||||
ToolRegistry.layer
|
ToolRegistry.layer
|
||||||
.pipe(
|
.pipe(
|
||||||
Layer.provide(configLayer),
|
Layer.provide(configLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(opts.plugin ?? Plugin.defaultLayer),
|
||||||
Layer.provide(Question.defaultLayer),
|
Layer.provide(Question.defaultLayer),
|
||||||
Layer.provide(Todo.defaultLayer),
|
Layer.provide(Todo.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
@@ -64,12 +69,38 @@ const registryLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
)
|
)
|
||||||
.pipe(Layer.provide(RuntimeFlags.layer(flags)))
|
.pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))
|
||||||
|
|
||||||
|
// Fake Plugin.Service that returns a single plugin whose `tool` map contains
|
||||||
|
// one definition with `args: undefined`. Used to exercise the plugin entry
|
||||||
|
// point of `fromPlugin` for the #27451 / #27630 regression.
|
||||||
|
const brokenPluginLayer = Layer.succeed(
|
||||||
|
Plugin.Service,
|
||||||
|
Plugin.Service.of({
|
||||||
|
init: () => Effect.void,
|
||||||
|
trigger: ((_name: unknown, _input: unknown, output: unknown) => Effect.succeed(output)) as Plugin.Interface["trigger"],
|
||||||
|
list: () =>
|
||||||
|
Effect.succeed([
|
||||||
|
{
|
||||||
|
tool: {
|
||||||
|
broken_plugin_tool: {
|
||||||
|
description: "plugin tool with missing args",
|
||||||
|
args: undefined as unknown as Record<string, never>,
|
||||||
|
execute: async () => "ok",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
|
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
|
||||||
const scout = testEffect(Layer.mergeAll(registryLayer({ experimentalScout: true }), node, Agent.defaultLayer))
|
const scout = testEffect(Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer))
|
||||||
const background = testEffect(
|
const background = testEffect(
|
||||||
Layer.mergeAll(registryLayer({ experimentalBackgroundSubagents: true }), node, Agent.defaultLayer),
|
Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer),
|
||||||
|
)
|
||||||
|
const withBrokenPlugin = testEffect(
|
||||||
|
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -186,6 +217,57 @@ describe("tool.registry", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Regression for #27451 / #27630: a custom tool that omits `args` must not
|
||||||
|
// crash registry initialization with
|
||||||
|
// `Object.entries requires that input parameter not be null or undefined`.
|
||||||
|
// Pre-1.14.49 the code path was `z.object(def.args)`, and `z.object(undefined)`
|
||||||
|
// silently produced an empty schema — so the tool registered as no-args.
|
||||||
|
// Preserve that tolerance.
|
||||||
|
it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const tool = path.join(test.directory, ".opencode", "tool")
|
||||||
|
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Bun.write(
|
||||||
|
path.join(tool, "noargs.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
" description: 'tool with no args',",
|
||||||
|
" args: undefined,",
|
||||||
|
" execute: async () => 'ok',",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const ids = yield* registry.ids()
|
||||||
|
// Built-in tools must still load — a single malformed custom tool must
|
||||||
|
// not poison the whole registry.
|
||||||
|
expect(ids).toContain("read")
|
||||||
|
const loaded = (yield* registry.all()).find((t) => t.id === "noargs")
|
||||||
|
if (!loaded) throw new Error("noargs tool was not loaded")
|
||||||
|
expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Same regression, plugin entry point. The original reports (#27451, #27630)
|
||||||
|
// came in through `plugin.list()` — `oh-my-opencode` was registering a tool
|
||||||
|
// with `args: undefined` and crashing every message submit. The file-scan
|
||||||
|
// and plugin-list loops both funnel through `fromPlugin`, but covering both
|
||||||
|
// entry points means a future refactor that splits them won't silently lose
|
||||||
|
// protection.
|
||||||
|
withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const ids = yield* registry.ids()
|
||||||
|
expect(ids).toContain("read")
|
||||||
|
expect(ids).toContain("broken_plugin_tool")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.instance("loads tools from .opencode/tools (plural)", () =>
|
it.instance("loads tools from .opencode/tools (plural)", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestInstance
|
const test = yield* TestInstance
|
||||||
|
|||||||
Reference in New Issue
Block a user