From bcaf561d94947ed8a5d4bc4871e01d36e7d9c8f3 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:04:17 -0600 Subject: [PATCH] fix(server): stop waiting for plugins in request handlers (#62796) --- AGENTS.md | 7 + .../cli/src/commands/handlers/auth/shared.ts | 4 +- .../cli/src/commands/handlers/debug/agents.ts | 2 + packages/cli/src/commands/handlers/models.ts | 2 + packages/cli/src/services/plugin-startup.ts | 13 ++ .../client/src/promise/generated/client.ts | 4 +- packages/protocol/openapi.json | 20 --- packages/protocol/src/groups/agent.ts | 3 +- packages/protocol/src/groups/skill.ts | 2 - packages/sdk/test/embedded.test.ts | 39 ++++-- packages/server/src/handlers/agent.ts | 9 +- packages/server/src/handlers/artifact.ts | 4 +- packages/server/src/handlers/generate.ts | 5 +- packages/server/src/handlers/integration.ts | 2 - packages/server/src/handlers/model.ts | 4 +- packages/server/src/handlers/provider.ts | 3 +- packages/server/src/handlers/rpc.ts | 2 - packages/server/src/handlers/skill.ts | 10 +- packages/server/src/handlers/usage.ts | 4 +- packages/server/src/handlers/vcs.ts | 2 - packages/server/src/handlers/voice.ts | 4 +- packages/server/src/handlers/websearch.ts | 4 +- packages/server/src/location.ts | 16 +-- packages/server/test/artifact.test.ts | 6 +- packages/server/test/fixture/routes.ts | 37 ++++++ .../server/test/integration-policy.test.ts | 2 + packages/server/test/model.test.ts | 20 ++- packages/server/test/provider.test.ts | 42 ++---- .../server/test/rpc-handler-errors.test.ts | 2 + packages/server/test/skill.test.ts | 16 ++- packages/server/test/vcs.test.ts | 120 ++++++++++-------- 31 files changed, 224 insertions(+), 186 deletions(-) create mode 100644 packages/cli/src/services/plugin-startup.ts create mode 100644 packages/server/test/fixture/routes.ts diff --git a/AGENTS.md b/AGENTS.md index 7fdbe171b152..5738a04febfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/packages/cli/src/commands/handlers/auth/shared.ts b/packages/cli/src/commands/handlers/auth/shared.ts index 737bffab56bd..746e0f7a11a6 100644 --- a/packages/cli/src/commands/handlers/auth/shared.ts +++ b/packages/cli/src/commands/handlers/auth/shared.ts @@ -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(run: (signal: AbortSignal) => Promise) { } 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")), diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index 8386155a3303..7086e57452f7 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -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( diff --git a/packages/cli/src/commands/handlers/models.ts b/packages/cli/src/commands/handlers/models.ts index f7a66bd7d0d5..64ee9759ba47 100644 --- a/packages/cli/src/commands/handlers/models.ts +++ b/packages/cli/src/commands/handlers/models.ts @@ -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}`) diff --git a/packages/cli/src/services/plugin-startup.ts b/packages/cli/src/services/plugin-startup.ts new file mode 100644 index 000000000000..187b82dd7a2f --- /dev/null +++ b/packages/cli/src/services/plugin-startup.ts @@ -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, + ) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index b04473c5ec9c..cb1725def15f 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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, diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index c008436f7c3e..aa5745c3e106 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -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.", diff --git a/packages/protocol/src/groups/agent.ts b/packages/protocol/src/groups/agent.ts index eecf67af0850..485d0ae37cbf 100644 --- a/packages/protocol/src/groups/agent.ts +++ b/packages/protocol/src/groups/agent.ts @@ -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( diff --git a/packages/protocol/src/groups/skill.ts b/packages/protocol/src/groups/skill.ts index 4694342834f3..b01bf265d4d4 100644 --- a/packages/protocol/src/groups/skill.ts +++ b/packages/protocol/src/groups/skill.ts @@ -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( diff --git a/packages/sdk/test/embedded.test.ts b/packages/sdk/test/embedded.test.ts index f9c188b4a501..85d270dd087e 100644 --- a/packages/sdk/test/embedded.test.ts +++ b/packages/sdk/test/embedded.test.ts @@ -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 = (name: string, read: Effect.Effect, 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", diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts index eff3bb9497a7..91a3f1019426 100644 --- a/packages/server/src/handlers/agent.ts +++ b/packages/server/src/handlers/agent.ts @@ -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) { diff --git a/packages/server/src/handlers/artifact.ts b/packages/server/src/handlers/artifact.ts index 1a217107f206..60841d58fe2e 100644 --- a/packages/server/src/handlers/artifact.ts +++ b/packages/server/src/handlers/artifact.ts @@ -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 = (effect: Effect.Effect) => diff --git a/packages/server/src/handlers/generate.ts b/packages/server/src/handlers/generate.ts index 73b916fea9d7..0799842e5ed3 100644 --- a/packages/server/src/handlers/generate.ts +++ b/packages/server/src/handlers/generate.ts @@ -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)) } }, diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index e7fee308b277..e11af39930b3 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -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( diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index eb5517449947..af2bb0daced4 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -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()) }), diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index 3b0bdf4a4956..ab9c2201a5ee 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -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()) }), diff --git a/packages/server/src/handlers/rpc.ts b/packages/server/src/handlers/rpc.ts index ded5610ae618..bc7013ce21a0 100644 --- a/packages/server/src/handlers/rpc.ts +++ b/packages/server/src/handlers/rpc.ts @@ -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 } diff --git a/packages/server/src/handlers/skill.ts b/packages/server/src/handlers/skill.ts index 98947c389e5c..39039fcd638b 100644 --- a/packages/server/src/handlers/skill.ts +++ b/packages/server/src/handlers/skill.ts @@ -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()))), ) diff --git a/packages/server/src/handlers/usage.ts b/packages/server/src/handlers/usage.ts index e70592c1b31a..93dab7f1281a 100644 --- a/packages/server/src/handlers/usage.ts +++ b/packages/server/src/handlers/usage.ts @@ -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( diff --git a/packages/server/src/handlers/vcs.ts b/packages/server/src/handlers/vcs.ts index 2d792578cb25..7600ed5dcaed 100644 --- a/packages/server/src/handlers/vcs.ts +++ b/packages/server/src/handlers/vcs.ts @@ -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) => { diff --git a/packages/server/src/handlers/voice.ts b/packages/server/src/handlers/voice.ts index f9507a98c335..ff9d46a7385b 100644 --- a/packages/server/src/handlers/voice.ts +++ b/packages/server/src/handlers/voice.ts @@ -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) diff --git a/packages/server/src/handlers/websearch.ts b/packages/server/src/handlers/websearch.ts index 853e223d54b0..71808b86e206 100644 --- a/packages/server/src/handlers/websearch.ts +++ b/packages/server/src/handlers/websearch.ts @@ -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( diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index f98d7ceeaf30..c37979e94138 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -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(effect: Effect.Effect) { ) } -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(data: Effect.Effect) { return Effect.gen(function* () { const location = yield* Location.Service diff --git a/packages/server/test/artifact.test.ts b/packages/server/test/artifact.test.ts index 3ee6047e0c0f..41780e72fdfe 100644 --- a/packages/server/test/artifact.test.ts +++ b/packages/server/test/artifact.test.ts @@ -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) => 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 diff --git a/packages/server/test/fixture/routes.ts b/packages/server/test/fixture/routes.ts new file mode 100644 index 000000000000..5bf8cbf2c663 --- /dev/null +++ b/packages/server/test/fixture/routes.ts @@ -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, 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), + } +}) diff --git a/packages/server/test/integration-policy.test.ts b/packages/server/test/integration-policy.test.ts index bc2f1c9062af..502965f8e356 100644 --- a/packages/server/test/integration-policy.test.ts +++ b/packages/server/test/integration-policy.test.ts @@ -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. diff --git a/packages/server/test/model.test.ts b/packages/server/test/model.test.ts index 85991acd06d3..0583ea9178d1 100644 --- a/packages/server/test/model.test.ts +++ b/packages/server/test/model.test.ts @@ -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") diff --git a/packages/server/test/provider.test.ts b/packages/server/test/provider.test.ts index 4e132e7fd453..037fc5323588 100644 --- a/packages/server/test/provider.test.ts +++ b/packages/server/test/provider.test.ts @@ -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, ) diff --git a/packages/server/test/rpc-handler-errors.test.ts b/packages/server/test/rpc-handler-errors.test.ts index 6b141d0e25bd..3129005254f8 100644 --- a/packages/server/test/rpc-handler-errors.test.ts +++ b/packages/server/test/rpc-handler-errors.test.ts @@ -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(() => diff --git a/packages/server/test/skill.test.ts b/packages/server/test/skill.test.ts index 3a2f096c6c10..c2df82b048c4 100644 --- a/packages/server/test/skill.test.ts +++ b/packages/server/test/skill.test.ts @@ -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()) diff --git a/packages/server/test/vcs.test.ts b/packages/server/test/vcs.test.ts index c3802e1948b9..290e5129ba6d 100644 --- a/packages/server/test/vcs.test.ts +++ b/packages/server/test/vcs.test.ts @@ -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,