diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index bbc4595b077..a24f56386db 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 c81e4b44b0d..303046e1ec3 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 b41e1f56d26..167131636e6 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 dd553884c3b..0be7ecf37ca 100644
--- a/packages/core/src/instance.ts
+++ b/packages/core/src/instance.ts
@@ -55,6 +55,7 @@ import { ToolOutput } from "./tool-output.js"
import { Vcs } from "./vcs.js"
export * as Instance from "./instance.js"
+export { Service, byLocationNode, type Interface } from "./instance/service.js"
const nodes = [
Location.node,
@@ -110,9 +111,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
@@ -141,29 +142,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,
@@ -171,6 +166,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/instance/service.ts b/packages/core/src/instance/service.ts
new file mode 100644
index 00000000000..c5a46b08f62
--- /dev/null
+++ b/packages/core/src/instance/service.ts
@@ -0,0 +1,42 @@
+export * as Instance from "./service.js"
+export type { Services } from "../instance.js"
+
+import { Context, Effect, Layer, Option, Scope } from "effect"
+import type { Session } from "@opencode-ai/schema/session"
+import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
+import type { Services } from "../instance.js"
+import { LocationServiceMap } from "../location-service-map.js"
+
+/** Selects Session capabilities; implementations own caching and lifetime. */
+export interface Interface {
+ readonly provide: (
+ session: Session.Info,
+ ) => (effect: Effect.Effect) => Effect.Effect>
+ /** Borrow a cached instance without initializing one when it is absent. */
+ readonly provideIfLoaded: (
+ session: Session.Info,
+ ) => (effect: Effect.Effect) => Effect.Effect, E, Exclude>
+}
+
+export class Service extends Context.Service()("@opencode/Instance") {}
+
+const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const locations = yield* LocationServiceMap.Service
+ return Service.of({
+ provide: (session) => Effect.provide(locations.get(session.location)),
+ provideIfLoaded: (session) => (effect) =>
+ // Scope the borrowed reference without replacing the caller's Scope.
+ Effect.scopedWith((scope) =>
+ Effect.gen(function* () {
+ const context = yield* locations.contextEffectOption(session.location).pipe(Scope.provide(scope))
+ if (Option.isNone(context)) return Option.none()
+ return Option.some(yield* effect.pipe(Effect.provide(context.value)))
+ }),
+ ),
+ })
+ }),
+)
+
+export const byLocationNode = makeGlobalNode({ service: Service, layer, deps: [LocationServiceMap.node] })
diff --git a/packages/core/src/persistent-pty/index.ts b/packages/core/src/persistent-pty/index.ts
index 0f316b69cb7..b898c66f76b 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/src/plugin/host.ts b/packages/core/src/plugin/host.ts
index 3e1d5598d47..b725c542a8e 100644
--- a/packages/core/src/plugin/host.ts
+++ b/packages/core/src/plugin/host.ts
@@ -80,7 +80,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const response = (effect: Effect.Effect) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
- return {
+ // Keep the instance graph's inferred types independent of Session handles.
+ const context: Plugin.Context = {
app,
location: locationInfo(),
options: {},
@@ -206,7 +207,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
.subscribe()
.pipe(
Stream.filter(
- (event): event is EventManifest.ServerEvent | RpcEvent => EventManifest.isServer(event) || isRpcEvent(event),
+ (event): event is EventManifest.ServerEvent | RpcEvent =>
+ EventManifest.isServer(event) || isRpcEvent(event),
),
),
},
@@ -449,7 +451,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
wait: (input) => runtime.session.wait(input.sessionID),
context: (input) => runtime.session.context(input.sessionID),
},
- } satisfies Plugin.Context
+ }
+ return context
})
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts
index 89cf606da05..05d52b25875 100644
--- a/packages/core/src/plugin/runtime.ts
+++ b/packages/core/src/plugin/runtime.ts
@@ -62,7 +62,7 @@ const require = (cell: Cell, f: (runtime: Interface) => Effect.Effect
+export const layerWithCell = (cell: Cell): Layer.Layer =>
Layer.succeed(
Service,
Service.of({
diff --git a/packages/core/src/plugin/skill/opencode.md b/packages/core/src/plugin/skill/opencode.md
index 1ed6bb2ffc2..a5a7dcfa61f 100644
--- a/packages/core/src/plugin/skill/opencode.md
+++ b/packages/core/src/plugin/skill/opencode.md
@@ -11,6 +11,9 @@ truth. Follow links from that page when the question needs more detail. Fetch
first when you need to discover the relevant
documentation page.
+A machine-readable documentation index is available at
+.
+
## Version policy
Always answer for OpenCode V2 unless the user explicitly asks about V1,
@@ -152,6 +155,8 @@ before answering. Refer to this guide when the user wants to build a plugin. It
covers hooks, transforms, tools, plugin context capabilities, and package
entrypoints. Plugins can also extend the TUI; for those, fetch the
[CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli).
+For custom methods and events shared with other plugins or clients, fetch the
+[RPC guide](https://opencode.ai/v2/docs/build/plugins/rpc).
## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service)
@@ -220,6 +225,16 @@ exposes typed Effects, Streams, and decoded OpenCode schema values. Its
`Service` API can discover, start, stop, and authenticate with the local
background service from a Node application.
+## [SDK](https://opencode.ai/v2/docs/build/sdk)
+
+For questions about embedding OpenCode directly in an application, fetch the
+full [SDK guide](https://opencode.ai/v2/docs/build/sdk) before answering. The SDK
+hosts OpenCode in the application without opening an HTTP listener.
+
+Use the [Effect SDK guide](https://opencode.ai/v2/docs/build/sdk/effect) for
+Effect applications. For Cloudflare Durable Objects, use the
+[Cloudflare SDK guide](https://opencode.ai/v2/docs/build/sdk/cloudflare).
+
## [Troubleshooting](https://opencode.ai/v2/docs/troubleshooting)
OpenCode runs a client and a background server. Start by determining whether a
diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index 1ff70c3032c..25298d3f415 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
-import { Cause, Effect, Layer, Schema, Context, RcMap, Stream } from "effect"
+import { Cause, Effect, Layer, Schema, Context, Stream } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
@@ -10,6 +10,7 @@ import { Location } from "./location.js"
import { SessionMessage } from "./session/message.js"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Bus } from "./bus.js"
+import { Instance } from "./instance/service.js"
import { Database } from "./database/database.js"
import { SessionProjector } from "./session/projector.js"
import { SessionMessageTable } from "./session/sql.js"
@@ -238,20 +239,16 @@ const layer = Layer.effect(
const global = yield* Global.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
+ const instances = yield* Instance.Service
const locations = yield* LocationServiceMap.Service
const fs = yield* FSUtil.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
- const sessions = yield* Session.make((ref) => locations.get(ref))
+ const sessions = yield* Session.make()
const admission = yield* SessionInbox.Service
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
- const location = Location.Ref.make({
- directory: session.location.directory,
- workspaceID: session.location.workspaceID,
- })
- if (!(yield* RcMap.has(locations.rcMap, location))) return
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
- Effect.provide(locations.get(location)),
+ instances.provideIfLoaded(session),
)
})
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -403,7 +400,7 @@ const layer = Layer.effect(
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
- const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
+ const generate = yield* SessionGenerate.Service.pipe(instances.provide(session))
return yield* generate.generate(input)
}),
command: Effect.fn("Session.command")(function* (input) {
@@ -412,7 +409,7 @@ const layer = Layer.effect(
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Command.Service
- }).pipe(Effect.provide(locations.get(session.location)))
+ }).pipe(instances.provide(session))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
@@ -535,6 +532,7 @@ export const node = makeGlobalNode({
Project.node,
SessionExecution.node,
SessionStore.node,
+ Instance.byLocationNode,
SessionInbox.node,
LocationServiceMap.node,
SessionProjector.node,
diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts
index 3842614395e..a01c1645f17 100644
--- a/packages/core/src/session/execution.ts
+++ b/packages/core/src/session/execution.ts
@@ -4,7 +4,7 @@ import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Job } from "../job.js"
-import { LocationServiceMap } from "../location-service-map.js"
+import { Instance } from "../instance/service.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEvent } from "./event.js"
import { SessionRunCoordinator } from "./run-coordinator.js"
@@ -35,7 +35,7 @@ export interface Interface {
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect
}
-/** Routes execution from a Session ID to the runner owned by that Session's Location. */
+/** Routes execution from a Session ID to its selected instance's runner. */
export class Service extends Context.Service()("@opencode/SessionExecution") {}
type InterruptReason = "user" | "shutdown"
@@ -48,12 +48,12 @@ export function terminal(exit: Exit.Exit, reason?:
return { type: "failed" as const, error: toSessionError(failure) }
}
-/** Process-local execution: drains run in this process, routed through the Session's Location graph. */
+/** Process-local execution: drains run in this process using the selected instance. */
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
- const locations = yield* LocationServiceMap.Service
+ const instances = yield* Instance.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const db = (yield* Database.Service).db
@@ -90,7 +90,7 @@ export const layer = Layer.effect(
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation, promotable }),
).pipe(
- Effect.provide(locations.get(session.location)),
+ instances.provide(session),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
@@ -173,7 +173,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
- deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
+ deps: [SessionStore.node, Instance.byLocationNode, Bus.node, Database.node, Job.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
diff --git a/packages/core/src/session/session.ts b/packages/core/src/session/session.ts
index ce404ed1fc6..7dd1db4df85 100644
--- a/packages/core/src/session/session.ts
+++ b/packages/core/src/session/session.ts
@@ -1,11 +1,11 @@
export * as Session from "./session.js"
-import { DateTime, Effect, Fiber, Layer, Schema, Scope } from "effect"
+import { DateTime, Effect, Fiber, Schema, Scope } from "effect"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import { Event } from "@opencode-ai/schema/event"
import { Bus } from "../bus.js"
-import { Location } from "../location.js"
+import { Instance } from "../instance/service.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Shell } from "../shell.js"
import { ShellResult } from "../shell/result.js"
@@ -33,26 +33,19 @@ import { SessionRevert } from "./revert.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
-export type Services =
- | PluginSupervisor.Service
- | Reference.Service
- | SessionPrompt.Service
- | SessionRevert.Service
- | Shell.Service
- | Skill.Service
-
type PromptRequest = SessionPrompt.Input & {
id?: SessionMessage.ID
resume?: boolean
}
/**
- * Build once in the host Scope: `const sessions = yield* Session.make(servicesFor)`.
+ * Build once in the host Scope: `const sessions = yield* Session.make()`.
* Use `sessions.forSession(id)` for handles that share host services and reload current state.
*/
-export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Location.Ref) => Layer.Layer) {
+export const make = Effect.fn("Session.make")(function* () {
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
+ const instances = yield* Instance.Service
const execution = yield* SessionExecution.Service
const admission = yield* SessionInbox.Service
const scope = yield* Scope.Scope
@@ -174,7 +167,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
const preparation = yield* SessionPrompt.Service
const references = yield* Reference.Service
return { item: yield* preparation.prepare({ sessionID, messageID, input }), references }
- }).pipe(Effect.provide(servicesFor(session.location))),
+ }).pipe(instances.provide(session)),
)
// Commit a staged revert only after preparation succeeds, before admitting new work.
if (session.revert) yield* SessionRevert.commit(bus, session)
@@ -205,7 +198,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Shell.Service
- }).pipe(Effect.provide(servicesFor(session.location)))
+ }).pipe(instances.provide(session))
const started = yield* shell
.create({
command: input.command,
@@ -256,7 +249,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
input: { id?: SessionMessage.ID; skill: Skill.ID; resume?: boolean },
) {
const session = yield* get(sessionID)
- const skills = yield* Skill.Service.pipe(Effect.provide(servicesFor(session.location)))
+ const skills = yield* Skill.Service.pipe(instances.provide(session))
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* bus.publish(
@@ -355,14 +348,12 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.Service.use((revert) =>
revert.stage({ session, messageID: input.messageID, files: input.files }),
- ).pipe(Effect.provide(servicesFor(session.location)))
+ ).pipe(instances.provide(session))
})
const clear = Effect.fn("Session.revert.clear")(function* (sessionID: SessionSchema.ID) {
const session = yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
- yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(
- Effect.provide(servicesFor(session.location)),
- )
+ yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(instances.provide(session))
return yield* execution.wake(sessionID)
})
const commit = Effect.fn("Session.revert.commit")(function* (sessionID: SessionSchema.ID) {
diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts
index 2f287602116..1f18cbdc72a 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 891cf2753c0..c3cf7b78c0e 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 4820b53887c..26f638986c4 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 ab670be22c9..ff6edcc4ac6 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 6525f17af1f..9a6d9fd9568 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 29f1250223e..5d9e72150cc 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 c2e9db41676..8bc526b45b8 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 938ee7fe1a3..744c8cce88b 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 67344a94247..ff64d034fe4 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 2cf7a984799..6ed5ac8dfef 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 e59eae8122c..4213e7d5894 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 bd54b29fed0..42e7489afa3 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 b6920a97766..a8cb7544130 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 73f662eb316..d1208b28baa 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] })
+// 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: [] })
+ 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: [] })
-// @ts-expect-error A node must have a service or name
-make({ layer: aLayer, 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
-// @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 = () =>
+ 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
+ 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" }
-class TagA extends Context.Service()("test/TagA") {}
-class TagB extends Context.Service()("test/TagB") {}
-class TagC extends Context.Service()("test/TagC") {}
+ 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 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({})
- }),
-)
+ 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)
-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])] })
+ 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
-// @ts-expect-error Tag configuration can only reference declared tags
-LayerNode.tags({ request: ["missing"], global: [] })
+ 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)
-// @ts-expect-error An unrelated dependency cannot satisfy TagA
-request({ service: TagB, layer: tagBLayer, deps: [requestB] })
+ 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])] })
-// @ts-expect-error Providing only TagA leaves TagB missing
-request({ service: TagC, layer: tagCLayer, deps: [globalA] })
+ 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: [] })
-// @ts-expect-error Providing only TagB leaves TagA missing
-request({ service: TagC, layer: tagCLayer, deps: [requestB] })
+ void [
+ invalidStored,
+ rawStored,
+ forged,
+ groupNode,
+ forgedGraph,
+ narrowedOutput,
+ widenedOutput,
+ widenedError,
+ narrowedError,
+ widenedTag,
+ narrowedTag,
+ widenedGraph,
+ ]
+}
-// @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: [] })
-
-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 5c36f20255b..ec530319048 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]))),
+ 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")
+ }),
+ )
+
+ 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)
+ }),
+ )
+
+ 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")
+ }),
+ )
+
+ 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")
+ }),
+ )
+
+ 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")
+ }),
+ )
+
+ 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")
+ }),
+ )
+
+ 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("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",
)
- expect(await Effect.runPromise(program)).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")
- })
-
- test("exposes roots but hides transitive dependencies", () => {
- const layer = build(LayerNode.group([greeting]))
- const check: Layer.Layer = layer
- void check
- })
-
- 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"])
- })
-
- 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")
- })
-
- 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")
- })
-
- 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"])
- })
-
- 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" })
- }),
- )
- await Effect.runPromise(
- Effect.map(Greeting, (item) => item.value).pipe(
- Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])),
- ),
- )
- 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: [],
- })
-
- const program = Effect.map(Greeting, (item) => item.value).pipe(
- Effect.provide(build(LayerNode.group([original]), [[original, replacement]])),
- )
-
- expect(await Effect.runPromise(program)).toBe("replacement")
- expect(acquisitions).toBe(0)
- })
-
- 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" }))],
- ]),
- ),
- )
-
- expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
- })
-
- 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,
+ const dependent = make({
+ service: Value,
layer: Layer.effect(
- Users,
- Effect.gen(function* () {
- const db = yield* Database
- return Users.of({ list: Effect.succeed([db.name]) })
- }),
+ Value,
+ Effect.map(Greeting, (item) => Value.of({ value: item.value })),
),
- deps: [database],
+ deps: [greeting],
})
- 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 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(await Effect.runPromise(program)).toEqual(["Alice"])
- })
-
- 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",
+ expect(() => LayerNode.compile(greeting, { replacements: [value.replace(dependent)] })).toThrow(
+ "Cycle detected in layer graph",
)
})
- 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)
+ 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([])
+ }),
+ )
- expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
- kind: "group",
- dependencies: [],
- })
+ 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"])
+ }),
+ )
+
+ 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"])
+ }),
+ )
+
+ 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)
+ }),
+ )
+
+ 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")
+ }),
+ )
+
+ 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 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")
+
+ 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),
+ )
+
+ 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 })
+
+ 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"])
+ }),
+ )
})
})
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 96c77e0d258..baed42e3b07 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 0d5c8d868d7..08b5ad770ff 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 dc2de5bbc60..ff8e5da2175 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 570acf91b2d..9ad0401684a 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 b79c0a9458a..894254f2956 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 36784ccf87d..a2114eb7c95 100644
--- a/packages/core/test/fixture/prompt-location.ts
+++ b/packages/core/test/fixture/prompt-location.ts
@@ -26,9 +26,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 }),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
diff --git a/packages/core/test/formatter.test.ts b/packages/core/test/formatter.test.ts
index d4d9f96e242..2f4adf3b13e 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 5dbc71fe82c..786a033afe7 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 b55c4bc02e3..a18d16934a9 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 0c2cbb68ad6..1af50a4b1a1 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 c54f2ed6fa9..023c9a0b4b0 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 db700aef88c..60453fa8101 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 90091ec610c..c367e16c73c 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 d3b1a76bf52..0be4a284a53 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 0a8f4b1e9c2..d85ac711075 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 f3753d44432..640bcc8bfd9 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 bfa18da62c3..3512c8f2ee6 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 70c8a0f9a27..870974dacd2 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 da372e8a3da..bd4fad07fb3 100644
--- a/packages/core/test/mcp.test.ts
+++ b/packages/core/test/mcp.test.ts
@@ -378,10 +378,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),
]),
)
@@ -1688,8 +1688,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) =>
@@ -1702,9 +1701,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),
]),
),
),
@@ -1731,8 +1730,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(() => [
@@ -1744,9 +1742,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 e41fa71559f..2c0fed83a3d 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 f31fcec764c..215472d1dbf 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 3c08246f364..294cc489245 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 7efcc825329..b9c680fb8ab 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 504ad8cccca..cf802ecc4d5 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 5ac8dfdfcc0..0dfa9d311ee 100644
--- a/packages/core/test/plugin/fixture.ts
+++ b/packages/core/test/plugin/fixture.ts
@@ -88,12 +88,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 4b63883c813..bb665a0c8c6 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 20c4fecfc59..18a624bdfba 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 762d9378928..2a253634704 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 ce61770ec0f..f188e287e99 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 9a184bfd929..06cf8fd2140 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 563264ec71e..31c87de49af 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 5cfac6903b1..2baf4cb15cb 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 430287ade21..657d65a1f9e 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 6245ce26ea2..47665d2811d 100644
--- a/packages/core/test/repository-cache.test.ts
+++ b/packages/core/test/repository-cache.test.ts
@@ -227,8 +227,8 @@ describe("RepositoryCache", () => {
function cacheLayer(root: string) {
return AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node]), [
- [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
- [Database.node, Database.configured({ path: path.join(root, "cache.sqlite") })],
+ Global.node.replace(Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })),
+ Database.node.replace(Database.configured({ path: path.join(root, "cache.sqlite") })),
])
}
diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts
index 2f11446d58c..08bbbe53384 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/rpc.test.ts b/packages/core/test/rpc.test.ts
index 59b23a4e566..32812547614 100644
--- a/packages/core/test/rpc.test.ts
+++ b/packages/core/test/rpc.test.ts
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
const ref = Location.Ref.make({ directory: AbsolutePath.make("/rpc-project") })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Rpc.node, Bus.node, Location.node]), [
- [Location.node, Layer.succeed(Location.Service, location(ref))],
+ Location.node.replace(Layer.succeed(Location.Service, location(ref))),
]),
)
const Echo = Rpc.define({
@@ -200,8 +200,7 @@ describe("Rpc", () => {
events: {},
})
yield* rpc.register(Failing, {
- standard: (_input, context) =>
- Effect.fail(context.error("missing", "Missing", { attempts: "2" })),
+ standard: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: "2" })),
effect: (_input, context) => Effect.fail(context.error("invalid", "Invalid", { count: 3 })),
})
@@ -269,7 +268,6 @@ describe("Rpc", () => {
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", "42").pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", 0).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* registration.events.emit("counted", { count: 0 }).pipe(Effect.exit))).toBe(true)
-
}),
)
@@ -330,10 +328,12 @@ describe("Rpc", () => {
const bus = yield* Bus.Service
const otherRef = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_other") })
const otherContext = yield* Layer.build(
- LayerNode.compile(Rpc.node, [
- [Bus.node, Layer.succeed(Bus.Service, bus)],
- [Location.node, Layer.succeed(Location.Service, location(otherRef))],
- ]).pipe(Layer.fresh),
+ LayerNode.compile(Rpc.node, {
+ replacements: [
+ Bus.node.replace(Layer.succeed(Bus.Service, bus)),
+ Location.node.replace(Layer.succeed(Location.Service, location(otherRef))),
+ ],
+ }).pipe(Layer.fresh),
)
const other = Context.get(otherContext, Rpc.Service)
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts
index 9c625d6ff87..c15112d7aed 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 ed3c2a40ab1..41c9e99e070 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 d4e319293f5..e6b7cdac58c 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-execution.test.ts b/packages/core/test/session-execution.test.ts
index fab76d0e316..9892f6c379b 100644
--- a/packages/core/test/session-execution.test.ts
+++ b/packages/core/test/session-execution.test.ts
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
+import { Instance } from "@opencode-ai/core/instance/service"
import { Job } from "@opencode-ai/core/job"
import { KV } from "@opencode-ai/core/kv"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
@@ -1371,7 +1372,12 @@ function buildExecution(
Layer.provide(Layer.succeed(Bus.Service, bus)),
Layer.provide(Layer.succeed(SessionStore.Service, store)),
Layer.provide(Layer.succeed(Job.Service, jobs)),
- Layer.provide(locations),
+ // Do not reuse the outer harness's selector with its already-captured Location map.
+ Layer.provide(
+ LayerNode.compile(Instance.byLocationNode, {
+ replacements: [LocationServiceMap.node.replace(locations)],
+ }).pipe(Layer.fresh),
+ ),
),
scope,
)
diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts
index 69e768ab793..818f1e5fc32 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 c9b8bd79119..bd9decea5c2 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 be5bfd556bc..a0a739428c7 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 3bd23bd8e43..25076956662 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 7ca0852d6e7..99eb74c9a12 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 b625986efb8..f465e53b296 100644
--- a/packages/core/test/session-move.test.ts
+++ b/packages/core/test/session-move.test.ts
@@ -25,10 +25,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 itWithActiveExecution = testEffect(
@@ -42,20 +39,21 @@ const itWithActiveExecution = testEffect(
Session.node,
]),
[
- [Project.node, globalProjectNode],
- [
- LocationServiceMap.node,
+ Project.node.replace(globalProjectNode),
+ LocationServiceMap.node.replace(
Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) =>
Layer.merge(
- LayerNode.compile(Location.boundNode(ref), [[Project.node, globalProjectNode]]),
+ LayerNode.compile(Location.boundNode(ref), {
+ replacements: [Project.node.replace(globalProjectNode)],
+ }),
Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }),
) as unknown as Layer.Layer,
),
),
- ],
+ ),
],
),
)
@@ -69,9 +67,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 848e04e9d10..da18ebe03f1 100644
--- a/packages/core/test/session-owned.test.ts
+++ b/packages/core/test/session-owned.test.ts
@@ -15,6 +15,7 @@ import { Bus } from "../src/bus.js"
import { Database } from "../src/database/database.js"
import { EventTable } from "../src/event/sql.js"
import { Image } from "../src/image.js"
+import { Instance } from "../src/instance/service.js"
import { Location } from "../src/location.js"
import { PluginHooks } from "../src/plugin/hooks.js"
import { PluginSupervisor } from "../src/plugin/supervisor-service.js"
@@ -51,10 +52,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")
@@ -130,7 +130,7 @@ const setup = Effect.fnUntraced(function* (options?: {
Layer.mock(Image.Service, {}),
options?.shell ?? Layer.mock(Shell.Service, {}),
)
- const servicesFor = (ref: Location.Ref): Layer.Layer => {
+ const servicesFor = (ref: Location.Ref) => {
locations.push(ref)
return Layer.merge(SessionRevert.layer, SessionPrompt.layer).pipe(
Layer.provideMerge(
@@ -159,10 +159,20 @@ const setup = Effect.fnUntraced(function* (options?: {
Layer.fresh,
)
}
- const sessions = yield* Session.make(servicesFor).pipe(
+ const sessions = yield* Session.make().pipe(
Effect.satisfiesServicesType<
- Bus.Service | SessionStore.Service | SessionExecution.Service | SessionInbox.Service | Scope.Scope
+ | Bus.Service
+ | SessionStore.Service
+ | Instance.Service
+ | SessionExecution.Service
+ | SessionInbox.Service
+ | Scope.Scope
>(),
+ Effect.provideService(Instance.Service, {
+ // This fixture supplies only the instance services exercised by Session.
+ provide: (session) => Effect.provide(servicesFor(session.location) as Layer.Layer),
+ provideIfLoaded: () => () => Effect.die("Unexpected loaded-only instance lookup"),
+ }),
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
)
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts
index 7591856881c..0043dffdf4d 100644
--- a/packages/core/test/session-projector.test.ts
+++ b/packages/core/test/session-projector.test.ts
@@ -35,10 +35,10 @@ import { Snapshot } from "@opencode-ai/core/snapshot"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.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 599c8707e7f..27193f2a2cd 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 30c7c5859f7..26c20c98a85 100644
--- a/packages/core/test/session-prompt.test.ts
+++ b/packages/core/test/session-prompt.test.ts
@@ -95,9 +95,9 @@ const locations = (references: Layer.Layer) =>
Layer.provideMerge(
Layer.mergeAll(
references,
- 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
@@ -131,9 +131,9 @@ const sessionLayer = (references = Layer.mock(Reference.Service, { refresh: () =
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(references)],
+ Bus.node.replace(Bus.configured({ persist: true })),
+ SessionExecution.node.replace(execution),
+ LocationServiceMap.node.replace(locations(references)),
],
)
const it = testEffect(sessionLayer())
@@ -298,13 +298,14 @@ describe("Session.prompt", () => {
}).pipe(
Effect.provide(
AppNodeBuilder.build(Reference.node, [
- [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
- [
- RepositoryCache.node,
+ Global.node.replace(
+ Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }),
+ ),
+ RepositoryCache.node.replace(
Layer.succeed(RepositoryCache.Service, {
ensure: (input) => cache.ensure(input).pipe(Effect.tap(() => Queue.offer(completed, undefined))),
}),
- ],
+ ),
]),
),
)
@@ -312,7 +313,7 @@ describe("Session.prompt", () => {
Effect.scoped,
Effect.provide(
AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node, EffectFlock.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/session-remove.test.ts b/packages/core/test/session-remove.test.ts
index 2ef15c97ba4..71874d5cea9 100644
--- a/packages/core/test/session-remove.test.ts
+++ b/packages/core/test/session-remove.test.ts
@@ -1,9 +1,10 @@
import { describe, expect } from "bun:test"
-import { Effect, Layer } from "effect"
+import { Effect, Layer, Option, RcMap, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
+import { Instance } from "@opencode-ai/core/instance/service"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -19,12 +20,18 @@ import { globalProjectNode } from "./lib/project"
import { tmpdirScoped } from "./fixture/tmpdir"
const closed: Session.ID[] = []
-const transport = Layer.succeed(
+const transportScopes = new Set()
+const transport = Layer.effect(
SessionModelTransport.Service,
- SessionModelTransport.Service.of({
- bind: () => ({ execute: () => Effect.die("Unexpected WebSocket execution") }),
- close: (sessionID) => Effect.sync(() => closed.push(sessionID)),
- closeAll: Effect.void,
+ Effect.gen(function* () {
+ const scope = yield* Scope.Scope
+ transportScopes.add(scope)
+ yield* Effect.addFinalizer(() => Effect.sync(() => transportScopes.delete(scope)))
+ return SessionModelTransport.Service.of({
+ bind: () => ({ execute: () => Effect.die("Unexpected WebSocket execution") }),
+ close: (sessionID) => Effect.sync(() => closed.push(sessionID)),
+ closeAll: Effect.void,
+ })
}),
)
const it = testEffect(
@@ -36,12 +43,13 @@ const it = testEffect(
SessionStore.node,
SessionEnvironment.node,
Session.node,
+ Instance.byLocationNode,
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),
],
),
)
@@ -72,6 +80,27 @@ describe("Session.remove", () => {
}),
)
+ it.live("removes unloaded sessions and children without initializing an instance", () =>
+ Effect.gen(function* () {
+ const temporary = yield* tmpdirScoped()
+ const sessions = yield* Session.Service
+ const locations = yield* LocationServiceMap.Service
+ const parent = yield* sessions.create({
+ location: Location.Ref.make({ directory: AbsolutePath.make(temporary.path) }),
+ })
+ yield* sessions.create({ parentID: parent.id })
+ closed.length = 0
+ expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
+
+ yield* sessions.remove(parent.id)
+
+ expect(closed).toEqual([])
+ expect(transportScopes.size).toBe(0)
+ expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
+ expect((yield* sessions.list()).data).toEqual([])
+ }),
+ )
+
it.effect("fails when the session does not exist", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -84,3 +113,40 @@ describe("Session.remove", () => {
}),
)
})
+
+describe("Instance.provideIfLoaded", () => {
+ it.live("skips absent instances and scopes loaded borrows without replacing the caller's Scope", () =>
+ Effect.gen(function* () {
+ const temporary = yield* tmpdirScoped()
+ const sessions = yield* Session.Service
+ const instances = yield* Instance.Service
+ const locations = yield* LocationServiceMap.Service
+ const scope = yield* Scope.Scope
+ const session = yield* sessions.create({
+ location: Location.Ref.make({ directory: AbsolutePath.make(temporary.path) }),
+ })
+ const absent = Effect.die("An unloaded instance must not run the effect").pipe(instances.provideIfLoaded(session))
+
+ expect(yield* absent).toEqual(Option.none())
+ expect(transportScopes.size).toBe(0)
+ yield* Location.Service.pipe(instances.provide(session))
+ expect(transportScopes.size).toBe(1)
+ expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.some(undefined))
+ const failure = new Error("Borrowed operation failed")
+ expect(yield* Effect.fail(failure).pipe(instances.provideIfLoaded(session), Effect.flip)).toBe(failure)
+
+ const borrowed = yield* Effect.gen(function* () {
+ const location = yield* Location.Service
+ const callerScope = yield* Scope.Scope
+ expect(callerScope).toBe(scope)
+ yield* locations.invalidate(session.location)
+ expect(transportScopes.size).toBe(1)
+ return location.directory
+ }).pipe(instances.provideIfLoaded(session), Effect.satisfiesServicesType())
+
+ expect(borrowed).toEqual(Option.some(session.location.directory))
+ expect(transportScopes.size).toBe(0)
+ expect(yield* absent).toEqual(Option.none())
+ }),
+ )
+})
diff --git a/packages/core/test/session-revert.test.ts b/packages/core/test/session-revert.test.ts
index 23c2d7d251f..8242f23bd97 100644
--- a/packages/core/test/session-revert.test.ts
+++ b/packages/core/test/session-revert.test.ts
@@ -40,10 +40,10 @@ const it = testEffect(
LocationServiceMap.node,
]),
[
- [Bus.node, Bus.configured({ persist: true })],
- [Global.node, tempGlobalLayer],
+ Bus.node.replace(Bus.configured({ persist: true })),
+ Global.node.replace(tempGlobalLayer),
// These tests move directories explicitly; native watchers can hold them open on Windows.
- [Watcher.node, Watcher.configured({ enabled: false })],
+ Watcher.node.replace(Watcher.configured({ enabled: false })),
],
),
)
diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts
index 6404e179880..dd8e05825c0 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 8a2ac3ecaa9..4e0fdc9121d 100644
--- a/packages/core/test/session-runner-tool-events.test.ts
+++ b/packages/core/test/session-runner-tool-events.test.ts
@@ -132,7 +132,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 2eabb13beed..e50ec46818b 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 46546c5a961..a0cb863c7ef 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 0ac7ad96837..1cb4c8f2c2f 100644
--- a/packages/core/test/session-skill.test.ts
+++ b/packages/core/test/session-skill.test.ts
@@ -71,9 +71,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 e993f66fb17..063ade46afa 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-store.test.ts b/packages/core/test/session-store.test.ts
index fcf472f37d2..ae72cfc52e8 100644
--- a/packages/core/test/session-store.test.ts
+++ b/packages/core/test/session-store.test.ts
@@ -17,7 +17,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [
- [Bus.node, Bus.configured({ persist: true })],
+ Bus.node.replace(Bus.configured({ persist: true })),
]),
)
diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts
index 5422eedef34..8f508d1bb4b 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 d37fed2b9cd..191d8be18bd 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 f7e74c73cb9..e6b244785bc 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 e6947b13b81..1f01d9803c0 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 41804a25e0b..30b8cdd315d 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 b8dc53e2896..0c49d558301 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 dcb54d478ed..c3e352dc650 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 463ad63ccb0..d26bf788df4 100644
--- a/packages/core/test/snapshot.test.ts
+++ b/packages/core/test/snapshot.test.ts
@@ -120,9 +120,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* () {
@@ -355,8 +355,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 fa0b3e22a27..9cdfe584def 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 b5fa2d14aad..1c14c9acada 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 0295b042009..30e95e5486b 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 520dbe3225f..ec5becae5fd 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 d680e8e4f26..56fc610ca17 100644
--- a/packages/core/test/tool-read.test.ts
+++ b/packages/core/test/tool-read.test.ts
@@ -142,14 +142,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 b25643aab42..b38826585c1 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 0627d3555e9..dec824ab238 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 6c9feb96f78..779b0353a6a 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 a4f93e1c111..ecd27383a03 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 b028dd8bfa4..460b800cede 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 dcc8895fd9f..f58f6cf8bc4 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 704d5a9ee68..4259599546d 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 52fc4a92ad2..f1a18a28e8f 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 4af0445c8c3..ff4e074e30b 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 5469c16e93f..3563b70fa98 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 bb14ef9f437..2ee1a96d379 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 f1944ca431c..3797cfdffe5 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 7adb2111f73..66f8b6884a1 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 1a7fd4661ec..2a7f7a13d88 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/handlers/form.ts b/packages/server/src/handlers/form.ts
index f7cdba048ba..9166873b9f8 100644
--- a/packages/server/src/handlers/form.ts
+++ b/packages/server/src/handlers/form.ts
@@ -1,6 +1,7 @@
-import { Database } from "@opencode-ai/core/database/database"
import { Form } from "@opencode-ai/core/form"
+import { Instance } from "@opencode-ai/core/instance/service"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
+import { Session } from "@opencode-ai/core/session"
import {
ConflictError,
FormAlreadySettledError,
@@ -11,7 +12,7 @@ import {
import { Effect, Option } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
-import { requestRef, response, sessionRef, withLoadedLocationServices } from "../location"
+import { requestRef, response, sessionInfo, withLoadedLocationServices } from "../location"
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
@@ -20,7 +21,8 @@ function missingForm(id: Form.ID) {
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
- const database = yield* Database.Service
+ const instances = yield* Instance.Service
+ const sessions = yield* Session.Service
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
const form = yield* Form.Service
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
@@ -39,15 +41,12 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
- const ref =
- ctx.params.sessionID === "global"
- ? requestRef(ctx.request)
- : yield* sessionRef(database, ctx.params.sessionID)
- const forms = yield* withLoadedLocationServices(
- locations,
- ref,
- Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID })),
- )
+ const session =
+ ctx.params.sessionID === "global" ? undefined : yield* sessionInfo(sessions, ctx.params.sessionID)
+ const read = Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID }))
+ const forms = yield* session
+ ? read.pipe(instances.provideIfLoaded(session))
+ : withLoadedLocationServices(locations, requestRef(ctx.request), read)
return { data: Option.getOrElse(forms, () => []) }
}),
)
diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts
index b33b64ef740..76fcdfad9ef 100644
--- a/packages/server/src/handlers/permission.ts
+++ b/packages/server/src/handlers/permission.ts
@@ -1,13 +1,14 @@
-import { Database } from "@opencode-ai/core/database/database"
+import { Instance } from "@opencode-ai/core/instance/service"
import { Location } from "@opencode-ai/core/location"
-import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
+import { Session } from "@opencode-ai/core/session"
import { Effect, Option } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
-import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
-import { response, sessionRef, withLoadedLocationServices } from "../location"
+import { PermissionNotFoundError } from "@opencode-ai/protocol/errors"
+import { response, sessionInfo } from "../location"
+import { missingSession } from "./session-error"
function missingRequest(id: Permission.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
@@ -15,8 +16,8 @@ function missingRequest(id: Permission.ID) {
export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
Effect.gen(function* () {
- const locations = yield* LocationServiceMap.Service
- const database = yield* Database.Service
+ const instances = yield* Instance.Service
+ const sessions = yield* Session.Service
const requireOwnedRequest = Effect.fnUntraced(function* (
sessionID: Permission.Request["sessionID"],
requestID: Permission.ID,
@@ -51,28 +52,17 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
source: ctx.payload.source,
agent: ctx.payload.agent,
})
- .pipe(
- Effect.catchTag(
- "Session.NotFoundError",
- (error) =>
- new SessionNotFoundError({
- sessionID: error.sessionID,
- message: `Session not found: ${error.sessionID}`,
- }),
- ),
- ),
+ .pipe(Effect.catchTag("Session.NotFoundError", missingSession)),
}
}),
)
.handle(
"session.permission.list",
Effect.fn(function* (ctx) {
- const ref = yield* sessionRef(database, ctx.params.sessionID)
- const requests = yield* withLoadedLocationServices(
- locations,
- ref,
- Permission.Service.use((permission) => permission.forSession(ctx.params.sessionID)),
- )
+ const session = yield* sessionInfo(sessions, ctx.params.sessionID)
+ const requests = yield* Permission.Service.use((permission) =>
+ permission.forSession(ctx.params.sessionID),
+ ).pipe(instances.provideIfLoaded(session))
return { data: Option.getOrElse(requests, () => []) }
}),
)
diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts
index 13a0b4fbfb0..2761687ac70 100644
--- a/packages/server/src/location.ts
+++ b/packages/server/src/location.ts
@@ -1,15 +1,13 @@
-import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
-import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
-import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
-import { eq } from "drizzle-orm"
+import { InvalidRequestError } from "@opencode-ai/protocol/errors"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
+import { missingSession } from "./handlers/session-error"
export type LocationServices = Layer.Success>
@@ -33,24 +31,12 @@ export function response(data: Effect.Effect) {
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
-export function sessionRef(database: Context.Service.Shape, sessionID: unknown) {
- return Effect.gen(function* () {
- const id = yield* decodeSessionID(sessionID).pipe(
- Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
- )
- const row = yield* database.db
- .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
- .from(SessionTable)
- .where(eq(SessionTable.id, id))
- .get()
- .pipe(Effect.orDie)
- if (!row) return yield* new SessionNotFoundError({ sessionID: id, message: `Session not found: ${id}` })
- return Location.Ref.make({
- directory: AbsolutePath.make(row.directory),
- workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
- })
- })
-}
+export const sessionInfo = Effect.fnUntraced(function* (sessions: Session.Interface, sessionID: unknown) {
+ const id = yield* decodeSessionID(sessionID).pipe(
+ Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
+ )
+ return yield* sessions.get(id).pipe(Effect.catchTag("Session.NotFoundError", missingSession))
+})
export function withLoadedLocationServices(
locations: Context.Service.Shape,
diff --git a/packages/server/src/middleware/form-location.ts b/packages/server/src/middleware/form-location.ts
index ebcef974020..d089d20f407 100644
--- a/packages/server/src/middleware/form-location.ts
+++ b/packages/server/src/middleware/form-location.ts
@@ -1,10 +1,11 @@
-import { Database } from "@opencode-ai/core/database/database"
+import { Instance } from "@opencode-ai/core/instance/service"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
+import { Session } from "@opencode-ai/core/session"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
-import { requestRef, sessionRef, type LocationServices } from "../location"
+import { requestRef, sessionInfo, type LocationServices } from "../location"
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
FormLocationMiddleware,
@@ -16,7 +17,8 @@ export class FormLocationMiddleware extends HttpApiMiddleware.Service<
export const formLocationLayer = Layer.effect(
FormLocationMiddleware,
Effect.gen(function* () {
- const database = yield* Database.Service
+ const sessions = yield* Session.Service
+ const instances = yield* Instance.Service
const locations = yield* LocationServiceMap.Service
return FormLocationMiddleware.of((effect) =>
@@ -30,8 +32,8 @@ export const formLocationLayer = Layer.effect(
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
}
- const ref = yield* sessionRef(database, route.params.sessionID)
- return yield* effect.pipe(Effect.provide(locations.get(ref)))
+ const session = yield* sessionInfo(sessions, route.params.sessionID)
+ return yield* effect.pipe(instances.provide(session))
}),
)
}),
diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts
index ca5c7182b19..b0f91ab4989 100644
--- a/packages/server/src/middleware/session-location.ts
+++ b/packages/server/src/middleware/session-location.ts
@@ -1,10 +1,10 @@
-import { Database } from "@opencode-ai/core/database/database"
-import { LocationServiceMap } from "@opencode-ai/core/location-services"
+import { Instance } from "@opencode-ai/core/instance/service"
+import { Session } from "@opencode-ai/core/session"
import { Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
-import { sessionRef, type LocationServices } from "../location"
+import { sessionInfo, type LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
@@ -16,14 +16,14 @@ export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
export const sessionLocationLayer = Layer.effect(
SessionLocationMiddleware,
Effect.gen(function* () {
- const database = yield* Database.Service
- const locations = yield* LocationServiceMap.Service
+ const sessions = yield* Session.Service
+ const instances = yield* Instance.Service
return SessionLocationMiddleware.of((effect) =>
Effect.gen(function* () {
const route = yield* HttpRouter.RouteContext
- const ref = yield* sessionRef(database, route.params.sessionID)
- return yield* effect.pipe(Effect.provide(locations.get(ref)))
+ const session = yield* sessionInfo(sessions, route.params.sessionID)
+ return yield* effect.pipe(instances.provide(session))
}),
)
}),
diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts
index 9d2ee4a7c27..e537f3ccaa6 100644
--- a/packages/server/src/routes.ts
+++ b/packages/server/src/routes.ts
@@ -14,6 +14,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session"
+import { Instance } from "@opencode-ai/core/instance/service"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Job } from "@opencode-ai/core/job"
@@ -56,6 +57,7 @@ const applicationServiceNodes = [
Project.node,
Worktree.node,
Session.node,
+ Instance.byLocationNode,
SessionTransfer.node,
PluginRuntime.providerNode,
SdkPlugins.node,
@@ -100,35 +102,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 a27dadadba7..2d2e23f865e 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 c7a3cc98d37..3074d591670 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()
@@ -340,7 +340,7 @@ it.live("serves the session view operation and missing-session error", () =>
}),
)
-it.live("does not load a location when reading pending session requests", () =>
+it.live("routes pending requests by Session without loading an instance", () =>
Effect.gen(function* () {
const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-pending-read-")))
const handler = yield* ServerFetch.make({
@@ -368,16 +368,18 @@ it.live("does not load a location when reading pending session requests", () =>
),
)
+ // Session routing must ignore the caller's unrelated Location.
+ const headers = { "x-opencode-directory": encodeURIComponent(config.path) }
expect(yield* loaded()).toEqual([])
for (const resource of ["permission", "form"]) {
const response = yield* Effect.promise(() =>
- handler(new Request(`http://opencode.local/api/session/${created.data.id}/${resource}`)),
+ handler(new Request(`http://opencode.local/api/session/${created.data.id}/${resource}`, { headers })),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] })
const missing = yield* Effect.promise(() =>
- handler(new Request(`http://opencode.local/api/session/ses_missing_pending/${resource}`)),
+ handler(new Request(`http://opencode.local/api/session/ses_missing_pending/${resource}`, { headers })),
)
expect(missing.status).toBe(404)
}
@@ -396,7 +398,7 @@ it.live("does not load a location when reading pending session requests", () =>
handler(
new Request(`http://opencode.local/api/session/${created.data.id}/form`, {
method: "POST",
- headers: { "content-type": "application/json" },
+ headers: { "content-type": "application/json", ...headers },
body: JSON.stringify({ title: "Test form", fields: [{ key: "answer", type: "string" }] }),
}),
),
@@ -404,7 +406,7 @@ it.live("does not load a location when reading pending session requests", () =>
expect(createdForm.status).toBe(200)
const forms = yield* Effect.promise(() =>
- handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`)),
+ handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`, { headers })),
)
expect(forms.status).toBe(200)
expect(yield* Effect.promise(() => forms.json())).toMatchObject({
@@ -442,7 +444,7 @@ it.live("does not load a location when reading pending session requests", () =>
handler(
new Request(`http://opencode.local/api/session/${created.data.id}/permission`, {
method: "POST",
- headers: { "content-type": "application/json" },
+ headers: { "content-type": "application/json", ...headers },
body: JSON.stringify({ id: "per_pending_read", action: "shell", resources: ["pwd"] }),
}),
),
@@ -453,7 +455,7 @@ it.live("does not load a location when reading pending session requests", () =>
})
const permissions = yield* Effect.promise(() =>
- handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`)),
+ handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`, { headers })),
)
expect(permissions.status).toBe(200)
expect(yield* Effect.promise(() => permissions.json())).toMatchObject({
diff --git a/packages/server/test/generate.test.ts b/packages/server/test/generate.test.ts
index 7c64aef94de..a61c4f0ec0f 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-instances.test.ts b/packages/server/test/session-instances.test.ts
new file mode 100644
index 00000000000..bf3cf89eb7e
--- /dev/null
+++ b/packages/server/test/session-instances.test.ts
@@ -0,0 +1,328 @@
+import { expect } from "bun:test"
+import { App } from "@opencode-ai/core/app"
+import { Bus } from "@opencode-ai/core/bus"
+import { Database } from "@opencode-ai/core/database/database"
+import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
+import { Watcher } from "@opencode-ai/core/filesystem/watcher"
+import { Form } from "@opencode-ai/core/form"
+import { Instance } from "@opencode-ai/core/instance"
+import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
+import { ModelsDev } from "@opencode-ai/core/models-dev"
+import { Permission } from "@opencode-ai/core/permission"
+import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
+import { Session } from "@opencode-ai/core/session"
+import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
+import { Plugin } from "@opencode-ai/plugin/effect"
+import { Agent } from "@opencode-ai/schema/agent"
+import { Location } from "@opencode-ai/schema/location"
+import { AbsolutePath } from "@opencode-ai/schema/schema"
+import { LayerNode } from "@opencode-ai/util/effect/layer-node"
+import { Global } from "@opencode-ai/util/global"
+import { Context, Duration, Effect, Layer, LayerMap, Option, RcMap, Schema, Scope } from "effect"
+import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
+import { LanguageModel, LLMClient } from "../../ai/src"
+import { OpenAIChat } from "../../ai/src/protocols/openai-chat"
+import { TestLLM } from "../../ai/src/testing"
+import { tempGlobalLayer } from "../../core/test/fixture/global"
+import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
+import { it } from "../../core/test/lib/effect"
+import { createEmbeddedRoutes } from "../src/routes"
+
+it.live(
+ "isolates same-directory Session tools, hooks, commands, and HTTP requests through one selector",
+ () =>
+ Effect.gen(function* () {
+ const directory = yield* tmpdirScoped()
+ const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
+ const first = { id: Session.ID.make("ses_instance_first"), tool: "instance_first", temperature: 0.1 }
+ const second = { id: Session.ID.make("ses_instance_second"), tool: "instance_second", temperature: 0.2 }
+ const configs = [first, second]
+ const boots: Session.ID[] = []
+ const executed: Session.ID[] = []
+ const commands: Session.ID[] = []
+ const llm = yield* TestLLM.Test.pipe(Effect.provide(TestLLM.testLayer()))
+ const model = SessionRunnerModel.resolved(
+ LanguageModel.make({ id: "instance-model", provider: "test", route: OpenAIChat.route }),
+ {
+ capabilities: { tools: true, input: ["text"], output: ["text"] },
+ cost: [],
+ limit: { context: 200_000, output: 8_192 },
+ },
+ )
+ const cell = PluginRuntime.makeCell()
+ // Host and private instances must reuse the same global layer identities.
+ const replacements: LayerNode.Replacements = [
+ [Global.node, tempGlobalLayer],
+ [Database.node, Database.node],
+ [Bus.node, Bus.node],
+ [App.node, App.node],
+ [ModelsDev.node, ModelsDev.configured({ fetch: false })],
+ [Watcher.node, Watcher.configured({ enabled: false })],
+ [PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
+ [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(cell)],
+ [llmClient, Layer.succeed(LLMClient.Service, llm)],
+ [SessionRunnerModel.node, Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) })],
+ [
+ Instance.byLocationNode,
+ Layer.effect(
+ Instance.Service,
+ Effect.gen(function* () {
+ const instances = yield* LayerMap.make(
+ (id: Session.ID) => {
+ const config = configs.find((config) => config.id === id)
+ if (!config) throw new Error(`No instance configuration for ${id}`)
+ return Instance.layer(location, {
+ discovery: false,
+ replacements,
+ plugins: [
+ Plugin.define({
+ id: "session-instance",
+ effect: Effect.fnUntraced(function* (ctx) {
+ boots.push(config.id)
+ yield* ctx.agent.transform((draft) =>
+ draft.update("build", (agent) => {
+ agent.permissions = [{ action: "*", resource: "*", effect: "allow" }]
+ }),
+ )
+ yield* ctx.session.hook("prompt", (event) =>
+ Effect.sync(() => {
+ event.prompt.text += ` [${config.tool}]`
+ }),
+ )
+ yield* ctx.session.hook("context", (event) =>
+ Effect.sync(() => {
+ event.generation.temperature = config.temperature
+ }),
+ )
+ yield* ctx.permission.hook("evaluate", (event) =>
+ Effect.sync(() => {
+ event.effect = event.action === "instance-test" ? "ask" : "allow"
+ if (event.action === "instance-test") event.message = config.tool
+ }),
+ )
+ yield* ctx.tool.transform((draft) =>
+ draft.add({
+ name: config.tool,
+ description: `Tool for ${config.id}`,
+ input: Schema.Struct({}),
+ output: Schema.String,
+ options: { codemode: false },
+ execute: () =>
+ Effect.sync(() => {
+ executed.push(config.id)
+ return { output: config.id, content: config.id }
+ }),
+ }),
+ )
+ yield* ctx.command.transform((draft) =>
+ draft.add({
+ name: "instance-check",
+ execute: (input) =>
+ ctx.session
+ .prompt({
+ sessionID: input.sessionID,
+ text: `command ${config.tool}`,
+ delivery: input.delivery,
+ resume: false,
+ })
+ .pipe(
+ Effect.tap(() => Effect.sync(() => commands.push(config.id))),
+ Effect.asVoid,
+ ),
+ }),
+ )
+ }),
+ }),
+ ],
+ })
+ },
+ { idleTimeToLive: Duration.infinity },
+ )
+ return Instance.Service.of({
+ provide: (session) => Effect.provide(instances.get(session.id)),
+ provideIfLoaded: (session) => (effect) =>
+ Effect.scopedWith((scope) =>
+ Effect.gen(function* () {
+ const context = yield* instances.contextEffectOption(session.id).pipe(Scope.provide(scope))
+ if (Option.isNone(context)) return Option.none()
+ return Option.some(yield* effect.pipe(Effect.provide(context.value)))
+ }),
+ ),
+ })
+ }),
+ ),
+ ],
+ ]
+ const context = yield* Layer.build(
+ createEmbeddedRoutes({}, replacements).pipe(Layer.provide(HttpServer.layerServices)),
+ )
+ const sessions = Context.get(context, Session.Service)
+ const instances = Context.get(context, Instance.Service)
+ const locations = Context.get(context, LocationServiceMap.Service)
+ const handler = Context.get(context, HttpRouter.HttpRouter)
+ .asHttpEffect()
+ .pipe(HttpEffect.toWebHandlerWith(context))
+ const request = (route: string, body?: unknown) =>
+ Effect.promise(() =>
+ handler(
+ new Request(
+ `http://opencode.local${route}`,
+ body === undefined
+ ? undefined
+ : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) },
+ ),
+ ),
+ )
+
+ yield* Effect.forEach(configs, (config) =>
+ sessions.create({
+ id: config.id,
+ title: config.tool,
+ agent: Agent.ID.make("build"),
+ model: model.ref,
+ location,
+ }),
+ )
+ expect((yield* sessions.list()).data.map((session) => session.location)).toEqual([location, location])
+ expect(yield* sessions.messages({ sessionID: first.id })).toEqual([])
+ yield* sessions.get(second.id)
+ for (const config of configs) {
+ for (const resource of ["permission", "form"]) {
+ const response = yield* request(`/api/session/${config.id}/${resource}`)
+ expect(response.status).toBe(200)
+ expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] })
+ }
+ }
+ expect(boots).toEqual([])
+
+ for (const config of configs) {
+ const admitted = yield* sessions.prompt({ sessionID: config.id, text: "run", resume: false })
+ expect(admitted.payload.text).toBe(`run [${config.tool}]`)
+ }
+ expect(boots).toEqual([first.id, second.id])
+
+ for (const config of configs) {
+ yield* llm.push(TestLLM.tool(`call_${config.tool}`, config.tool, {}), TestLLM.text(config.id, config.tool))
+ yield* sessions.resume(config.id)
+ }
+ expect(executed).toEqual([first.id, second.id])
+
+ for (const config of configs) {
+ yield* llm.push(TestLLM.text(`generated ${config.id}`, config.tool))
+ expect(yield* sessions.generate({ sessionID: config.id, prompt: "summarize" })).toBe(`generated ${config.id}`)
+ yield* sessions.command({ sessionID: config.id, command: "instance-check", text: "" })
+ expect(yield* sessions.inbox(config.id)).toMatchObject([
+ { type: "user", payload: { text: `command ${config.tool} [${config.tool}]` } },
+ ])
+ }
+ expect(commands).toEqual([first.id, second.id])
+ expect(
+ (yield* llm.requests()).map((request) => ({
+ temperature: request.generation?.temperature,
+ tools: request.tools?.filter((tool) => tool.name.startsWith("instance_")).map((tool) => tool.name),
+ })),
+ ).toEqual(
+ [first, first, second, second, first, second].map((config) => ({
+ temperature: config.temperature,
+ tools: [config.tool],
+ })),
+ )
+
+ // Seed through Core, then use HTTP to reach those exact private instances.
+ const pending = yield* Effect.forEach(configs, (config) =>
+ Effect.gen(function* () {
+ const session = yield* sessions.get(config.id)
+ return yield* Effect.gen(function* () {
+ const forms = yield* Form.Service
+ const permissions = yield* Permission.Service
+ const form = yield* forms.create({
+ sessionID: config.id,
+ title: config.tool,
+ fields: [{ key: "answer", type: "string" }],
+ })
+ const permission = {
+ id: Permission.ID.create(),
+ sessionID: config.id,
+ action: "instance-test",
+ resources: [config.tool],
+ }
+ expect(yield* permissions.ask(permission)).toEqual({ id: permission.id, effect: "ask" })
+ const foreignID = config.id === first.id ? second.id : first.id
+ const foreignForm = yield* forms.create({
+ sessionID: foreignID,
+ title: "Foreign form",
+ fields: [{ key: "answer", type: "string" }],
+ })
+ const foreignPermission = yield* permissions.ask({
+ ...permission,
+ id: Permission.ID.create(),
+ sessionID: foreignID,
+ })
+ return {
+ session,
+ form,
+ permission: { ...permission, message: config.tool },
+ foreignForm,
+ foreignPermission,
+ }
+ }).pipe(instances.provide(session))
+ }),
+ )
+ for (const entry of pending) {
+ const forms = yield* request(`/api/session/${entry.session.id}/form`)
+ expect(forms.status).toBe(200)
+ expect(yield* Effect.promise(() => forms.json())).toEqual({ data: [entry.form] })
+ const permissions = yield* request(`/api/session/${entry.session.id}/permission`)
+ expect(permissions.status).toBe(200)
+ expect(yield* Effect.promise(() => permissions.json())).toEqual({ data: [entry.permission] })
+
+ // These IDs exist in the selected instance, but belong to the other Session.
+ expect((yield* request(`/api/session/${entry.session.id}/form/${entry.foreignForm.id}`)).status).toBe(404)
+ expect(
+ (yield* request(`/api/session/${entry.session.id}/permission/${entry.foreignPermission.id}`)).status,
+ ).toBe(404)
+ expect(
+ (yield* request(`/api/session/${entry.session.id}/form/${entry.foreignForm.id}/reply`, {
+ answer: { answer: "wrong" },
+ })).status,
+ ).toBe(404)
+ expect(
+ (yield* request(`/api/session/${entry.session.id}/permission/${entry.foreignPermission.id}/reply`, {
+ reply: "once",
+ })).status,
+ ).toBe(404)
+ yield* Effect.gen(function* () {
+ const forms = yield* Form.Service
+ const permissions = yield* Permission.Service
+ expect(yield* forms.state(entry.foreignForm.id)).toEqual({ status: "pending" })
+ expect(yield* permissions.get(entry.foreignPermission.id)).toMatchObject({
+ sessionID: entry.foreignForm.sessionID,
+ })
+ }).pipe(instances.provide(entry.session))
+ }
+ for (const entry of pending) {
+ expect(
+ (yield* request(`/api/session/${entry.session.id}/form/${entry.form.id}/reply`, {
+ answer: { answer: entry.session.id },
+ })).status,
+ ).toBe(204)
+ expect(
+ (yield* request(`/api/session/${entry.session.id}/permission/${entry.permission.id}/reply`, {
+ reply: "once",
+ })).status,
+ ).toBe(204)
+ yield* Effect.gen(function* () {
+ const forms = yield* Form.Service
+ const permissions = yield* Permission.Service
+ expect(yield* forms.state(entry.form.id)).toEqual({
+ status: "answered",
+ answer: { answer: entry.session.id },
+ })
+ expect(yield* permissions.get(entry.permission.id)).toBeUndefined()
+ }).pipe(instances.provide(entry.session))
+ }
+ expect(boots).toEqual([first.id, second.id])
+ expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
+ }),
+ 15_000,
+)
diff --git a/packages/server/test/session-message-update.test.ts b/packages/server/test/session-message-update.test.ts
index 39352db6bf1..49b0b72eb2b 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"
@@ -55,7 +56,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 760854fac95..59921c5451b 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 537405b44b4..04c2d588d34 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 d016f6d1a24..c6b17038a28 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/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx
index c1434b1fffd..533ef1dc5f1 100644
--- a/packages/tui/src/routes/session/sidebar.tsx
+++ b/packages/tui/src/routes/session/sidebar.tsx
@@ -29,16 +29,40 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
paddingRight={2}
position={props.overlay ? "absolute" : "relative"}
>
+
+
+ {withTimestampedFallback(session())}
+
+
+ {session().location.workspaceID}
+
+
- queueMicrotask(() => {
- if (!scroll.isDestroyed) scroll.verticalScrollBar.resetVisibilityControl()
- })
- }
flexGrow={1}
+ minHeight={0}
scrollAcceleration={scrollAcceleration()}
+ // The sidebar only scrolls vertically; a horizontal bar steals a row during initial layout.
+ horizontalScrollbarOptions={{ visible: false }}
verticalScrollbarOptions={{
- visible: false,
+ // Use the content's reserved right padding instead of changing its width when the bar toggles.
+ position: "absolute",
+ right: 0,
+ top: 0,
+ width: 1,
+ height: "100%",
trackOptions: {
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
@@ -46,27 +70,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
}}
>
-
-
- {withTimestampedFallback(session())}
-
-
- {session().location.workspaceID}
-
-
diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx
index aa7f54ea681..0bc55037fd8 100644
--- a/packages/tui/test/cli/tui/dialog-select.test.tsx
+++ b/packages/tui/test/cli/tui/dialog-select.test.tsx
@@ -192,7 +192,7 @@ test("ctrl+c clears a dialog text selection before closing the dialog", async ()
const column = frame[row]!.indexOf("Alpha") + 1
await select.app.mockMouse.click(column, row)
await select.app.mockMouse.click(column, row)
- expect(select.app.renderer.getSelection()?.getSelectedText()).toBe("Alpha")
+ await select.app.waitFor(() => select.app.renderer.getSelection()?.getSelectedText() === "Alpha")
select.app.mockInput.pressKey("c", { ctrl: true })
await select.app.waitFor(() => !select.app.renderer.getSelection())
diff --git a/packages/util/docs/layer-node.md b/packages/util/docs/layer-node.md
new file mode 100644
index 00000000000..aa78972df20
--- /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 8117eba8a38..75fe0861342 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 e0491c9f015..dd5cfce6cec 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 325777ab737..8a2942d48e6 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"
+
+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
+ }
}
-type NodeIdentity =
+class NodeValue extends GraphValue {
+ // Forgetting part of a node's contract must not authorize a weaker replacement.
+ declare readonly [NodeTypeId]: (_: [A, E, T]) => [A, E, T]
+
+ constructor(
+ definition: Exclude,
+ readonly tag: T,
+ ) {
+ super(definition)
+ }
+
+ /** Replace this declaration, including every dependency on it, without acquiring its old wiring. */
+ replace(
+ this: Node,
+ replacement: Target & CheckReplacement,
+ ): Replacement {
+ if (replacement instanceof NodeValue) {
+ if (replacement.tag !== this.tag) throw new Error(`Cannot replace ${this.name} across tags`)
+ return new ReplacementValue(this, replacement)
+ }
+ if (!Layer.isLayer(replacement)) throw new Error("A replacement must be a node or an Effect Layer")
+ return new ReplacementValue(this, makeProvider({ name: this.name, layer: replacement, deps: [] }, this.tag))
+ }
+}
+
+class ProviderNode extends NodeValue {
+ /** Decorate the implementation while preserving its dependency wiring and service contract. */
+ mapLayer(this: Provider, f: (layer: Layer.Layer) => Layer.Layer): Provider {
+ const definition = this[GraphTypeId]
+ if (definition.kind !== "layer") throw new Error(`Cannot map unbound layer node: ${this.name}`)
+ return new ProviderNode(
+ { ...definition, implementation: f(definition.implementation as Layer.Layer) },
+ this.tag,
+ )
+ }
+}
+
+class ReplacementValue {
+ declare private readonly checked: void
+ readonly [ReplacementTypeId]: { readonly source: AnyNode; readonly target: AnyNode }
+
+ constructor(source: AnyNode, target: AnyNode) {
+ this[ReplacementTypeId] = { source, target }
+ }
+}
+
+type CheckErrors = [Exclude] extends [never]
+ ? never
+ : { readonly "New replacement errors": Exclude }
+
+type CheckReplacement = [ReplacementErrors] extends [never]
+ ? unknown
+ : ReplacementErrors
+
+type ReplacementErrors = Target extends AnyNode
+ ? [Exclude>] extends [never]
+ ? [GraphTag] extends [T]
+ ? CheckErrors>
+ : { readonly "Invalid replacement tag": GraphTag }
+ : { readonly "Missing replacement outputs": Exclude> }
+ : Target extends Layer.Layer
+ ? CheckErrors
+ : { readonly "Replacement must be a closed layer": Target }
+
+type CheckDependencies = [
+ Exclude, ListOutput>,
+] extends [never]
+ ? unknown
+ : { readonly "Missing dependencies": Exclude, ListOutput> }
+
+type Identity =
| { readonly service: Context.Service.Any; readonly name?: never }
| { readonly name: string; readonly service?: never }
+type CheckLayer = [Implementation] extends [RuntimeLayer]
+ ? unknown
+ : { readonly "Layer contract must be preserved": Implementation }
+type TagInput = { readonly tag: T } | ([T] extends [undefined] ? { readonly tag?: undefined } : never)
+type MakeInput = Identity &
+ TagInput & {
+ readonly layer: Implementation & CheckLayer>
+ readonly deps: Items & CheckDependencies>
+ }
type DistributiveOmit = A extends unknown ? Omit : never
export type TagConfig = Readonly>
type TagNames = keyof Config & string
-type NodeInTags = Node | undefined>
-type CheckTags = [Exclude>] extends [
- never,
-]
+type CheckTags = [
+ Exclude, Tag | undefined>,
+] extends [never]
? unknown
- : { readonly "Invalid tag dependencies": Exclude> }
+ : { readonly "Invalid tag dependencies": Exclude, Tag | undefined> }
export interface Tags {
readonly values: { readonly [Name in TagNames]: Tag }
readonly make: >(
name: Name,
- ) => (
+ ) => (
input: DistributiveOmit>, "tag"> &
CheckTags>,
- ) => Node, Layer.Error | Error, Tag>
+ ) => Provider, Layer.Error | Error, Tag>
}
export function tags(
@@ -61,271 +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)
+}
+
+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 unbound(service: Context.Key, tag: T): Node {
- return {
- kind: "unbound",
- name: service.key,
- service,
- dependencies: [],
- tag,
- }
+ return new NodeValue({ kind: "unbound", name: service.key }, tag)
}
-export function group(
+/** Ordered, associative composition. Only these roots' outputs are exposed; their dependencies remain private. */
+export function group(
dependencies: Items,
-): Node