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, + })