mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(protocol): remove plugin activation wait
This commit is contained in:
+11
-7
@@ -1,7 +1,7 @@
|
||||
# V2 HTTP API audit checklist
|
||||
|
||||
**Source:** `packages/protocol/openapi.json`
|
||||
**Current endpoint count:** 140
|
||||
**Current endpoint count:** 139
|
||||
**Last regenerated:** 2026-09-13
|
||||
|
||||
## How to use this checklist
|
||||
@@ -29,7 +29,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
## Progress
|
||||
|
||||
- [x] Group 1: Foundation and placement (4)
|
||||
- [ ] Group 2: Configuration and capability catalogs (17)
|
||||
- [ ] Group 2: Configuration and capability catalogs (16)
|
||||
- [ ] Group 3: Credentials, integrations, MCP, and web search (22)
|
||||
- [ ] Group 4: Session lifecycle (12)
|
||||
- [ ] Group 5: Session execution and inputs (11)
|
||||
@@ -41,6 +41,11 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|
||||
## Resolved during audit
|
||||
|
||||
### [x] `POST /api/plugin/await-activation`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Notes:** Activation timing is an internal server concern. Catalog reads remain non-blocking.
|
||||
|
||||
### [x] Location response wrappers
|
||||
|
||||
- **Decision:** Reduce generic endpoint response locations to `{ directory }`.
|
||||
@@ -76,14 +81,13 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|
||||
## Group 2: Configuration and capability catalogs
|
||||
|
||||
**Endpoints:** 17
|
||||
**Endpoints:** 16
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 008 | `GET` | `/api/agent` | `agent.list` | | |
|
||||
| [ ] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | | |
|
||||
| [ ] 010 | `GET` | `/api/plugin` | `plugin.list` | | |
|
||||
| [ ] 011 | `POST` | `/api/plugin/await-activation` | `plugin.awaitActivation` | | |
|
||||
| [x] 008 | `GET` | `/api/agent` | `agent.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | Keep | Request, response, and not-found error accepted as-is. |
|
||||
| [x] 010 | `GET` | `/api/plugin` | `plugin.list` | Keep | Request and response accepted as-is. |
|
||||
| [ ] 012 | `POST` | `/api/plugin/check` | `plugin.check` | | |
|
||||
| [ ] 013 | `POST` | `/api/plugin/update` | `plugin.update` | | |
|
||||
| [ ] 014 | `GET` | `/api/model` | `model.list` | | |
|
||||
|
||||
@@ -408,8 +408,7 @@ function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: Ski
|
||||
|
||||
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
|
||||
const location = { directory: cwd }
|
||||
await client.plugin.awaitActivation({ location })
|
||||
// Some providers discover models in the background after activation has settled.
|
||||
// Some providers discover models in the background after plugin startup begins.
|
||||
const deadline = Date.now() + 5_000
|
||||
let missing = "No models are available"
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
@@ -4,47 +4,6 @@ import { makeACPFixture, makeSession, secondModel, testModel } from "./service-f
|
||||
import { flattenSelectOptions, requireSelectOption } from "./subprocess"
|
||||
|
||||
describe("acp service directory behavior", () => {
|
||||
test("does not cache an available model before plugin activation settles", async () => {
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
let ready = false
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
requested.resolve()
|
||||
if (request.path === "/api/plugin/await-activation") {
|
||||
return release.promise.then(() => {
|
||||
ready = true
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
}
|
||||
if (!ready && request.path === "/api/model") {
|
||||
return Response.json({ data: [{ ...testModel, providerID: "ambient" }] })
|
||||
}
|
||||
if (!ready && request.path === "/api/model/default") {
|
||||
return Response.json({ data: { ...testModel, providerID: "ambient" } })
|
||||
}
|
||||
if (request.path === "/api/session" && request.method === "POST") {
|
||||
return Response.json({ data: { ...makeSession("ses_ready"), model: undefined } })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const pending = fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
try {
|
||||
await requested.promise
|
||||
expect(fixture.requests.map((request) => request.path)).toEqual(["/api/plugin/await-activation"])
|
||||
expect(fixture.requests[0]?.query["location[directory]"]).toBe("/workspace")
|
||||
release.resolve()
|
||||
expect(currentValue(await pending, "model")).toBe("test/test-model")
|
||||
expect(
|
||||
fixture.requests.find((request) => request.path === "/api/session" && request.method === "POST")?.body,
|
||||
).toMatchObject({ model: { providerID: "test", id: "test-model" } })
|
||||
} finally {
|
||||
release.resolve()
|
||||
await pending.catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||
let created = 0
|
||||
await using fixture = makeACPFixture({
|
||||
@@ -69,7 +28,6 @@ describe("acp service directory behavior", () => {
|
||||
expect(currentValue(first[0], "mode")).toBe("build")
|
||||
expect(
|
||||
[
|
||||
"/api/plugin/await-activation",
|
||||
"/api/model",
|
||||
"/api/model/default",
|
||||
"/api/agent",
|
||||
@@ -86,7 +44,6 @@ describe("acp service directory behavior", () => {
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
|
||||
@@ -152,7 +152,6 @@ export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
|
||||
const directory = request.query["location[directory]"] ?? "/workspace"
|
||||
const location = { directory, project: { id: "global", directory } }
|
||||
if (request.path === "/api/plugin/await-activation") return new Response(null, { status: 204 })
|
||||
if (request.path === "/api/event") {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
return new Response(
|
||||
|
||||
@@ -18,7 +18,6 @@ describe("acp service", () => {
|
||||
body: request.method === "GET" ? undefined : await request.json().catch(() => undefined),
|
||||
})
|
||||
const location = { directory: "/workspace", project: { id: "global", directory: "/workspace" } }
|
||||
if (url.pathname === "/api/plugin/await-activation") return new Response(null, { status: 204 })
|
||||
if (url.pathname === "/api/model") return Response.json({ location, data: [model] })
|
||||
if (url.pathname === "/api/model/default") return Response.json({ location, data: model })
|
||||
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
|
||||
|
||||
@@ -78,12 +78,6 @@ export type PluginListInput = { readonly location?: { readonly directory?: strin
|
||||
export type PluginListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
|
||||
|
||||
export type PluginAwaitActivationInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type PluginAwaitActivationOutput = void
|
||||
export type PluginAwaitActivationOperation<E = never> = (
|
||||
input?: PluginAwaitActivationInput,
|
||||
) => Effect.Effect<PluginAwaitActivationOutput, E>
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly target?: string | undefined
|
||||
@@ -100,7 +94,6 @@ export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Eff
|
||||
|
||||
export interface PluginApi<E = never> {
|
||||
readonly list: PluginListOperation<E>
|
||||
readonly awaitActivation: PluginAwaitActivationOperation<E>
|
||||
readonly check: PluginCheckOperation<E>
|
||||
readonly update: PluginUpdateOperation<E>
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginAwaitActivationInput,
|
||||
PluginAwaitActivationOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
@@ -322,11 +320,6 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginAwaitActivation = (raw: RawClient["server.plugin"]) => (input?: PluginAwaitActivationInput) =>
|
||||
preserveEffect<PluginAwaitActivationOutput>()(
|
||||
raw["plugin.awaitActivation"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
|
||||
preserveEffect<PluginCheckOutput>()(
|
||||
raw["plugin.check"]({ query: { location: input?.["location"] }, payload: { target: input?.["target"] } }).pipe(
|
||||
@@ -343,7 +336,6 @@ const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: Plugin
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
awaitActivation: EndpointPluginAwaitActivation(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
update: EndpointPluginUpdate(raw),
|
||||
})
|
||||
|
||||
@@ -8,8 +8,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginAwaitActivationInput,
|
||||
PluginAwaitActivationOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
@@ -467,18 +465,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
awaitActivation: (input?: PluginAwaitActivationInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginAwaitActivationOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/await-activation`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginCheckOutput>(
|
||||
{
|
||||
|
||||
@@ -2625,12 +2625,6 @@ export type PluginListInput = {
|
||||
|
||||
export type PluginListOutput = { location: LocationPublicRef; data: Array<PluginInfo> }
|
||||
|
||||
export type PluginAwaitActivationInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
export type PluginAwaitActivationOutput = void
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
readonly target?: { readonly target?: string | undefined }["target"]
|
||||
|
||||
@@ -394,72 +394,6 @@
|
||||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/await-activation": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "plugin.awaitActivation",
|
||||
"parameters": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
|
||||
"summary": "Wait for plugin activation"
|
||||
}
|
||||
},
|
||||
"/api/plugin/check": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
|
||||
@@ -20,21 +20,6 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("plugin.awaitActivation", "/api/plugin/await-activation", {
|
||||
query: LocationQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "plugin.awaitActivation",
|
||||
summary: "Wait for plugin activation",
|
||||
description:
|
||||
"Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
|
||||
query: LocationQuery,
|
||||
|
||||
@@ -13,11 +13,9 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
|
||||
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
|
||||
}),
|
||||
)
|
||||
.handle("plugin.awaitActivation", () => Plugin.awaitActivation)
|
||||
.handle("plugin.check", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const inventory = yield* plugins.list()
|
||||
const targets = [
|
||||
...new Set(inventory.flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : []))),
|
||||
@@ -57,7 +55,6 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
|
||||
.handle("plugin.update", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const inventory = new Set(
|
||||
(yield* plugins.list()).flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
|
||||
)
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { SdkPlugins } from "@opencode/core/plugin/sdk"
|
||||
import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Context, Deferred, Effect, Fiber, Layer } 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"
|
||||
|
||||
const fixture = Effect.fn(function* (plugin: Plugin.Plugin) {
|
||||
const tmp = yield* tmpdirScoped("opencode-plugin-activation-")
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
const config = path.join(tmp.path, "config")
|
||||
yield* Effect.promise(() => Promise.all([first, second, config].map((directory) => mkdir(directory))))
|
||||
const context = yield* Layer.build(
|
||||
createRoutes({
|
||||
password: "secret",
|
||||
database: { path: ":memory:" },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
config: {
|
||||
directory: config,
|
||||
project: false,
|
||||
content: JSON.stringify({
|
||||
providers: {
|
||||
acme: {
|
||||
models: {
|
||||
reasoner: { name: "Configured Reasoner", limit: { context: 96_000, output: 8_000 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
)
|
||||
const sdk = Context.get(context, SdkPlugins.Service)
|
||||
yield* sdk.register(plugin)
|
||||
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
|
||||
return {
|
||||
first,
|
||||
second,
|
||||
request: (method: "GET" | "POST", route: string, directory = first, signal?: AbortSignal) =>
|
||||
Effect.promise((interruption) => {
|
||||
const url = new URL(route, "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", directory)
|
||||
return handler(
|
||||
new Request(url, {
|
||||
method,
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
signal: signal ?? interruption,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
it.live(
|
||||
"awaits activation only for the requested location without blocking model or plugin snapshots",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const server = yield* fixture(
|
||||
Plugin.define({
|
||||
id: "slow-plugin",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
if (path.basename(ctx.location.directory) !== "first") return
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const pending = yield* server.request("POST", "/api/plugin/await-activation").pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
expect(pending.pollUnsafe()).toBeUndefined()
|
||||
|
||||
const models = yield* server.request("GET", "/api/model")
|
||||
expect(models.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => models.json())).toMatchObject({
|
||||
location: { directory: server.first },
|
||||
data: expect.not.arrayContaining([expect.objectContaining({ providerID: "acme", id: "reasoner" })]),
|
||||
})
|
||||
const plugins = yield* server.request("GET", "/api/plugin")
|
||||
expect(plugins.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({ location: { directory: server.first } })
|
||||
|
||||
const second = yield* server.request("POST", "/api/plugin/await-activation", server.second)
|
||||
expect(second.status).toBe(204)
|
||||
expect(pending.pollUnsafe()).toBeUndefined()
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const response = yield* Fiber.join(pending)
|
||||
expect(response.status).toBe(204)
|
||||
expect(yield* Effect.promise(() => response.text())).toBe("")
|
||||
const configured = yield* server.request("GET", "/api/model")
|
||||
expect(configured.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => configured.json())).toMatchObject({
|
||||
location: { directory: server.first },
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
providerID: "acme",
|
||||
id: "reasoner",
|
||||
name: "Configured Reasoner",
|
||||
limit: { context: 96_000, output: 8_000 },
|
||||
}),
|
||||
]),
|
||||
})
|
||||
const active = yield* server.request("GET", "/api/plugin")
|
||||
expect(active.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => active.json())).toMatchObject({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "slow-plugin", source: { type: "sdk" }, state: { status: "active" } }),
|
||||
]),
|
||||
})
|
||||
}).pipe(Effect.timeout("10 seconds")),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"aborting an activation wait does not cancel plugin setup",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const completed = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const server = yield* fixture(
|
||||
Plugin.define({
|
||||
id: "slow-plugin",
|
||||
effect: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
yield* Deferred.succeed(completed, undefined)
|
||||
}).pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))),
|
||||
}),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()))
|
||||
const pending = yield* server
|
||||
.request("POST", "/api/plugin/await-activation", server.first, controller.signal)
|
||||
.pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
controller.abort()
|
||||
// HttpEffect resolves a cancelled Web request with 499 rather than rejecting its Promise.
|
||||
expect((yield* Fiber.join(pending)).status).toBe(499)
|
||||
expect(yield* Deferred.isDone(interrupted)).toBe(false)
|
||||
expect(yield* Deferred.isDone(completed)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect((yield* server.request("POST", "/api/plugin/await-activation")).status).toBe(204)
|
||||
expect(yield* Deferred.isDone(completed)).toBe(true)
|
||||
expect(yield* Deferred.isDone(interrupted)).toBe(false)
|
||||
const plugins = yield* server.request("GET", "/api/plugin")
|
||||
expect(plugins.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({
|
||||
data: expect.arrayContaining([expect.objectContaining({ id: "slow-plugin", state: { status: "active" } })]),
|
||||
})
|
||||
}).pipe(Effect.timeout("10 seconds")),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"settles activation when plugin setup fails and exposes the failure in the inventory",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture(
|
||||
Plugin.define({
|
||||
id: "failing-plugin",
|
||||
effect: () => Effect.die(new Error("fixture setup failed")),
|
||||
}),
|
||||
)
|
||||
expect((yield* server.request("POST", "/api/plugin/await-activation")).status).toBe(204)
|
||||
const plugins = yield* server.request("GET", "/api/plugin")
|
||||
expect(plugins.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({
|
||||
location: { directory: server.first },
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "failing-plugin",
|
||||
source: { type: "sdk" },
|
||||
state: { status: "failed", error: expect.stringContaining("fixture setup failed") },
|
||||
}),
|
||||
]),
|
||||
})
|
||||
}).pipe(Effect.timeout("10 seconds")),
|
||||
15_000,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from "bun:test"
|
||||
import { SdkPlugins } from "@opencode/core/plugin/sdk"
|
||||
import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Context, Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
@@ -62,11 +62,9 @@ it.live(
|
||||
}),
|
||||
)
|
||||
})
|
||||
const pending = yield* request("POST", "/api/plugin/await-activation").pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
// Config providers activate after SDK plugins; reads must return the current snapshot without waiting.
|
||||
const list = yield* request("GET", "/api/provider").pipe(Effect.timeout("2 seconds"))
|
||||
yield* Deferred.await(started)
|
||||
expect(list.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => list.json())).toMatchObject({
|
||||
location: { directory: tmp.path },
|
||||
@@ -78,29 +76,7 @@ it.live(
|
||||
_tag: "ProviderNotFoundError",
|
||||
providerID: "custom",
|
||||
})
|
||||
expect(pending.pollUnsafe()).toBeUndefined()
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect((yield* Fiber.join(pending)).status).toBe(204)
|
||||
const provider = {
|
||||
id: "custom",
|
||||
name: "Configured Custom Provider",
|
||||
activation: "enabled",
|
||||
package: "@opencode/ai/providers/openai-compatible",
|
||||
settings: { apiKey: "secret" },
|
||||
}
|
||||
const configuredList = yield* request("GET", "/api/provider").pipe(Effect.timeout("2 seconds"))
|
||||
expect(configuredList.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => configuredList.json())).toMatchObject({
|
||||
location: { directory: tmp.path },
|
||||
data: expect.arrayContaining([expect.objectContaining(provider)]),
|
||||
})
|
||||
const configuredGet = yield* request("GET", "/api/provider/custom").pipe(Effect.timeout("2 seconds"))
|
||||
expect(configuredGet.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => configuredGet.json())).toMatchObject({
|
||||
location: { directory: tmp.path },
|
||||
data: provider,
|
||||
})
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
@@ -215,9 +215,6 @@ it.live(
|
||||
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
|
||||
yield* Effect.promise(async () => {
|
||||
await api.location.get({ location: { directory: source } })
|
||||
const url = new URL("/api/plugin/await-activation", server.base)
|
||||
url.searchParams.set("location[directory]", source)
|
||||
expect((await fetch(url, { method: "POST", headers: server.headers })).status).toBe(204)
|
||||
expect(await api.worktree.list({ location: { directory: target } })).toContainEqual({
|
||||
directory: path.join(destination, "delegated"),
|
||||
strategy: "target-copy",
|
||||
|
||||
@@ -148,7 +148,6 @@ export function PluginsDialog(props: {
|
||||
location,
|
||||
targets: [entry.plugin.source.target],
|
||||
})
|
||||
.then(() => props.context.client.plugin.awaitActivation({ location }))
|
||||
.then(() => refetch())
|
||||
.catch((cause) => {
|
||||
props.context.ui.toast.show({
|
||||
|
||||
@@ -394,72 +394,6 @@
|
||||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/await-activation": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "plugin.awaitActivation",
|
||||
"parameters": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
|
||||
"summary": "Wait for plugin activation"
|
||||
}
|
||||
},
|
||||
"/api/plugin/check": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
|
||||
@@ -394,72 +394,6 @@
|
||||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/await-activation": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "plugin.awaitActivation",
|
||||
"parameters": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
|
||||
"summary": "Wait for plugin activation"
|
||||
}
|
||||
},
|
||||
"/api/plugin/check": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
|
||||
Reference in New Issue
Block a user