Compare commits

...
1 Commits
Author SHA1 Message Date
Kit Langton b259709590 fix(core): attribute deferred skill plugin failures 2026-09-02 22:45:07 -04:00
15 changed files with 430 additions and 6 deletions
@@ -1634,7 +1634,7 @@ export function make(options: ClientOptions) {
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 500],
empty: false,
},
requestOptions,
@@ -2488,6 +2488,15 @@ export type PermissionNotFoundError = {
export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError"
export type PluginCallbackError = {
readonly _tag: "PluginCallbackError"
readonly pluginID: string
readonly operation: "skill.transform"
readonly message: string
}
export const isPluginCallbackError = (value: unknown): value is PluginCallbackError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginCallbackError"
export type RpcError = {
readonly _tag: "RpcError"
readonly type: string
+17
View File
@@ -15,6 +15,23 @@ import {
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
test("skill.list decodes a declared plugin callback failure", async () => {
const failure = {
_tag: "PluginCallbackError",
pluginID: "broken-skills",
operation: "skill.transform",
message: 'Plugin "broken-skills" failed during skill.transform. Check server logs for details.',
}
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(failure, { status: 500 }))),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.skill.list().pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toMatchObject(failure)
})
test("health.get decodes the readiness response", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))),
+16 -1
View File
@@ -1,5 +1,20 @@
import { expect, test } from "bun:test"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
import { isPluginCallbackError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
test("skill.list preserves a declared plugin callback failure", async () => {
const failure = {
_tag: "PluginCallbackError",
pluginID: "broken-skills",
operation: "skill.transform",
message: 'Plugin "broken-skills" failed during skill.transform. Check server logs for details.',
}
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () => Response.json(failure, { status: 500 }),
})
await expect(client.skill.list()).rejects.toEqual(failure)
expect(isPluginCallbackError(failure)).toBe(true)
})
test("exposes every standard HTTP API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
+1 -3
View File
@@ -43,9 +43,7 @@ const layer = Layer.effect(
const load = Effect.fnUntraced(function* (plugin: Generation) {
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() =>
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
).pipe(
const loaded = yield* Effect.suspend(() => plugin.effect(PluginHost.forPlugin(host, kv, plugin.id))).pipe(
inherit,
Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, child).pipe(
+14
View File
@@ -0,0 +1,14 @@
export * as PluginCallback from "./callback.js"
import { Data } from "effect"
/** Local failure detail. Transport boundaries must explicitly select public fields. */
export class Error extends Data.TaggedError("PluginCallbackError")<{
readonly pluginID: string
readonly operation: "skill.transform"
readonly cause: unknown
}> {
override get message() {
return `Plugin "${this.pluginID}" failed during ${this.operation}.`
}
}
+20
View File
@@ -33,6 +33,7 @@ import { WebSearch } from "../websearch.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
import { PluginCallback } from "./callback.js"
import type { Interface } from "../plugin.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -518,6 +519,25 @@ export const requirements = LayerNode.group([
LocationServiceMap.node,
])
export function forPlugin(host: Plugin.Context, kv: KV.Interface, pluginID: string): Plugin.Context {
return {
...host,
storage: storage(kv, pluginID),
skill: {
...host.skill,
transform: (callback) =>
host.skill.transform((editor) => {
try {
callback(editor)
} catch (cause) {
// Replay happens after setup, potentially in a different consumer's Effect context.
throw new PluginCallback.Error({ pluginID, operation: "skill.transform", cause })
}
}),
},
}
}
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
const namespace = `plugin:${pluginID
.split("")
@@ -0,0 +1,95 @@
import { expect } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import { Plugin } from "@opencode-ai/core/plugin"
import { Skill } from "@opencode-ai/core/skill"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const skill = {
id: Skill.ID.make("review"),
name: Skill.Name.make("Review"),
description: "Review changes",
location: AbsolutePath.make("/fixture/review.md"),
content: "Review changes",
}
for (const cause of [new TypeError("synthetic-private-detail"), "synthetic-private-detail"]) {
it.effect(`attributes a deferred skill transform throwing ${typeof cause}`, () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const skills = yield* Skill.Service
let setup = false
const activation = yield* plugins
.activate([
{
id: "healthy",
revision: "1",
effect: (ctx) => ctx.skill.transform((editor) => editor.add(skill)).pipe(Effect.asVoid),
},
{
id: "broken-skills",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.skill.transform((editor) => {
editor.remove(skill.id)
throw cause
})
setup = true
}),
},
])
.pipe(Effect.exit)
expect(setup).toBe(true)
// Neither a partial fold nor the old value is returned after failure. Every read retries.
for (const exit of [
activation,
yield* skills.list().pipe(Effect.asVoid, Effect.exit),
yield* skills.get(skill.id).pipe(Effect.asVoid, Effect.exit),
]) {
if (Exit.isSuccess(exit)) throw new Error("Expected a failed skill fold")
expect(Cause.hasFails(exit.cause)).toBe(false)
expect(Cause.squash(exit.cause)).toMatchObject({
_tag: "PluginCallbackError",
pluginID: "broken-skills",
operation: "skill.transform",
message: 'Plugin "broken-skills" failed during skill.transform.',
cause,
})
}
// Only an explicit registration change removes the failure; nothing is silently disabled.
yield* plugins.activate([
{
id: "healthy",
revision: "1",
effect: (ctx) => ctx.skill.transform((editor) => editor.add(skill)).pipe(Effect.asVoid),
},
])
expect(yield* skills.list()).toEqual([skill])
}),
)
}
it.effect("keeps setup failures distinct from deferred callback failures", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const skills = yield* Skill.Service
yield* plugins.activate([
{ id: "setup-failure", revision: "1", effect: () => Effect.die(new Error("fixture setup failed")) },
{
id: "healthy",
revision: "1",
effect: (ctx) => ctx.skill.transform((editor) => editor.add(skill)).pipe(Effect.asVoid),
},
])
expect((yield* plugins.list()).find((plugin) => plugin.id === "setup-failure")?.state).toMatchObject({
status: "failed",
error: expect.stringContaining("fixture setup failed"),
})
expect(yield* skills.list()).toEqual([skill])
}),
)
+31
View File
@@ -9259,6 +9259,16 @@
}
}
}
},
"500": {
"description": "PluginCallbackError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginCallbackErrorEncoded"
}
}
}
}
},
"description": "Retrieve currently registered skills.",
@@ -16984,6 +16994,27 @@
}
]
},
"PluginCallbackErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCallbackError"]
},
"pluginID": {
"type": "string"
},
"operation": {
"type": "string",
"enum": ["skill.transform"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "pluginID", "operation", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
+11
View File
@@ -1,5 +1,16 @@
import { Schema } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
import { Plugin } from "@opencode-ai/schema/plugin"
export class PluginCallbackError extends Schema.TaggedError<PluginCallbackError>()(
"PluginCallbackError",
{
pluginID: Plugin.ID,
operation: Schema.Literal("skill.transform"),
message: Schema.String,
},
{ httpApiStatus: 500 },
) {}
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>()(
"InvalidRequestError",
+2
View File
@@ -3,12 +3,14 @@ import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { PluginCallbackError } from "../errors.js"
export const SkillGroup = HttpApiGroup.make("server.skill")
.add(
HttpApiEndpoint.get("skill.list", "/api/skill", {
query: LocationQuery,
success: Location.response(Schema.Array(Skill.Info)),
error: PluginCallbackError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
+22 -1
View File
@@ -1,8 +1,29 @@
import { Skill } from "@opencode-ai/core/skill"
import { PluginCallback } from "@opencode-ai/core/plugin/callback"
import { PluginCallbackError } from "@opencode-ai/protocol/errors"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) =>
handlers.handle("skill.list", () => response(Skill.Service.use((skill) => skill.list()))),
handlers.handle("skill.list", () =>
response(Skill.Service.use((skill) => skill.list())).pipe(
Effect.catchDefect((error) => {
if (!(error instanceof PluginCallback.Error)) return Effect.die(error)
return Effect.logError("Plugin callback failed", error).pipe(
Effect.andThen(
Effect.fail(
new PluginCallbackError({
pluginID: Plugin.ID.make(error.pluginID),
operation: error.operation,
message: `${error.message} Check server logs for details.`,
}),
),
),
)
}),
),
),
)
+129
View File
@@ -0,0 +1,129 @@
import { expect } from "bun:test"
import path from "node:path"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Skill } from "@opencode-ai/core/skill"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Context, Effect, Layer, Logger } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createRoutes } from "../src/routes"
it.live("skill.list reports the failing plugin without exposing its exception", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped("opencode-skill-failures-")
const messages: unknown[] = []
const logger = Logger.make((options) => messages.push(options.message))
const context = yield* Layer.build(
createRoutes({
password: "secret",
database: { path: ":memory:" },
models: { fetch: false },
fs: { filewatcher: false },
config: { directory: path.join(tmp.path, "config"), project: false },
}).pipe(
Layer.provide(HttpServer.layerServices),
Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })),
),
)
const sdk = Context.get(context, SdkPlugins.Service)
const cause = new TypeError("synthetic-private-detail")
yield* sdk.register(
Plugin.define({
id: "broken-skills",
effect: (ctx) =>
ctx.skill
.transform(() => {
throw cause
})
.pipe(Effect.asVoid),
}),
)
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
const request = (method: string, route: string) =>
Effect.promise(() =>
handler(
new Request(`http://opencode.local${route}?location[directory]=${encodeURIComponent(tmp.path)}`, {
method,
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}),
),
)
expect((yield* request("POST", "/api/plugin/await-activation")).status).toBe(204)
// The directory is valid. A skill failure is not a location-not-found error.
expect((yield* request("GET", "/api/location")).status).toBe(200)
for (const attempt of [1, 2]) {
const response = yield* request("GET", "/api/skill")
expect(response.status).toBe(500)
const body = yield* Effect.promise(() => response.text())
expect(body).toContain('"PluginCallbackError"')
expect(JSON.parse(body)).toEqual({
_tag: "PluginCallbackError",
pluginID: "broken-skills",
operation: "skill.transform",
message: 'Plugin "broken-skills" failed during skill.transform. Check server logs for details.',
})
expect(body).not.toContain("synthetic-private-detail")
expect(body).not.toContain("TypeError")
expect(body).not.toContain(tmp.path)
expect(
messages.filter((message) => Array.isArray(message) && message[0] === "Plugin callback failed"),
).toHaveLength(attempt)
}
expect(messages).toContainEqual([
"Plugin callback failed",
expect.objectContaining({ pluginID: "broken-skills", operation: "skill.transform", cause }),
])
}).pipe(Effect.timeout("10 seconds")),
)
for (const scenario of [
{ name: "unrelated defects", effect: Effect.die(new Error("unrelated-private-detail")), status: 500 },
// Effect's HTTP boundary maps server interruption to 503 (a client abort is 499).
{ name: "interruption", effect: Effect.interrupt, status: 503 },
]) {
it.live(`skill.list does not label ${scenario.name} as plugin callback failures`, () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped("opencode-skill-control-")
const context = yield* Layer.build(
createRoutes(
{
password: "secret",
database: { path: ":memory:" },
models: { fetch: false },
fs: { filewatcher: false },
config: { directory: tmp.path, project: false },
},
() => [],
[
Skill.node.replace(
Layer.succeed(
Skill.Service,
Skill.Service.of({
list: () => scenario.effect,
get: () => Effect.undefined,
reload: () => Effect.void,
transform: () => Effect.succeed({ dispose: Effect.void }),
}),
),
),
],
).pipe(Layer.provide(HttpServer.layerServices)),
)
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
const response = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/skill?location[directory]=${encodeURIComponent(tmp.path)}`, {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}),
),
)
expect(response.status).toBe(scenario.status)
expect(yield* Effect.promise(() => response.text())).toBe("")
}).pipe(Effect.timeout("10 seconds")),
)
}
+31
View File
@@ -9259,6 +9259,16 @@
}
}
}
},
"500": {
"description": "PluginCallbackError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginCallbackErrorEncoded"
}
}
}
}
},
"description": "Retrieve currently registered skills.",
@@ -16984,6 +16994,27 @@
}
]
},
"PluginCallbackErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCallbackError"]
},
"pluginID": {
"type": "string"
},
"operation": {
"type": "string",
"enum": ["skill.transform"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "pluginID", "operation", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
+31
View File
@@ -9259,6 +9259,16 @@
}
}
}
},
"500": {
"description": "PluginCallbackError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginCallbackErrorEncoded"
}
}
}
}
},
"description": "Retrieve currently registered skills.",
@@ -16984,6 +16994,27 @@
}
]
},
"PluginCallbackErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCallbackError"]
},
"pluginID": {
"type": "string"
},
"operation": {
"type": "string",
"enum": ["skill.transform"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "pluginID", "operation", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {