Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export namespace Plugin {
})

export async function trigger<
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool" | "route">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
Expand Down
163 changes: 163 additions & 0 deletions packages/opencode/test/plugin/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>", () => {
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()
})
})
})
91 changes: 91 additions & 0 deletions packages/opencode/test/plugin/trigger.test.ts
Original file line number Diff line number Diff line change
@@ -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<keyof Required<Hooks>, "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<keyof Required<Hooks>, "auth" | "event" | "tool" | "route">
const name: TriggerableHooks = "model.select"
type Fn = Required<Hooks>[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<Hooks>["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<Hooks>["model.select"] = async (_input, output) => {
output.subModel = "first"
}
const hook2: Required<Hooks>["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<Hooks>["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()
})
})
21 changes: 20 additions & 1 deletion packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,19 @@ export type PluginInput = {
directory: string
worktree: string
serverUrl: URL
getAuth?: (provider: string) => Promise<Auth | null>
$: BunShell
}

export type Plugin = (input: PluginInput) => Promise<Hooks>

export type AuthLoaderResult = Record<string, any> & {
getModel?: (sdk: any, modelID: string, options?: Record<string, any>) => any
}

export type AuthHook = {
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<AuthLoaderResult>
methods: (
| {
type: "oauth"
Expand Down Expand Up @@ -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<void>
/**
* Register HTTP routes for this plugin.
* Routes are mounted under /plugin/<prefix>/ 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<void>
}
Loading