register.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import "zod-openapi/extend"
  2. import { describe, expect, test } from "bun:test"
  3. import path from "path"
  4. import os from "os"
  5. import { Instance } from "../../src/project/instance"
  6. // Helper to create a Request targeting the in-memory Hono app
  7. function makeRequest(method: string, url: string, body?: any) {
  8. const headers: Record<string, string> = { "content-type": "application/json" }
  9. const init: RequestInit = { method, headers }
  10. if (body !== undefined) init.body = JSON.stringify(body)
  11. return new Request(url, init)
  12. }
  13. describe("HTTP tool registration API", () => {
  14. test("POST /tool/register then list via /tool/ids and /tool", async () => {
  15. const projectRoot = path.join(__dirname, "../..")
  16. await Instance.provide(projectRoot, async () => {
  17. const { Server } = await import("../../src/server/server")
  18. const toolSpec = {
  19. id: "http-echo",
  20. description: "Simple echo tool (test-only)",
  21. parameters: {
  22. type: "object" as const,
  23. properties: {
  24. foo: { type: "string" as const, optional: true },
  25. bar: { type: "number" as const },
  26. },
  27. },
  28. callbackUrl: "http://localhost:9999/echo",
  29. }
  30. // Register
  31. const registerRes = await Server.App.fetch(
  32. makeRequest("POST", "http://localhost:4096/experimental/tool/register", toolSpec),
  33. )
  34. expect(registerRes.status).toBe(200)
  35. const ok = await registerRes.json()
  36. expect(ok).toBe(true)
  37. // IDs should include the new tool
  38. const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
  39. expect(idsRes.status).toBe(200)
  40. const ids = (await idsRes.json()) as string[]
  41. expect(ids).toContain("http-echo")
  42. // List tools for a provider/model and check JSON Schema shape
  43. const listRes = await Server.App.fetch(
  44. makeRequest("GET", "http://localhost:4096/experimental/tool?provider=openai&model=gpt-4o"),
  45. )
  46. expect(listRes.status).toBe(200)
  47. const list = (await listRes.json()) as Array<{ id: string; description: string; parameters: any }>
  48. const found = list.find((t) => t.id === "http-echo")
  49. expect(found).toBeTruthy()
  50. expect(found!.description).toBe("Simple echo tool (test-only)")
  51. // Basic JSON Schema checks
  52. expect(found!.parameters?.type).toBe("object")
  53. expect(found!.parameters?.properties?.bar?.type).toBe("number")
  54. const foo = found!.parameters?.properties?.foo
  55. // optional -> nullable for OpenAI/Azure providers; accept either type array including null or nullable: true
  56. const fooIsNullable = Array.isArray(foo?.type) ? foo.type.includes("null") : foo?.nullable === true
  57. expect(fooIsNullable).toBe(true)
  58. })
  59. })
  60. })
  61. describe("Plugin tool.register hook", () => {
  62. test("Plugin registers tool during Plugin.init()", async () => {
  63. // Create a temporary project directory with opencode.json that points to our plugin
  64. const tmpDir = path.join(os.tmpdir(), `opencode-test-project-${Date.now()}`)
  65. await Bun.$`mkdir -p ${tmpDir}`
  66. const tmpPluginPath = path.join(tmpDir, `test-plugin-${Date.now()}.ts`)
  67. const pluginCode = `
  68. export async function TestPlugin() {
  69. return {
  70. async ["tool.register"](_input, { registerHTTP }) {
  71. registerHTTP({
  72. id: "from-plugin",
  73. description: "Registered from test plugin",
  74. parameters: { type: "object", properties: { name: { type: "string", optional: true } } },
  75. callbackUrl: "http://localhost:9999/echo"
  76. })
  77. }
  78. }
  79. }
  80. `
  81. await Bun.write(tmpPluginPath, pluginCode)
  82. const configPath = path.join(tmpDir, "opencode.json")
  83. await Bun.write(configPath, JSON.stringify({ plugin: ["file://" + tmpPluginPath] }, null, 2))
  84. await Instance.provide(tmpDir, async () => {
  85. const { Plugin } = await import("../../src/plugin")
  86. const { ToolRegistry } = await import("../../src/tool/registry")
  87. const { Server } = await import("../../src/server/server")
  88. // Initialize plugins (will invoke our tool.register hook)
  89. await Plugin.init()
  90. // Confirm the tool is registered
  91. const allIDs = ToolRegistry.ids()
  92. expect(allIDs).toContain("from-plugin")
  93. // Also verify via the HTTP surface
  94. const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
  95. expect(idsRes.status).toBe(200)
  96. const ids = (await idsRes.json()) as string[]
  97. expect(ids).toContain("from-plugin")
  98. })
  99. })
  100. })
  101. test("Multiple plugins can each register tools", async () => {
  102. const tmpDir = path.join(os.tmpdir(), `opencode-test-project-multi-${Date.now()}`)
  103. await Bun.$`mkdir -p ${tmpDir}`
  104. // Create two plugin files
  105. const pluginAPath = path.join(tmpDir, `plugin-a-${Date.now()}.ts`)
  106. const pluginBPath = path.join(tmpDir, `plugin-b-${Date.now()}.ts`)
  107. const pluginA = `
  108. export async function PluginA() {
  109. return {
  110. async ["tool.register"](_input, { registerHTTP }) {
  111. registerHTTP({
  112. id: "alpha-tool",
  113. description: "Alpha tool",
  114. parameters: { type: "object", properties: { a: { type: "string", optional: true } } },
  115. callbackUrl: "http://localhost:9999/echo"
  116. })
  117. }
  118. }
  119. }
  120. `
  121. const pluginB = `
  122. export async function PluginB() {
  123. return {
  124. async ["tool.register"](_input, { registerHTTP }) {
  125. registerHTTP({
  126. id: "beta-tool",
  127. description: "Beta tool",
  128. parameters: { type: "object", properties: { b: { type: "number", optional: true } } },
  129. callbackUrl: "http://localhost:9999/echo"
  130. })
  131. }
  132. }
  133. }
  134. `
  135. await Bun.write(pluginAPath, pluginA)
  136. await Bun.write(pluginBPath, pluginB)
  137. // Config with both plugins
  138. await Bun.write(
  139. path.join(tmpDir, "opencode.json"),
  140. JSON.stringify({ plugin: ["file://" + pluginAPath, "file://" + pluginBPath] }, null, 2),
  141. )
  142. await Instance.provide(tmpDir, async () => {
  143. const { Plugin } = await import("../../src/plugin")
  144. const { ToolRegistry } = await import("../../src/tool/registry")
  145. const { Server } = await import("../../src/server/server")
  146. await Plugin.init()
  147. const ids = ToolRegistry.ids()
  148. expect(ids).toContain("alpha-tool")
  149. expect(ids).toContain("beta-tool")
  150. const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
  151. expect(res.status).toBe(200)
  152. const httpIds = (await res.json()) as string[]
  153. expect(httpIds).toContain("alpha-tool")
  154. expect(httpIds).toContain("beta-tool")
  155. })
  156. })
  157. test("Plugin registers native/local tool with function execution", async () => {
  158. const tmpDir = path.join(os.tmpdir(), `opencode-test-project-native-${Date.now()}`)
  159. await Bun.$`mkdir -p ${tmpDir}`
  160. const pluginPath = path.join(tmpDir, `plugin-native-${Date.now()}.ts`)
  161. const pluginCode = `
  162. export async function NativeToolPlugin({ $, Tool, z }) {
  163. // Use z (zod) provided by the plugin system
  164. // Define a native tool using Tool.define from plugin input
  165. const MyNativeTool = Tool.define("my-native-tool", {
  166. description: "A native tool that runs local code",
  167. parameters: z.object({
  168. message: z.string().describe("Message to process"),
  169. count: z.number().optional().describe("Repeat count").default(1)
  170. }),
  171. async execute(args, ctx) {
  172. // This runs locally in the plugin process, not via HTTP!
  173. const result = args.message.repeat(args.count)
  174. const output = \`Processed: \${result}\`
  175. // Can also run shell commands directly
  176. const hostname = await $\`hostname\`.text()
  177. return {
  178. title: "Native Tool Result",
  179. output: output + " on " + hostname.trim(),
  180. metadata: { processedAt: new Date().toISOString() }
  181. }
  182. }
  183. })
  184. return {
  185. async ["tool.register"](_input, { register, registerHTTP }) {
  186. // Register our native tool
  187. register(MyNativeTool)
  188. // Can also register HTTP tools in the same plugin
  189. registerHTTP({
  190. id: "http-tool-from-same-plugin",
  191. description: "HTTP tool alongside native tool",
  192. parameters: { type: "object", properties: {} },
  193. callbackUrl: "http://localhost:9999/echo"
  194. })
  195. }
  196. }
  197. }
  198. `
  199. await Bun.write(pluginPath, pluginCode)
  200. await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
  201. await Instance.provide(tmpDir, async () => {
  202. const { Plugin } = await import("../../src/plugin")
  203. const { ToolRegistry } = await import("../../src/tool/registry")
  204. const { Server } = await import("../../src/server/server")
  205. await Plugin.init()
  206. // Both tools should be registered
  207. const ids = ToolRegistry.ids()
  208. expect(ids).toContain("my-native-tool")
  209. expect(ids).toContain("http-tool-from-same-plugin")
  210. // Verify via HTTP endpoint
  211. const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
  212. expect(res.status).toBe(200)
  213. const httpIds = (await res.json()) as string[]
  214. expect(httpIds).toContain("my-native-tool")
  215. expect(httpIds).toContain("http-tool-from-same-plugin")
  216. // Get tool details to verify native tool has proper structure
  217. const toolsRes = await Server.App.fetch(
  218. new Request("http://localhost:4096/experimental/tool?provider=anthropic&model=claude"),
  219. )
  220. expect(toolsRes.status).toBe(200)
  221. const tools = (await toolsRes.json()) as any[]
  222. const nativeTool = tools.find((t) => t.id === "my-native-tool")
  223. expect(nativeTool).toBeTruthy()
  224. expect(nativeTool.description).toBe("A native tool that runs local code")
  225. expect(nativeTool.parameters.properties.message).toBeTruthy()
  226. expect(nativeTool.parameters.properties.count).toBeTruthy()
  227. })
  228. })
  229. // Malformed plugin (no tool.register) should not throw and should not register anything
  230. test("Plugin without tool.register is handled gracefully", async () => {
  231. const tmpDir = path.join(os.tmpdir(), `opencode-test-project-noreg-${Date.now()}`)
  232. await Bun.$`mkdir -p ${tmpDir}`
  233. const pluginPath = path.join(tmpDir, `plugin-noreg-${Date.now()}.ts`)
  234. const pluginSrc = `
  235. export async function NoRegisterPlugin() {
  236. return {
  237. // no tool.register hook provided
  238. async config(_cfg) { /* noop */ }
  239. }
  240. }
  241. `
  242. await Bun.write(pluginPath, pluginSrc)
  243. await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
  244. await Instance.provide(tmpDir, async () => {
  245. const { Plugin } = await import("../../src/plugin")
  246. const { ToolRegistry } = await import("../../src/tool/registry")
  247. const { Server } = await import("../../src/server/server")
  248. await Plugin.init()
  249. // Ensure our specific id isn't present
  250. const ids = ToolRegistry.ids()
  251. expect(ids).not.toContain("malformed-tool")
  252. const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
  253. expect(res.status).toBe(200)
  254. const httpIds = (await res.json()) as string[]
  255. expect(httpIds).not.toContain("malformed-tool")
  256. })
  257. })