register.test.ts 11 KB

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