diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 8790efac49be..8ed3944865c2 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -110,7 +110,7 @@ export namespace Plugin { }) export async function trigger< - Name extends Exclude, "auth" | "event" | "tool">, + Name extends Exclude, "auth" | "event" | "tool" | "route">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { diff --git a/packages/opencode/test/plugin/hooks.test.ts b/packages/opencode/test/plugin/hooks.test.ts new file mode 100644 index 000000000000..02a9445c86c9 --- /dev/null +++ b/packages/opencode/test/plugin/hooks.test.ts @@ -0,0 +1,163 @@ +import { test, expect, describe } from "bun:test" +import type { Hooks, PluginInput, Plugin, AuthLoaderResult } from "@opencode-ai/plugin" + +describe("plugin SDK types", () => { + describe("PluginInput.getAuth", () => { + test("is optional", () => { + const input: PluginInput = { + client: {} as any, + project: {} as any, + directory: "/tmp", + worktree: "/tmp", + serverUrl: new URL("http://localhost:4096"), + $: {} as any, + } + expect(input.getAuth).toBeUndefined() + }) + + test("accepts a function returning Auth", () => { + const input = { + client: {} as any, + project: {} as any, + directory: "/tmp", + worktree: "/tmp", + serverUrl: new URL("http://localhost:4096"), + getAuth: async () => ({ type: "api" as const, key: "glpat-test" }), + $: {} as any, + } satisfies PluginInput + expect(typeof input.getAuth).toBe("function") + }) + + test("accepts a function returning null", () => { + const input = { + client: {} as any, + project: {} as any, + directory: "/tmp", + worktree: "/tmp", + serverUrl: new URL("http://localhost:4096"), + getAuth: async () => null, + $: {} as any, + } satisfies PluginInput + expect(typeof input.getAuth).toBe("function") + }) + }) + + describe("Hooks.route", () => { + test("accepts function form", () => { + const hooks: Hooks = { + route: (_app) => {}, + } + expect(typeof hooks.route).toBe("function") + }) + + test("accepts object form with prefix", () => { + const hooks: Hooks = { + route: { prefix: "gitlab", handler: (_app) => {} }, + } + expect(hooks.route).toBeDefined() + expect(typeof hooks.route).toBe("object") + const route = hooks.route as { prefix: string; handler: (app: any) => void } + expect(route.prefix).toBe("gitlab") + expect(typeof route.handler).toBe("function") + }) + + test("is optional", () => { + const hooks: Hooks = {} + expect(hooks.route).toBeUndefined() + }) + }) + + describe("Hooks.model.select", () => { + test("accepts async handler with input and output", () => { + const hooks: Hooks = { + "model.select": async (input, output) => { + expect(input.providerID).toBeDefined() + expect(input.modelID).toBeDefined() + output.subModel = "claude_4" + output.displayName = "Claude 4" + }, + } + expect(typeof hooks["model.select"]).toBe("function") + }) + + test("input has optional sessionID", () => { + const hooks: Hooks = { + "model.select": async (input, _output) => { + void input.sessionID + }, + } + expect(hooks["model.select"]).toBeDefined() + }) + + test("is optional", () => { + const hooks: Hooks = {} + expect(hooks["model.select"]).toBeUndefined() + }) + }) + + describe("AuthLoaderResult", () => { + test("extends Record", () => { + const result: AuthLoaderResult = { + foo: "bar", + num: 42, + } + expect(result.foo).toBe("bar") + }) + + test("has optional getModel", () => { + const result: AuthLoaderResult = {} + expect(result.getModel).toBeUndefined() + }) + + test("getModel accepts sdk, modelID, options", () => { + const result: AuthLoaderResult = { + getModel: (sdk, modelID, options) => { + return { sdk, modelID, options } + }, + } + const model = result.getModel!({}, "test-model", { temperature: 0.5 }) + expect(model.modelID).toBe("test-model") + }) + }) + + describe("Plugin function", () => { + test("can return hooks with route and model.select", async () => { + const plugin: Plugin = async (_input) => ({ + route: { prefix: "test", handler: () => {} }, + "model.select": async (_input, output) => { + output.displayName = "Test" + }, + }) + const hooks = await plugin({ + client: {} as any, + project: {} as any, + directory: "/tmp", + worktree: "/tmp", + serverUrl: new URL("http://localhost:4096"), + $: {} as any, + }) + expect(hooks.route).toBeDefined() + expect(hooks["model.select"]).toBeDefined() + }) + + test("can return hooks without new fields (backward compat)", async () => { + const plugin: Plugin = async (_input) => ({ + auth: { + provider: "test", + methods: [], + }, + }) + const hooks = await plugin({ + client: {} as any, + project: {} as any, + directory: "/tmp", + worktree: "/tmp", + serverUrl: new URL("http://localhost:4096"), + $: {} as any, + }) + expect(hooks.auth?.provider).toBe("test") + expect(hooks.route).toBeUndefined() + expect(hooks["model.select"]).toBeUndefined() + }) + }) +}) diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts new file mode 100644 index 000000000000..3004dd3e9adf --- /dev/null +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -0,0 +1,91 @@ +import { test, expect, describe } from "bun:test" +import type { Hooks } from "@opencode-ai/plugin" + +describe("plugin trigger type constraints", () => { + test("route is excluded from triggerable hooks", () => { + type TriggerableHooks = Exclude, "auth" | "event" | "tool" | "route"> + const allowed: TriggerableHooks[] = [ + "config", + "chat.message", + "chat.params", + "chat.headers", + "permission.ask", + "command.execute.before", + "tool.execute.before", + "shell.env", + "tool.execute.after", + "experimental.chat.messages.transform", + "experimental.chat.system.transform", + "experimental.session.compacting", + "experimental.text.complete", + "tool.definition", + "model.select", + ] + expect(allowed).toContain("model.select") + // @ts-expect-error route should not be assignable to TriggerableHooks + const _bad: TriggerableHooks = "route" + void _bad + }) + + test("model.select is a callable hook", () => { + type TriggerableHooks = Exclude, "auth" | "event" | "tool" | "route"> + const name: TriggerableHooks = "model.select" + type Fn = Required[typeof name] + const fn: Fn = async (input, output) => { + output.subModel = "test" + output.displayName = input.modelID + } + expect(typeof fn).toBe("function") + }) + + test("model.select hook mutates output", async () => { + const hook: Required["model.select"] = async (_input, output) => { + output.subModel = "claude_4" + output.displayName = "Claude 4" + } + const output: { subModel?: string; displayName?: string } = {} + await hook({ providerID: "gitlab", modelID: "duo_workflow" }, output) + expect(output.subModel).toBe("claude_4") + expect(output.displayName).toBe("Claude 4") + }) + + test("route hook object form has correct shape", () => { + const hooks: Hooks = { + route: { + prefix: "gitlab", + handler: (app: any) => { + app.get("/test", () => {}) + }, + }, + } + const route = hooks.route as { prefix: string; handler: (app: any) => void } + expect(route.prefix).toBe("gitlab") + const calls: string[] = [] + route.handler({ get: (path: string) => calls.push(path) }) + expect(calls).toEqual(["/test"]) + }) + + test("multiple hooks chain model.select output", async () => { + const hook1: Required["model.select"] = async (_input, output) => { + output.subModel = "first" + } + const hook2: Required["model.select"] = async (_input, output) => { + if (!output.subModel) output.subModel = "second" + output.displayName = `Model: ${output.subModel}` + } + const output: { subModel?: string; displayName?: string } = {} + const input = { providerID: "gitlab", modelID: "duo_workflow" } + await hook1(input, output) + await hook2(input, output) + expect(output.subModel).toBe("first") + expect(output.displayName).toBe("Model: first") + }) + + test("model.select with no-op hook preserves empty output", async () => { + const hook: Required["model.select"] = async () => {} + const output: { subModel?: string; displayName?: string } = {} + await hook({ providerID: "openai", modelID: "gpt-4" }, output) + expect(output.subModel).toBeUndefined() + expect(output.displayName).toBeUndefined() + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index b78bcae177d4..5a6964add2d2 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -29,14 +29,19 @@ export type PluginInput = { directory: string worktree: string serverUrl: URL + getAuth?: (provider: string) => Promise $: BunShell } export type Plugin = (input: PluginInput) => Promise +export type AuthLoaderResult = Record & { + getModel?: (sdk: any, modelID: string, options?: Record) => any +} + export type AuthHook = { provider: string - loader?: (auth: () => Promise, provider: Provider) => Promise> + loader?: (auth: () => Promise, provider: Provider) => Promise methods: ( | { type: "oauth" @@ -231,4 +236,18 @@ export interface Hooks { * Modify tool definitions (description and parameters) sent to LLM */ "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise + /** + * Register HTTP routes for this plugin. + * Routes are mounted under /plugin// where prefix is + * `route.prefix`, `auth.provider`, or "unknown". + */ + route?: { prefix: string; handler: (app: any) => void } | ((app: any) => void) + /** + * Called when a model is selected. Allows plugins to resolve sub-models + * (e.g., workflow model discovery). + */ + "model.select"?: ( + input: { providerID: string; modelID: string; sessionID?: string }, + output: { subModel?: string; displayName?: string }, + ) => Promise }