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..8a2942d48e68 100644
--- a/packages/util/src/effect/layer-node.ts
+++ b/packages/util/src/effect/layer-node.ts
@@ -1,57 +1,171 @@
-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 kind: "layer"
+ readonly name: string
+ 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
+ }
+}
+
+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, makeProvider({ name: this.name, layer: replacement, deps: [] }, this.tag))
+ }
}
-type NodeIdentity =
+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 +175,156 @@ 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,
+ implementation: input.layer,
+ dependencies: [...input.deps],
+ },
tag,
- }
-}
-
-export function group(
- dependencies: Items,
-): Node