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 = {