Compare commits

...
Author SHA1 Message Date
Kit Langton f327f66931 fix(core): make tool declines explicit 2026-08-28 21:49:47 -04:00
47 changed files with 1001 additions and 244 deletions
+13 -5
View File
@@ -1,8 +1,8 @@
export * as CodeModeTool from "./tool.js"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import type { Content, Context, Declined, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Deferred, Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
const ExecuteFile = Schema.Struct({
@@ -43,7 +43,7 @@ const description = [
export const create = (
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error | Declined>,
) => {
return {
name: "execute",
@@ -52,6 +52,7 @@ export const create = (
output: ExecuteOutput,
execute: ({ code }, context) =>
Effect.gen(function* () {
const declined = yield* Deferred.make<never, Declined>()
const callIndex = yield* Ref.make(0)
const files = yield* Ref.make<Array<CollectedFiles>>([])
const calls = yield* Ref.make<Array<ExecuteCall>>([])
@@ -60,12 +61,17 @@ export const create = (
lock.withPermit(
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
const execution = runtime(
registrations,
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const executed = yield* executeTool(name, tool, input, context)
// A decline stops the host execution; never expose it as a catchable JavaScript rejection.
const executed = yield* executeTool(name, tool, input, context).pipe(
Effect.catchTag("Tool.Declined", (error) =>
Deferred.fail(declined, error).pipe(Effect.andThen(Effect.never)),
),
)
const content =
typeof executed.content === "string"
? [{ type: "text" as const, text: executed.content }]
@@ -99,6 +105,8 @@ export const create = (
},
},
).execute(code)
// Register the interpreter fiber with the race before a synchronous tool can signal a decline.
const result = yield* Effect.raceFirst(Deferred.await(declined), Effect.andThen(Effect.yieldNow, execution))
const toolCalls = yield* Ref.get(calls)
const collected = (yield* Ref.get(files))
.toSorted((left, right) => left.index - right.index)
+1 -1
View File
@@ -207,7 +207,7 @@ export const layer = Layer.effect(
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
(entry) => Deferred.interrupt(entry.deferred).pipe(Effect.andThen(cancel(entry.form.id)), Effect.ignore),
{ discard: true },
),
),
+7 -13
View File
@@ -61,7 +61,7 @@ export type AskResult = typeof AskResult.Type
export { Event } from "@opencode-ai/schema/permission"
export class DeclinedError extends Schema.TaggedError<DeclinedError>()("Permission.DeclinedError", {}) {}
export class Declined extends Schema.TaggedError<Declined>()("Permission.Declined", {}) {}
export class CorrectedError extends Schema.TaggedError<CorrectedError>()("Permission.CorrectedError", {
feedback: Schema.String,
@@ -82,7 +82,7 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Permissi
requestID: ID,
}) {}
export type Error = BlockedError | CorrectedError
export type Error = BlockedError | CorrectedError | Declined
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
return (
@@ -114,7 +114,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pe
interface Pending {
readonly request: Request
readonly agent?: Agent.ID
readonly deferred: Deferred.Deferred<void, DeclinedError | CorrectedError>
readonly deferred: Deferred.Deferred<void, Declined | CorrectedError>
}
const layer = Layer.effect(
@@ -129,7 +129,7 @@ const layer = Layer.effect(
const pending = new Map<ID, Pending>()
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
Effect.forEach(pending.values(), (item) => Deferred.interrupt(item.deferred), {
discard: true,
}).pipe(
Effect.ensuring(
@@ -199,7 +199,7 @@ const layer = Layer.effect(
const create = (request: Request, agent?: Agent.ID) =>
Effect.uninterruptible(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
const deferred = yield* Deferred.make<void, Declined | CorrectedError>()
const item = { request, agent, deferred }
if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
@@ -234,12 +234,6 @@ const layer = Layer.effect(
if (result.effect === "allow") return
const item = yield* create(request(input, result.message), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
@@ -265,7 +259,7 @@ const layer = Layer.effect(
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(),
input.message ? new CorrectedError({ feedback: input.message }) : new Declined(),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
@@ -275,7 +269,7 @@ const layer = Layer.effect(
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new DeclinedError())
yield* Deferred.fail(item.deferred, new Declined())
pending.delete(id)
}
return
+1 -1
View File
@@ -20,7 +20,7 @@ export interface Domains {
type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs.
// Only tool execute.before may fail, with Tool.Error or Tool.Declined before the call runs.
interface Failures extends Record<keyof Domains, unknown> {
readonly aisdk: NoFailures<AISDKHooks>
readonly session: NoFailures<SessionHooks>
+1 -1
View File
@@ -90,7 +90,7 @@ export const layer = Layer.effect(
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
Cause.hasInterruptsOnly(cause) || Cause.squash(cause) instanceof UserInterruptedError
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
+3 -25
View File
@@ -5,13 +5,11 @@ import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Content } from "@opencode-ai/schema/tool"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { Config, Context, Effect, Layer, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionRunnerModel } from "./runner/model.js"
@@ -28,23 +26,6 @@ const IMAGE_REMOVED =
const responsesWebSocketFlag = (providerID: string) =>
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
// tunnel entered in Permission.assert and the question tool), so a user's "no" can
// never become model-facing tool output. They resurface as typed failures exactly once,
// here at the seam the runner executes through.
const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
const decline = cause.reasons.flatMap((reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof Permission.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
? [reason.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
}
export interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
@@ -53,7 +34,7 @@ export interface Prepared {
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
*/
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
readonly executeTool: Tool.Snapshot["execute"]
}
interface PrepareInput {
@@ -361,10 +342,7 @@ export const layer = Layer.effect(
? { webSocket: transport.bind(session.id) }
: {}),
}
const executeTool: Prepared["executeTool"] = (input) =>
tools
.execute({ ...input, definitions: hooked })
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
const executeTool: Prepared["executeTool"] = (input) => tools.execute({ ...input, definitions: hooked })
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
return {
request,
+16 -29
View File
@@ -13,12 +13,10 @@ import type { Agent } from "@opencode-ai/schema/agent"
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
import { Snapshot } from "../../snapshot.js"
import { Tool } from "../../tool.js"
import { ToolOutput } from "../../tool-output.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { StepFailedError } from "../error.js"
import { StepFailedError, UserInterruptedError } from "../error.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionModelRequest } from "../model-request.js"
@@ -80,7 +78,7 @@ export const make = Effect.gen(function* () {
})
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, Permission.DeclinedError | QuestionTool.CancelledError>
readonly fiber: Fiber.Fiber<void, Tool.Declined>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const executeTool = (call: ToolCall) => {
@@ -141,8 +139,10 @@ export const make = Effect.gen(function* () {
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
if (Exit.isFailure(joined)) yield* interruptTools
const tools = classifyToolExits(joined, toolRuns)
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
if (
!interrupted &&
!publisher.record().outputStarted &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(input.recoverOverflow))
@@ -163,6 +163,7 @@ export const make = Effect.gen(function* () {
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
if (
!interrupted &&
input.recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
@@ -170,7 +171,7 @@ export const make = Effect.gen(function* () {
)
return Outcome.RecoverFull()
const retry =
llmFailure && llmError && !isContextOverflowFailure(llmFailure)
!interrupted && llmFailure && llmError && !isContextOverflowFailure(llmFailure)
? yield* restore(
input.retry(
llmFailure,
@@ -190,12 +191,8 @@ export const make = Effect.gen(function* () {
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
message: decline.reason.message,
})
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
const toolFailure = interrupted
? TOOLS_INTERRUPTED
: tools.failure !== undefined
@@ -240,20 +237,12 @@ export const make = Effect.gen(function* () {
})
}
if (
llmFailure &&
llmError &&
retry?.retry &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
if (llmFailure && llmError && retry?.retry && record.outputStarted && !interrupted)
return Outcome.Continue({ error: llmError, decision: retry })
if (tools.declines.length > 0 && !streamInterrupted) return yield* new UserInterruptedError()
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return Outcome.Completed({
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
@@ -273,7 +262,7 @@ const isInterruptedStream = (failure: AIError) => {
/** Tool.Error settles in each fiber; only user declines remain in the typed error channel. */
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>>>,
settled: Exit.Exit<Array<Exit.Exit<void, Tool.Declined>>>,
runs: ReadonlyArray<{ readonly call: ToolCall }>,
) => {
const exits = Exit.isSuccess(settled) ? settled.value : []
@@ -287,12 +276,10 @@ const classifyToolExits = (
const causes = Exit.isFailure(settled)
? [settled.cause]
: exits.flatMap((exit) => (Exit.isFailure(exit) ? [exit.cause] : []))
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.filter(Cause.isDieReason)
return reasons.length > 0 ? [Cause.fromReasons<never>(reasons)] : []
})
.at(0)
return { interrupted: causes.some(Cause.hasInterrupts), declines, failure }
const failure = causes.find(Cause.hasInterrupts) ?? causes.find(Cause.hasDies)
return {
interrupted: failure !== undefined && Cause.hasInterrupts(failure),
declines,
failure: failure && Cause.fromReasons<never>(failure.reasons.filter((reason) => !Cause.isFailReason(reason))),
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
export * as Tool from "./tool.js"
export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
export { CallID, Content, Declined, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool"
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
@@ -51,7 +51,7 @@ export interface Snapshot {
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
/** Surviving request definitions, keyed by the names advertised after session context hooks. */
readonly definitions?: ReadonlyMap<string, ToolDefinition>
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error | Tool.Declined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Tool") {}
+1 -1
View File
@@ -26,7 +26,7 @@ const source = {
}
```
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive. User declines from `Permission.assert` and question dismissals travel as defects beneath leaf `mapError` blankets and resurface as typed failures at `SessionModelRequest.executeTool`; leaves must never catch or convert them. A decline with feedback (`Permission.CorrectedError`) stays typed so the leaf converts it into `ToolFailure` and the model continues.
Leaves own resolution, permission, and side-effect ordering. Registration accepts executors with arbitrary typed failures; `Tool.Snapshot.execute` normalizes ordinary failures into `Tool.Error` and preserves `Tool.Declined`. Translate expected operational errors into `ToolFailure` only when supplying curated messages or metadata, using narrow catches or mappings at the failing operation rather than blanket executor mappings. `Permission.assert` fails with typed `Permission.Declined`, which the tool execution boundary translates into `Tool.Declined`; question dismissals fail with `Tool.Declined` directly. Leaves must preserve these control failures. A decline with feedback (`Permission.CorrectedError`) remains an ordinary model-facing error so the model continues. Do not use `catchCause`: interruption and defects must survive.
## Registration
+3 -4
View File
@@ -104,10 +104,9 @@ export const layer = Layer.effect(
...(content.length === 0 ? {} : { content }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
() => new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
),
})
+11 -4
View File
@@ -224,10 +224,17 @@ export const Plugin = {
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
Effect.catchTag(
[
"PlatformError",
"Environment.NotFound",
"Environment.WrongKind",
"Environment.Failed",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
),
)
},
+10 -4
View File
@@ -128,10 +128,16 @@ export const Plugin = {
),
metadata: { count: result.entries.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
Effect.catchTag(
[
"PlatformError",
"Environment.Failed",
"Ripgrep.Error",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
),
),
}),
+14 -6
View File
@@ -151,12 +151,20 @@ export const Plugin = {
),
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
Effect.catchTag(
"Ripgrep.InvalidPatternError",
(error) => new ToolFailure({ message: `Invalid regex pattern: ${error.message}` }),
),
Effect.catchTag(
[
"PlatformError",
"Environment.Failed",
"Ripgrep.Error",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
),
),
}),
+6 -6
View File
@@ -185,10 +185,9 @@ export const Plugin = {
})
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
Effect.catchTag(
["PlatformError", "Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
),
)
}
@@ -289,8 +288,9 @@ export const Plugin = {
content: toModelContent(output),
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: "Unable to apply patch", error }),
),
)
},
+7 -11
View File
@@ -6,6 +6,7 @@ import { Effect, Schema } from "effect"
import { Form } from "../../form.js"
import { Permission } from "../../permission.js"
import { Question } from "@opencode-ai/schema/question"
import { Tool } from "@opencode-ai/schema/tool"
export const name = "question"
@@ -29,12 +30,6 @@ export const Output = Schema.Struct({
})
export type Output = typeof Output.Type
export class CancelledError extends Schema.TaggedError<CancelledError>()("QuestionTool.CancelledError", {}) {
override get message() {
return "The user dismissed this question"
}
}
export const toModelContent = (questions: ReadonlyArray<Question.Prompt>, answers: ReadonlyArray<Question.Answer>) => {
const formatted = questions
.map(
@@ -69,7 +64,10 @@ export const Plugin = {
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.pipe(
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: "Permission denied: question", error }),
),
Effect.andThen(
forms
.ask({
@@ -87,10 +85,8 @@ export const Plugin = {
.pipe(Effect.orDie),
),
Effect.flatMap((state) => {
// Deliberate defect tunnel (see Permission.assert): a dismissal must dodge
// leaf `mapError` blankets so it never becomes model-facing tool output; it
// resurfaces as a typed failure at SessionModelRequest.executeTool.
if (state.status === "cancelled") return Effect.die(new CancelledError())
if (state.status === "cancelled")
return new Tool.Declined({ message: "The user dismissed this question" })
const output = {
answers: input.questions.map((_, index): Question.Answer => {
const value = state.answer[`q${index}`]
+19 -11
View File
@@ -116,17 +116,25 @@ export const Plugin = {
content: toModelContent(input.path, input.offset, output),
metadata: { truncated: output.type === "file" ? false : output.truncated },
})),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
error instanceof ReadToolFileSystem.PathKindError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
}),
Effect.catchTag(
[
"ReadTool.BinaryFileError",
"ReadTool.MediaIngestLimitError",
"ReadTool.OffsetOutOfRangeError",
"ReadTool.PathKindError",
],
(error) => new ToolFailure({ message: error.message, error }),
),
Effect.catchTag(
[
"PlatformError",
"Environment.Failed",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to read ${input.path}`, error }),
),
)
},
}),
+35 -7
View File
@@ -187,7 +187,11 @@ export const Plugin = {
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
portable,
})
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
)
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({
path: LocationMutation.resolvePath(target.absolute, directory),
@@ -219,12 +223,20 @@ export const Plugin = {
source,
})
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
Effect.catchTag(
"Environment.NotFound",
() =>
new ToolFailure({
message: `Unable to execute command: ${input.command}`,
error: new Error(`Working directory does not exist: ${target.absolute}`),
}),
),
)
if (workdir !== "directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
return yield* new ToolFailure({
message: `Unable to execute command: ${input.command}`,
error: new Error(`Working directory is not a directory: ${target.absolute}`),
})
}),
)
yield* context.progress({ shellID: info.id })
@@ -309,13 +321,29 @@ export const Plugin = {
return backgroundResult(info.id, info.file)
}
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return yield* new ToolFailure({
message: `Unable to execute command: ${input.command}`,
error: new Error(result.info.error ?? "Command failed"),
})
if (result?.info.status === "cancelled")
return yield* new ToolFailure({
message: `Unable to execute command: ${input.command}`,
error: new Error("Command cancelled"),
})
return yield* Deferred.await(settled)
}).pipe(
Effect.map(toolResult),
Effect.mapError(
Effect.catchTag(
[
"PlatformError",
"Environment.Failed",
"AppProcessError",
"Shell.NotFoundError",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
),
+12 -4
View File
@@ -47,8 +47,8 @@ export const Plugin = {
Effect.gen(function* () {
const skill = yield* skills.get(input.id)
if (!skill) return yield* unableToLoad(input.id)
return yield* Effect.gen(function* () {
yield* permission.assert({
yield* permission
.assert({
action: name,
resources: [skill.id],
save: [skill.id],
@@ -56,8 +56,16 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
return { name: skill.name, ...(yield* Skill.prepare(fs, skill)) }
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
.pipe(
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => unableToLoad(input.id, error),
),
)
return {
name: skill.name,
...(yield* Skill.prepare(fs, skill).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))),
}
}).pipe(
Effect.map((output) => ({
output,
+6 -1
View File
@@ -147,7 +147,12 @@ export const Plugin = {
id: context.id,
},
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
.pipe(
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error }),
),
)
const existing =
input.sessionID === undefined
+20 -12
View File
@@ -118,18 +118,25 @@ export const Plugin = {
Effect.gen(function* () {
yield* Effect.try({
try: () => assertHttpUrl(new URL(input.url)),
catch: (error) => error,
catch: (error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }),
})
yield* permission.assert({
action: name,
resources: [input.url],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
yield* permission
.assert({
action: name,
resources: [input.url],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.pipe(
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }),
),
)
const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, input.url, input.format).pipe(
@@ -147,11 +154,12 @@ export const Plugin = {
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
orElse: () => Effect.fail(new Error("Request timed out")),
}),
Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error })),
)
const content = new TextDecoder().decode(body)
const output = yield* Effect.try({
try: () => convert(content, contentType, input.format),
catch: (error) => error,
catch: (error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }),
})
const result = {
url: input.url,
@@ -160,7 +168,7 @@ export const Plugin = {
output,
}
return { output: result, content: result.output, metadata: { contentType: result.contentType } }
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
}),
}),
)
.pipe(Effect.orDie)
+48 -39
View File
@@ -41,15 +41,22 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
yield* permission
.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.pipe(
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
)
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
websearch.default().pipe(
Effect.flatMap((provider) => {
@@ -146,7 +153,37 @@ export const Plugin = {
)
}),
)
const result = yield* search()
const result = yield* search().pipe(
Effect.mapError((error) => {
const fallback = `Unable to search the web for ${input.query}`
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
const status = HttpClientError.isHttpClientError(error.cause)
? error.cause.response?.status
: undefined
switch (status) {
case 429:
return new ToolFailure({
message: "Web search rate limited (HTTP 429)",
error,
metadata: { provider: error.providerID },
})
case 401:
return new ToolFailure({
message: "Web search authentication failed (HTTP 401)",
error,
metadata: { provider: error.providerID },
})
case undefined:
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
default:
return new ToolFailure({
message: `Web search request failed (HTTP ${status})`,
error,
metadata: { provider: error.providerID },
})
}
}),
)
const output = {
provider: result.data.providerID,
results: result.data.results,
@@ -163,35 +200,7 @@ export const Plugin = {
.join("\n\n")
: NO_RESULTS
return { output, content, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError((error) => {
const fallback = `Unable to search the web for ${input.query}`
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
switch (status) {
case 429:
return new ToolFailure({
message: "Web search rate limited (HTTP 429)",
error,
metadata: { provider: error.providerID },
})
case 401:
return new ToolFailure({
message: "Web search authentication failed (HTTP 401)",
error,
metadata: { provider: error.providerID },
})
case undefined:
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
default:
return new ToolFailure({
message: `Web search request failed (HTTP ${status})`,
error,
metadata: { provider: error.providerID },
})
}
}),
),
}),
}),
)
.pipe(Effect.orDie)
+12 -1
View File
@@ -99,7 +99,18 @@ export const Plugin = {
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelContent(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
Effect.catchTag(
[
"PlatformError",
"Environment.NotFound",
"Environment.WrongKind",
"Environment.Failed",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to write ${input.path}`, error }),
),
),
}),
)
+21 -12
View File
@@ -1,8 +1,9 @@
import type { ToolDefinition } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
import { Cache, Cause, Effect, JsonSchema, Result, Schema, SchemaIssue, SchemaRepresentation } from "effect"
import { $ZodType, toJSONSchema } from "zod/v4/core"
import { Permission } from "../permission.js"
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
@@ -27,18 +28,26 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool, input)
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
// downstream and leave its call permanently unsettled, so the declared contract is
// enforced here at the untrusted boundary. Declines tunnel through as defects and
// interrupts are not errors; neither is touched.
// Keep user decisions distinct from failures the model can recover from.
const result = yield* tool.execute(decoded, context).pipe(
Effect.mapError((error: unknown) =>
error instanceof Tool.Error
? error
: new Tool.Error({
message: error instanceof globalThis.Error ? error.message : String(error),
}),
// Promise wrappers turn inner Effect failures into exceptions. Only recover a lone decline;
// an accompanying interruption or defect must retain its original cause.
Effect.catchCauseFilter((cause) => {
const reason = cause.reasons[0]
return cause.reasons.length === 1 &&
Cause.isDieReason(reason) &&
(reason.defect instanceof Permission.Declined || reason.defect instanceof Tool.Declined)
? Result.succeed(reason.defect)
: Result.fail(cause)
}, Effect.fail),
Effect.mapError((error) =>
error instanceof Permission.Declined
? new Tool.Declined({ message: "The user declined this tool call" })
: error instanceof Tool.Error || error instanceof Tool.Declined
? error
: new Tool.Error({
message: error instanceof globalThis.Error ? error.message : String(error),
}),
),
)
if (tool.output === undefined) {
+24 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber } from "effect"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
@@ -51,6 +51,29 @@ describe("Form", () => {
}),
)
it.effect("interrupts pending asks on service teardown rather than returning a dismissal", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* Layer.buildWithScope(Layer.fresh(Form.layer), scope)
const service = Context.get(context, Form.Service)
const bus = yield* Bus.Service
const created = yield* Deferred.make<void>()
const unsubscribe = yield* bus.listen((event) =>
event.type === Form.Event.Created.type ? Deferred.succeed(created, undefined).pipe(Effect.asVoid) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
yield* Deferred.await(created)
yield* Scope.close(scope, Exit.void)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(yield* service.state(formID)).toEqual({ status: "cancelled" })
}),
)
it.effect("supports the temporary global mcp elicitation owner", () =>
Effect.gen(function* () {
const service = yield* Form.Service
+1 -1
View File
@@ -83,7 +83,7 @@ export interface ToolExecution {
export const executeTool = (
registry: Tool.Interface,
input: Parameters<Tool.Snapshot["execute"]>[0],
): Effect.Effect<ToolExecution> =>
): Effect.Effect<ToolExecution, Tool.Declined> =>
registry.snapshot().pipe(
Effect.flatMap((tools) => tools.execute(input)),
Effect.map((result) => ({ status: "completed" as const, ...result }) satisfies ToolExecution),
+31 -16
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -26,20 +26,19 @@ const current = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionStore.node,
PermissionSaved.node,
Agent.node,
PluginHooks.node,
Permission.node,
]),
[[Location.node, current]],
),
const layer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionStore.node,
PermissionSaved.node,
Agent.node,
PluginHooks.node,
Permission.node,
]),
[[Location.node, current]],
)
const it = testEffect(layer)
function setup(rules: Permission.Ruleset = [], sessionID = Session.ID.make("ses_test")) {
return Effect.gen(function* () {
@@ -338,7 +337,7 @@ describe("Permission", () => {
}),
)
it.effect("defects when an asked permission is declined", () =>
it.effect("fails with a typed decline when an asked permission is rejected", () =>
Effect.gen(function* () {
yield* setup()
const { service, fiber, request } = yield* waitForRequest()
@@ -349,13 +348,29 @@ describe("Permission", () => {
if (exit._tag === "Failure")
expect(
exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
(reason) => Cause.isFailReason(reason) && reason.error instanceof Permission.Declined,
),
).toBe(true)
expect(yield* service.list()).toEqual([])
}),
)
it.effect("interrupts pending assertions on service teardown without reporting a user decline", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* Layer.buildWithScope(Layer.fresh(layer), scope)
yield* setup().pipe(Effect.provide(context))
const pending = yield* waitForRequest().pipe(Effect.provide(context))
yield* Scope.close(scope, Exit.void)
const exit = yield* Fiber.await(pending.fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(yield* pending.service.list()).toEqual([])
}),
)
it.effect("stores and removes saved resources for a project", () =>
Effect.gen(function* () {
yield* setup()
+194 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { Cause, DateTime, Effect, Exit, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -9,6 +9,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { Permission } from "@opencode-ai/core/permission"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Vcs } from "@opencode-ai/core/vcs"
import { Session } from "@opencode-ai/core/session"
@@ -726,6 +727,198 @@ describe("fromPromise", () => {
}),
)
it.live("preserves canonical declines from Promise tools and wrapped Effect tools", () =>
Effect.gen(function* () {
const PromisePlugin = yield* Effect.promise(() => import("@opencode-ai/plugin"))
const EffectPlugin = yield* Effect.promise(() => import("@opencode-ai/plugin/effect"))
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const declined = new PromisePlugin.Tool.Declined({ message: "The user declined this tool call" })
expect(PromisePlugin.Tool.Declined).toBe(Tool.Declined)
expect(EffectPlugin.Tool.Declined).toBe(Tool.Declined)
yield* host.tool.transform((draft) => {
for (const name of ["effect_decline", "wrapped_decline", "wrapped_permission_decline"]) {
draft.add({
name,
description: "Decline",
options: { codemode: false },
input: Schema.Struct({}),
execute: () => Effect.fail(name === "wrapped_permission_decline" ? new Permission.Declined() : declined),
})
}
})
yield* PluginPromise.fromPromise(
PromisePlugin.Plugin.define({
id: "promise-tool-declined",
setup: async (ctx) => {
await ctx.tool.transform((draft) => {
draft.add({
name: "sync_decline",
description: "Decline synchronously",
options: { codemode: false },
input: Schema.Struct({}),
execute: () => {
throw declined
},
})
draft.add({
name: "async_decline",
description: "Decline asynchronously",
options: { codemode: false },
input: Schema.Struct({}),
execute: async () => {
throw declined
},
})
for (const name of ["wrapped_decline", "wrapped_permission_decline"])
draft.update(name, (tool) => {
const execute = tool.execute
tool.execute = async (input, context) => execute(input, context)
})
})
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
for (const name of [
"sync_decline",
"async_decline",
"effect_decline",
"wrapped_decline",
"wrapped_permission_decline",
]) {
const error = yield* snapshot
.execute({
sessionID: Session.ID.make("ses_plugin_declined"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_plugin_declined"),
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.Declined)
if (name !== "wrapped_permission_decline") expect(error).toBe(declined)
expect(error.message).toBe("The user declined this tool call")
}
}),
)
it.live("preserves declines from Effect and synchronous or asynchronous Promise before hooks", () =>
Effect.gen(function* () {
const PromisePlugin = yield* Effect.promise(() => import("@opencode-ai/plugin"))
const EffectPlugin = yield* Effect.promise(() => import("@opencode-ai/plugin/effect"))
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const declined = new EffectPlugin.Tool.Declined({ message: "The plugin declined this tool call" })
const calls: string[] = []
yield* host.tool.transform((draft) => {
for (const name of ["effect_decline", "sync_decline", "async_decline"]) {
draft.add({
name,
description: "Must not execute",
options: { codemode: false },
input: Schema.Struct({}),
execute: () =>
Effect.sync(() => {
calls.push(name)
return { content: "executed" }
}),
})
}
})
yield* host.tool.hook("execute.before", (event) =>
event.tool === "effect_decline" ? Effect.fail(declined) : Effect.void,
)
yield* PluginPromise.fromPromise(
PromisePlugin.Plugin.define({
id: "promise-before-declined",
setup: async (ctx) => {
await ctx.tool.hook("execute.before", (event) => {
if (event.tool === "sync_decline") throw declined
})
await ctx.tool.hook("execute.before", async (event) => {
if (event.tool === "async_decline") throw declined
})
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
for (const name of ["effect_decline", "sync_decline", "async_decline"]) {
const error = yield* snapshot
.execute({
sessionID: Session.ID.make("ses_plugin_before_declined"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_plugin_before_declined"),
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
})
.pipe(Effect.flip)
expect(error).toBe(declined)
expect(error.message).toBe("The plugin declined this tool call")
}
expect(calls).toEqual([])
}),
)
it.live("keeps unrelated Promise exceptions and after-hook declines as defects", () =>
Effect.gen(function* () {
const PromisePlugin = yield* Effect.promise(() => import("@opencode-ai/plugin"))
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const failure = new Error("Unexpected plugin failure")
const declined = new PromisePlugin.Tool.Declined({ message: "Too late to decline" })
yield* PluginPromise.fromPromise(
PromisePlugin.Plugin.define({
id: "promise-tool-defects",
setup: async (ctx) => {
await ctx.tool.transform((draft) => {
for (const name of ["sync_defect", "async_defect", "before_defect", "after_decline"]) {
draft.add({
name,
description: "Fail unexpectedly",
options: { codemode: false },
input: Schema.Struct({}),
execute: () => {
if (name === "sync_defect") throw failure
if (name === "async_defect") return Promise.reject(failure)
return Promise.resolve({ content: "executed" })
},
})
}
})
await ctx.tool.hook("execute.before", async (event) => {
if (event.tool === "before_defect") throw failure
})
await ctx.tool.hook("execute.after", (event) => {
if (event.tool === "after_decline") throw declined
})
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
for (const name of ["sync_defect", "async_defect", "before_defect", "after_decline"]) {
const exit = yield* snapshot
.execute({
sessionID: Session.ID.make("ses_plugin_defects"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_plugin_defects"),
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.hasDies(exit.cause)).toBe(true)
expect(Cause.squash(exit.cause)).toBe(name === "after_decline" ? declined : failure)
}
}
}),
)
it.live("adapts listed and retrieved tool executors without invoking them eagerly", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -136,6 +136,28 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("releases a declined execution's claim without resuming it after restart", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_claim_declined")
yield* seedSessions(database, [sessionID])
const drained: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, ({ sessionID }) =>
Effect.sync(() => drained.push(sessionID)).pipe(Effect.andThen(new UserInterruptedError())),
)
const execution = Context.get(context, SessionExecution.Service)
const error = yield* execution.resume(sessionID).pipe(Effect.flip)
expect(error).toBeInstanceOf(UserInterruptedError)
yield* execution.awaitIdle(sessionID)
expect((yield* claims(database))[sessionID]).toBe(false)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
expect(drained).toEqual([sessionID])
}),
)
it.effect("reports an idle interrupt as a no-op", () =>
Effect.gen(function* () {
const sessionID = Session.ID.make("ses_idle_cancel")
+89 -5
View File
@@ -50,7 +50,7 @@ import { SessionUsage } from "@opencode-ai/core/session/usage"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { Agent } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
@@ -3877,7 +3877,7 @@ describe("SessionRunnerLLM", () => {
description: "Fail because the user declined approval",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new Permission.DeclinedError()),
execute: () => Effect.fail(new Permission.Declined()),
},
},
{ codemode: false },
@@ -3894,7 +3894,7 @@ describe("SessionRunnerLLM", () => {
const exit = yield* s.resume.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
if (exit._tag === "Failure") expect(Cause.squash(exit.cause)).toBeInstanceOf(UserInterruptedError)
expect(s.requests).toHaveLength(1)
expect(yield* s.context).toMatchObject([
Expected.user("Call declined"),
@@ -3908,6 +3908,43 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("does not continue a failed provider stream after a tool decline", function* (s) {
const registry = yield* Tool.Service
yield* transformTools(
registry,
{
declined: {
name: "declined",
description: "Decline execution",
input: Schema.Struct({}),
execute: () => new Tool.Declined({ message: "The user declined this tool call" }),
},
},
{ codemode: false },
)
yield* s.admit("Call declined")
yield* s.llm.push(
TestLLM.failAfter(
streamDisconnected(),
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }),
),
)
const error = yield* s.resume.pipe(Effect.flip)
expect(error).toBeInstanceOf(UserInterruptedError)
expect(s.requests).toHaveLength(1)
expect(yield* s.context).toMatchObject([
Expected.user("Call declined"),
Expected.assistant({}, [
Expected.failedTool(
{ id: "call-declined" },
{ error: { type: "aborted", message: "The user declined this tool call" } },
),
]),
])
})
scenario("returns permission corrections to the model and continues", function* (s) {
const registry = yield* Tool.Service
yield* transformTools(
@@ -3982,7 +4019,7 @@ describe("SessionRunnerLLM", () => {
description: "Ask the user",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new QuestionTool.CancelledError()),
execute: () => Effect.fail(new Tool.Declined({ message: "The user dismissed this question" })),
},
},
{ codemode: false },
@@ -3995,7 +4032,7 @@ describe("SessionRunnerLLM", () => {
const exit = yield* Fiber.join(run)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
if (exit._tag === "Failure") expect(Cause.squash(exit.cause)).toBeInstanceOf(UserInterruptedError)
expect(s.requests).toHaveLength(1)
expect(yield* s.context).toMatchObject([
Expected.user("Ask then stop"),
@@ -4008,6 +4045,53 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("preserves form-service teardown as shutdown interruption after tool settlement", function* (s) {
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* Layer.buildWithScope(Layer.fresh(Form.layer), scope)
const forms = Context.get(context, Form.Service)
const registry = yield* Tool.Service
const execution = yield* SessionExecution.Service
const opened = yield* Deferred.make<void>()
const unsubscribe = yield* s.bus.listen((event) =>
event.type === Form.Event.Created.type ? Deferred.succeed(opened, undefined).pipe(Effect.asVoid) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* transformTools(
registry,
{
waiting: {
name: "waiting",
description: "Wait for a form answer",
input: Schema.Struct({}),
execute: () =>
forms
.ask({ sessionID, title: "Question", fields: [{ key: "answer", type: "string" }] })
.pipe(Effect.as({ content: "answered" })),
},
},
{ codemode: false },
)
yield* s.admit("Ask a question")
yield* s.llm.push(TestLLM.tool("call-waiting", "waiting", {}))
const run = yield* execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped)
yield* Deferred.await(opened)
yield* Scope.close(scope, Exit.void)
const exit = yield* Fiber.join(run)
expect(SessionExecution.terminal(exit)).toEqual({ type: "interrupted", reason: "shutdown" })
expect(s.requests).toHaveLength(1)
expect(yield* s.context).toMatchObject([
Expected.user("Ask a question"),
Expected.assistant({}, [
Expected.failedTool(
{ id: "call-waiting" },
{ error: { type: "aborted", message: "Tool execution interrupted" } },
),
]),
])
})
scenario("awaits started local tools before surfacing provider stream failure", function* (s) {
yield* s.admit("Settle before failing")
// Non-retryable so the step settles terminally instead of continuing after tool output.
+1 -1
View File
@@ -111,7 +111,7 @@ for (const fixture of [
executeTool: () =>
Effect.sync(() => {
executions++
return { content: "Completed tool" }
return { content: [{ type: "text", text: "Completed tool" }] }
}),
},
retry: (_cause, _error, retry) =>
+53 -6
View File
@@ -1,12 +1,14 @@
import { expect, test } from "bun:test"
import { CodeModeTool } from "@opencode-ai/core/codemode/tool"
import { Tool } from "@opencode-ai/core/tool"
import { execute } from "@opencode-ai/core/tool/runtime"
import { Permission } from "@opencode-ai/core/permission"
import { definition, execute } from "@opencode-ai/core/tool/runtime"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Info } from "@opencode-ai/schema/tool"
import { Effect, Schema } from "effect"
import { Declined } from "@opencode-ai/schema/tool"
import { Cause, Effect, Exit, Schema } from "effect"
const context = {
sessionID: Session.ID.make("ses_execute"),
@@ -98,18 +100,63 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
class ForeignFailure extends Schema.TaggedError<ForeignFailure>()("Plugin.ForeignFailure", {
message: Schema.String,
}) {}
const lying: Info = {
name: "lying",
const foreign: Info = {
name: "foreign",
description: "Fails with a non-Tool.Error typed failure",
input: Schema.Struct({}),
execute: () => new ForeignFailure({ message: "transport died" }) as never,
execute: () => new ForeignFailure({ message: "transport died" }),
}
const error = await Effect.runPromise(execute(lying, {}, context).pipe(Effect.flip))
const error = await Effect.runPromise(execute(foreign, {}, context).pipe(Effect.flip))
expect(error).toBeInstanceOf(Tool.Error)
expect(error.message).toBe("transport died")
})
test("execution classifies permission declines without leaking the permission failure", async () => {
const tool: Info = {
name: "declined",
description: "Decline execution",
input: Schema.Struct({}),
execute: () => new Permission.Declined(),
}
const error = await Effect.runPromise(execute(tool, {}, context).pipe(Effect.flip))
expect(Tool.Declined).toBe(Declined)
expect(error).toBeInstanceOf(Tool.Declined)
expect(error).not.toBeInstanceOf(Permission.Declined)
if (!(error instanceof Tool.Declined)) throw error
expect(Schema.encodeSync(Declined)(error)).toEqual({
_tag: "Tool.Declined",
message: "The user declined this tool call",
})
expect(definition(tool)).not.toHaveProperty("outputSchema")
})
test("execution recovers lone decline exceptions but preserves compound causes", async () => {
const declined = new Tool.Declined({ message: "The user declined this tool call" })
const tool: Info = {
name: "exception",
description: "Fail through an exception boundary",
input: Schema.Struct({}),
execute: () => Effect.die(declined),
}
expect(await Effect.runPromise(execute(tool, {}, context).pipe(Effect.flip))).toBe(declined)
for (const cause of [
Cause.combine(Cause.die(declined), Cause.die("unexpected defect")),
Cause.combine(Cause.die(new Permission.Declined()), Cause.interrupt()),
]) {
const exit = await Effect.runPromiseExit(execute({ ...tool, execute: () => Effect.failCause(cause) }, {}, context))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(exit.cause.reasons.map((reason) => reason._tag)).toEqual(cause.reasons.map((reason) => reason._tag))
expect(exit.cause.reasons.filter(Cause.isDieReason).map((reason) => reason.defect)).toEqual(
cause.reasons.filter(Cause.isDieReason).map((reason) => reason.defect),
)
}
}
})
test("execute supports callable namespace tools", async () => {
const callable: Info = {
name: "admin",
+2 -1
View File
@@ -255,7 +255,8 @@ describe("QuestionTool", () => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error).toBeInstanceOf(QuestionTool.CancelledError)
expect(error).toBeInstanceOf(Tool.Declined)
expect(Cause.hasDies(exit.cause)).toBe(false)
expect(error).toHaveProperty("message", "The user dismissed this question")
}
}),
+170
View File
@@ -202,6 +202,176 @@ describe("Tool", () => {
}),
)
for (const [name, code] of [
["try/catch", 'try { await tools.decline({}) } catch { return "recovered" }'],
["Promise.allSettled", 'await Promise.allSettled([tools.decline({})]); return "recovered"'],
["an unawaited call", 'tools.decline({}); return "escaped"'],
] as const) {
it.effect(`stops Code Mode on a decline despite ${name}`, () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
const declined = new Tool.Declined({ message: "User declined this tool" })
const observed: string[] = []
const after: string[] = []
const progress: Tool.Metadata[] = []
const tool: Info = {
name: "decline",
description: "Decline execution",
input: Schema.Struct({}),
execute: () => Effect.sync(() => observed.push("decline")).pipe(Effect.andThen(Effect.fail(declined))),
}
yield* transform(service, { decline: tool, echo: make() })
yield* transform(service, { direct: tool }, { codemode: false })
yield* hooks.register("tool", "execute.after", (event) => Effect.sync(() => after.push(event.tool)))
const snapshot = yield* service.snapshot()
expect(yield* snapshot.execute(call("direct")).pipe(Effect.flip)).toBe(declined)
expect(
yield* snapshot
.execute({
...call("execute"),
call: { type: "tool-call", id: "declined", name: "execute", input: { code } },
progress: (update) => Effect.sync(() => progress.push(update)),
})
.pipe(Effect.flip),
).toBe(declined)
expect(observed).toEqual(["decline", "decline"])
expect(after).toEqual([])
expect(progress).toEqual([
{ toolCalls: [{ tool: "decline", status: "running" }] },
{ toolCalls: [{ tool: "decline", status: "error" }] },
])
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "healthy",
name: "execute",
input: { code: 'return await tools.echo({ text: "healthy" })' },
},
})
expect(result.output).toMatchObject({ output: '{\n "text": "healthy"\n}' })
expect(after).toEqual(["echo", "execute"])
}),
)
}
it.effect("awaits already-running Code Mode sibling cleanup before returning a decline", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
const declined = new Tool.Declined({ message: "User declined this tool" })
const started = yield* Deferred.make<void>()
const cleaning = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const events: string[] = []
const after: string[] = []
const progress: Tool.Metadata[] = []
yield* transform(service, {
sibling: {
name: "sibling",
description: "Wait until interrupted",
input: Schema.Struct({}),
execute: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(
Deferred.succeed(cleaning, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Effect.sync(() => events.push("cleaned"))),
),
),
),
},
decline: {
name: "decline",
description: "Decline after the sibling starts",
input: Schema.Struct({}),
execute: () => Deferred.await(started).pipe(Effect.andThen(Effect.fail(declined))),
},
})
yield* hooks.register("tool", "execute.after", (event) => Effect.sync(() => after.push(event.tool)))
const snapshot = yield* service.snapshot()
const fiber = yield* snapshot
.execute({
...call("execute"),
call: {
type: "tool-call",
id: "siblings",
name: "execute",
input: { code: "return await Promise.allSettled([tools.sibling({}), tools.decline({})])" },
},
progress: (update) => Effect.sync(() => progress.push(update)),
})
.pipe(Effect.forkChild)
yield* Deferred.await(cleaning)
expect(fiber.pollUnsafe()).toBeUndefined()
expect(events).toEqual([])
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(fiber).pipe(Effect.flip)).toBe(declined)
expect(events).toEqual(["cleaned"])
expect(after).toEqual([])
expect(progress.at(-1)).toEqual({
toolCalls: [
{ tool: "sibling", status: "error" },
{ tool: "decline", status: "error" },
],
})
}),
)
it.effect("keeps Code Mode tool errors and operational defects catchable", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
const after: string[] = []
yield* transform(service, {
failed: {
name: "failed",
description: "Fail with an ordinary tool error",
input: Schema.Struct({}),
execute: () => Effect.fail(new Tool.Error({ message: "Expected tool failure" })),
},
defect: {
name: "defect",
description: "Fail with an operational defect",
input: Schema.Struct({}),
execute: () => Effect.die(new Error("Unexpected tool defect")),
},
})
yield* hooks.register("tool", "execute.after", (event) =>
Effect.sync(() => after.push(`${event.tool}:${event.status}`)),
)
const snapshot = yield* service.snapshot()
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "recovered",
name: "execute",
input: {
code: `const caught = [];
try { await tools.failed({}) } catch (error) { caught.push(error.message) }
const settled = await Promise.allSettled([tools.defect({})]);
caught.push(settled[0].reason.message);
return caught;`,
},
},
})
expect(result.output).toMatchObject({
output: '[\n "Expected tool failure",\n "Unexpected tool defect"\n]',
})
expect(result.metadata).toEqual({
toolCalls: [
{ tool: "failed", status: "error" },
{ tool: "defect", status: "error" },
],
})
expect(after).toEqual(["failed:error", "execute:completed"])
}),
)
it.effect("replays mutations on refreshed sources and restores tools on disposal and scope cleanup", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+70 -3
View File
@@ -35,7 +35,11 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { EditTool } from "@opencode-ai/core/tool/plugin/edit"
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
import { WriteTool } from "@opencode-ai/core/tool/plugin/write"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
@@ -288,6 +292,69 @@ const runPermissionCommand = (
return { exit, requests }
}).pipe(Effect.scoped, Effect.timeout(Duration.seconds(5)))
permissionIt.live("preserves declines through file-tool wrappers before mutation", () =>
withScanner(false, (registry, fixture) =>
Effect.gen(function* () {
yield* registerToolPlugin(ReadTool.Plugin)
yield* registerToolPlugin(WriteTool.Plugin)
yield* registerToolPlugin(EditTool.Plugin)
yield* registerToolPlugin(PatchTool.Plugin)
const target = path.join(fixture.active, "target.txt")
const outside = path.join(fixture.outside, "target.txt")
yield* Effect.promise(() => Promise.all([Bun.write(target, "before\n"), Bun.write(outside, "outside\n")]))
const permission = yield* Permission.Service
const bus = yield* Bus.Service
const requests = yield* Queue.unbounded<Permission.Request>()
yield* bus.subscribe(Permission.Event.Asked).pipe(
Stream.runForEach((event) => Queue.offer(requests, event.data)),
Effect.forkScoped({ startImmediately: true }),
)
const tools = yield* registry.snapshot()
for (const fixture of [
{ name: "read", action: "read", input: { path: target } },
{ name: "write", action: "edit", input: { path: target, content: "after\n" } },
{ name: "edit", action: "edit", input: { path: target, oldString: "before", newString: "after" } },
{
name: "patch",
action: "edit",
input: { patchText: `*** Begin Patch\n*** Delete File: ${target}\n*** End Patch` },
},
{
name: "patch",
action: "external_directory",
input: { patchText: `*** Begin Patch\n*** Delete File: ${outside}\n*** End Patch` },
},
]) {
const execution = yield* tools
.execute({
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: `decline-${fixture.name}-${fixture.action}`,
name: fixture.name,
input: fixture.input,
},
})
.pipe(Effect.forkScoped)
const request = yield* Queue.take(requests)
expect(request.action).toBe(fixture.action)
yield* permission.reply({ requestID: request.id, reply: "reject" })
expect(yield* Fiber.await(execution)).toMatchObject({
_tag: "Failure",
cause: {
reasons: [{ _tag: "Fail", error: new Tool.Declined({ message: "The user declined this tool call" }) }],
},
})
expect(yield* Effect.promise(() => Bun.file(target).text())).toBe("before\n")
expect(yield* Effect.promise(() => Bun.file(outside).text())).toBe("outside\n")
expect(yield* permission.list()).toEqual([])
}
expect(yield* Queue.size(requests)).toBe(0)
}).pipe(Effect.scoped, Effect.timeout(Duration.seconds(5))),
),
)
// Directory cases still document inherited limitations; fixed scanner cases require matching behavior.
describe("ShellTool scanner permissions", () => {
const test = isWindows || !Bun.which("sh") ? permissionIt.live.skip : permissionIt.live
@@ -327,7 +394,7 @@ describe("ShellTool scanner permissions", () => {
if (Exit.isFailure(result.exit))
expect(
result.exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
(reason) => Cause.isFailReason(reason) && reason.error instanceof Tool.Declined,
),
).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
@@ -424,7 +491,7 @@ describe("ShellTool scanner permissions", () => {
if (Exit.isFailure(result.exit))
expect(
result.exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
(reason) => Cause.isFailReason(reason) && reason.error instanceof Tool.Declined,
),
).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
@@ -505,7 +572,7 @@ describe("ShellTool scanner permissions", () => {
if (Exit.isFailure(result.exit))
expect(
result.exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
(reason) => Cause.isFailReason(reason) && reason.error instanceof Tool.Declined,
),
).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
+1 -1
View File
@@ -390,7 +390,7 @@ describe("WebSearchTool registration", () => {
const sessionError = toSessionError(error)
expect(sessionError).toEqual({ type: "tool.execution", message })
expect(sessionError.message).not.toContain("secret")
expect(error.metadata).toEqual({ provider: "exa" })
expect(error).toHaveProperty("metadata", { provider: "exa" })
expect(progress).toEqual([{ provider: "exa" }])
}),
{ discard: true },
+16
View File
@@ -124,6 +124,22 @@ await ctx.tool.transform((tools) => {
})
```
## Declining A Tool
Tools and `execute.before` hooks can decline a call by throwing the canonical
`Tool.Declined`. OpenCode treats this as a refused interaction, not an ordinary
tool error for the model to recover from. Other thrown errors remain defects.
```ts
import { Tool } from "@opencode-ai/plugin"
await ctx.tool.hook("execute.before", (event) => {
if (event.tool === "deploy") {
throw new Tool.Declined({ message: "Deployment was declined" })
}
})
```
## Reloading A Domain
When data captured by a transform changes, reload the affected domain:
+15
View File
@@ -107,6 +107,21 @@ yield *
)
```
## Declining A Tool
Tools and `execute.before` hooks can fail with the canonical `Tool.Declined`.
OpenCode treats this as a refused interaction, not an ordinary tool error for
the model to recover from. Defects and interruptions retain their meaning.
```ts
import { Tool } from "@opencode-ai/plugin/effect"
yield *
ctx.tool.hook("execute.before", (event) =>
event.tool === "deploy" ? Effect.fail(new Tool.Declined({ message: "Deployment was declined" })) : Effect.void,
)
```
## Reloading A Domain
When data captured by a transform changes, reload the affected domain:
+1
View File
@@ -13,5 +13,6 @@ export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
export { Tool } from "@opencode-ai/schema/tool"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
+2 -2
View File
@@ -44,9 +44,9 @@ export interface ToolHooks {
)
}
// Only execute.before may fail: a Tool.Error rejects the call before the tool runs.
// Only execute.before may fail, rejecting or declining the call before the tool runs.
export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
readonly "execute.before": Tool.Error
readonly "execute.before": Tool.Error | Tool.Declined
readonly "execute.after": never
}
+13 -2
View File
@@ -3,6 +3,7 @@ import { Effect, Schema, SchemaAST, Stream } from "effect"
import type { Scope } from "effect"
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
import { define } from "../effect/plugin.js"
import type { ToolFailures } from "../effect/tool.js"
import type { Context, Plugin } from "./plugin.js"
import type { Info } from "./tool.js"
@@ -341,7 +342,17 @@ 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) =>
Effect.promise(() => Promise.resolve(callback(event))).pipe(
Effect.catchDefect((error) =>
name === "execute.before" && error instanceof Tool.Declined
? Effect.fail(error as ToolFailures[typeof name])
: Effect.die(error),
),
),
),
),
},
vcs: {
get: adaptApiMethod(VcsEndpoints["vcs.get"], host.vcs.get),
@@ -431,4 +442,4 @@ const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
)
).pipe(Effect.catchDefect((error) => (error instanceof Tool.Declined ? Effect.fail(error) : Effect.die(error))))
+1
View File
@@ -14,5 +14,6 @@ export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
export { Tool } from "@opencode-ai/schema/tool"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
+1 -1
View File
@@ -1,4 +1,4 @@
export { CallID, Error } from "@opencode-ai/schema/tool"
export { CallID, Declined, Error } from "@opencode-ai/schema/tool"
export type { Metadata, Options, Result } from "@opencode-ai/schema/tool"
import { Tool } from "@opencode-ai/schema/tool"
@@ -12,11 +12,13 @@ import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Skill } from "@opencode-ai/schema/skill"
import { Tool } from "@opencode-ai/schema/tool"
import { Vcs } from "@opencode-ai/schema/vcs"
import { WebSearch } from "@opencode-ai/schema/websearch"
const Plugin = await import("../src/effect/index")
const PromisePlugin = await import("../src/promise/index")
const PromiseTool = await import("../src/promise/tool")
const TuiPlugin = await import("../src/tui/index")
test.each([
@@ -35,6 +37,8 @@ test.each([
expect(entrypoint.Provider).toBe(Provider)
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Skill).toBe(Skill)
expect(entrypoint.Tool).toBe(Tool)
expect(entrypoint.Tool.Declined).toBe(Tool.Declined)
expect(entrypoint.Vcs).toBe(Vcs)
expect(entrypoint.WebSearch).toBe(WebSearch)
expect(Object.keys(entrypoint).sort()).toEqual([
@@ -51,6 +55,7 @@ test.each([
"Provider",
"Reference",
"Skill",
"Tool",
"Vcs",
"WebSearch",
])
@@ -63,6 +68,10 @@ test.each([
expect(plugin.vcs).toEqual({ markers: [".svn"] })
})
test("Promise tool entrypoint exposes the canonical decline", () => {
expect(PromiseTool.Declined).toBe(Tool.Declined)
})
test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
+7 -1
View File
@@ -59,6 +59,11 @@ export class Error extends Schema.TaggedError<Error>()("Tool.Error", {
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
/** A refused interaction, not a model-facing tool error or a shutdown interruption. */
export class Declined extends Schema.TaggedError<Declined>()("Tool.Declined", {
message: Schema.String,
}) {}
export interface TextContent extends Schema.Schema.Type<typeof TextContent> {}
export const TextContent = Schema.Struct({
type: Schema.Literal("text"),
@@ -91,7 +96,8 @@ export type Info<
readonly name: string
readonly input: Input
readonly description: string
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Result<Output>, Error>
/** Producer failures are classified by the host at execution, not at registration. */
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Result<Output>, unknown>
readonly output?: Output
readonly options?: Options
}
+1 -1
View File
@@ -1,3 +1,3 @@
export { RegistrationError } from "@opencode-ai/core/tool"
export { Error } from "@opencode-ai/schema/tool"
export { Declined, Error } from "@opencode-ai/schema/tool"
export type { Context, Info } from "@opencode-ai/schema/tool"
+1 -1
View File
@@ -1,3 +1,3 @@
export { RegistrationError } from "@opencode-ai/core/tool"
export { Error } from "@opencode-ai/schema/tool"
export { Declined, Error } from "@opencode-ai/schema/tool"
export type { ToolContext as Context, Info } from "@opencode-ai/plugin/promise/tool"
@@ -15,6 +15,7 @@ import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Tool } from "@opencode-ai/schema/tool"
import { Api } from "@opencode-ai/server/api"
import { ClientApi, groupNames, promiseOmitEndpoints } from "@opencode-ai/protocol/client"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
@@ -92,6 +93,12 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(Workspace.ID.create()).toStartWith("wrk_")
})
test("both SDK tool facades expose the canonical decline", async () => {
const EffectSDK = await import("../src/effect/index")
expect(SDK.Tool.Declined).toBe(Tool.Declined)
expect(EffectSDK.Tool.Declined).toBe(Tool.Declined)
})
test("client and Server contracts generate identically", () => {
const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints })
const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })