feat(sdk): add ConfigProvider seam to embedded OpenCode hosts

This commit is contained in:
Kit Langton 2026-07-02 08:51:45 -04:00
commit a25ba777ef
2 changed files with 40 additions and 20 deletions

View file

@ -4,14 +4,30 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import { Context, Effect, Layer, Scope } from "effect"
import { ConfigProvider, Context, Effect, Layer, Scope } from "effect"
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
export const create = Effect.fn("OpenCode.create")(function* () {
export interface Options {
/**
* Replaces the ConfigProvider this host's layers read while they are built,
* for example the OPENCODE_DB database placement. The default provider
* snapshots the process environment on first use, so per-host configuration
* must come through this seam rather than env mutation. Compose with
* ConfigProvider.orElse(ConfigProvider.fromEnv()) to keep environment
* fallback.
*/
readonly configProvider?: ConfigProvider.ConfigProvider
}
export const create = Effect.fn("OpenCode.create")(function* (options?: Options) {
const scope = yield* Scope.Scope
const memoMap = yield* Layer.makeMemoMap
const withConfig = <A, E, R>(layer: Layer.Layer<A, E, R>) =>
options?.configProvider === undefined
? layer
: layer.pipe(Layer.provide(ConfigProvider.layer(options.configProvider)))
const context = yield* Layer.buildWithMemoMap(
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, PermissionSaved.node])),
withConfig(AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, PermissionSaved.node]))),
memoMap,
scope,
)
@ -20,9 +36,11 @@ export const create = Effect.fn("OpenCode.create")(function* () {
const web = yield* Effect.acquireRelease(
Effect.sync(() =>
HttpRouter.toWebHandler(
createEmbeddedRoutes().pipe(
HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)),
Layer.provide(HttpServer.layerServices),
withConfig(
createEmbeddedRoutes().pipe(
HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)),
Layer.provide(HttpServer.layerServices),
),
),
{ disableLogger: true, memoMap },
),
@ -47,3 +65,5 @@ export type Interface = Effect.Success<ReturnType<typeof create>>
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk-next/OpenCode") {}
export const layer = Layer.effect(Service, create())
export const layerWith = (options: Options) => Layer.effect(Service, create(options))

View file

@ -1,17 +1,15 @@
import { afterAll, expect, test } from "bun:test"
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect"
import { ConfigProvider, Deferred, Effect, Latch, Option, Schema, Stream } from "effect"
import type { OpenCodeEvent } from "../src"
// The database layer resolves OPENCODE_DB through Effect Config, and the
// default ConfigProvider snapshots the process environment on first use, so
// database placement is process-wide. Point every embedded host in this file
// at one shared temporary database before anything builds a runtime.
const databaseDirectory = await mkdtemp(join(tmpdir(), "opencode-embedded-db-"))
process.env.OPENCODE_DB = join(databaseDirectory, "opencode.sqlite")
afterAll(() => rm(databaseDirectory, { recursive: true, force: true }))
// The default ConfigProvider snapshots the process environment on first use,
// so per-host database placement must come through the create() config seam
// rather than env mutation.
const databaseConfig = (directory: string) =>
ConfigProvider.fromUnknown({ OPENCODE_DB: join(directory, "opencode.sqlite") })
test("embedded client uses the real router and handlers", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
@ -21,7 +19,7 @@ test("embedded client uses the real router and handlers", async () => {
try {
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.create()
const opencode = yield* OpenCode.create({ configProvider: databaseConfig(directory) })
yield* opencode.tools.register({
embedded_tool: Tool.make({
description: "Embedded test tool",
@ -103,6 +101,8 @@ test("embedded client uses the real router and handlers", async () => {
expect(missingMessage._tag).toBe("MessageNotFoundError")
})
await Effect.runPromise(Effect.scoped(program))
// The database materializes at the path this host's ConfigProvider chose.
expect(await Bun.file(join(directory, "opencode.sqlite")).exists()).toBe(true)
} finally {
await rm(directory, { recursive: true, force: true })
}
@ -115,7 +115,7 @@ test("Location-owned runner events reach the ready global client", async () => {
try {
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.create()
const opencode = yield* OpenCode.create({ configProvider: databaseConfig(directory) })
const connected = yield* Latch.make(false)
const prompted = yield* Deferred.make<OpenCodeEvent>()
yield* opencode.events.subscribe().pipe(
@ -151,8 +151,8 @@ test("independent embedded hosts do not share live notifications", async () => {
try {
const program = Effect.gen(function* () {
const first = yield* OpenCode.create()
const second = yield* OpenCode.create()
const first = yield* OpenCode.create({ configProvider: databaseConfig(directory) })
const second = yield* OpenCode.create({ configProvider: databaseConfig(directory) })
const firstReady = yield* Latch.make(false)
const secondReady = yield* Latch.make(false)
const firstEvent = yield* Latch.make(false)
@ -197,7 +197,7 @@ test("embedded client is available as a Layer service", async () => {
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
}).pipe(Effect.provide(OpenCode.layer), Effect.scoped),
}).pipe(Effect.provide(OpenCode.layerWith({ configProvider: databaseConfig(directory) })), Effect.scoped),
)
expect(created.id).toBe(sessionID)