From e3a3fa7108391000b9be0b9e2aed35437c2dc421 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:22:49 -0500 Subject: [PATCH] fix(plugin): forward Promise tool cancellation (#50190) --- .../core/test/plugin/promise-tool.test.ts | 55 +++++++++++++++++++ packages/plugin/src/promise/adapter.ts | 14 +++-- packages/plugin/src/promise/tool.ts | 1 + .../src/docs/content/build/plugins/index.mdx | 10 ++++ 4 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 packages/core/test/plugin/promise-tool.test.ts diff --git a/packages/core/test/plugin/promise-tool.test.ts b/packages/core/test/plugin/promise-tool.test.ts new file mode 100644 index 00000000000..b312b348d16 --- /dev/null +++ b/packages/core/test/plugin/promise-tool.test.ts @@ -0,0 +1,55 @@ +import { expect } from "bun:test" +import { Agent } from "@opencode/core/agent" +import { Plugin } from "@opencode/core/plugin" +import { PluginHost } from "@opencode/core/plugin/host" +import { PluginPromise } from "@opencode/core/plugin/promise" +import { Tool } from "@opencode/core/tool" +import { Session } from "@opencode/schema/session" +import { SessionMessage } from "@opencode/schema/session-message" +import { Cause, Deferred, Effect, Exit, Fiber } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +it.live("Promise tool executors receive interruption through their AbortSignal", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const tools = yield* Tool.Service + const started = yield* Deferred.make() + yield* PluginPromise.fromPromise({ + id: "cancel-tool", + async setup(context) { + await context.tool.transform((editor) => + editor.add({ + name: "wait", + description: "Wait until cancelled", + input: { type: "object", properties: {}, additionalProperties: false }, + options: { codemode: false }, + execute: (_input, tool) => + new Promise((_resolve, reject) => { + tool.signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }) + Effect.runSync(Deferred.succeed(started, tool.signal)) + }), + }), + ) + }, + }).effect(yield* PluginHost.make(plugins)) + + const snapshot = yield* tools.snapshot() + const fiber = yield* snapshot + .execute({ + sessionID: Session.ID.make("ses_promise_tool_cancel"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_promise_tool_cancel"), + call: { type: "tool-call", id: "call_promise_tool_cancel", name: "wait", input: {} }, + }) + .pipe(Effect.forkScoped) + const signal = yield* Deferred.await(started) + expect(signal.aborted).toBe(false) + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber) + expect(signal.aborted).toBe(true) + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) + }), +) diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 7fd49d85ab5..39f33866383 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -250,12 +250,13 @@ export function fromPromise(plugin: Plugin) { const promiseExecutor = (execute: Tool.Info["execute"]): Info["execute"] => - (input, context) => - run( + (input, tool) => + Effect.runPromiseWith(context)( execute(input, { - ...context, - progress: (update) => Effect.promise(() => context.progress(update)), + ...tool, + progress: (update) => Effect.promise(() => tool.progress(update)), }), + { signal: tool.signal }, ) const adaptApiMethod = ( @@ -612,9 +613,10 @@ function attempt(evaluate: (signal: AbortSignal) => PromiseLike) { type RuntimeSchema = Schema.Codec const executePromiseTool = (tool: Info, input: any, context: Tool.Context) => - Effect.promise(() => + Effect.promise((signal) => tool.execute(input, { ...context, - progress: (update) => Effect.runPromise(context.progress(update)), + signal, + progress: (update) => Effect.runPromise(context.progress(update), { signal }), }), ) diff --git a/packages/plugin/src/promise/tool.ts b/packages/plugin/src/promise/tool.ts index 4f6788b4243..5eb814e3980 100644 --- a/packages/plugin/src/promise/tool.ts +++ b/packages/plugin/src/promise/tool.ts @@ -9,6 +9,7 @@ import type { Types } from "effect" import type { Hooks, Transform } from "./registration.js" export interface ToolContext extends Omit { + readonly signal: AbortSignal readonly progress: (update: Tool.Metadata) => Promise } diff --git a/services/www/src/docs/content/build/plugins/index.mdx b/services/www/src/docs/content/build/plugins/index.mdx index 0e4d35bdc2c..8a1507c9dbe 100644 --- a/services/www/src/docs/content/build/plugins/index.mdx +++ b/services/www/src/docs/content/build/plugins/index.mdx @@ -927,6 +927,16 @@ const registration = await ctx.tool.transform((editor) => { }) ``` +Promise tool executors receive `tool.signal`. Pass it to cancellable work such as `fetch` so stopping the Session also +stops the underlying operation. + +```ts +execute: async ({ url }, tool) => { + const response = await fetch(url, { signal: tool.signal }) + return { content: await response.text() } +} +``` + Call `reload()` after changing source data captured by the callback. Reload replays the active transforms without changing their order; it does not rerun plugin setup.