Compare commits

...
Author SHA1 Message Date
Kit Langton 5b67d64f94 refactor(core): simplify Console recovery lifecycle 2026-08-27 20:11:33 -04:00
Kit Langton 79f5080431 fix(core): keep Console inventory recovery plugin-local
Remove the Catalog refresh registry and SessionContext retry path. Use SynchronizedRef, FiberHandle, and Schedule inside the Console plugin to retain successful inventories and own retry and cancellation behavior.

Cache unexpanded inventories per stored connection, restore them before HTTP revalidation, and keep persistence failures and nested catalog-policy mutations from corrupting source data. Materialize the existing OAuth registration when batched startup would otherwise resolve an expired token without its refresh implementation.
2026-08-27 19:38:49 -04:00
Kit Langton b70bdad053 fix(core): recover Console models after startup failures
A transient Console configuration fetch failure was swallowed into an empty inventory that stayed captured after networking recovered. Refresh sources separately from catalog replay, retry transient loads within a bounded startup budget, and recover failed inventories in the background with capped backoff.

Preserve same-account inventory, refresh missing selections and moved sessions, and cover startup recovery, account isolation, cancellation, and ordered policy replay with production-service tests.
2026-08-27 17:37:09 -04:00
4 changed files with 698 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Keep Console model inventories available across restarts using a stored-connection cache, and recover transient fetch failures with scoped retries. Refresh and caching stay inside the Console plugin, preserve existing catalog policy, and do not persist resolved credentials.
+154 -26
View File
@@ -1,8 +1,8 @@
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import { Cause, Duration, Effect, Exit, Latch, Option, Schedule, Schema, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
@@ -12,11 +12,31 @@ import { ConfigProviderV1 } from "../../v1/config/provider.js"
import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
import { ConfigV1 } from "../../v1/config/config.js"
import { isDeepStrictEqual } from "node:util"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
const CachedInventory = Schema.fromJsonString(
Schema.Record(Schema.String, ConfigProviderV1.Info).check(
Schema.makeFilter((providers) =>
Object.values(providers).every(
(provider) =>
cacheableURL(provider.api) &&
cacheable(provider.options) &&
Object.values(provider.models ?? {}).every(
(model) =>
cacheableURL(model.provider?.api) &&
cacheable(model.options) &&
cacheable({ headers: model.headers }) &&
Object.values(model.variants ?? {}).every(cacheable),
),
),
),
),
)
const placeholder = /^(?:Bearer )?\{env:[a-z_][a-z_0-9]*\}$/i
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -87,25 +107,6 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
connected = connection !== undefined
providers = credential
? yield* fetchProviders(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
})
yield* ctx.integration.transform((draft) => {
draft.update("opencode", (integration) => {
integration.name = "OpenCode"
@@ -114,9 +115,59 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
})
yield* load()
const read = Effect.fn("OpencodePlugin.readCache")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
// Stored connection IDs survive token refresh and separate accounts, servers, and organizations.
const cached =
connection?.type === "credential"
? yield* ctx.storage
.get(`inventory:${connection.id}`)
.pipe(
Effect.catchDefect((cause) =>
Effect.logWarning("failed to read Console inventory cache", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
return { connection, providers: Option.getOrUndefined(Schema.decodeUnknownOption(CachedInventory)(cached)) }
})
let inventory = yield* read()
const ready = yield* Latch.make()
const refresh = Effect.fn("OpencodePlugin.refresh")(function* () {
// Activation batches transforms; materialize OAuth refresh before resolution, but not before cache restore.
if (inventory.connection?.type === "credential") {
const registered = yield* ctx.integration
.get({ integrationID: Integration.ID.make("opencode") })
.pipe(Effect.orElseSucceed(() => undefined))
if (!registered?.data?.methods.some((method) => method.type === "oauth" && method.id === methodID))
yield* ctx.integration.reload()
}
const providers = inventory.connection
? yield* ctx.integration.connection.resolve(inventory.connection).pipe(
Effect.flatMap((credential) =>
credential
? fetchProviders(http, credential).pipe(Effect.map((providers) => providers ?? {}))
: Effect.undefined,
),
Effect.retry({ while: retryable, times: 2, schedule: Schedule.exponential(200) }),
Effect.timeout("5 seconds"),
)
: undefined
if (isDeepStrictEqual(inventory.providers, providers)) return
inventory = { connection: inventory.connection, providers }
yield* ctx.catalog.reload()
if (inventory.connection?.type !== "credential" || providers === undefined) return
const cached = Schema.encodeOption(CachedInventory)(providers)
yield* (
Option.isSome(cached)
? ctx.storage.set(`inventory:${inventory.connection.id}`, cached.value)
: ctx.storage.remove(`inventory:${inventory.connection.id}`)
).pipe(Effect.catchDefect((cause) => Effect.logWarning("failed to persist Console inventory cache", { cause })))
})
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
// Later transforms may mutate nested settings; keep the source inventory independent of catalog policy.
for (const [providerID, item] of Object.entries(structuredClone(inventory.providers ?? {}))) {
catalog.provider.update(providerID, (provider) => {
provider.integrationID = Integration.ID.make("opencode")
if (item.name !== undefined) provider.name = item.name
@@ -176,7 +227,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const item = catalog.provider.get(Provider.ID.opencode)
if (!item) return
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
const hasKey = Boolean(process.env.OPENCODE_API_KEY || inventory.connection || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) {
provider.activation = "enabled"
@@ -192,15 +243,92 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
// Switching waits for the previous refresh to stop, so only one worker writes the captured inventory.
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
Stream.prepend([undefined]),
Stream.switchMap((event) =>
Stream.fromEffect(
Effect.gen(function* () {
if (event) {
inventory = yield* read()
yield* ctx.catalog.reload()
}
if (inventory.providers !== undefined) yield* ready.open
yield* refresh().pipe(
Effect.tapError((cause) => Effect.logWarning("failed to load OpenCode provider config", { cause })),
Effect.onExit((exit) =>
Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) ? Effect.void : ready.open,
),
Effect.retry({
while: retryable,
schedule: Schedule.min([Schedule.exponential("5 seconds"), Schedule.spaced("30 seconds")]),
}),
Effect.ignore,
)
}),
),
),
Stream.runDrain,
Effect.forkScoped({ startImmediately: true }),
)
yield* ready.await
}),
})
function cacheable(value: unknown): boolean {
if (Array.isArray(value)) return value.every(cacheable)
if (value === null || typeof value !== "object") return true
return Object.entries(value).every(([key, item]) => {
const name = key.replace(/[-_]/g, "").toLowerCase()
if (name === "headers") {
if (item === undefined) return true
if (item === null || typeof item !== "object" || Array.isArray(item)) return false
return Object.entries(item).every(
([header, content]) =>
typeof content === "string" &&
(placeholder.test(content) ||
[
"x-org-id",
"anthropic-version",
"anthropic-beta",
"content-type",
"accept",
"openai-organization",
"openai-project",
].includes(header.toLowerCase())),
)
}
if (
/^(?:apiKey|xApiKey|xGoogApiKey|authorization|accessToken|authToken|refreshToken|password|secret|credentials|cookie|setCookie)$/i.test(
name,
)
)
return typeof item === "string" && placeholder.test(item)
if (name === "baseurl" || name === "enterpriseurl") return typeof item === "string" && cacheableURL(item)
return cacheable(item)
})
}
function cacheableURL(value: string | undefined) {
if (value === undefined) return true
const url = URL.parse(value)
return url !== null && !url.username && !url.password && !url.search && !url.hash
}
function retryable(cause: unknown): boolean {
if (cause instanceof Integration.AuthorizationError) return retryable(cause.cause)
if (Cause.isTimeoutError(cause)) return true
if (!HttpClientError.isHttpClientError(cause)) return false
return (
cause.reason._tag === "TransportError" ||
(cause.reason._tag === "StatusCodeError" &&
(cause.reason.response.status === 408 ||
cause.reason.response.status === 429 ||
cause.reason.response.status >= 500))
)
}
function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
const metadata = value.metadata
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
@@ -0,0 +1,315 @@
import { describe, expect } from "bun:test"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { Provider } from "@opencode-ai/core/provider"
import { State } from "@opencode-ai/core/state"
import { Effect, Exit, Fiber, Schedule, Schema, Scope } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const providerID = Provider.ID.make("example")
const modelID = Model.ID.make("chat")
const hiddenID = Model.ID.make("hidden")
const placeholder = "{env:OPENCODE_CONSOLE_TOKEN}"
const headers = { Authorization: `Bearer ${placeholder}`, "x-org-id": "org-a" }
const variant = {
apiKey: placeholder,
headers,
temperature: 0.2,
reasoning: { effort: "high", budget: { tokens: 2048 } },
response: { format: { type: "json", required: ["answer", "source"] } },
}
const addPlugin = Effect.fn(function* (set?: (key: string, value: Schema.Json) => Effect.Effect<void>) {
const scope = yield* Effect.acquireRelease(Scope.make(), (scope, exit) => Scope.close(scope, exit))
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin, OpencodePlugin.id)
yield* State.batch(
OpencodePlugin.effect(set ? { ...host, storage: { ...host.storage, set } } : host).pipe(Scope.provide(scope)),
)
return { host, scope }
})
const serve = (fetch: (request: Request) => Response | Promise<Response>) =>
Effect.acquireRelease(
Effect.sync(() => Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })),
(server) => Effect.promise(() => server.stop(true)),
)
const connect = Effect.fn(function* (server: string, key = "fixture-key") {
const credentials = yield* Credential.Service
return yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key, metadata: { server, orgID: "org-a" } }),
})
})
function eventually<A, E, R>(effect: Effect.Effect<A, E, R>, until: (value: A) => boolean) {
return effect.pipe(Effect.repeat({ until, schedule: Schedule.spaced("10 millis") }), Effect.timeout("2 seconds"))
}
function inventory(origin: string, output = 1000, apiKey = placeholder) {
return Response.json({
config: {
provider: {
example: {
name: "Example Console",
npm: "@ai-sdk/openai-compatible",
api: `${origin}/v1`,
options: { apiKey, headers },
models: {
chat: {
name: "Example Chat",
family: "example-chat",
release_date: "2026-01-02",
tool_call: true,
modalities: { input: ["text", "image"], output: ["text"] },
cost: { input: 1, output: 2, cache_read: 0.1, cache_write: 0.2 },
limit: { context: 10000, output },
options: { apiKey: placeholder, temperature: 0.5 },
variants: { careful: variant },
},
hidden: { name: "Hidden Chat" },
},
},
},
},
})
}
describe("OpencodePlugin inventory cache", () => {
it.live("restores metadata and nested variants before HTTP completes, then replays catalog policy", () =>
Effect.gen(function* () {
const gate = { enabled: false }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const paths: string[] = []
const server = yield* serve(async (request) => {
paths.push(new URL(request.url).pathname)
if (!gate.enabled) return inventory(new URL(request.url).origin)
requested.resolve()
await release.promise
if (new URL(request.url).pathname === "/auth/device/token")
return Response.json({ access_token: "rotated-access", refresh_token: "rotated-refresh", expires_in: 3600 })
if (request.headers.get("authorization") !== "Bearer rotated-access")
return new Response("Expired credential", { status: 401 })
return inventory(new URL(request.url).origin, 2000)
})
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
const credentials = yield* Credential.Service
const account = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "fixture-access-token",
refresh: "fixture-refresh-token",
expires: Date.now() + 3_600_000,
metadata: { server: server.url.origin, accountID: "account-a", orgID: "org-a" },
}),
})
const catalog = yield* Catalog.Service
const first = yield* addPlugin()
const saved = yield* first.host.storage.scan({ prefix: "" })
expect(saved.entries).toHaveLength(1)
expect(JSON.stringify(saved)).toContain(placeholder)
expect(JSON.stringify(saved)).not.toContain("fixture-access-token")
expect(JSON.stringify(saved)).not.toContain("fixture-refresh-token")
yield* Scope.close(first.scope, Exit.void)
expect(yield* catalog.model.available()).toEqual([])
if (account.value.type !== "oauth") return yield* Effect.die("Expected OAuth credential")
yield* credentials.update(account.id, { value: Credential.OAuth.make({ ...account.value, expires: 0 }) })
gate.enabled = true
const persisted = Promise.withResolvers<Schema.Json>()
yield* addPlugin((_key, value) => Effect.sync(() => persisted.resolve(value))).pipe(Effect.timeout("2 seconds"))
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("2 seconds"))
expect(paths.at(-1)).toBe("/auth/device/token")
expect(yield* catalog.provider.get(providerID)).toMatchObject({
name: "Example Console",
integrationID: "opencode",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: `${server.url.origin}/v1` },
headers,
})
const cached = yield* catalog.model.get(providerID, modelID)
expect(cached).toMatchObject({
name: "Example Chat",
family: "example-chat",
time: { released: Date.parse("2026-01-02") },
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
limit: { context: 10000, output: 1000 },
settings: { temperature: 0.5 },
})
expect(cached?.variants).toEqual([
{
id: Model.VariantID.make("careful"),
headers,
settings: { temperature: 0.2, reasoning: variant.reasoning, response: variant.response },
},
])
expect((yield* catalog.model.available()).map((model) => model.id)).toContain(modelID)
yield* catalog.transform((draft) => {
draft.model.remove(providerID, hiddenID)
draft.model.update(providerID, modelID, (model) => {
model.name = "Policy Chat"
const reasoning = model.variants?.find((variant) => variant.id === "careful")?.settings?.reasoning
if (typeof reasoning !== "object" || reasoning === null || !("effort" in reasoning))
throw new Error("Expected reasoning options")
reasoning.effort = "low"
})
})
release.resolve()
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.limit.output === 2000)
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Policy Chat")
expect(yield* catalog.model.get(providerID, hiddenID)).toBeUndefined()
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([modelID])
expect(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(yield* Effect.promise(() => persisted.promise)),
).toMatchObject({
example: {
models: { chat: { limit: { output: 2000 }, variants: { careful: { reasoning: { effort: "high" } } } } },
},
})
}),
)
it.live("isolates accounts, restores the previous cache offline, and cancels obsolete workers", () =>
Effect.gen(function* () {
const gate = { enabled: false }
const release = Promise.withResolvers<void>()
const requests: { authorization: string | null; aborted: boolean }[] = []
const server = yield* serve(async (request) => {
const entry = { authorization: request.headers.get("authorization"), aborted: false }
requests.push(entry)
request.signal.addEventListener("abort", () => (entry.aborted = true), { once: true })
if (gate.enabled) await release.promise
return inventory(new URL(request.url).origin)
})
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const account = yield* connect(server.url.origin, "fixture-account-a")
const first = yield* addPlugin()
expect(JSON.stringify(yield* first.host.storage.scan({ prefix: "" }))).not.toContain("fixture-account-a")
yield* Scope.close(first.scope, Exit.void)
gate.enabled = true
const second = yield* addPlugin().pipe(Effect.timeout("2 seconds"))
yield* eventually(
Effect.sync(() => requests.length),
(count) => count === 2,
)
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
yield* connect(server.url.origin, "fixture-account-b")
yield* eventually(
Effect.sync(() => requests.length),
(count) => count === 3,
)
yield* eventually(
Effect.sync(() => requests[1]?.aborted),
Boolean,
)
yield* eventually(catalog.model.available(), (models) => models.length === 0)
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
yield* credentials.activate(account.id)
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.name === "Example Chat")
yield* eventually(
Effect.sync(() => requests.length),
(count) => count === 4,
)
yield* eventually(
Effect.sync(() => requests[2]?.aborted),
Boolean,
)
expect(requests.map((request) => request.authorization)).toEqual([
"Bearer fixture-account-a",
"Bearer fixture-account-a",
"Bearer fixture-account-b",
"Bearer fixture-account-a",
])
yield* Scope.close(second.scope, Exit.void).pipe(Effect.timeout("2 seconds"))
yield* eventually(
Effect.sync(() => requests[3]?.aborted),
Boolean,
)
expect(yield* catalog.model.available()).toEqual([])
}),
)
it.live("publishes fresh inventory even when cache persistence dies", () =>
Effect.gen(function* () {
const server = yield* serve((request) => inventory(new URL(request.url).origin))
yield* connect(server.url.origin)
const writes: string[] = []
const instance = yield* addPlugin(() =>
Effect.sync(() => writes.push("attempted")).pipe(Effect.andThen(Effect.die(new Error("Cache write failed")))),
)
const catalog = yield* Catalog.Service
expect(writes).toEqual(["attempted"])
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
expect((yield* catalog.model.available()).map((model) => model.id)).toContain(modelID)
expect((yield* instance.host.storage.scan({ prefix: "" })).entries).toEqual([])
}),
)
it.live("ignores malformed cached data and replaces it with a fresh response", () =>
Effect.gen(function* () {
const gate = { enabled: false }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const server = yield* serve(async (request) => {
if (!gate.enabled) return inventory(new URL(request.url).origin)
requested.resolve()
await release.promise
return inventory(new URL(request.url).origin, 2000)
})
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
yield* connect(server.url.origin)
const first = yield* addPlugin()
const saved = yield* first.host.storage.scan({ prefix: "" })
expect(saved.entries).toHaveLength(1)
const entry = saved.entries[0]
if (!entry) return yield* Effect.die("Expected cached inventory")
yield* Scope.close(first.scope, Exit.void)
yield* first.host.storage.set(entry.key, "malformed cache")
gate.enabled = true
const loading = yield* addPlugin().pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("2 seconds"))
const catalog = yield* Catalog.Service
expect(yield* catalog.model.available()).toEqual([])
release.resolve()
const second = yield* Fiber.join(loading).pipe(Effect.timeout("2 seconds"))
expect((yield* catalog.model.get(providerID, modelID))?.limit.output).toBe(2000)
const replaced = yield* second.host.storage.scan({ prefix: "" })
expect(replaced.entries).toHaveLength(1)
expect(replaced.entries[0]?.value).not.toBe("malformed cache")
}),
)
it.live("uses literal-credential config live without persisting its inventory", () =>
Effect.gen(function* () {
const server = yield* serve((request) => inventory(new URL(request.url).origin, 1000, "fixture-literal-key"))
yield* connect(server.url.origin)
const instance = yield* addPlugin()
const catalog = yield* Catalog.Service
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
expect((yield* catalog.model.available()).map((model) => model.id)).toContain(modelID)
expect((yield* instance.host.storage.scan({ prefix: "" })).entries).toEqual([])
}),
)
})
@@ -0,0 +1,224 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { Provider } from "@opencode-ai/core/provider"
import { State } from "@opencode-ai/core/state"
import { Effect, Fiber, Option, Stream } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const providerID = Provider.ID.make("example")
const modelID = Model.ID.make("chat")
const hiddenID = Model.ID.make("hidden")
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin, OpencodePlugin.id)
yield* State.batch(OpencodePlugin.effect(host))
})
const connect = Effect.fn(function* (respond: (request: Request, attempt: number) => Response | Promise<Response>) {
const requests: number[] = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: (request) => {
requests.push(performance.now())
return respond(request, requests.length)
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "test-key", metadata: { server: server.url.origin } }),
})
return requests
})
const inventory = () =>
Response.json({
config: {
provider: {
example: {
name: "Example",
npm: "@ai-sdk/openai-compatible",
models: { chat: { name: "Example Chat" }, hidden: { name: "Hidden Chat" } },
},
},
},
})
describe("OpencodePlugin source recovery", () => {
it.live("retries an initial 503 before plugin setup completes", () =>
Effect.gen(function* () {
const requests = yield* connect((_request, attempt) =>
attempt === 1 ? new Response("Unavailable", { status: 503 }) : inventory(),
)
const catalog = yield* Catalog.Service
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
expect(requests).toHaveLength(2)
expect(requests.at(1)).toBeGreaterThanOrEqual((requests.at(0) ?? Infinity) + 180)
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
}),
)
it.live(
"backs off repeated background failures before recovering",
() =>
Effect.gen(function* () {
const requests = yield* connect((_request, attempt) =>
attempt < 7 ? new Response("Unavailable", { status: 503 }) : inventory(),
)
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
yield* addPlugin()
const published = yield* bus
.subscribe(Catalog.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
yield* Fiber.join(published)
expect(requests).toHaveLength(7)
expect(requests.at(6)).toBeGreaterThanOrEqual((requests.at(5) ?? Infinity) + 9800)
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
}),
20_000,
)
it.live(
"recovers after setup without changing credentials and replays later catalog policy",
() =>
Effect.gen(function* () {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const retried = Promise.withResolvers<void>()
const requests = yield* connect(async (_request, attempt) => {
if (attempt <= 3) return new Response("Unavailable", { status: 503 })
if (attempt > 4) retried.resolve()
requested.resolve()
await release.promise
return inventory()
})
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const before = yield* credentials.list(Integration.ID.make("opencode"))
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
const ready = performance.now()
expect(requests).toHaveLength(3)
expect(requests.at(1)).toBeGreaterThanOrEqual((requests.at(0) ?? Infinity) + 180)
expect(requests.at(2)).toBeGreaterThanOrEqual((requests.at(1) ?? Infinity) + 380)
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
yield* catalog.transform((draft) => {
draft.model.remove(providerID, hiddenID)
if (!draft.model.get(providerID, modelID)) return
draft.model.update(providerID, modelID, (model) => {
model.name = "Policy Chat"
})
})
const published = yield* bus
.subscribe(Catalog.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("6 seconds"))
expect(requests).toHaveLength(4)
expect(requests.at(3)).toBeGreaterThanOrEqual(ready + 4800)
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
release.resolve()
yield* Fiber.join(published).pipe(Effect.timeout("2 seconds"))
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Policy Chat")
expect(yield* catalog.model.get(providerID, hiddenID)).toBeUndefined()
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([modelID])
expect(yield* credentials.list(Integration.ID.make("opencode"))).toEqual(before)
expect(yield* Effect.promise(() => retried.promise).pipe(Effect.timeoutOption("5500 millis"))).toEqual(
Option.none(),
)
}),
15_000,
)
Object.entries({
"401": () => new Response("Unauthorized", { status: 401 }),
"403": () => new Response("Forbidden", { status: 403 }),
"schema decode failure": () => Response.json({ config: { provider: false } }),
}).forEach(([name, respond]) => {
it.live(`does not retry ${name} during initial load`, () =>
Effect.gen(function* () {
const requests = yield* connect(respond)
const catalog = yield* Catalog.Service
yield* addPlugin()
expect(requests).toHaveLength(1)
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
}),
)
})
it.live(
"stops background retries when an exhausted outage becomes nonretryable",
() =>
Effect.gen(function* () {
const rejected = Promise.withResolvers<void>()
const retried = Promise.withResolvers<void>()
const requests = yield* connect((_request, attempt) => {
if (attempt <= 3) return new Response("Unavailable", { status: 503 })
if (attempt > 4) retried.resolve()
rejected.resolve()
return new Response("Unauthorized", { status: 401 })
})
const catalog = yield* Catalog.Service
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
expect(requests).toHaveLength(3)
yield* Effect.promise(() => rejected.promise).pipe(Effect.timeout("6 seconds"))
expect(requests).toHaveLength(4)
expect(yield* Effect.promise(() => retried.promise).pipe(Effect.timeoutOption("5500 millis"))).toEqual(
Option.none(),
)
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
}),
15_000,
)
it.live(
"bounds a pending initial load and aborts its request",
() =>
Effect.gen(function* () {
const aborted = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const requests = yield* connect(async (request) => {
request.signal.addEventListener("abort", () => aborted.resolve(), { once: true })
await release.promise
return inventory()
})
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
const catalog = yield* Catalog.Service
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
expect(requests).toHaveLength(1)
expect(performance.now()).toBeGreaterThanOrEqual((requests.at(0) ?? Infinity) + 4800)
yield* Effect.promise(() => aborted.promise).pipe(Effect.timeout("1 second"))
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
}),
10_000,
)
})