Compare commits

...
Author SHA1 Message Date
Kit Langton 3387eb70df chore(core): reconcile refresh with runner assembly
Merge origin/v2 and keep model availability reads in SessionContext. Apply the single missing-model refresh retry at that existing assembly boundary and expose the already-built Context alongside Location services.
2026-08-27 00:03:04 -04:00
Kit Langton 68ae314190 fix(core): preserve queued catalog refreshes
Serialize refreshes without dropping newer account changes. Keep replay protected from interruption so later lookups cannot return before captured inventory is visible. Cover account-switch and cancellation races, destination invalidation, and policy replay.
2026-08-26 23:54:34 -04:00
Kit Langton 3b707c5a1d refactor(core): narrow Console inventory refresh
Remove polling, read-through TTL checks, force options, the interval factory, and duplicate plugin lock/state. Keep explicit move/account refresh and the awaited selected-model miss guard. Coalesce overlapping refreshes and cover a lookup racing the move's inventory fetch.
2026-08-26 23:37:01 -04:00
Kit Langton 5093b8d92a fix(core): refresh Console model inventories
Cached Locations retained Console inventories from plugin activation, so moving a Session into an older Location could reject its selected model until server restart. Refresh inventory sources on reads, destination moves, and a periodic schedule, and retry missing selections once without bypassing Location policy.
2026-08-26 23:17:52 -04:00
8 changed files with 399 additions and 19 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Refresh Console model inventories after Session moves into cached Locations and retry missing selected models once, without bypassing destination policy. Retain cached inventory on same-account refresh failures.
+24 -1
View File
@@ -1,7 +1,7 @@
export * as Catalog from "./catalog.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
import { Array, Context, Effect, Layer, Order, pipe, Scope, Semaphore } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { Model } from "./model.js"
import { Provider } from "./provider.js"
@@ -42,6 +42,9 @@ export type Draft = {
}
export interface Interface extends State.Transformable<Draft> {
/** Internal sources update captured data and report whether ordered transforms need replaying. */
readonly onRefresh: (source: () => Effect.Effect<boolean>) => Effect.Effect<void, never, Scope.Scope>
readonly refresh: () => Effect.Effect<void>
readonly provider: {
readonly get: (providerID: Provider.ID) => Effect.Effect<Provider.Info | undefined>
readonly all: () => Effect.Effect<Provider.Info[]>
@@ -63,6 +66,8 @@ const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const integrations = yield* Integration.Service
const sources = new Set<() => Effect.Effect<boolean>>()
const refreshing = Semaphore.makeUnsafe(1)
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
if (provider.activation === "disabled") return false
@@ -138,9 +143,27 @@ const layer = Layer.effect(
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
const refresh = Effect.fn("Catalog.refresh")(() =>
refreshing.withPermit(
Effect.gen(function* () {
const changed = yield* Effect.forEach(sources, (source) => source())
// Keep the permit until captured changes are visible, even if the caller is interrupted.
if (changed.some(Boolean)) yield* state.reload().pipe(Effect.uninterruptible)
}),
),
)
const result: Interface = {
transform: state.transform,
reload: state.reload,
onRefresh: (source) =>
Effect.acquireRelease(
Effect.sync(() => sources.add(source)),
() =>
Effect.sync(() => {
sources.delete(source)
}),
).pipe(Effect.asVoid),
refresh,
provider: {
get: Effect.fn("Catalog.provider.get")(function* (providerID) {
+2
View File
@@ -38,6 +38,7 @@ import { SessionRunnerLLM } from "./session/runner/llm.js"
import { SessionRunnerModel } from "./session/runner/model.js"
import { SessionModelTransport } from "./session/model-transport.js"
import { SessionCompaction } from "./session/compaction.js"
import { SessionContext } from "./session/context.js"
import { SessionTitle } from "./session/title.js"
import { Skill } from "./skill.js"
import { SkillInstructions } from "./skill/instructions.js"
@@ -96,6 +97,7 @@ const locationServiceNodes = [
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
SessionContext.node,
SessionRunnerModel.node,
SessionModelTransport.node,
SessionCompaction.node,
+30 -16
View File
@@ -1,9 +1,10 @@
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import { Duration, Effect, 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 { Bus } from "../../bus.js"
import { Catalog } from "../../catalog.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
@@ -12,6 +13,8 @@ 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 { SessionEvent } from "../../session/event.js"
import { isDeepStrictEqual } from "node:util"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
@@ -82,28 +85,34 @@ function oauth(http: HttpClient.HttpClient) {
} satisfies IntegrationOAuthMethodRegistration
}
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope.Scope>({
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Catalog.Service | Scope.Scope>({
id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const catalog = yield* Catalog.Service
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined
let source: string | 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(
const id = connection?.type === "credential" ? connection.id : connection?.name
const next = connection
? yield* ctx.integration.connection.resolve(connection).pipe(
Effect.flatMap((credential) => (credential ? fetchProviders(http, credential) : Effect.undefined)),
Effect.timeout("5 seconds"),
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
// Retain inventory through same-account outages, never across an account switch.
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
Effect.as(id === source ? providers : undefined),
),
),
)
: undefined
const changed = source !== id || !isDeepStrictEqual(providers, next)
source = id
providers = next
return changed
})
yield* ctx.integration.transform((draft) => {
@@ -176,7 +185,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 || source !== undefined || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) {
provider.activation = "enabled"
@@ -192,10 +201,15 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
yield* catalog.onRefresh(load)
yield* bus.subscribe([Credential.Event.Switched, SessionEvent.Moved]).pipe(
Stream.filter((event) =>
event.type === "credential.switched"
? event.data.integrationID === Integration.ID.make("opencode")
: event.data.location.directory === ctx.location.directory &&
event.data.location.workspaceID === ctx.location.workspaceID,
),
Stream.runForEach(() => catalog.refresh()),
Effect.forkScoped({ startImmediately: true }),
)
}),
+9 -1
View File
@@ -91,7 +91,15 @@ const layer = Layer.effect(
const store = yield* SessionStore.Service
const registry = yield* Tool.Service
const resolveModel = (session: SessionSchema.Info) => models.resolve(session, catalog.model.available)
const resolveModel = Effect.fn("SessionContext.resolveModel")(function* (session: SessionSchema.Info) {
const resolve = models.resolve(session, catalog.model.available)
// Retry once against a refreshed inventory without bypassing Location policy.
return yield* resolve.pipe(
Effect.catchTag("SessionRunnerModel.ModelUnavailableError", () =>
catalog.refresh().pipe(Effect.andThen(resolve)),
),
)
})
const selectTitle = Effect.fn("SessionContext.selectTitle")(function* (session: SessionSchema.Info) {
const agent = yield* agents.get(Agent.ID.make("title"))
+64
View File
@@ -30,6 +30,70 @@ const catalogLayer = AppNodeBuilder.build(
const it = testEffect(catalogLayer)
describe("Catalog", () => {
it.effect("refreshes scoped sources and replays only changed snapshots", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const source = { fresh: false, calls: 0 }
yield* catalog.transform((draft) => {
if (!source.fresh) return
draft.model.update(Provider.ID.make("example"), Model.ID.make("chat"), () => {})
draft.model.default.set(Provider.ID.make("example"), Model.ID.make("chat"))
})
yield* Effect.scoped(
Effect.gen(function* () {
yield* catalog.onRefresh(() =>
Effect.sync(() => {
source.calls++
if (source.fresh) return false
source.fresh = true
return true
}),
)
expect(yield* catalog.model.default()).toBeUndefined()
const refresh = yield* catalog.refresh().pipe(Effect.forkScoped)
yield* TestClock.adjust("1 second")
yield* Fiber.join(refresh)
expect((yield* catalog.model.default())?.id).toBe(Model.ID.make("chat"))
expect(source.calls).toBe(1)
yield* catalog.refresh()
expect(source.calls).toBe(2)
}),
)
yield* catalog.refresh()
expect(source.calls).toBe(2)
}),
)
it.effect("finishes replay before releasing an interrupted refresh", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const source = { fresh: false }
yield* catalog.transform((draft) => {
if (source.fresh) draft.model.update(Provider.ID.make("example"), Model.ID.make("chat"), () => {})
})
yield* catalog.onRefresh(() =>
Effect.sync(() => {
if (source.fresh) return false
source.fresh = true
return true
}),
)
const refresh = yield* catalog.refresh().pipe(Effect.forkScoped({ startImmediately: true }))
yield* TestClock.adjust("0 millis")
const interrupted = yield* Fiber.interrupt(refresh).pipe(Effect.forkScoped({ startImmediately: true }))
yield* TestClock.adjust("0 millis")
const retry = yield* catalog.refresh().pipe(Effect.forkScoped({ startImmediately: true }))
yield* TestClock.adjust("0 millis")
expect(retry.pollUnsafe()).toBeUndefined()
yield* TestClock.adjust("1 second")
yield* Fiber.join(interrupted)
yield* Fiber.join(retry)
expect((yield* catalog.model.get(Provider.ID.make("example"), Model.ID.make("chat")))?.id).toBe(
Model.ID.make("chat"),
)
}),
)
it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -0,0 +1,143 @@
import { expect } from "bun:test"
import { Effect, Fiber, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Model } from "@opencode-ai/core/model"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionContext } from "@opencode-ai/core/session/context"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempGlobalLayer } from "./fixture/global"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Bus.node, Credential.node, Session.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
it.live(
"keeps a Console model available after moving into a cached Location",
() =>
Effect.gen(function* () {
const directories = yield* Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()] as const)),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]()))),
)
const destination = Location.Ref.make({ directory: AbsolutePath.make(directories[0].path) })
const source = Location.Ref.make({ directory: AbsolutePath.make(directories[1].path) })
const inventory = { published: false, name: "Example Chat", requests: 0 }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: async (request) => {
inventory.requests++
if (inventory.requests === 3) {
requested.resolve()
await release.promise
}
return Response.json({
config: {
provider: {
"example-console": {
npm: "@ai-sdk/openai",
api: `${new URL(request.url).origin}/v1`,
models: inventory.published
? { "example-chat": { name: inventory.name, variants: { swift: {} } } }
: {},
},
},
},
})
},
}),
),
(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: "fixture-key", metadata: { server: server.url.origin } }),
})
const locations = yield* LocationServiceMap.Service
const ready = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Catalog.Service
})
const cached = yield* ready.pipe(Effect.provide(locations.get(destination)))
expect(inventory.requests).toBe(1)
inventory.published = true
yield* ready.pipe(Effect.provide(locations.get(source)))
expect(inventory.requests).toBe(2)
const session = yield* Session.Service
const model = Model.Ref.make({
providerID: Provider.ID.make("example-console"),
id: Model.ID.make("example-chat"),
variant: Model.VariantID.make("swift"),
})
const created = yield* session.create({
location: source,
model,
})
const resolve = Effect.gen(function* () {
const current = yield* session.get(created.id)
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const context = yield* SessionContext.Service
return yield* context.resolveModel(current)
}).pipe(Effect.provide(locations.get(current.location)))
})
expect((yield* resolve).ref).toEqual(model)
const bus = yield* Bus.Service
const updated = yield* bus.subscribe(Catalog.Event.Updated).pipe(
Stream.filter((event) => event.location?.directory === destination.directory),
Stream.take(1),
Stream.mapEffect((event) => cached.model.available().pipe(Effect.map((models) => ({ event, models })))),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
yield* session.move({ sessionID: created.id, directory: destination.directory })
yield* session.resume(created.id)
expect((yield* session.get(created.id)).location).toEqual(destination)
// The idle move starts the fetch; a lookup during that fetch must wait for replay, not reject the old snapshot.
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("2 seconds"))
const continuation = yield* resolve.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
expect(continuation.pollUnsafe()).toBeUndefined()
release.resolve()
const updates = yield* Fiber.join(updated).pipe(Effect.timeout("2 seconds"))
expect(updates[0]?.event.location).toEqual(destination)
expect(updates[0]?.models.map((item) => item.id)).toContain(model.id)
expect((yield* Fiber.join(continuation)).ref).toEqual(model)
expect(yield* ready.pipe(Effect.provide(locations.get(destination)))).toBe(cached)
expect(inventory.requests).toBe(4)
yield* cached.transform((draft) =>
draft.model.update(model.providerID, model.id, (item) => {
item.enabled = false
}),
)
inventory.name = "Updated Example Chat"
expect(yield* resolve.pipe(Effect.flip)).toBeInstanceOf(SessionRunnerModel.ModelUnavailableError)
expect((yield* cached.model.get(model.providerID, model.id))?.name).toBe(inventory.name)
expect(inventory.requests).toBe(5)
}),
{ timeout: 15_000 },
)
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { Effect, Fiber, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Bus } from "@opencode-ai/core/bus"
@@ -360,6 +360,127 @@ describe("OpencodePlugin", () => {
),
)
it.live("loads the new account after a switch during an in-flight refresh", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const completed = Promise.withResolvers<void>()
const requests: string[] = []
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: async (request) => {
const account = request.headers.get("authorization") === "Bearer account-a" ? "account-a" : "account-b"
requests.push(account)
if (requests.length === 2) {
requested.resolve()
await release.promise
}
return Response.json({
config: { provider: { example: { models: { [account]: { name: account } } } } },
})
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
const create = (key: string) =>
credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key, metadata: { server: server.url.origin } }),
})
yield* create("account-a")
// Observe the event caller entering and leaving the real refresh boundary.
yield* addPlugin().pipe(
Effect.provideService(Catalog.Service, {
...catalog,
refresh: () =>
Effect.gen(function* () {
started.resolve()
yield* catalog.refresh()
completed.resolve()
}),
}),
)
const refresh = yield* catalog.refresh().pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => requested.promise)
yield* create("account-b")
yield* Effect.promise(() => started.promise)
release.resolve()
yield* Fiber.join(refresh)
yield* Effect.promise(() => completed.promise)
expect(requests).toEqual(["account-a", "account-a", "account-b"])
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([Model.ID.make("account-b")])
}),
)
it.live("retains same-account inventory on failure but clears it after a failed account switch", () =>
Effect.gen(function* () {
const inventory = { fail: false, requests: 0 }
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: () => {
inventory.requests++
if (inventory.fail) return new Response("Unavailable", { status: 503 })
return Response.json({
config: { provider: { example: { models: { chat: { name: "Example Chat" } } } } },
})
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "first", metadata: { server: server.url.origin } }),
})
yield* addPlugin()
const updates: number[] = []
yield* bus.subscribe(Catalog.Event.Updated).pipe(
Stream.runForEach((event) =>
Effect.sync(() => {
updates.push(event.created)
}),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* catalog.model.available()
expect(inventory.requests).toBe(1)
yield* catalog.refresh()
expect(inventory.requests).toBe(2)
expect(updates).toEqual([])
inventory.fail = true
yield* catalog.refresh()
expect((yield* catalog.model.get(Provider.ID.make("example"), Model.ID.make("chat")))?.name).toBe("Example Chat")
expect(updates).toEqual([])
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "second", metadata: { server: server.url.origin } }),
})
yield* eventually(
catalog.model.get(Provider.ID.make("example"), Model.ID.make("chat")),
(model) => model === undefined,
)
expect(inventory.requests).toBe(4)
inventory.fail = false
yield* catalog.refresh()
expect((yield* catalog.model.get(Provider.ID.make("example"), Model.ID.make("chat")))?.name).toBe("Example Chat")
expect(inventory.requests).toBe(5)
}),
)
it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {