From 346d121ec361e03329375002ed6cf2416d932923 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 28 Aug 2026 23:58:10 -0400 Subject: [PATCH 1/3] refactor(util): make layer graphs opaque and composable --- packages/cli/src/index.ts | 11 +- packages/cli/src/server-process.ts | 11 +- packages/core/src/effect/app-node-builder.ts | 17 +- packages/core/src/instance.ts | 25 +- packages/core/src/persistent-pty/index.ts | 12 +- packages/core/test/agent.test.ts | 4 +- .../core/test/bus-session-routing.test.ts | 2 +- packages/core/test/bus.test.ts | 20 +- packages/core/test/catalog.test.ts | 8 +- packages/core/test/codemode.test.ts | 2 +- .../core/test/codemode/instructions.test.ts | 2 +- packages/core/test/config/command.test.ts | 21 +- packages/core/test/config/compaction.test.ts | 7 +- packages/core/test/config/config.test.ts | 23 +- packages/core/test/config/plugin.test.ts | 11 +- packages/core/test/config/skill.test.ts | 13 +- packages/core/test/config/snapshot.test.ts | 4 +- packages/core/test/config/tool-output.test.ts | 4 +- .../layer-node/layer-node-types.test.ts | 339 +++++---- .../test/effect/layer-node/layer-node.test.ts | 643 ++++++++++++------ .../test/effect/layer-node/node-build.test.ts | 73 +- packages/core/test/file-mutation.test.ts | 4 +- packages/core/test/filesystem/search.test.ts | 34 +- packages/core/test/filesystem/watcher.test.ts | 14 +- .../core/test/fixture/effect-flock-worker.ts | 2 +- packages/core/test/fixture/prompt-location.ts | 6 +- packages/core/test/formatter.test.ts | 2 +- packages/core/test/instance-plugins.test.ts | 6 +- packages/core/test/instance-vanilla.test.ts | 11 +- .../core/test/instruction-discovery.test.ts | 13 +- packages/core/test/instruction-state.test.ts | 2 +- .../core/test/instructions/builtins.test.ts | 4 +- packages/core/test/integration.test.ts | 2 +- .../core/test/location-filesystem.test.ts | 15 +- packages/core/test/location-layer.test.ts | 6 +- packages/core/test/location-mutation.test.ts | 21 +- packages/core/test/location.test.ts | 2 +- packages/core/test/mcp-instructions.test.ts | 5 +- packages/core/test/mcp.test.ts | 26 +- packages/core/test/models.test.ts | 12 +- packages/core/test/npm.test.ts | 2 +- packages/core/test/permission.test.ts | 2 +- packages/core/test/plugin-hooks.test.ts | 6 +- packages/core/test/plugin/command.test.ts | 4 +- packages/core/test/plugin/fixture.ts | 18 +- packages/core/test/plugin/host.test.ts | 10 +- packages/core/test/plugin/models-dev.test.ts | 4 +- packages/core/test/plugin/variant.test.ts | 2 +- .../core/test/plugin/websearch-fixture.ts | 2 +- packages/core/test/pty/pty-session.test.ts | 6 +- packages/core/test/pty/ticket.test.ts | 4 +- .../core/test/reference-instructions.test.ts | 2 +- packages/core/test/reference.test.ts | 2 +- packages/core/test/repository-cache.test.ts | 2 +- packages/core/test/ripgrep.test.ts | 2 +- packages/core/test/session-compact.test.ts | 6 +- packages/core/test/session-compaction.test.ts | 5 +- packages/core/test/session-create.test.ts | 23 +- packages/core/test/session-generate.test.ts | 22 +- .../core/test/session-instructions.test.ts | 14 +- packages/core/test/session-log.test.ts | 6 +- .../core/test/session-message-update.test.ts | 13 +- .../core/test/session-model-request.test.ts | 10 +- packages/core/test/session-move.test.ts | 11 +- packages/core/test/session-owned.test.ts | 7 +- packages/core/test/session-projector.test.ts | 4 +- .../core/test/session-prompt-hooks.test.ts | 10 +- packages/core/test/session-prompt.test.ts | 12 +- packages/core/test/session-remove.test.ts | 6 +- packages/core/test/session-revert.test.ts | 6 +- .../core/test/session-runner-recorded.test.ts | 62 +- .../test/session-runner-tool-events.test.ts | 2 +- packages/core/test/session-runner.test.ts | 34 +- packages/core/test/session-shell.test.ts | 4 +- packages/core/test/session-skill.test.ts | 6 +- packages/core/test/session-step.test.ts | 2 +- packages/core/test/session-title.test.ts | 10 +- .../core/test/session-tool-progress.test.ts | 2 +- packages/core/test/session-view.test.ts | 10 +- packages/core/test/session-wait.test.ts | 5 +- packages/core/test/shell-cleanup.test.ts | 2 +- packages/core/test/skill-discovery.test.ts | 2 +- packages/core/test/skill/instructions.test.ts | 2 +- packages/core/test/snapshot.test.ts | 10 +- packages/core/test/tool-edit.test.ts | 11 +- packages/core/test/tool-output.test.ts | 2 +- packages/core/test/tool-patch.test.ts | 11 +- packages/core/test/tool-question.test.ts | 6 +- packages/core/test/tool-read.test.ts | 16 +- packages/core/test/tool-registry.test.ts | 2 +- packages/core/test/tool-search.test.ts | 10 +- packages/core/test/tool-shell.test.ts | 16 +- packages/core/test/tool-skill.test.ts | 6 +- packages/core/test/tool-subagent.test.ts | 19 +- packages/core/test/tool-webfetch.test.ts | 6 +- packages/core/test/tool-websearch.test.ts | 14 +- packages/core/test/tool-write.test.ts | 11 +- packages/core/test/util/effect-flock.test.ts | 2 +- packages/core/test/vcs-hg.test.ts | 21 +- packages/core/test/vcs.test.ts | 31 +- packages/core/test/workspace.test.ts | 2 +- packages/sdk/src/internal/host.ts | 2 +- packages/sdk/test/embedded.test.ts | 29 +- packages/server/src/routes.ts | 34 +- packages/server/src/workerd.ts | 16 +- packages/server/test/fetch.test.ts | 2 +- packages/server/test/generate.test.ts | 2 +- .../test/session-message-update.test.ts | 9 +- packages/server/test/vcs.test.ts | 5 +- packages/simulation/src/backend/index.ts | 62 +- .../test/simulated-provider.test.ts | 2 +- packages/util/docs/layer-node.md | 155 +++++ packages/util/package.json | 3 +- packages/util/src/effect/app-node.ts | 4 +- packages/util/src/effect/layer-node.ts | 547 +++++++-------- packages/util/tsconfig.dist.json | 10 + 116 files changed, 1676 insertions(+), 1207 deletions(-) create mode 100644 packages/util/docs/layer-node.md create mode 100644 packages/util/tsconfig.dist.json diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bbc4595b077e..a24f56386db7 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -98,12 +98,13 @@ Effect.gen(function* () { Effect.provide(Config.layer), Effect.provide(Updater.layer), Effect.provide( - LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [ - [ - Global.node, - Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), + LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), { + replacements: [ + Global.node.replace( + Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), + ), ], - ]), + }), ), Effect.provide( Observability.layer({ diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index c81e4b44b0d6..303046e1ec30 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -30,12 +30,13 @@ export const run = Effect.fnUntraced(function* (options: Options) { return yield* processEffect(options).pipe( Effect.provide(Updater.layer), Effect.provide( - LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [ - [ - Global.node, - Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), + LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), { + replacements: [ + Global.node.replace( + Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), + ), ], - ]), + }), ), Effect.provide(NodeServices.layer), ) diff --git a/packages/core/src/effect/app-node-builder.ts b/packages/core/src/effect/app-node-builder.ts index b41e1f56d268..167131636e6d 100644 --- a/packages/core/src/effect/app-node-builder.ts +++ b/packages/core/src/effect/app-node-builder.ts @@ -1,20 +1,11 @@ import { buildLocationServiceMap } from "../location-services.js" import { LocationServiceMap } from "../location-service-map.js" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" -export function build(root: LayerNode.Node, replacements: LayerNode.Replacements = []) { - // Only build the location service map if it's actually needed - if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node)) - return LayerNode.compile(root, replacements) - - const locationMap = buildLocationServiceMap(replacements) - const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] }) - return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]])) -} - -function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node) { - return replacements.some(([source]) => source.name === node.name) +export function build(root: LayerNode.Graph, replacements: LayerNode.Replacements = []) { + return LayerNode.compile(root, { + replacements: [LocationServiceMap.node.replace(buildLocationServiceMap(replacements)), ...replacements], + }) } export * as AppNodeBuilder from "./app-node-builder.js" diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 5d0aedb4456d..eeb97e3ab761 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -108,9 +108,9 @@ const nodes = [ Vcs.node, // Start repository watches only after boot-critical filesystem and Git work. LocationWatcher.node, -] as const satisfies readonly Node.LocationNode[] +] as const satisfies readonly Node.LocationGraph[] -export const graph = LayerNode.group(nodes) +export const graph = LayerNode.group(nodes) export type Services = LayerNode.Output export type Error = LayerNode.Error @@ -139,29 +139,23 @@ export interface Options { // source still honors explicit plugin operations from wellknown and // host-injected config. const vanillaReplacements: LayerNode.Replacements = [ - [Config.node, Config.configured({ project: false, global: false })], - [InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })], + Config.node.replace(Config.configured({ project: false, global: false })), + InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: false, global: false })), ] // One instance is one compiled, fresh copy of the graph standing on a directory. export function layer(ref: Location.Ref, options: Options = {}) { const startedAt = performance.now() // Ordered: vanilla defaults, then caller replacements (which win over the - // defaults), then bound pairs (which win over everything). - const allReplacements: LayerNode.Replacements = [ + // defaults), then instance bindings (which win over everything). + const replacements: LayerNode.Replacements = [ ...(options.discovery === false ? vanillaReplacements : []), ...(options.replacements ?? []), - [Location.node, Location.boundNode(ref, { discovery: options.discovery })], - [InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])], + Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })), + InstancePlugins.node.replace(InstancePlugins.bound(options.plugins ?? [])), ] - // Apply replacements during hoist, not afterward: replacements can - // introduce new tagged dependencies (Location.boundNode depends on - // Project), and the hoist walk is the only pass that can still slice - // those back out. - const location = LayerNode.hoist(graph, Node.tags.values.global, allReplacements) - return LayerNode.compile(location.node).pipe( - Layer.fresh, + return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe( Layer.tap(() => Effect.logInfo("location services booted", { directory: ref.directory, @@ -169,6 +163,5 @@ export function layer(ref: Location.Ref, options: Options = {}) { durationMs: Math.round(performance.now() - startedAt), }), ), - Layer.provide(LayerNode.compile(location.hoisted)), ) } diff --git a/packages/core/src/persistent-pty/index.ts b/packages/core/src/persistent-pty/index.ts index 0f316b69cb75..b898c66f76ba 100644 --- a/packages/core/src/persistent-pty/index.ts +++ b/packages/core/src/persistent-pty/index.ts @@ -112,7 +112,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/PersistentPty") {} -export const configured = (options: Options = {}) => +const makeLayer = (options: Options = {}) => Layer.effect( Service, Effect.gen(function* () { @@ -361,8 +361,14 @@ export const configured = (options: Options = {}) => }), ) -export const layer = configured() -export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] }) +export const layer = makeLayer() +export const configured = (options?: Options) => + makeGlobalNode({ + service: Service, + layer: options === undefined ? layer : makeLayer(options), + deps: [Bus.node, Global.node], + }) +export const node = configured() const request = (daemon: DaemonTransport, value: object, start = false) => daemon.request(value, start).pipe(Effect.mapError(unavailable)) diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 2f2876021165..1f18cbdc72a8 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -22,8 +22,8 @@ const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global)) const it = testEffect( AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [ - [Global.node, globalLayer], - [Location.node, locationLayer], + Global.node.replace(globalLayer), + Location.node.replace(locationLayer), ]) as unknown as Layer.Layer, ) diff --git a/packages/core/test/bus-session-routing.test.ts b/packages/core/test/bus-session-routing.test.ts index 891cf2753c0b..c3cf7b78c0ed 100644 --- a/packages/core/test/bus-session-routing.test.ts +++ b/packages/core/test/bus-session-routing.test.ts @@ -21,7 +21,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), ) const a = Location.Ref.make({ directory: AbsolutePath.make("/a") }) diff --git a/packages/core/test/bus.test.ts b/packages/core/test/bus.test.ts index 4820b53887c0..26f638986c44 100644 --- a/packages/core/test/bus.test.ts +++ b/packages/core/test/bus.test.ts @@ -100,12 +100,14 @@ const tail = (bus: Bus.Interface, input: { aggregateID: string; after?: number } const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Location.node]), [ - [Location.node, locationLayer], - [Bus.node, Bus.configured({ persist: true })], + Location.node.replace(locationLayer), + Bus.node.replace(Bus.configured({ persist: true })), ]), ) const itWithoutLocation = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]), + AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [ + Bus.node.replace(Bus.configured({ persist: true })), + ]), ) const itWithoutPersistence = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]))) @@ -631,8 +633,7 @@ describe("Bus", () => { const continueRead = yield* Deferred.make() let pause = true const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [ - [ - Bus.node, + Bus.node.replace( Bus.configured({ persist: true, beforeAggregateRead: () => @@ -640,7 +641,7 @@ describe("Bus", () => { ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) : Effect.void, }), - ], + ), ]) yield* Effect.gen(function* () { @@ -1318,7 +1319,7 @@ describe("Bus", () => { it.effect("log replays across configured read pages", () => Effect.gen(function* () { const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [ - [Bus.node, Bus.configured({ persist: true, logReadPageSize: 2 })], + Bus.node.replace(Bus.configured({ persist: true, logReadPageSize: 2 })), ]) yield* Effect.gen(function* () { @@ -1351,8 +1352,7 @@ describe("Bus", () => { const releaseRead = yield* Deferred.make() const firstRead = yield* Ref.make(true) const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [ - [ - Bus.node, + Bus.node.replace( Bus.configured({ persist: true, beforeAggregateRead: () => @@ -1363,7 +1363,7 @@ describe("Bus", () => { }), ), }), - ], + ), ]) yield* Effect.gen(function* () { diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index ab670be22c98..ff6edcc4ac6d 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -25,7 +25,7 @@ const locationLayer = Layer.succeed( ) const catalogLayer = AppNodeBuilder.build( LayerNode.group([Catalog.node, Bus.node, Credential.node, Integration.node]), - [[Location.node, locationLayer]], + [Location.node.replace(locationLayer)], ) const it = testEffect(catalogLayer) @@ -48,7 +48,7 @@ describe("Catalog", () => { it.effect("derives availability from active credentials without changing provider state", () => { const integrationID = Integration.ID.make("test") const localCatalogLayer = Layer.fresh( - AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]), + AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [Location.node.replace(locationLayer)]), ) return Effect.gen(function* () { @@ -78,7 +78,7 @@ describe("Catalog", () => { const providerID = Provider.ID.make("remote") const localCatalogLayer = Layer.fresh( AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [ - [Location.node, locationLayer], + Location.node.replace(locationLayer), ]), ) @@ -108,7 +108,7 @@ describe("Catalog", () => { const providerID = Provider.ID.make("remote") const localCatalogLayer = Layer.fresh( AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [ - [Location.node, locationLayer], + Location.node.replace(locationLayer), ]), ) diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index 6525f17af1f0..9a6d9fd95681 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -35,7 +35,7 @@ describe("CodeMode", () => { Effect.scoped, Effect.provide( AppNodeBuilder.build(Tool.node, [ - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), ]), ), ), diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index 29f1250223e3..5d9e72150cce 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -85,7 +85,7 @@ describe("CodeModeInstructions", () => { execute: () => Effect.succeed({ output: "zeta" }), } const layer = AppNodeBuilder.build(Tool.node, [ - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), ]) return Effect.gen(function* () { diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index c2e9db416761..8bc526b45b8f 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -43,10 +43,10 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]), [ - [Mcp.node, emptyMcpLayer], - [Config.node, emptyConfigLayer], - [Location.node, testLocationLayer], - [ShellSelect.node, shellLayer], + Mcp.node.replace(emptyMcpLayer), + Config.node.replace(emptyConfigLayer), + Location.node.replace(testLocationLayer), + ShellSelect.node.replace(shellLayer), ], ), ) @@ -340,17 +340,16 @@ describeNative("ConfigCommandPlugin native watcher", () => { ShellSelect.node, ]), [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })), ), - ], - [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })], - [ShellSelect.node, shellLayer], - [Credential.node, emptyCredentialNode], - [WellKnown.node, emptyWellknownNode], + ), + Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })), + ShellSelect.node.replace(shellLayer), + Credential.node.replace(emptyCredentialNode), + WellKnown.node.replace(emptyWellknownNode), ], ), ), diff --git a/packages/core/test/config/compaction.test.ts b/packages/core/test/config/compaction.test.ts index 938ee7fe1a32..744c8cce88bc 100644 --- a/packages/core/test/config/compaction.test.ts +++ b/packages/core/test/config/compaction.test.ts @@ -40,13 +40,12 @@ const it = testEffect( Layer.merge( config, AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [ - [ - llmClient, + llmClient.replace( Layer.mock(LLMClient.Service)({ stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })), }), - ], - [Config.node, config], + ), + Config.node.replace(config), ]), ), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 67344a94247d..ff64d034fe46 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -55,12 +55,12 @@ function testLayer( ), ) const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [ - [Config.node, Config.configured(options)], - [Location.node, locationLayer], - [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })], - [Credential.node, credentialNode], - [WellKnown.node, wellknownNode], - [Watcher.node, watcher], + Config.node.replace(Config.configured(options)), + Location.node.replace(locationLayer), + Global.node.replace(Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })), + Credential.node.replace(credentialNode), + WellKnown.node.replace(wellknownNode), + Watcher.node.replace(watcher), ]) // Merge the watcher layer by reference so Watcher.Test resolves to the same // memoized instance the built graph uses. @@ -311,16 +311,15 @@ describe("Config", () => { }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(project) })), ), - ], - [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })], - [Credential.node, emptyCredentialNode], - [WellKnown.node, emptyWellknownNode], + ), + Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })), + Credential.node.replace(emptyCredentialNode), + WellKnown.node.replace(emptyWellknownNode), ]), ), ) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index b42e1c008a67..cd3f3f3826ea 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -28,13 +28,13 @@ import { testEffect } from "../lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], + Global.node.replace(tempGlobalLayer), ]), ) const staticIt = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [ConfigPluginSource.node, ConfigPluginSource.empty], - [Global.node, tempGlobalLayer], + ConfigPluginSource.node.replace(ConfigPluginSource.empty), + Global.node.replace(tempGlobalLayer), ]), ) const refreshNpm = makeGlobalNode({ @@ -65,10 +65,7 @@ const refreshNpm = makeGlobalNode({ const refreshIt = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]), - [ - [Global.node, tempGlobalLayer], - [Npm.node, refreshNpm], - ], + [Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)], ), ) diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index e59eae8122cf..4213e7d58941 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -86,14 +86,13 @@ const discover = (directory: string, global: string) => }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [ - [ - Location.node, + Location.node.replace( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - ], - [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })], - [Credential.node, emptyCredentialNode], - [WellKnown.node, emptyWellknownNode], - [Watcher.node, Watcher.testLayer], + ), + Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })), + Credential.node.replace(emptyCredentialNode), + WellKnown.node.replace(emptyWellknownNode), + Watcher.node.replace(Watcher.testLayer), ]), ), ) diff --git a/packages/core/test/config/snapshot.test.ts b/packages/core/test/config/snapshot.test.ts index bd54b29fed0d..42e7489afa3b 100644 --- a/packages/core/test/config/snapshot.test.ts +++ b/packages/core/test/config/snapshot.test.ts @@ -51,8 +51,8 @@ describe("ConfigSnapshotPlugin.Plugin", () => { }).pipe( Effect.provide( AppNodeBuilder.build(Snapshot.node, [ - [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))], - [Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })], + Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), + Global.node.replace(Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })), ]), ), ) diff --git a/packages/core/test/config/tool-output.test.ts b/packages/core/test/config/tool-output.test.ts index b6920a97766b..a8cb7544130f 100644 --- a/packages/core/test/config/tool-output.test.ts +++ b/packages/core/test/config/tool-output.test.ts @@ -44,7 +44,9 @@ describe("ConfigToolOutputPlugin.Plugin", () => { } yield* Effect.die(new Error("Timed out waiting for tool output config reload")) }).pipe( - Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])), + Effect.provide( + AppNodeBuilder.build(ToolOutput.node, [Global.node.replace(Global.layerWith({ data: tmp.path }))]), + ), ), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ).pipe( diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts index 73f662eb3168..d1208b28baad 100644 --- a/packages/core/test/effect/layer-node/layer-node-types.test.ts +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -13,131 +13,218 @@ class OtherError { readonly _tag = "OtherError" } -const tags = LayerNode.tags({ app: [] }) -const make = tags.make("app") -const build = (root: LayerNode.Node) => LayerNode.compile(root) as Layer.Layer -const aLayer = Layer.succeed(A, A.of({})) -const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) -const cLayer = Layer.effect( - C, - Effect.gen(function* () { - yield* A - yield* B - return C.of({}) - }), -) -const failingA = Layer.effect(A, Effect.fail(new LayerError())) -const a = make({ service: A, layer: aLayer, deps: [] }) -const b = make({ service: B, layer: bLayer, deps: [a] }) -const c = make({ service: C, layer: cLayer, deps: [a, b] }) -const failing = make({ service: A, layer: failingA, deps: [] }) -const dependent = make({ service: B, layer: bLayer, deps: [failing] }) -const inputA = LayerNode.unbound(A, tags.values.app) -const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] }) - -make({ name: "manual-a", layer: aLayer, deps: [] }) - -// @ts-expect-error A node must have a service or name -make({ layer: aLayer, deps: [] }) - -// @ts-expect-error Service and name are mutually exclusive -make({ service: A, name: "a", layer: aLayer, deps: [] }) - -// @ts-expect-error B requires A -make({ service: B, layer: bLayer, deps: [] }) - -// @ts-expect-error C requires A and B -make({ service: C, layer: cLayer, deps: [a] }) - -const closed = build(LayerNode.group([c])) -const closedWithError = build(LayerNode.group([dependent])) -const checkClosed: Layer.Layer = closed -const checkError: Layer.Layer = closedWithError -void checkClosed -void checkError - -LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]]) -LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]]) - -// @ts-expect-error Replacement must provide A -LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]]) - -// @ts-expect-error Node replacement must provide A -const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]]) -void invalidNodeReplacement - -// @ts-expect-error Replacement cannot introduce a new error -LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]]) - -const invalidNodeErrorReplacement = () => +// Keep intentionally invalid expressions out of runtime execution. +const contracts = (tag: LayerNode.Tag<"app"> | LayerNode.Tag<"other">, flag: boolean) => { + const tags = LayerNode.tags({ app: [] }) + const make = tags.make("app") + const aLayer = Layer.succeed(A, A.of({})) + const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) + const cLayer = Layer.effect( + C, + Effect.gen(function* () { + yield* A + yield* B + return C.of({}) + }), + ) + const a = make({ service: A, layer: aLayer, deps: [] }) + const b = make({ service: B, layer: bLayer, deps: [a] }) + const c = make({ service: C, layer: cLayer, deps: [a, b] }) + const ab = make({ name: "a-and-b", layer: Layer.mergeAll(aLayer, Layer.succeed(B, {})), deps: [] }) + const failing = make({ service: A, layer: Layer.effect(A, Effect.fail(new LayerError())), deps: [] }) + const dependent = make({ service: B, layer: bLayer, deps: [failing] }) + const inputA = LayerNode.unbound(A, tags.values.app) + const group = LayerNode.group([a, b]) + + make({ name: "manual-a", layer: aLayer, deps: [] }) + // @ts-expect-error A node must have a service or name + make({ layer: aLayer, deps: [] }) + // @ts-expect-error Service and name are mutually exclusive + make({ service: A, name: "a", layer: aLayer, deps: [] }) + // @ts-expect-error An explicit tagged contract requires a corresponding runtime tag + LayerNode.make({ service: A, layer: aLayer, deps: [] }) + // @ts-expect-error B requires A + make({ service: B, layer: bLayer, deps: [] }) + // @ts-expect-error C requires A and B + make({ service: C, layer: cLayer, deps: [a] }) + const erasedLayer: Layer.Any = bLayer + // @ts-expect-error Erasing a Layer's contract cannot hide its inputs and errors + make({ service: B, layer: erasedLayer, deps: [] }) + + LayerNode.compile(c) satisfies Layer.Layer + LayerNode.compile(dependent) satisfies Layer.Layer + LayerNode.compile(group) satisfies Layer.Layer + LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer + // @ts-expect-error An empty graph cannot supply arbitrary services + LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer + LayerNode.compile(inputA, { replacements: [inputA.replace(a)] }) satisfies Layer.Layer + // @ts-expect-error A is a private dependency, not a root output + LayerNode.compile(c) satisfies Layer.Layer + // @ts-expect-error Dependency failures are not erased + LayerNode.compile(dependent) satisfies Layer.Layer + + const replacements: LayerNode.Replacements = [a.replace(aLayer), a.replace(ab), failing.replace(a)] + const replacement: LayerNode.Replacement = a.replace(Layer.mergeAll(aLayer, Layer.succeed(B, {}))) + LayerNode.compile(a, { replacements: [...replacements, replacement] }) + inputA.replace(a) + a.replace(a) + // @ts-expect-error Closed layer replacements must provide every source output + ab.replace(aLayer) + // @ts-expect-error Node replacements must provide every source output + ab.replace(a) + // @ts-expect-error Replacement must provide A + a.replace(Layer.succeed(B, {})) + // @ts-expect-error Node replacement must provide A + a.replace(b) + // @ts-expect-error Raw layers with inputs are not closed + a.replace(Layer.effect(A, Effect.as(B, A.of({})))) + // @ts-expect-error Replacement cannot introduce a new error + a.replace(Layer.effect(A, Effect.fail(new OtherError()))) // @ts-expect-error Node replacement cannot introduce a new error - LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]]) -void invalidNodeErrorReplacement - -class TagA extends Context.Service()("test/TagA") {} -class TagB extends Context.Service()("test/TagB") {} -class TagC extends Context.Service()("test/TagC") {} - -const scopedTags = LayerNode.tags({ request: ["global"], global: [] }) -const request = scopedTags.make("request") -const global = scopedTags.make("global") -const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) -const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) -const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] }) -const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({}))) -const tagCLayer = Layer.effect( - TagC, - Effect.gen(function* () { - yield* TagA - yield* TagB - return TagC.of({}) - }), -) - -request({ service: TagB, layer: tagBLayer, deps: [globalA] }) -request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] }) -request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] }) - -// @ts-expect-error Tag configuration can only reference declared tags -LayerNode.tags({ request: ["missing"], global: [] }) - -// @ts-expect-error An unrelated dependency cannot satisfy TagA -request({ service: TagB, layer: tagBLayer, deps: [requestB] }) - -// @ts-expect-error Providing only TagA leaves TagB missing -request({ service: TagC, layer: tagCLayer, deps: [globalA] }) - -// @ts-expect-error Providing only TagB leaves TagA missing -request({ service: TagC, layer: tagCLayer, deps: [requestB] }) - -// @ts-expect-error Duplicate TagA providers still leave TagB missing -request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] }) - -// @ts-expect-error A group with only TagA still leaves TagB missing -request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] }) - -// @ts-expect-error Global cannot depend on request -global({ service: TagB, layer: tagBLayer, deps: [requestA] }) - -// @ts-expect-error Groups preserve their child tags -global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] }) - -class ScopedA extends Context.Service()("test/ScopedA") {} -class ScopedB extends Context.Service()("test/ScopedB") {} - -const scopedA = Layer.succeed(ScopedA, ScopedA.of({})) -const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({}))) -const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] }) -const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] }) - -makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) -makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) -makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) - -// @ts-expect-error Global nodes cannot depend on location nodes -makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) - -// @ts-expect-error ScopedB requires ScopedA -makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] }) + a.replace(failing) + // @ts-expect-error Existing errors do not authorize unrelated replacement errors + failing.replace(Layer.effect(A, Effect.fail(new OtherError()))) + // @ts-expect-error Every alternative of a node replacement must supply A + a.replace(flag ? a : b) + // @ts-expect-error Every alternative of a raw-layer replacement must supply A + a.replace(flag ? aLayer : Layer.succeed(B, {})) + // @ts-expect-error A valid alternative cannot hide a new error in another alternative + a.replace(flag ? a : failing) + a.replace(flag ? a : ab) + failing.replace(flag ? a : failing) + // @ts-expect-error Storing replacements must not erase their validation + const invalidStored: LayerNode.Replacements = [a.replace(b)] + // @ts-expect-error Raw tuples cannot be stored as opaque replacements + const rawStored: LayerNode.Replacements = [[a, aLayer]] + // @ts-expect-error Raw tuples cannot be supplied to compile + LayerNode.compile(a, { replacements: [[a, aLayer]] }) + // @ts-expect-error Replacements are not structurally forgeable + const forged: LayerNode.Replacement = { source: a, target: a } + // @ts-expect-error Groups are not replaceable nodes + group.replace(a) + // @ts-expect-error Groups cannot be replacement targets + a.replace(group) + // @ts-expect-error Groups cannot be widened to nodes + const groupNode: LayerNode.Node = group + // @ts-expect-error Graphs are opaque + const forgedGraph: LayerNode.Graph = { name: "a" } + + const aContract: LayerNode.Node = a + aContract.replace(aLayer) + // @ts-expect-error A method cannot be rebound to a declaration with a stronger contract + a.replace.call(ab, aLayer) + const detached = a.replace + // @ts-expect-error Replacement authority requires its checked receiver + detached(aLayer) + // @ts-expect-error Output narrowing cannot forget B before replacement + const narrowedOutput: LayerNode.Node = ab + // @ts-expect-error Output widening cannot add B before replacement + const widenedOutput: LayerNode.Node = a + // @ts-expect-error Error widening cannot authorize a new replacement error + const widenedError: LayerNode.Node = a + // @ts-expect-error Error narrowing cannot forget an existing failure + const narrowedError: LayerNode.Node = failing + // @ts-expect-error Tag widening cannot authorize replacement across tags + const widenedTag: LayerNode.Node = a + const unionTag = LayerNode.unbound(A, tag) + // @ts-expect-error Tag narrowing cannot forget a possible tag + const narrowedTag: LayerNode.Node = unionTag + + const outputProjection: LayerNode.Graph = group + // @ts-expect-error Graph output projection cannot invent a service + const widenedGraph: LayerNode.Graph = a + // @ts-expect-error A projected Graph has no replacement authority + outputProjection.replace(aLayer) + + const choice = flag ? a : b + // @ts-expect-error Choosing one dependency does not provide both services + make({ service: C, layer: cLayer, deps: [choice] }) + // @ts-expect-error A conditional root promises only outputs present in every alternative + LayerNode.compile(LayerNode.group([choice])) satisfies Layer.Layer + const conditional = make({ name: "conditional", layer: flag ? aLayer : Layer.succeed(B, {}), deps: [] }) + LayerNode.compile(conditional) satisfies Layer.Layer + // @ts-expect-error A conditional implementation does not acquire both branches + LayerNode.compile(conditional) satisfies Layer.Layer + LayerNode.compile(LayerNode.group([flag ? a : ab])) satisfies Layer.Layer + const dynamic: Array = [] + // @ts-expect-error An unbounded array may contain no roots + LayerNode.compile(LayerNode.group(dynamic)) satisfies Layer.Layer + + const decorated = b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.void))) + LayerNode.compile(decorated) satisfies Layer.Layer + b.replace(decorated) + // @ts-expect-error A layer mapper cannot be rebound to a weaker declaration + ab.mapLayer.call(a, (layer) => layer) + // @ts-expect-error mapLayer cannot add an input requirement + b.mapLayer((layer) => layer.pipe(Layer.tap(() => C))) + // @ts-expect-error mapLayer cannot grow the error channel + b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.fail(new OtherError())))) + // @ts-expect-error mapLayer cannot drop an output + ab.mapLayer(() => aLayer) + // @ts-expect-error Unbound declarations have no implementation to map + inputA.mapLayer((layer: Layer.Layer) => layer) + + const scopedTags = LayerNode.tags({ request: ["global"], global: [] }) + const request = scopedTags.make("request") + const global = scopedTags.make("global") + const globalA = global({ service: A, layer: aLayer, deps: [] }) + const requestA = request({ service: A, layer: aLayer, deps: [] }) + const requestB = request({ service: B, layer: Layer.succeed(B, {}), deps: [] }) + request({ service: B, layer: bLayer, deps: [globalA] }) + request({ service: C, layer: cLayer, deps: [globalA, requestB] }) + request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA, requestB])] }) + LayerNode.compile(LayerNode.group([globalA, requestB]), { shared: scopedTags.values.global }) satisfies Layer.Layer< + A | B + > + // @ts-expect-error Tag configuration can only reference declared tags + LayerNode.tags({ request: ["missing"], global: [] }) + // @ts-expect-error Shared tags must be branded + LayerNode.compile(globalA, { shared: "global" }) + // @ts-expect-error Replacement targets must keep the source tag + globalA.replace(requestA) + // @ts-expect-error Replacement targets must keep the source tag in either direction + requestA.replace(globalA) + // @ts-expect-error Every alternative must keep the source tag + globalA.replace(flag ? globalA : requestA) + // @ts-expect-error Providing only A leaves B missing + request({ service: C, layer: cLayer, deps: [globalA] }) + // @ts-expect-error Providing only B leaves A missing + request({ service: C, layer: cLayer, deps: [requestB] }) + // @ts-expect-error Duplicate A providers still leave B missing + request({ service: C, layer: cLayer, deps: [globalA, requestA] }) + // @ts-expect-error A group with only A still leaves B missing + request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA])] }) + // @ts-expect-error Global cannot depend on request + global({ service: B, layer: bLayer, deps: [requestA] }) + // @ts-expect-error Groups preserve their child tags + global({ service: B, layer: bLayer, deps: [LayerNode.group([requestA])] }) + + const globalScopedA = makeGlobalNode({ service: A, layer: aLayer, deps: [] }) + const locationScopedA = makeLocationNode({ service: A, layer: aLayer, deps: [] }) + makeGlobalNode({ service: B, layer: bLayer, deps: [globalScopedA] }) + makeLocationNode({ service: B, layer: bLayer, deps: [globalScopedA] }) + makeLocationNode({ service: B, layer: bLayer, deps: [locationScopedA] }) + // @ts-expect-error Global nodes cannot depend on location nodes + makeGlobalNode({ service: B, layer: bLayer, deps: [locationScopedA] }) + // @ts-expect-error B requires A + makeLocationNode({ service: B, layer: bLayer, deps: [] }) + + void [ + invalidStored, + rawStored, + forged, + groupNode, + forgedGraph, + narrowedOutput, + widenedOutput, + widenedError, + narrowedError, + widenedTag, + narrowedTag, + widenedGraph, + ] +} -test("type exploration compiles", () => {}) +test("layer node type contracts compile", () => { + void contracts +}) diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts index 5c36f20255bc..ec5303190481 100644 --- a/packages/core/test/effect/layer-node/layer-node.test.ts +++ b/packages/core/test/effect/layer-node/layer-node.test.ts @@ -1,19 +1,21 @@ import { describe, expect, test } from "bun:test" -import { Context, Effect, Layer } from "effect" +import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, Option } from "effect" import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { testEffect } from "../../lib/effect" class Value extends Context.Service()("test/LayerNodeValue") {} class Greeting extends Context.Service()("test/LayerNodeGreeting") {} class Left extends Context.Service()("test/LayerNodeLeft") {} class Right extends Context.Service()("test/LayerNodeRight") {} -class Database extends Context.Service()("test/GraphDatabase") {} -class Users extends Context.Service }>()("test/GraphUsers") {} -class App extends Context.Service }>()("test/GraphApp") {} +class Memo extends Context.Service()("test/LayerNodeMemo") {} +class Support extends Context.Service()("test/LayerNodeSupport") {} +class Locations extends Context.Service>()( + "test/LayerNodeLocations", +) {} +const it = testEffect(Layer.empty) const tags = LayerNode.tags({ app: [] }) const make = tags.make("app") -const build = (root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) => - LayerNode.compile(root, replacements) as Layer.Layer const valueLayer = Layer.succeed(Value, Value.of({ value: "production" })) const greetingLayer = Layer.effect( Greeting, @@ -23,240 +25,443 @@ const value = make({ service: Value, layer: valueLayer, deps: [] }) const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] }) describe("layer node", () => { - test("builds an untagged graph", async () => { - const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] }) - const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] }) - const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(LayerNode.compile(LayerNode.group([greeting]))), - ) - expect(await Effect.runPromise(program)).toBe("hello production") - }) + it.effect("builds an untagged graph", () => + Effect.gen(function* () { + const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] }) + const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] }) + const result = yield* Greeting.pipe(Effect.provide(LayerNode.compile(LayerNode.group([greeting])))) + expect(result.value).toBe("hello production") + }), + ) - test("builds a dependency graph", async () => { - const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting])))) - expect(await Effect.runPromise(program)).toBe("hello production") - }) + it.effect("exposes roots but hides transitive dependencies", () => + Effect.gen(function* () { + const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting]))) + expect(Context.get(context, Greeting).value).toBe("hello production") + expect(Option.isNone(Context.getOption(context, Value))).toBe(true) + }), + ) - test("exposes roots but hides transitive dependencies", () => { - const layer = build(LayerNode.group([greeting])) - const check: Layer.Layer = layer - void check - }) + it.effect("replaces exact declarations, not sibling names or native layer identities", () => + Effect.gen(function* () { + const sibling = make({ service: Value, layer: valueLayer, deps: [] }) + const target = make({ name: "different-name", layer: Layer.succeed(Value, { value: "replaced" }), deps: [] }) + const left = make({ + service: Left, + layer: Layer.effect( + Left, + Effect.map(Value, (item) => Left.of({ value: item.value })), + ), + deps: [value], + }) + const right = make({ + service: Right, + layer: Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ), + deps: [sibling], + }) + const context = yield* Layer.build( + LayerNode.compile(LayerNode.group([left, right]), { replacements: [value.replace(target)] }), + ) + expect(Context.get(context, Left).value).toBe("replaced") + expect(Context.get(context, Right).value).toBe("production") + }), + ) - test("preserves branch-specific implementations across roots", async () => { - const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] }) - const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) - const leftLayer = Layer.effect( - Left, - Effect.map(Value, (item) => Left.of({ value: item.value })), - ) - const rightLayer = Layer.effect( - Right, - Effect.map(Value, (item) => Right.of({ value: item.value })), - ) - const left = make({ service: Left, layer: leftLayer, deps: [firstValue] }) - const right = make({ service: Right, layer: rightLayer, deps: [secondValue] }) - const layer = build(LayerNode.group([left, right])) - const program = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(layer)) - expect(await Effect.runPromise(program)).toEqual(["first", "second"]) - }) + it.effect("requires reachable unbound nodes to be replaced", () => + Effect.gen(function* () { + const unbound = LayerNode.unbound(Value, tags.values.app) + const root = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) + expect(() => LayerNode.compile(root)).toThrow("Unbound layer node: test/LayerNodeValue") + const result = yield* Greeting.pipe( + Effect.provide(LayerNode.compile(root, { replacements: [unbound.replace(value)] })), + ) + expect(result.value).toBe("hello production") + }), + ) - test("requires unbound nodes to be replaced before compilation", async () => { - const unbound = LayerNode.unbound(Value, tags.values.app) - const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) - const tree = LayerNode.group([greeting]) - expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue") - const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer - const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer)) - expect(await Effect.runPromise(program)).toBe("hello production") - }) + it.effect("replaces every use of a declaration with a stored closed-layer replacement", () => + Effect.gen(function* () { + const replacements: LayerNode.Replacements = [value.replace(Layer.succeed(Value, { value: "replacement" }))] + const right = make({ + service: Right, + layer: Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ), + deps: [value], + }) + const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting, right]), { replacements })) + expect(Context.get(context, Greeting).value).toBe("hello replacement") + expect(Context.get(context, Right).value).toBe("replacement") + }), + ) - test("replaces a node with a closed layer", async () => { - const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) - const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])), - ) - expect(await Effect.runPromise(program)).toBe("hello simulation") - }) + it.effect("uses the last replacement and ignores unreachable unbound defaults and cycles", () => + Effect.gen(function* () { + const unbound = LayerNode.unbound(Value, tags.values.app) + const unused = make({ service: Value, layer: valueLayer, deps: [] }) + const result = yield* Greeting.pipe( + Effect.provide( + LayerNode.compile(greeting, { + replacements: [ + value.replace(unbound), + unbound.replace(unused), + unused.replace(unbound), + value.replace(Layer.succeed(Value, { value: "last" })), + ], + }), + ), + ) + expect(result.value).toBe("hello last") + }), + ) - test("replaces every use of the same layer", async () => { - const leftLayer = Layer.effect( - Left, - Effect.map(Value, (item) => Left.of({ value: item.value })), - ) - const rightLayer = Layer.effect( - Right, - Effect.map(Value, (item) => Right.of({ value: item.value })), - ) - const left = make({ service: Left, layer: leftLayer, deps: [value] }) - const right = make({ service: Right, layer: rightLayer, deps: [value] }) - const replacement = Layer.succeed(Value, Value.of({ value: "replaced" })) - const layer = build(LayerNode.group([left, right]), [[value, replacement]]) - const program = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(layer)) - expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"]) - }) + it.effect("resolves target chains independently of replacement order and treats self-replacement as identity", () => + Effect.gen(function* () { + const middle = make({ service: Value, layer: Layer.succeed(Value, { value: "middle" }), deps: [] }) + const target = make({ service: Value, layer: Layer.succeed(Value, { value: "target" }), deps: [] }) + const result = yield* Greeting.pipe( + Effect.provide( + LayerNode.compile(greeting, { + replacements: [target.replace(target), middle.replace(target), value.replace(middle)], + }), + ), + ) + expect(result.value).toBe("hello target") + }), + ) - test("does not acquire an unused replacement", async () => { - let acquisitions = 0 - const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] }) - const replacement = Layer.effect( - Left, - Effect.sync(() => { - acquisitions++ - return Left.of({ value: "replacement" }) - }), + test("rejects reachable replacement and dependency cycles", () => { + const other = make({ service: Value, layer: valueLayer, deps: [] }) + expect(() => LayerNode.compile(greeting, { replacements: [value.replace(other), other.replace(value)] })).toThrow( + "Cycle detected in layer graph", ) - await Effect.runPromise( - Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])), + const dependent = make({ + service: Value, + layer: Layer.effect( + Value, + Effect.map(Greeting, (item) => Value.of({ value: item.value })), ), + deps: [greeting], + }) + expect(() => LayerNode.compile(greeting, { replacements: [value.replace(dependent)] })).toThrow( + "Cycle detected in layer graph", ) - expect(acquisitions).toBe(0) }) - test("replaces a node without acquiring its dependencies", async () => { - let acquisitions = 0 - const dependencyLayer = Layer.effect( - Value, - Effect.sync(() => { - acquisitions++ - return Value.of({ value: "dependency" }) - }), - ) - const dependency = make({ service: Value, layer: dependencyLayer, deps: [] }) - const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] }) - const replacement = make({ - service: Greeting, - layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })), - deps: [], - }) + it.effect("does not acquire replaced dependencies or unused replacement targets", () => + Effect.gen(function* () { + const acquired: string[] = [] + const dependency = make({ + service: Value, + layer: Layer.effect( + Value, + Effect.sync(() => { + acquired.push("old dependency") + return Value.of({ value: "dependency" }) + }), + ), + deps: [], + }) + const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] }) + const result = yield* Greeting.pipe( + Effect.provide( + LayerNode.compile(original, { + replacements: [ + original.replace(Layer.succeed(Greeting, { value: "replacement" })), + value.replace( + Layer.effect( + Value, + Effect.sync(() => { + acquired.push("unused target") + return Value.of({ value: "unused" }) + }), + ), + ), + ], + }), + ), + ) + expect(result.value).toBe("replacement") + expect(acquired).toEqual([]) + }), + ) - const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(build(LayerNode.group([original]), [[original, replacement]])), - ) + it.effect("mapLayer preserves dependency wiring and replacement traversal", () => + Effect.gen(function* () { + const acquired: string[] = [] + const decorated = greeting.mapLayer((layer) => + layer.pipe( + Layer.tap((context) => + Effect.sync(() => { + acquired.push(Context.get(context, Greeting).value) + }), + ), + ), + ) + const result = yield* Greeting.pipe( + Effect.provide( + LayerNode.compile(greeting, { + replacements: [ + greeting.replace(decorated), + value.replace(Layer.succeed(Value, { value: "mapped dependency" })), + ], + }), + ), + ) + expect(result.value).toBe("hello mapped dependency") + expect(acquired).toEqual(["hello mapped dependency"]) + }), + ) - expect(await Effect.runPromise(program)).toBe("replacement") - expect(acquisitions).toBe(0) - }) + it.effect("memoizes shared wiring instead of expanding a diamond into a tree", () => + Effect.gen(function* () { + const acquisitions: string[] = [] + const shared = value.mapLayer((layer) => + layer.pipe(Layer.tap(() => Effect.sync(() => acquisitions.push("shared")))), + ) + const left = make({ name: "left", layer: Layer.empty, deps: [shared] }) + const right = make({ name: "right", layer: Layer.empty, deps: [shared] }) + yield* Layer.build(LayerNode.compile(LayerNode.group([left, right]))) + expect(acquisitions).toEqual(["shared"]) + }), + ) - test("applies later replacements inside earlier replacement nodes", async () => { - const original = make({ service: Greeting, layer: greetingLayer, deps: [value] }) - const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] }) - const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide( - build(LayerNode.group([original]), [ - [original, replacement], - [value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))], - ]), - ), - ) + it.effect("preserves declared memo-service outputs rather than filtering them as build metadata", () => + Effect.gen(function* () { + const supplied = yield* Layer.makeMemoMap + const memo = make({ + service: Layer.CurrentMemoMap, + layer: Layer.succeed(Layer.CurrentMemoMap, supplied), + deps: [], + }) + const observer = make({ service: Memo, layer: Layer.effect(Memo, Layer.CurrentMemoMap), deps: [memo] }) + expect(yield* Memo.pipe(Effect.provide(LayerNode.compile(observer)))).toBe(supplied) + }), + ) - expect(await Effect.runPromise(program)).toBe("hello replacement dependency") - }) + it.effect("rejects one implementation wired to different effective dependencies in either memo domain", () => + Effect.gen(function* () { + const other = make({ service: Value, layer: Layer.succeed(Value, { value: "other" }), deps: [] }) + const sibling = make({ service: Greeting, layer: greetingLayer, deps: [other] }) + const root = LayerNode.group([greeting, sibling]) + expect(() => LayerNode.compile(root)).toThrow("wired to different dependencies") + expect(() => LayerNode.compile(root, { shared: tags.values.app })).toThrow("wired to different dependencies") + const result = yield* Greeting.pipe( + Effect.provide(LayerNode.compile(root, { replacements: [value.replace(other)] })), + ) + expect(result.value).toBe("hello other") + }), + ) - test("hoists and compiles tagged graphs", async () => { - const tags = LayerNode.tags({ location: ["global"], global: [] }) - const global = tags.make("global") - const location = tags.make("location") - const database = global({ - service: Database, - layer: Layer.succeed(Database, Database.of({ name: "Alice" })), - deps: [], - }) - const users = location({ - service: Users, - layer: Layer.effect( - Users, + it.effect("starts dependencies in parallel and nested group roots in order", () => + Effect.gen(function* () { + const valueStarted = yield* Deferred.make() + const greetingStarted = yield* Deferred.make() + const firstStarted = yield* Deferred.make() + const releaseFirst = yield* Deferred.make() + const events: string[] = [] + const value = make({ + service: Value, + layer: Layer.effect( + Value, + Effect.gen(function* () { + yield* Deferred.succeed(valueStarted, undefined) + yield* Deferred.await(greetingStarted) + return Value.of({ value: "value" }) + }), + ), + deps: [], + }) + const greeting = make({ + service: Greeting, + layer: Layer.effect( + Greeting, + Effect.gen(function* () { + yield* Deferred.succeed(greetingStarted, undefined) + yield* Deferred.await(valueStarted) + return Greeting.of({ value: "greeting" }) + }), + ), + deps: [], + }) + const first = make({ + service: Left, + layer: Layer.effect( + Left, + Effect.gen(function* () { + yield* Value + yield* Greeting + events.push("first started") + yield* Deferred.succeed(firstStarted, undefined) + yield* Deferred.await(releaseFirst) + events.push("first finished") + return Left.of({ value: "first" }) + }), + ), + deps: [value, greeting], + }) + const second = make({ + service: Right, + layer: Layer.effect( + Right, + Effect.sync(() => { + expect(events).toEqual(["first started", "first finished"]) + events.push("second started") + return Right.of({ value: "second" }) + }), + ), + deps: [], + }) + const fiber = yield* Layer.build(LayerNode.compile(LayerNode.group([LayerNode.group([first]), second]))).pipe( + Effect.forkChild, + ) + yield* Deferred.await(firstStarted) + expect(events).toEqual(["first started"]) + yield* Deferred.succeed(releaseFirst, undefined) + const context = yield* Fiber.join(fiber) + expect(events).toEqual(["first started", "first finished", "second started"]) + expect(Context.get(context, Left).value).toBe("first") + expect(Context.get(context, Right).value).toBe("second") + }), + ) + ;[false, true].forEach((topLevel) => { + it.effect( + `LayerMap isolates builds and retains resources ${topLevel ? "with" : "without"} a top-level global owner`, + () => Effect.gen(function* () { - const db = yield* Database - return Users.of({ list: Effect.succeed([db.name]) }) - }), - ), - deps: [database], - }) - const app = location({ - service: App, - layer: Layer.effect( - App, - Effect.gen(function* () { - const service = yield* Users - return App.of({ run: service.list }) - }), - ), - deps: [users], - }) - - const result = LayerNode.hoist(LayerNode.group([app]), tags.values.global) - expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ - kind: "group", - dependencies: [], - }) - expect(result.hoisted.dependencies).toEqual([database]) + const acquired = { global: 0, local: 0, support: 0 } + const released: string[] = [] + const startup: string[] = [] + yield* Effect.gen(function* () { + const memoMap = yield* Layer.makeMemoMap + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const support = LayerNode.make({ + service: Support, + layer: Layer.effect( + Support, + Effect.acquireRelease( + Effect.sync(() => { + acquired.support++ + return Support.of({}) + }), + () => + Effect.sync(() => { + released.push("support") + }), + ), + ), + deps: [], + }) + const value = global({ + service: Value, + layer: Layer.effect( + Value, + Effect.andThen( + Support, + Effect.acquireRelease( + Effect.sync(() => { + startup.push("global") + return Value.of({ value: `global-${++acquired.global}` }) + }), + (value) => + Effect.sync(() => { + released.push(value.value) + }), + ), + ), + ), + deps: [support], + }) + const local = location({ + service: Greeting, + layer: Layer.effect( + Greeting, + Effect.gen(function* () { + yield* Value + return yield* Effect.acquireRelease( + Effect.sync(() => Greeting.of({ value: `local-${++acquired.local}` })), + (value) => + Effect.sync(() => { + released.push(value.value) + }), + ) + }), + ), + deps: [LayerNode.group([value])], + }) + const root = location({ + service: Right, + layer: Layer.effect( + Right, + Effect.gen(function* () { + const local = yield* Greeting + if (local.value === "local-2") return yield* Effect.fail("failed location" as const) + return Right.of(local) + }), + ), + deps: [local], + }) + // Every key builds the same compiled Layer, not a new graph per lookup. + const compiled = LayerNode.compile(LayerNode.group([value, root]), { shared: tags.values.global }) + const locations = location({ + service: Locations, + layer: Layer.effect( + Locations, + Effect.gen(function* () { + startup.push("map") + expect(Option.getOrUndefined(yield* Effect.serviceOption(Layer.CurrentMemoMap))).toBe(memoMap) + return yield* LayerMap.make((_: string) => compiled, { idleTimeToLive: Duration.infinity }) + }), + ), + deps: [], + }) + const scope = yield* Effect.scope + const context = yield* Layer.buildWithMemoMap( + LayerNode.compile(LayerNode.group([locations, ...(topLevel ? [value] : [])]), { + shared: tags.values.global, + }), + memoMap, + scope, + ) + expect(startup).toEqual(topLevel ? ["map", "global"] : ["map"]) + const map = Context.get(context, Locations) + const first = yield* map.contextEffect("first").pipe(Effect.scoped) + expect(Option.getOrUndefined(Context.getOption(context, Value))).toBe( + topLevel ? Context.get(first, Value) : undefined, + ) + expect(Option.isNone(Context.getOption(first, Greeting))).toBe(true) + expect(Context.get(first, Right).value).toBe("local-1") - const layer = LayerNode.compile(result.node).pipe( - Layer.provide(LayerNode.compile(result.hoisted)), - ) as unknown as Layer.Layer - const program = Effect.gen(function* () { - const app = yield* App - return yield* app.run - }).pipe(Effect.provide(layer)) + expect(yield* map.contextEffect("failed").pipe(Effect.scoped, Effect.flip)).toBe("failed location") + expect(released).toEqual(["local-2"]) + expect(Context.get(yield* map.contextEffect("first").pipe(Effect.scoped), Right)).toBe( + Context.get(first, Right), + ) - expect(await Effect.runPromise(program)).toEqual(["Alice"]) - }) + const second = yield* map.contextEffect("second").pipe(Effect.scoped) + expect(Context.get(second, Value)).toBe(Context.get(first, Value)) + expect(Context.get(second, Right)).not.toBe(Context.get(first, Right)) + expect(acquired).toEqual({ global: 1, local: 3, support: 1 }) - test("rejects conflicting hoisted implementations", () => { - const tags = LayerNode.tags({ location: ["global"], global: [] }) - const global = tags.make("global") - const location = tags.make("location") - const first = global({ - service: Database, - layer: Layer.succeed(Database, Database.of({ name: "first" })), - deps: [], - }) - const second = global({ - service: Database, - layer: Layer.succeed(Database, Database.of({ name: "second" })), - deps: [], - }) - const left = location({ - service: Users, - layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), - deps: [first], - }) - const right = location({ - service: App, - layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))), - deps: [second], - }) - - expect(() => LayerNode.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow( - "Tag global has conflicting implementations for test/GraphDatabase", + yield* map.invalidate("first") + expect(released).toEqual(["local-2", "local-1"]) + expect(Context.get(yield* map.contextEffect("second").pipe(Effect.scoped), Right)).toBe( + Context.get(second, Right), + ) + const rebuilt = yield* map.contextEffect("first").pipe(Effect.scoped) + expect(Context.get(rebuilt, Right).value).toBe("local-4") + expect(Context.get(rebuilt, Value)).toBe(Context.get(first, Value)) + expect(acquired).toEqual({ global: 1, local: 4, support: 1 }) + expect(released).not.toContain("global-1") + }).pipe(Effect.scoped) + expect(released.toSorted()).toEqual(["global-1", "local-1", "local-2", "local-3", "local-4", "support"]) + }), ) }) - - test("treats dependency groups as transparent while hoisting", () => { - const tags = LayerNode.tags({ location: ["global"], global: [] }) - const global = tags.make("global") - const location = tags.make("location") - const database = global({ - service: Database, - layer: Layer.succeed(Database, Database.of({ name: "Alice" })), - deps: [], - }) - const users = location({ - service: Users, - layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), - deps: [LayerNode.group([database])], - }) - const result = LayerNode.hoist(LayerNode.group([users]), tags.values.global) - - expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ - kind: "group", - dependencies: [], - }) - }) }) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index 96c77e0d258d..baed42e3b078 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -1,20 +1,23 @@ import { describe, expect, test } from "bun:test" -import { Context, Effect, Layer, LayerMap, Option } from "effect" +import { Context, Effect, Layer, Option } from "effect" import { Node } from "@opencode-ai/util/effect/app-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" -import type { LocationError, LocationServices } from "@opencode-ai/core/location-services" +import { buildLocationServiceMap } from "@opencode-ai/core/location-services" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "../../fixture/tmpdir" +import { testEffect } from "../../lib/effect" class Value extends Context.Service()("test/TagValue") {} class Result extends Context.Service()("test/TagResult") {} class CycleA extends Context.Service()("test/NodeBuildA") {} class CycleB extends Context.Service()("test/NodeBuildB") {} +const it = testEffect(Layer.empty) + describe("node build", () => { test("does not build a location service map when the graph does not require it", async () => { const result = Node.makeGlobalNode({ @@ -31,7 +34,7 @@ describe("node build", () => { expect(await Effect.runPromise(program)).toBe("plain") }) - test("detects cycles through a replaced location service map", async () => { + test("detects cycles through a replaced location service map", () => { const a = Node.makeGlobalNode({ service: CycleA, layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))), @@ -45,31 +48,49 @@ describe("node build", () => { ), deps: [a], }) - const mapLayer = Layer.effect( - LocationServiceMap.Service, - Effect.gen(function* () { - const service = yield* CycleB - return yield* LayerMap.make( - (ref: Location.Ref) => - Layer.succeed( - Location.Service, - Location.Service.of({ - directory: ref.directory, - workspaceID: ref.workspaceID, - project: { id: Project.ID.global, directory: service.directory, canonical: service.directory }, - }), - ), - { idleTimeToLive: "1 minute" }, - ) - }) as unknown as Effect.Effect, never, CycleB>, - ) + const mapLayer = Layer.unwrap(Effect.as(CycleB, buildLocationServiceMap())) const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] }) - expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow( - "Cycle detected in layer tree", + expect(() => AppNodeBuilder.build(LayerNode.group([a]), [LocationServiceMap.node.replace(map)])).toThrow( + "Cycle detected in layer graph", ) }) - test("shares top-level project with location services", async () => { + it.effect("supplies the lazy map when only a replacement introduces the dependency", () => + Effect.gen(function* () { + const original = Node.makeGlobalNode({ + service: Result, + layer: Layer.succeed(Result, { value: "original" }), + deps: [], + }) + const replacement = Node.makeGlobalNode({ + service: Result, + layer: Layer.effect(Result, Effect.as(LocationServiceMap.Service, Result.of({ value: "has map" }))), + deps: [LocationServiceMap.node], + }) + const result = yield* Result.pipe(Effect.provide(AppNodeBuilder.build(original, [original.replace(replacement)]))) + expect(result.value).toBe("has map") + }), + ) + + it.effect("caller replacements override the lazy default without building any locations", () => + Effect.gen(function* () { + const acquisitions: string[] = [] + const override = buildLocationServiceMap().pipe( + Layer.tap(() => + Effect.sync(() => { + acquisitions.push("caller map") + }), + ), + ) + const context = yield* Layer.build( + AppNodeBuilder.build(LocationServiceMap.node, [LocationServiceMap.node.replace(override)]), + ) + expect(Context.get(context, LocationServiceMap.Service)).toBeDefined() + expect(acquisitions).toEqual(["caller map"]) + }), + ) + + test("shares top-level project even when the location service map is built first", async () => { await using tmp = await tmpdir() let acquisitions = 0 const projectLayer = Layer.effect( @@ -84,8 +105,8 @@ describe("node build", () => { }), ) const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) - const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [ - [Project.node, projectLayer], + const layer = AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Project.node]), [ + Project.node.replace(projectLayer), ]) const program = Effect.gen(function* () { yield* Project.Service diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index 0d5c8d868d75..08b5ad770ff1 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -21,8 +21,8 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform = ) return Effect.provide( AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [ - [Location.node, activeLocation], - [Environment.node, transformEnvironmentFiles(transformFiles)], + Location.node.replace(activeLocation), + Environment.node.replace(transformEnvironmentFiles(transformFiles)), ]), ) } diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index dc2de5bbc604..ff8e5da21756 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -77,16 +77,15 @@ describe("FileSystemSearch", () => { workspaceID: Workspace.ID.make("wrk_test"), }) const layer = AppNodeBuilder.build(FileSystemSearch.node, [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of( location(ref, { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } }), ), ), - ], - [Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))], + ), + Ripgrep.node.replace(ripgrepStub("remote.ts", (input) => (observed = input))), ]) yield* Effect.gen(function* () { @@ -103,8 +102,7 @@ describe("FileSystemSearch", () => { let observed: Ripgrep.FindInput | undefined const home = AbsolutePath.make(os.homedir()) const layer = AppNodeBuilder.build(FileSystemSearch.node, [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of( @@ -114,8 +112,8 @@ describe("FileSystemSearch", () => { ), ), ), - ], - [Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))], + ), + Ripgrep.node.replace(ripgrepStub("src/index.ts", (input) => (observed = input))), ]) yield* Effect.gen(function* () { const search = yield* FileSystemSearch.Service @@ -137,17 +135,15 @@ describe("FileSystemSearch", () => { const started = yield* Deferred.make() const release = yield* Deferred.make() const layer = AppNodeBuilder.build(FileSystemSearch.node, [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of( location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }), ), ), - ], - [ - Ripgrep.node, + ), + Ripgrep.node.replace( Layer.succeed( Ripgrep.Service, Ripgrep.Service.of({ @@ -169,7 +165,7 @@ describe("FileSystemSearch", () => { grep: () => Effect.succeed([]), }), ), - ], + ), ]) yield* Effect.gen(function* () { @@ -208,17 +204,15 @@ describe("FileSystemSearch", () => { (value) => Effect.sync(() => value.mockRestore()), ) const layer = AppNodeBuilder.build(FileSystemSearch.node, [ - [ - Location.node, + Location.node.replace( Layer.succeed( Location.Service, Location.Service.of( location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }), ), ), - ], - [ - Ripgrep.node, + ), + Ripgrep.node.replace( Layer.succeed( Ripgrep.Service, Ripgrep.Service.of({ @@ -234,7 +228,7 @@ describe("FileSystemSearch", () => { grep: () => Effect.succeed([]), }), ), - ], + ), ]) yield* Effect.gen(function* () { diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 570acf91b2d1..9ad0401684a6 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -6,7 +6,7 @@ import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } fr import { Config } from "@opencode-ai/core/config" import { ConfigLocationWatcherPlugin } from "@opencode-ai/core/config/plugin/location-watcher" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { makeLocationNode, type LocationNode } from "@opencode-ai/util/effect/app-node" +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -129,7 +129,7 @@ function provide( vcs?: Location.Interface["vcs"], watcher?: Layer.Layer, config: Layer.Layer = configLayer, - plugins: LocationNode = pluginNode, + plugins: typeof pluginNode = pluginNode, ) { const locationLayer = Layer.succeed( Location.Service, @@ -138,10 +138,10 @@ function provide( const built = AppNodeBuilder.build( LayerNode.group([LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]), [ - [Config.node, config], - [Location.node, locationLayer], - [PluginSupervisor.node, plugins], - ...(watcher ? ([[Watcher.node, watcher]] as const) : []), + Config.node.replace(config), + Location.node.replace(locationLayer), + PluginSupervisor.node.replace(plugins), + ...(watcher ? ([Watcher.node.replace(watcher)] as const) : []), ], ) return Effect.provide(built) @@ -154,7 +154,7 @@ function withTmp( init?: (directory: string) => Promise watcher?: Layer.Layer config?: Layer.Layer - plugins?: LocationNode + plugins?: typeof pluginNode }, ) { return Effect.acquireRelease( diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts index b79c0a9458a0..894254f2956a 100644 --- a/packages/core/test/fixture/effect-flock-worker.ts +++ b/packages/core/test/fixture/effect-flock-worker.ts @@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({ log: os.tmpdir(), }) -const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]]) +const testLayer = LayerNode.compile(EffectFlock.node, { replacements: [Global.node.replace(testGlobal)] }) async function job() { if (msg.ready) await fs.writeFile(msg.ready, String(process.pid)) diff --git a/packages/core/test/fixture/prompt-location.ts b/packages/core/test/fixture/prompt-location.ts index 643823f287f9..e105802e4814 100644 --- a/packages/core/test/fixture/prompt-location.ts +++ b/packages/core/test/fixture/prompt-location.ts @@ -25,9 +25,9 @@ export const promptLocationNode = makeGlobalNode({ SessionPrompt.layer.pipe( Layer.provideMerge( Layer.mergeAll( - LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), [ - [Bus.node, Layer.succeed(Bus.Service, bus)], - ]), + LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), { + replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))], + }), Layer.succeed(FSUtil.Service, fs), Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }), ), diff --git a/packages/core/test/formatter.test.ts b/packages/core/test/formatter.test.ts index 492ff7b77001..5f7ad43ee210 100644 --- a/packages/core/test/formatter.test.ts +++ b/packages/core/test/formatter.test.ts @@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], + Global.node.replace(tempGlobalLayer), ]), ) type ConfigInput = typeof Info.Encoded diff --git a/packages/core/test/instance-plugins.test.ts b/packages/core/test/instance-plugins.test.ts index 5dbc71fe82c0..786a033afe76 100644 --- a/packages/core/test/instance-plugins.test.ts +++ b/packages/core/test/instance-plugins.test.ts @@ -34,7 +34,7 @@ const instances = Layer.effect( (ref: Location.Ref) => Instance.layer(ref, { plugins: path.basename(ref.directory) === "thread-a" ? [agentPlugin("thread-a-plugin", "thread-a-agent")] : [], - replacements: [[Global.node, tempGlobalLayer]], + replacements: [Global.node.replace(tempGlobalLayer)], }), { idleTimeToLive: Duration.infinity }, ), @@ -42,8 +42,8 @@ const instances = Layer.effect( const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], - [LocationServiceMap.node, instances], + Global.node.replace(tempGlobalLayer), + LocationServiceMap.node.replace(instances), ]), ) diff --git a/packages/core/test/instance-vanilla.test.ts b/packages/core/test/instance-vanilla.test.ts index b55c4bc02e30..a18d16934a95 100644 --- a/packages/core/test/instance-vanilla.test.ts +++ b/packages/core/test/instance-vanilla.test.ts @@ -23,14 +23,13 @@ import { Bus } from "../src/bus" // Config the host hands the vanilla instance explicitly: a value and an // explicit plugin removal, both of which must survive discovery: false. const hostConfig: LayerNode.Replacements = [ - [ - Config.node, + Config.node.replace( Config.configured({ project: false, global: false, content: JSON.stringify({ shell: "vanilla-host", plugins: ["-opencode.tool.shell"] }), }), - ], + ), ] // Same directory contents, two instances: one vanilla, one with discovery. @@ -43,7 +42,7 @@ const instances = Layer.effect( // "bare" exercises the vanilla defaults themselves: no caller Config. discovery: name !== "vanilla" && name !== "bare", // Caller replacements win over the vanilla defaults. - replacements: [[Global.node, tempGlobalLayer], ...(name === "vanilla" ? hostConfig : [])], + replacements: [Global.node.replace(tempGlobalLayer), ...(name === "vanilla" ? hostConfig : [])], }) }, { idleTimeToLive: Duration.infinity }, @@ -52,8 +51,8 @@ const instances = Layer.effect( const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], - [LocationServiceMap.node, instances], + Global.node.replace(tempGlobalLayer), + LocationServiceMap.node.replace(instances), ]), ) diff --git a/packages/core/test/instruction-discovery.test.ts b/packages/core/test/instruction-discovery.test.ts index 0c2cbb68ad6b..1af50a4b1a1f 100644 --- a/packages/core/test/instruction-discovery.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -33,19 +33,18 @@ const instructionLayer = (input: { AppNodeBuilder.build( LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]), [ - [InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })], - [ - Global.node, + InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })), + Global.node.replace( input.config || input.home ? Global.layerWith({ ...(input.config ? { config: input.config } : {}), ...(input.home ? { home: input.home } : {}), }) : tempGlobalLayer, - ], - [Location.node, input.locationServiceLayer], - [Watcher.node, watcher], - ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), + ), + Location.node.replace(input.locationServiceLayer), + Watcher.node.replace(watcher), + ...(input.filesystemLayer ? [FSUtil.node.replace(input.filesystemLayer)] : []), ], ), watcher, diff --git a/packages/core/test/instruction-state.test.ts b/packages/core/test/instruction-state.test.ts index c54f2ed6fa98..023c9a0b4b0a 100644 --- a/packages/core/test/instruction-state.test.ts +++ b/packages/core/test/instruction-state.test.ts @@ -24,7 +24,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), ) diff --git a/packages/core/test/instructions/builtins.test.ts b/packages/core/test/instructions/builtins.test.ts index db700aef88c7..60453fa81016 100644 --- a/packages/core/test/instructions/builtins.test.ts +++ b/packages/core/test/instructions/builtins.test.ts @@ -30,8 +30,8 @@ const locationLayer = Layer.succeed( ) const it = testEffect( AppNodeBuilder.build(InstructionBuiltIns.node, [ - [Location.node, locationLayer], - [Global.node, Global.layerWith({ config: temporary, tmp: temporary })], + Location.node.replace(locationLayer), + Global.node.replace(Global.layerWith({ config: temporary, tmp: temporary })), ]), ) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 90091ec610c5..c367e16c73c2 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -27,7 +27,7 @@ const failingCredentialNode = makeGlobalNode({ deps: [], }) const failingIt = testEffect( - AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [[Credential.node, failingCredentialNode]]), + AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [Credential.node.replace(failingCredentialNode)]), ) function eventually( diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index d3b1a76bf52c..0be4a284a530 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -13,15 +13,16 @@ import { it } from "./lib/effect" const provide = (directory: string, workspaceID?: Workspace.ID) => Effect.provide( - LayerNode.compile(FileSystem.node, [ - [ - Location.node, - Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })), + LayerNode.compile(FileSystem.node, { + replacements: [ + Location.node.replace( + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })), + ), ), ], - ]), + }), ) const withTmp = (f: (directory: string) => Effect.Effect) => diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index b38ab70b697d..13ab09557b55 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -51,12 +51,12 @@ import { Tool } from "../src/tool" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], + Global.node.replace(tempGlobalLayer), ]), ) const itWithSdk = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - [Global.node, tempGlobalLayer], + Global.node.replace(tempGlobalLayer), ]), ) const activityLocations = Layer.effect( @@ -77,7 +77,7 @@ const activityLocations = Layer.effect( ) const itWithActivity = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]), [ - [LocationServiceMap.node, activityLocations], + LocationServiceMap.node.replace(activityLocations), ]), ) diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/location-mutation.test.ts index f3753d44432c..640bcc8bfd91 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/location-mutation.test.ts @@ -13,20 +13,21 @@ import { it } from "./lib/effect" function provide(directory: string, projectDirectory = directory) { return Effect.provide( - LayerNode.compile(LocationMutation.node, [ - [ - Location.node, - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { projectDirectory: AbsolutePath.make(projectDirectory) }, + LayerNode.compile(LocationMutation.node, { + replacements: [ + Location.node.replace( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(projectDirectory) }, + ), ), ), ), ], - ]), + }), ) } diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index bfa18da62c34..3512c8f2ee6f 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -23,7 +23,7 @@ const projectLayer = Layer.succeed( }), }), ) -const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]])) +const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [Project.node.replace(projectLayer)])) describe("Location", () => { it.effect("resolves the current project and vcs information", () => diff --git a/packages/core/test/mcp-instructions.test.ts b/packages/core/test/mcp-instructions.test.ts index 70c8a0f9a278..870974dacd22 100644 --- a/packages/core/test/mcp-instructions.test.ts +++ b/packages/core/test/mcp-instructions.test.ts @@ -23,13 +23,12 @@ const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.Ser const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) => AppNodeBuilder.build(McpInstructions.node, [ - [ - Mcp.node, + Mcp.node.replace( Layer.mock(Mcp.Service, { instructions: () => Effect.succeed(catalog()), tools: () => Effect.succeed(tools()), }), - ], + ), ]) describe("McpInstructions", () => { diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 3e84f1a7b8f4..16e59ab40fd5 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -376,10 +376,10 @@ const permissions = Layer.mock(Permission.Service, { const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never }) const it = testEffect( AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [ - [Mcp.node, mcp], - [Permission.node, permissions], - [Bus.node, events], - [Image.node, imagePassthrough], + Mcp.node.replace(mcp), + Permission.node.replace(permissions), + Bus.node.replace(events), + Image.node.replace(imagePassthrough), ]), ) @@ -1645,8 +1645,7 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr Effect.provide( Layer.fresh( AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [ - [ - Mcp.node, + Mcp.node.replace( Layer.mock(Mcp.Service, { tools: () => Ref.get(catalog), callTool: (input) => @@ -1659,9 +1658,9 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr }), ), }), - ], - [Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })], - [Image.node, imagePassthrough], + ), + Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })), + Image.node.replace(imagePassthrough), ]), ), ), @@ -1688,8 +1687,7 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [ - [ - Mcp.node, + Mcp.node.replace( Layer.mock(Mcp.Service, { tools: () => Effect.sync(() => [ @@ -1701,9 +1699,9 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in }), ]), }), - ], - [Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })], - [Image.node, imagePassthrough], + ), + Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })), + Image.node.replace(imagePassthrough), ]), ), ) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index e41fa71559fc..2c0fed83a3da 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -182,9 +182,9 @@ const buildLayer = (state: Ref.Ref, cache: MockCache, options: Models // every test would reuse the cachedInvalidateWithTTL state from the first run. Layer.fresh( AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [ - [ModelsDev.node, ModelsDev.configured(options)], - [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], - [KV.node, makeMockKV(cache)], + ModelsDev.node.replace(ModelsDev.configured(options)), + LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), + KV.node.replace(makeMockKV(cache)), ]), ) @@ -312,9 +312,9 @@ describe("ModelsDev Service", () => { const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) const layer = Layer.fresh( AppNodeBuilder.build(ModelsDev.node, [ - [ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })], - [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], - [KV.node, makeFailingWriteKV(cache)], + ModelsDev.node.replace(ModelsDev.configured({ fetch: true, snapshot: false })), + LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), + KV.node.replace(makeFailingWriteKV(cache)), ]), ) const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer)) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index f31fcec764cd..215472d1dbf7 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -20,7 +20,7 @@ const writePackage = (dir: string, pkg: Record) => ) const npmLayer = (cache: string) => - AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]]) + AppNodeBuilder.build(Npm.node, [Global.node.replace(Global.layerWith({ cache, state: path.join(cache, "state") }))]) async function createGitFixture(directory: string) { const repository = path.join(directory, "repository") diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 3c08246f3647..294cc4892451 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -37,7 +37,7 @@ const it = testEffect( PluginHooks.node, Permission.node, ]), - [[Location.node, current]], + [Location.node.replace(current)], ), ) diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts index 7efcc8253297..b9c680fb8abd 100644 --- a/packages/core/test/plugin-hooks.test.ts +++ b/packages/core/test/plugin-hooks.test.ts @@ -4,12 +4,12 @@ import { Agent } from "@opencode-ai/schema/agent" import { Model } from "@opencode-ai/schema/model" import { Provider } from "@opencode-ai/schema/provider" import { Session } from "@opencode-ai/schema/session" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Effect } from "effect" import { PluginHooks } from "../src/plugin/hooks" import { testEffect } from "./lib/effect" -const layer = PluginHooks.node.implementation as Layer.Layer -const it = testEffect(layer) +const it = testEffect(LayerNode.compile(PluginHooks.node)) describe("PluginHooks", () => { it.effect("registers scoped session hooks and triggers them sequentially", () => diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 504ad8cccca3..cf802ecc4d56 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -27,8 +27,8 @@ const locationLayer = Layer.succeed( ) const it = testEffect( AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [ - [Mcp.node, emptyMcpLayer], - [Location.node, locationLayer], + Mcp.node.replace(emptyMcpLayer), + Location.node.replace(locationLayer), ]), ) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 9bb2804912ed..bc6bb71e6d2d 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -86,12 +86,14 @@ export const PluginTestLayer = LayerNode.compile( Watcher.node, WebSearch.node, ]), - [ - [Location.node, tempLocationLayer], - [Npm.node, npmLayer], - [Config.node, Config.testLayer()], - [Mcp.node, emptyMcpLayer], - [Generate.node, generateLayer], - [Permission.node, permissionLayer], - ], + { + replacements: [ + Location.node.replace(tempLocationLayer), + Npm.node.replace(npmLayer), + Config.node.replace(Config.testLayer()), + Mcp.node.replace(emptyMcpLayer), + Generate.node.replace(generateLayer), + Permission.node.replace(permissionLayer), + ], + }, ) as unknown as Layer.Layer diff --git a/packages/core/test/plugin/host.test.ts b/packages/core/test/plugin/host.test.ts index 4b63883c8138..bb665a0c8c68 100644 --- a/packages/core/test/plugin/host.test.ts +++ b/packages/core/test/plugin/host.test.ts @@ -23,11 +23,11 @@ const it = testEffect( PluginRuntime.providerNodeWithCell(cell), ]), [ - [Global.node, tempGlobalLayer], - [Watcher.node, Watcher.configured({ enabled: false })], - [SessionExecution.node, SessionExecution.noopLayer], - [PluginRuntime.node, PluginRuntime.layerWithCell(cell)], - [PersistentPty.node, PersistentPty.configured()], + Global.node.replace(tempGlobalLayer), + Watcher.node.replace(Watcher.configured({ enabled: false })), + SessionExecution.node.replace(SessionExecution.noopLayer), + PluginRuntime.node.replace(PluginRuntime.layerWithCell(cell)), + PersistentPty.node.replace(PersistentPty.configured()), ], ), ) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 20c4fecfc596..18a624bdfba4 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -27,12 +27,12 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), ) const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [ - [Location.node, locationLayer], + Location.node.replace(locationLayer), ]) const it = testEffect(layer) const real = testEffect(PluginTestLayer) const models = (file: string) => - AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]]) + AppNodeBuilder.build(ModelsDev.node, [ModelsDev.node.replace(ModelsDev.configured({ file, fetch: false }))]) describe("ModelsDevPlugin", () => { real.effect("keeps the retained model seed unchanged across catalog replay", () => diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts index 762d93789280..2a253634704e 100644 --- a/packages/core/test/plugin/variant.test.ts +++ b/packages/core/test/plugin/variant.test.ts @@ -15,7 +15,7 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), ) -const it = testEffect(AppNodeBuilder.build(Catalog.node, [[Location.node, locationLayer]])) +const it = testEffect(AppNodeBuilder.build(Catalog.node, [Location.node.replace(locationLayer)])) describe("VariantPlugin", () => { it.effect("adds GLM 5.2 variants after catalog sources", () => diff --git a/packages/core/test/plugin/websearch-fixture.ts b/packages/core/test/plugin/websearch-fixture.ts index ce61770ec0f5..f188e287e990 100644 --- a/packages/core/test/plugin/websearch-fixture.ts +++ b/packages/core/test/plugin/websearch-fixture.ts @@ -42,7 +42,7 @@ const http = Layer.succeed( export const webSearchIntegrationTest = testEffect( Layer.merge( AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]), [ - [Config.node, Config.testLayer()], + Config.node.replace(Config.testLayer()), ]), http, ), diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 9a184bfd929a..06cf8fd21401 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -17,7 +17,9 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })), ) -const it = testEffect(AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [[Location.node, locationLayer]])) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [Location.node.replace(locationLayer)]), +) const ptyTest = process.platform === "win32" ? it.live.skip : it.live const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () { @@ -200,7 +202,7 @@ describe("pty", () => { const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash") const configuredIt = testEffect( - AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [[Location.node, locationLayer]]), + AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [Location.node.replace(locationLayer)]), ) const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live diff --git a/packages/core/test/pty/ticket.test.ts b/packages/core/test/pty/ticket.test.ts index 563264ec71e9..31c87de49afa 100644 --- a/packages/core/test/pty/ticket.test.ts +++ b/packages/core/test/pty/ticket.test.ts @@ -8,7 +8,9 @@ import { testEffect } from "../lib/effect" const it = testEffect(LayerNode.compile(PtyTicket.node)) const itExpiring = testEffect( - LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]), + LayerNode.compile(PtyTicket.node, { + replacements: [PtyTicket.node.replace(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))], + }), ) describe("PTY websocket tickets", () => { diff --git a/packages/core/test/reference-instructions.test.ts b/packages/core/test/reference-instructions.test.ts index 5cfac6903b1d..2baf4cb15cb2 100644 --- a/packages/core/test/reference-instructions.test.ts +++ b/packages/core/test/reference-instructions.test.ts @@ -8,7 +8,7 @@ import { it } from "./lib/effect" import { readInitial, readUpdate } from "./lib/instructions" const instructionsLayer = (referenceLayer: Layer.Layer) => - AppNodeBuilder.build(ReferenceInstructions.node, [[Reference.node, referenceLayer]]) + AppNodeBuilder.build(ReferenceInstructions.node, [Reference.node.replace(referenceLayer)]) describe("ReferenceInstructions", () => { it.effect("lists available references in the instructions", () => diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index 430287ade217..657d65a1f9ea 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -11,7 +11,7 @@ import { it } from "./lib/effect" const cache = Layer.mock(RepositoryCache.Service, { ensure: () => Effect.die("unexpected Git materialization"), }) -const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]]) +const referenceLayer = AppNodeBuilder.build(Reference.node, [RepositoryCache.node.replace(cache)]) describe("Reference", () => { it.effect("registers normalized sources for the owning scope", () => diff --git a/packages/core/test/repository-cache.test.ts b/packages/core/test/repository-cache.test.ts index ed033f9ba9e3..213467a13cb0 100644 --- a/packages/core/test/repository-cache.test.ts +++ b/packages/core/test/repository-cache.test.ts @@ -124,7 +124,7 @@ describe("RepositoryCache", () => { function cacheLayer(root: string) { return AppNodeBuilder.build(RepositoryCache.node, [ - [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })], + Global.node.replace(Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })), ]) } diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index 2f11446d58c0..08bbbe533843 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -10,7 +10,7 @@ import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { tempLocationLayer } from "./fixture/location" -const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [[Location.node, tempLocationLayer]])) +const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [Location.node.replace(tempLocationLayer)])) describe("Ripgrep", () => { it.live("globs files as an array", () => diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index d353f9478662..2502181e0d46 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -65,9 +65,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [LocationServiceMap.node, locations], - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], + LocationServiceMap.node.replace(locations), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index ed3c2a40ab19..41c9e99e0707 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -88,10 +88,7 @@ const it = testEffect( SessionCompaction.node, SessionModelRequest.node, ]), - [ - [Bus.node, Bus.configured({ persist: true })], - [llmClient, client], - ], + [Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)], ), ) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index d4e319293f55..e6b7cdac58cd 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -51,30 +51,27 @@ const it = testEffect( InstructionEntry.node, ]), [ - [Bus.node, Bus.configured({ persist: true })], - [Project.node, globalProjectNode], - [LocationServiceMap.node, promptLocationNode], - [SessionExecution.node, SessionExecution.noopLayer], + Bus.node.replace(Bus.configured({ persist: true })), + Project.node.replace(globalProjectNode), + LocationServiceMap.node.replace(promptLocationNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) const liveIt = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]), - [ - [Bus.node, Bus.configured({ persist: true })], - [SessionExecution.node, SessionExecution.noopLayer], - ], + [Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(SessionExecution.noopLayer)], ), ) const projectIt = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), // Project adoption needs plain-prompt admission, not live plugin/provider startup. - [LocationServiceMap.node, promptLocationNode], - [SessionExecution.node, SessionExecution.noopLayer], + LocationServiceMap.node.replace(promptLocationNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) @@ -968,8 +965,8 @@ describe("Session.create", () => { const targetLayer = AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [ - [Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })], - [Bus.node, Bus.configured({ persist: true })], + Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })), + Bus.node.replace(Bus.configured({ persist: true })), ], ) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 69e768ab7936..818f1e5fc322 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -142,17 +142,17 @@ const it = testEffect( SessionGenerateNode.node, ]), [ - [Bus.node, Bus.configured({ persist: true })], - [llmClient, client], - [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, builtins], - [InstructionDiscovery.node, discovery], - [SkillInstructions.node, skills], - [ReferenceInstructions.node, references], - [McpInstructions.node, mcp], - [PluginSupervisor.node, plugins], - [Tool.node, tools], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + Bus.node.replace(Bus.configured({ persist: true })), + llmClient.replace(client), + SessionRunnerModel.node.replace(models), + InstructionBuiltIns.node.replace(builtins), + InstructionDiscovery.node.replace(discovery), + SkillInstructions.node.replace(skills), + ReferenceInstructions.node.replace(references), + McpInstructions.node.replace(mcp), + PluginSupervisor.node.replace(plugins), + Tool.node.replace(tools), + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), ], ), ) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index c9b8bd791199..bd9decea5c27 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -53,7 +53,7 @@ const readToolNode = makeLocationNode({ const permission = permissionLayer({ assert: () => Effect.void }) const config = Config.testLayer() -const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) +const imageLayer = AppNodeBuilder.build(Image.node, [Config.node.replace(config)]) const testLayer = AppNodeBuilder.build( LayerNode.group([ @@ -74,12 +74,12 @@ const testLayer = AppNodeBuilder.build( Image.node, ]), [ - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], - [Location.node, tempLocationLayer], - [Permission.node, permission], - [Config.node, config], - [Image.node, imageLayer], + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), + Location.node.replace(tempLocationLayer), + Permission.node.replace(permission), + Config.node.replace(config), + Image.node.replace(imageLayer), ], ) diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index be5bfd556bcd..a0a739428c74 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -22,9 +22,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], + Bus.node.replace(Bus.configured({ persist: true })), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) diff --git a/packages/core/test/session-message-update.test.ts b/packages/core/test/session-message-update.test.ts index 3bd23bd8e43c..25076956662e 100644 --- a/packages/core/test/session-message-update.test.ts +++ b/packages/core/test/session-message-update.test.ts @@ -30,10 +30,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [Project.node, globalProjectNode], - [ - SessionExecution.node, + Bus.node.replace(Bus.configured({ persist: true })), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace( Layer.succeed( SessionExecution.Service, SessionExecution.Service.of({ @@ -45,7 +44,7 @@ const it = testEffect( awaitIdle: () => Effect.void, }), ), - ], + ), ], ), ) @@ -154,8 +153,8 @@ describe("Session.updateMessage", () => { const target = AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [ - [Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })], - [Bus.node, Bus.configured({ persist: true })], + Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })), + Bus.node.replace(Bus.configured({ persist: true })), ], ) diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index 7ca0852d6e7f..99eb74c9a12f 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -20,9 +20,13 @@ import { testEffect } from "./lib/effect" const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] }) const it = testEffect( - LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), [ - [SessionModelTransport.node, SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") })], - ]), + LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), { + replacements: [ + SessionModelTransport.node.replace( + SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") }), + ), + ], + }), ) const requestInput = (model: LanguageModel) => ({ diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index a46ead5c962b..92da4bef73c1 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -24,10 +24,7 @@ import { globalProjectNode } from "./lib/project" const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), - [ - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], - ], + [Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer)], ), ) const unavailableLocations = Layer.effect( @@ -40,9 +37,9 @@ const itWithUnavailableDestination = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], - [LocationServiceMap.node, unavailableLocations], + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), + LocationServiceMap.node.replace(unavailableLocations), ], ), ) diff --git a/packages/core/test/session-owned.test.ts b/packages/core/test/session-owned.test.ts index 36d70cc4b628..50ba8e3114df 100644 --- a/packages/core/test/session-owned.test.ts +++ b/packages/core/test/session-owned.test.ts @@ -49,10 +49,9 @@ const it = testEffect( SessionInbox.node, FSUtil.node, ]), - [ - [Bus.node, Bus.configured({ persist: true })], - [Global.node, tempGlobalLayer], - ], + { + replacements: [Bus.node.replace(Bus.configured({ persist: true })), Global.node.replace(tempGlobalLayer)], + }, ), ) const sessionID = SessionSchema.ID.make("ses_owned") diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index a987ca217b7c..f162f1f6adbb 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -33,10 +33,10 @@ import { Snapshot } from "@opencode-ai/core/snapshot" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), ) -const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]]) +const sessionsLayer = AppNodeBuilder.build(Session.node, [SessionExecution.node.replace(SessionExecution.noopLayer)]) const sessionID = Session.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") } diff --git a/packages/core/test/session-prompt-hooks.test.ts b/packages/core/test/session-prompt-hooks.test.ts index 599c8707e7fb..27193f2a2cd4 100644 --- a/packages/core/test/session-prompt-hooks.test.ts +++ b/packages/core/test/session-prompt-hooks.test.ts @@ -38,11 +38,11 @@ const it = testEffect( PluginRuntime.providerNodeWithCell(runtime), ]), [ - [Bus.node, Bus.configured({ persist: true })], - [Global.node, tempGlobalLayer], - [Watcher.node, Watcher.configured({ enabled: false })], - [SessionExecution.node, SessionExecution.noopLayer], - [PluginRuntime.node, PluginRuntime.layerWithCell(runtime)], + Bus.node.replace(Bus.configured({ persist: true })), + Global.node.replace(tempGlobalLayer), + Watcher.node.replace(Watcher.configured({ enabled: false })), + SessionExecution.node.replace(SessionExecution.noopLayer), + PluginRuntime.node.replace(PluginRuntime.layerWithCell(runtime)), ], ), ) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index f1cbb8a954c2..7c5939606951 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -86,9 +86,9 @@ const locations = makeGlobalNode({ return Layer.merge(SessionRevert.layer, SessionPrompt.layer).pipe( Layer.provideMerge( Layer.mergeAll( - LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), [ - [Bus.node, Layer.succeed(Bus.Service, bus)], - ]), + LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), { + replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))], + }), Layer.mock(Image.Service, { normalize: (_resource, content) => ready @@ -122,9 +122,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [SessionExecution.node, execution], - [LocationServiceMap.node, locations], + Bus.node.replace(Bus.configured({ persist: true })), + SessionExecution.node.replace(execution), + LocationServiceMap.node.replace(locations), ], ), ) diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts index 2ef15c97ba4e..5f8ced6cd9aa 100644 --- a/packages/core/test/session-remove.test.ts +++ b/packages/core/test/session-remove.test.ts @@ -39,9 +39,9 @@ const it = testEffect( LocationServiceMap.node, ]), [ - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], - [SessionModelTransport.node, transport], + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), + SessionModelTransport.node.replace(transport), ], ), ) diff --git a/packages/core/test/session-revert.test.ts b/packages/core/test/session-revert.test.ts index 525ee2a6b5e0..9080771aa9a3 100644 --- a/packages/core/test/session-revert.test.ts +++ b/packages/core/test/session-revert.test.ts @@ -31,9 +31,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [Global.node, tempGlobalLayer], - [SessionExecution.node, SessionExecution.noopLayer], + Bus.node.replace(Bus.configured({ persist: true })), + Global.node.replace(tempGlobalLayer), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 6404e1798800..dd8e05825c01 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -1,6 +1,6 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat" -import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/ai/route" +import { Auth, LLMClient, type LLMClientService, RequestExecutor } from "@opencode-ai/ai/route" import { Catalog } from "@opencode-ai/core/catalog" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -100,22 +100,22 @@ const promptCatalog = Layer.mock(Catalog.Service, { small: () => Effect.undefined, }, }) -const runnerLayer = (llmClient: Layer.Layer) => +const runnerLayer = (llmClient: Layer.Layer) => AppNodeBuilder.build(SessionRunnerLLM.node, [ - [Snapshot.node, Snapshot.noopLayer], - [LayerNodePlatform.llmClient, llmClient], - [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, systemContext], - [InstructionDiscovery.node, instructionContext], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillInstructions.node, skillInstructions], - [ReferenceInstructions.node, referenceInstructions], - [McpInstructions.node, mcpInstructions], - [Config.node, config], - [Permission.node, permission], - [PluginSupervisor.node, pluginSupervisor], + Snapshot.node.replace(Snapshot.noopLayer), + LayerNodePlatform.llmClient.replace(llmClient), + SessionRunnerModel.node.replace(models), + InstructionBuiltIns.node.replace(systemContext), + InstructionDiscovery.node.replace(instructionContext), + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), + SkillInstructions.node.replace(skillInstructions), + ReferenceInstructions.node.replace(referenceInstructions), + McpInstructions.node.replace(mcpInstructions), + Config.node.replace(config), + Permission.node.replace(permission), + PluginSupervisor.node.replace(pluginSupervisor), ]) -const execution = (llmClient: Layer.Layer) => +const execution = (llmClient: Layer.Layer) => Layer.effect( SessionExecution.Service, Effect.gen(function* () { @@ -133,7 +133,7 @@ const execution = (llmClient: Layer.Layer) => }) }), ).pipe(Layer.provide(runnerLayer(llmClient))) -const testLayer = (llmClient: Layer.Layer) => +const testLayer = (llmClient: Layer.Layer) => AppNodeBuilder.build( LayerNode.group([ Database.node, @@ -155,21 +155,21 @@ const testLayer = (llmClient: Layer.Layer) => Session.node, ]), [ - [Bus.node, Bus.configured({ persist: true })], - [LocationServiceMap.node, promptLocationNode], - [LayerNodePlatform.llmClient, llmClient], - [Permission.node, permission], - [Catalog.node, promptCatalog], - [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, systemContext], - [InstructionDiscovery.node, instructionContext], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillInstructions.node, skillInstructions], - [ReferenceInstructions.node, referenceInstructions], - [Config.node, config], - [Snapshot.node, Snapshot.noopLayer], - [PluginSupervisor.node, pluginSupervisor], - [SessionExecution.node, execution(llmClient)], + Bus.node.replace(Bus.configured({ persist: true })), + LocationServiceMap.node.replace(promptLocationNode), + LayerNodePlatform.llmClient.replace(llmClient), + Permission.node.replace(permission), + Catalog.node.replace(promptCatalog), + SessionRunnerModel.node.replace(models), + InstructionBuiltIns.node.replace(systemContext), + InstructionDiscovery.node.replace(instructionContext), + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), + SkillInstructions.node.replace(skillInstructions), + ReferenceInstructions.node.replace(referenceInstructions), + Config.node.replace(config), + Snapshot.node.replace(Snapshot.noopLayer), + PluginSupervisor.node.replace(pluginSupervisor), + SessionExecution.node.replace(execution(llmClient)), ], ) const it = testEffect(testLayer(client)) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 958a5310744d..7f5cb76f0b7c 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -127,7 +127,7 @@ test("provider-executed success derives content and retains provider result stat testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), ).effect("commits a hosted tool result when cancellation races with the aggregate lock", () => Effect.gen(function* () { diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index ca1d965543c3..8670cdae6b89 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -408,22 +408,22 @@ const layer = Layer.unwrap( }, }) const replacements: LayerNode.Replacements = [ - [Snapshot.node, Snapshot.noopLayer], - [LayerNodePlatform.llmClient, TestLLM.clientLayer], - [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, systemContext], - [InstructionDiscovery.node, instructionContext], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillInstructions.node, skillInstructions], - [ReferenceInstructions.node, referenceInstructions], - [Permission.node, permission], - [Config.node, config], - [PluginSupervisor.node, pluginSupervisor], - [SessionModelTransport.node, modelTransport], + Snapshot.node.replace(Snapshot.noopLayer), + LayerNodePlatform.llmClient.replace(TestLLM.clientLayer.pipe(Layer.provide(testLLM))), + SessionRunnerModel.node.replace(models), + InstructionBuiltIns.node.replace(systemContext), + InstructionDiscovery.node.replace(instructionContext), + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), + SkillInstructions.node.replace(skillInstructions), + ReferenceInstructions.node.replace(referenceInstructions), + Permission.node.replace(permission), + Config.node.replace(config), + PluginSupervisor.node.replace(pluginSupervisor), + SessionModelTransport.node.replace(modelTransport), ] const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ ...replacements, - [McpInstructions.node, mcpInstructions], + McpInstructions.node.replace(mcpInstructions), ]) const execution = Layer.effect( SessionExecution.Service, @@ -485,10 +485,10 @@ const layer = Layer.unwrap( ]), [ ...replacements, - [Bus.node, Bus.configured({ persist: true })], - [LocationServiceMap.node, promptLocationNode], - [Catalog.node, promptCatalog], - [SessionExecution.node, execution], + Bus.node.replace(Bus.configured({ persist: true })), + LocationServiceMap.node.replace(promptLocationNode), + Catalog.node.replace(promptCatalog), + SessionExecution.node.replace(execution), ], ) }), diff --git a/packages/core/test/session-shell.test.ts b/packages/core/test/session-shell.test.ts index 46546c5a961f..a0cb863c7ef1 100644 --- a/packages/core/test/session-shell.test.ts +++ b/packages/core/test/session-shell.test.ts @@ -54,8 +54,8 @@ const executionLayer = Layer.effect( const it = testEffect( AppNodeBuilder.build(LayerNode.group([Bus.node, Session.node, SessionExecution.node, LocationServiceMap.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [SessionExecution.node, executionLayer], + Bus.node.replace(Bus.configured({ persist: true })), + SessionExecution.node.replace(executionLayer.pipe(Layer.provide(controlLayer))), ]).pipe(Layer.provideMerge(controlLayer)), ) diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 83b99acdd1f0..3ec344908536 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -67,9 +67,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [LocationServiceMap.node, locations], - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], + LocationServiceMap.node.replace(locations), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) diff --git a/packages/core/test/session-step.test.ts b/packages/core/test/session-step.test.ts index e993f66fb171..063ade46afa0 100644 --- a/packages/core/test/session-step.test.ts +++ b/packages/core/test/session-step.test.ts @@ -27,7 +27,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( Layer.merge( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, ToolOutput.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), TestLLM.testLayer(), ), diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 5422eedef342..8f508d1bb4ba 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -126,11 +126,11 @@ const it = testEffect( SessionTitle.node, ]), [ - [llmClient, client], - [Catalog.node, catalog], - [SessionRunnerModel.node, models], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [PluginSupervisor.node, Layer.mock(PluginSupervisor.Service, { flush: Effect.void })], + llmClient.replace(client), + Catalog.node.replace(catalog), + SessionRunnerModel.node.replace(models), + Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })), + PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { flush: Effect.void })), ], ), ) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index d37fed2b9cdd..191d8be18bdf 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -21,7 +21,7 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [ - [Bus.node, Bus.configured({ persist: true })], + Bus.node.replace(Bus.configured({ persist: true })), ]), ) const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") } diff --git a/packages/core/test/session-view.test.ts b/packages/core/test/session-view.test.ts index f7e74c73cb9c..e6b244785bcc 100644 --- a/packages/core/test/session-view.test.ts +++ b/packages/core/test/session-view.test.ts @@ -25,9 +25,9 @@ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ - [Bus.node, Bus.configured({ persist: true })], - [Project.node, globalProjectNode], - [SessionExecution.node, SessionExecution.noopLayer], + Bus.node.replace(Bus.configured({ persist: true })), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) @@ -186,8 +186,8 @@ describe("Session.view", () => { const targetLayer = AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [ - [Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })], - [Bus.node, Bus.configured({ persist: true })], + Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })), + Bus.node.replace(Bus.configured({ persist: true })), ], ) diff --git a/packages/core/test/session-wait.test.ts b/packages/core/test/session-wait.test.ts index e6947b13b818..1f01d9803c06 100644 --- a/packages/core/test/session-wait.test.ts +++ b/packages/core/test/session-wait.test.ts @@ -22,10 +22,7 @@ const execution = Layer.mock(SessionExecution.Service, { const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), - [ - [Project.node, globalProjectNode], - [SessionExecution.node, execution], - ], + [Project.node.replace(globalProjectNode), SessionExecution.node.replace(execution)], ), ) diff --git a/packages/core/test/shell-cleanup.test.ts b/packages/core/test/shell-cleanup.test.ts index 41804a25e0bf..30b8cdd315d7 100644 --- a/packages/core/test/shell-cleanup.test.ts +++ b/packages/core/test/shell-cleanup.test.ts @@ -14,7 +14,7 @@ const withStore = (body: (fs: FSUtil.Interface, root: string) => Effect Effect.promise(() => tmpdir()), (tmp) => { const layer = AppNodeBuilder.build(LayerNode.group([FSUtil.node, Global.node]), [ - [Global.node, Global.layerWith({ data: tmp.path })], + Global.node.replace(Global.layerWith({ data: tmp.path })), ]) return Effect.gen(function* () { const fs = yield* FSUtil.Service diff --git a/packages/core/test/skill-discovery.test.ts b/packages/core/test/skill-discovery.test.ts index b8dc53e2896f..0c49d558301c 100644 --- a/packages/core/test/skill-discovery.test.ts +++ b/packages/core/test/skill-discovery.test.ts @@ -44,7 +44,7 @@ const fixture = Effect.gen(function* () { return yield* discovery.pull(base) }).pipe( Effect.provide( - AppNodeBuilder.build(SkillDiscovery.node, [[Global.node, Global.layerWith({ cache: tmp.path })]]), + AppNodeBuilder.build(SkillDiscovery.node, [Global.node.replace(Global.layerWith({ cache: tmp.path }))]), ), ) return { directories, requests: state.requests.slice() } diff --git a/packages/core/test/skill/instructions.test.ts b/packages/core/test/skill/instructions.test.ts index dcb54d478edd..c3e352dc6504 100644 --- a/packages/core/test/skill/instructions.test.ts +++ b/packages/core/test/skill/instructions.test.ts @@ -41,7 +41,7 @@ const manual = Skill.Info.make({ const layer = (list: () => Skill.Info[]) => AppNodeBuilder.build(SkillInstructions.node, [ - [Skill.node, Layer.mock(Skill.Service, { list: () => Effect.succeed(list()) })], + Skill.node.replace(Layer.mock(Skill.Service, { list: () => Effect.succeed(list()) })), ]) describe("SkillInstructions", () => { diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 122818facd53..1f1027ac9f9b 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -54,9 +54,9 @@ describe("Snapshot", () => { }, }) const layer = AppNodeBuilder.build(Snapshot.node, [ - [Location.node, Layer.succeed(Location.Service, location)], - [Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })], - [Git.node, Layer.succeed(Git.Service, instrumented)], + Location.node.replace(Layer.succeed(Location.Service, location)), + Global.node.replace(Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })), + Git.node.replace(Layer.succeed(Git.Service, instrumented)), ]) yield* Effect.gen(function* () { @@ -239,8 +239,8 @@ describe("Snapshot", () => { function snapshotLayer(data: string, directory: string) { return AppNodeBuilder.build(Snapshot.node, [ - [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))], - [Global.node, Global.layerWith({ data, config: path.join(data, "config") })], + Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), + Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })), ]) } diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index fa0b3e22a270..9cdfe584def9 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -92,8 +92,7 @@ const withTool = ( }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [ - [ - Environment.node, + Environment.node.replace( transformEnvironmentFiles((files) => ({ read: (target, range) => files @@ -104,10 +103,10 @@ const withTool = ( write: (target, content) => Effect.sync(() => fixture.writes.push(target)).pipe(Effect.andThen(files.write(target, content))), })), - ], - [Location.node, activeLocation], - [Formatter.node, fixture.formatter], - [Permission.node, fixture.permission], + ), + Location.node.replace(activeLocation), + Formatter.node.replace(fixture.formatter), + Permission.node.replace(fixture.permission), ]), ), ) diff --git a/packages/core/test/tool-output.test.ts b/packages/core/test/tool-output.test.ts index b5fa2d14aade..1c14c9acada3 100644 --- a/packages/core/test/tool-output.test.ts +++ b/packages/core/test/tool-output.test.ts @@ -18,7 +18,7 @@ const withStore = ( Effect.promise(() => tmpdir()), (tmp) => { const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [ - [Global.node, Global.layerWith({ data: tmp.path })], + Global.node.replace(Global.layerWith({ data: tmp.path })), ]) return Effect.gen(function* () { const output = yield* ToolOutput.Service diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 0295b042009f..30e95e5486ba 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -100,8 +100,7 @@ const withTool = ( }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [ - [ - Environment.node, + Environment.node.replace( transformEnvironmentFiles((files) => ({ read: (target, range) => Effect.sync(() => { @@ -120,10 +119,10 @@ const withTool = ( return files.write(target, content) }, })), - ], - [Location.node, activeLocation], - [Formatter.node, formatter], - [Permission.node, permission], + ), + Location.node.replace(activeLocation), + Formatter.node.replace(formatter), + Permission.node.replace(permission), ]), ), ) diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 520dbe3225f9..ec5becae5fd1 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -66,9 +66,9 @@ const questionToolNode = makeLocationNode({ const it = testEffect( AppNodeBuilder.build(LayerNode.group([Tool.node, questionToolNode]), [ - [Permission.node, permission], - [Form.node, form], - [Image.node, imagePassthrough], + Permission.node.replace(permission), + Form.node.replace(form), + Image.node.replace(imagePassthrough), ]), ) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 85a19212c6e0..e747002c4a73 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -134,14 +134,14 @@ const unavailableImage = Layer.mock(Image.Service, { const readLayer = (imageLayer: Layer.Layer) => Layer.mergeAll( AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [ - [ReadToolFileSystem.node, reader], - [Permission.node, permission], - [Config.node, config], - [Image.node, imageLayer], - [LocationMutation.node, mutation], - [FSUtil.node, testFileSystem], - [Location.node, locationLayer], - [Global.node, Global.layerWith({ data: Global.Path.data })], + ReadToolFileSystem.node.replace(reader), + Permission.node.replace(permission), + Config.node.replace(config), + Image.node.replace(imageLayer), + LocationMutation.node.replace(mutation), + FSUtil.node.replace(testFileSystem), + Location.node.replace(locationLayer), + Global.node.replace(Global.layerWith({ data: Global.Path.data })), ]), // Merge by reference so Config.Test and Image.Service resolve to the memoized instances. config, diff --git a/packages/core/test/tool-registry.test.ts b/packages/core/test/tool-registry.test.ts index b25643aab421..b38826585c15 100644 --- a/packages/core/test/tool-registry.test.ts +++ b/packages/core/test/tool-registry.test.ts @@ -43,7 +43,7 @@ const imageStore = Layer.mock(Image.Service, { }, }) const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node, SessionModelRequest.node]), [ - [Image.node, imageStore], + Image.node.replace(imageStore), ]) const it = testEffect(registryLayer) const identity = { diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 0627d3555e96..dec824ab2387 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -45,19 +45,17 @@ const withTools = ( }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, globToolNode, grepToolNode]), [ - [ - Location.node, + Location.node.replace( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - ], - [ - Permission.node, + ), + Permission.node.replace( permissionLayer({ assert: (input) => Effect.sync(() => { assertions?.push(input) }), }), - ], + ), ]), ), ) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 3df6393523f9..b195af9f559e 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -156,17 +156,19 @@ const nodes = LayerNode.group([ Global.node, ]) const replacements = [ - [SessionExecution.node, executionNode], - [Permission.node, permission], - [Global.node, tempGlobalLayer], + SessionExecution.node.replace(executionNode), + Permission.node.replace(permission), + Global.node.replace(tempGlobalLayer), ] satisfies LayerNode.Replacements const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements)) -const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]])) +const it = testEffect( + AppNodeBuilder.build(nodes, [...replacements, PluginSupervisor.node.replace(shellPluginSupervisor)]), +) const permissionIt = testEffect( AppNodeBuilder.build(LayerNode.group([nodes, PermissionSaved.node]), [ - [SessionExecution.node, executionNode], - [Global.node, tempGlobalLayer], - [PluginSupervisor.node, shellPluginSupervisor], + SessionExecution.node.replace(executionNode), + Global.node.replace(tempGlobalLayer), + PluginSupervisor.node.replace(shellPluginSupervisor), ]), ) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index a4f93e1c1116..ecd27383a032 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -74,9 +74,9 @@ describe("SkillTool", () => { list: () => Effect.succeed(current), }) const skillToolLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, skillToolNode]), [ - [Permission.node, permission], - [Skill.node, skills], - [Image.node, imagePassthrough], + Permission.node.replace(permission), + Skill.node.replace(skills), + Image.node.replace(imagePassthrough), ]) return yield* Effect.gen(function* () { diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index b028dd8bfa40..460b800cedef 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -122,18 +122,19 @@ const nodes = LayerNode.group([ LocationServiceMap.node, ]) const replacements = [ - [SessionExecution.node, executionNode], - [Global.node, tempGlobalLayer], + SessionExecution.node.replace(executionNode), + Global.node.replace(tempGlobalLayer), ] satisfies LayerNode.Replacements const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements)) -const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]])) +const it = testEffect( + AppNodeBuilder.build(nodes, [...replacements, PluginSupervisor.node.replace(subagentPluginSupervisor)]), +) const completionIt = testEffect( AppNodeBuilder.build(LayerNode.group([nodes, SessionRestart.node, KV.node]), [ - [Global.node, tempGlobalLayer], - [PluginSupervisor.node, subagentPluginSupervisor], - [LayerNodePlatform.llmClient, TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })], - [ - SessionRunnerModel.node, + Global.node.replace(tempGlobalLayer), + PluginSupervisor.node.replace(subagentPluginSupervisor), + LayerNodePlatform.llmClient.replace(TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })), + SessionRunnerModel.node.replace( Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed( @@ -147,7 +148,7 @@ const completionIt = testEffect( ), ), }), - ], + ), ]), ) diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 1d5d73c17626..185ef93de85b 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -42,11 +42,11 @@ const http = Layer.succeed( const permission = permissionLayer({ assert: (input) => Effect.sync(() => assertions.push(input)) }) const toolLayer = (replacements: LayerNode.Replacements = []) => AppNodeBuilder.build(LayerNode.group([Tool.node, webFetchToolNode]), [ - [Permission.node, permission], - [Image.node, imagePassthrough], + Permission.node.replace(permission), + Image.node.replace(imagePassthrough), ...replacements, ]) -const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]])) +const it = testEffect(toolLayer([LayerNodePlatform.httpClient.replace(http)])) const live = testEffect(toolLayer()) const reset = () => { diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 704d5a9ee68a..4259599546d4 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -70,8 +70,7 @@ const setup = Effect.gen(function* () { ) const context = yield* Layer.build( AppNodeBuilder.build(LayerNode.group([Tool.node, webSearchToolNode]), [ - [ - Permission.node, + Permission.node.replace( permissionLayer({ assert: (input) => Effect.sync(() => { @@ -79,10 +78,9 @@ const setup = Effect.gen(function* () { fixture.assertions.push(input) }), }), - ], - [WebSearch.node, Layer.succeed(WebSearch.Service, websearch)], - [ - Form.node, + ), + WebSearch.node.replace(Layer.succeed(WebSearch.Service, websearch)), + Form.node.replace( Layer.mock(Form.Service, { ask: (input) => Effect.gen(function* () { @@ -91,8 +89,8 @@ const setup = Effect.gen(function* () { return fixture.formResponses.shift() ?? fixture.formResponse }), }), - ], - [Image.node, imagePassthrough], + ), + Image.node.replace(imagePassthrough), ]), ) return Object.assign(fixture, { websearch, kv, registry: Context.get(context, Tool.Service) }) diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 52fc4a92ad28..f1a18a28e8f1 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -80,16 +80,15 @@ const withTool = ( }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [ - [ - Environment.node, + Environment.node.replace( transformEnvironmentFiles((files) => ({ write: (target, content) => Effect.sync(() => fixture.writes.push(target)).pipe(Effect.andThen(files.write(target, content))), })), - ], - [Location.node, activeLocation], - [Formatter.node, fixture.formatter], - [Permission.node, fixture.permission], + ), + Location.node.replace(activeLocation), + Formatter.node.replace(fixture.formatter), + Permission.node.replace(fixture.permission), ]), ), ) diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index 4af0445c8c33..ff4e074e30b9 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -55,7 +55,7 @@ const testGlobal = Global.layerWith({ log: os.tmpdir(), }) -const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]]) +const testLayer = LayerNode.compile(EffectFlock.node, { replacements: [Global.node.replace(testGlobal)] }) // --------------------------------------------------------------------------- // Tests diff --git a/packages/core/test/vcs-hg.test.ts b/packages/core/test/vcs-hg.test.ts index 5469c16e93ff..3563b70fa987 100644 --- a/packages/core/test/vcs-hg.test.ts +++ b/packages/core/test/vcs-hg.test.ts @@ -22,20 +22,21 @@ const describeHg = Bun.which("hg") ? describe : describe.skip const provide = (directory: string) => Effect.provide( - LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node]), [ - [ - Location.node, - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { vcs: { type: "hg", store: AbsolutePath.make(path.join(directory, ".hg")) } }, + LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node]), { + replacements: [ + Location.node.replace( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { vcs: { type: "hg", store: AbsolutePath.make(path.join(directory, ".hg")) } }, + ), ), ), ), ], - ]), + }), ) const withTmp = (f: (directory: string) => Effect.Effect) => diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts index bb14ef9f437b..2ee1a96d379a 100644 --- a/packages/core/test/vcs.test.ts +++ b/packages/core/test/vcs.test.ts @@ -22,25 +22,26 @@ import { host } from "./plugin/host" const provide = (directory: string, input: { git?: boolean; worktree?: string } = {}) => Effect.provide( - LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node, Git.node]), [ - [ - Location.node, - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { - projectDirectory: input.worktree ? AbsolutePath.make(input.worktree) : undefined, - ...(input.git - ? { vcs: { type: "git", store: AbsolutePath.make(path.join(input.worktree ?? directory, ".git")) } } - : {}), - }, + LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node, Git.node]), { + replacements: [ + Location.node.replace( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { + projectDirectory: input.worktree ? AbsolutePath.make(input.worktree) : undefined, + ...(input.git + ? { vcs: { type: "git", store: AbsolutePath.make(path.join(input.worktree ?? directory, ".git")) } } + : {}), + }, + ), ), ), ), ], - ]), + }), ) const withTmp = (f: (directory: string) => Effect.Effect) => diff --git a/packages/core/test/workspace.test.ts b/packages/core/test/workspace.test.ts index f1944ca431cb..3797cfdffe59 100644 --- a/packages/core/test/workspace.test.ts +++ b/packages/core/test/workspace.test.ts @@ -41,7 +41,7 @@ const driver = WorkspaceDriver.make({ const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Workspace.configured({ idleThreshold: "5 minutes", pollInterval: "1 minute" })]), - [[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: driver, other: driver })]], + [WorkspaceDriver.node.replace(WorkspaceDriver.registryNode({ fake: driver, other: driver }))], ), ) diff --git a/packages/sdk/src/internal/host.ts b/packages/sdk/src/internal/host.ts index 7adb2111f73e..66f8b6884a19 100644 --- a/packages/sdk/src/internal/host.ts +++ b/packages/sdk/src/internal/host.ts @@ -35,7 +35,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* ( database: { path: ":memory:", ...server.database }, }, workspaceProviders - ? [...(embed.overrides ?? []), [WorkspaceDriver.node, WorkspaceDriver.registryNode(workspaceProviders)]] + ? [...(embed.overrides ?? []), WorkspaceDriver.node.replace(WorkspaceDriver.registryNode(workspaceProviders))] : embed.overrides, ).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(layer(log))), ) diff --git a/packages/sdk/test/embedded.test.ts b/packages/sdk/test/embedded.test.ts index 1a7fd4661ec5..2a7f7a13d883 100644 --- a/packages/sdk/test/embedded.test.ts +++ b/packages/sdk/test/embedded.test.ts @@ -43,13 +43,15 @@ for (const selection of ["explicit", "default"] as const) { const llm = yield* TestLLM.Test.pipe( Effect.provide(TestLLM.testLayer({ fallback: TestLLM.text("ready", "answer") })), ) - const supervisor = Layer.effect( - PluginSupervisor.Service, - Effect.gen(function* () { - const plugins = yield* PluginSupervisor.Service - return { flush: release.open.pipe(Effect.andThen(plugins.flush)) } - }), - ).pipe(Layer.provide(PluginSupervisor.layer)) + const supervisor = PluginSupervisor.node.mapLayer((layer) => + Layer.effect( + PluginSupervisor.Service, + Effect.gen(function* () { + const plugins = yield* PluginSupervisor.Service + return { flush: release.open.pipe(Effect.andThen(plugins.flush)) } + }), + ).pipe(Layer.provide(layer)), + ) const opencode = yield* fixture.sdk.OpenCode.create( { config: { @@ -71,8 +73,8 @@ for (const selection of ["explicit", "default"] as const) { }, { overrides: [ - [llmClient, Layer.succeed(LLMClient.Service, llm)], - [PluginSupervisor.node, { ...PluginSupervisor.node, implementation: supervisor }], + llmClient.replace(Layer.succeed(LLMClient.Service, llm)), + PluginSupervisor.node.replace(supervisor), ], }, ) @@ -685,10 +687,7 @@ const workspaceModelScenario = (fixture: Fixture, policy: "eager" | "lazy") => workspaceProviders: { fake: driver }, }, { - overrides: [ - [llmClient, client], - [SessionRunnerModel.node, models], - ], + overrides: [llmClient.replace(client), SessionRunnerModel.node.replace(models)], }, ) const workspaceID = yield* opencode.workspace.create({ provider: "fake" }) @@ -790,8 +789,8 @@ it.live( }, { overrides: [ - [llmClient, Layer.succeed(LLMClient.Service, llm)], - [SessionRunnerModel.node, models], + llmClient.replace(Layer.succeed(LLMClient.Service, llm)), + SessionRunnerModel.node.replace(models), ], }, ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 9d2ee4a7c27a..4c9cd55c5552 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -100,35 +100,33 @@ function makeRoutes( ) { const pluginRuntimeCell = PluginRuntime.makeCell() const standard: LayerNode.Replacements = [ - [Database.node, Database.configured(options.database)], - [PersistentPty.node, PersistentPty.configured(options.pty)], - [Bus.node, Bus.configured({ persist: options.events?.persist })], - [App.node, App.configured(options.app)], - [ModelsDev.node, ModelsDev.configured(options.models)], - [Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })], - [FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })], - [Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})], - [ - Config.node, + Database.node.replace(Database.configured(options.database)), + PersistentPty.node.replace(PersistentPty.configured(options.pty)), + Bus.node.replace(Bus.configured({ persist: options.events?.persist })), + App.node.replace(App.configured(options.app)), + ModelsDev.node.replace(ModelsDev.configured(options.models)), + Watcher.node.replace(Watcher.configured({ enabled: options.fs?.filewatcher })), + FileSystemSearch.node.replace(FileSystemSearch.configured({ fff: options.fs?.fff })), + Global.node.replace(Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})), + Config.node.replace( Config.configured({ project: options.config?.project, file: options.config?.file, content: options.config?.content, }), - ], - [InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })], - [ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })], - [ - Mcp.node, + ), + InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: options.config?.project })), + ShellSelect.node.replace(ShellSelect.configured({ gitbash: options.windows?.gitbash })), + Mcp.node.replace( Mcp.configured({ clientInfo: { name: options.app?.name ?? "opencode", version: options.app?.version ?? "unknown", }, }), - ], - [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], - [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], + ), + PluginRuntime.node.replace(PluginRuntime.layerWithCell(pluginRuntimeCell)), + PluginRuntime.providerNode.replace(PluginRuntime.providerNodeWithCell(pluginRuntimeCell)), ] const replacements: LayerNode.Replacements = [...standard, ...overrides] const serviceLayer = options.simulation diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts index a27dadadba79..2d2e23f865e9 100644 --- a/packages/server/src/workerd.ts +++ b/packages/server/src/workerd.ts @@ -75,16 +75,16 @@ export function serverOptions(options: Options): ServerOptions { /** The workerd replacement graph, applied after the standard server replacements. */ export function replacements(options: Options): LayerNode.Replacements { return [ - [Database.node, Database.configuredClient(sqliteLayer({ storage: options.storage }))], - [CrossSpawnSpawner.node, EnvironmentUnavailable.layer], - [Snapshot.node, Snapshot.noopLayer], - [Vcs.node, vcsLayer], - [FileSystem.node, fileSystemLayer], - [FileSystemSearch.node, fileSystemSearchLayer], - [Pty.node, ptyLayer], + Database.node.replace(Database.configuredClient(sqliteLayer({ storage: options.storage }))), + CrossSpawnSpawner.node.replace(EnvironmentUnavailable.layer), + Snapshot.node.replace(Snapshot.noopLayer), + Vcs.node.replace(vcsLayer), + FileSystem.node.replace(fileSystemLayer), + FileSystemSearch.node.replace(fileSystemSearchLayer), + Pty.node.replace(ptyLayer), // Precompiled (internal and SDK) plugins only: no plugin-directory scan, npm // install, or import of plugin code from disk. - [ConfigPluginSource.node, ConfigPluginSource.empty], + ConfigPluginSource.node.replace(ConfigPluginSource.empty), ] } diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index c7a3cc98d377..3b59b9bf71a7 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -255,7 +255,7 @@ it.live("creates idempotent caller-identified workspaces through the HttpApi", ( Effect.gen(function* () { const handler = yield* ServerFetch.make(options, { overrides: [ - [WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: workspaceDriver, other: workspaceDriver })], + WorkspaceDriver.node.replace(WorkspaceDriver.registryNode({ fake: workspaceDriver, other: workspaceDriver })), ], }) const id = Workspace.ID.create() diff --git a/packages/server/test/generate.test.ts b/packages/server/test/generate.test.ts index 7c64aef94dea..a61c4f0ec0fc 100644 --- a/packages/server/test/generate.test.ts +++ b/packages/server/test/generate.test.ts @@ -48,7 +48,7 @@ it.live("uses base configuration without depending on process.cwd()", () => config: { directory: global }, fs: { filewatcher: false }, }, - { overrides: [[Generate.node, generate]] }, + { overrides: [Generate.node.replace(generate)] }, ) expect(global).not.toBe(process.cwd()) diff --git a/packages/server/test/session-message-update.test.ts b/packages/server/test/session-message-update.test.ts index ca0867b68f98..24a6fcdf519d 100644 --- a/packages/server/test/session-message-update.test.ts +++ b/packages/server/test/session-message-update.test.ts @@ -8,6 +8,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionMessage } from "@opencode-ai/core/session/message" import { Money } from "@opencode-ai/schema/money" +import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { Effect, Layer } from "effect" import { it } from "../../core/test/lib/effect" import { ServerFetch } from "../src/fetch" @@ -53,7 +54,13 @@ it.live("updates completed assistant message content through the session HTTP AP ) const handler = yield* ServerFetch.make( { app: { version: "test-version" }, database: { path: ":memory:" }, fs: { filewatcher: false } }, - { overrides: [[SessionExecution.node, execution]] }, + { + overrides: [ + SessionExecution.node.replace( + makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }), + ), + ], + }, ) const created = yield* Effect.promise(() => handler( diff --git a/packages/server/test/vcs.test.ts b/packages/server/test/vcs.test.ts index 760854fac955..59921c5451ba 100644 --- a/packages/server/test/vcs.test.ts +++ b/packages/server/test/vcs.test.ts @@ -77,8 +77,7 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", () { database: { path: ":memory:" }, config: { directory: tmp.path }, fs: { filewatcher: false } }, { overrides: [ - [ - SdkPlugins.node, + SdkPlugins.node.replace( Layer.succeed( SdkPlugins.Service, SdkPlugins.Service.of({ @@ -106,7 +105,7 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", () ], }), ), - ], + ), ], }, ) diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index 537405b44b45..04c2d588d344 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -23,37 +23,39 @@ import { SimulatedProvider } from "./simulated-provider" export const simulationReplacements: (app: { readonly version: string -}) => Effect.Effect = Effect.fn("Simulation.replacements")(function* (app: { - readonly version: string -}) { - // ModelsDev dies when its catalog fetch fails, so simulation answers it with - // an empty catalog; providers come from seeded config instead. - const models = SimulationNetwork.json("GET", "https://models.opencode.ai/api.json", {}) - const drive = yield* Config.string("OPENCODE_DRIVE").pipe(Config.withDefault(undefined)) - if (!drive) return [[httpClient, SimulationNetwork.layer([models])]] satisfies LayerNode.Replacements +}) => Effect.Effect = Effect.fn("Simulation.replacements")( + function* (app: { readonly version: string }) { + // ModelsDev dies when its catalog fetch fails, so simulation answers it with + // an empty catalog; providers come from seeded config instead. + const models = SimulationNetwork.json("GET", "https://models.opencode.ai/api.json", {}) + const drive = yield* Config.string("OPENCODE_DRIVE").pipe(Config.withDefault(undefined)) + if (!drive) return [httpClient.replace(SimulationNetwork.layer([models]))] satisfies LayerNode.Replacements - const manifest = yield* DriveManifest.resolve() - const networkLayer = Layer.effect( - HttpClient.HttpClient, - Effect.gen(function* () { - const provider = yield* SimulatedProvider.Service - const network = yield* SimulationNetwork.make([SimulationOpenAI.route(provider), models]) - return network.client - }), - ).pipe( - Layer.provide( - SimulatedProvider.layerDrive({ - endpoint: manifest.endpoints.backend, - version: app.version, + const manifest = yield* DriveManifest.resolve() + const networkLayer = Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const provider = yield* SimulatedProvider.Service + const network = yield* SimulationNetwork.make([SimulationOpenAI.route(provider), models]) + return network.client }), - ), - ) - const networkNode = makeGlobalNode({ - service: HttpClient.HttpClient, - layer: networkLayer, - deps: [SdkPlugins.node], - }) - return [[httpClient, networkNode]] satisfies LayerNode.Replacements -}) + ).pipe( + Layer.provide( + SimulatedProvider.layerDrive({ + endpoint: manifest.endpoints.backend, + version: app.version, + }), + ), + // The platform HTTP contract is infallible; a failed driver installation aborts startup. + Layer.orDie, + ) + const networkNode = makeGlobalNode({ + service: HttpClient.HttpClient, + layer: networkLayer, + deps: [SdkPlugins.node], + }) + return [httpClient.replace(networkNode)] satisfies LayerNode.Replacements + }, +) export * as Simulation from "./index" diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts index d016f6d1a24a..c6b17038a28e 100644 --- a/packages/simulation/test/simulated-provider.test.ts +++ b/packages/simulation/test/simulated-provider.test.ts @@ -701,7 +701,7 @@ const toolLifecycleLayer = (endpoint: string) => { }) return AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, provider]), - [[Config.node, Config.testLayer()]], + [Config.node.replace(Config.testLayer())], ) } diff --git a/packages/util/docs/layer-node.md b/packages/util/docs/layer-node.md new file mode 100644 index 000000000000..aa78972df20c --- /dev/null +++ b/packages/util/docs/layer-node.md @@ -0,0 +1,155 @@ +# Layer Graphs + +`LayerNode` describes replaceable wiring for ordinary Effect Layers. Declarations +and compilation do not acquire resources. Effect still owns acquisition, +memoization, scopes, and finalization. + +There are three values to compose: + +- A **Node** is a replaceable declaration with a fixed output, error, and tag + contract. `make` supplies its default implementation; `unbound` leaves that + implementation for the caller to supply. +- A **Graph** is a node or an ordered `group` of graphs. Grouping exposes the + selected roots, not their transitive dependencies. Groups are not replacement + targets. +- A **Replacement** is a checked instruction made by `node.replace(...)`. + Replacement arrays can be stored and concatenated without losing their checks. + +```ts +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" + +class Database extends Context.Service()("example/Database") {} +class Users extends Context.Service()("example/Users") {} + +const tags = LayerNode.tags({ global: [], location: ["global"] }) +const global = tags.make("global") +const location = tags.make("location") + +const database = global({ + service: Database, + layer: Layer.succeed(Database, { name: "live" }), + deps: [], +}) +const users = location({ + service: Users, + layer: Layer.effect( + Users, + Effect.map(Database, (db) => ({ name: db.name })), + ), + deps: [database], +}) + +const replacements: LayerNode.Replacements = [database.replace(Layer.succeed(Database, { name: "test" }))] + +const layer = LayerNode.compile(LayerNode.group([database, users]), { + replacements, + shared: tags.values.global, +}) +// Layer: both selected roots remain available. +``` + +## Substitution + +Replacements target the exact declaration, not its display name or service key. +Two independently declared nodes can provide the same service in different +branches without capturing each other's overrides. + +Keep the original declaration as the target when configuring a default: + +```ts +const replacements = [Database.node.replace(Database.configured(options.database)), ...profile.replacements] +``` + +The replacement may be a closed Layer or another node. It must provide all the +source's outputs, may provide additional outputs, must not introduce new errors, +and must retain the source's tag. An open Layer must first be wired into a node. +Replacing a node prunes its original dependencies; replacing it with another node +uses that node's dependencies instead. + +The rules are: + +- The last replacement for the same source wins, before any traversal. +- Replacements also apply inside replacement dependency graphs and through + replacement chains. +- Replacing a node with itself is the identity operation. +- Unreachable replacements neither acquire resources nor traverse their graphs. +- Unbound nodes and cycles in the effective graph fail during compilation. + +A node's contract is invariant. It cannot be widened or narrowed to authorize a +replacement that would violate another use of the original declaration. A graph +can forget outputs like an ordinary Layer, but cannot be used as a weaker +replacement handle. + +Choice and composition are different: `group([a, b])` provides both roots, while +`group([condition ? a : b])` promises only outputs common to both alternatives. +The same rule applies to conditional implementation Layers. Use tuples for known +roots; a dynamic array can be empty and therefore guarantees no service outputs. +Every alternative of a conditional replacement must satisfy the source contract. + +## Decoration + +Use `mapLayer` to decorate a default implementation without duplicating or +inspecting its dependencies: + +```ts +const observed = database.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.logDebug("database ready")))) +const replacements = [database.replace(observed)] +``` + +The callback is parametric in the Layer's requirements: it must preserve the +declared wiring and service/error contract. `mapLayer` returns a new node; it does +not mutate or automatically override the original. Only implemented nodes offer +this operation, not unbound slots or groups. + +## Lifetimes + +Without `shared`, compilation uses Effect's enclosing memo map throughout. +With `shared: tag`, matching nodes and their dependency subgraphs use that map; +remaining wiring uses one fresh map per build. A shared service's dependencies +must live at least as long as the service, including untagged helpers. This keeps application globals shared while isolating each +Location, without exposing incomplete graphs or losing shared root outputs. + +The enclosing memo map remains ambient during construction, so a lazy LayerMap +captures the shared ancestry rather than a Location's private cache. Cache +selection is internal metadata, not an extra service output of each dependency. + +Native Layer identity still controls acquisition within each memo map. Use a +distinct implementation Layer for differently wired definitions; compilation +rejects conflicting wiring of the same Layer within one graph's memo domain. +Compiled wiring is memoized once per build and memo domain, so a shared dependency +graph is not expanded into a tree during acquisition. Original Layer identities +still determine service sharing across separately compiled builds. An explicitly +fresh implementation refreshes its acquisition when its wiring is built. + +Groups preserve root startup order and associate transparently. A node's +dependencies are built concurrently through native `Layer.provide`. Named +startup actions and Context references are supported even when their required +service output type is `never`. + +## Migration + +| Previous API | Current API | +| ------------------------------------------------ | ----------------------------------------------- | +| `[source, replacement]` | `source.replace(replacement)` | +| `compile(graph, replacements)` | `compile(graph, { replacements })` | +| `hoist`, compile both halves, `fresh`, `provide` | `compile(graph, { replacements, shared: tag })` | +| Spread a node and overwrite `implementation` | `node.mapLayer(transform)` | +| Generic root parameter `Node` | `Graph` | + +An application can prepend a default binding and append caller overrides instead +of inspecting the original graph with `hasUnbound`. Unused defaults are pruned, +and defaults introduced as dependencies by an override are found naturally. + +Code that reconstructed a replacement source by name must retain the original +declaration instead. Compiler representation fields are no longer public. + +`PersistentPty.configured(options)` now returns a wired node, like the other Core +configuration factories. `PersistentPty.layer` remains the default raw Layer. + +## Verification + +Run `bun run typecheck:dist` in `packages/util` to build the package and check the +same positive and negative contract tests against its emitted declarations. +This ensures package consumers receive the same inference and replacement checks +as callers importing the source. diff --git a/packages/util/package.json b/packages/util/package.json index 8117eba8a385..75fe08613422 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -34,7 +34,8 @@ "scripts": { "build": "bun run script/build.ts", "test": "bun test --only-failures", - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "typecheck:dist": "bun run build && bun typecheck --project tsconfig.dist.json" }, "dependencies": { "@effect/opentelemetry": "catalog:", diff --git a/packages/util/src/effect/app-node.ts b/packages/util/src/effect/app-node.ts index e0491c9f0151..dd5cfce6cec7 100644 --- a/packages/util/src/effect/app-node.ts +++ b/packages/util/src/effect/app-node.ts @@ -5,8 +5,8 @@ export const tags = LayerNode.tags({ global: [], }) -export type GlobalNode = LayerNode.Node -export type LocationNode = LayerNode.Node +export type GlobalGraph = LayerNode.Graph +export type LocationGraph = LayerNode.Graph export const makeGlobalNode = tags.make("global") export const makeLocationNode = tags.make("location") diff --git a/packages/util/src/effect/layer-node.ts b/packages/util/src/effect/layer-node.ts index 325777ab7376..dd1de3cc5adb 100644 --- a/packages/util/src/effect/layer-node.ts +++ b/packages/util/src/effect/layer-node.ts @@ -1,57 +1,184 @@ -import { Brand, Context, Layer } from "effect" +import { Brand, Context, Effect, Layer, type Scope } from "effect" -type AnyNode = Node -type RuntimeLayer = Layer.Layer -type NodeList = readonly [] | readonly [Item, ...Item[]] -export type Output = [Item] extends [never] ? never : Item extends Node ? A : never -export type Error = [Item] extends [never] ? never : Item extends Node ? E : never -type NodeTag = [Item] extends [never] ? undefined : Item extends Node ? T : never -type Missing = Exclude> -type CheckDependencies = [ - Missing, Dependencies>, -] extends [never] - ? unknown - : { readonly "Missing dependencies": Missing, Dependencies> } -declare const $OutputType: unique symbol -declare const $ErrorType: unique symbol +export * as LayerNode from "./layer-node.js" -export type Tag = Name & Brand.Brand<"LayerNode.Tag"> +const GraphTypeId = Symbol("LayerNode.Graph") +const VarianceTypeId = Symbol("LayerNode.Variance") +const NodeTypeId = Symbol("LayerNode.Node") +const ReplacementTypeId = Symbol("LayerNode.Replacement") +export type Tag = Name & Brand.Brand<"LayerNode.Tag"> const makeTag = Brand.nominal() -export interface Node { - readonly kind: "layer" | "unbound" | "group" - readonly name: string - readonly service?: Context.Service.Any - readonly implementation?: Layer.Any - readonly dependencies: readonly AnyNode[] - readonly tag?: T - readonly [$OutputType]?: () => A - readonly [$ErrorType]?: () => E +export type Graph = GraphValue +export type Node = NodeValue +export type Provider = ProviderNode +export type Replacement = ReplacementValue +export type Replacements = readonly Replacement[] + +type AnyGraph = Graph +type AnyNode = AnyGraph & { readonly [NodeTypeId]: unknown; readonly tag: Tag | undefined } +type RuntimeLayer = Layer.Layer +type GraphList = readonly [] | readonly [AnyGraph, ...AnyGraph[]] + +export type Output = CommonOutput ? A : never> +export type Error = Item extends Graph ? E : never +type GraphTag = [Item] extends [never] ? undefined : Item extends Graph ? T : never +type CommonOutput = A extends unknown ? ([Item] extends [Graph] ? A : never) : never +type LayerOutput> = A extends unknown + ? [Item] extends [Layer.Layer] + ? A + : never + : never +type ListOutput = [Items] extends [ + readonly [infer Head extends AnyGraph, ...infer Tail extends readonly AnyGraph[]], +] + ? ListOutput> + : A + +type Definition = + | { readonly kind: "group"; readonly name: string; readonly dependencies: readonly AnyGraph[] } + | { readonly kind: "unbound"; readonly name: string; readonly tag: Tag | undefined } + | { + readonly kind: "layer" + readonly name: string + readonly tag: Tag | undefined + readonly implementation: RuntimeLayer + readonly dependencies: readonly AnyGraph[] + } + +class GraphValue { + declare private readonly graph: void + // Public symbol types survive declaration emit; the private brand prevents structural forgery. + declare readonly [VarianceTypeId]: { + readonly output: (_: A) => void + readonly error: () => E + readonly tags: () => T + } + + readonly [GraphTypeId]: Definition + + constructor(definition: Definition) { + this[GraphTypeId] = definition + } + + get name() { + return this[GraphTypeId].name + } } -type NodeIdentity = +class NodeValue extends GraphValue { + // Forgetting part of a node's contract must not authorize a weaker replacement. + declare readonly [NodeTypeId]: (_: [A, E, T]) => [A, E, T] + + constructor( + definition: Exclude, + readonly tag: T, + ) { + super(definition) + } + + /** Replace this declaration, including every dependency on it, without acquiring its old wiring. */ + replace( + this: Node, + replacement: Target & CheckReplacement, + ): Replacement { + if (replacement instanceof NodeValue) { + if (replacement.tag !== this.tag) throw new Error(`Cannot replace ${this.name} across tags`) + return new ReplacementValue(this, replacement) + } + if (!Layer.isLayer(replacement)) throw new Error("A replacement must be a node or an Effect Layer") + return new ReplacementValue( + this, + new ProviderNode( + { + kind: "layer", + name: this.name, + tag: this.tag, + implementation: replacement, + dependencies: [], + }, + this.tag, + ), + ) + } +} + +class ProviderNode extends NodeValue { + /** Decorate the implementation while preserving its dependency wiring and service contract. */ + mapLayer(this: Provider, f: (layer: Layer.Layer) => Layer.Layer): Provider { + const definition = this[GraphTypeId] + if (definition.kind !== "layer") throw new Error(`Cannot map unbound layer node: ${this.name}`) + return new ProviderNode( + { ...definition, implementation: f(definition.implementation as Layer.Layer) }, + this.tag, + ) + } +} + +class ReplacementValue { + declare private readonly checked: void + readonly [ReplacementTypeId]: { readonly source: AnyNode; readonly target: AnyNode } + + constructor(source: AnyNode, target: AnyNode) { + this[ReplacementTypeId] = { source, target } + } +} + +type CheckErrors = [Exclude] extends [never] + ? never + : { readonly "New replacement errors": Exclude } + +type CheckReplacement = [ReplacementErrors] extends [never] + ? unknown + : ReplacementErrors + +type ReplacementErrors = Target extends AnyNode + ? [Exclude>] extends [never] + ? [GraphTag] extends [T] + ? CheckErrors> + : { readonly "Invalid replacement tag": GraphTag } + : { readonly "Missing replacement outputs": Exclude> } + : Target extends Layer.Layer + ? CheckErrors + : { readonly "Replacement must be a closed layer": Target } + +type CheckDependencies = [ + Exclude, ListOutput>, +] extends [never] + ? unknown + : { readonly "Missing dependencies": Exclude, ListOutput> } + +type Identity = | { readonly service: Context.Service.Any; readonly name?: never } | { readonly name: string; readonly service?: never } +type CheckLayer = [Implementation] extends [RuntimeLayer] + ? unknown + : { readonly "Layer contract must be preserved": Implementation } +type TagInput = { readonly tag: T } | ([T] extends [undefined] ? { readonly tag?: undefined } : never) +type MakeInput = Identity & + TagInput & { + readonly layer: Implementation & CheckLayer> + readonly deps: Items & CheckDependencies> + } type DistributiveOmit = A extends unknown ? Omit : never export type TagConfig = Readonly> type TagNames = keyof Config & string -type NodeInTags = Node | undefined> -type CheckTags = [Exclude>] extends [ - never, -] +type CheckTags = [ + Exclude, Tag | undefined>, +] extends [never] ? unknown - : { readonly "Invalid tag dependencies": Exclude> } + : { readonly "Invalid tag dependencies": Exclude, Tag | undefined> } export interface Tags { readonly values: { readonly [Name in TagNames]: Tag } readonly make: >( name: Name, - ) => ( + ) => ( input: DistributiveOmit>, "tag"> & CheckTags>, - ) => Node, Layer.Error | Error, Tag> + ) => Provider, Layer.Error | Error, Tag> } export function tags( @@ -61,271 +188,157 @@ export function tags [name, makeTag(name)])) as Tags["values"] return { values, - make: ((name: TagNames) => (input: DistributiveOmit, "tag">) => - make({ ...input, tag: values[name] })) as Tags["make"], + make: + >(name: Name) => + ( + input: DistributiveOmit>, "tag"> & + CheckTags>, + ) => + makeProvider>(input, values[name]), } } -// Nodes --------------------------------------------------------------------- - -type MakeInput< - Implementation extends Layer.Any, - Items extends NodeList, - T extends Tag | undefined = undefined, -> = NodeIdentity & { - readonly layer: Implementation - readonly deps: Items & CheckDependencies> - readonly tag?: T -} - export function make< const Implementation extends Layer.Any, - const Items extends NodeList, + const Items extends GraphList, const T extends Tag | undefined = undefined, >( input: MakeInput, -): Node, Layer.Error | Error, T> { - return { - kind: "layer", - name: input.service !== undefined ? input.service.key : input.name, - service: input.service, - implementation: input.layer, - dependencies: input.deps, - tag: input.tag, - } +): Provider, Layer.Error | Error, T> { + return makeProvider(input, input.tag as T) } -export function unbound(service: Context.Key, tag: T): Node { - return { - kind: "unbound", - name: service.key, - service, - dependencies: [], +function makeProvider( + input: Identity & { readonly layer: Implementation; readonly deps: Items }, + tag: T, +): Provider, Layer.Error | Error, T> { + if (!Layer.isLayer(input.layer)) throw new Error("A layer node requires an Effect Layer") + return new ProviderNode( + { + kind: "layer", + name: input.service !== undefined ? input.service.key : input.name, + tag, + implementation: input.layer, + dependencies: [...input.deps], + }, tag, - } -} - -export function group( - dependencies: Items, -): Node, Error, NodeTag> { - return { kind: "group", name: "group", dependencies } -} - -export type Replacement = readonly [source: AnyNode, replacement: AnyNode | Layer.Any] -export type Replacements = readonly Replacement[] - -type CheckReplacementErrors = [Exclude] extends [never] - ? unknown - : { readonly "New replacement errors": Exclude } - -type CheckReplacement = Item extends readonly [Node, infer Replacement] - ? Replacement extends Node, infer E2, T> - ? CheckReplacementErrors> - : Replacement extends Layer.Layer, infer E2, never> - ? CheckReplacementErrors> - : { readonly "Invalid replacement": Replacement } - : { readonly "Invalid replacement": Item } - -type CheckReplacements = { - readonly [K in keyof Items]: CheckReplacement + ) } -type ValidReplacements = Items & CheckReplacements - -function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) { - const replacementNode = isNode(replacement) - ? replacement - : make({ - ...nodeMakeIdentity(source), - layer: replacement as Layer.Layer, - deps: [], - tag: source.tag, - }) - if (source.name !== replacementNode.name) { - throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`) - } - if (source.tag !== replacementNode.tag) { - throw new Error(`Cannot replace ${source.name} across tags`) - } - return replacementNode +export function unbound(service: Context.Key, tag: T): Node { + return new NodeValue({ kind: "unbound", name: service.key, tag }, tag) } -function nodeMakeIdentity(node: AnyNode): NodeIdentity { - if (node.service !== undefined) return { service: node.service } - return { name: node.name } +/** Ordered, associative composition. Only these roots' outputs are exposed; their dependencies remain private. */ +export function group( + dependencies: Items, +): Graph, Error, GraphTag> { + return new GraphValue({ kind: "group", name: "group", dependencies: [...dependencies] }) } -function isNode(input: Layer.Any | AnyNode): input is AnyNode { - return "kind" in input && "dependencies" in input +export interface CompileOptions { + readonly replacements?: Replacements + /** Share subgraphs rooted at this tag; give the remaining wiring a fresh map for each build. */ + readonly shared?: Tag } -// Tree ----------------------------------------------------------------------- - -type Visit = (node: AnyNode, context: VisitContext) => Result - -type VisitContext = { - readonly visit: (node: AnyNode) => Result +type Resolved = { + readonly name: string + readonly implementation: RuntimeLayer + readonly dependencies: readonly Resolved[] + readonly shared: boolean } -function walk( - root: AnyNode, - visit: Visit, - options: { - readonly cache?: Map - readonly resolve?: (node: AnyNode) => AnyNode - } = {}, -) { - const cache = options.cache ?? new Map() - const visiting = new Set() - const stack: AnyNode[] = [] - - const recur = (node: AnyNode): Result => { - const target = options.resolve?.(node) ?? node - const cached = cache.get(target) - if (cached !== undefined || cache.has(target)) return cached! - - if (visiting.has(target)) { - const start = stack.indexOf(target) +/** Resolve the final overrides before validating or acquiring anything. Effect owns acquisition and finalization. */ +export function compile(root: Graph, options: CompileOptions = {}): Layer.Layer { + const shared = options.shared + const replacements = new Map( + options.replacements?.map((item) => { + const replacement = item[ReplacementTypeId] + return [replacement.source, replacement.target] as const + }), + ) + const cache = { + shared: new Map(), + local: new Map(), + } + const stack: AnyGraph[] = [] + const definitions = { shared: new Map(), local: new Map() } + + const resolve = (graph: AnyGraph, inherited = false): readonly Resolved[] => { + const definition = graph[GraphTypeId] + const isShared = inherited || (shared !== undefined && definition.kind !== "group" && definition.tag === shared) + const resolved = isShared ? cache.shared : cache.local + const cached = resolved.get(graph) + if (cached) return cached + const cycle = stack.indexOf(graph) + if (cycle !== -1) { throw new Error( - `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`, + `Cycle detected in layer graph: ${[...stack.slice(cycle), graph].map((item) => item.name).join(" -> ")}`, ) } - - visiting.add(target) - stack.push(target) - try { - const result = visit(target, { visit: recur }) - if (!cache.has(target)) cache.set(target, result) - return result - } finally { - stack.pop() - visiting.delete(target) - } - } - - return recur(root) -} - -export function hoist( - root: Node, - tag: T, - replacements?: ValidReplacements, -): { - readonly node: Node - readonly hoisted: Node -} { - const hoisted = new Map() - const replacementMap = replacementMapFrom(replacements) - - const node = walk( - root, - (node, context) => { - if (node.kind === "group") { - return { ...node, dependencies: node.dependencies.map(context.visit) } + stack.push(graph) + const replacement = replacements.get(graph) + const result = (() => { + if (replacement && replacement !== graph) return resolve(replacement, isShared) + if (definition.kind === "group") + return definition.dependencies.flatMap((dependency) => resolve(dependency, isShared)) + if (definition.kind === "unbound") throw new Error(`Unbound layer node: ${definition.name}`) + const node: Resolved = { + name: definition.name, + implementation: definition.implementation, + dependencies: definition.dependencies.flatMap((dependency) => resolve(dependency, isShared)), + shared: isShared, } - if (node.tag === tag) { - const existing = hoisted.get(node.name) - if (existing && existing.implementation !== node.implementation) { - throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) + const registry = node.shared ? definitions.shared : definitions.local + const existing = registry.get(node.implementation) + if (existing) { + if ( + existing.dependencies.length !== node.dependencies.length || + existing.dependencies.some((dependency, index) => dependency !== node.dependencies[index]) + ) { + throw new Error(`Layer ${node.name} is wired to different dependencies; use a distinct implementation Layer`) } - hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap)) - return group([]) - } - if (node.kind === "unbound") { - return node + return [existing] } - return { ...node, dependencies: node.dependencies.map(context.visit) } - }, - { resolve: (node) => replacementMap.get(node.name) ?? node }, - ) - - return { - node: node as Node, - hoisted: group(Array.from(hoisted.values())) as Node, + registry.set(node.implementation, node) + return [node] + })() + stack.pop() + resolved.set(graph, result) + return result } -} - -export function compile( - root: Node, - replacements?: ValidReplacements, -): Layer.Layer { - const replacementMap = replacementMapFrom(replacements) - const cache = new Map() - const compileNode = (node: AnyNode) => - walk( - node, - (node, context) => { - if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) - const dependencies = node.dependencies.flatMap(flatten).map(context.visit) - const implementation = node.implementation! as RuntimeLayer - return dependencies.length === 0 - ? implementation - : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]])) - }, - { cache, resolve: (node) => replacementMap.get(node.name) ?? node }, - ) - const layers = flatten(root).map((node) => compileNode(node)) - const layer = layers.reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty) - return layer as Layer.Layer -} -function replacementMapFrom(replacements?: Replacements) { - return ( - replacements?.reduce((map, [source, replacement]) => { - const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map) - const current = new Map([[source.name, normalized]]) - for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current)) - map.set(source.name, normalized) - return map - }, new Map()) ?? new Map() - ) -} - -function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap) { - if (replacements.size === 0) return root - const cache = new Map() - const visiting = new Set() - const stack: AnyNode[] = [] - - const recur = (node: AnyNode, isRoot = false): AnyNode => { - const target = isRoot ? node : (replacements.get(node.name) ?? node) - const cached = cache.get(target) - if (cached !== undefined || cache.has(target)) return cached! - if (visiting.has(target)) { - const start = stack.indexOf(target) - throw new Error( - `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`, - ) - } - - visiting.add(target) - stack.push(target) - try { - const dependencies = target.dependencies.map((dependency) => recur(dependency)) - const result = dependencies.every((dependency, index) => dependency === target.dependencies[index]) - ? target - : { ...target, dependencies } - cache.set(target, result) - return result - } finally { - stack.pop() - visiting.delete(target) + const roots = resolve(root) + return Layer.fromBuild((memoMap, scope) => { + const local = shared === undefined ? memoMap : Layer.makeMemoMapUnsafe() + const ambient = Layer.succeed(Layer.CurrentMemoMap, memoMap) + const layers = new Map() + const build = (node: Resolved): RuntimeLayer => { + const cached = layers.get(node) + if (cached) return cached + const dependencies = node.dependencies.map(build) + // Acquisition uses the selected cache, while lazy LayerMaps inherit the enclosing shared cache. + const implementation = Layer.suspend(() => node.implementation.pipe(Layer.provide([ambient, ...dependencies]))) + const layer = Layer.fromBuild((_, scope) => buildContext(implementation, node.shared ? memoMap : local, scope)) + layers.set(node, layer) + return layer } - } - - return recur(root, true) + return buildContext( + roots.map(build).reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty), + memoMap, + scope, + ) + }) as Layer.Layer } -export function hasUnbound(root: Node, source: AnyNode): boolean { - if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`) - return walk(root, (node, context) => { - if (node === source) return true - return node.dependencies.some(context.visit) - }) -} +class BuildResult extends Context.Service>()("@opencode/LayerNode/BuildResult") {} -function flatten(node: AnyNode): readonly AnyNode[] { - return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node] +function buildContext(layer: RuntimeLayer, memoMap: Layer.MemoMap, scope: Scope.Scope) { + // Preserve the exact output context before Effect appends its own memo-map metadata. + return Layer.buildWithMemoMap( + layer.pipe(Layer.flatMap((context) => Layer.succeed(BuildResult, context))), + memoMap, + scope, + ).pipe(Effect.map(Context.get(BuildResult))) } - -export * as LayerNode from "./layer-node.js" diff --git a/packages/util/tsconfig.dist.json b/packages/util/tsconfig.dist.json new file mode 100644 index 000000000000..c24daa277b54 --- /dev/null +++ b/packages/util/tsconfig.dist.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@opencode-ai/util/effect/layer-node": ["./dist/effect/layer-node.d.ts"], + "@opencode-ai/util/effect/app-node": ["./dist/effect/app-node.d.ts"] + } + }, + "include": ["../core/test/effect/layer-node/layer-node-types.test.ts"] +} From 15fbbad20a9d4a6d2453f2166b0e19ec7fa48dfd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 29 Aug 2026 00:27:46 -0400 Subject: [PATCH 2/3] refactor(util): simplify layer node construction --- packages/util/src/effect/layer-node.ts | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/util/src/effect/layer-node.ts b/packages/util/src/effect/layer-node.ts index dd1de3cc5adb..35322747249b 100644 --- a/packages/util/src/effect/layer-node.ts +++ b/packages/util/src/effect/layer-node.ts @@ -38,11 +38,10 @@ type ListOutput = [Items] extends type Definition = | { readonly kind: "group"; readonly name: string; readonly dependencies: readonly AnyGraph[] } - | { readonly kind: "unbound"; readonly name: string; readonly tag: Tag | undefined } + | { readonly kind: "unbound"; readonly name: string } | { readonly kind: "layer" readonly name: string - readonly tag: Tag | undefined readonly implementation: RuntimeLayer readonly dependencies: readonly AnyGraph[] } @@ -88,19 +87,7 @@ class NodeValue extends Gr return new ReplacementValue(this, replacement) } if (!Layer.isLayer(replacement)) throw new Error("A replacement must be a node or an Effect Layer") - return new ReplacementValue( - this, - new ProviderNode( - { - kind: "layer", - name: this.name, - tag: this.tag, - implementation: replacement, - dependencies: [], - }, - this.tag, - ), - ) + return new ReplacementValue(this, makeProvider({ name: this.name, layer: replacement, deps: [] }, this.tag)) } } @@ -217,7 +204,6 @@ function makeProvider(service: Context.Key, tag: T): Node { - return new NodeValue({ kind: "unbound", name: service.key, tag }, tag) + return new NodeValue({ kind: "unbound", name: service.key }, tag) } /** Ordered, associative composition. Only these roots' outputs are exposed; their dependencies remain private. */ @@ -267,7 +253,7 @@ export function compile(root: Graph, options: CompileOptions = {}): const resolve = (graph: AnyGraph, inherited = false): readonly Resolved[] => { const definition = graph[GraphTypeId] - const isShared = inherited || (shared !== undefined && definition.kind !== "group" && definition.tag === shared) + const isShared = inherited || (shared !== undefined && graph instanceof NodeValue && graph.tag === shared) const resolved = isShared ? cache.shared : cache.local const cached = resolved.get(graph) if (cached) return cached From 927ca66df710a276c9fbaaf39d1bddb7239ff9cf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 29 Aug 2026 09:03:06 -0400 Subject: [PATCH 3/3] refactor(util): drop redundant resolved node names --- packages/util/src/effect/layer-node.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/util/src/effect/layer-node.ts b/packages/util/src/effect/layer-node.ts index 35322747249b..8a2942d48e68 100644 --- a/packages/util/src/effect/layer-node.ts +++ b/packages/util/src/effect/layer-node.ts @@ -229,7 +229,6 @@ export interface CompileOptions { } type Resolved = { - readonly name: string readonly implementation: RuntimeLayer readonly dependencies: readonly Resolved[] readonly shared: boolean @@ -271,7 +270,6 @@ export function compile(root: Graph, options: CompileOptions = {}): return definition.dependencies.flatMap((dependency) => resolve(dependency, isShared)) if (definition.kind === "unbound") throw new Error(`Unbound layer node: ${definition.name}`) const node: Resolved = { - name: definition.name, implementation: definition.implementation, dependencies: definition.dependencies.flatMap((dependency) => resolve(dependency, isShared)), shared: isShared, @@ -283,7 +281,9 @@ export function compile(root: Graph, options: CompileOptions = {}): existing.dependencies.length !== node.dependencies.length || existing.dependencies.some((dependency, index) => dependency !== node.dependencies[index]) ) { - throw new Error(`Layer ${node.name} is wired to different dependencies; use a distinct implementation Layer`) + throw new Error( + `Layer ${definition.name} is wired to different dependencies; use a distinct implementation Layer`, + ) } return [existing] }