fix(server): stop waiting for plugins in request handlers (#62796)

This commit is contained in:
Aiden Cline 2026-11-08 22:04:17 -06:00 • committed by GitHub
parent adb0053d09
commit bcaf561d94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 224 additions and 186 deletions

View file

@ -202,6 +202,13 @@ const table = sqliteTable("session", {
- Local `typecheck` scripts use `bun check`. CI and package publishing run `typecheck:tsc`, which uses the official TypeScript compiler (`@typescript/native`). When the two disagree, `tsc` is the source of truth; reproduce with `bun run typecheck:tsc` from the package directory.
- `bun check` aims to match TypeScript 7.1, not the `7.0.2` that `process.versions.typescript` reports (oven-sh/bun#46536). When `bun check` and `tsc` from the pinned `@typescript/native` report different errors, file a minimal, dependency-free repro at https://github.com/oven-sh/bun/issues, with the output of both. `bun check` writes no declaration files, so declaration-emit errors such as TS5088 or TS2883 are expected to appear only in `tsc`; do not report those.
## Plugin Loading
- Plugins load asynchronously. Server handlers never wait for them (`Plugin.awaitActivation`), never retry until something appears, and never turn slow plugins into a timeout or 503. This applies to reads and to actions such as `generate.*`, `rpc.call`, `vcs.init` and `websearch.query`.
- The server answers with what is loaded now: lists may be empty or partial on a cold Location, and an action whose model, provider or RPC hasn't loaded yet fails with not-found. That is expected, not a bug to fix on the server.
- Clients are responsible for making sure what they need is loaded before they call. Long-lived clients refresh on the matching `*.updated` event (`model.updated`, `agent.updated`, `skill.updated`, `integration.updated`, `plugin.updated`, ...). One-shot clients (CLI commands, ACP, SDK callers) wait on their side, for example by polling `plugin.list` or the entry they need. See `packages/cli/src/services/plugin-startup.ts`.
- Running the hooks that are registered and waiting for their result is fine (for example `hooks.trigger("session", "http.request", ...)`). What is not allowed is waiting for plugins to load first: if the plugin that adds a hook hasn't loaded yet, the call runs without that hook.
## Session Core
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.

View file

@ -2,6 +2,7 @@ import { Effect } from "effect"
import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { ServerConnection } from "../../../services/server-connection"
import { waitForPlugins } from "../../../services/plugin-startup"
export const location = { directory: process.cwd() }
@ -15,8 +16,7 @@ export function request<A>(run: (signal: AbortSignal) => Promise<A>) {
}
export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) {
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
yield* request((signal) => client.model.default({ location }, { signal }))
yield* waitForPlugins(client, location.directory)
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
Effect.map((response) =>
response.data.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")),

View file

@ -5,12 +5,14 @@ import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode/client/effect/service"
import { ServerConnection } from "../../../services/server-connection"
import { waitForPlugins } from "../../../services/plugin-startup"
export default Runtime.handler(
Commands.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(function* () {
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
yield* waitForPlugins(client, process.cwd())
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
JSON.stringify(

View file

@ -5,6 +5,7 @@ import { EOL } from "node:os"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { waitForPlugins } from "../../services/plugin-startup"
export default Runtime.handler(
Commands.commands.models,
@ -14,6 +15,7 @@ export default Runtime.handler(
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
yield* waitForPlugins(client, process.cwd())
const response = yield* Effect.promise(() => client.model.list({ location: { directory: process.cwd() } }))
const models = response.data
.map((model) => `${model.providerID}/${model.id}`)

View file

@ -0,0 +1,13 @@
import type { OpenCodeClient } from "@opencode/client"
import { Effect, Schedule } from "effect"
// Catalog reads return whatever is loaded, and a cold Location lists no plugins until its first activation.
// One-shot commands read once and exit, so they give that activation a moment before reading.
export const waitForPlugins = (client: OpenCodeClient, directory: string) =>
Effect.tryPromise((signal) => client.plugin.list({ location: { directory } }, { signal })).pipe(
Effect.repeat({
until: (plugins) => plugins.data.length > 0,
schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" })),
}),
Effect.ignore,
)

View file

@ -529,7 +529,7 @@ export function make(options: ClientOptions) {
path: `/api/agent`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@ -2257,7 +2257,7 @@ export function make(options: ClientOptions) {
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,

View file

@ -345,16 +345,6 @@
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Retrieve currently registered agents.",
@ -9955,16 +9945,6 @@
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Retrieve currently registered skills.",

View file

@ -3,14 +3,13 @@ import { Location } from "@opencode/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { AgentNotFoundError, ServiceUnavailableError } from "../errors.js"
import { AgentNotFoundError } from "../errors.js"
export const AgentGroup = HttpApiGroup.make("server.agent")
.add(
HttpApiEndpoint.get("agent.list", "/api/agent", {
query: LocationQuery,
success: Location.response(Schema.Array(Agent.Info)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(

View file

@ -2,7 +2,6 @@ import { Skill } from "@opencode/schema/skill"
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")
@ -10,7 +9,6 @@ export const SkillGroup = HttpApiGroup.make("server.skill")
HttpApiEndpoint.get("skill.list", "/api/skill", {
query: LocationQuery,
success: Location.response(Schema.Array(Skill.Info)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(

View file

@ -11,7 +11,7 @@ import { WorkspaceDriver } from "@opencode/core/workspace/driver"
import { Plugin } from "@opencode/plugin/effect"
import { makeDurableObjectStorage } from "../../core/test/fixture/durable-object-storage"
import { OpenCodeWorkerd } from "../src/effect/workerd"
import { Deferred, Effect, Fiber, Latch, Layer, Option, Stream } from "effect"
import { Deferred, Effect, Fiber, Latch, Layer, Option, Schedule, Stream } from "effect"
import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src/effect"
@ -37,6 +37,15 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
const location = (fixture: Fixture) =>
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
// The server doesn't wait for plugins, so an SDK caller waits until the plugin-provided entry it needs is loaded.
// Activation takes milliseconds here; the cap stays well inside the 5s test timeout so a miss fails with its name.
const loaded = <A, E, R>(name: string, read: Effect.Effect<A, E, R>, ready: (value: A) => boolean) =>
read.pipe(
Effect.repeat({ until: ready, schedule: Schedule.spaced("10 millis") }),
Effect.timeoutOrElse({ duration: "3 seconds", orElse: () => Effect.die(new Error(`${name} never loaded`)) }),
Effect.andThen(read),
)
for (const selection of ["explicit", "default"] as const) {
it.live(`generate.text uses configured providers with ${selection} model selection`, () =>
withEmbedded("opencode-embedded-generate-", (fixture) =>
@ -67,6 +76,9 @@ for (const selection of ["explicit", "default"] as const) {
overrides: [llmClient.replace(Layer.succeed(LLMClient.Service, llm))],
},
)
yield* loaded("custom/fictional-chat", opencode.model.list({ location: location(fixture) }), (models) =>
models.data.some((model) => model.providerID === "custom" && model.id === "fictional-chat"),
)
const result = yield* opencode.experimental.generate.text({
prompt: "Say ready",
@ -89,7 +101,7 @@ for (const selection of ["explicit", "default"] as const) {
)
}
it.live("seeds host plugins from CreateOptions and waits for delayed model plugins without self-deadlock", () =>
it.live("seeds host plugins from CreateOptions and loads delayed model plugins without self-deadlock", () =>
withEmbedded("opencode-embedded-seeded-plugin-", (fixture) =>
Effect.gen(function* () {
const llm = yield* TestLLM.Test.pipe(
@ -136,10 +148,12 @@ it.live("seeds host plugins from CreateOptions and waits for delayed model plugi
},
)
const [defaultModel, generated] = yield* Effect.all(
[opencode.model.default({ location: location(fixture) }), opencode.experimental.generate.text({ prompt: "Say ready" })],
{ concurrency: "unbounded" },
const defaultModel = yield* loaded(
"seeded model",
opencode.model.default({ location: location(fixture) }),
(model) => model.data?.providerID === "seeded",
)
const generated = yield* opencode.experimental.generate.text({ prompt: "Say ready" })
expect(listedDuringSetup.length).toBeGreaterThan(0)
expect(defaultModel.data).toMatchObject({
@ -184,9 +198,13 @@ it.live("OpenCodeWorkerd seeds plugins before cold model resolution", () =>
],
})
const selected = yield* opencode.model.default({
location: fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make("/workspace") }),
})
const selected = yield* loaded(
"workerd-seeded model",
opencode.model.default({
location: fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make("/workspace") }),
}),
(model) => model.data?.providerID === "workerd-seeded",
)
expect(selected.data).toMatchObject({
providerID: "workerd-seeded",
id: "fictional-chat",
@ -320,6 +338,11 @@ it.live("embedded client exposes plugin-backed web search", () =>
})
}),
})
yield* loaded(
"embedded-websearch provider",
opencode.websearch.providers({ location: location(fixture) }),
(providers) => providers.data.some((provider) => provider.id === providerID),
)
const result = yield* opencode.websearch.query({
query: "opencode",

View file

@ -2,17 +2,12 @@ import { Agent } from "@opencode/core/agent"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { awaitPlugins, response } from "../location"
import { response } from "../location"
import { AgentNotFoundError } from "@opencode/protocol/errors"
export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) =>
handlers
.handle("agent.list", () =>
Effect.gen(function* () {
yield* awaitPlugins
return yield* response(Agent.Service.use((agent) => agent.list()))
}),
)
.handle("agent.list", () => response(Agent.Service.use((agent) => agent.list())))
.handle(
"agent.get",
Effect.fn(function* (ctx) {

View file

@ -15,7 +15,7 @@ import {
import { Effect } from "effect"
import { type HttpServerRequest, HttpServerResponse } from "effect/http"
import { HttpApiSchema } from "effect/http-api"
import { awaitPlugins, locationErrors, requestRef, response } from "../location"
import { locationErrors, requestRef, response } from "../location"
import { missingSession } from "./session-error"
/** `artifacts.*` in the `server.experimental` group. Every route needs experimental.artifacts in the resolved Location. */
@ -77,7 +77,7 @@ export const makeArtifact = Effect.gen(function* () {
) =>
Effect.gen(function* () {
const ref = yield* resolveLocation(request, options)
return yield* awaitPlugins.pipe(Effect.andThen(effect), Effect.provide(locations.get(ref)), locationErrors)
return yield* effect.pipe(Effect.provide(locations.get(ref)), locationErrors)
})
const mapCommon = <A, E>(effect: Effect.Effect<A, E>) =>

View file

@ -15,7 +15,7 @@ import { Model } from "@opencode/schema/model"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { awaitPlugins, locationErrors } from "../location"
import { locationErrors } from "../location"
export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (handlers) =>
Effect.gen(function* () {
@ -24,7 +24,6 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
const services = locations.get(Location.Ref.make({ directory: AbsolutePath.make(global.config) }))
const text = Effect.fn("server.generate.text")(
function* (request: { readonly payload: { readonly prompt: string; readonly model?: Model.Ref } }) {
yield* awaitPlugins
const generate = yield* Generate.Service
return { data: { text: yield* generate.text(request.payload).pipe(Effect.mapError(generateError)) } }
},
@ -33,7 +32,6 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
)
const image = Effect.fn("server.generate.image")(
function* (request: { readonly payload: ImageRequest }) {
yield* awaitPlugins
const generate = yield* Generate.Service
return { data: yield* generate.image(request.payload).pipe(Effect.mapError(generateError)) }
},
@ -48,7 +46,6 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
message: "Decision question names must be unique",
field: "questions",
})
yield* awaitPlugins
const generate = yield* Generate.Service
return { data: yield* generate.decisions(request.payload).pipe(Effect.mapError(generateError)) }
},

View file

@ -1,5 +1,4 @@
import { Integration } from "@opencode/core/integration"
import { Plugin } from "@opencode/core/plugin"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/http-api"
import { Api } from "../api"
@ -54,7 +53,6 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
.handle(
"integration.list",
Effect.fn(function* () {
yield* Plugin.awaitActivation
const service = yield* Integration.Service
const current = yield* statements()
return yield* response(

View file

@ -2,7 +2,7 @@ import { Model } from "@opencode/core/model"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { awaitPlugins, response } from "../location"
import { response } from "../location"
export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) =>
Effect.gen(function* () {
@ -10,7 +10,6 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
.handle(
"model.list",
Effect.fn(function* (ctx) {
yield* awaitPlugins
const models = yield* Model.Service
return yield* response(models.available(ctx.query.types))
}),
@ -18,7 +17,6 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
.handle(
"model.default",
Effect.fn(function* () {
yield* awaitPlugins
const models = yield* Model.Service
return yield* response(models.default())
}),

View file

@ -3,7 +3,7 @@ import { ProviderNotFoundError } from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { awaitPlugins, response } from "../location"
import { response } from "../location"
export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
Effect.gen(function* () {
@ -11,7 +11,6 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han
.handle(
"provider.list",
Effect.fn(function* () {
yield* awaitPlugins
const providers = yield* Provider.Service
return yield* response(providers.available())
}),

View file

@ -1,5 +1,4 @@
import { Rpc } from "@opencode/core/rpc"
import { Plugin } from "@opencode/core/plugin"
import { RpcError, RpcInternalError } from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
@ -8,7 +7,6 @@ import { Api } from "../api"
export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
handlers.handle("rpc.call", ({ params, payload }) =>
Effect.gen(function* () {
yield* Plugin.awaitActivation
const rpc = yield* Rpc.Service
const output = yield* rpc.call(params.rpcID, params.method, payload.input)
return output === undefined ? {} : { output }

View file

@ -1,14 +1,8 @@
import { Skill } from "@opencode/core/skill"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { awaitPlugins, response } from "../location"
import { response } from "../location"
export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) =>
handlers.handle("skill.list", () =>
Effect.gen(function* () {
yield* awaitPlugins
return yield* response(Skill.Service.use((skill) => skill.list()))
}),
),
handlers.handle("skill.list", () => response(Skill.Service.use((skill) => skill.list()))),
)

View file

@ -3,15 +3,13 @@ import { ServiceUnavailableError } from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { awaitPlugins, response } from "../location"
import { response } from "../location"
export const UsageHandler = HttpApiBuilder.group(Api, "server.usage", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"usage.limits",
Effect.fn("server.usage.limits")(function* () {
// The OpenCode Console plugin registers the sign-in method that refreshes an expired credential.
yield* awaitPlugins
const limits = yield* UsageLimits.Service
return yield* response(
limits.get().pipe(

View file

@ -2,7 +2,6 @@ import { Vcs } from "@opencode/core/vcs"
import { Project } from "@opencode/core/project"
import { Worktree } from "@opencode/core/worktree"
import { Bus } from "@opencode/core/bus"
import { Plugin } from "@opencode/core/plugin"
import { Location } from "@opencode/core/location"
import { LocationServiceMap } from "@opencode/core/location-services"
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol/errors"
@ -23,7 +22,6 @@ export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
const location = yield* Location.Service
const directory = location.project.directory
const providerID = ctx.query.provider ?? "git"
yield* Plugin.awaitActivation
const vcs = yield* Vcs.Service
yield* vcs.initialize(providerID).pipe(
Effect.mapError((error) => {

View file

@ -7,7 +7,7 @@ import { Global } from "@opencode/util/global"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { awaitPlugins, locationErrors } from "../location"
import { locationErrors } from "../location"
export const VoiceHandler = HttpApiBuilder.group(Api, "server.voice", (handlers) =>
Effect.gen(function* () {
@ -19,7 +19,6 @@ export const VoiceHandler = HttpApiBuilder.group(Api, "server.voice", (handlers)
"voice.models",
Effect.fn("server.voice.models")(
function* () {
yield* awaitPlugins
const voice = yield* Voice.Service
return { data: yield* voice.models() }
},
@ -31,7 +30,6 @@ export const VoiceHandler = HttpApiBuilder.group(Api, "server.voice", (handlers)
"voice.session",
Effect.fn("server.voice.session")(
function* (request) {
yield* awaitPlugins
const voice = yield* Voice.Service
const transport = yield* voice
.session(request.payload)

View file

@ -3,7 +3,7 @@ import { InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { awaitPlugins, response } from "../location"
import { response } from "../location"
export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (handlers) =>
Effect.gen(function* () {
@ -18,8 +18,6 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
.handle(
"websearch.query",
Effect.fn("server.websearch.query")(function* (request) {
// A cold Location must finish activating plugins before their providers can be resolved.
yield* awaitPlugins
const websearch = yield* WebSearch.Service
return yield* response(
websearch.query(request.payload).pipe(

View file

@ -1,10 +1,9 @@
import { FileSystem } from "@opencode/core/filesystem"
import { Location } from "@opencode/core/location"
import { Plugin } from "@opencode/core/plugin"
import { LocationServiceMap } from "@opencode/core/location-services"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { LocationNotFoundError, InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol/errors"
import { LocationNotFoundError, InvalidRequestError } from "@opencode/protocol/errors"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/http"
import { HttpApiMiddleware } from "effect/http-api"
@ -33,19 +32,6 @@ export function locationErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
)
}
export const awaitPlugins = Plugin.awaitActivation.pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () =>
Effect.fail(
new ServiceUnavailableError({
message: "Location plugins did not finish activating",
service: "plugin",
}),
),
}),
)
export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const location = yield* Location.Service

View file

@ -5,7 +5,7 @@ import { OpenCode } from "@opencode/client"
import { Effect } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
import { serveRoutes } from "./fixture/routes"
const client = (handler: (request: Request) => Promise<Response>) =>
OpenCode.make({
@ -81,14 +81,16 @@ export default {
)
})
const handler = yield* ServerFetch.make({
const server = yield* serveRoutes({
app: { version: "test" },
database: { path: path.join(dir, "opencode.db") },
config: { directory: global, project: true, claude: false },
models: { fetch: false },
fs: { filewatcher: false },
})
const handler = server.handler
const api = client(handler)
yield* server.pluginsLoaded(enabled)
yield* Effect.promise(async () => {
// Disabled location returns ForbiddenError

View file

@ -0,0 +1,37 @@
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { Plugin } from "@opencode/core/plugin"
import { Location } from "@opencode/schema/location"
import { AbsolutePath } from "@opencode/schema/schema"
import type { LayerNode } from "@opencode/util/effect/layer-node"
import { Context, Effect, Layer } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/http"
import type { ServerOptions } from "../../src/options"
import { createRoutes } from "../../src/routes"
// The server never waits for a Location's plugins to load. A test that sets up a Location does, so it can
// exercise plugin-provided entries without polling.
export const pluginsLoaded = (context: Context.Context<LocationServiceMap.Service>, directory: string) =>
Plugin.awaitActivation.pipe(
Effect.provide(
Context.get(context, LocationServiceMap.Service).get(
Location.Ref.make({ directory: AbsolutePath.make(directory) }),
),
),
)
// Serves the routes in this process so the test can reach the Location's plugins.
export const serveRoutes = Effect.fnUntraced(function* (
options: ServerOptions,
overrides: LayerNode.Replacements = [],
) {
const context = yield* Layer.build(
createRoutes(options, () => [], overrides).pipe(Layer.provide(HttpServer.layerServices)),
)
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
return {
context,
handler,
fetch: (input: string | URL, init?: RequestInit) => handler(new Request(input, init)),
pluginsLoaded: (directory: string) => pluginsLoaded(context, directory),
}
})

View file

@ -4,6 +4,7 @@ import { HttpEffect, HttpRouter, HttpServer } from "effect/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createRoutes } from "../src/routes"
import { pluginsLoaded } from "./fixture/routes"
it.live("integration policy filters /connect and blocks denied integrations and providers", () =>
Effect.gen(function* () {
@ -58,6 +59,7 @@ it.live("integration policy filters /connect and blocks denied integrations and
return { status: response.status, body: response.status === 204 ? undefined : await response.json() }
})
yield* pluginsLoaded(context, tmp.path)
const integrations = yield* request("GET", "/api/integration")
expect(integrations.status).toBe(200)
// MCP sign-ins are outside integration policy; `mcp.use` governs MCP servers.

View file

@ -4,9 +4,9 @@ import { expect } from "bun:test"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
import { serveRoutes } from "./fixture/routes"
it.live("waits for plugin initialization before listing models", () =>
it.live("lists configured models filtered by type", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-model-endpoint-")))
yield* Effect.promise(() =>
@ -23,10 +23,16 @@ it.live("waits for plugin initialization before listing models", () =>
}),
),
)
const server = yield* startServer(tmp.path)
const url = new URL("/api/model", server.base)
const server = yield* serveRoutes({
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
models: { fetch: false },
})
yield* server.pluginsLoaded(tmp.path)
const url = new URL("/api/model", "http://opencode.local")
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
const response = yield* Effect.promise(() => server.fetch(url))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
@ -36,14 +42,14 @@ it.live("waits for plugin initialization before listing models", () =>
).toBeTrue()
url.searchParams.set("types", "image")
const images = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
const images = yield* Effect.promise(() => server.fetch(url))
expect(images.status).toBe(200)
const imageBody: unknown = yield* Effect.promise(() => images.json())
if (!isRecord(imageBody) || !Array.isArray(imageBody["data"])) throw new Error("Expected a model list response")
expect(imageBody["data"].some((model) => isRecord(model) && model["providerID"] === "custom")).toBeFalse()
url.searchParams.append("types", "chat")
const both = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
const both = yield* Effect.promise(() => server.fetch(url))
expect(both.status).toBe(200)
const bothBody: unknown = yield* Effect.promise(() => both.json())
if (!isRecord(bothBody) || !Array.isArray(bothBody["data"])) throw new Error("Expected a model list response")

View file

@ -1,14 +1,14 @@
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/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createRoutes } from "../src/routes"
it.live(
"agent and provider lists wait for plugins while single provider reads stay nonblocking",
"catalog reads answer while plugins are still activating",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped("opencode-provider-endpoints-")
@ -63,40 +63,26 @@ it.live(
}),
)
})
// Config providers and agents activate after SDK plugins, so the lists wait rather than answer with a partial
// catalog that a client would cache as loaded.
const providers = yield* request("GET", "/api/provider").pipe(Effect.forkScoped)
const agents = yield* request("GET", "/api/agent").pipe(Effect.forkScoped)
// Config providers and agents activate after SDK plugins. Reads return the current snapshot without waiting,
// and clients refresh on the matching *.updated events.
yield* request("GET", "/api/provider").pipe(Effect.timeout("2 seconds"))
yield* Deferred.await(started)
const integrations = yield* request("GET", "/api/integration").pipe(Effect.forkScoped)
for (const route of ["/api/provider", "/api/agent", "/api/model", "/api/skill", "/api/integration"]) {
const response = yield* request("GET", route).pipe(Effect.timeout("2 seconds"))
expect(response.status).toBe(200)
}
const list = yield* request("GET", "/api/provider").pipe(Effect.timeout("2 seconds"))
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(providers.pollUnsafe()).toBeUndefined()
expect(agents.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
const list = yield* Fiber.join(providers)
expect(list.status).toBe(200)
expect(yield* Effect.promise(() => list.json())).toMatchObject({
location: { directory: tmp.path },
data: expect.arrayContaining([expect.objectContaining({ id: "custom" })]),
})
const agentList = yield* Fiber.join(agents)
expect(agentList.status).toBe(200)
expect(yield* Effect.promise(() => agentList.json())).toMatchObject({
data: expect.arrayContaining([
expect.objectContaining({ id: "build" }),
expect.objectContaining({ id: "reviewer" }),
]),
})
const discovered = yield* Fiber.join(integrations)
expect(discovered.status).toBe(200)
expect(yield* Effect.promise(() => discovered.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ id: "opencode" })]),
})
}),
15_000,
)

View file

@ -7,6 +7,7 @@ import { HttpEffect, HttpRouter, HttpServer } from "effect/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createEmbeddedRoutes } from "../src/routes"
import { pluginsLoaded } from "./fixture/routes"
const Broken = Rpc.define({
id: "broken",
@ -52,6 +53,7 @@ for (const method of ["handler", "schema"] as const) {
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
yield* pluginsLoaded(context, directory.path)
const url = new URL(`/api/rpc/broken/${method}`, "http://opencode.local")
url.searchParams.set("location[directory]", directory.path)
const response = yield* Effect.promise(() =>

View file

@ -4,9 +4,9 @@ import { expect } from "bun:test"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
import { serveRoutes } from "./fixture/routes"
it.live("waits for plugin initialization before listing skills", () =>
it.live("lists configured skills", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-skill-endpoint-")))
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "skills", "demo"), { recursive: true }))
@ -19,10 +19,16 @@ it.live("waits for plugin initialization before listing skills", () =>
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)
const server = yield* serveRoutes({
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
models: { fetch: false },
})
yield* server.pluginsLoaded(tmp.path)
const url = new URL("/api/skill", "http://opencode.local")
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
const response = yield* Effect.promise(() => server.fetch(url))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())

View file

@ -6,6 +6,7 @@ import { Effect, Layer, Schedule } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
import { serveRoutes } from "./fixture/routes"
import { ServerFetch } from "../src/fetch"
it.live(
@ -14,33 +15,37 @@ it.live(
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-init-")))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
const server = yield* serveRoutes({
database: { path: ":memory:" },
config: { directory: path.join(tmp.path, "config") },
fs: { filewatcher: false },
models: { fetch: false },
})
yield* server.pluginsLoaded(tmp.path)
const url = new URL("/api/vcs/init", "http://opencode.local")
url.searchParams.set("location[directory]", tmp.path)
const before = yield* Effect.promise(() =>
fetch(new URL(`/api/location${url.search}`, server.base), { headers: server.headers }),
server.fetch(new URL(`/api/location${url.search}`, "http://opencode.local")),
)
expect(before.status).toBe(200)
const original = yield* Effect.promise(() => before.json())
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
const initialized = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => $`git -C ${tmp.path} rev-parse --is-inside-work-tree`.text())).toBe("true\n")
url.pathname = "/api/location"
const location = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
const location = yield* Effect.promise(() => server.fetch(url))
expect(location.status).toBe(200)
const refreshed = yield* Effect.promise(() => location.json())
expect(refreshed).toMatchObject({ project: { directory: tmp.path } })
expect(refreshed.project.id).not.toBe(original.project.id)
const projects = yield* Effect.promise(() =>
fetch(new URL("/api/project", server.base), { headers: server.headers }),
)
const projects = yield* Effect.promise(() => server.fetch(new URL("/api/project", "http://opencode.local")))
expect(yield* Effect.promise(() => projects.json())).toContainEqual(
expect.objectContaining({ id: refreshed.project.id, vcs: "git" }),
)
url.pathname = "/api/vcs"
const vcs = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: server.headers })
const response = await server.fetch(url)
const body: unknown = await response.json()
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "git")
throw new Error("Git provider not ready")
@ -51,14 +56,14 @@ it.live(
expect(vcs).toMatchObject({ data: { provider: "git" } })
url.pathname = "/api/vcs/diff"
url.searchParams.set("mode", "working")
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
const diff = yield* Effect.promise(() => server.fetch(url))
expect(diff.status).toBe(200)
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
})
url.searchParams.delete("mode")
url.pathname = "/api/vcs/init"
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
const repeated = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
expect(repeated.status).toBe(409)
}),
15_000,
@ -85,11 +90,17 @@ it.live(
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-provider-")))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
const server = yield* serveRoutes({
database: { path: ":memory:" },
config: { directory: path.join(tmp.path, "config") },
fs: { filewatcher: false },
models: { fetch: false },
})
yield* server.pluginsLoaded(tmp.path)
const url = new URL("/api/vcs/init", "http://opencode.local")
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "unknown")
const unsupported = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
const unsupported = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
expect(unsupported.status).toBe(400)
expect(yield* Effect.promise(() => unsupported.json())).toMatchObject({
_tag: "InvalidRequestError",
@ -98,7 +109,7 @@ it.live(
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(false)
url.searchParams.set("provider", "git")
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
const initialized = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(true)
}),
@ -108,49 +119,48 @@ it.live(
it.live("returns 501 when a registered VCS provider has no initializer", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-no-init-")))
const handler = yield* ServerFetch.make(
const server = yield* serveRoutes(
{
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SdkPlugins.node.replace(
Layer.succeed(
SdkPlugins.Service,
SdkPlugins.Service.of({
register: () => Effect.void,
all: () => [
{
id: "read-only-vcs",
revision: "test",
effect: (ctx) =>
ctx.vcs
.transform((editor) => {
editor.add({
id: "read-only",
name: "Read-only VCS",
info: () => Effect.succeed({ branch: {} }),
branches: () => Effect.succeed([]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
})
[
SdkPlugins.node.replace(
Layer.succeed(
SdkPlugins.Service,
SdkPlugins.Service.of({
register: () => Effect.void,
all: () => [
{
id: "read-only-vcs",
revision: "test",
effect: (ctx) =>
ctx.vcs
.transform((editor) => {
editor.add({
id: "read-only",
name: "Read-only VCS",
info: () => Effect.succeed({ branch: {} }),
branches: () => Effect.succeed([]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
})
.pipe(Effect.asVoid),
},
],
}),
),
})
.pipe(Effect.asVoid),
},
],
}),
),
],
},
),
],
)
yield* server.pluginsLoaded(tmp.path)
const url = new URL("http://opencode.local/api/vcs/init")
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "read-only")
const response = yield* Effect.promise(() => handler(new Request(url, { method: "POST" })))
const response = yield* Effect.promise(() => server.handler(new Request(url, { method: "POST" })))
expect(response.status).toBe(501)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "VcsInitNotSupportedError",
@ -170,17 +180,23 @@ describeHg("Mercurial initialization", () => {
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-hg-init-")))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
const server = yield* serveRoutes({
database: { path: ":memory:" },
config: { directory: path.join(tmp.path, "config") },
fs: { filewatcher: false },
models: { fetch: false },
})
yield* server.pluginsLoaded(tmp.path)
const url = new URL("/api/vcs/init", "http://opencode.local")
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "hg")
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
const initialized = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(true)
url.pathname = "/api/vcs"
const info = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: server.headers })
const response = await server.fetch(url)
const body: unknown = await response.json()
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "hg")
throw new Error("Mercurial provider not ready")
@ -191,7 +207,7 @@ describeHg("Mercurial initialization", () => {
expect(info).toMatchObject({ data: { provider: "hg" } })
url.pathname = "/api/vcs/diff"
url.searchParams.set("mode", "working")
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
const diff = yield* Effect.promise(() => server.fetch(url))
expect(diff.status).toBe(200)
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
@ -199,7 +215,7 @@ describeHg("Mercurial initialization", () => {
url.pathname = "/api/vcs/init"
url.searchParams.delete("mode")
url.searchParams.set("provider", "git")
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
const repeated = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
expect(repeated.status).toBe(409)
}),
15_000,