Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGESET.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 21 additions & 4 deletions packages/core/src/plugin/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -60,7 +60,18 @@ export function define<R>(plugin: Plugin<R>) {
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<void>
}

export class Service extends Context.Service<Service, Interface>()("@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
Expand Down Expand Up @@ -105,6 +116,7 @@ const layer = Layer.effectDiscard(
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
}

const booted = yield* Deferred.make<void>()
yield* State.batch(
Effect.gen(function* () {
yield* add(ConfigReferencePlugin.Plugin)
Expand All @@ -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) })
}),
)

Expand All @@ -130,7 +147,7 @@ export const locationLayer = layer.pipe(
)

export const node = makeLocationNode({
name: "plugin-internal",
service: Service,
layer,
deps: [
Catalog.node,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void, ToolOutputStore.Error>()
Expand Down Expand Up @@ -439,6 +444,7 @@ export const node = makeLocationNode({
AgentV2.node,
ToolRegistry.node,
SessionRunnerModel.node,
PluginInternal.node,
SessionStore.node,
Location.node,
SystemContextRegistry.node,
Expand Down
197 changes: 124 additions & 73 deletions packages/core/test/location-layer.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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])),
)
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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()),
Expand Down
3 changes: 3 additions & 0 deletions packages/core/test/session-runner-recorded.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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],
Expand Down
Loading
Loading