Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 17 additions & 8 deletions packages/plugin/src/promise/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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) =>
Expand All @@ -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,
})
Loading