feat(sdk): configure session-selected instances

This commit is contained in:
Kit Langton 2026-08-31 19:26:00 -04:00
parent cc0cc59700
commit cd4be9646f
16 changed files with 952 additions and 35 deletions

View file

@ -1,6 +1,8 @@
import { Context, Effect, Layer, LayerMap } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import path from "path"
import { Location } from "./location.js"
import type { Instance } from "./instance.js"
@ -15,4 +17,12 @@ export class Service extends Context.Service<
export const node = LayerNode.unbound(Service, Node.tags.values.global)
/** Normalize equivalent placements before they become resource-cache keys. */
export function canonical(ref: Location.Ref) {
return Location.Ref.make({
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
workspaceID: ref.workspaceID,
})
}
export * as LocationServiceMap from "./location-service-map.js"

View file

@ -1,11 +1,9 @@
import { Duration, Effect, Layer, LayerMap } from "effect"
import { existsSync } from "fs"
import path from "path"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { AbsolutePath } from "./schema.js"
export { LocationServiceMap } from "./location-service-map.js"
@ -15,13 +13,6 @@ export type LocationError = Instance.Error
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> {
// Structural Equal distinguishes optional-key shape and Windows separator style.
// The RcMap caches the raw key before the build callback, so normalize both here.
const canonical = (ref: Location.Ref) =>
Location.Ref.make({
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
workspaceID: ref.workspaceID,
})
return Layer.effect(
LocationServiceMap.Service,
Effect.map(
@ -35,10 +26,10 @@ export function buildLocationServiceMap(
}),
(inner) => ({
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(LocationServiceMap.canonical(ref)),
}),
),
)

View file

