mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
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:
committed by
GitHub
Unverified
parent
9de9b746e3
commit
73517183b3
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user