| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298 |
- import { describe, expect, test } from "bun:test"
- import path from "path"
- import os from "os"
- import { Instance } from "../../src/project/instance"
- // Helper to create a Request targeting the in-memory Hono app
- function makeRequest(method: string, url: string, body?: any) {
- const headers: Record<string, string> = { "content-type": "application/json" }
- const init: RequestInit = { method, headers }
- if (body !== undefined) init.body = JSON.stringify(body)
- return new Request(url, init)
- }
- describe("HTTP tool registration API", () => {
- test("POST /tool/register then list via /tool/ids and /tool", async () => {
- const projectRoot = path.join(__dirname, "../..")
- await Instance.provide(projectRoot, async () => {
- const { Server } = await import("../../src/server/server")
- const toolSpec = {
- id: "http-echo",
- description: "Simple echo tool (test-only)",
- parameters: {
- type: "object" as const,
- properties: {
- foo: { type: "string" as const, optional: true },
- bar: { type: "number" as const },
- },
- },
- callbackUrl: "http://localhost:9999/echo",
- }
- // Register
- const registerRes = await Server.App().fetch(
- makeRequest("POST", "http://localhost:4096/experimental/tool/register", toolSpec),
- )
- expect(registerRes.status).toBe(200)
- const ok = await registerRes.json()
- expect(ok).toBe(true)
- // IDs should include the new tool
- const idsRes = await Server.App().fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
- expect(idsRes.status).toBe(200)
- const ids = (await idsRes.json()) as string[]
- expect(ids).toContain("http-echo")
- // List tools for a provider/model and check JSON Schema shape
- const listRes = await Server.App().fetch(
- makeRequest("GET", "http://localhost:4096/experimental/tool?provider=openai&model=gpt-4o"),
- )
- expect(listRes.status).toBe(200)
- const list = (await listRes.json()) as Array<{ id: string; description: string; parameters: any }>
- const found = list.find((t) => t.id === "http-echo")
- expect(found).toBeTruthy()
- expect(found!.description).toBe("Simple echo tool (test-only)")
- // Basic JSON Schema checks
- expect(found!.parameters?.type).toBe("object")
- expect(found!.parameters?.properties?.bar?.type).toBe("number")
- const foo = found!.parameters?.properties?.foo
- // optional -> nullable for OpenAI/Azure providers; accept either type array including null or nullable: true
- const fooIsNullable = Array.isArray(foo?.type) ? foo.type.includes("null") : foo?.nullable === true
- expect(fooIsNullable).toBe(true)
- })
- })
- })
- describe("Plugin tool.register hook", () => {
- test("Plugin registers tool during Plugin.init()", async () => {
- // Create a temporary project directory with opencode.json that points to our plugin
- const tmpDir = path.join(os.tmpdir(), `opencode-test-project-${Date.now()}`)
- await Bun.$`mkdir -p ${tmpDir}`
- const tmpPluginPath = path.join(tmpDir, `test-plugin-${Date.now()}.ts`)
- const pluginCode = `
- export async function TestPlugin() {
- return {
- async ["tool.register"](_input, { registerHTTP }) {
- registerHTTP({
- id: "from-plugin",
- description: "Registered from test plugin",
- parameters: { type: "object", properties: { name: { type: "string", optional: true } } },
- callbackUrl: "http://localhost:9999/echo"
- })
- }
- }
- }
- `
- await Bun.write(tmpPluginPath, pluginCode)
- const configPath = path.join(tmpDir, "opencode.json")
- await Bun.write(configPath, JSON.stringify({ plugin: ["file://" + tmpPluginPath] }, null, 2))
- await Instance.provide(tmpDir, async () => {
- const { Plugin } = await import("../../src/plugin")
- const { ToolRegistry } = await import("../../src/tool/registry")
- const { Server } = await import("../../src/server/server")
- // Initialize plugins (will invoke our tool.register hook)
- await Plugin.init()
- // Confirm the tool is registered
- const allIDs = ToolRegistry.ids()
- expect(allIDs).toContain("from-plugin")
- // Also verify via the HTTP surface
- const idsRes = await Server.App().fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
- expect(idsRes.status).toBe(200)
- const ids = (await idsRes.json()) as string[]
- expect(ids).toContain("from-plugin")
- })
- })
- })
- test("Multiple plugins can each register tools", async () => {
- const tmpDir = path.join(os.tmpdir(), `opencode-test-project-multi-${Date.now()}`)
- await Bun.$`mkdir -p ${tmpDir}`
- // Create two plugin files
- const pluginAPath = path.join(tmpDir, `plugin-a-${Date.now()}.ts`)
- const pluginBPath = path.join(tmpDir, `plugin-b-${Date.now()}.ts`)
- const pluginA = `
- export async function PluginA() {
- return {
- async ["tool.register"](_input, { registerHTTP }) {
- registerHTTP({
- id: "alpha-tool",
- description: "Alpha tool",
- parameters: { type: "object", properties: { a: { type: "string", optional: true } } },
- callbackUrl: "http://localhost:9999/echo"
- })
- }
- }
- }
- `
- const pluginB = `
- export async function PluginB() {
- return {
- async ["tool.register"](_input, { registerHTTP }) {
- registerHTTP({
- id: "beta-tool",
- description: "Beta tool",
- parameters: { type: "object", properties: { b: { type: "number", optional: true } } },
- callbackUrl: "http://localhost:9999/echo"
- })
- }
- }
- }
- `
- await Bun.write(pluginAPath, pluginA)
- await Bun.write(pluginBPath, pluginB)
- // Config with both plugins
- await Bun.write(
- path.join(tmpDir, "opencode.json"),
- JSON.stringify({ plugin: ["file://" + pluginAPath, "file://" + pluginBPath] }, null, 2),
- )
- await Instance.provide(tmpDir, async () => {
- const { Plugin } = await import("../../src/plugin")
- const { ToolRegistry } = await import("../../src/tool/registry")
- const { Server } = await import("../../src/server/server")
- await Plugin.init()
- const ids = ToolRegistry.ids()
- expect(ids).toContain("alpha-tool")
- expect(ids).toContain("beta-tool")
- const res = await Server.App().fetch(new Request("http://localhost:4096/experimental/tool/ids"))
- expect(res.status).toBe(200)
- const httpIds = (await res.json()) as string[]
- expect(httpIds).toContain("alpha-tool")
- expect(httpIds).toContain("beta-tool")
- })
- })
- test("Plugin registers native/local tool with function execution", async () => {
- const tmpDir = path.join(os.tmpdir(), `opencode-test-project-native-${Date.now()}`)
- await Bun.$`mkdir -p ${tmpDir}`
- const pluginPath = path.join(tmpDir, `plugin-native-${Date.now()}.ts`)
- const pluginCode = `
- export async function NativeToolPlugin({ $, Tool, z }) {
- // Use z (zod) provided by the plugin system
-
- // Define a native tool using Tool.define from plugin input
- const MyNativeTool = Tool.define("my-native-tool", {
- description: "A native tool that runs local code",
- parameters: z.object({
- message: z.string().describe("Message to process"),
- count: z.number().optional().describe("Repeat count").default(1)
- }),
- async execute(args, ctx) {
- // This runs locally in the plugin process, not via HTTP!
- const result = args.message.repeat(args.count)
- const output = \`Processed: \${result}\`
-
- // Can also run shell commands directly
- const hostname = await $\`hostname\`.text()
-
- return {
- title: "Native Tool Result",
- output: output + " on " + hostname.trim(),
- metadata: { processedAt: new Date().toISOString() }
- }
- }
- })
-
- return {
- async ["tool.register"](_input, { register, registerHTTP }) {
- // Register our native tool
- register(MyNativeTool)
-
- // Can also register HTTP tools in the same plugin
- registerHTTP({
- id: "http-tool-from-same-plugin",
- description: "HTTP tool alongside native tool",
- parameters: { type: "object", properties: {} },
- callbackUrl: "http://localhost:9999/echo"
- })
- }
- }
- }
- `
- await Bun.write(pluginPath, pluginCode)
- await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
- await Instance.provide(tmpDir, async () => {
- const { Plugin } = await import("../../src/plugin")
- const { ToolRegistry } = await import("../../src/tool/registry")
- const { Server } = await import("../../src/server/server")
- await Plugin.init()
- // Both tools should be registered
- const ids = ToolRegistry.ids()
- expect(ids).toContain("my-native-tool")
- expect(ids).toContain("http-tool-from-same-plugin")
- // Verify via HTTP endpoint
- const res = await Server.App().fetch(new Request("http://localhost:4096/experimental/tool/ids"))
- expect(res.status).toBe(200)
- const httpIds = (await res.json()) as string[]
- expect(httpIds).toContain("my-native-tool")
- expect(httpIds).toContain("http-tool-from-same-plugin")
- // Get tool details to verify native tool has proper structure
- const toolsRes = await Server.App().fetch(
- new Request("http://localhost:4096/experimental/tool?provider=anthropic&model=claude"),
- )
- expect(toolsRes.status).toBe(200)
- const tools = (await toolsRes.json()) as any[]
- const nativeTool = tools.find((t) => t.id === "my-native-tool")
- expect(nativeTool).toBeTruthy()
- expect(nativeTool.description).toBe("A native tool that runs local code")
- expect(nativeTool.parameters.properties.message).toBeTruthy()
- expect(nativeTool.parameters.properties.count).toBeTruthy()
- })
- })
- // Malformed plugin (no tool.register) should not throw and should not register anything
- test("Plugin without tool.register is handled gracefully", async () => {
- const tmpDir = path.join(os.tmpdir(), `opencode-test-project-noreg-${Date.now()}`)
- await Bun.$`mkdir -p ${tmpDir}`
- const pluginPath = path.join(tmpDir, `plugin-noreg-${Date.now()}.ts`)
- const pluginSrc = `
- export async function NoRegisterPlugin() {
- return {
- // no tool.register hook provided
- async config(_cfg) { /* noop */ }
- }
- }
- `
- await Bun.write(pluginPath, pluginSrc)
- await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
- await Instance.provide(tmpDir, async () => {
- const { Plugin } = await import("../../src/plugin")
- const { ToolRegistry } = await import("../../src/tool/registry")
- const { Server } = await import("../../src/server/server")
- await Plugin.init()
- // Ensure our specific id isn't present
- const ids = ToolRegistry.ids()
- expect(ids).not.toContain("malformed-tool")
- const res = await Server.App().fetch(new Request("http://localhost:4096/experimental/tool/ids"))
- expect(res.status).toBe(200)
- const httpIds = (await res.json()) as string[]
- expect(httpIds).not.toContain("malformed-tool")
- })
- })
|