diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6ef6d39a65a..3ea9571d459 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -55,6 +55,7 @@ import { Reference } from "@/reference/reference" import { BackgroundJob } from "@/background/job" import { SessionStatus } from "@/session/status" import { RuntimeFlags } from "@/effect/runtime-flags" +import { serviceUse } from "@opencode-ai/core/effect/service-use" const log = Log.create({ service: "tool.registry" }) @@ -81,6 +82,8 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ToolRegistry") {} +export const use = serviceUse(Service) + export const layer: Layer.Layer< Service, never, diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index d3549e66f34..eb1eb1adbb2 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -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( - Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer), + Layer.mergeAll( + registryLayer({ flags: { experimentalScout: true } }), + node, + Agent.defaultLayer, + AppFileSystem.defaultLayer, + ), ) 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( - Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer), + Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer, AppFileSystem.defaultLayer), ) afterEach(async () => { @@ -113,8 +123,7 @@ afterEach(async () => { describe("tool.registry", () => { it.instance("hides repo research tools unless experimental", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).not.toContain("repo_clone") expect(ids).not.toContain("repo_overview") @@ -123,8 +132,7 @@ describe("tool.registry", () => { scout.instance("shows repo research tools when experimental scout is enabled", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("repo_clone") expect(ids).toContain("repo_overview") @@ -133,8 +141,7 @@ describe("tool.registry", () => { it.instance("hides task_status unless experimental background subagents are enabled", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() 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", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service const agent = yield* Agent.Service const build = yield* agent.get("build") if (!build) throw new Error("build agent not found") - const task = (yield* registry.tools({ + const task = (yield* ToolRegistry.use.tools({ providerID: ProviderID.opencode, modelID: ModelID.make("test"), agent: build, @@ -159,8 +165,7 @@ describe("tool.registry", () => { background.instance("shows task_status when experimental background subagents are enabled", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("task_status") }), @@ -169,26 +174,20 @@ describe("tool.registry", () => { it.instance("loads tools from .opencode/tool (singular)", () => Effect.gen(function* () { const test = yield* TestInstance - const opencode = path.join(test.directory, ".opencode") - const tool = path.join(opencode, "tool") - yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tool, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tool", "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("hello") }), ) @@ -196,25 +195,20 @@ describe("tool.registry", () => { it.instance("ignores non-tool exports in .opencode/tool files", () => 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, "mixed.ts"), - [ - "export const helper = 'not a tool'", - "export default {", - " description: 'mixed tool',", - " args: {},", - " execute: async () => 'ok',", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tool", "mixed.ts"), + [ + "export const helper = 'not a tool'", + "export default {", + " description: 'mixed tool',", + " args: {},", + " execute: async () => 'ok',", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("mixed") 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)", () => 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"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "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() + const ids = yield* ToolRegistry.use.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") + const loaded = (yield* ToolRegistry.use.all()).find((t) => t.id === "noargs") if (!loaded) throw new Error("noargs tool was not loaded") expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} }) }), @@ -264,8 +253,7 @@ describe("tool.registry", () => { // 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() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("read") expect(ids).toContain("broken_plugin_tool") }), @@ -274,26 +262,20 @@ describe("tool.registry", () => { it.instance("loads tools from .opencode/tools (plural)", () => Effect.gen(function* () { const test = yield* TestInstance - const opencode = path.join(test.directory, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tools", "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("hello") }), ) @@ -301,26 +283,21 @@ describe("tool.registry", () => { it.instance("loads Zod-schema custom tools with JSON Schema and validation", () => Effect.gen(function* () { 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 - yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(customTools, "sql.ts"), - [ - `import { tool } from ${JSON.stringify(pluginTool)}`, - "export default tool({", - " description: 'query database',", - " args: { query: tool.schema.string().describe('SQL query to execute') },", - " execute: async ({ query }) => query,", - "})", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tools", "sql.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'query database',", + " args: { query: tool.schema.string().describe('SQL query to execute') },", + " execute: async ({ query }) => query,", + "})", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const loaded = (yield* registry.all()).find((tool) => tool.id === "sql") + const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "sql") if (!loaded) throw new Error("custom sql tool was not loaded") expect(loaded?.jsonSchema).toMatchObject({ type: "object", @@ -333,7 +310,7 @@ describe("tool.registry", () => { expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false) const agents = yield* Agent.Service - const promptTools = yield* registry.tools({ + const promptTools = yield* ToolRegistry.use.tools({ providerID: ProviderID.opencode, modelID: ModelID.make("test"), agent: yield* agents.defaultInfo(), @@ -357,53 +334,44 @@ describe("tool.registry", () => { const opencode = path.join(test.directory, ".opencode") const customTools = path.join(opencode, "tools") 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(() => fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), { dereference: true, recursive: true, }), ) - yield* Effect.promise(() => - Bun.write( - path.join(plugin, "package.json"), - JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(plugin, "package.json"), + JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }), ) - yield* Effect.promise(() => - Bun.write( - path.join(plugin, "dist", "index.js"), - [ - "import { z } from 'zod'", - "export function tool(input) {", - " return input", - "}", - "tool.schema = z", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(plugin, "dist", "index.js"), + [ + "import { z } from 'zod'", + "export function tool(input) {", + " return input", + "}", + "tool.schema = z", + "", + ].join("\n"), ) - yield* Effect.promise(() => - Bun.write( - path.join(customTools, "addition.ts"), - [ - 'import { tool } from "@opencode-ai/plugin"', - "export default tool({", - " description: 'Use this tool to add two numbers and return their sum.',", - " args: {", - " left: tool.schema.number().describe('The first number to add'),", - " right: tool.schema.number().describe('The second number to add'),", - " },", - " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,", - "})", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(customTools, "addition.ts"), + [ + 'import { tool } from "@opencode-ai/plugin"', + "export default tool({", + " description: 'Use this tool to add two numbers and return their sum.',", + " args: {", + " left: tool.schema.number().describe('The first number to add'),", + " right: tool.schema.number().describe('The second number to add'),", + " },", + " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,", + "})", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const loaded = (yield* registry.all()).find((tool) => tool.id === "addition") + const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "addition") if (!loaded) throw new Error("custom addition tool was not loaded") expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ @@ -419,29 +387,24 @@ describe("tool.registry", () => { it.instance("preserves attachments from structured custom tool results", () => Effect.gen(function* () { 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 - yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(customTools, "image.ts"), - [ - `import { tool } from ${JSON.stringify(pluginTool)}`, - "export default tool({", - " description: 'image tool',", - " args: {},", - " execute: async () => ({", - " output: 'here is an image',", - " attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],", - " }),", - "})", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tools", "image.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'image tool',", + " args: {},", + " execute: async () => ({", + " output: 'here is an image',", + " attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],", + " }),", + "})", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const loaded = (yield* registry.all()).find((tool) => tool.id === "image") + const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "image") if (!loaded) throw new Error("custom image tool was not loaded") const agents = yield* Agent.Service const result = yield* loaded.execute({}, { @@ -464,24 +427,19 @@ describe("tool.registry", () => { it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () => Effect.gen(function* () { const test = yield* TestInstance - const tools = path.join(test.directory, ".opencode", "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "legacy.ts"), - [ - "export default {", - " description: 'legacy schema tool',", - " args: { text: { type: 'string', description: 'Text to render' } },", - " execute: async ({ text }) => text,", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tools", "legacy.ts"), + [ + "export default {", + " description: 'legacy schema tool',", + " args: { text: { type: 'string', description: 'Text to render' } },", + " execute: async ({ text }) => text,", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy") + const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "legacy") if (!loaded) throw new Error("legacy custom tool was not loaded") expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ type: "object", @@ -498,73 +456,60 @@ describe("tool.registry", () => { const test = yield* TestInstance const opencode = path.join(test.directory, ".opencode") const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package.json"), - JSON.stringify({ - name: "custom-tools", - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, - }), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(opencode, "package.json"), + JSON.stringify({ + name: "custom-tools", + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", + }, + }), ) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package-lock.json"), - JSON.stringify({ - name: "custom-tools", - lockfileVersion: 3, - packages: { - "": { - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, + yield* AppFileSystem.use.writeWithDirs( + path.join(opencode, "package-lock.json"), + JSON.stringify({ + name: "custom-tools", + lockfileVersion: 3, + packages: { + "": { + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", }, }, - }), - ), + }, + }), ) const cowsay = path.join(opencode, "node_modules", "cowsay") - yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "package.json"), - JSON.stringify({ - name: "cowsay", - type: "module", - exports: "./index.js", - }), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(cowsay, "package.json"), + JSON.stringify({ + name: "cowsay", + type: "module", + exports: "./index.js", + }), ) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "index.js"), - ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(cowsay, "index.js"), + ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), ) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "cowsay.ts"), - [ - "import { say } from 'cowsay'", - "export default {", - " description: 'tool that imports cowsay at top level',", - " args: { text: { type: 'string' } },", - " execute: async ({ text }: { text: string }) => {", - " return say({ text })", - " },", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(tools, "cowsay.ts"), + [ + "import { say } from 'cowsay'", + "export default {", + " description: 'tool that imports cowsay at top level',", + " args: { text: { type: 'string' } },", + " execute: async ({ text }: { text: string }) => {", + " return say({ text })", + " },", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("cowsay") }), )