mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 14:04:40 +00:00
fix(cli): await plugin activation before caching ACP catalog (#46682)
This commit is contained in:
parent
fe4ea1d693
commit
74fbe199af
15 changed files with 628 additions and 69 deletions
|
|
@ -400,7 +400,8 @@ function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: Ski
|
|||
|
||||
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
|
||||
const location = { directory: cwd }
|
||||
// Location plugins initialize asynchronously, so the first ACP request may observe an empty catalog.
|
||||
await client.plugin.awaitActivation({ location })
|
||||
// Some providers discover models in the background after activation has settled.
|
||||
const deadline = Date.now() + 5_000
|
||||
let missing = "No models are available"
|
||||
while (Date.now() < deadline) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,50 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
|
||||
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({
|
||||
|
|
@ -27,7 +68,14 @@ describe("acp service directory behavior", () => {
|
|||
expect(currentValue(first[0], "model")).toBe("test/test-model")
|
||||
expect(currentValue(first[0], "mode")).toBe("build")
|
||||
expect(
|
||||
["/api/model", "/api/model/default", "/api/agent", "/api/command", "/api/skill"].map((path) =>
|
||||
[
|
||||
"/api/plugin/await-activation",
|
||||
"/api/model",
|
||||
"/api/model/default",
|
||||
"/api/agent",
|
||||
"/api/command",
|
||||
"/api/skill",
|
||||
].map((path) =>
|
||||
fixture.requests
|
||||
.filter((request) => request.path === path)
|
||||
.map((request) => request.query["location[directory]"]),
|
||||
|
|
@ -38,6 +86,7 @@ describe("acp service directory behavior", () => {
|
|||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ 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,6 +18,7 @@ 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] })
|
||||
|
|
|
|||
|
|
@ -88,6 +88,14 @@ export type PluginListInput = {
|
|||
export type PluginListOutput = { readonly location: Location.Info; 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; readonly workspace?: 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; readonly workspace?: string | undefined } | undefined
|
||||
readonly target?: string | undefined
|
||||
|
|
@ -104,6 +112,7 @@ 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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import type {
|
|||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginAwaitActivationInput,
|
||||
PluginAwaitActivationOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
|
|
@ -326,6 +328,11 @@ 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(
|
||||
|
|
@ -342,6 +349,7 @@ 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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import type {
|
|||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginAwaitActivationInput,
|
||||
PluginAwaitActivationOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
|
|
@ -470,6 +472,18 @@ 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>(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2585,6 +2585,14 @@ export type PluginListOutput = {
|
|||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginAwaitActivationInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PluginAwaitActivationOutput = void
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
|
|
|||
|
|
@ -486,6 +486,82 @@
|
|||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/await-activation": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.awaitActivation",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"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,6 +20,21 @@ 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: "v2.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,6 +13,7 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
|
|||
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
|
||||
}),
|
||||
)
|
||||
.handle("plugin.awaitActivation", () => Plugin.Service.use((plugin) => plugin.awaitActivation))
|
||||
.handle("plugin.check", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
|
|
|||
192
packages/server/test/plugin-activation.test.ts
Normal file
192
packages/server/test/plugin-activation.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { expect } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { Plugin } from "@opencode-ai/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,74 +1,106 @@
|
|||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schedule } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { Plugin } from "@opencode-ai/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 { startServer } from "./fixture/server"
|
||||
import { createRoutes } from "../src/routes"
|
||||
|
||||
it.live(
|
||||
"lists providers without blocking on plugin initialization",
|
||||
"lists and gets providers without blocking on plugin initialization",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* configuredProvider("opencode-provider-list-endpoint-")
|
||||
const url = new URL("/api/provider", fixture.server.base)
|
||||
url.searchParams.set("location[directory]", fixture.path)
|
||||
yield* Effect.promise(async () => {
|
||||
const response = await fetch(url, { headers: fixture.server.headers })
|
||||
if (response.status !== 200) return false
|
||||
const body: unknown = await response.json()
|
||||
return isRecord(body) && Array.isArray(body["data"])
|
||||
? body["data"].some((provider) => isRecord(provider) && provider["id"] === "custom")
|
||||
: false
|
||||
}).pipe(
|
||||
Effect.filterOrFail((found) => found),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
Effect.timeout("2 seconds"),
|
||||
)
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"gets providers without blocking on plugin initialization",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* configuredProvider("opencode-provider-get-endpoint-")
|
||||
const url = new URL("/api/provider/custom", fixture.server.base)
|
||||
url.searchParams.set("location[directory]", fixture.path)
|
||||
const body: unknown = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const response = await fetch(url, { headers: fixture.server.headers })
|
||||
if (response.status !== 200) throw new Error(`Provider not ready: ${response.status}`)
|
||||
return response.json()
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
|
||||
if (!isRecord(body) || !isRecord(body["data"])) throw new Error("Expected a provider response")
|
||||
expect(body["data"]["id"]).toBe("custom")
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
const configuredProvider = Effect.fnUntraced(function* (prefix: string) {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir(prefix)))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: { apiKey: "secret" },
|
||||
models: { chat: {} },
|
||||
const tmp = yield* tmpdirScoped("opencode-provider-endpoints-")
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const context = yield* Layer.build(
|
||||
createRoutes({
|
||||
password: "secret",
|
||||
database: { path: ":memory:" },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
config: {
|
||||
directory: tmp.path,
|
||||
project: false,
|
||||
content: JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured Custom Provider",
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: { apiKey: "secret" },
|
||||
models: { chat: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { server: yield* startServer(tmp.path), path: tmp.path }
|
||||
})
|
||||
}).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
)
|
||||
const sdk = Context.get(context, SdkPlugins.Service)
|
||||
yield* sdk.register(
|
||||
Plugin.define({
|
||||
id: "slow-plugin",
|
||||
effect: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const handler = Context.get(context, HttpRouter.HttpRouter)
|
||||
.asHttpEffect()
|
||||
.pipe(HttpEffect.toWebHandlerWith(context))
|
||||
const request = (method: "GET" | "POST", route: string) =>
|
||||
Effect.promise((signal) => {
|
||||
const url = new URL(route, "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
return handler(
|
||||
new Request(url, {
|
||||
method,
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
signal,
|
||||
}),
|
||||
)
|
||||
})
|
||||
const pending = yield* request("POST", "/api/plugin/await-activation").pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
// 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"))
|
||||
expect(list.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => list.json())).toMatchObject({
|
||||
location: { directory: tmp.path },
|
||||
data: expect.not.arrayContaining([expect.objectContaining({ id: "custom" })]),
|
||||
})
|
||||
const get = yield* request("GET", "/api/provider/custom").pipe(Effect.timeout("2 seconds"))
|
||||
expect(get.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => get.json())).toMatchObject({
|
||||
_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/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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -486,6 +486,82 @@
|
|||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/await-activation": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.awaitActivation",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"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"],
|
||||
|
|
|
|||
|
|
@ -486,6 +486,82 @@
|
|||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/await-activation": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.awaitActivation",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"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"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue