fix(plugin): forward Promise tool cancellation (#50190)

This commit is contained in:
Aiden Cline
2026-09-20 11:22:49 -05:00
committed by GitHub
parent 6238af397e
commit e3a3fa7108
4 changed files with 74 additions and 6 deletions
@@ -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<AbortSignal>()
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<never>((_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)
}),
)
+8 -6
View File
@@ -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 = <PromiseMethod>(
@@ -612,9 +613,10 @@ function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
type RuntimeSchema = Schema.Codec<unknown, unknown>
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 }),
}),
)
+1
View File
@@ -9,6 +9,7 @@ import type { Types } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolContext extends Omit<Tool.Context, "progress"> {
readonly signal: AbortSignal
readonly progress: (update: Tool.Metadata) => Promise<void>
}
@@ -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.