From b119c55d3b5d03aef7b960bada41fc2221dc6f89 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 16:47:32 +0800 Subject: [PATCH] fix(plugin): run awaited plugin ctx.ask + ctx.metadata through EffectBridge Plugin tools declare ctx.ask as Promise and ctx.metadata as void, but the framework versions are Effects. The fromPlugin bridge spread toolCtx straight through, so the returned Effects were never executed: an awaited ctx.ask(...) resolved an inert Effect (permission prompt a silent no-op, tool ran as if granted) and ctx.metadata(...) discarded its Effect (title/metadata never applied). Bridge both through EffectBridge.make().promise(): ask is awaited, metadata is fire-and-forget (void contract) with a warn on failure. Update the plugin ToolContext ask contract to Promise (dropping the now-unused Effect import); metadata was already typed void. Regression test: a .opencode/tool plugin whose execute awaits ctx.ask and calls ctx.metadata now observes both framework Effects run exactly once (0 before the bridge, 1 after). Semantic port of anomalyco/opencode#28217 (ctx.metadata gap caught in review). --- packages/opencode/src/tool/registry.ts | 17 ++++- packages/opencode/test/tool/registry.test.ts | 70 ++++++++++++++++++++ packages/plugin/src/tool.ts | 3 +- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 0100cd102..d0e4e5368 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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" @@ -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, } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index d404e9675..ed6d084cc 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -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://") diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index b568d0371..23aa512d9 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -1,5 +1,4 @@ import { z } from "zod" -import { Effect } from "effect" export type ToolContext = { sessionID: string @@ -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 + ask(input: AskInput): Promise } type AskInput = {