mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 06:56:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f327f66931 |
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)}` }),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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}`]
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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,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,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 })
|
||||
|
||||
Reference in New Issue
Block a user