Skip to content
Merged
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
17 changes: 16 additions & 1 deletion packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
import { Todo } from "../session/todo"
Expand Down Expand Up @@ -173,9 +174,23 @@ export namespace ToolRegistry {
description: def.description,
execute: (args, toolCtx) =>
Effect.gen(function* () {
// Plugin tools see `ask`/`metadata` as Promise/void (see
// @opencode-ai/plugin), but the framework versions are Effects.
// Without bridging, the `...toolCtx` spread hands the plugin the raw
// Effect: an awaited `ctx.ask(...)` resolves it unexecuted and a
// `ctx.metadata(...)` discards it — both silent no-ops. Bridge them
// so they actually run.
const bridge = yield* EffectBridge.make()
const pluginCtx: PluginToolContext = {
...toolCtx,
ask: (req) => toolCtx.ask(req),
ask: (req) => bridge.promise(toolCtx.ask(req)),
metadata: (input) => {
// `metadata` returns void in the plugin contract, so fire the
// bridged Effect and log on failure rather than dropping it.
void bridge.promise(toolCtx.metadata(input)).catch((err) => {
log.warn("failed to set plugin tool metadata", { error: String(err) })
})
},
directory: ctx.directory,
worktree: ctx.worktree,
}
Comment thread
Astro-Han marked this conversation as resolved.
Expand Down
70 changes: 70 additions & 0 deletions packages/opencode/test/tool/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,76 @@ describe("tool.registry", () => {
})
})

test("bridges plugin ctx.ask and ctx.metadata so the framework Effects actually run", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const toolDir = path.join(dir, ".opencode", "tool")
await fs.mkdir(toolDir, { recursive: true })
await Bun.write(
path.join(toolDir, "asker.ts"),
[
"export default {",
" description: 'asks for permission and sets metadata then resolves',",
" args: {},",
" execute: async (_args: unknown, ctx: any) => {",
" await ctx.ask({ permission: 'asker', patterns: [], always: [], metadata: {} })",
" ctx.metadata({ title: 'asked', metadata: { ok: true } })",
" return 'asked'",
" },",
"}",
"",
].join("\n"),
)
},
})

await withMockedConfigInstall(async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-5"),
agent: { name: "build", mode: "primary", permission: [], options: {} },
})
const asker = tools.find((tool) => tool.id === "asker")
expect(asker).toBeDefined()

// The framework `ask`/`metadata` are Effects. Plugin tools call them as a
// Promise (`await ctx.ask`) and `void` (`ctx.metadata`); without the
// EffectBridge the `...toolCtx` spread hands over the raw Effect and it is
// never executed — both silent no-ops. Counting runs proves the bridge runs
// them: `ask` is awaited, `metadata` is fire-and-forget.
let askRuns = 0
let metadataRuns = 0
const ctx = {
sessionID: SessionID.descending(),
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () =>
Effect.sync(() => {
metadataRuns++
}),
ask: () =>
Effect.sync(() => {
askRuns++
}),
extra: {},
}

const result = await Effect.runPromise(asker!.execute({}, ctx))
// `metadata` is fire-and-forget; flush pending microtasks before asserting.
await new Promise((resolve) => setTimeout(resolve, 10))
expect(askRuns).toBe(1)
expect(metadataRuns).toBe(1)
expect(result.output).toBe("asked")
},
})
})
})

test("local tool import spec normalizes filesystem paths to file URLs", () => {
const toolPath = path.resolve("pawwork-tools", "marked.ts")
expect(localToolImportSpec(toolPath)).toStartWith("file://")
Expand Down
3 changes: 1 addition & 2 deletions packages/plugin/src/tool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { z } from "zod"
import { Effect } from "effect"

export type ToolContext = {
sessionID: string
Expand All @@ -17,7 +16,7 @@ export type ToolContext = {
worktree: string
abort: AbortSignal
metadata(input: { title?: string; metadata?: { [key: string]: any } }): void
ask(input: AskInput): Effect.Effect<void>
ask(input: AskInput): Promise<void>
}

type AskInput = {
Expand Down
Loading