diff --git a/CHANGESET.md b/CHANGESET.md index ba57f7c5afcf..45c06670377e 100644 --- a/CHANGESET.md +++ b/CHANGESET.md @@ -85,6 +85,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 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],