test(opencode): migrate tool registry fixtures (#29042)

This commit is contained in:
Kit Langton 2026-05-24 11:15:39 -04:00 committed by GitHub
commit 84eff93bfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 185 additions and 237 deletions

View file

@ -55,6 +55,7 @@ import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job" import { BackgroundJob } from "@/background/job"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session/status"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
const log = Log.create({ service: "tool.registry" }) const log = Log.create({ service: "tool.registry" })
@ -81,6 +82,8 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const use = serviceUse(Service)
export const layer: Layer.Layer< export const layer: Layer.Layer<
Service, Service,
never, never,

View file

@ -95,15 +95,25 @@ const brokenPluginLayer = Layer.succeed(
}), }),
) )
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer)) const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer, AppFileSystem.defaultLayer))
const scout = testEffect( const scout = testEffect(
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer), Layer.mergeAll(
registryLayer({ flags: { experimentalScout: true } }),
node,
Agent.defaultLayer,
AppFileSystem.defaultLayer,
),
) )
const background = testEffect( const background = testEffect(
Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer), Layer.mergeAll(
registryLayer({ flags: { experimentalBackgroundSubagents: true } }),
node,
Agent.defaultLayer,
AppFileSystem.defaultLayer,
),
) )
const withBrokenPlugin = testEffect( const withBrokenPlugin = testEffect(
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer), Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer, AppFileSystem.defaultLayer),
) )
afterEach(async () => { afterEach(async () => {
@ -113,8 +123,7 @@ afterEach(async () => {
describe("tool.registry", () => { describe("tool.registry", () => {
it.instance("hides repo research tools unless experimental", () => it.instance("hides repo research tools unless experimental", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).not.toContain("repo_clone") expect(ids).not.toContain("repo_clone")
expect(ids).not.toContain("repo_overview") expect(ids).not.toContain("repo_overview")
@ -123,8 +132,7 @@ describe("tool.registry", () => {
scout.instance("shows repo research tools when experimental scout is enabled", () => scout.instance("shows repo research tools when experimental scout is enabled", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("repo_clone") expect(ids).toContain("repo_clone")
expect(ids).toContain("repo_overview") expect(ids).toContain("repo_overview")
@ -133,8 +141,7 @@ describe("tool.registry", () => {
it.instance("hides task_status unless experimental background subagents are enabled", () => it.instance("hides task_status unless experimental background subagents are enabled", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).not.toContain("task_status") expect(ids).not.toContain("task_status")
}), }),
@ -142,11 +149,10 @@ describe("tool.registry", () => {
it.instance("hides task background parameter unless experimental background subagents are enabled", () => it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agent = yield* Agent.Service const agent = yield* Agent.Service
const build = yield* agent.get("build") const build = yield* agent.get("build")
if (!build) throw new Error("build agent not found") if (!build) throw new Error("build agent not found")
const task = (yield* registry.tools({ const task = (yield* ToolRegistry.use.tools({
providerID: ProviderID.opencode, providerID: ProviderID.opencode,
modelID: ModelID.make("test"), modelID: ModelID.make("test"),
agent: build, agent: build,
@ -159,8 +165,7 @@ describe("tool.registry", () => {
background.instance("shows task_status when experimental background subagents are enabled", () => background.instance("shows task_status when experimental background subagents are enabled", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("task_status") expect(ids).toContain("task_status")
}), }),
@ -169,26 +174,20 @@ describe("tool.registry", () => {
it.instance("loads tools from .opencode/tool (singular)", () => it.instance("loads tools from .opencode/tool (singular)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode") yield* AppFileSystem.use.writeWithDirs(
const tool = path.join(opencode, "tool") path.join(test.directory, ".opencode", "tool", "hello.ts"),
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) [
yield* Effect.promise(() => "export default {",
Bun.write( " description: 'hello tool',",
path.join(tool, "hello.ts"), " args: {},",
[ " execute: async () => {",
"export default {", " return 'hello world'",
" description: 'hello tool',", " },",
" args: {},", "}",
" execute: async () => {", "",
" return 'hello world'", ].join("\n"),
" },",
"}",
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("hello") expect(ids).toContain("hello")
}), }),
) )
@ -196,25 +195,20 @@ describe("tool.registry", () => {
it.instance("ignores non-tool exports in .opencode/tool files", () => it.instance("ignores non-tool exports in .opencode/tool files", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const tool = path.join(test.directory, ".opencode", "tool") yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) path.join(test.directory, ".opencode", "tool", "mixed.ts"),
yield* Effect.promise(() => [
Bun.write( "export const helper = 'not a tool'",
path.join(tool, "mixed.ts"), "export default {",
[ " description: 'mixed tool',",
"export const helper = 'not a tool'", " args: {},",
"export default {", " execute: async () => 'ok',",
" description: 'mixed tool',", "}",
" args: {},", "",
" execute: async () => 'ok',", ].join("\n"),
"}",
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("mixed") expect(ids).toContain("mixed")
expect(ids).not.toContain("mixed_helper") expect(ids).not.toContain("mixed_helper")
}), }),
@ -229,28 +223,23 @@ describe("tool.registry", () => {
it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () => it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const tool = path.join(test.directory, ".opencode", "tool") yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) path.join(test.directory, ".opencode", "tool", "noargs.ts"),
yield* Effect.promise(() => [
Bun.write( "export default {",
path.join(tool, "noargs.ts"), " description: 'tool with no args',",
[ " args: undefined,",
"export default {", " execute: async () => 'ok',",
" description: 'tool with no args',", "}",
" args: undefined,", "",
" execute: async () => 'ok',", ].join("\n"),
"}",
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
// Built-in tools must still load — a single malformed custom tool must // Built-in tools must still load — a single malformed custom tool must
// not poison the whole registry. // not poison the whole registry.
expect(ids).toContain("read") expect(ids).toContain("read")
const loaded = (yield* registry.all()).find((t) => t.id === "noargs") const loaded = (yield* ToolRegistry.use.all()).find((t) => t.id === "noargs")
if (!loaded) throw new Error("noargs tool was not loaded") if (!loaded) throw new Error("noargs tool was not loaded")
expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} }) expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} })
}), }),
@ -264,8 +253,7 @@ describe("tool.registry", () => {
// protection. // protection.
withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () => withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("read") expect(ids).toContain("read")
expect(ids).toContain("broken_plugin_tool") expect(ids).toContain("broken_plugin_tool")
}), }),
@ -274,26 +262,20 @@ describe("tool.registry", () => {
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
const opencode = path.join(test.directory, ".opencode") yield* AppFileSystem.use.writeWithDirs(
const tools = path.join(opencode, "tools") path.join(test.directory, ".opencode", "tools", "hello.ts"),
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) [
yield* Effect.promise(() => "export default {",
Bun.write( " description: 'hello tool',",
path.join(tools, "hello.ts"), " args: {},",
[ " execute: async () => {",
"export default {", " return 'hello world'",
" description: 'hello tool',", " },",
" args: {},", "}",
" execute: async () => {", "",
" return 'hello world'", ].join("\n"),
" },",
"}",
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("hello") expect(ids).toContain("hello")
}), }),
) )
@ -301,26 +283,21 @@ describe("tool.registry", () => {
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () => it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => path.join(test.directory, ".opencode", "tools", "sql.ts"),
Bun.write( [
path.join(customTools, "sql.ts"), `import { tool } from ${JSON.stringify(pluginTool)}`,
[ "export default tool({",
`import { tool } from ${JSON.stringify(pluginTool)}`, " description: 'query database',",
"export default tool({", " args: { query: tool.schema.string().describe('SQL query to execute') },",
" description: 'query database',", " execute: async ({ query }) => query,",
" args: { query: tool.schema.string().describe('SQL query to execute') },", "})",
" execute: async ({ query }) => query,", "",
"})", ].join("\n"),
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "sql")
const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
if (!loaded) throw new Error("custom sql tool was not loaded") if (!loaded) throw new Error("custom sql tool was not loaded")
expect(loaded?.jsonSchema).toMatchObject({ expect(loaded?.jsonSchema).toMatchObject({
type: "object", type: "object",
@ -333,7 +310,7 @@ describe("tool.registry", () => {
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false) expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
const agents = yield* Agent.Service const agents = yield* Agent.Service
const promptTools = yield* registry.tools({ const promptTools = yield* ToolRegistry.use.tools({
providerID: ProviderID.opencode, providerID: ProviderID.opencode,
modelID: ModelID.make("test"), modelID: ModelID.make("test"),
agent: yield* agents.defaultInfo(), agent: yield* agents.defaultInfo(),
@ -357,53 +334,44 @@ describe("tool.registry", () => {
const opencode = path.join(test.directory, ".opencode") const opencode = path.join(test.directory, ".opencode")
const customTools = path.join(opencode, "tools") const customTools = path.join(opencode, "tools")
const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin") const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin")
yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() => yield* Effect.promise(() =>
fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), { fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), {
dereference: true, dereference: true,
recursive: true, recursive: true,
}), }),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(plugin, "package.json"),
path.join(plugin, "package.json"), JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(plugin, "dist", "index.js"),
path.join(plugin, "dist", "index.js"), [
[ "import { z } from 'zod'",
"import { z } from 'zod'", "export function tool(input) {",
"export function tool(input) {", " return input",
" return input", "}",
"}", "tool.schema = z",
"tool.schema = z", "",
"", ].join("\n"),
].join("\n"),
),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(customTools, "addition.ts"),
path.join(customTools, "addition.ts"), [
[ 'import { tool } from "@opencode-ai/plugin"',
'import { tool } from "@opencode-ai/plugin"', "export default tool({",
"export default tool({", " description: 'Use this tool to add two numbers and return their sum.',",
" description: 'Use this tool to add two numbers and return their sum.',", " args: {",
" args: {", " left: tool.schema.number().describe('The first number to add'),",
" left: tool.schema.number().describe('The first number to add'),", " right: tool.schema.number().describe('The second number to add'),",
" right: tool.schema.number().describe('The second number to add'),", " },",
" },", " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,", "})",
"})", "",
"", ].join("\n"),
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "addition")
const loaded = (yield* registry.all()).find((tool) => tool.id === "addition")
if (!loaded) throw new Error("custom addition tool was not loaded") if (!loaded) throw new Error("custom addition tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
@ -419,29 +387,24 @@ describe("tool.registry", () => {
it.instance("preserves attachments from structured custom tool results", () => it.instance("preserves attachments from structured custom tool results", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => path.join(test.directory, ".opencode", "tools", "image.ts"),
Bun.write( [
path.join(customTools, "image.ts"), `import { tool } from ${JSON.stringify(pluginTool)}`,
[ "export default tool({",
`import { tool } from ${JSON.stringify(pluginTool)}`, " description: 'image tool',",
"export default tool({", " args: {},",
" description: 'image tool',", " execute: async () => ({",
" args: {},", " output: 'here is an image',",
" execute: async () => ({", " attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
" output: 'here is an image',", " }),",
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],", "})",
" }),", "",
"})", ].join("\n"),
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "image")
const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
if (!loaded) throw new Error("custom image tool was not loaded") if (!loaded) throw new Error("custom image tool was not loaded")
const agents = yield* Agent.Service const agents = yield* Agent.Service
const result = yield* loaded.execute({}, { const result = yield* loaded.execute({}, {
@ -464,24 +427,19 @@ describe("tool.registry", () => {
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () => it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const tools = path.join(test.directory, ".opencode", "tools") yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) path.join(test.directory, ".opencode", "tools", "legacy.ts"),
yield* Effect.promise(() => [
Bun.write( "export default {",
path.join(tools, "legacy.ts"), " description: 'legacy schema tool',",
[ " args: { text: { type: 'string', description: 'Text to render' } },",
"export default {", " execute: async ({ text }) => text,",
" description: 'legacy schema tool',", "}",
" args: { text: { type: 'string', description: 'Text to render' } },", "",
" execute: async ({ text }) => text,", ].join("\n"),
"}",
"",
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "legacy")
const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
if (!loaded) throw new Error("legacy custom tool was not loaded") if (!loaded) throw new Error("legacy custom tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
type: "object", type: "object",
@ -498,73 +456,60 @@ describe("tool.registry", () => {
const test = yield* TestInstance const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode") const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools") const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => path.join(opencode, "package.json"),
Bun.write( JSON.stringify({
path.join(opencode, "package.json"), name: "custom-tools",
JSON.stringify({ dependencies: {
name: "custom-tools", "@opencode-ai/plugin": "^0.0.0",
dependencies: { cowsay: "^1.6.0",
"@opencode-ai/plugin": "^0.0.0", },
cowsay: "^1.6.0", }),
},
}),
),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(opencode, "package-lock.json"),
path.join(opencode, "package-lock.json"), JSON.stringify({
JSON.stringify({ name: "custom-tools",
name: "custom-tools", lockfileVersion: 3,
lockfileVersion: 3, packages: {
packages: { "": {
"": { dependencies: {
dependencies: { "@opencode-ai/plugin": "^0.0.0",
"@opencode-ai/plugin": "^0.0.0", cowsay: "^1.6.0",
cowsay: "^1.6.0",
},
}, },
}, },
}), },
), }),
) )
const cowsay = path.join(opencode, "node_modules", "cowsay") const cowsay = path.join(opencode, "node_modules", "cowsay")
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) yield* AppFileSystem.use.writeWithDirs(
yield* Effect.promise(() => path.join(cowsay, "package.json"),
Bun.write( JSON.stringify({
path.join(cowsay, "package.json"), name: "cowsay",
JSON.stringify({ type: "module",
name: "cowsay", exports: "./index.js",
type: "module", }),
exports: "./index.js",
}),
),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(cowsay, "index.js"),
path.join(cowsay, "index.js"), ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
),
) )
yield* Effect.promise(() => yield* AppFileSystem.use.writeWithDirs(
Bun.write( path.join(tools, "cowsay.ts"),
path.join(tools, "cowsay.ts"), [
[ "import { say } from 'cowsay'",
"import { say } from 'cowsay'", "export default {",
"export default {", " description: 'tool that imports cowsay at top level',",
" description: 'tool that imports cowsay at top level',", " args: { text: { type: 'string' } },",
" args: { text: { type: 'string' } },", " execute: async ({ text }: { text: string }) => {",
" execute: async ({ text }: { text: string }) => {", " return say({ text })",
" return say({ text })", " },",
" },", "}",
"}", "",
"", ].join("\n"),
].join("\n"),
),
) )
const registry = yield* ToolRegistry.Service const ids = yield* ToolRegistry.use.ids()
const ids = yield* registry.ids()
expect(ids).toContain("cowsay") expect(ids).toContain("cowsay")
}), }),
) )