import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" import { Effect, Layer, Result, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" import { Flag } from "@opencode-ai/core/flag/flag" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Plugin } from "@/plugin" import { Question } from "@/question" import { Todo } from "@/session/todo" import { Skill } from "@/skill" import { Agent } from "@/agent/agent" import { Session } from "@/session/session" import { Provider } from "@/provider/provider" import { Git } from "@/git" import { LSP } from "@/lsp/lsp" import { Instruction } from "@/session/instruction" import { Bus } from "@/bus" import { FetchHttpClient } from "effect/unstable/http" import { Format } from "@/format" import { Ripgrep } from "@/file/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" import { Reference } from "@/reference/reference" import { ProviderID, ModelID } from "@/provider/schema" import { ToolJsonSchema } from "@/tool/json-schema" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), }) const registryLayer = ToolRegistry.layer.pipe( Layer.provide(configLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Question.defaultLayer), Layer.provide(Todo.defaultLayer), Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Git.defaultLayer), Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Bus.layer), Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), Layer.provide(node), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Truncate.defaultLayer), ) const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer)) afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout await disposeAllInstances() }) describe("tool.registry", () => { it.instance("hides repo research tools unless experimental", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_SCOUT = false const registry = yield* ToolRegistry.Service const ids = yield* registry.ids() expect(ids).not.toContain("codesearch") expect(ids).not.toContain("repo_clone") expect(ids).not.toContain("repo_overview") }), ) it.instance("shows repo research tools when experimental scout is enabled", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_SCOUT = true const registry = yield* ToolRegistry.Service const ids = yield* registry.ids() expect(ids).toContain("codesearch") expect(ids).toContain("repo_clone") expect(ids).toContain("repo_overview") }), ) 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"), ), ) const registry = yield* ToolRegistry.Service const ids = yield* registry.ids() expect(ids).toContain("hello") }), ) 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"), ), ) const registry = yield* ToolRegistry.Service const ids = yield* registry.ids() expect(ids).toContain("hello") }), ) 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"), ), ) const registry = yield* ToolRegistry.Service const loaded = (yield* registry.all()).find((tool) => tool.id === "sql") if (!loaded) throw new Error("custom sql tool was not loaded") expect(loaded?.jsonSchema).toMatchObject({ type: "object", properties: { query: { type: "string", description: "SQL query to execute" }, }, required: ["query"], }) expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true) expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false) const agents = yield* Agent.Service const promptTools = yield* registry.tools({ providerID: ProviderID.opencode, modelID: ModelID.make("test"), agent: yield* agents.get(yield* agents.defaultAgent()), }) const promptTool = promptTools.find((tool) => tool.id === "sql") if (!promptTool) throw new Error("custom sql tool was not returned for prompts") expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({ properties: { query: { type: "string", description: "SQL query to execute" }, }, required: ["query"], }) }), ) 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"), ), ) const registry = yield* ToolRegistry.Service const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy") if (!loaded) throw new Error("legacy custom tool was not loaded") expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ type: "object", properties: { text: { type: "string", description: "Text to render" }, }, required: ["text"], }) }), ) it.instance("loads tools with external dependencies without crashing", () => 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(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", }, }, }, }), ), ) 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* Effect.promise(() => Bun.write( 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"), ), ) const registry = yield* ToolRegistry.Service const ids = yield* registry.ids() expect(ids).toContain("cowsay") }), ) })