diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index d29a5268579..cb45acafb89 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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, diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index ec858694639..48964f54d3e 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -359,6 +359,16 @@ } } } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableErrorEncoded" + } + } + } } }, "description": "Retrieve currently registered agents.", diff --git a/packages/protocol/src/groups/agent.ts b/packages/protocol/src/groups/agent.ts index 485d0ae37cb..eecf67af085 100644 --- a/packages/protocol/src/groups/agent.ts +++ b/packages/protocol/src/groups/agent.ts @@ -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( diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts index f54148af226..eff3bb9497a 100644 --- a/packages/server/src/handlers/agent.ts +++ b/packages/server/src/handlers/agent.ts @@ -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())) }), ) diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index ab9c2201a5e..3b0bdf4a495 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 { 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()) }), diff --git a/packages/server/test/provider.test.ts b/packages/server/test/provider.test.ts index 9da592289ee..4e132e7fd45 100644 --- a/packages/server/test/provider.test.ts +++ b/packages/server/test/provider.test.ts @@ -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({