diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts
index 022d5713692..431e5148184 100644
--- a/packages/core/test/session-runner.test.ts
+++ b/packages/core/test/session-runner.test.ts
@@ -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(
diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts
index 76b40b3e430..b66bccb378f 100644
--- a/packages/plugin/src/promise/adapter.ts
+++ b/packages/plugin/src/promise/adapter.ts
@@ -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(evaluate: (signal: AbortSignal) => PromiseLike) {
type RuntimeSchema = Schema.Codec
+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) => Effect.Effect
+} = {
+ "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,
+ })