fix(server): let agent and provider lists wait for plugins (#59082)

Co-authored-by: Kit Langton <kit.langton@gmail.com>
This commit is contained in:
Luke Parker
2026-10-26 08:55:28 +10:00
committed by GitHub
Unverified
parent f6991636a5
commit 61163cb134
6 changed files with 39 additions and 12 deletions
+1 -1
View File
@@ -492,7 +492,7 @@ export function make(options: ClientOptions) {
path: `/api/agent`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
+10
View File
@@ -359,6 +359,16 @@
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Retrieve currently registered agents.",
+2 -1
View File
@@ -3,13 +3,14 @@ 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 } from "../errors.js"
import { AgentNotFoundError, ServiceUnavailableError } 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(
+2 -1
View File
@@ -2,13 +2,14 @@ import { Agent } from "@opencode/core/agent"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { response } from "../location"
import { awaitPlugins, 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()))
}),
)
+2 -1
View File
@@ -3,7 +3,7 @@ import { ProviderNotFoundError } from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { response } from "../location"
import { awaitPlugins, response } from "../location"
export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
Effect.gen(function* () {
@@ -11,6 +11,7 @@ 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())
}),
+22 -8
View File
@@ -8,7 +8,7 @@ import { it } from "../../core/test/lib/effect"
import { createRoutes } from "../src/routes"
it.live(
"provider reads stay nonblocking while authentication discovery waits for plugins",
"agent and provider lists wait for plugins while single provider reads stay nonblocking",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped("opencode-provider-endpoints-")
@@ -32,6 +32,7 @@ it.live(
models: { chat: {} },
},
},
agents: { reviewer: { description: "Reviews changes" } },
}),
},
}).pipe(Layer.provide(HttpServer.layerServices)),
@@ -62,22 +63,35 @@ it.live(
}),
)
})
// Config providers activate after SDK plugins; reads must return the current snapshot without waiting.
const list = yield* request("GET", "/api/provider").pipe(Effect.timeout("2 seconds"))
// 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)
yield* Deferred.await(started)
const integrations = yield* request("GET", "/api/integration").pipe(Effect.forkScoped)
expect(list.status).toBe(200)
expect(yield* Effect.promise(() => list.json())).toMatchObject({
location: { directory: tmp.path },
data: expect.not.arrayContaining([expect.objectContaining({ id: "custom" })]),
})
const get = yield* request("GET", "/api/provider/custom").pipe(Effect.timeout("2 seconds"))
expect(get.status).toBe(404)
expect(yield* Effect.promise(() => get.json())).toMatchObject({
_tag: "ProviderNotFoundError",
providerID: "custom",
})
expect(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({