registry.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import { afterEach, describe, expect } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { fileURLToPath, pathToFileURL } from "url"
  5. import { Effect, Layer, Result, Schema } from "effect"
  6. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  7. import { Database } from "@opencode-ai/core/database/database"
  8. import { ToolRegistry } from "@/tool/registry"
  9. import { Tool } from "@/tool/tool"
  10. import { disposeAllInstances, TestInstance } from "../fixture/fixture"
  11. import { testEffect } from "../lib/effect"
  12. import { TestConfig } from "../fixture/config"
  13. import { FSUtil } from "@opencode-ai/core/fs-util"
  14. import { Plugin } from "@/plugin"
  15. import { Question } from "@/question"
  16. import { Todo } from "@/session/todo"
  17. import { Skill } from "@/skill"
  18. import { Agent } from "@/agent/agent"
  19. import { BackgroundJob } from "@/background/job"
  20. import { Session } from "@/session/session"
  21. import { SessionStatus } from "@/session/status"
  22. import { Provider } from "@/provider/provider"
  23. import { Git } from "@/git"
  24. import { LSP } from "@/lsp/lsp"
  25. import { Instruction } from "@/session/instruction"
  26. import { EventV2Bridge } from "@/event-v2-bridge"
  27. import { FetchHttpClient } from "effect/unstable/http"
  28. import { Format } from "@/format"
  29. import { Ripgrep } from "@opencode-ai/core/ripgrep"
  30. import * as Truncate from "@/tool/truncate"
  31. import { InstanceState } from "@/effect/instance-state"
  32. import { ToolJsonSchema } from "@/tool/json-schema"
  33. import { MessageID, SessionID } from "@/session/schema"
  34. import { RuntimeFlags } from "@/effect/runtime-flags"
  35. import { ProviderV2 } from "@opencode-ai/core/provider"
  36. import { ModelV2 } from "@opencode-ai/core/model"
  37. const node = CrossSpawnSpawner.defaultLayer
  38. const configLayer = TestConfig.layer({
  39. directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
  40. })
  41. type RegistryLayerOptions = {
  42. flags?: Partial<RuntimeFlags.Info>
  43. plugin?: Layer.Layer<Plugin.Service>
  44. }
  45. const registryLayer = (opts: RegistryLayerOptions = {}) =>
  46. ToolRegistry.layer
  47. .pipe(
  48. Layer.provide(configLayer),
  49. Layer.provide(opts.plugin ?? Plugin.defaultLayer),
  50. Layer.provide(Question.defaultLayer),
  51. Layer.provide(Todo.defaultLayer),
  52. Layer.provide(Skill.defaultLayer),
  53. Layer.provide(Agent.defaultLayer),
  54. Layer.provide(Session.defaultLayer),
  55. Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
  56. Layer.provide(Provider.defaultLayer),
  57. Layer.provide(Git.defaultLayer),
  58. Layer.provide(LSP.defaultLayer),
  59. Layer.provide(Instruction.defaultLayer),
  60. Layer.provide(FSUtil.defaultLayer),
  61. Layer.provide(EventV2Bridge.defaultLayer),
  62. Layer.provide(FetchHttpClient.layer),
  63. Layer.provide(Format.defaultLayer),
  64. Layer.provide(Layer.mergeAll(node, Database.defaultLayer)),
  65. Layer.provide(Ripgrep.defaultLayer),
  66. Layer.provide(Truncate.defaultLayer),
  67. )
  68. .pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))
  69. // Fake Plugin.Service that returns a single plugin whose `tool` map contains
  70. // one definition with `args: undefined`. Used to exercise the plugin entry
  71. // point of `fromPlugin` for the #27451 / #27630 regression.
  72. const brokenPluginLayer = Layer.succeed(
  73. Plugin.Service,
  74. Plugin.Service.of({
  75. init: () => Effect.void,
  76. trigger: ((_name: unknown, _input: unknown, output: unknown) =>
  77. Effect.succeed(output)) as Plugin.Interface["trigger"],
  78. list: () =>
  79. Effect.succeed([
  80. {
  81. tool: {
  82. broken_plugin_tool: {
  83. description: "plugin tool with missing args",
  84. args: undefined as unknown as Record<string, never>,
  85. execute: async () => "ok",
  86. },
  87. },
  88. },
  89. ]),
  90. }),
  91. )
  92. const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
  93. const withBrokenPlugin = testEffect(
  94. Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
  95. )
  96. afterEach(async () => {
  97. await disposeAllInstances()
  98. })
  99. describe("tool.registry", () => {
  100. it.instance("does not expose task_status", () =>
  101. Effect.gen(function* () {
  102. const registry = yield* ToolRegistry.Service
  103. const ids = yield* registry.ids()
  104. expect(ids).not.toContain("task_status")
  105. }),
  106. )
  107. it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
  108. Effect.gen(function* () {
  109. const registry = yield* ToolRegistry.Service
  110. const agent = yield* Agent.Service
  111. const build = yield* agent.get("build")
  112. if (!build) throw new Error("build agent not found")
  113. const task = (yield* registry.tools({
  114. providerID: ProviderV2.ID.opencode,
  115. modelID: ModelV2.ID.make("test"),
  116. agent: build,
  117. })).find((tool) => tool.id === "task")
  118. expect(task?.jsonSchema).toBeDefined()
  119. expect((task?.jsonSchema?.properties as Record<string, unknown> | undefined)?.background).toBeUndefined()
  120. }),
  121. )
  122. it.instance("loads tools from .opencode/tool (singular)", () =>
  123. Effect.gen(function* () {
  124. const test = yield* TestInstance
  125. const opencode = path.join(test.directory, ".opencode")
  126. const tool = path.join(opencode, "tool")
  127. yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
  128. yield* Effect.promise(() =>
  129. Bun.write(
  130. path.join(tool, "hello.ts"),
  131. [
  132. "export default {",
  133. " description: 'hello tool',",
  134. " args: {},",
  135. " execute: async () => {",
  136. " return 'hello world'",
  137. " },",
  138. "}",
  139. "",
  140. ].join("\n"),
  141. ),
  142. )
  143. const registry = yield* ToolRegistry.Service
  144. const ids = yield* registry.ids()
  145. expect(ids).toContain("hello")
  146. }),
  147. )
  148. it.instance("ignores non-tool exports in .opencode/tool files", () =>
  149. Effect.gen(function* () {
  150. const test = yield* TestInstance
  151. const tool = path.join(test.directory, ".opencode", "tool")
  152. yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
  153. yield* Effect.promise(() =>
  154. Bun.write(
  155. path.join(tool, "mixed.ts"),
  156. [
  157. "export const helper = 'not a tool'",
  158. "export default {",
  159. " description: 'mixed tool',",
  160. " args: {},",
  161. " execute: async () => 'ok',",
  162. "}",
  163. "",
  164. ].join("\n"),
  165. ),
  166. )
  167. const registry = yield* ToolRegistry.Service
  168. const ids = yield* registry.ids()
  169. expect(ids).toContain("mixed")
  170. expect(ids).not.toContain("mixed_helper")
  171. }),
  172. )
  173. // Regression for #27451 / #27630: a custom tool that omits `args` must not
  174. // crash registry initialization with
  175. // `Object.entries requires that input parameter not be null or undefined`.
  176. // Pre-1.14.49 the code path was `z.object(def.args)`, and `z.object(undefined)`
  177. // silently produced an empty schema — so the tool registered as no-args.
  178. // Preserve that tolerance.
  179. it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
  180. Effect.gen(function* () {
  181. const test = yield* TestInstance
  182. const tool = path.join(test.directory, ".opencode", "tool")
  183. yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
  184. yield* Effect.promise(() =>
  185. Bun.write(
  186. path.join(tool, "noargs.ts"),
  187. [
  188. "export default {",
  189. " description: 'tool with no args',",
  190. " args: undefined,",
  191. " execute: async () => 'ok',",
  192. "}",
  193. "",
  194. ].join("\n"),
  195. ),
  196. )
  197. const registry = yield* ToolRegistry.Service
  198. const ids = yield* registry.ids()
  199. // Built-in tools must still load — a single malformed custom tool must
  200. // not poison the whole registry.
  201. expect(ids).toContain("read")
  202. const loaded = (yield* registry.all()).find((t) => t.id === "noargs")
  203. if (!loaded) throw new Error("noargs tool was not loaded")
  204. expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} })
  205. }),
  206. )
  207. // Same regression, plugin entry point. The original reports (#27451, #27630)
  208. // came in through `plugin.list()` — `oh-my-opencode` was registering a tool
  209. // with `args: undefined` and crashing every message submit. The file-scan
  210. // and plugin-list loops both funnel through `fromPlugin`, but covering both
  211. // entry points means a future refactor that splits them won't silently lose
  212. // protection.
  213. withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
  214. Effect.gen(function* () {
  215. const registry = yield* ToolRegistry.Service
  216. const ids = yield* registry.ids()
  217. expect(ids).toContain("read")
  218. expect(ids).toContain("broken_plugin_tool")
  219. }),
  220. )
  221. it.instance("loads tools from .opencode/tools (plural)", () =>
  222. Effect.gen(function* () {
  223. const test = yield* TestInstance
  224. const opencode = path.join(test.directory, ".opencode")
  225. const tools = path.join(opencode, "tools")
  226. yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
  227. yield* Effect.promise(() =>
  228. Bun.write(
  229. path.join(tools, "hello.ts"),
  230. [
  231. "export default {",
  232. " description: 'hello tool',",
  233. " args: {},",
  234. " execute: async () => {",
  235. " return 'hello world'",
  236. " },",
  237. "}",
  238. "",
  239. ].join("\n"),
  240. ),
  241. )
  242. const registry = yield* ToolRegistry.Service
  243. const ids = yield* registry.ids()
  244. expect(ids).toContain("hello")
  245. }),
  246. )
  247. it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
  248. Effect.gen(function* () {
  249. const test = yield* TestInstance
  250. const customTools = path.join(test.directory, ".opencode", "tools")
  251. const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
  252. yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
  253. yield* Effect.promise(() =>
  254. Bun.write(
  255. path.join(customTools, "sql.ts"),
  256. [
  257. `import { tool } from ${JSON.stringify(pluginTool)}`,
  258. "export default tool({",
  259. " description: 'query database',",
  260. " args: { query: tool.schema.string().describe('SQL query to execute') },",
  261. " execute: async ({ query }) => query,",
  262. "})",
  263. "",
  264. ].join("\n"),
  265. ),
  266. )
  267. const registry = yield* ToolRegistry.Service
  268. const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
  269. if (!loaded) throw new Error("custom sql tool was not loaded")
  270. expect(loaded?.jsonSchema).toMatchObject({
  271. type: "object",
  272. properties: {
  273. query: { type: "string", description: "SQL query to execute" },
  274. },
  275. required: ["query"],
  276. })
  277. expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true)
  278. expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
  279. const agents = yield* Agent.Service
  280. const promptTools = yield* registry.tools({
  281. providerID: ProviderV2.ID.opencode,
  282. modelID: ModelV2.ID.make("test"),
  283. agent: yield* agents.defaultInfo(),
  284. })
  285. const promptTool = promptTools.find((tool) => tool.id === "sql")
  286. if (!promptTool) throw new Error("custom sql tool was not returned for prompts")
  287. expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({
  288. properties: {
  289. query: { type: "string", description: "SQL query to execute" },
  290. },
  291. required: ["query"],
  292. })
  293. }),
  294. )
  295. it.instance(
  296. "preserves Zod arg descriptions from older config-scoped plugin packages",
  297. () =>
  298. Effect.gen(function* () {
  299. const test = yield* TestInstance
  300. const opencode = path.join(test.directory, ".opencode")
  301. const customTools = path.join(opencode, "tools")
  302. const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin")
  303. yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
  304. yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
  305. yield* Effect.promise(() =>
  306. fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), {
  307. dereference: true,
  308. recursive: true,
  309. }),
  310. )
  311. yield* Effect.promise(() =>
  312. Bun.write(
  313. path.join(plugin, "package.json"),
  314. JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
  315. ),
  316. )
  317. yield* Effect.promise(() =>
  318. Bun.write(
  319. path.join(plugin, "dist", "index.js"),
  320. [
  321. "import { z } from 'zod'",
  322. "export function tool(input) {",
  323. " return input",
  324. "}",
  325. "tool.schema = z",
  326. "",
  327. ].join("\n"),
  328. ),
  329. )
  330. yield* Effect.promise(() =>
  331. Bun.write(
  332. path.join(customTools, "addition.ts"),
  333. [
  334. 'import { tool } from "@opencode-ai/plugin"',
  335. "export default tool({",
  336. " description: 'Use this tool to add two numbers and return their sum.',",
  337. " args: {",
  338. " left: tool.schema.number().describe('The first number to add'),",
  339. " right: tool.schema.number().describe('The second number to add'),",
  340. " },",
  341. " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
  342. "})",
  343. "",
  344. ].join("\n"),
  345. ),
  346. )
  347. const registry = yield* ToolRegistry.Service
  348. const loaded = (yield* registry.all()).find((tool) => tool.id === "addition")
  349. if (!loaded) throw new Error("custom addition tool was not loaded")
  350. expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
  351. properties: {
  352. left: { type: "number", description: "The first number to add" },
  353. right: { type: "number", description: "The second number to add" },
  354. },
  355. })
  356. }),
  357. 20_000,
  358. )
  359. it.instance("preserves attachments from structured custom tool results", () =>
  360. Effect.gen(function* () {
  361. const test = yield* TestInstance
  362. const customTools = path.join(test.directory, ".opencode", "tools")
  363. const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
  364. yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
  365. yield* Effect.promise(() =>
  366. Bun.write(
  367. path.join(customTools, "image.ts"),
  368. [
  369. `import { tool } from ${JSON.stringify(pluginTool)}`,
  370. "export default tool({",
  371. " description: 'image tool',",
  372. " args: {},",
  373. " execute: async () => ({",
  374. " output: 'here is an image',",
  375. " attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
  376. " }),",
  377. "})",
  378. "",
  379. ].join("\n"),
  380. ),
  381. )
  382. const registry = yield* ToolRegistry.Service
  383. const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
  384. if (!loaded) throw new Error("custom image tool was not loaded")
  385. const agents = yield* Agent.Service
  386. const result = yield* loaded.execute({}, {
  387. sessionID: SessionID.make("ses_test"),
  388. messageID: MessageID.make("msg_test"),
  389. agent: (yield* agents.defaultInfo()).name,
  390. abort: new AbortController().signal,
  391. messages: [],
  392. metadata: () => Effect.void,
  393. ask: () => Effect.void,
  394. } satisfies Tool.Context)
  395. expect(result.output).toBe("here is an image")
  396. expect(result.attachments).toEqual([
  397. { type: "file", mime: "image/png", filename: "picture.png", url: "data:image/png;base64,AAAA" },
  398. ])
  399. }),
  400. )
  401. it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
  402. Effect.gen(function* () {
  403. const test = yield* TestInstance
  404. const tools = path.join(test.directory, ".opencode", "tools")
  405. yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
  406. yield* Effect.promise(() =>
  407. Bun.write(
  408. path.join(tools, "legacy.ts"),
  409. [
  410. "export default {",
  411. " description: 'legacy schema tool',",
  412. " args: { text: { type: 'string', description: 'Text to render' } },",
  413. " execute: async ({ text }) => text,",
  414. "}",
  415. "",
  416. ].join("\n"),
  417. ),
  418. )
  419. const registry = yield* ToolRegistry.Service
  420. const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
  421. if (!loaded) throw new Error("legacy custom tool was not loaded")
  422. expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
  423. type: "object",
  424. properties: {
  425. text: { type: "string", description: "Text to render" },
  426. },
  427. required: ["text"],
  428. })
  429. }),
  430. )
  431. it.instance("loads tools with external dependencies without crashing", () =>
  432. Effect.gen(function* () {
  433. const test = yield* TestInstance
  434. const opencode = path.join(test.directory, ".opencode")
  435. const tools = path.join(opencode, "tools")
  436. yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
  437. yield* Effect.promise(() =>
  438. Bun.write(
  439. path.join(opencode, "package.json"),
  440. JSON.stringify({
  441. name: "custom-tools",
  442. dependencies: {
  443. "@opencode-ai/plugin": "^0.0.0",
  444. cowsay: "^1.6.0",
  445. },
  446. }),
  447. ),
  448. )
  449. yield* Effect.promise(() =>
  450. Bun.write(
  451. path.join(opencode, "package-lock.json"),
  452. JSON.stringify({
  453. name: "custom-tools",
  454. lockfileVersion: 3,
  455. packages: {
  456. "": {
  457. dependencies: {
  458. "@opencode-ai/plugin": "^0.0.0",
  459. cowsay: "^1.6.0",
  460. },
  461. },
  462. },
  463. }),
  464. ),
  465. )
  466. const cowsay = path.join(opencode, "node_modules", "cowsay")
  467. yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
  468. yield* Effect.promise(() =>
  469. Bun.write(
  470. path.join(cowsay, "package.json"),
  471. JSON.stringify({
  472. name: "cowsay",
  473. type: "module",
  474. exports: "./index.js",
  475. }),
  476. ),
  477. )
  478. yield* Effect.promise(() =>
  479. Bun.write(
  480. path.join(cowsay, "index.js"),
  481. ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
  482. ),
  483. )
  484. yield* Effect.promise(() =>
  485. Bun.write(
  486. path.join(tools, "cowsay.ts"),
  487. [
  488. "import { say } from 'cowsay'",
  489. "export default {",
  490. " description: 'tool that imports cowsay at top level',",
  491. " args: { text: { type: 'string' } },",
  492. " execute: async ({ text }: { text: string }) => {",
  493. " return say({ text })",
  494. " },",
  495. "}",
  496. "",
  497. ].join("\n"),
  498. ),
  499. )
  500. const registry = yield* ToolRegistry.Service
  501. const ids = yield* registry.ids()
  502. expect(ids).toContain("cowsay")
  503. }),
  504. )
  505. })