Repository navigation
Conversation
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
|
The following comment was made by an LLM, it may be inaccurate: |
|
Agent work: stage finished Client: Mode: Stage: Recovery: Prior claim and abort requests completed; no child executors remain; abort the owned stopped claim with qualified marker recovery Scope in this repository: read-only or external scope |
|
I reproduced #51680 on v2.1.0 ( This PR waits through Additional regression coverage would help distinguish a settled empty registry from activation in progress:
A local five-file candidate covers those cases. Its three new tests fail on the original handler and protocol declarations. With the candidate, the focused tests pass. 10 repetitions give 30 passes. The server suite gives 90 passes, 1 skip, and no failures. The protocol suite gives 9 passes. Server and protocol typechecks and builds pass. These results cover the local candidate, not PR head Activation settlement includes failed plugins. It does not certify that every plugin is healthy. The installed CLI remains unchanged. |
|
Follow-up to my earlier comment, with qualification from 23–24 October 2026. The Bun pin mismatch and incomplete root-check coverage from that comment are resolved for the local candidates below.
Both candidates use the existing bounded On 23 October, each candidate passed four focused tests, nine protocol tests, and ten regression repetitions totaling 30 passes. Server suites returned 90 passes for v2.1.2 and 95 for dev, each with one skip and zero failures. Root On 24 October, I checked source archives, all five candidate files, pinned Bun executable hashes, and 62 retained evidence bindings. I repeated these checks on both exact bases:
The fresh CLI uses isolated HOME/XDG paths, disables project and Claude discovery, and loads the fixture from its isolated configuration directory. This prevents ancestor discovery from including user skills. The cold/warm HTTP test and full suites were not repeated on 24 October. Their results above remain dated 23 October. This three-skill fixture is separate from the earlier 137-name check. These results cover the tested local candidates, not PR head Generated baseline drift is separate from this fix. Regeneration without the candidate adds The dev patch and manifest below make the dev candidate self-contained. The v2.1.2 patch is not included in this comment. Its results provide a separate historical comparison. It remains a candidate for this existing PR. CI and validation on the eventual integrated head remain required. Exact dev candidate manifest{
"commit": "d93568f3344e1a93297d56bc1dc71892104856d0",
"bun": "1.4.4",
"files": [
{
"path": "packages/server/src/handlers/skill.ts",
"baseline_sha256": "428dc0167217bb47fb9bdcc883dcef6803a92b93e6e9ad1ccb1614ffb71bd5dc",
"candidate_sha256": "3004e1ae9df238c98f43304144455b16464041478cd27718856d35576120a49d"
},
{
"path": "packages/protocol/src/groups/skill.ts",
"baseline_sha256": "18d47e5dc4cc2ec598ec37cf0222eee6f6527c38cc71f861869618b6e6541b0c",
"candidate_sha256": "0c0dec82703907d85462a49688631bc921f4c6215f5b5f7eb336c04d76c83516"
},
{
"path": "packages/server/test/skill.test.ts",
"baseline_sha256": null,
"candidate_sha256": "868f096864561de268b554e41271ada2042627bef56f965851ff10c693f79864"
},
{
"path": "packages/client/src/promise/generated/client.ts",
"baseline_sha256": "4924b56aae80d5fa3fa62fde60519f3284f29a943c8b35511998fcccaac81511",
"candidate_sha256": "5fe3b362cb5b0c2b4c7911f4f513c1e5b10e07101d61fee119479322c5fa53b3"
},
{
"path": "packages/protocol/openapi.json",
"baseline_sha256": "c04f3cf77629247f47fc0a6fa4a8aeba689531894e9aa6c4b2a6e1b0b63c99eb",
"candidate_sha256": "3c9cb4f4cea4dcd074ccfe7583102d7e32b0f668612c5f5a938b4451747e1492"
}
],
"patch_sha256": "d1dbc82781115160634c65815c0fdda78b899db9fd24551218c0b568132e3467",
"patch_bytes": 17091
}Exact dev candidate patch (17,091 bytes before Markdown wrapping)diff --git a/packages/server/src/handlers/skill.ts b/packages/server/src/handlers/skill.ts
--- a/packages/server/src/handlers/skill.ts
+++ b/packages/server/src/handlers/skill.ts
@@ -1,8 +1,15 @@
import { Skill } from "@opencode/core/skill"
+import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
-import { response } from "../location"
+import { awaitPlugins, 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",
+ Effect.fn(function* () {
+ yield* awaitPlugins
+ return yield* response(Skill.Service.use((skill) => skill.list()))
+ }),
+ ),
)
diff --git a/packages/protocol/src/groups/skill.ts b/packages/protocol/src/groups/skill.ts
--- a/packages/protocol/src/groups/skill.ts
+++ b/packages/protocol/src/groups/skill.ts
@@ -2,6 +2,7 @@
import { Location } from "@opencode/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api"
+import { ServiceUnavailableError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const SkillGroup = HttpApiGroup.make("server.skill")
@@ -9,6 +10,7 @@
HttpApiEndpoint.get("skill.list", "/api/skill", {
query: LocationQuery,
success: Location.response(Schema.Array(Skill.Info)),
+ error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
diff --git a/packages/server/test/skill.test.ts b/packages/server/test/skill.test.ts
new file mode 100644
--- /dev/null
+++ b/packages/server/test/skill.test.ts
@@ -0,0 +1,150 @@
+import fs from "node:fs/promises"
+import path from "node:path"
+import { expect } from "bun:test"
+import { Skill } from "@opencode/core/skill"
+import { LocationServiceMap } from "@opencode/core/location-services"
+import { SdkPlugins } from "@opencode/core/plugin/sdk"
+import { define } from "@opencode/plugin/effect/plugin"
+import { Location } from "@opencode/schema/location"
+import { AbsolutePath } from "@opencode/schema/schema"
+import { Global } from "@opencode/util/global"
+import { Context, Deferred, Effect, Fiber, Layer } from "effect"
+import { HttpEffect, HttpRouter, HttpServer } from "effect/http"
+import { tempGlobalLayer } from "../../core/test/fixture/global"
+import { tmpdir } from "../../core/test/fixture/tmpdir"
+import { advance } from "../../core/test/lib/clock"
+import { it } from "../../core/test/lib/effect"
+import { createEmbeddedRoutes } from "../src/routes"
+import { startServer } from "./fixture/server"
+
+it.live("waits for plugin initialization before listing skills", () =>
+ Effect.gen(function* () {
+ const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-skill-endpoint-")))
+ const directory = path.join(tmp.path, "skills", "cold-skill")
+ yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
+ yield* Effect.promise(() =>
+ fs.writeFile(
+ path.join(directory, "SKILL.md"),
+ "---\nname: cold-skill\ndescription: Fixture for cold API discovery.\n---\nFixture skill.\n",
+ ),
+ )
+ yield* Effect.promise(() =>
+ fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ skills: [path.join(tmp.path, "skills")] })),
+ )
+ const server = yield* startServer(tmp.path)
+ const url = new URL("/api/skill", server.base)
+ url.searchParams.set("location[directory]", tmp.path)
+ const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
+
+ expect(response.status).toBe(200)
+ const body: unknown = yield* Effect.promise(() => response.json())
+ if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a skill list response")
+ expect(body["data"].some((skill) => isRecord(skill) && skill["name"] === "cold-skill")).toBeTrue()
+
+ const warm = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
+ expect(warm.status).toBe(200)
+ const settled: unknown = yield* Effect.promise(() => warm.json())
+ if (!isRecord(settled) || !Array.isArray(settled["data"])) throw new Error("Expected a warm skill list response")
+ expect(settled["data"].some((skill) => isRecord(skill) && skill["name"] === "cold-skill")).toBeTrue()
+ }),
+)
+
+it.live("does not expose partial skills and preserves a settled empty registry", () =>
+ Effect.gen(function* () {
+ const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-skill-gate-")))
+ const server = yield* embedded(tmp.path)
+ const entered = yield* Deferred.make<void>()
+ const gate = yield* Deferred.make<void>()
+ const partial = {
+ id: Skill.ID.make("partial"),
+ name: Skill.Name.make("partial"),
+ path: AbsolutePath.make(path.join(tmp.path, "partial.md")),
+ content: "Partial skill.",
+ }
+ yield* server.sdk.register(
+ define({
+ id: "gated-skills",
+ effect: (ctx) =>
+ Effect.gen(function* () {
+ yield* ctx.skill.transform((editor) => editor.add(partial))
+ yield* Deferred.succeed(entered, undefined)
+ yield* Deferred.await(gate)
+ yield* ctx.skill.transform((editor) =>
+ editor.add({ ...partial, id: Skill.ID.make("complete"), name: Skill.Name.make("complete") }),
+ )
+ }),
+ }),
+ )
+ const pending = yield* server.request().pipe(Effect.forkScoped({ startImmediately: true }))
+ yield* Deferred.await(entered)
+ yield* Deferred.succeed(gate, undefined)
+ const response = yield* Fiber.join(pending)
+ expect(response.status).toBe(200)
+ const body: unknown = yield* Effect.promise(() => response.json())
+ if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a settled skill list")
+ expect(body["data"].some((skill) => isRecord(skill) && skill["name"] === "complete")).toBeTrue()
+
+ yield* Skill.Service.use((skills) =>
+ skills.transform((editor) => {
+ for (const skill of editor.list()) editor.remove(skill.id)
+ }),
+ ).pipe(Effect.provide(server.locations.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))))
+ const empty = yield* server.request()
+ expect(empty.status).toBe(200)
+ expect(yield* Effect.promise(() => empty.json())).toMatchObject({ data: [] })
+ }),
+)
+
+it.effect("returns a typed 503 when skill plugin activation does not settle", () =>
+ Effect.gen(function* () {
+ const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-skill-timeout-")))
+ const server = yield* embedded(tmp.path)
+ const entered = yield* Deferred.make<void>()
+ yield* server.sdk.register(
+ define({
+ id: "stalled-skills",
+ effect: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)),
+ }),
+ )
+ const pending = yield* server.request().pipe(Effect.forkScoped({ startImmediately: true }))
+ yield* Deferred.await(entered)
+ yield* advance(() => pending.pollUnsafe() !== undefined)
+ const response = yield* Fiber.join(pending)
+ expect(response.status).toBe(503)
+ expect(yield* Effect.promise(() => response.json())).toMatchObject({
+ _tag: "ServiceUnavailableError",
+ service: "plugin",
+ })
+ }),
+)
+
+const embedded = Effect.fnUntraced(function* (directory: string) {
+ const parent = yield* Effect.context()
+ const context = Context.merge(
+ parent,
+ yield* Layer.build(
+ createEmbeddedRoutes(
+ {
+ database: { path: ":memory:" },
+ config: { project: false, claude: false },
+ fs: { filewatcher: false },
+ models: { fetch: false },
+ },
+ [Global.node.replace(tempGlobalLayer)],
+ ).pipe(Layer.provide(HttpServer.layerServices)),
+ ),
+ )
+ const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
+ return {
+ sdk: Context.get(context, SdkPlugins.Service),
+ locations: Context.get(context, LocationServiceMap.Service),
+ request: () =>
+ Effect.promise(() =>
+ handler(new Request(`http://opencode.local/api/skill?location[directory]=${encodeURIComponent(directory)}`)),
+ ),
+ }
+})
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts
--- a/packages/client/src/promise/generated/client.ts
+++ b/packages/client/src/promise/generated/client.ts
@@ -1767,7 +1767,7 @@
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
- declaredStatuses: [400, 401, 404],
+ declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json
--- a/packages/protocol/openapi.json
+++ b/packages/protocol/openapi.json
@@ -7781,6 +7781,247 @@
"summary": "List MCP resources"
}
},
+ "/api/experimental/client-mcp/token": {
+ "post": {
+ "tags": ["mcp"],
+ "operationId": "experimental.mcp.client.token",
+ "parameters": [
+ {
+ "name": "x-opencode-ticket",
+ "in": "header",
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "required": false
+ },
+ {
+ "name": "location",
+ "in": "query",
+ "schema": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "directory": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "required": false,
+ "style": "deepObject",
+ "explode": true
+ },
+ {
+ "name": "name",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ },
+ "required": true
+ }
+ ],
+ "security": [],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "$ref": "#/components/schemas/Location.PublicRef"
+ },
+ "data": {
+ "$ref": "#/components/schemas/PtyTicket.ConnectToken"
+ }
+ },
+ "required": ["location", "data"],
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "InvalidRequestError",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidRequestErrorEncoded"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "UnauthorizedError",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UnauthorizedErrorEncoded"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "ForbiddenError",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ForbiddenErrorEncoded"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "LocationNotFoundError",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LocationNotFoundErrorEncoded"
+ }
+ }
+ }
+ }
+ },
+ "description": "Create a short-lived single-use ticket for opening a client MCP WebSocket connection from a browser.",
+ "summary": "Create client MCP WebSocket token"
+ }
+ },
+ "/api/experimental/client-mcp": {
+ "get": {
+ "tags": ["mcp"],
+ "operationId": "experimental.mcp.client",
+ "parameters": [
+ {
+ "name": "location",
+ "in": "query",
+ "schema": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "directory": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "required": false
+ },
+ {
+ "name": "name",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ },
+ "required": true
+ },
+ {
+ "name": "ticket",
+ "in": "query",
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "required": false
+ }
+ ],
+ "security": [],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "InvalidRequestError",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidRequestErrorEncoded"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "UnauthorizedError",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UnauthorizedErrorEncoded"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "ForbiddenError",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ForbiddenErrorEncoded"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "LocationNotFoundError",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LocationNotFoundErrorEncoded"
+ }
+ }
+ }
+ }
+ },
+ "description": "Upgrade to a WebSocket over which the client hosts an MCP server named after `name`; its tools stay available while the socket is open. One JSON-RPC message per text frame. Experimental: may change without compatibility guarantees.",
+ "summary": "Attach client MCP server",
+ "x-websocket": true
+ }
+ },
"/api/credential": {
"get": {
"tags": ["credential"],
@@ -9687,6 +9928,16 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/LocationNotFoundErrorEncoded"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "ServiceUnavailableError",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
} |
|
Automated PR Cleanup Thank you for contributing to opencode. Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions. This PR was closed because it matched the following cleanup criteria:
PRs created within the last month are not affected by this cleanup. If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate. Thanks again for taking the time to contribute. |
Issue for this PR
Closes #51680
Related: #51282, #35556
Type of change
What does this PR do?
Waits for plugin activation before listing skills, so the first request includes registered skills.
How did you verify your code works?
packages/server/test/skill.test.tsfor the initial skill list.[]before the fix and registered skills after.Screenshots / recordings
Not applicable.
Checklist