fix(plugin): keep each Promise hook rejection on its own tool call (#55263)

Co-authored-by: 1052326311 <65798732+1052326311@users.noreply.github.com>
This commit is contained in:
Aiden Cline
2026-10-13 19:31:51 -05:00
committed by GitHub
Unverified
parent 9de9b746e3
commit 73517183b3
2 changed files with 55 additions and 8 deletions
+38
View File
@@ -54,6 +54,7 @@ import { SessionUsage } from "@opencode/core/session/usage"
import { PluginSupervisor } from "@opencode/core/plugin/supervisor"
import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { OptimizePlugin } from "@opencode/core/plugin/optimize"
import { IdentityPlugin } from "@opencode/core/plugin/identity"
import { QuestionTool } from "@opencode/core/tool/plugin/question"
@@ -4869,6 +4870,43 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("preserves each parallel Promise hook failure", function* (s) {
const hooks = yield* PluginHooks.Service
const calls: string[] = []
yield* PluginPromise.fromPromise({
id: "parallel-hook-errors",
async setup(context) {
await context.tool.hook("execute.before", (event) => {
calls.push(event.id)
if (event.id === "call-alpha") throw new Error("HOOK BLOCK ALPHA")
if (event.id === "call-beta") throw new Error("HOOK BLOCK BETA")
})
},
}).effect(host({ tool: { ...host().tool, hook: (name, callback) => hooks.register("tool", name, callback) } }))
yield* s.admit("Run parallel guarded tools")
yield* s.llm.push(
TestLLM.toolCalls(
LLMEvent.toolCall({ id: "call-alpha", name: "echo", input: { text: "alpha-block" } }),
LLMEvent.toolCall({ id: "call-beta", name: "echo", input: { text: "beta-block" } }),
LLMEvent.toolCall({ id: "call-ok", name: "echo", input: { text: "ok" } }),
),
TestLLM.stop(),
)
yield* s.resume
expect(calls).toEqual(["call-alpha", "call-beta", "call-ok"])
expect(s.executions).toEqual(["ok"])
expect(s.requests).toHaveLength(2)
expect(yield* s.context).toMatchObject([
Expected.user("Run parallel guarded tools"),
Expected.assistant({}, [
Expected.failedTool({ id: "call-alpha" }, { error: { message: "HOOK BLOCK ALPHA" } }),
Expected.failedTool({ id: "call-beta" }, { error: { message: "HOOK BLOCK BETA" } }),
Expected.completedTool({ id: "call-ok" }, { content: [{ type: "text", text: "ok" }] }),
]),
{ type: "assistant", finish: "stop" },
])
})
scenario("returns tool-wrapped policy blocks to the model and continues", function* (s) {
const registry = yield* Tool.Service
yield* transformTools(
+17 -8
View File
@@ -5,6 +5,7 @@ import { Effect, Schema, SchemaAST, Stream } from "effect"
import type { Scope } from "effect"
import { HttpApiEndpoint, HttpApiSchema } from "effect/http-api"
import { define } from "../effect/plugin.js"
import type { ToolFailures, ToolHooks } from "../effect/tool.js"
import type { Plugin } from "./plugin.js"
import type { Info } from "./tool.js"
import type { RpcDomain, RpcHandlers } from "./rpc.js"
@@ -510,7 +511,7 @@ export function fromPromise(plugin: Plugin) {
),
),
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
register(host.tool.hook(name, (event) => toolHook[name](() => Promise.resolve(callback(event))))),
},
vcs: {
get: adaptApiMethod(VcsEndpoints["vcs.get"], host.vcs.get),
@@ -625,6 +626,19 @@ function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
type RuntimeSchema = Schema.Codec<unknown, unknown>
const toolError = (error: unknown) =>
error instanceof Tool.Error
? error
: new Tool.Error({ message: error instanceof Error ? error.message : String(error), error })
// Only execute.before may reject a call; a rejection fails that call alone, like an Effect hook.
const toolHook: {
readonly [Name in keyof ToolHooks]: (run: () => Promise<void>) => Effect.Effect<void, ToolFailures[Name]>
} = {
"execute.before": (run) => Effect.tryPromise({ try: run, catch: toolError }),
"execute.after": (run) => Effect.promise(run),
}
const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
Effect.tryPromise({
try: (signal) =>
@@ -633,10 +647,5 @@ const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
signal,
progress: (update) => Effect.runPromise(context.progress(update), { signal }),
}),
catch: (error) => error,
}).pipe(
Effect.catch((error) => {
if (error instanceof Tool.Error) return Effect.fail(error)
return Effect.fail(new Tool.Error({ message: error instanceof Error ? error.message : String(error), error }))
}),
)
catch: toolError,
})