diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 096b46d4b7..0c31592247 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -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 = (layer: Layer.Layer) => + 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> export class Service extends Context.Service()("@opencode-ai/sdk-next/OpenCode") {} export const layer = Layer.effect(Service, create()) + +export const layerWith = (options: Options) => Layer.effect(Service, create(options)) diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 01cbec0e66..773801fb40 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -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() 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)