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,