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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/client/src/promise/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions packages/client/test/effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))),
Expand Down
17 changes: 16 additions & 1 deletion packages/client/test/promise.test.ts
Original file line number Diff line number Diff line change
@@ -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" })
Expand Down
4 changes: 1 addition & 3 deletions packages/core/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/plugin/callback.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 20 additions & 0 deletions packages/core/src/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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("")
Expand Down
95 changes: 95 additions & 0 deletions packages/core/test/plugin/skill-failures.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 31 additions & 0 deletions packages/protocol/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9259,6 +9259,16 @@
}
}
}
},
"500": {
"description": "PluginCallbackError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginCallbackErrorEncoded"
}
}
}
}
},
"description": "Retrieve currently registered skills.",
Expand Down Expand Up @@ -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": {
Expand Down
11 changes: 11 additions & 0 deletions packages/protocol/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/protocol/src/groups/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
23 changes: 22 additions & 1 deletion packages/server/src/handlers/skill.ts
Original file line number Diff line number Diff line change
@@ -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.`,
}),
),
),
)
}),
),
),
)
Loading
Loading