From 52497c803d2bdbf216642127c0595cc322fcf6e2 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 23 Oct 2026 14:34:48 +0000 Subject: [PATCH 1/5] docs(core): plan V2 config env sources for swxtch-egress (#1952) Plan for swxtchio/swx-abbe#1952. Root cause: the V2 Config layer (packages/core/src/config.ts) reads only the global config directory and discovered project files. It ignores OPENCODE_CONFIG and OPENCODE_CONFIG_CONTENT. The jail supplies its whole config through those two variables (Dockerfile.jail sets OPENCODE_CONFIG, opencode_server.py renders OPENCODE_CONFIG_CONTENT), so the V2 catalog never sees the swxtch-egress provider or a configured default model. V1 (packages/opencode/src/config/config.ts) reads both, which is why V1 turns run on the egress provider. Change: - Config takes explicit file and content sources, defaulting to Flag.OPENCODE_CONFIG and Flag.OPENCODE_CONFIG_CONTENT, with a layerWith seam for tests (mirrors Global.layerWith and upstream's Config.Options file/content from 43c08387f1). - Precedence matches V1 and upstream: global directory, then the OPENCODE_CONFIG file, then project files, then project .opencode directories, then OPENCODE_CONFIG_CONTENT last. - V1-format sources migrate through the existing loader path. Tests: - Config loader orders explicit file and content entries. - A jail-shaped V1 config supplied as content reaches the catalog and SessionRunnerModel resolves the named swxtch-egress model to an OpenAI-compatible route at the proxy URL. Claude-Session: https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp From 07cfa271c96b7c38f900c86a14c04b4f01188bba Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 23 Oct 2026 14:42:19 +0000 Subject: [PATCH 2/5] fix(core): load env-supplied config in V2 config The V2 Config layer read only the global config directory and discovered project files, ignoring OPENCODE_CONFIG and OPENCODE_CONFIG_CONTENT. The OpenCode jail supplies its whole config through those variables, so the V2 runner's catalog never held the swxtch-egress provider or the configured default model: a named egress model failed with ModelUnavailableError and an unnamed one fell to another provider (swxtchio/swx-abbe#1952). Config now takes explicit file and content sources, defaulting to the two flags, and orders them as V1 does: global directory, explicit file, project files, .opencode directories, then inline content. V1-format sources migrate through the existing loader path. Config.nodeWith names the sources directly for tests. Tests drive the real loader: precedence of every source, and the jail's V1 config (placeholder file plus rendered content) through the config provider plugin and catalog to SessionRunnerModel, for a named model and for the configured default against a competing provider. Claude-Session: https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp --- CHANGESET.md | 5 + packages/core/src/config.ts | 207 ++++++++++-------- packages/core/test/config/config.test.ts | 41 ++++ packages/core/test/config/environment.test.ts | 145 ++++++++++++ 4 files changed, 307 insertions(+), 91 deletions(-) create mode 100644 packages/core/test/config/environment.test.ts diff --git a/CHANGESET.md b/CHANGESET.md index 04de32822cf6..e73bb901d3d7 100644 --- a/CHANGESET.md +++ b/CHANGESET.md @@ -84,6 +84,11 @@ Newest first. Each entry names the upstream range brought in. ### Runtime fixes +- **#347** Load `OPENCODE_CONFIG` and `OPENCODE_CONFIG_CONTENT` in the V2 core config at V1's precedence (global + directory, explicit file, project files, `.opencode` directories, inline content last), so V2 sessions in the + OpenCode jail resolve its config-declared `swxtch-egress` provider and configured default model + (swxtchio/swx-abbe#1952). _Fork-only; upstream `43c08387f1` adds the same sources to its rewritten core config, and + a sync bringing it supersedes this._ - **#342** Add independently configured V1 small models at global, agent and persisted session scopes (swxtchio/swx-opencode#338), wire titles and compaction summaries to the effective selection, and report unresolvable configured models without a substitute request. See the model settings in `packages/web/src/content/docs/config.mdx`. _Fork-only._ - **#325** Port bounded provider output-limit continuation from `anomalyco/opencode` commit `a3f7f42ac96800aec06fb26365cd482024a52cd1` into V1 execution (swxtchio/swx-opencode#304, output-limit sub-scope). Preserve partial history, tool custody and queue delivery; keep capacity compaction from renewing the allowance and buffer argument fragments until settlement. Expose exhausted continuation through the existing V1 output-length error. _Fork-only V1 port._ - **#314** Add `Ctrl+f` in the pending-prompt review to give only the selected prepared item steer timing, releasing its confirmed review hold only when no other live holder remains. Consolidating a selected human-composer item now combines the complete ready, delivery- and execution-compatible human set in admission order, preserving original inputs, parts, receipts and constituents. `Ctrl+e` enhances the selected pending prompt through the existing model and transcript context, retaining row identity, order, receipts and non-text parts. A late pre-force renewal triggers a holder-status read rather than being discarded, so the view reflects server-applied lease changes after force. _Fork-only._ diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c76486968b07..efbc468da451 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -5,6 +5,7 @@ import path from "path" import { type ParseError, parse } from "jsonc-parser" import { Context, Effect, Layer, Option, Schema } from "effect" import { Permission } from "@opencode-ai/schema/permission" +import { Flag } from "./flag/flag" import { FSUtil } from "./fs-util" import { Global } from "./global" import { Location } from "./location" @@ -132,96 +133,120 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Config") {} -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const location = yield* Location.Service - const policy = yield* Policy.Service - const names = ["opencode.json", "opencode.jsonc"] - const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const - const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) - const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions) - - const loadFile = Effect.fnUntraced(function* (filepath: string) { - const text = yield* fs.readFileStringSafe(filepath) - if (!text) return - - const errors: ParseError[] = [] - const input: unknown = parse(text, errors, { allowTrailingComma: true }) - if (errors.length) return - - const info = Option.getOrUndefined( - ConfigMigrateV1.isV1(input) - ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo)) - : decodeInfo(input), +/** Config named outside discovery: V1's `OPENCODE_CONFIG` file path and `OPENCODE_CONFIG_CONTENT` JSON. */ +export interface Sources { + readonly file?: string + readonly content?: string +} + +const layerWith = (sources: Sources) => + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const location = yield* Location.Service + const policy = yield* Policy.Service + const names = ["opencode.json", "opencode.jsonc"] + const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const + const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) + const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions) + + const parseInfo = (text: string) => { + const errors: ParseError[] = [] + const input: unknown = parse(text, errors, { allowTrailingComma: true }) + if (errors.length) return + return Option.getOrUndefined( + ConfigMigrateV1.isV1(input) + ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo)) + : decodeInfo(input), + ) + } + + const loadFile = Effect.fnUntraced(function* (filepath: string) { + const text = yield* fs.readFileStringSafe(filepath) + if (!text) return + const info = parseInfo(text) + if (!info) return + return new Document({ type: "document", path: filepath, info }) + }) + + const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { + return [ + ...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( + Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), + )), + new Directory({ type: "directory", path: directory }), + ] + }) + + const globalDirectory = AbsolutePath.make(global.config) + const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) + // Read configuration once when this location opens. Later calls reuse these + // values until the location is reopened. + const discovered = locationIsGlobal + ? [] + : yield* fs + .up({ + targets: [".opencode", ...names.toReversed()], + start: location.directory, + stop: location.project.directory, + }) + .pipe(Effect.orDie) + const directories = [ + globalDirectory, + ...discovered + .filter((item) => path.basename(item) === ".opencode") + .toReversed() + .map((directory) => AbsolutePath.make(directory)), + ] + // A config closer to the opened directory should win over one higher up. + // Search starts nearby, so reverse the results before applying them. + const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() + const direct = yield* Effect.forEach(directPaths, loadFile).pipe( + Effect.orDie, + Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), ) - if (!info) return - return new Document({ type: "document", path: filepath, info }) - }) - - const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { - return [ - ...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( - Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), - )), - new Directory({ type: "directory", path: directory }), + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) + const explicit = sources.file ? yield* loadFile(path.resolve(sources.file)).pipe(Effect.orDie) : undefined + const content = sources.content ? parseInfo(sources.content) : undefined + // Apply general settings first and more specific settings last, in V1's + // order: global config, the explicit file, project files, `.opencode` + // files, then inline content. + const configs = [ + ...(supplementary[0] ?? []), + ...(explicit ? [explicit] : []), + ...direct, + ...supplementary.slice(1).flat(), + ...(content ? [new Document({ type: "document", info: content })] : []), ] - }) - - const globalDirectory = AbsolutePath.make(global.config) - const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) - // Read configuration once when this location opens. Later calls reuse these - // values until the location is reopened. - const discovered = locationIsGlobal - ? [] - : yield* fs - .up({ - targets: [".opencode", ...names.toReversed()], - start: location.directory, - stop: location.project.directory, - }) - .pipe(Effect.orDie) - const directories = [ - globalDirectory, - ...discovered - .filter((item) => path.basename(item) === ".opencode") - .toReversed() - .map((directory) => AbsolutePath.make(directory)), - ] - // A config closer to the opened directory should win over one higher up. - // Search starts nearby, so reverse the results before applying them. - const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() - const direct = yield* Effect.forEach(directPaths, loadFile).pipe( - Effect.orDie, - Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), - ) - const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) - // Apply general settings first and more specific settings last: - // global config, project files, then `.opencode` files. - const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] - // Rules use the opposite order so a user-global rule can override a - // repository rule. Statement order inside each file stays unchanged. - yield* policy.load( - configs - .filter((config): config is Document => config.type === "document") - .toReversed() - .flatMap((config) => config.info.experimental?.policies ?? []), - ) - - return Service.of({ - entries: Effect.fn("Config.entries")(function* () { - return configs - }), - }) - }), -) - -export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer)) - -export const node = makeLocationNode({ - service: Service, - layer, - deps: [FSUtil.node, Global.node, Location.node, Policy.node], -}) + // Rules use the opposite order so a user-global rule can override a + // repository rule. Statement order inside each file stays unchanged. + yield* policy.load( + configs + .filter((config): config is Document => config.type === "document") + .toReversed() + .flatMap((config) => config.info.experimental?.policies ?? []), + ) + + return Service.of({ + entries: Effect.fn("Config.entries")(function* () { + return configs + }), + }) + }), + ) + +const environment = { file: Flag.OPENCODE_CONFIG, content: Flag.OPENCODE_CONFIG_CONTENT } + +export const locationLayer = layerWith(environment).pipe(Layer.provideMerge(Policy.locationLayer)) + +/** Test or embedding seam for naming the explicit config sources instead of reading them from the environment. */ +export const nodeWith = (sources: Sources) => + makeLocationNode({ + service: Service, + layer: layerWith(sources), + deps: [FSUtil.node, Global.node, Location.node, Policy.node], + }) + +export const node = nodeWith(environment) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index b9615877539b..0b189f4fa2af 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -26,6 +26,7 @@ function testLayer( globalDirectory = path.join(directory, "global"), projectDirectory = directory, vcs?: Project.Vcs, + sources?: Config.Sources, ) { const locationLayer = Layer.succeed( Location.Service, @@ -39,6 +40,7 @@ function testLayer( return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ [Location.node, locationLayer], [Global.node, Global.layerWith({ config: globalDirectory })], + ...(sources ? [[Config.node, Config.nodeWith(sources)] as const] : []), ]) } @@ -52,6 +54,45 @@ const provider = { } describe("Config", () => { + it.live("loads the explicit file and inline content at V1 precedence", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const explicit = path.join(tmp.path, "custom.json") + return Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(path.join(project, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" })) + await fs.writeFile(explicit, JSON.stringify({ shell: "explicit" })) + await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) + await fs.writeFile(path.join(project, ".opencode", "opencode.json"), JSON.stringify({ shell: "dot" })) + }).pipe( + Effect.andThen( + Effect.gen(function* () { + const config = yield* Config.Service + const entries = yield* config.entries() + expect( + entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])), + ).toEqual(["global", "explicit", "project", "dot", "content"]) + expect(Config.latest(entries, "shell")).toBe("content") + }).pipe( + Effect.provide( + testLayer(project, global, project, undefined, { + file: explicit, + content: JSON.stringify({ shell: "content" }), + }), + ), + ), + ), + ) + }), + ), + ) + it.effect("returns the latest defined scalar from priority-ordered documents", () => Effect.sync(() => { const entries = [ diff --git a/packages/core/test/config/environment.test.ts b/packages/core/test/config/environment.test.ts new file mode 100644 index 000000000000..8e133119070e --- /dev/null +++ b/packages/core/test/config/environment.test.ts @@ -0,0 +1,145 @@ +import path from "path" +import fs from "fs/promises" +import { describe, expect } from "bun:test" +import { LLM, type Model } from "@opencode-ai/llm" +import { DateTime, Effect, Layer } from "effect" +import { Headers } from "effect/unstable/http" +import { Config } from "@opencode-ai/core/config" +import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { Policy } from "@opencode-ai/core/policy" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { location } from "../fixture/location" +import { tmpdir } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) + +const providerID = ProviderV2.ID.make("swxtch-egress") +const modelID = ModelV2.ID.make("accounts/fireworks/models/deepseek-v4p1-flash") + +// The OpenCode jail's V1 config: the image names it with OPENCODE_CONFIG, and its +// entrypoint renders the proxy values into OPENCODE_CONFIG_CONTENT. +const jail = (options: { baseURL: string; apiKey: string }, model?: string) => ({ + $schema: "https://opencode.ai/config.json", + autoupdate: false, + plugin: [], + ...(model ? { model } : {}), + provider: { + "swxtch-egress": { + npm: "@ai-sdk/openai-compatible", + name: "swXtch egress proxy", + options, + models: { [modelID]: { name: "DeepSeek V4.1 Flash" } }, + }, + }, +}) + +const placeholders = { baseURL: "{env:OPENCODE_PROXY_BASE_URL}", apiKey: "{env:OPENCODE_PROXY_CAPABILITY}" } +const rendered = { baseURL: "https://egress.test/v1", apiKey: "capability" } + +// Loads the jail's config sources through the real Config loader and the config provider plugin into the catalog. +const loadJail = Effect.fn(function* (content: unknown, global?: unknown) { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const file = path.join(tmp.path, "jail", "opencode.json") + const directory = path.join(tmp.path, "agent") + yield* Effect.promise(async () => { + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.mkdir(path.join(tmp.path, "global"), { recursive: true }) + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(file, JSON.stringify(jail(placeholders))) + if (global) await fs.writeFile(path.join(tmp.path, "global", "opencode.json"), JSON.stringify(global)) + }) + const config = yield* Config.Service.pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ + [ + Location.node, + Layer.succeed( + Location.Service, + Location.Service.of( + location({ directory: AbsolutePath.make(directory) }, { projectDirectory: AbsolutePath.make(directory) }), + ), + ), + ], + [Global.node, Global.layerWith({ config: path.join(tmp.path, "global") })], + [Config.node, Config.nodeWith({ file, content: JSON.stringify(content) })], + ]), + ), + ) + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)) + return yield* SessionRunnerModel.Service.pipe(Effect.provide(SessionRunnerModel.locationLayer)) +}) + +const session = (model?: SessionV2.Info["model"]) => + SessionV2.Info.make({ + id: SessionV2.ID.make("ses_jail_egress"), + projectID: ProjectV2.ID.global, + title: "test", + model, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + +const authorization = Effect.fn(function* (resolved: Model) { + const headers = yield* resolved.route.auth.apply({ + request: LLM.request({ model: resolved, prompt: "Hello" }), + method: "POST", + url: `${rendered.baseURL}/chat/completions`, + body: "{}", + headers: Headers.empty, + }) + return headers.authorization +}) + +describe("environment-supplied config", () => { + it.effect("resolves a V2 session's named jail egress model at the rendered proxy", () => + Effect.gen(function* () { + const models = yield* loadJail(jail(rendered)) + + const resolved = yield* models.resolve(session({ providerID, id: modelID })) + + expect(resolved).toMatchObject({ provider: providerID, id: modelID }) + expect(resolved.route.endpoint).toMatchObject({ baseURL: rendered.baseURL }) + expect(yield* authorization(resolved)).toBe(`Bearer ${rendered.apiKey}`) + }), + ) + + it.effect("selects the configured egress default for a V2 session that names no model", () => + Effect.gen(function* () { + // A global provider loads first and so sorts ahead of the egress one; only the configured default selects egress. + const models = yield* loadJail(jail(rendered, `${providerID}/${modelID}`), { + provider: { + other: { + npm: "@ai-sdk/openai-compatible", + options: { baseURL: "https://other.test/v1", apiKey: "other" }, + models: { chat: { name: "Other" } }, + }, + }, + }) + + const resolved = yield* models.resolve(session()) + + expect(resolved).toMatchObject({ provider: providerID, id: modelID }) + expect(resolved.route.endpoint).toMatchObject({ baseURL: rendered.baseURL }) + }), + ) +}) From b9cc3267d22a191020f1e318554a54d6af58c294 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 23 Oct 2026 15:04:31 +0000 Subject: [PATCH 3/5] fix(core): default V2 sessions to config-declared providers Review round 1: with the env-config fix alone, a location-only V2 session in the jail (create_session_v2 names no model, and the jail config sets none) still resolved a credential-free catalog model such as opencode/hy3-free ahead of the config-declared swxtch-egress model, so egress turns still failed. V1's Provider.defaultModel takes its default from the config-declared providers when no model is configured. The V2 catalog now does the same: the config provider plugin records the declared providers in config order, and Catalog.model.default picks the newest available model of the first one that has any, before its newest-available fallback. An explicit session model or configured `model` still wins. The preference registers on the internal catalog under the plugin's scope, because the public plugin catalog API has no such hook. Tests: the jail path through the production location runtime (every internal plugin, the recorded models.dev corpus, the jail config via OPENCODE_CONFIG and OPENCODE_CONFIG_CONTENT) resolves a location-only session to the egress model; and the preference skips an unavailable declared provider and beats a newer undeclared model. Both are red without the preference. Claude-Session: https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp --- CHANGESET.md | 6 +- packages/core/src/catalog.ts | 13 ++- packages/core/src/config/plugin/provider.ts | 15 +++ packages/core/test/config/provider.test.ts | 38 +++++++ packages/core/test/location-layer.test.ts | 111 +++++++++++++++++++- 5 files changed, 179 insertions(+), 4 deletions(-) diff --git a/CHANGESET.md b/CHANGESET.md index e73bb901d3d7..8afd0d91e935 100644 --- a/CHANGESET.md +++ b/CHANGESET.md @@ -87,8 +87,10 @@ Newest first. Each entry names the upstream range brought in. - **#347** Load `OPENCODE_CONFIG` and `OPENCODE_CONFIG_CONTENT` in the V2 core config at V1's precedence (global directory, explicit file, project files, `.opencode` directories, inline content last), so V2 sessions in the OpenCode jail resolve its config-declared `swxtch-egress` provider and configured default model - (swxtchio/swx-abbe#1952). _Fork-only; upstream `43c08387f1` adds the same sources to its rewritten core config, and - a sync bringing it supersedes this._ + (swxtchio/swx-abbe#1952). With no `model` configured, the V2 catalog default comes, as in V1, from the first + config-declared provider with an available model, ahead of credential-free catalog models. _Fork-only; upstream + `43c08387f1` adds the same config sources to its rewritten core config, and a sync bringing it supersedes that + half. Upstream has no config-provider default preference._ - **#342** Add independently configured V1 small models at global, agent and persisted session scopes (swxtchio/swx-opencode#338), wire titles and compaction summaries to the effective selection, and report unresolvable configured models without a substitute request. See the model settings in `packages/web/src/content/docs/config.mdx`. _Fork-only._ - **#325** Port bounded provider output-limit continuation from `anomalyco/opencode` commit `a3f7f42ac96800aec06fb26365cd482024a52cd1` into V1 execution (swxtchio/swx-opencode#304, output-limit sub-scope). Preserve partial history, tool custody and queue delivery; keep capacity compaction from renewing the allowance and buffer argument fragments until settlement. Expose exhausted continuation through the existing V1 output-length error. _Fork-only V1 port._ - **#314** Add `Ctrl+f` in the pending-prompt review to give only the selected prepared item steer timing, releasing its confirmed review hold only when no other live holder remains. Consolidating a selected human-composer item now combines the complete ready, delivery- and execution-compatible human set in admission order, preserving original inputs, parts, receipts and constituents. `Ctrl+e` enhances the selected pending prompt through the existing model and transcript context, retaining row identity, order, receipts and non-text parts. A late pre-force renewal triggers a holder-status read rather than being discarded, so the view reflects server-applied lease changes after force. _Fork-only._ diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 1945024b54e8..19c20887d659 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -24,6 +24,7 @@ export const Event = Catalog.Event type Data = { providers: Map defaultModel?: DefaultModel + preferredProviders?: ProviderV2.ID[] } export type Draft = { @@ -40,6 +41,8 @@ export type Draft = { default: { get: () => DefaultModel | undefined set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void + /** Providers, in order, whose newest available model is the default when none is set (V1's configured providers). */ + prefer: (providerIDs: readonly ProviderV2.ID[]) => void } } } @@ -152,6 +155,9 @@ const layer = Layer.effect( set: (providerID, modelID) => { draft.defaultModel = { providerID, modelID } }, + prefer: (providerIDs) => { + draft.preferredProviders = [...providerIDs] + }, }, }, } @@ -222,9 +228,14 @@ const layer = Layer.effect( } } + // Like V1, the first preferred provider with an available model supplies the default. + const available = yield* result.model.available() + const preferred = (state.get().preferredProviders ?? []) + .map((providerID) => available.filter((model) => model.providerID === providerID)) + .find((models) => models.length > 0) return Option.getOrUndefined( pipe( - yield* result.model.available(), + preferred ?? available, Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), Array.head, ), diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 6f6e0528da73..1290c9a6609f 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -2,6 +2,7 @@ export * as ConfigProviderPlugin from "./provider" import { define } from "../../plugin/internal" import { Effect } from "effect" +import { Catalog } from "../../catalog" import { Config } from "../../config" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" @@ -10,6 +11,7 @@ export const Plugin = define({ id: "config-provider", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service + const catalogService = yield* Catalog.Service yield* ctx.integration.transform( Effect.fn(function* (integrations) { const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") @@ -38,6 +40,19 @@ export const Plugin = define({ }), ) + // The plugin API has no default preference, so this registers on the internal catalog under the same plugin scope. + // Like V1, the config-declared providers supply the default when no model is configured. + yield* catalogService.transform( + Effect.fn(function* (catalog) { + const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") + catalog.model.default.prefer([ + ...new Set( + files.flatMap((file) => Object.keys(file.info.providers ?? {}).map((id) => ProviderV2.ID.make(id))), + ), + ]) + }), + ) + yield* ctx.catalog.transform( Effect.fn(function* (catalog) { const entries = yield* config.entries() diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 605bcd63cf97..31c466639524 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -151,6 +151,44 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) + it.effect("takes the default from the first available config-declared provider when no model is configured", () => + withEnv({ COLD_API_KEY: undefined }, () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + // A newer model from a provider the config does not declare. + yield* catalog.transform((draft) => { + draft.provider.update(ProviderV2.ID.make("newer"), (provider) => { + provider.request.body.apiKey = "newer" + }) + draft.model.update(ProviderV2.ID.make("newer"), ModelV2.ID.make("chat"), (model) => { + model.time.released = 1_000 + }) + }) + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + providers: { + cold: { env: ["COLD_API_KEY"], models: { chat: {} } }, + warm: { request: { body: { apiKey: "warm" } }, models: { chat: {} } }, + }, + }), + }), + ]), + }) + + yield* addPlugin(config) + + expect(yield* catalog.model.default()).toMatchObject({ + providerID: ProviderV2.ID.make("warm"), + id: ModelV2.ID.make("chat"), + }) + }), + ), + ) + it.effect("loads configured providers and applies later model overrides", () => withEnv({ CUSTOM_API_KEY: "secret" }, () => Effect.gen(function* () { diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index e28e758c87b8..3f7ebb54c87a 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,11 +1,12 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { DateTime, Effect, Equal, Hash, Schema } from "effect" +import { DateTime, Effect, Equal, Hash, Layer, Schedule, Schema } from "effect" import { Tool } from "@opencode-ai/core/tool/tool" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" +import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LocationServiceMap } from "@opencode-ai/core/location-services" @@ -190,6 +191,114 @@ describe("LocationServiceMap", () => { ), ) + it.live("resolves a location-only V2 session in the OpenCode jail to its config-declared egress model", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + // The jail's opencode.json: no `model`, one config-declared provider. The image names it with + // OPENCODE_CONFIG and the entrypoint renders the proxy values into OPENCODE_CONFIG_CONTENT. + const jail = (options: { baseURL: string; apiKey: string }) => ({ + $schema: "https://opencode.ai/config.json", + autoupdate: false, + plugin: [], + provider: { + "swxtch-egress": { + npm: "@ai-sdk/openai-compatible", + name: "swXtch egress proxy", + options, + models: { "accounts/fireworks/models/deepseek-v4p1-flash": { name: "DeepSeek V4.1 Flash" } }, + }, + }, + }) + const file = path.join(dir.path, "jail", "opencode.json") + const global = path.join(dir.path, "global") + const directory = path.join(dir.path, "agent") + yield* Effect.promise(async () => { + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile( + file, + JSON.stringify( + jail({ baseURL: "{env:OPENCODE_PROXY_BASE_URL}", apiKey: "{env:OPENCODE_PROXY_CAPABILITY}" }), + ), + ) + }) + const location = Location.Ref.make({ directory: AbsolutePath.make(directory) }) + // The recorded models.dev corpus, with the opencode provider's credential-free models. + const corpus = yield* Effect.promise(() => + Bun.file( + path.join(import.meta.dir, "..", "..", "opencode", "test", "tool", "fixtures", "models-api.json"), + ).json(), + ) + + // Every internal plugin boots against the recorded models.dev corpus, so credential-free catalog + // models compete with the egress provider exactly as they do in the jail. + const { available, resolved } = yield* Effect.gen(function* () { + const catalog = yield* Catalog.Service + const models = yield* SessionRunnerModel.Service + // The location boots its plugins in the background; wait until the catalog shows the egress provider. + yield* catalog.provider + .get(ProviderV2.ID.make("swxtch-egress")) + .pipe( + Effect.repeat({ until: (provider) => provider !== undefined, schedule: Schedule.spaced("1 millis") }), + Effect.timeout("10 seconds"), + ) + return { + available: yield* catalog.model.available(), + resolved: yield* models.resolve( + SessionV2.Info.make({ + id: SessionV2.ID.make("ses_jail_location_only"), + projectID: ProjectV2.ID.global, + title: "test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location, + }), + ), + } + }).pipe( + Effect.provide(LocationServiceMap.Service.get(location)), + Effect.provide( + AppNodeBuilder.build( + LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node]), + [ + [Global.node, Global.layerWith({ config: global })], + [ + ModelsDev.node, + Layer.succeed( + ModelsDev.Service, + ModelsDev.Service.of({ get: () => Effect.succeed(corpus), refresh: () => Effect.void }), + ), + ], + [ + Config.node, + Config.nodeWith({ + file, + content: JSON.stringify(jail({ baseURL: "https://egress.test/v1", apiKey: "capability" })), + }), + ], + ], + ), + ), + ) + + // Credential-free catalog models are available alongside the egress one. + expect(available.some((model) => model.providerID !== "swxtch-egress")).toBe(true) + expect(resolved).toMatchObject({ + provider: "swxtch-egress", + id: "accounts/fireworks/models/deepseek-v4p1-flash", + }) + expect(resolved.route.endpoint).toMatchObject({ baseURL: "https://egress.test/v1" }) + }), + ), + ), + ) + it.live("installs public plugins into a location", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), From edca55c3012da07917020350dddfc31a7b8616b4 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 23 Oct 2026 18:02:35 +0000 Subject: [PATCH 4/5] fix(core): run V2 turns only after the location's plugins boot A Location boots its internal plugins in a forked fiber, and nothing on the turn path waited for it. The jail's adapter creates a session and prompts it at once, so the drain that boots the Location starts its first turn while the catalog is still empty or only partly built. On the jail image running v1.18.32-swxtch.774d90c7 the first turn failed with ModelNotSelectedError (or ModelUnavailableError when the egress model is named), or was sent to a bundled model such as nano-gpt/..., and the egress provider received nothing. The internal plugin layer now exposes a readiness signal that completes when its boot ends, whether it loaded every plugin or failed, and every runner turn waits on it before it selects the agent, model, tools and guidance. Refs swxtchio/swx-abbe#1952 Claude-Session: https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp --- packages/core/src/plugin/internal.ts | 25 ++- packages/core/src/session/runner/llm.ts | 6 + packages/core/test/location-layer.test.ts | 197 +++++++++++------- .../core/test/session-runner-recorded.test.ts | 3 + packages/core/test/session-runner.test.ts | 3 + 5 files changed, 157 insertions(+), 77 deletions(-) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index d4ab71cb6b04..693753753d27 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -3,7 +3,7 @@ export * as PluginInternal from "./internal" import { makeLocationNode } from "../effect/app-node" import { httpClient } from "../effect/app-node-platform" import type { PluginContext } from "@opencode-ai/plugin/v2/effect" -import { Effect, Layer, Scope } from "effect" +import { Context, Deferred, Effect, Layer, Scope } from "effect" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { CommandV2 } from "../command" @@ -60,7 +60,18 @@ export function define(plugin: Plugin) { return plugin } -const layer = Layer.effectDiscard( +export interface Interface { + /** Completes once the internal plugin boot has ended, whether it loaded every plugin or failed. */ + readonly ready: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/PluginInternal") {} + +/** Test or embedding seam for a Location whose plugins are already loaded. */ +export const readyLayer = Layer.succeed(Service, Service.of({ ready: Effect.void })) + +const layer = Layer.effect( + Service, Effect.gen(function* () { const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service @@ -105,6 +116,7 @@ const layer = Layer.effectDiscard( return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) } + const booted = yield* Deferred.make() yield* State.batch( Effect.gen(function* () { yield* add(ConfigReferencePlugin.Plugin) @@ -120,7 +132,12 @@ const layer = Layer.effectDiscard( yield* add(ConfigProviderPlugin.Plugin) yield* add(VariantPlugin.Plugin) }), - ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + ).pipe( + Effect.withSpan("PluginInternal.boot"), + Effect.ensuring(Deferred.succeed(booted, undefined)), + Effect.forkScoped({ startImmediately: true }), + ) + return Service.of({ ready: Deferred.await(booted) }) }), ) @@ -130,7 +147,7 @@ export const locationLayer = layer.pipe( ) export const node = makeLocationNode({ - name: "plugin-internal", + service: Service, layer, deps: [ Catalog.node, diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index a8a05bdac80d..6cc40396c434 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -16,6 +16,7 @@ import { EventV2 } from "../../event" import { Location } from "../../location" import { ModelV2 } from "../../model" import { PermissionV2 } from "../../permission" +import { PluginInternal } from "../../plugin/internal" import { ProviderV2 } from "../../provider" import { QuestionV2 } from "../../question" import { SystemContext } from "../../system-context/index" @@ -99,6 +100,7 @@ const layer = Layer.effect( const agents = yield* AgentV2.Service const tools = yield* ToolRegistry.Service const models = yield* SessionRunnerModel.Service + const plugins = yield* PluginInternal.Service const store = yield* SessionStore.Service const location = yield* Location.Service const systemContext = yield* SystemContextRegistry.Service @@ -184,6 +186,9 @@ const layer = Layer.effect( const session = yield* getSession(sessionID) if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) return yield* Effect.interrupt + // The Location boots its plugins in the background, and a drain can start as the Location does: the agent, + // model, tools and guidance below are only complete once that boot has ended. + yield* plugins.ready const agent = yield* agents.select(session.agent) const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id) const toolFibers = yield* FiberSet.make() @@ -439,6 +444,7 @@ export const node = makeLocationNode({ AgentV2.node, ToolRegistry.node, SessionRunnerModel.node, + PluginInternal.node, SessionStore.node, Location.node, SystemContextRegistry.node, diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 3f7ebb54c87a..fc3c5e026e01 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,22 +1,28 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { DateTime, Effect, Equal, Hash, Layer, Schedule, Schema } from "effect" +import { DateTime, Effect, Equal, Hash, Layer, Schema, Stream } from "effect" +import { LLMClient, LLMError, TransportReason, type LLMClientShape, type LLMRequest } from "@opencode-ai/llm" import { Tool } from "@opencode-ai/core/tool/tool" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginInternal } from "@opencode-ai/core/plugin/internal" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -33,6 +39,70 @@ import { Reference } from "../src/reference" import { ToolRegistry } from "../src/tool/registry" import { ApplicationTools } from "../src/tool/application-tools" +// The OpenCode jail's opencode.json: no `model`, one config-declared provider. The image names it with +// OPENCODE_CONFIG and the entrypoint renders the proxy values into OPENCODE_CONFIG_CONTENT. +const jailConfig = (options: { baseURL: string; apiKey: string }) => ({ + $schema: "https://opencode.ai/config.json", + autoupdate: false, + plugin: [], + provider: { + "swxtch-egress": { + npm: "@ai-sdk/openai-compatible", + name: "swXtch egress proxy", + options, + models: { "accounts/fireworks/models/deepseek-v4p1-flash": { name: "DeepSeek V4.1 Flash" } }, + }, + }, +}) + +// The jail's runtime: its config sources, sessions executed locally, and every internal plugin booting +// against the recorded models.dev corpus, so credential-free catalog models compete with the egress +// provider exactly as they do in the jail. +const jailServices = Effect.fn(function* (root: string, replacements: LayerNode.Replacements = []) { + const file = path.join(root, "jail", "opencode.json") + const global = path.join(root, "global") + const directory = path.join(root, "agent") + yield* Effect.promise(async () => { + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile( + file, + JSON.stringify( + jailConfig({ baseURL: "{env:OPENCODE_PROXY_BASE_URL}", apiKey: "{env:OPENCODE_PROXY_CAPABILITY}" }), + ), + ) + }) + const corpus = yield* Effect.promise(() => + Bun.file(path.join(import.meta.dir, "..", "..", "opencode", "test", "tool", "fixtures", "models-api.json")).json(), + ) + return { + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + layer: AppNodeBuilder.build( + LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node, SessionV2.node]), + [ + [SessionExecution.node, SessionExecutionLocal.node], + [Global.node, Global.layerWith({ config: global })], + [ + ModelsDev.node, + Layer.succeed( + ModelsDev.Service, + ModelsDev.Service.of({ get: () => Effect.succeed(corpus), refresh: () => Effect.void }), + ), + ], + [ + Config.node, + Config.nodeWith({ + file, + content: JSON.stringify(jailConfig({ baseURL: "https://egress.test/v1", apiKey: "capability" })), + }), + ], + ...replacements, + ], + ), + } +}) + const it = testEffect( AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node])), ) @@ -198,55 +268,12 @@ describe("LocationServiceMap", () => { ).pipe( Effect.flatMap((dir) => Effect.gen(function* () { - // The jail's opencode.json: no `model`, one config-declared provider. The image names it with - // OPENCODE_CONFIG and the entrypoint renders the proxy values into OPENCODE_CONFIG_CONTENT. - const jail = (options: { baseURL: string; apiKey: string }) => ({ - $schema: "https://opencode.ai/config.json", - autoupdate: false, - plugin: [], - provider: { - "swxtch-egress": { - npm: "@ai-sdk/openai-compatible", - name: "swXtch egress proxy", - options, - models: { "accounts/fireworks/models/deepseek-v4p1-flash": { name: "DeepSeek V4.1 Flash" } }, - }, - }, - }) - const file = path.join(dir.path, "jail", "opencode.json") - const global = path.join(dir.path, "global") - const directory = path.join(dir.path, "agent") - yield* Effect.promise(async () => { - await fs.mkdir(path.dirname(file), { recursive: true }) - await fs.mkdir(global, { recursive: true }) - await fs.mkdir(directory, { recursive: true }) - await fs.writeFile( - file, - JSON.stringify( - jail({ baseURL: "{env:OPENCODE_PROXY_BASE_URL}", apiKey: "{env:OPENCODE_PROXY_CAPABILITY}" }), - ), - ) - }) - const location = Location.Ref.make({ directory: AbsolutePath.make(directory) }) - // The recorded models.dev corpus, with the opencode provider's credential-free models. - const corpus = yield* Effect.promise(() => - Bun.file( - path.join(import.meta.dir, "..", "..", "opencode", "test", "tool", "fixtures", "models-api.json"), - ).json(), - ) - - // Every internal plugin boots against the recorded models.dev corpus, so credential-free catalog - // models compete with the egress provider exactly as they do in the jail. + const jail = yield* jailServices(dir.path) const { available, resolved } = yield* Effect.gen(function* () { const catalog = yield* Catalog.Service const models = yield* SessionRunnerModel.Service - // The location boots its plugins in the background; wait until the catalog shows the egress provider. - yield* catalog.provider - .get(ProviderV2.ID.make("swxtch-egress")) - .pipe( - Effect.repeat({ until: (provider) => provider !== undefined, schedule: Schedule.spaced("1 millis") }), - Effect.timeout("10 seconds"), - ) + const plugins = yield* PluginInternal.Service + yield* plugins.ready return { available: yield* catalog.model.available(), resolved: yield* models.resolve( @@ -257,35 +284,11 @@ describe("LocationServiceMap", () => { cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, - location, + location: jail.location, }), ), } - }).pipe( - Effect.provide(LocationServiceMap.Service.get(location)), - Effect.provide( - AppNodeBuilder.build( - LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node]), - [ - [Global.node, Global.layerWith({ config: global })], - [ - ModelsDev.node, - Layer.succeed( - ModelsDev.Service, - ModelsDev.Service.of({ get: () => Effect.succeed(corpus), refresh: () => Effect.void }), - ), - ], - [ - Config.node, - Config.nodeWith({ - file, - content: JSON.stringify(jail({ baseURL: "https://egress.test/v1", apiKey: "capability" })), - }), - ], - ], - ), - ), - ) + }).pipe(Effect.provide(LocationServiceMap.Service.get(jail.location)), Effect.provide(jail.layer)) // Credential-free catalog models are available alongside the egress one. expect(available.some((model) => model.providerID !== "swxtch-egress")).toBe(true) @@ -299,6 +302,54 @@ describe("LocationServiceMap", () => { ), ) + it.live("runs a location-only V2 turn admitted while its location boots on the jail's egress model", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const requests: LLMRequest[] = [] + // Records where the turn is sent, then fails it: the provider's answer is not under test. + const client = Layer.succeed( + LLMClient.Service, + LLMClient.Service.of({ + prepare: () => Effect.die("unused"), + stream: ((request: LLMRequest) => { + requests.push(request) + return Stream.fail( + new LLMError({ + module: "test", + method: "stream", + reason: new TransportReason({ message: "recorded" }), + }), + ) + }) as unknown as LLMClientShape["stream"], + generate: () => Effect.die("unused"), + }), + ) + const jail = yield* jailServices(dir.path, [[LayerNodePlatform.llmClient, client]]) + + // As the jail's adapter does: create with only a location, prompt, and drain at once, so the + // location's services boot under the turn. + yield* Effect.gen(function* () { + const sessions = yield* SessionV2.Service + const session = yield* sessions.create({ location: jail.location }) + yield* sessions.prompt({ sessionID: session.id, prompt: Prompt.make({ text: "Hello" }), resume: false }) + yield* sessions.resume(session.id).pipe(Effect.exit) + }).pipe(Effect.provide(jail.layer)) + + expect(requests).toHaveLength(1) + expect(requests[0]?.model).toMatchObject({ + provider: "swxtch-egress", + id: "accounts/fireworks/models/deepseek-v4p1-flash", + }) + expect(requests[0]?.model.route.endpoint).toMatchObject({ baseURL: "https://egress.test/v1" }) + }), + ), + ), + ) + it.live("installs public plugins into a location", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 60357b9eac9b..fce162eae575 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -23,6 +23,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { PluginInternal } from "@opencode-ai/core/plugin/internal" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { SessionTable } from "@opencode-ai/core/session/sql" @@ -76,6 +77,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], + [PluginInternal.node, PluginInternal.readyLayer], [SystemContextRegistry.node, systemContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], @@ -123,6 +125,7 @@ const it = testEffect( [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], [SessionRunnerModel.node, models], + [PluginInternal.node, PluginInternal.readyLayer], [SystemContextRegistry.node, systemContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 94c649ee37cb..073058fe77e9 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -43,6 +43,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { PluginInternal } from "@opencode-ai/core/plugin/internal" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { AgentV2 } from "@opencode-ai/core/agent" @@ -256,6 +257,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], + [PluginInternal.node, PluginInternal.readyLayer], [SystemContextRegistry.node, systemContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], @@ -349,6 +351,7 @@ const it = testEffect( [LayerNodePlatform.llmClient, client], [PermissionV2.node, permission], [SessionRunnerModel.node, models], + [PluginInternal.node, PluginInternal.readyLayer], [SystemContextRegistry.node, systemContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], From 0b4b03375e14fb65391b23bd12d90b21cebab20f Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 23 Oct 2026 18:21:00 +0000 Subject: [PATCH 5/5] docs: record #350 in CHANGESET Claude-Session: https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp --- CHANGESET.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGESET.md b/CHANGESET.md index 67eac81d5328..53cc7b6b75cd 100644 --- a/CHANGESET.md +++ b/CHANGESET.md @@ -84,6 +84,11 @@ Newest first. Each entry names the upstream range brought in. ### Runtime fixes +- **#350** Run each V2 turn only after its Location's background plugin boot has ended (`PluginInternal.ready`). + The jail's adapter prompts a new session at once, so its first turn raced the boot that fills the catalog, + agents and tools: it found no model, could not find the named `swxtch-egress` model, or ran on a bundled one, + and the egress provider got nothing (swxtchio/swx-abbe#1952 on `v1.18.32-swxtch.774d90c7`). _Fork-only; + upstream has the same race._ - **#347** Load `OPENCODE_CONFIG` and `OPENCODE_CONFIG_CONTENT` in the V2 core config at V1's precedence (global directory, explicit file, project files, `.opencode` directories, inline content last), so V2 sessions in the OpenCode jail resolve its config-declared `swxtch-egress` provider and configured default model