@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
import { Cause, Effect, Exit, Latch, Layer, Stream } from "effect"
import path from "path"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@ -86,9 +86,8 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
}
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
function make(failOnError = false) {
return Effect.gen(function* () {
const registry = yield* Plugin.Service
const sdk = yield* SdkPlugins.Service
const instance = yield* InstancePlugins.Service
@ -97,6 +96,7 @@ export const layer = Layer.effect(
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let observed = 0
let activation = Exit.void
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
@ -148,15 +148,19 @@ export const layer = Layer.effect(
Stream.debounce("100 millis"),
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
activation = yield* Effect.exit(activate())
if (Exit.isFailure(activation))
yield* Effect.logError("failed to reload plugins", { cause: activation.cause })
if (observed === target) yield* ready.open
}),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: ready.await })
}),
)
return Service.of({ flush: failOnError ? ready.await.pipe(Effect.andThen(() => activation)) : ready.await })
})
}
export const layer = Layer.effect(Service, make())
const nodeDeps = [
Plugin.node,
@ -174,3 +178,8 @@ function pluginSource(target: string): Plugin.Source {
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
/** Opt into propagating failed plugin generations through flush instead of only logging them. */
export function configured(options: { readonly failOnError?: boolean } = {}) {
return makeLocationNode({ service: Service, layer: Layer.effect(Service, make(options.failOnError)), deps: nodeDeps })
}

View file

@ -28,6 +28,53 @@ await using opencode = await OpenCode.create({
`close()` and `Symbol.asyncDispose` release router resources, Location services, fibers, and scoped plugin registrations.
## Session-Selected Plugins
Use `instances` when Sessions in the same directory need different application plugins. The application selects a stable key from Session metadata; the SDK constructs and caches an instance for that key and the Session's current Location.
```ts
import { OpenCode } from "@opencode-ai/sdk"
import { threads } from "./threads"
import { slackPlugin } from "./slack-plugin"
await using opencode = await OpenCode.create({
database: { path: "./sessions.db" },
instances: {
key(session) {
const threadID = session.metadata?.threadID
if (typeof threadID !== "string") throw new Error("Session has no thread ID")
return threadID
},
configure: async (threadID) => ({
plugins: [slackPlugin(await threads.get(threadID))],
}),
},
})
const session = await opencode.sessions.create({
location: { directory: "/workspace" },
metadata: { threadID: "thread-42" },
})
await opencode.sessions.prompt({ sessionID: session.id, text: "Review the changes" })
```
`threads` and `slackPlugin` are application-owned modules. `key` is synchronous and should only select identity, not initialize plugins. `configure` returns plugin definitions; their setup receives the selected instance's `ctx.location`.
- The same key and Location share one live instance. Different directories or workspace IDs always select separate instances, even with the same application key.
- `configure` runs on a cache miss, not on each prompt. Loaded instances live until the host closes; change the application key or restart the host to reconstruct their birth configuration. Plugin transforms and reloads remain available within that lifetime.
- Session reads and unloaded permission/form lists do not initialize plugins. Configuration or initial supplied-plugin setup failure prevents capability acquisition, without falling back to another instance. A subsequent request can retry a failed construction.
- Existing HTTP prompt middleware also acquires capabilities for an idempotent retry. After restart, that retry can reconstruct plugins before returning the original admission; prompt preparation and hooks do not rerun. Configuration failure can therefore block the retry even when its input was already saved.
- Instance selection is not an authorization or storage-isolation boundary. Plugin Session APIs and the existing plugin-ID-based durable storage retain their normal scope.
- Omitting `instances` preserves default Location sharing. Host-wide plugins remain separate from Session-selected configuration; retain host-wide catalog policy when locationless generation needs it.
### Restart and Lifetime
The selector is installed before automatic recovery starts. Its callbacks must be able to load application data without depending on the returned `opencode` handle or a later registration call. Functions are reconstructed, not serialized.
Use a persistent `database.path` to recover Sessions after restart; the default database is in memory. Workerd uses its injected Durable Object storage. After restart, the next capability-dependent operation or recovery drain rebuilds the selected instance from saved Session metadata and application data.
Promise plugin resources should be acquired in `setup` and released by its cleanup function. Effect configuration can acquire resources in its supplied instance Scope; capture application services before creating the SDK host.
## Workerd
Use the Workerd entrypoint inside a Cloudflare Durable Object. Hold one host for the lifetime of the object instance rather than creating one per request.
@ -70,3 +117,25 @@ const session = yield * opencode.sessions.get({ sessionID })
```
The Effect Workerd entrypoint is `@opencode-ai/sdk/workerd/effect`.
Effect configuration uses the same keys and lifetime rules, with canonical `Session.Info` values and an Effect-returning factory:
```ts
import { OpenCode } from "@opencode-ai/sdk/effect"
import { Effect, Schema } from "effect"
import { threads } from "./threads-effect"
import { slackPlugin } from "./slack-plugin-effect"
const threadMetadata = Schema.decodeUnknownSync(Schema.Struct({ threadID: Schema.String }))
const opencode =
yield *
OpenCode.create({
database: { path: "./sessions.db" },
instances: {
key: (session) => threadMetadata(session.metadata).threadID,
configure: (threadID) => threads.get(threadID).pipe(Effect.map((thread) => ({ plugins: [slackPlugin(thread)] }))),
},
})
```
Both Workerd entrypoints also accept `instances`. The public `OpenCode.InstanceOptions` and `OpenCode.InstanceConfiguration` types describe the corresponding Promise or Effect callbacks.

View file

@ -105,9 +105,30 @@ import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
export class OpenCodeDO {
constructor(state) {
this.configurations = 0
this.opencode = state.blockConcurrencyWhile(() => OpenCodeWorkerd.create({
storage: state.storage,
app: { version: "packed-workerd" },
models: { fetch: false },
instances: {
key: session => String(session.metadata.thread),
configure: key => {
this.configurations++
return {
plugins: [{
id: "packed-instance",
async setup(ctx) {
if (ctx.app.version !== "packed-workerd" || ctx.location.directory !== "/workspace") {
throw new Error("Selected instance did not inherit the host configuration")
}
await ctx.session.hook("prompt", event => {
event.prompt.text += ":" + key
})
},
}],
}
},
},
}))
}
@ -116,6 +137,18 @@ export class OpenCodeDO {
throw new Error("Packed workerd SHA-256 mismatch")
}
const opencode = await this.opencode
const sessions = await Promise.all([1, 2].map(() => opencode.sessions.create({
location: { directory: "/workspace" },
metadata: { thread: "packed-thread" },
})))
const admitted = await Promise.all(sessions.map(session => opencode.sessions.prompt({
sessionID: session.id,
text: "Packed prompt",
resume: false,
})))
if (this.configurations !== 1 || admitted.some(item => item.payload.text !== "Packed prompt:packed-thread")) {
throw new Error("Packed instance configuration did not share or prepare prompts correctly")
}
return Response.json(await opencode.health.get())
}
}

View file

@ -6,11 +6,14 @@ import { Context, Effect, Layer } from "effect"
import type { Config, Scope } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { EmbeddedHost } from "../internal/host"
import type { SdkInstances } from "../internal/instances"
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "../logging"
export type CreateOptions = EmbeddedHost.CreateOptions
export type EmbedOptions = EmbeddedHost.EmbedOptions
export type InstanceOptions = SdkInstances.Options
export type InstanceConfiguration = SdkInstances.Configuration
export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
readonly sessions: OpenCodeClient["session"]

View file

@ -10,11 +10,15 @@ export type Configuration = WorkerdProfile.Configuration
export interface CreateOptions extends WorkerdProfile.Options {
readonly log?: OpenCode.CreateOptions["log"]
readonly workspaceProviders?: OpenCode.CreateOptions["workspaceProviders"]
readonly instances?: OpenCode.CreateOptions["instances"]
}
export const create = ({ log, workspaceProviders, ...options }: CreateOptions) => {
export const create = ({ log, workspaceProviders, instances, ...options }: CreateOptions) => {
const profile = WorkerdProfile.make(options)
return OpenCode.create({ ...profile.options, log, workspaceProviders }, { overrides: profile.replacements })
return OpenCode.create(
{ ...profile.options, log, workspaceProviders, instances },
{ overrides: profile.replacements },
)
}
export const layer = (options: CreateOptions): Layer.Layer<OpenCode.Service, Config.ConfigError | Error> =>

View file

@ -11,10 +11,12 @@ import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import { HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { context, layer, type LogOptions } from "../logging"
import { OwnedFetch } from "./fetch"
import { SdkInstances } from "./instances"
export interface CreateOptions extends Omit<ServerOptions, "hostname" | "port" | "password"> {
readonly log?: LogOptions
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
readonly instances?: SdkInstances.Options
}
/** Host hooks for embedding opencode on a non-default runtime profile. */
@ -26,7 +28,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* (
options: CreateOptions = {},
embed: EmbedOptions = {},
) {
const { log, workspaceProviders, ...server } = options
const { log, workspaceProviders, instances, ...server } = options
const runtime = ManagedRuntime.make(
createEmbeddedRoutes(
{
@ -37,6 +39,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* (
workspaceProviders
? [...(embed.overrides ?? []), WorkspaceDriver.node.replace(WorkspaceDriver.registryNode(workspaceProviders))]
: embed.overrides,
instances ? (replacements) => SdkInstances.layer(instances, replacements) : undefined,
).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(layer(log))),
)

View file

@ -0,0 +1,84 @@
export * as SdkInstances from "./instances"
import { Instance } from "@opencode-ai/core/instance"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import type { InstancePlugins } from "@opencode-ai/core/plugin/instance"
import { Location } from "@opencode-ai/schema/location"
import type { Session } from "@opencode-ai/schema/session"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Duration, Effect, Layer, LayerMap, Option, Scope } from "effect"
export interface Configuration {
readonly plugins: InstancePlugins.List
}
export interface Options {
/** Select a sharing key within the Session's current Location. Must not initialize plugins. */
readonly key: (session: Session.Info) => string
/** Reconstruct configuration on a cache miss. Resources belong to the instance Scope. */
readonly configure: (key: string) => Effect.Effect<Configuration, unknown, Scope.Scope>
}
export function layer(options: Options, replacements: LayerNode.Replacements) {
return Layer.effect(
Instance.Service,
Effect.gen(function* () {
const scope = yield* Effect.scope
const key = (session: Session.Info) => ({
key: options.key(session),
...LocationServiceMap.canonical(session.location),
})
const instances: LayerMap.LayerMap<ReturnType<typeof key>, Instance.Services> = yield* LayerMap.make(
(input: ReturnType<typeof key>) =>
Layer.unwrap(
Effect.gen(function* () {
const configuration = yield* options.configure(input.key).pipe(Effect.orDie)
return Instance.layer(Location.Ref.make({ directory: input.directory, workspaceID: input.workspaceID }), {
plugins: configuration.plugins,
replacements: [
PluginSupervisor.node.replace(PluginSupervisor.configured({ failOnError: true })),
...replacements,
],
}).pipe(
Layer.tap((context) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const plugins = yield* Plugin.Service
yield* supervisor.flush
const failed = (yield* plugins.list()).filter(
(plugin) =>
plugin.state.status === "failed" &&
configuration.plugins.some((configured) => configured.id === plugin.id),
)
if (failed.length > 0)
yield* Effect.die(
new Error(`Instance plugin setup failed: ${failed.map((plugin) => plugin.id).join(", ")}`),
)
}).pipe(Effect.provide(context)),
),
)
}),
).pipe(
// Eviction can close the lookup's scope; do not make that fiber wait on itself.
Layer.tapCause(() =>
instances.invalidate(input).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid),
),
),
{ idleTimeToLive: Duration.infinity },
)
return Instance.Service.of({
provide: (session) => Effect.provide(instances.get(key(session))),
provideIfLoaded: (session) => (effect) =>
Effect.scopedWith((scope) =>
Effect.gen(function* () {
const context = yield* instances.contextEffectOption(key(session)).pipe(Scope.provide(scope))
if (Option.isNone(context)) return Option.none()
return Option.some(yield* effect.pipe(Effect.provide(context.value)))
}),
),
})
}),
)
}

View file

@ -3,6 +3,8 @@ import { PromiseSdk } from "./promise"
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
export type CreateOptions = PromiseSdk.CreateOptions
export type InstanceOptions = PromiseSdk.InstanceOptions
export type InstanceConfiguration = PromiseSdk.InstanceConfiguration
export type Interface = PromiseSdk.Interface
export const create = (options: CreateOptions = {}) => PromiseSdk.create(options)

View file

@ -2,11 +2,24 @@ export * as PromiseSdk from "./promise"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin"
import { Effect } from "effect"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Schema } from "effect"
import { EmbeddedHost } from "./internal/host"
export interface CreateOptions extends Omit<EmbeddedHost.CreateOptions, "workspaceProviders"> {
export interface InstanceConfiguration {
readonly plugins: ReadonlyArray<Plugin.Plugin>
}
export interface InstanceOptions {
/** Select a sharing key within the Session's current Location. Must not initialize plugins. */
readonly key: (session: typeof Session.Info.Encoded) => string
/** Reconstruct configuration on a cache miss, including after a host restart. */
readonly configure: (key: string) => InstanceConfiguration | Promise<InstanceConfiguration>
}
export interface CreateOptions extends Omit<EmbeddedHost.CreateOptions, "workspaceProviders" | "instances"> {
readonly plugins?: ReadonlyArray<Plugin.Plugin>
readonly instances?: InstanceOptions
}
export type Interface = Omit<OpenCodeClient, "plugin"> & {
@ -18,8 +31,26 @@ export type Interface = Omit<OpenCodeClient, "plugin"> & {
}
export async function create(options: CreateOptions = {}, embed: EmbeddedHost.EmbedOptions = {}): Promise<Interface> {
const { plugins, ...hostOptions } = options
const host = await Effect.runPromise(EmbeddedHost.create(hostOptions, embed))
const { plugins, instances, ...hostOptions } = options
const host = await Effect.runPromise(
EmbeddedHost.create(
{
...hostOptions,
instances: instances
? {
key: (session) => instances.key(Schema.encodeSync(Session.Info)(session)),
configure: (key) =>
Effect.gen(function* () {
const { PluginPromise } = yield* Effect.promise(() => import("@opencode-ai/core/plugin/promise"))
const configuration = yield* Effect.tryPromise(async () => instances.configure(key))
return { plugins: configuration.plugins.map(PluginPromise.fromPromise) }
}),
}
: undefined,
},
embed,
),
)
const client = OpenCode.make({ baseUrl: "http://opencode.local", fetch: host.fetch })
const register = async (plugin: Plugin.Plugin) => {
const { PluginPromise } = await import("@opencode-ai/core/plugin/promise")

View file

@ -9,6 +9,7 @@ export type Configuration = WorkerdProfile.Configuration
export interface CreateOptions extends WorkerdProfile.Options {
readonly log?: LogOptions
readonly plugins?: PromiseSdk.CreateOptions["plugins"]
readonly instances?: PromiseSdk.CreateOptions["instances"]
}
/**
@ -25,9 +26,9 @@ export interface CreateOptions extends WorkerdProfile.Options {
* session operations plus the live `events.subscribe()` stream served over
* an in-process fetch transport, so no request leaves the isolate.
*/
export const create = ({ log, plugins, ...options }: CreateOptions) => {
export const create = ({ log, plugins, instances, ...options }: CreateOptions) => {
const profile = WorkerdProfile.make(options)
return PromiseSdk.create({ ...profile.options, log, plugins }, { overrides: profile.replacements })
return PromiseSdk.create({ ...profile.options, log, plugins, instances }, { overrides: profile.replacements })
}
export type Interface = PromiseSdk.Interface

View file

@ -0,0 +1,236 @@
import { expect } from "bun:test"
import path from "path"
import { LanguageModel, LLMClient } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { TestLLM } from "@opencode-ai/ai/testing"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Deferred, Effect, Exit, Layer, Schema, Scope } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { testEffect } from "../../core/test/lib/effect"
import { AbsolutePath, Agent, Location, OpenCode } from "../src/effect"
const it = testEffect(Layer.empty)
const metadata = Schema.decodeUnknownSync(Schema.Struct({ threadID: Schema.String }))
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "instance-model", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 8_192 },
},
)
it.live(
"reconstructs configured instances before automatic recovery executes tools",
() =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const scope = yield* Effect.scope
const firstScope = yield* Scope.fork(scope)
const secondScope = yield* Scope.fork(scope)
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const llm = yield* TestLLM.Test.pipe(Effect.provide(TestLLM.testLayer()))
const configured: string[] = []
const closed: number[] = []
const executed: number[] = []
const options: OpenCode.CreateOptions = {
database: { path: path.join(directory.path, "sessions.db") },
app: { name: "instance-test", version: "1.2.3" },
events: { persist: true },
config: { directory: directory.path, project: false, content: "{}" },
models: { fetch: false },
fs: { filewatcher: false },
instances: {
key: (session) => metadata(session.metadata).threadID,
configure: (key) =>
Effect.gen(function* () {
const generation = configured.push(key)
yield* Effect.addFinalizer(() => Effect.sync(() => closed.push(generation)))
if (generation === 2) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
return {
plugins: [
Plugin.define({
id: "thread-tools",
effect: (ctx) =>
Effect.gen(function* () {
expect(ctx.app).toMatchObject({ name: "instance-test", version: "1.2.3" })
yield* ctx.agent.transform((draft) =>
draft.update(Agent.ID.make("build"), (agent) => {
agent.permissions = [{ action: "*", resource: "*", effect: "allow" }]
}),
)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.generation.temperature = 0.25
}),
)
yield* ctx.tool.transform((draft) =>
draft.add({
name: "thread_echo",
description: "Report the configured thread",
input: Schema.Struct({}),
output: Schema.String,
options: { codemode: false },
execute: (_, tool) =>
Effect.gen(function* () {
executed.push(generation)
yield* ctx.session
.rename({ sessionID: tool.sessionID, title: `${key}:${generation}` })
.pipe(Effect.orDie)
return { output: key, content: key }
}),
}),
)
}),
}),
],
}
}),
},
}
const embed = {
overrides: [
llmClient.replace(Layer.succeed(LLMClient.Service, llm)),
SessionRunnerModel.node.replace(
Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }),
),
],
}
yield* llm.push(TestLLM.hangAfter())
const first = yield* OpenCode.create(options, embed).pipe(Scope.provide(firstScope))
const session = yield* first.sessions.create({
title: "Recovery fixture",
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
model: model.ref,
metadata: { threadID: "thread-recovery" },
})
expect(configured).toEqual([])
yield* first.sessions.prompt({ sessionID: session.id, text: "Use the thread tool" })
yield* llm.wait(1).pipe(Effect.timeout("5 seconds"))
expect(configured).toEqual(["thread-recovery"])
// Closing the host interrupts active execution while preserving its durable recovery claim.
yield* Scope.close(firstScope, Exit.void)
expect(closed).toEqual([1])
yield* llm.push(TestLLM.tool("recovered-tool", "thread_echo", {}), TestLLM.text("Recovered", "answer"))
const second = yield* OpenCode.create(options, embed).pipe(Scope.provide(secondScope))
yield* Deferred.await(started).pipe(Effect.timeout("5 seconds"))
expect((yield* llm.requests()).length).toBe(1)
yield* Deferred.succeed(release, undefined)
yield* llm.wait(3).pipe(Effect.timeout("5 seconds"))
yield* second.sessions.wait({ sessionID: session.id })
expect(configured).toEqual(["thread-recovery", "thread-recovery"])
expect(executed).toEqual([2])
expect((yield* second.sessions.get({ sessionID: session.id })).title).toBe("thread-recovery:2")
expect(
(yield* llm.requests()).map((request) => ({
temperature: request.generation?.temperature,
tools: request.tools?.filter((tool) => tool.name.startsWith("thread_")).map((tool) => tool.name),
})),
).toEqual(Array.from({ length: 3 }, () => ({ temperature: 0.25, tools: ["thread_echo"] })))
yield* Scope.close(secondScope, Exit.void)
expect(closed).toEqual([1, 2])
}),
15_000,
)
it.live(
"qualifies application keys by workspace and reselects a moved Session",
() =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const llm = yield* TestLLM.Test.pipe(
Effect.provide(TestLLM.testLayer({ fallback: TestLLM.text("Ready", "answer") })),
)
const configured: string[] = []
const placements: Location.Ref[] = []
const driver = WorkspaceDriver.make({
create: ({ workspaceID }) => Effect.succeed({ binding: { workspaceID } }),
connect: () => Effect.succeed(makeMemoryDriver()),
suspendForIdle: () => Effect.void,
destroy: () => Effect.void,
})
const opencode = yield* OpenCode.create(
{
config: { directory: directory.path, project: false, content: "{}" },
models: { fetch: false },
fs: { filewatcher: false },
workspaceProviders: { memory: driver },
instances: {
key: (session) => metadata(session.metadata).threadID,
configure: (key) =>
Effect.sync(() => {
configured.push(key)
return {
plugins: [
Plugin.define({
id: "placement-hook",
effect: (ctx) =>
Effect.gen(function* () {
placements.push(Location.Ref.make(ctx.location))
yield* ctx.session.hook("prompt", (event) =>
Effect.sync(() => {
event.prompt.text += `:${ctx.location.workspaceID}`
}),
)
}),
}),
],
}
}),
},
},
{
overrides: [
llmClient.replace(Layer.succeed(LLMClient.Service, llm)),
SessionRunnerModel.node.replace(
Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }),
),
],
},
)
const firstWorkspace = yield* opencode.workspace.create({ provider: "memory" })
const secondWorkspace = yield* opencode.workspace.create({ provider: "memory" })
const first = yield* opencode.sessions.create({
title: "First placement",
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path), workspaceID: firstWorkspace }),
model: model.ref,
metadata: { threadID: "same-thread" },
})
const second = yield* opencode.sessions.create({
title: "Second placement",
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path), workspaceID: secondWorkspace }),
model: model.ref,
metadata: { threadID: "same-thread" },
})
for (const session of [first, second]) {
const admitted = yield* opencode.sessions.prompt({ sessionID: session.id, text: "Hello" })
expect(admitted.payload.text).toBe(`Hello:${session.location.workspaceID}`)
yield* opencode.sessions.wait({ sessionID: session.id })
}
expect(configured).toEqual(["same-thread", "same-thread"])
expect(placements.map((location) => location.workspaceID)).toEqual([firstWorkspace, secondWorkspace])
yield* opencode.sessions.move({
sessionID: first.id,
directory: AbsolutePath.make(directory.path),
workspaceID: secondWorkspace,
})
yield* opencode.sessions.wait({ sessionID: first.id })
const moved = yield* opencode.sessions.get({ sessionID: first.id })
expect(moved.location.workspaceID).toBe(secondWorkspace)
const admitted = yield* opencode.sessions.prompt({ sessionID: first.id, text: "Moved", resume: false })
expect(admitted.payload.text).toBe(`Moved:${secondWorkspace}`)
expect(configured).toEqual(["same-thread", "same-thread"])
}),
15_000,
)

View file

@ -0,0 +1,73 @@
import { expect } from "bun:test"
import { Instance } from "@opencode-ai/core/instance/service"
import { Session } from "@opencode-ai/core/session"
import { Location } from "@opencode-ai/schema/location"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Context, Deferred, Effect, Exit, Fiber, Layer, Option } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { testEffect } from "../../core/test/lib/effect"
import { EmbeddedHost } from "../src/internal/host"
const it = testEffect(Layer.empty)
it.live("a cancelled borrower cannot strand a later failed instance construction", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const started = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>()
const release = yield* Deferred.make<void>()
const acquired: number[] = []
const released: number[] = []
const host = yield* Effect.acquireRelease(
EmbeddedHost.create({
config: { directory: directory.path, project: false, content: "{}" },
models: { fetch: false },
fs: { filewatcher: false },
instances: {
key: () => "shared",
configure: () =>
Effect.gen(function* () {
const attempt = acquired.length + 1
yield* Effect.acquireRelease(
Effect.sync(() => acquired.push(attempt)),
() => Effect.sync(() => released.push(attempt)),
)
if (attempt !== 1) return { plugins: [] }
yield* Effect.withFiber((fiber) => Deferred.succeed(started, fiber))
yield* Deferred.await(release)
return yield* Effect.fail(new Error("Configuration unavailable"))
}),
},
}),
(host) => Effect.promise(host.close),
)
const services = yield* host.runtime.contextEffect
const instances = Context.get(services, Instance.Service)
const sessions = Context.get(services, Session.Service)
const session = yield* sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
})
expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.none())
expect(acquired).toEqual([])
const borrower = yield* Effect.void.pipe(instances.provide(session), Effect.forkScoped)
const lookup = yield* Deferred.await(started)
yield* Fiber.interrupt(borrower)
expect(released).toEqual([])
yield* Deferred.succeed(release, undefined)
expect(Exit.isFailure(yield* Fiber.await(lookup).pipe(Effect.timeout("1 second")))).toBe(true)
expect(released).toEqual([1])
yield* Effect.void.pipe(instances.provide(session))
expect(acquired).toEqual([1, 2])
expect(released).toEqual([1])
const callerScope = yield* Effect.scope
expect(Option.getOrThrow(yield* Effect.scope.pipe(instances.provideIfLoaded(session)))).toBe(callerScope)
expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.some(undefined))
const error = new Error("Operation failed")
expect(yield* Effect.fail(error).pipe(instances.provideIfLoaded(session), Effect.flip)).toBe(error)
yield* Effect.promise(host.close)
expect(released).toEqual([1, 2])
expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.none())
}),
)

View file

@ -0,0 +1,353 @@
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import { join } from "node:path"
import { Schema } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { OpenCode, Session, SessionMessage } from "../src"
const metadata = Schema.decodeUnknownSync(Schema.Struct({ account: Schema.String }))
const hostOptions = (directory: string) => ({
database: { path: join(directory, "opencode.sqlite") },
config: { directory, project: false, content: "{}" },
models: { fetch: false },
fs: { filewatcher: false },
})
test("Promise instances are lazy, share by key and Location, and stay isolated between hosts", async () => {
await using directory = await tmpdir("opencode-promise-instances-")
const otherDirectory = join(directory.path, "other")
await mkdir(otherDirectory)
const configured: string[] = []
const setups: string[] = []
const cleanups: string[] = []
const createHost = (name: string) => {
const instances: OpenCode.InstanceOptions = {
key(session) {
expect(typeof session.time.created).toBe("number")
return metadata(session.metadata).account
},
configure(key): OpenCode.InstanceConfiguration {
configured.push(`${name}:${key}`)
return {
plugins: [
{
id: "account-prompts",
async setup(ctx) {
const activation = `${name}:${key}@${ctx.location.directory}`
setups.push(activation)
await ctx.session.hook("prompt", (event) => {
event.prompt.text = `${activation}: ${event.prompt.text}`
})
return () => {
cleanups.push(activation)
}
},
},
],
}
},
}
return OpenCode.create({
...hostOptions(directory.path),
database: { path: join(directory.path, `${name}.sqlite`) },
instances,
})
}
await using first = await createHost("first")
await using second = await createHost("second")
const sessionID = Session.ID.create()
const original = await first.sessions.create({
id: sessionID,
location: { directory: directory.path },
metadata: { account: "alpha", labels: ["review", 2] },
})
const sameKey = await first.sessions.create({
location: { directory: directory.path },
metadata: original.metadata,
})
const separateKey = await first.sessions.create({
location: { directory: directory.path },
metadata: { account: "beta" },
})
const separateLocation = await first.sessions.create({
location: { directory: otherDirectory },
metadata: { account: "alpha" },
})
const separateHost = await second.sessions.create({
id: sessionID,
location: { directory: directory.path },
metadata: original.metadata,
})
expect(await first.sessions.get({ sessionID })).toEqual(original)
expect((await first.sessions.list()).data).toHaveLength(4)
expect((await first.message.list({ sessionID })).data).toEqual([])
expect(await first.sessions.context({ sessionID })).toEqual([])
expect(await first.sessions.inbox.list({ sessionID })).toEqual([])
expect(await first.permission.list({ sessionID })).toEqual([])
expect(await first.form.list({ sessionID })).toEqual([])
expect(await second.sessions.get({ sessionID })).toEqual(separateHost)
expect(configured).toEqual([])
expect(setups).toEqual([])
await Promise.all(
[
{ host: first, session: original, prefix: `first:alpha@${directory.path}` },
{ host: first, session: sameKey, prefix: `first:alpha@${directory.path}` },
{ host: first, session: separateKey, prefix: `first:beta@${directory.path}` },
{ host: first, session: separateLocation, prefix: `first:alpha@${otherDirectory}` },
{ host: second, session: separateHost, prefix: `second:alpha@${directory.path}` },
].map(async (input) => {
const admitted = await input.host.sessions.prompt({
sessionID: input.session.id,
text: "Review this change",
resume: false,
})
expect(admitted.payload.text).toBe(`${input.prefix}: Review this change`)
expect(await input.host.sessions.inbox.list({ sessionID: input.session.id })).toEqual([admitted])
}),
)
await first.sessions.switchAgent({ sessionID, agent: "plan" })
const fork = await first.sessions.fork({ sessionID, boundary: { type: "through" } })
expect(fork.metadata).toEqual(original.metadata)
expect(fork.location).toEqual(original.location)
expect(fork.fork?.sessionID).toBe(sessionID)
const inherited = await first.sessions.prompt({ sessionID: fork.id, text: "Review the fork", resume: false })
expect(inherited.payload.text).toBe(`first:alpha@${directory.path}: Review the fork`)
expect(await first.sessions.inbox.list({ sessionID: fork.id })).toEqual([inherited])
expect(configured.toSorted()).toEqual(["first:alpha", "first:alpha", "first:beta", "second:alpha"])
expect(setups.toSorted()).toEqual(
[
`first:alpha@${directory.path}`,
`first:alpha@${otherDirectory}`,
`first:beta@${directory.path}`,
`second:alpha@${directory.path}`,
].toSorted(),
)
expect(cleanups).toEqual([])
expect(await first.sessions.active()).toEqual({})
await first.close()
expect(cleanups.toSorted()).toEqual(setups.filter((activation) => activation.startsWith("first:")).toSorted())
const continued = await second.sessions.prompt({ sessionID, text: "Keep working", resume: false })
expect(continued.payload.text).toBe(`second:alpha@${directory.path}: Keep working`)
expect(await second.sessions.inbox.list({ sessionID })).toHaveLength(2)
expect(configured).toHaveLength(4)
await second.close()
expect(cleanups.toSorted()).toEqual(setups.toSorted())
}, 20_000)
test.each(["configuration", "plugin setup"])(
"Promise instance %s failure admits nothing, preserves healthy instances, and can be retried",
async (failure) => {
await using directory = await tmpdir("opencode-promise-instance-failure-")
const configured: string[] = []
const setups: string[] = []
const cleanups: string[] = []
await using opencode = await OpenCode.create({
...hostOptions(directory.path),
instances: {
key: (session) => metadata(session.metadata).account,
async configure(key) {
configured.push(key)
if (key === "retry" && failure === "configuration" && configured.filter((item) => item === key).length === 1)
throw new Error("Account configuration unavailable")
return {
plugins: [
{
id: "account-prompts",
async setup(ctx) {
setups.push(key)
await ctx.session.hook("prompt", (event) => {
event.prompt.text = `${key}: ${event.prompt.text}`
})
if (
key === "retry" &&
failure === "plugin setup" &&
setups.filter((item) => item === key).length === 1
)
throw new Error("Account plugin unavailable")
return () => {
cleanups.push(key)
}
},
},
],
}
},
},
})
const healthy = await opencode.sessions.create({
location: { directory: directory.path },
metadata: { account: "healthy" },
})
const retry = await opencode.sessions.create({
location: { directory: directory.path },
metadata: { account: "retry" },
})
const before = await opencode.sessions.prompt({ sessionID: healthy.id, text: "Before failure", resume: false })
expect(before.payload.text).toBe("healthy: Before failure")
const input = { sessionID: retry.id, id: SessionMessage.ID.create(), text: "Retry this input", resume: false }
expect(await opencode.sessions.prompt(input).catch((error: unknown) => error)).toMatchObject({
name: "ClientError",
reason: "UnexpectedStatus",
})
expect(await opencode.sessions.inbox.list({ sessionID: retry.id })).toEqual([])
expect((await opencode.message.list({ sessionID: retry.id })).data).toEqual([])
expect(await opencode.sessions.get({ sessionID: retry.id })).toEqual(retry)
expect(configured).toEqual(["healthy", "retry"])
expect(cleanups).toEqual([])
const after = await opencode.sessions.prompt({ sessionID: healthy.id, text: "After failure", resume: false })
expect(after.payload.text).toBe("healthy: After failure")
expect(await opencode.sessions.inbox.list({ sessionID: healthy.id })).toEqual([before, after])
const admitted = await opencode.sessions.prompt(input)
expect(admitted.id).toBe(input.id)
expect(admitted.payload.text).toBe("retry: Retry this input")
expect(await opencode.sessions.inbox.list({ sessionID: retry.id })).toEqual([admitted])
expect(configured).toEqual(["healthy", "retry", "retry"])
expect(setups).toEqual(failure === "configuration" ? ["healthy", "retry"] : ["healthy", "retry", "retry"])
expect(cleanups).toEqual([])
await opencode.close()
expect(cleanups.toSorted()).toEqual(["healthy", "retry"])
},
20_000,
)
test("Promise instances reconstruct callbacks for persisted Sessions only on a cold prompt", async () => {
await using directory = await tmpdir("opencode-promise-instance-restart-")
const sessionID = Session.ID.create()
const configured: string[] = []
const setups: number[] = []
const cleanups: number[] = []
const options: OpenCode.CreateOptions = {
...hostOptions(directory.path),
instances: {
key: (session) => metadata(session.metadata).account,
configure(key) {
configured.push(key)
const generation = configured.length
return {
plugins: [
{
id: "account-prompts",
async setup(ctx) {
setups.push(generation)
await ctx.session.hook("prompt", (event) => {
event.prompt.text = `${key}/${generation}: ${event.prompt.text}`
})
return () => {
cleanups.push(generation)
}
},
},
],
}
},
},
}
await using first = await OpenCode.create(options)
const created = await first.sessions.create({
id: sessionID,
location: { directory: directory.path },
metadata: { account: "alpha", labels: ["review"] },
})
const before = await first.sessions.prompt({ sessionID, text: "Before restart", resume: false })
expect(before.payload.text).toBe("alpha/1: Before restart")
await first.close()
expect(cleanups).toEqual([1])
await using second = await OpenCode.create(options)
expect(await second.sessions.get({ sessionID })).toMatchObject({
id: sessionID,
location: created.location,
metadata: created.metadata,
time: { created: created.time.created },
})
expect((await second.sessions.list()).data.map((session) => session.id)).toEqual([sessionID])
expect(await second.sessions.inbox.list({ sessionID })).toEqual([before])
expect((await second.message.list({ sessionID })).data).toEqual([])
expect(await second.permission.list({ sessionID })).toEqual([])
expect(await second.form.list({ sessionID })).toEqual([])
expect(configured).toEqual(["alpha"])
expect(setups).toEqual([1])
// The HTTP prompt boundary still acquires capabilities, even when Core reconciles an existing admission.
expect(await second.sessions.prompt({ sessionID, id: before.id, text: "Already admitted", resume: false })).toEqual(
before,
)
expect(configured).toEqual(["alpha", "alpha"])
expect(setups).toEqual([1, 2])
expect(await second.sessions.active()).toEqual({})
const after = await second.sessions.prompt({ sessionID, text: "After restart", resume: false })
expect(after.payload.text).toBe("alpha/2: After restart")
expect(await second.sessions.inbox.list({ sessionID })).toEqual([before, after])
expect(configured).toEqual(["alpha", "alpha"])
expect(setups).toEqual([1, 2])
expect(cleanups).toEqual([1])
await second.close()
expect(cleanups).toEqual([1, 2])
}, 20_000)
test("Promise instance plugin ID collisions reject admission and reconstruct on retry", async () => {
await using directory = await tmpdir("opencode-promise-instance-collision-")
const configured: string[] = []
const setups: string[] = []
await using opencode = await OpenCode.create({
...hostOptions(directory.path),
plugins: [
{
id: "account-prompts",
setup() {
setups.push("host")
},
},
],
instances: {
key: (session) => metadata(session.metadata).account,
configure(key) {
configured.push(key)
return {
plugins: [
{
id: configured.length === 1 ? "account-prompts" : "instance-prompts",
async setup(ctx) {
setups.push(key)
await ctx.session.hook("prompt", (event) => {
event.prompt.text = `${key}: ${event.prompt.text}`
})
},
},
],
}
},
},
})
const session = await opencode.sessions.create({
location: { directory: directory.path },
metadata: { account: "alpha" },
})
const input = { sessionID: session.id, id: SessionMessage.ID.create(), text: "Retry this input", resume: false }
expect(configured).toEqual([])
expect(await opencode.sessions.prompt(input).catch((error: unknown) => error)).toMatchObject({
name: "ClientError",
reason: "UnexpectedStatus",
})
expect(await opencode.sessions.inbox.list({ sessionID: session.id })).toEqual([])
expect((await opencode.message.list({ sessionID: session.id })).data).toEqual([])
expect(configured).toEqual(["alpha"])
expect(setups).toEqual([])
const admitted = await opencode.sessions.prompt(input)
expect(admitted.id).toBe(input.id)
expect(admitted.payload.text).toBe("alpha: Retry this input")
expect(await opencode.sessions.inbox.list({ sessionID: session.id })).toEqual([admitted])
expect(configured).toEqual(["alpha", "alpha"])
expect(setups).toEqual(["host", "alpha"])
}, 20_000)

View file

@ -89,8 +89,14 @@ export function createRoutes(
)
}
export function createEmbeddedRoutes(options: ServerOptions = {}, overrides: LayerNode.Replacements = []) {
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides)
type InstanceLayer = (replacements: LayerNode.Replacements) => Layer.Layer<Instance.Service>
export function createEmbeddedRoutes(
options: ServerOptions = {},
overrides: LayerNode.Replacements = [],
instances?: InstanceLayer,
) {
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides, instances)
}
function makeRoutes<AuthError, AuthServices>(
@ -99,6 +105,7 @@ function makeRoutes<AuthError, AuthServices>(
serviceURLs: () => ReadonlyArray<string>,
// Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win.
overrides: LayerNode.Replacements,
instances?: InstanceLayer,
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const standard: LayerNode.Replacements = [
@ -130,16 +137,24 @@ function makeRoutes<AuthError, AuthServices>(
PluginRuntime.node.replace(PluginRuntime.layerWithCell(pluginRuntimeCell)),
PluginRuntime.providerNode.replace(PluginRuntime.providerNodeWithCell(pluginRuntimeCell)),
]
const replacements: LayerNode.Replacements = [...standard, ...overrides]
const build = (overrides: LayerNode.Replacements) => {
const replacements: LayerNode.Replacements = [
...standard,
// Resolve lazily so private instances inherit the complete host graph, including this selector.
...(instances ? [Instance.byLocationNode.replace(Layer.suspend(() => instances(replacements)))] : []),
...overrides,
]
return AppNodeBuilder.build(applicationServices, replacements)
}
const serviceLayer = options.simulation
? Layer.unwrap(
Effect.gen(function* () {
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
return build([...overrides, ...simulation])
}),
)
: AppNodeBuilder.build(applicationServices, replacements)
: build(overrides)
return serviceLayer.pipe(
Layer.flatMap((context) => {
const services = Layer.succeedContext(context)