Przeglądaj źródła

refactor(core): finish test layer node conversion (#34385)

James Long 2 miesięcy temu
rodzic
commit
a3776429aa
60 zmienionych plików z 728 dodań i 602 usunięć
  1. 7 0
      packages/core/src/control-plane/move-session.ts
  2. 42 1
      packages/core/src/effect/layer-node.ts
  3. 8 0
      packages/core/src/tool/apply-patch.ts
  4. 8 0
      packages/core/src/tool/bash.ts
  5. 13 30
      packages/core/src/tool/builtins.ts
  6. 8 0
      packages/core/src/tool/edit.ts
  7. 8 0
      packages/core/src/tool/glob.ts
  8. 8 0
      packages/core/src/tool/grep.ts
  9. 8 0
      packages/core/src/tool/question.ts
  10. 8 0
      packages/core/src/tool/read.ts
  11. 8 0
      packages/core/src/tool/skill.ts
  12. 8 0
      packages/core/src/tool/todowrite.ts
  13. 9 0
      packages/core/src/tool/webfetch.ts
  14. 11 0
      packages/core/src/tool/websearch.ts
  15. 8 0
      packages/core/src/tool/write.ts
  16. 2 1
      packages/core/test/agent.test.ts
  17. 6 3
      packages/core/test/background-job.test.ts
  18. 2 1
      packages/core/test/command.test.ts
  19. 4 7
      packages/core/test/config/config.test.ts
  20. 3 2
      packages/core/test/database-migration.test.ts
  21. 4 4
      packages/core/test/effect/layer-node/layer-node-types.test.ts
  22. 15 0
      packages/core/test/effect/layer-node/layer-node.test.ts
  23. 8 5
      packages/core/test/event.test.ts
  24. 10 5
      packages/core/test/file-mutation.test.ts
  25. 9 11
      packages/core/test/filesystem/watcher.test.ts
  26. 3 3
      packages/core/test/fixture/effect-flock-worker.ts
  27. 82 61
      packages/core/test/instruction-context.test.ts
  28. 5 20
      packages/core/test/location-layer.test.ts
  29. 2 1
      packages/core/test/location.test.ts
  30. 1 1
      packages/core/test/models.test.ts
  31. 13 36
      packages/core/test/move-session.test.ts
  32. 3 10
      packages/core/test/npm.test.ts
  33. 7 19
      packages/core/test/permission.test.ts
  34. 3 5
      packages/core/test/plugin/command.test.ts
  35. 42 27
      packages/core/test/plugin/fixture.ts
  36. 2 1
      packages/core/test/plugin/provider-dynamic.test.ts
  37. 6 1
      packages/core/test/plugin/provider-opencode.test.ts
  38. 4 5
      packages/core/test/policy.test.ts
  39. 12 11
      packages/core/test/pty/pty-session.test.ts
  40. 5 2
      packages/core/test/pty/ticket.test.ts
  41. 31 28
      packages/core/test/reference-guidance.test.ts
  42. 11 22
      packages/core/test/session-create.test.ts
  43. 6 18
      packages/core/test/session-history.test.ts
  44. 5 17
      packages/core/test/session-prompt.test.ts
  45. 48 47
      packages/core/test/session-runner-recorded.test.ts
  46. 7 3
      packages/core/test/session-runner-tool-registry.test.ts
  47. 53 57
      packages/core/test/session-runner.test.ts
  48. 4 1
      packages/core/test/skill/guidance.test.ts
  49. 8 18
      packages/core/test/snapshot.test.ts
  50. 2 1
      packages/core/test/system-context/registry.test.ts
  51. 17 12
      packages/core/test/tool-apply-patch.test.ts
  52. 15 13
      packages/core/test/tool-bash.test.ts
  53. 14 12
      packages/core/test/tool-edit.test.ts
  54. 7 5
      packages/core/test/tool-question.test.ts
  55. 22 29
      packages/core/test/tool-read.test.ts
  56. 8 11
      packages/core/test/tool-skill.test.ts
  57. 17 7
      packages/core/test/tool-todowrite.test.ts
  58. 13 10
      packages/core/test/tool-webfetch.test.ts
  59. 11 7
      packages/core/test/tool-websearch.test.ts
  60. 14 11
      packages/core/test/tool-write.test.ts

+ 7 - 0
packages/core/src/control-plane/move-session.ts

@@ -1,6 +1,7 @@
 export * as MoveSession from "./move-session"
 
 import { Context, DateTime, Effect, Layer, Schema } from "effect"
+import { makeGlobalNode } from "../effect/app-node"
 import { EventV2 } from "../event"
 import { Git } from "../git"
 import { Location } from "../location"
@@ -146,3 +147,9 @@ export const defaultLayer = layer.pipe(
   Layer.provide(ProjectV2.defaultLayer),
   Layer.provide(SessionStore.defaultLayer),
 )
+
+export const node = makeGlobalNode({
+  service: Service,
+  layer,
+  deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
+})

+ 42 - 1
packages/core/src/effect/layer-node.ts

@@ -272,7 +272,48 @@ export function compile<A, E, const Items extends Replacements = readonly []>(
 }
 
 function replacementMapFrom(replacements?: Replacements) {
-  return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
+  return replacements?.reduce((map, [source, replacement]) => {
+    const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map)
+    const current = new Map([[source.name, normalized]])
+    for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current))
+    map.set(source.name, normalized)
+    return map
+  }, new Map<string, AnyNode>()) ?? new Map<string, AnyNode>()
+}
+
+function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap<string, AnyNode>) {
+  if (replacements.size === 0) return root
+  const cache = new Map<AnyNode, AnyNode>()
+  const visiting = new Set<AnyNode>()
+  const stack: AnyNode[] = []
+
+  const recur = (node: AnyNode, isRoot = false): AnyNode => {
+    const target = isRoot ? node : (replacements.get(node.name) ?? node)
+    const cached = cache.get(target)
+    if (cached !== undefined || cache.has(target)) return cached!
+    if (visiting.has(target)) {
+      const start = stack.indexOf(target)
+      throw new Error(
+        `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
+      )
+    }
+
+    visiting.add(target)
+    stack.push(target)
+    try {
+      const dependencies = target.dependencies.map((dependency) => recur(dependency))
+      const result = dependencies.every((dependency, index) => dependency === target.dependencies[index])
+        ? target
+        : { ...target, dependencies }
+      cache.set(target, result)
+      return result
+    } finally {
+      stack.pop()
+      visiting.delete(target)
+    }
+  }
+
+  return recur(root, true)
 }
 
 export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {

+ 8 - 0
packages/core/src/tool/apply-patch.ts

@@ -4,11 +4,13 @@ import { ToolFailure } from "@opencode-ai/llm"
 import { FileDiff } from "@opencode-ai/schema/file-diff"
 import { createTwoFilesPatch, diffLines } from "diff"
 import { Effect, Layer, Schema } from "effect"
+import { makeLocationNode } from "../effect/app-node"
 import { FileMutation } from "../file-mutation"
 import { FSUtil } from "../fs-util"
 import { LocationMutation } from "../location-mutation"
 import { Patch } from "../patch"
 import { PermissionV2 } from "../permission"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -194,6 +196,12 @@ export const layer = Layer.effectDiscard(
   }),
 )
 
+export const node = makeLocationNode({
+  name: "tool/apply-patch",
+  layer,
+  deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
+})
+
 function patchFile(change: Prepared): typeof FileDiff.Info.Type {
   const counts = diffLines(change.before, change.after).reduce(
     (result, item) => ({

+ 8 - 0
packages/core/src/tool/bash.ts

@@ -5,11 +5,13 @@ import { ToolFailure } from "@opencode-ai/llm"
 import { Duration, Effect, Layer, Schema } from "effect"
 import { ChildProcess } from "effect/unstable/process"
 import { Config } from "../config"
+import { makeLocationNode } from "../effect/app-node"
 import { FSUtil } from "../fs-util"
 import { LocationMutation } from "../location-mutation"
 import { AppProcess } from "../process"
 import { PermissionV2 } from "../permission"
 import { PositiveInt } from "../schema"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -193,3 +195,9 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
   }),
 )
+
+export const node = makeLocationNode({
+  name: "tool/bash",
+  layer,
+  deps: [ToolRegistry.node, LocationMutation.node, FSUtil.node, AppProcess.node, Config.node, PermissionV2.node],
+})

+ 13 - 30
packages/core/src/tool/builtins.ts

@@ -15,20 +15,6 @@ import { TodoWriteTool } from "./todowrite"
 import { WebFetchTool } from "./webfetch"
 import { WebSearchTool } from "./websearch"
 import { WriteTool } from "./write"
-import { FSUtil } from "../fs-util"
-import { AppProcess } from "../process"
-import { Config } from "../config"
-import { Location } from "../location"
-import { LocationMutation } from "../location-mutation"
-import { FileMutation } from "../file-mutation"
-import { PermissionV2 } from "../permission"
-import { Ripgrep } from "../ripgrep"
-import { Image } from "../image"
-import { QuestionV2 } from "../question"
-import { SkillV2 } from "../skill"
-import { SessionTodo } from "../session/todo"
-import { ToolRegistry } from "./registry"
-import { httpClient } from "../effect/app-node-platform"
 
 /**
  * Composes only the shipped Location-scoped built-in tool transforms.
@@ -60,22 +46,19 @@ export const locationLayer = Layer.mergeAll(
 
 export const node = makeLocationNode({
   name: "built-in-tools",
-  layer: locationLayer,
+  layer: Layer.empty,
   deps: [
-    ToolRegistry.toolsNode,
-    FSUtil.node,
-    AppProcess.node,
-    Config.node,
-    Location.node,
-    LocationMutation.node,
-    FileMutation.node,
-    PermissionV2.node,
-    Ripgrep.node,
-    Image.node,
-    QuestionV2.node,
-    SkillV2.node,
-    SessionTodo.node,
-    ReadToolFileSystem.node,
-    httpClient,
+    ApplyPatchTool.node,
+    BashTool.node,
+    EditTool.node,
+    GlobTool.node,
+    GrepTool.node,
+    QuestionTool.node,
+    ReadTool.node,
+    SkillTool.node,
+    TodoWriteTool.node,
+    WebFetchTool.node,
+    WebSearchTool.node,
+    WriteTool.node,
   ],
 })

+ 8 - 0
packages/core/src/tool/edit.ts

@@ -10,10 +10,12 @@ import { ToolFailure } from "@opencode-ai/llm"
 import { FileDiff } from "@opencode-ai/schema/file-diff"
 import { createTwoFilesPatch, diffLines } from "diff"
 import { Effect, Layer, Schema } from "effect"
+import { makeLocationNode } from "../effect/app-node"
 import { FileMutation } from "../file-mutation"
 import { FSUtil } from "../fs-util"
 import { LocationMutation } from "../location-mutation"
 import { PermissionV2 } from "../permission"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -213,3 +215,9 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
   }),
 )
+
+export const node = makeLocationNode({
+  name: "tool/edit",
+  layer,
+  deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
+})

+ 8 - 0
packages/core/src/tool/glob.ts

@@ -3,11 +3,13 @@ export * as GlobTool from "./glob"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
 import path from "path"
+import { makeLocationNode } from "../effect/app-node"
 import { FileSystem } from "../filesystem"
 import { Location } from "../location"
 import { Ripgrep } from "../ripgrep"
 import { RelativePath } from "../schema"
 import { PermissionV2 } from "../permission"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -95,3 +97,9 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
   }),
 )
+
+export const node = makeLocationNode({
+  name: "tool/glob",
+  layer,
+  deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node],
+})

+ 8 - 0
packages/core/src/tool/grep.ts

@@ -3,12 +3,14 @@ export * as GrepTool from "./grep"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
 import path from "path"
+import { makeLocationNode } from "../effect/app-node"
 import { FileSystem } from "../filesystem"
 import { FSUtil } from "../fs-util"
 import { Location } from "../location"
 import { PermissionV2 } from "../permission"
 import { Ripgrep } from "../ripgrep"
 import { RelativePath } from "../schema"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -127,3 +129,9 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
   }),
 )
+
+export const node = makeLocationNode({
+  name: "tool/grep",
+  layer,
+  deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
+})

+ 8 - 0
packages/core/src/tool/question.ts

@@ -2,8 +2,10 @@ export * as QuestionTool from "./question"
 
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
+import { makeLocationNode } from "../effect/app-node"
 import { PermissionV2 } from "../permission"
 import { QuestionV2 } from "../question"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -84,3 +86,9 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
   }),
 )
+
+export const node = makeLocationNode({
+  name: "tool/question",
+  layer,
+  deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
+})

+ 8 - 0
packages/core/src/tool/read.ts

@@ -2,12 +2,14 @@ export * as ReadTool from "./read"
 
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
+import { makeLocationNode } from "../effect/app-node"
 import { FileSystem } from "../filesystem"
 import { Image } from "../image"
 import { LocationMutation } from "../location-mutation"
 import { PermissionV2 } from "../permission"
 import { AbsolutePath } from "../schema"
 import { ReadToolFileSystem } from "./read-filesystem"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -107,3 +109,9 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
   }),
 )
+
+export const node = makeLocationNode({
+  name: "tool/read",
+  layer,
+  deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
+})

+ 8 - 0
packages/core/src/tool/skill.ts

@@ -3,9 +3,11 @@ export * as SkillTool from "./skill"
 import path from "path"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
+import { makeLocationNode } from "../effect/app-node"
 import { FSUtil } from "../fs-util"
 import { SkillV2 } from "../skill"
 import { PermissionV2 } from "../permission"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -99,3 +101,9 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
   }),
 )
+
+export const node = makeLocationNode({
+  name: "tool/skill",
+  layer,
+  deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node],
+})

+ 8 - 0
packages/core/src/tool/todowrite.ts

@@ -2,8 +2,10 @@ export * as TodoWriteTool from "./todowrite"
 
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
+import { makeLocationNode } from "../effect/app-node"
 import { PermissionV2 } from "../permission"
 import { SessionTodo } from "../session/todo"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -52,3 +54,9 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
   }),
 )
+
+export const node = makeLocationNode({
+  name: "tool/todowrite",
+  layer,
+  deps: [ToolRegistry.node, PermissionV2.node, SessionTodo.node],
+})

+ 9 - 0
packages/core/src/tool/webfetch.ts

@@ -5,8 +5,11 @@ import { Duration, Effect, Layer, Schema } from "effect"
 import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import { Parser } from "htmlparser2"
 import TurndownService from "turndown"
+import { makeLocationNode } from "../effect/app-node"
+import { LayerNodePlatform } from "../effect/app-node-platform"
 import { PermissionV2 } from "../permission"
 import { collectBoundedResponseBody } from "./http-body"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -177,6 +180,12 @@ export const layer = Layer.effectDiscard(
   }),
 )
 
+export const node = makeLocationNode({
+  name: "tool/webfetch",
+  layer,
+  deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient],
+})
+
 export function extractTextFromHTML(html: string) {
   let text = ""
   let skipDepth = 0

+ 11 - 0
packages/core/src/tool/websearch.ts

@@ -3,6 +3,8 @@ export * as WebSearchTool from "./websearch"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Context, Duration, Effect, Layer, Schema } from "effect"
 import { HttpClient, HttpClientRequest } from "effect/unstable/http"
+import { makeLocationNode } from "../effect/app-node"
+import { LayerNodePlatform } from "../effect/app-node-platform"
 import { truthy } from "../flag/flag"
 import { InstallationVersion } from "../installation/version"
 import { PositiveInt } from "../schema"
@@ -11,6 +13,7 @@ import { Tool } from "./tool"
 import { Tools } from "./tools"
 import { collectBoundedResponseBody } from "./http-body"
 import { checksum } from "../util/encode"
+import { ToolRegistry } from "./registry"
 
 export const name = "websearch"
 export const NO_RESULTS = "No search results found. Please try a different query."
@@ -80,6 +83,8 @@ export const defaultConfigLayer = Layer.sync(ConfigService, () =>
   }),
 )
 
+export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
+
 export function selectProvider(
   sessionID: string,
   flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
@@ -247,3 +252,9 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
   }),
 )
+
+export const node = makeLocationNode({
+  name: "tool/websearch",
+  layer,
+  deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient, configNode],
+})

+ 8 - 0
packages/core/src/tool/write.ts

@@ -8,9 +8,11 @@ export * as WriteTool from "./write"
 
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
+import { makeLocationNode } from "../effect/app-node"
 import { FileMutation } from "../file-mutation"
 import { LocationMutation } from "../location-mutation"
 import { PermissionV2 } from "../permission"
+import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 
@@ -91,3 +93,9 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
   }),
 )
+
+export const node = makeLocationNode({
+  name: "tool/write",
+  layer,
+  deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node],
+})

+ 2 - 1
packages/core/test/agent.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect } from "bun:test"
 import { Effect, Exit, Scope } from "effect"
 import { AgentV2 } from "@opencode-ai/core/agent"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { Location } from "@opencode-ai/core/location"
 import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -8,7 +9,7 @@ import { location } from "./fixture/location"
 import { testEffect } from "./lib/effect"
 import { agentHost, host } from "./plugin/host"
 
-const it = testEffect(AgentV2.locationLayer)
+const it = testEffect(AppNodeBuilder.build(AgentV2.node))
 
 describe("AgentV2", () => {
   it.effect("starts without agents", () =>

+ 6 - 3
packages/core/test/background-job.test.ts

@@ -1,8 +1,11 @@
 import { describe, expect } from "bun:test"
 import { BackgroundJob } from "@opencode-ai/core/background-job"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Deferred, Effect, Exit, Scope } from "effect"
 import { it } from "./lib/effect"
 
+const jobsLayer = LayerNode.compile(BackgroundJob.node)
+
 describe("BackgroundJob", () => {
   it.live("tracks process-local work through explicit observation", () =>
     Effect.gen(function* () {
@@ -25,7 +28,7 @@ describe("BackgroundJob", () => {
         timedOut: false,
         info: { status: "completed", output: "done" },
       })
-    }).pipe(Effect.provide(BackgroundJob.layer)),
+    }).pipe(Effect.provide(jobsLayer)),
   )
 
   it.live("publishes jobs before starting immediately settling work", () =>
@@ -55,7 +58,7 @@ describe("BackgroundJob", () => {
           })
         })
       })
-    }).pipe(Effect.provide(BackgroundJob.layer)),
+    }).pipe(Effect.provide(jobsLayer)),
   )
 
   it.live("increments pending work before starting immediately settling extensions", () =>
@@ -80,7 +83,7 @@ describe("BackgroundJob", () => {
           })
         }),
       )
-    }).pipe(Effect.provide(BackgroundJob.layer)),
+    }).pipe(Effect.provide(jobsLayer)),
   )
 
   it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>

+ 2 - 1
packages/core/test/command.test.ts

@@ -1,11 +1,12 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
 import { CommandV2 } from "@opencode-ai/core/command"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { testEffect } from "./lib/effect"
 
-const it = testEffect(CommandV2.locationLayer)
+const it = testEffect(AppNodeBuilder.build(CommandV2.node))
 
 describe("CommandV2", () => {
   it.effect("applies command transforms and preserves later overrides", () =>

+ 4 - 7
packages/core/test/config/config.test.ts

@@ -5,7 +5,6 @@ import { Effect, Layer, Schema } from "effect"
 import { FastCheck } from "effect/testing"
 import { Config } from "@opencode-ai/core/config"
 import { ConfigProvider } from "@opencode-ai/core/config/provider"
-import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
@@ -28,11 +27,6 @@ function testLayer(
   projectDirectory = directory,
   vcs?: Project.Vcs,
 ) {
-  const configNode = makeLocationNode({
-    service: Config.Service,
-    layer: Layer.fresh(Config.layer.pipe(Layer.provide(Global.layerWith({ config: globalDirectory })))),
-    deps: [FSUtil.node, Location.node, Policy.node],
-  })
   const locationLayer = Layer.succeed(
     Location.Service,
     Location.Service.of(
@@ -42,7 +36,10 @@ function testLayer(
       ),
     ),
   )
-  return AppNodeBuilder.build(LayerNode.group([configNode, Policy.node]), [[Location.node, locationLayer]])
+  return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [
+    [Location.node, locationLayer],
+    [Global.node, Global.layerWith({ config: globalDirectory })],
+  ])
 }
 
 const provider = {

+ 3 - 2
packages/core/test/database-migration.test.ts

@@ -15,6 +15,8 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
 import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
 import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
 import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -268,7 +270,6 @@ describe("DatabaseMigration", () => {
         yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
 
         const database = Layer.succeed(Database.Service, { db })
-        const events = EventV2.layer.pipe(Layer.provide(database))
         yield* EventV2.Service.use((service) =>
           service.publish(SessionV1.Event.Updated, {
             sessionID: SessionSchema.ID.make("session"),
@@ -284,7 +285,7 @@ describe("DatabaseMigration", () => {
           }),
         ).pipe(
           Effect.provide(
-            Layer.merge(events, SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))),
+            AppNodeBuilder.build(LayerNode.group([EventV2.node, SessionProjector.node]), [[Database.node, database]]),
           ),
         )
 

+ 4 - 4
packages/core/test/effect/layer-node/layer-node-types.test.ts

@@ -69,10 +69,10 @@ void invalidNodeReplacement
 // @ts-expect-error Replacement cannot introduce a new error
 LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
 
-const nodeReplacementWithError = make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })
-
-// @ts-expect-error Node replacement cannot introduce a new error
-LayerNode.compile(a, [[a, nodeReplacementWithError]])
+const invalidNodeErrorReplacement = () =>
+  // @ts-expect-error Node replacement cannot introduce a new error
+  LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
+void invalidNodeErrorReplacement
 
 class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
 class TagB extends Context.Service<TagB, {}>()("test/TagB") {}

+ 15 - 0
packages/core/test/effect/layer-node/layer-node.test.ts

@@ -143,6 +143,21 @@ describe("layer node", () => {
     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")

+ 8 - 5
packages/core/test/event.test.ts

@@ -6,6 +6,8 @@ import { Session } from "@opencode-ai/schema/session"
 import { SessionEvent } from "@opencode-ai/schema/session-event"
 import { SessionV1 } from "@opencode-ai/schema/session-v1"
 import { Database } from "@opencode-ai/core/database/database"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
 import { Location } from "@opencode-ai/core/location"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -76,9 +78,10 @@ const durableData = (sessionID: Session.ID, text: string) => ({
   messageID: SessionV1.MessageID.ascending(`msg_${text}`),
 })
 
-const eventLayer = Layer.mergeAll(EventV2.layerWith().pipe(Layer.provide(Database.defaultLayer)), Database.defaultLayer)
-const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
-const itWithoutLocation = testEffect(eventLayer)
+const it = testEffect(
+  AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]),
+)
+const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node])))
 
 describe("EventV2", () => {
   it.effect("publishes events with the current location", () =>
@@ -462,7 +465,7 @@ describe("EventV2", () => {
           pause
             ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
             : Effect.void,
-      }).pipe(Layer.provide(Database.defaultLayer))
+      }).pipe(Layer.provide(LayerNode.compile(Database.node)))
 
       yield* Effect.gen(function* () {
         const events = yield* EventV2.Service
@@ -477,7 +480,7 @@ describe("EventV2", () => {
         expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
           [0, durableData(aggregateID, "during handoff")],
         ])
-      }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
+      }).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer)))
     }),
   )
 

+ 10 - 5
packages/core/test/file-mutation.test.ts

@@ -2,6 +2,8 @@ import fs from "fs/promises"
 import path from "path"
 import { describe, expect } from "bun:test"
 import { Deferred, Effect, Fiber, Layer } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FileMutation } from "@opencode-ai/core/file-mutation"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Location } from "@opencode-ai/core/location"
@@ -11,14 +13,17 @@ import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
 import { it } from "./lib/effect"
 
-function provide(directory: string, filesystem = FSUtil.defaultLayer) {
+function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
   const activeLocation = Layer.succeed(
     Location.Service,
     Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
   )
-  const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
-  const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
-  return Effect.provide(Layer.mergeAll(resolution, mutation))
+  return Effect.provide(
+    AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
+      [Location.node, activeLocation],
+      [FSUtil.node, filesystemLayer],
+    ]),
+  )
 }
 
 function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
@@ -359,5 +364,5 @@ function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string
           run(filesystem.writeFileString(target, content, options), target),
       })
     }),
-  ).pipe(Layer.provide(FSUtil.defaultLayer))
+  ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
 }

+ 9 - 11
packages/core/test/filesystem/watcher.test.ts

@@ -4,10 +4,11 @@ import fs from "fs/promises"
 import path from "path"
 import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
 import { Config } from "@opencode-ai/core/config"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Watcher } from "@opencode-ai/core/filesystem/watcher"
-import { Git } from "@opencode-ai/core/git"
 import { Location } from "@opencode-ai/core/location"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { location } from "../fixture/location"
@@ -18,7 +19,7 @@ const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe
 
 type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
 
-const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))
+const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))
 
 const configLayer = Layer.succeed(
   Config.Service,
@@ -40,10 +41,10 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
     Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
   )
   return Effect.provide(
-    Watcher.layer.pipe(
-      Layer.provide(configLayer),
-      Layer.provide(Git.defaultLayer),
-      Layer.provide(locationLayer),
+    AppNodeBuilder.build(Watcher.node, [
+      [Config.node, configLayer],
+      [Location.node, locationLayer],
+    ]).pipe(
       Layer.provide(flagsLayer),
     ),
   )
@@ -196,7 +197,7 @@ describeWatcher("Watcher", () => {
       yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
         Effect.provideService(EventV2.Service, events),
       )
-    }).pipe(Effect.provide(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))),
+    }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))),
   )
 
   it.live("ignores .git/index changes", () =>
@@ -228,10 +229,7 @@ describeWatcher("Watcher", () => {
           yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
           expect(
             yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
-          ).toEqual({
-            file: head,
-            event: "change",
-          })
+          ).toMatchObject({ file: head })
         }),
       { git: true },
     ),

+ 3 - 3
packages/core/test/fixture/effect-flock-worker.ts

@@ -1,7 +1,7 @@
 import fs from "fs/promises"
 import os from "os"
-import { Effect, Layer } from "effect"
-import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Effect } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import { Global } from "@opencode-ai/core/global"
 
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
   log: os.tmpdir(),
 })
 
-const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
+const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
 
 async function job() {
   if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))

+ 82 - 61
packages/core/test/instruction-context.test.ts

@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import fs from "fs/promises"
 import path from "path"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Global } from "@opencode-ai/core/global"
 import { InstructionContext } from "@opencode-ai/core/instruction-context"
@@ -15,6 +17,15 @@ import { testEffect } from "./lib/effect"
 
 const it = testEffect(Layer.empty)
 
+const instructionLayer = (
+  input: { config: string; locationServiceLayer: Layer.Layer<Location.Service>; filesystemLayer?: Layer.Layer<FSUtil.Service> },
+) =>
+  AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [
+    [Global.node, Global.layerWith({ config: input.config })],
+    [Location.node, input.locationServiceLayer],
+    ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
+  ])
+
 describe("InstructionContext", () => {
   it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
     Effect.acquireRelease(
@@ -41,19 +52,19 @@ describe("InstructionContext", () => {
 
           const load = SystemContextRegistry.Service.pipe(
             Effect.flatMap((service) => service.load()),
-            Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
-            Effect.provide(FSUtil.defaultLayer),
-            Effect.provide(Global.layerWith({ config: global })),
             Effect.provide(
-              Layer.succeed(
-                Location.Service,
-                Location.Service.of(
-                  location(
-                    { directory: AbsolutePath.make(directory) },
-                    { projectDirectory: AbsolutePath.make(project) },
+              instructionLayer({
+                config: global,
+                locationServiceLayer: Layer.succeed(
+                  Location.Service,
+                  Location.Service.of(
+                    location(
+                      { directory: AbsolutePath.make(directory) },
+                      { projectDirectory: AbsolutePath.make(project) },
+                    ),
                   ),
                 ),
-              ),
+              }),
             ),
           )
 
@@ -107,14 +118,14 @@ describe("InstructionContext", () => {
           yield* Effect.promise(() => fs.writeFile(file, ""))
           const context = yield* SystemContextRegistry.Service.pipe(
             Effect.flatMap((service) => service.load()),
-            Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
-            Effect.provide(FSUtil.defaultLayer),
-            Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })),
             Effect.provide(
-              Layer.succeed(
-                Location.Service,
-                Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
-              ),
+              instructionLayer({
+                config: path.join(tmp.path, "global"),
+                locationServiceLayer: Layer.succeed(
+                  Location.Service,
+                  Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
+                ),
+              }),
             ),
           )
 
@@ -133,14 +144,18 @@ describe("InstructionContext", () => {
             FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
           ),
         ),
-      ).pipe(Layer.provide(FSUtil.defaultLayer))
+      ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
       const context = yield* SystemContextRegistry.Service.pipe(
         Effect.flatMap((service) => service.load()),
-        Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
-        Effect.provide(failingFS),
-        Effect.provide(Global.layerWith({ config: "/global" })),
         Effect.provide(
-          Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
+          instructionLayer({
+            config: "/global",
+            filesystemLayer: failingFS,
+            locationServiceLayer: Layer.succeed(
+              Location.Service,
+              Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
+            ),
+          }),
         ),
       )
 
@@ -169,14 +184,18 @@ describe("InstructionContext", () => {
             }),
           ),
         ),
-      ).pipe(Layer.provide(FSUtil.defaultLayer))
+      ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
       const context = yield* SystemContextRegistry.Service.pipe(
         Effect.flatMap((service) => service.load()),
-        Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
-        Effect.provide(racingFS),
-        Effect.provide(Global.layerWith({ config: "/global" })),
         Effect.provide(
-          Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
+          instructionLayer({
+            config: "/global",
+            filesystemLayer: racingFS,
+            locationServiceLayer: Layer.succeed(
+              Location.Service,
+              Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
+            ),
+          }),
         ),
       )
 
@@ -208,20 +227,21 @@ describe("InstructionContext", () => {
             }),
           ),
         ),
-      ).pipe(Layer.provide(FSUtil.defaultLayer))
+      ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
 
       yield* SystemContextRegistry.Service.pipe(
         Effect.flatMap((service) => service.load()),
-        Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
-        Effect.provide(observingFS),
-        Effect.provide(Global.layerWith({ config: "/global" })),
         Effect.provide(
-          Layer.succeed(
-            Location.Service,
-            Location.Service.of(
-              location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
+          instructionLayer({
+            config: "/global",
+            filesystemLayer: observingFS,
+            locationServiceLayer: Layer.succeed(
+              Location.Service,
+              Location.Service.of(
+                location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
+              ),
             ),
-          ),
+          }),
         ),
       )
 
@@ -241,18 +261,20 @@ describe("InstructionContext", () => {
 
       yield* SystemContextRegistry.Service.pipe(
         Effect.flatMap((service) => service.load()),
-        Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
         Effect.provide(
-          Layer.effect(
-            FSUtil.Service,
-            FSUtil.Service.pipe(
-              Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
+          instructionLayer({
+            config: "/global",
+            filesystemLayer: Layer.effect(
+              FSUtil.Service,
+              FSUtil.Service.pipe(
+                Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
+              ),
+            ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
+            locationServiceLayer: Layer.succeed(
+              Location.Service,
+              Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
             ),
-          ).pipe(Layer.provide(FSUtil.defaultLayer)),
-        ),
-        Effect.provide(Global.layerWith({ config: "/global" })),
-        Effect.provide(
-          Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
+          }),
         ),
         Effect.ensuring(
           Effect.sync(() => {
@@ -271,23 +293,22 @@ describe("InstructionContext", () => {
       let scanned = false
       yield* SystemContextRegistry.Service.pipe(
         Effect.flatMap((service) => service.load()),
-        Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
         Effect.provide(
-          Layer.effect(
-            FSUtil.Service,
-            FSUtil.Service.pipe(
-              Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
-            ),
-          ).pipe(Layer.provide(FSUtil.defaultLayer)),
-        ),
-        Effect.provide(Global.layerWith({ config: "/global" })),
-        Effect.provide(
-          Layer.succeed(
-            Location.Service,
-            Location.Service.of(
-              location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }),
+          instructionLayer({
+            config: "/global",
+            filesystemLayer: Layer.effect(
+              FSUtil.Service,
+              FSUtil.Service.pipe(
+                Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
+              ),
+            ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
+            locationServiceLayer: Layer.succeed(
+              Location.Service,
+              Location.Service.of(
+                location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }),
+              ),
             ),
-          ),
+          }),
         ),
       )
 

+ 5 - 20
packages/core/test/location-layer.test.ts

@@ -1,12 +1,14 @@
 import fs from "fs/promises"
 import path from "path"
 import { describe, expect } from "bun:test"
-import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
+import { DateTime, Effect, Equal, Hash, Schema } from "effect"
 import { Tool } from "@opencode-ai/core/tool/tool"
 import { define } from "@opencode-ai/plugin/v2/effect"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Catalog } from "@opencode-ai/core/catalog"
-import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { LocationServiceMap } from "@opencode-ai/core/location-services"
 import { Location } from "@opencode-ai/core/location"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { ModelV2 } from "@opencode-ai/core/model"
@@ -30,25 +32,8 @@ import { Reference } from "../src/reference"
 import { ToolRegistry } from "../src/tool/registry"
 import { ApplicationTools } from "../src/tool/application-tools"
 
-const applicationTools = ApplicationTools.layer
 const it = testEffect(
-  Layer.merge(
-    Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
-    locationServiceMapLayer.pipe(
-      Layer.provide(applicationTools),
-      Layer.provide(
-        Layer.mergeAll(
-          Project.defaultLayer,
-          EventV2.defaultLayer,
-          Credential.defaultLayer.pipe(Layer.fresh),
-          Npm.defaultLayer,
-          ModelsDev.defaultLayer,
-          FSUtil.defaultLayer,
-          Global.defaultLayer,
-        ),
-      ),
-    ),
-  ),
+  AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node])),
 )
 
 describe("LocationServiceMap", () => {

+ 2 - 1
packages/core/test/location.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { Location } from "@opencode-ai/core/location"
 import { Project } from "@opencode-ai/core/project"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -21,7 +22,7 @@ const projectLayer = Layer.succeed(
     commit: () => Effect.void,
   }),
 )
-const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer)))
+const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
 
 describe("Location", () => {
   it.effect("resolves the current project and vcs information", () =>

+ 1 - 1
packages/core/test/models.test.ts

@@ -88,7 +88,7 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
   )
 
 const buildLayer = (state: Ref.Ref<MockState>) =>
-  // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
+  // Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
   // and Effect.provide uses a process-global MemoMap by default — without fresh,
   // every test would reuse the cachedInvalidateWithTTL state from the first run.
   Layer.fresh(

+ 13 - 36
packages/core/test/move-session.test.ts

@@ -3,57 +3,34 @@ import { $ } from "bun"
 import fs from "fs/promises"
 import path from "path"
 import { eq } from "drizzle-orm"
-import { Effect, Layer } from "effect"
+import { Effect } from "effect"
 import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
 import { Database } from "@opencode-ai/core/database/database"
-import { FSUtil } from "@opencode-ai/core/fs-util"
-import { Git } from "@opencode-ai/core/git"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProjectDirectories } from "@opencode-ai/core/project/directories"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
-import { SessionExecution } from "@opencode-ai/core/session/execution"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionStore } from "@opencode-ai/core/session/store"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
 
-const project = Project.layer.pipe(
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(FSUtil.defaultLayer),
-  Layer.provide(Git.defaultLayer),
-  Layer.provide(ProjectDirectories.defaultLayer),
-)
-const sessions = SessionV2.layer.pipe(
-  Layer.provide(locationServiceMapLayer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(project),
-  Layer.provide(SessionStore.defaultLayer),
-  Layer.provide(SessionExecution.noopLayer),
-)
-const layer = MoveSession.layer.pipe(
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(FSUtil.defaultLayer),
-  Layer.provide(Git.defaultLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(project),
-  Layer.provide(SessionStore.defaultLayer),
-)
 const it = testEffect(
-  Layer.mergeAll(
-    layer,
-    Database.defaultLayer,
-    EventV2.defaultLayer,
-    ProjectDirectories.defaultLayer,
-    project,
-    SessionProjector.defaultLayer,
-    SessionStore.defaultLayer,
-    sessions,
+  AppNodeBuilder.build(
+    LayerNode.group([
+      MoveSession.node,
+      Database.node,
+      EventV2.node,
+      ProjectDirectories.node,
+      Project.node,
+      SessionProjector.node,
+      SessionStore.node,
+    ]),
   ),
 )
 

+ 3 - 10
packages/core/test/npm.test.ts

@@ -1,12 +1,10 @@
 import fs from "fs/promises"
 import path from "path"
 import { describe, expect, test } from "bun:test"
-import { NodeFileSystem } from "@effect/platform-node"
-import { Effect, Layer, Option } from "effect"
-import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Effect, Option } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { Global } from "@opencode-ai/core/global"
 import { Npm } from "@opencode-ai/core/npm"
-import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import { tmpdir } from "./fixture/tmpdir"
 
 const win = process.platform === "win32"
@@ -21,12 +19,7 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
   )
 
 const npmLayer = (cache: string) =>
-  Npm.layer.pipe(
-    Layer.provide(EffectFlock.layer),
-    Layer.provide(FSUtil.layer),
-    Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
-    Layer.provide(NodeFileSystem.layer),
-  )
+  AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
 
 describe("Npm.sanitize", () => {
   test("keeps normal scoped package specs unchanged", () => {

+ 7 - 19
packages/core/test/permission.test.ts

@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
 import { Deferred, Effect, Fiber, Layer } from "effect"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Database } from "@opencode-ai/core/database/database"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { Location } from "@opencode-ai/core/location"
 import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -11,9 +13,7 @@ import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
 import { SessionTable } from "@opencode-ai/core/session/sql"
-import { SessionExecution } from "@opencode-ai/core/session/execution"
 import { SessionStore } from "@opencode-ai/core/session/store"
 import { eq } from "drizzle-orm"
 import { location } from "./fixture/location"
@@ -23,24 +23,12 @@ const current = Layer.succeed(
   Location.Service,
   Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
 )
-const sessions = SessionV2.layer.pipe(
-  Layer.provide(locationServiceMapLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(SessionStore.defaultLayer),
-  Layer.provide(Project.defaultLayer),
-  Layer.provide(SessionExecution.noopLayer),
+const it = testEffect(
+  AppNodeBuilder.build(
+    LayerNode.group([Database.node, EventV2.node, SessionStore.node, PermissionSaved.node, AgentV2.node, PermissionV2.node]),
+    [[Location.node, current]],
+  ),
 )
-const layer = PermissionV2.locationLayer.pipe(
-  Layer.provideMerge(Database.defaultLayer),
-  Layer.provideMerge(SessionStore.defaultLayer),
-  Layer.provideMerge(EventV2.defaultLayer),
-  Layer.provideMerge(current),
-  Layer.provideMerge(sessions),
-  Layer.provideMerge(SessionExecution.noopLayer),
-  Layer.provideMerge(PermissionSaved.defaultLayer),
-)
-const it = testEffect(layer)
 
 function setup(rules: PermissionV2.Ruleset = []) {
   return Effect.gen(function* () {

+ 3 - 5
packages/core/test/plugin/command.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { CommandV2 } from "@opencode-ai/core/command"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { Location } from "@opencode-ai/core/location"
 import { CommandPlugin } from "@opencode-ai/core/plugin/command"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -10,12 +11,9 @@ import { host } from "./host"
 
 const directory = AbsolutePath.make("/repo/packages/app")
 const project = AbsolutePath.make("/repo")
+const locationLayer = Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project })))
 const it = testEffect(
-  CommandV2.locationLayer.pipe(
-    Layer.provide(
-      Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
-    ),
-  ),
+  AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]]),
 )
 
 describe("CommandPlugin.Plugin", () => {

+ 42 - 27
packages/core/test/plugin/fixture.ts

@@ -1,37 +1,52 @@
+import { AgentV2 } from "@opencode-ai/core/agent"
+import { AISDK } from "@opencode-ai/core/aisdk"
+import { Catalog } from "@opencode-ai/core/catalog"
+import { CommandV2 } from "@opencode-ai/core/command"
 import { Credential } from "@opencode-ai/core/credential"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { FSUtil } from "@opencode-ai/core/fs-util"
-import { Global } from "@opencode-ai/core/global"
+import { Integration } from "@opencode-ai/core/integration"
+import { Location } from "@opencode-ai/core/location"
 import { Npm } from "@opencode-ai/core/npm"
 import { PluginV2 } from "@opencode-ai/core/plugin"
-import { RepositoryCache } from "@opencode-ai/core/repository-cache"
-import { Ripgrep } from "@opencode-ai/core/ripgrep"
-import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
+import { Reference } from "@opencode-ai/core/reference"
+import { SkillV2 } from "@opencode-ai/core/skill"
 import { Effect, Layer } from "effect"
-import { FetchHttpClient } from "effect/unstable/http"
 import { tempLocationLayer } from "../fixture/location"
 
-export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe(
-  Layer.provideMerge(
-    Layer.mergeAll(
-      Credential.defaultLayer,
-      EventV2.defaultLayer,
-      FetchHttpClient.layer,
-      FSUtil.defaultLayer,
-      Global.defaultLayer,
-      Layer.succeed(
-        Npm.Service,
-        Npm.Service.of({
-          add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
-          install: () => Effect.void,
-          which: () => Effect.succeed(undefined),
-        }),
-      ),
-      RepositoryCache.defaultLayer,
-      SkillDiscovery.defaultLayer,
-      Ripgrep.defaultLayer,
-      tempLocationLayer,
-    ),
-  ),
+const npmLayer = Layer.succeed(
+  Npm.Service,
+  Npm.Service.of({
+    add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
+    install: () => Effect.void,
+    which: () => Effect.succeed(undefined),
+  }),
+)
+
+export const PluginTestLayer = AppNodeBuilder.build(
+  LayerNode.group([
+    FileSystem.node,
+    FSUtil.node,
+    Location.node,
+    Npm.node,
+    Credential.node,
+    EventV2.node,
+    LayerNodePlatform.httpClient,
+    PluginV2.node,
+    AgentV2.node,
+    AISDK.node,
+    Catalog.node,
+    CommandV2.node,
+    Integration.node,
+    Reference.node,
+    SkillV2.node,
+  ]),
+  [
+    [Location.node, tempLocationLayer],
+    [Npm.node, npmLayer],
+  ],
 )

+ 2 - 1
packages/core/test/plugin/provider-dynamic.test.ts

@@ -6,6 +6,7 @@ import os from "os"
 import path from "path"
 import { fileURLToPath } from "url"
 import { AISDK } from "@opencode-ai/core/aisdk"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -17,7 +18,7 @@ import { PluginTestLayer } from "./fixture"
 const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
 const fixtureProviderPath = fileURLToPath(fixtureProvider)
 const it = testEffect(PluginTestLayer)
-const itWithAISDK = testEffect(AISDK.locationLayer.pipe(Layer.provideMerge(PluginTestLayer)))
+const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
 
 function npmEntrypoint(entrypoint?: string) {
   return Npm.Service.of({

+ 6 - 1
packages/core/test/plugin/provider-opencode.test.ts

@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
 import { Effect } from "effect"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Credential } from "@opencode-ai/core/credential"
+import { EventV2 } from "@opencode-ai/core/event"
 import { Integration } from "@opencode-ai/core/integration"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -16,8 +17,12 @@ const it = testEffect(PluginTestLayer)
 const addPlugin = Effect.fn(function* () {
   const plugin = yield* PluginV2.Service
   const host = yield* PluginHost.make(plugin)
+  const events = yield* EventV2.Service
   const integration = yield* Integration.Service
-  yield* OpencodePlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
+  yield* OpencodePlugin.effect(host).pipe(
+    Effect.provideService(EventV2.Service, events),
+    Effect.provideService(Integration.Service, integration),
+  )
 })
 
 function required<T>(value: T | undefined): T {

+ 4 - 5
packages/core/test/policy.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { Location } from "@opencode-ai/core/location"
 import { Policy } from "@opencode-ai/core/policy"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -7,11 +8,9 @@ import { location } from "./fixture/location"
 import { testEffect } from "./lib/effect"
 
 const it = testEffect(
-  Policy.locationLayer.pipe(
-    Layer.provide(
-      Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
-    ),
-  ),
+  AppNodeBuilder.build(Policy.node, [
+    [Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") })))],
+  ]),
 )
 
 describe("Policy", () => {

+ 12 - 11
packages/core/test/pty/pty-session.test.ts

@@ -1,6 +1,8 @@
 import { describe, expect } from "bun:test"
 import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
 import { Config } from "@opencode-ai/core/config"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { Location } from "@opencode-ai/core/location"
 import { Pty } from "@opencode-ai/core/pty"
@@ -17,11 +19,10 @@ const locationLayer = Layer.succeed(
 )
 const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
 const it = testEffect(
-  Pty.layer.pipe(
-    Layer.provide(configLayer),
-    Layer.provideMerge(EventV2.defaultLayer),
-    Layer.provideMerge(locationLayer),
-  ),
+  AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
+    [Config.node, configLayer],
+    [Location.node, locationLayer],
+  ]),
 )
 const ptyTest = process.platform === "win32" ? it.live.skip : it.live
 
@@ -205,8 +206,9 @@ describe("pty", () => {
 
 const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
 const configuredIt = testEffect(
-  Pty.layer.pipe(
-    Layer.provide(
+  AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
+    [
+      Config.node,
       Layer.mock(Config.Service)({
         entries: () =>
           Effect.succeed(
@@ -215,10 +217,9 @@ const configuredIt = testEffect(
               : [],
           ),
       }),
-    ),
-    Layer.provideMerge(EventV2.defaultLayer),
-    Layer.provideMerge(locationLayer),
-  ),
+    ],
+    [Location.node, locationLayer],
+  ]),
 )
 const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
 

+ 5 - 2
packages/core/test/pty/ticket.test.ts

@@ -1,12 +1,15 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { PtyID } from "@opencode-ai/core/pty/schema"
 import { PtyTicket } from "@opencode-ai/core/pty/ticket"
 import { WorkspaceV2 } from "@opencode-ai/core/workspace"
 import { testEffect } from "../lib/effect"
 
-const it = testEffect(PtyTicket.layer)
-const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
+const it = testEffect(LayerNode.compile(PtyTicket.node))
+const itExpiring = testEffect(
+  LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]),
+)
 
 describe("PTY websocket tickets", () => {
   it.live("consumes tickets once", () =>

+ 31 - 28
packages/core/test/reference-guidance.test.ts

@@ -1,11 +1,15 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { Reference } from "@opencode-ai/core/reference"
 import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
 import { SystemContext } from "@opencode-ai/core/system-context/index"
 import { it } from "./lib/effect"
 
+const guidanceLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
+  AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]])
+
 describe("ReferenceGuidance", () => {
   it.effect("lists available references in the system context", () =>
     Effect.gen(function* () {
@@ -17,23 +21,24 @@ describe("ReferenceGuidance", () => {
       expect(generation.baseline).toContain("<path>/docs</path>")
       expect(generation.baseline).toContain("<description>Use for product documentation</description>")
     }).pipe(
-      Effect.provide(ReferenceGuidance.layer),
       Effect.provide(
-        Layer.mock(Reference.Service, {
-          list: () =>
-            Effect.succeed([
-              new Reference.Info({
-                name: "docs",
-                path: AbsolutePath.make("/docs"),
-                description: "Use for product documentation",
-                source: Reference.LocalSource.make({
-                  type: "local",
+        guidanceLayer(
+          Layer.mock(Reference.Service, {
+            list: () =>
+              Effect.succeed([
+                new Reference.Info({
+                  name: "docs",
                   path: AbsolutePath.make("/docs"),
                   description: "Use for product documentation",
+                  source: Reference.LocalSource.make({
+                    type: "local",
+                    path: AbsolutePath.make("/docs"),
+                    description: "Use for product documentation",
+                  }),
                 }),
-              }),
-            ]),
-        }),
+              ]),
+          }),
+        ),
       ),
     ),
   )
@@ -43,10 +48,7 @@ describe("ReferenceGuidance", () => {
       const guidance = yield* ReferenceGuidance.Service
       const generation = yield* SystemContext.initialize(yield* guidance.load())
       expect(generation.baseline).toBe("")
-    }).pipe(
-      Effect.provide(ReferenceGuidance.layer),
-      Effect.provide(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })),
-    ),
+    }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
   )
 
   it.effect("omits references without descriptions", () =>
@@ -55,18 +57,19 @@ describe("ReferenceGuidance", () => {
       const generation = yield* SystemContext.initialize(yield* guidance.load())
       expect(generation.baseline).toBe("")
     }).pipe(
-      Effect.provide(ReferenceGuidance.layer),
       Effect.provide(
-        Layer.mock(Reference.Service, {
-          list: () =>
-            Effect.succeed([
-              new Reference.Info({
-                name: "docs",
-                path: AbsolutePath.make("/docs"),
-                source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
-              }),
-            ]),
-        }),
+        guidanceLayer(
+          Layer.mock(Reference.Service, {
+            list: () =>
+              Effect.succeed([
+                new Reference.Info({
+                  name: "docs",
+                  path: AbsolutePath.make("/docs"),
+                  source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
+                }),
+              ]),
+          }),
+        ),
       ),
     ),
   )

+ 11 - 22
packages/core/test/session-create.test.ts

@@ -4,6 +4,8 @@ import { Effect, Layer, Stream } from "effect"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { asc, eq } from "drizzle-orm"
 import { Database } from "@opencode-ai/core/database/database"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { Location } from "@opencode-ai/core/location"
@@ -13,7 +15,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import { Prompt } from "@opencode-ai/core/session/prompt"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -34,24 +35,11 @@ const projects = Layer.succeed(
     commit: () => Effect.void,
   }),
 )
-const sessions = SessionV2.layer.pipe(
-  Layer.provide(locationServiceMapLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(SessionStore.defaultLayer),
-  Layer.provide(projects),
-  Layer.provide(SessionExecution.noopLayer),
-)
 const it = testEffect(
-  Layer.mergeAll(
-    Database.defaultLayer,
-    EventV2.defaultLayer,
-    projects,
-    SessionProjector.defaultLayer,
-    SessionStore.defaultLayer,
-    SessionExecution.noopLayer,
-    sessions,
-  ),
+  AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), [
+    [ProjectV2.node, projects],
+    [SessionExecution.node, SessionExecution.noopLayer],
+  ]),
 )
 const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
 const id = SessionV2.ID.create()
@@ -248,9 +236,10 @@ describe("SessionV2.create", () => {
         (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
       )
       const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite"))
-      const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase))
-      const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase))
-      const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase))
+      const targetLayer = AppNodeBuilder.build(
+        LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]),
+        [[Database.node, targetDatabase]],
+      )
 
       yield* Effect.gen(function* () {
         const db = (yield* Database.Service).db
@@ -298,7 +287,7 @@ describe("SessionV2.create", () => {
           [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)],
           [2, EventV2.versionedType(SessionEvent.Prompted.type, 1)],
         ])
-      }).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore))))
+      }).pipe(Effect.provide(Layer.fresh(targetLayer)))
     }),
   )
 

+ 6 - 18
packages/core/test/session-history.test.ts

@@ -1,9 +1,10 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer, Schema } from "effect"
 import { Database } from "@opencode-ai/core/database/database"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { Location } from "@opencode-ai/core/location"
-import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -22,24 +23,11 @@ const projects = Layer.succeed(
     commit: () => Effect.void,
   }),
 )
-const sessions = SessionV2.layer.pipe(
-  Layer.provide(locationServiceMapLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(SessionStore.defaultLayer),
-  Layer.provide(projects),
-  Layer.provide(SessionExecution.noopLayer),
-)
 const it = testEffect(
-  Layer.mergeAll(
-    Database.defaultLayer,
-    EventV2.defaultLayer,
-    projects,
-    SessionProjector.defaultLayer,
-    SessionStore.defaultLayer,
-    SessionExecution.noopLayer,
-    sessions,
-  ),
+  AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), [
+    [ProjectV2.node, projects],
+    [SessionExecution.node, SessionExecution.noopLayer],
+  ]),
 )
 const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
 

+ 5 - 17
packages/core/test/session-prompt.test.ts

@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
 import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
 import { eq } from "drizzle-orm"
 import { Database } from "@opencode-ai/core/database/database"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -9,7 +11,6 @@ import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
 import { Prompt } from "@opencode-ai/core/session/prompt"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -41,23 +42,10 @@ const execution = Layer.succeed(
       }),
   }),
 )
-const sessions = SessionV2.layer.pipe(
-  Layer.provide(locationServiceMapLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(SessionStore.defaultLayer),
-  Layer.provide(Project.defaultLayer),
-  Layer.provide(execution),
-)
 const it = testEffect(
-  Layer.mergeAll(
-    Database.defaultLayer,
-    EventV2.defaultLayer,
-    SessionProjector.defaultLayer,
-    SessionStore.defaultLayer,
-    execution,
-    sessions,
-  ),
+  AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), [
+    [SessionExecution.node, execution],
+  ]),
 )
 const sessionID = SessionV2.ID.make("ses_prompt_test")
 const messageID = SessionMessage.ID.create()

+ 48 - 47
packages/core/test/session-runner-recorded.test.ts

@@ -3,6 +3,9 @@ import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
 import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
 import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
 import { Database } from "@opencode-ai/core/database/database"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -12,7 +15,6 @@ import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
 import { Snapshot } from "@opencode-ai/core/snapshot"
 import { Prompt } from "@opencode-ai/core/session/prompt"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -22,6 +24,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
 import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
 import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionStore } from "@opencode-ai/core/session/store"
 import { Location } from "@opencode-ai/core/location"
@@ -57,8 +60,6 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
-const agents = AgentV2.layer
 const model = OpenAIChat.route
   .with({
     endpoint: { baseURL: "https://api.openai.com/v1" },
@@ -67,26 +68,22 @@ const model = OpenAIChat.route
   })
   .model({ id: "gpt-4o-mini" })
 const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
-const systemContext = SystemContextRegistry.layer
-const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
+const systemContext = AppNodeBuilder.build(SystemContextRegistry.node)
 const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
 const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
 const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
-const runner = SessionRunnerLLM.defaultLayer.pipe(
-  Layer.provide(Snapshot.noopLayer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(SessionStore.defaultLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(client),
-  Layer.provide(registry),
-  Layer.provide(models),
-  Layer.provide(systemContext),
-  Layer.provide(location),
-  Layer.provide(agents),
-  Layer.provide(skillGuidance),
-  Layer.provide(referenceGuidance),
-  Layer.provide(config),
-)
+const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
+  [Snapshot.node, Snapshot.noopLayer],
+  [LayerNodePlatform.llmClient, client],
+  [SessionRunnerModel.node, models],
+  [SystemContextRegistry.node, systemContext],
+  [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
+  [SkillGuidance.node, skillGuidance],
+  [ReferenceGuidance.node, referenceGuidance],
+  [Config.node, config],
+  [PermissionV2.node, permission],
+  [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+])
 const execution = Layer.effect(
   SessionExecution.Service,
   Effect.gen(function* () {
@@ -101,34 +98,38 @@ const execution = Layer.effect(
       interrupt: coordinator.interrupt,
     })
   }),
-).pipe(Layer.provide(runner))
-const sessions = SessionV2.layer.pipe(
-  Layer.provide(locationServiceMapLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(SessionStore.defaultLayer),
-  Layer.provide(Project.defaultLayer),
-  Layer.provide(execution),
-)
+).pipe(Layer.provide(runnerLayer))
 const it = testEffect(
-  Layer.mergeAll(
-    Database.defaultLayer,
-    EventV2.defaultLayer,
-    SessionProjector.defaultLayer,
-    SessionStore.defaultLayer,
-    executor,
-    client,
-    permission,
-    agents,
-    registry,
-    models,
-    systemContext,
-    location,
-    skillGuidance,
-    config,
-    runner,
-    execution,
-    sessions,
+  AppNodeBuilder.build(
+    LayerNode.group([
+      Database.node,
+      EventV2.node,
+      SessionProjector.node,
+      SessionStore.node,
+      AgentV2.node,
+      ToolRegistry.node,
+      SessionRunnerModel.node,
+      SystemContextRegistry.node,
+      SkillGuidance.node,
+      ReferenceGuidance.node,
+      Config.node,
+      Snapshot.node,
+      SessionRunnerLLM.node,
+      SessionV2.node,
+    ]),
+    [
+      [LayerNodePlatform.llmClient, client],
+      [PermissionV2.node, permission],
+      [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+      [SessionRunnerModel.node, models],
+      [SystemContextRegistry.node, systemContext],
+      [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
+      [SkillGuidance.node, skillGuidance],
+      [ReferenceGuidance.node, referenceGuidance],
+      [Config.node, config],
+      [Snapshot.node, Snapshot.noopLayer],
+      [SessionExecution.node, execution],
+    ],
   ),
 )
 const sessionID = SessionV2.ID.make("ses_runner_recorded")

+ 7 - 3
packages/core/test/session-runner-tool-registry.test.ts

@@ -1,6 +1,8 @@
 import { describe, expect } from "bun:test"
 import { Tool } from "@opencode-ai/core/tool/tool"
 import { AgentV2 } from "@opencode-ai/core/agent"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { SessionMessage } from "@opencode-ai/core/session/message"
@@ -27,9 +29,11 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
     )
   },
 })
-const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
-const it = testEffect(registry)
-const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
+const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
+const it = testEffect(registryLayer)
+const integrated = testEffect(
+  AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node]), [[ToolOutputStore.node, outputStore]]),
+)
 const identity = {
   agent: AgentV2.ID.make("build"),
   assistantMessageID: SessionMessage.ID.make("msg_registry"),

+ 53 - 57
packages/core/test/session-runner.test.ts

@@ -11,6 +11,10 @@ import {
 } from "@opencode-ai/llm"
 import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
 import { Database } from "@opencode-ai/core/database/database"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { EventTable } from "@opencode-ai/core/event/sql"
@@ -19,7 +23,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { QuestionV2 } from "@opencode-ai/core/question"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
 import { Snapshot } from "@opencode-ai/core/snapshot"
 import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
 import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -33,7 +36,6 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
 import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
 import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
-import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Config } from "@opencode-ai/core/config"
@@ -57,7 +59,6 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream }
 import { asc, eq } from "drizzle-orm"
 import { testEffect } from "./lib/effect"
 
-const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
 const requests: LLMRequest[] = []
 let response: LLMEvent[] = []
 let responses: LLMEvent[][] | undefined
@@ -120,13 +121,6 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const applications = ApplicationTools.layer
-const registry = ToolRegistry.layer.pipe(
-  Layer.provide(permission),
-  Layer.provide(applications),
-  Layer.provide(ToolOutputStore.defaultLayer),
-)
-const agents = AgentV2.layer
 const echo = Layer.effectDiscard(
   ToolRegistry.Service.use((registry) =>
     registry.register({
@@ -156,7 +150,8 @@ const echo = Layer.effectDiscard(
       }),
     }),
   ),
-).pipe(Layer.provide(registry))
+)
+const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
 let modelResolveHook = Effect.void
 let currentModel = model
 const models = SessionRunnerModel.layerWith((session) =>
@@ -196,8 +191,7 @@ const systemContext = Layer.effectDiscard(
       }),
     ),
   ),
-).pipe(Layer.provideMerge(SystemContextRegistry.layer))
-const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
+).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node)))
 const skillGuidance = Layer.mock(SkillGuidance.Service, {
   load: (agent) =>
     Effect.succeed(
@@ -231,21 +225,17 @@ const config = Layer.succeed(
       ]),
   }),
 )
-const runner = SessionRunnerLLM.layer.pipe(
-  Layer.provide(Snapshot.noopLayer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(SessionStore.defaultLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(client),
-  Layer.provide(registry),
-  Layer.provide(models),
-  Layer.provide(systemContext),
-  Layer.provide(location),
-  Layer.provide(agents),
-  Layer.provide(skillGuidance),
-  Layer.provide(referenceGuidance),
-  Layer.provide(config),
-)
+const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
+  [Snapshot.node, Snapshot.noopLayer],
+  [LayerNodePlatform.llmClient, client],
+  [SessionRunnerModel.node, models],
+  [SystemContextRegistry.node, systemContext],
+  [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
+  [SkillGuidance.node, skillGuidance],
+  [ReferenceGuidance.node, referenceGuidance],
+  [PermissionV2.node, permission],
+  [Config.node, config],
+])
 const execution = Layer.effect(
   SessionExecution.Service,
   Effect.gen(function* () {
@@ -260,36 +250,42 @@ const execution = Layer.effect(
       interrupt: coordinator.interrupt,
     })
   }),
-).pipe(Layer.provide(runner))
-const sessions = SessionV2.layer.pipe(
-  Layer.provide(locationServiceMapLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(SessionStore.defaultLayer),
-  Layer.provide(Project.defaultLayer),
-  Layer.provide(execution),
-)
+).pipe(Layer.provide(runnerLayer))
 const it = testEffect(
-  Layer.mergeAll(
-    Database.defaultLayer,
-    EventV2.defaultLayer,
-    questions,
-    SessionProjector.defaultLayer,
-    SessionStore.defaultLayer,
-    client,
-    permission,
-    applications,
-    agents,
-    registry,
-    echo,
-    models,
-    systemContext,
-    location,
-    skillGuidance,
-    config,
-    runner,
-    execution,
-    sessions,
+  AppNodeBuilder.build(
+    LayerNode.group([
+      Database.node,
+      EventV2.node,
+      QuestionV2.node,
+      SessionProjector.node,
+      SessionStore.node,
+      ApplicationTools.node,
+      AgentV2.node,
+      ToolRegistry.node,
+      ToolRegistry.toolsNode,
+      echoNode,
+      SessionRunnerModel.node,
+      SystemContextRegistry.node,
+      SkillGuidance.node,
+      ReferenceGuidance.node,
+      Config.node,
+      Snapshot.node,
+      SessionRunnerLLM.node,
+      SessionExecution.node,
+      SessionV2.node,
+    ]),
+    [
+      [LayerNodePlatform.llmClient, client],
+      [PermissionV2.node, permission],
+      [SessionRunnerModel.node, models],
+      [SystemContextRegistry.node, systemContext],
+      [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
+      [SkillGuidance.node, skillGuidance],
+      [ReferenceGuidance.node, referenceGuidance],
+      [Snapshot.node, Snapshot.noopLayer],
+      [SessionExecution.node, execution],
+      [Config.node, config],
+    ],
   ),
 )
 const sessionID = SessionV2.ID.make("ses_runner_test")

+ 4 - 1
packages/core/test/skill/guidance.test.ts

@@ -2,6 +2,7 @@ import path from "path"
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { AgentV2 } from "@opencode-ai/core/agent"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SkillV2 } from "@opencode-ai/core/skill"
 import { SystemContext } from "@opencode-ai/core/system-context"
@@ -28,7 +29,9 @@ const denied = SkillV2.Info.make({
 })
 
 const layer = (list: () => SkillV2.Info[]) =>
-  SkillGuidance.layer.pipe(Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })))
+  AppNodeBuilder.build(SkillGuidance.node, [
+    [SkillV2.node, Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })],
+  ])
 
 describe("SkillGuidance", () => {
   it.effect("renders described agent skills and reconciles the complete available list", () => {

+ 8 - 18
packages/core/test/snapshot.test.ts

@@ -3,12 +3,9 @@ import { describe, expect } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
 import { Effect, Layer } from "effect"
-import { Config } from "@opencode-ai/core/config"
-import { FSUtil } from "@opencode-ai/core/fs-util"
-import { Git } from "@opencode-ai/core/git"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { Global } from "@opencode-ai/core/global"
 import { Location } from "@opencode-ai/core/location"
-import { Project } from "@opencode-ai/core/project"
 import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
 import { Snapshot } from "@opencode-ai/core/snapshot"
 import { Hash } from "@opencode-ai/core/util/hash"
@@ -117,11 +114,7 @@ describe("Snapshot", () => {
           const projectID = yield* Effect.gen(function* () {
             return (yield* Location.Service).project.id
           }).pipe(
-            Effect.provide(
-              Location.layer(Location.Ref.make({ directory: AbsolutePath.make(project) })).pipe(
-                Layer.provide(Project.defaultLayer),
-              ),
-            ),
+            Effect.provide(AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) })))),
           )
           expect(
             yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
@@ -172,15 +165,12 @@ describe("Snapshot", () => {
 })
 
 function snapshotLayer(data: string, directory: string) {
-  const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
-    Layer.provide(Project.defaultLayer),
-  )
-  return Snapshot.layer.pipe(
-    Layer.provide(location),
-    Layer.provide(Config.locationLayer.pipe(Layer.provide(location))),
-    Layer.provide(FSUtil.defaultLayer),
-    Layer.provide(Git.defaultLayer),
-    Layer.provide(Global.layerWith({ data, config: path.join(data, "config") })),
+  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") })],
+    ],
   )
 }
 

+ 2 - 1
packages/core/test/system-context/registry.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect } from "bun:test"
 import { Cause, Effect, Exit, Schema, Scope } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { SystemContext } from "@opencode-ai/core/system-context"
 import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
 import { testEffect } from "../lib/effect"
@@ -17,7 +18,7 @@ const entry = (key: string, text: string, sourceKey = key) => ({
   ),
 })
 
-const it = testEffect(SystemContextRegistry.layer)
+const it = testEffect(AppNodeBuilder.build(SystemContextRegistry.node))
 
 describe("SystemContextRegistry", () => {
   it.effect("loads empty system context when there are no entries", () =>

+ 17 - 12
packages/core/test/tool-apply-patch.test.ts

@@ -2,6 +2,8 @@ import fs from "fs/promises"
 import path from "path"
 import { describe, expect } from "bun:test"
 import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FileMutation } from "@opencode-ai/core/file-mutation"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Location } from "@opencode-ai/core/location"
@@ -10,6 +12,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
@@ -81,26 +84,28 @@ const filesystem = Layer.effect(
       },
     })
   }),
-).pipe(Layer.provide(FSUtil.defaultLayer))
+).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
 
 const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
   const activeLocation = Layer.succeed(
     Location.Service,
     Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
   )
-  const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
-  const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
-  const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
-  const patch = ApplyPatchTool.layer.pipe(
-    Layer.provide(registry),
-    Layer.provide(permission),
-    Layer.provide(resolution),
-    Layer.provide(mutation),
-    Layer.provide(filesystem),
-  )
   return Effect.gen(function* () {
     return yield* body(yield* ToolRegistry.Service)
-  }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, patch)))
+  }).pipe(
+    Effect.provide(
+      AppNodeBuilder.build(
+        LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, ApplyPatchTool.node]),
+        [
+          [FSUtil.node, filesystem],
+          [Location.node, activeLocation],
+          [PermissionV2.node, permission],
+          [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+        ],
+      ),
+    ),
+  )
 }
 
 const call = (patchText: string, id = "call-apply-patch") => ({

+ 15 - 13
packages/core/test/tool-bash.test.ts

@@ -6,6 +6,8 @@ import { Effect, Layer } from "effect"
 import { ChildProcess } from "effect/unstable/process"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Config } from "@opencode-ai/core/config"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Location } from "@opencode-ai/core/location"
 import { LocationMutation } from "@opencode-ai/core/location-mutation"
 import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -14,6 +16,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { BashTool } from "@opencode-ai/core/tool/bash"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
@@ -99,24 +102,23 @@ const withTool = <A, E, R>(
   body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
   processLayer: Layer.Layer<AppProcess.Service> = appProcess,
 ) => {
-  const filesystem = FSUtil.defaultLayer
   const activeLocation = Layer.succeed(
     Location.Service,
     Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
   )
-  const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
-  const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
-  const bash = BashTool.layer.pipe(
-    Layer.provide(registry),
-    Layer.provide(permission),
-    Layer.provide(mutation),
-    Layer.provide(filesystem),
-    Layer.provide(processLayer),
-    Layer.provide(config),
-  )
   return Effect.gen(function* () {
     return yield* body(yield* ToolRegistry.Service)
-  }).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
+  }).pipe(
+    Effect.provide(
+      AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, BashTool.node]), [
+        [Location.node, activeLocation],
+        [PermissionV2.node, permission],
+        [AppProcess.node, processLayer],
+        [Config.node, config],
+        [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+      ]),
+    ),
+  )
 }
 
 const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
@@ -231,7 +233,7 @@ describe("BashTool", () => {
           return withTool(
             tmp.path,
             (registry) => settleTool(registry, call({ command: "printf core-bash" })),
-            AppProcess.defaultLayer,
+            LayerNode.compile(AppProcess.node),
           ).pipe(
             Effect.andThen((settled) =>
               Effect.sync(() => {

+ 14 - 12
packages/core/test/tool-edit.test.ts

@@ -3,6 +3,8 @@ import path from "path"
 import { fileURLToPath } from "url"
 import { describe, expect, test } from "bun:test"
 import { Effect, Layer } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FileMutation } from "@opencode-ai/core/file-mutation"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Location } from "@opencode-ai/core/location"
@@ -11,6 +13,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { EditTool } from "@opencode-ai/core/tool/edit"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
@@ -71,26 +74,25 @@ const filesystem = Layer.effect(
         Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
     })
   }),
-).pipe(Layer.provide(FSUtil.defaultLayer))
+).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
 
 const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
   const activeLocation = Layer.succeed(
     Location.Service,
     Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
   )
-  const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
-  const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
-  const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
-  const edit = EditTool.layer.pipe(
-    Layer.provide(registry),
-    Layer.provide(permission),
-    Layer.provide(resolution),
-    Layer.provide(mutation),
-    Layer.provide(filesystem),
-  )
   return Effect.gen(function* () {
     return yield* body(yield* ToolRegistry.Service)
-  }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit)))
+  }).pipe(
+    Effect.provide(
+      AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, EditTool.node]), [
+        [FSUtil.node, filesystem],
+        [Location.node, activeLocation],
+        [PermissionV2.node, permission],
+        [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+      ]),
+    ),
+  )
 }
 
 const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({

+ 7 - 5
packages/core/test/tool-question.test.ts

@@ -31,9 +31,6 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [
-  [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
-])
 const question = Layer.succeed(
   QuestionV2.Service,
   QuestionV2.Service.of({
@@ -46,8 +43,13 @@ const question = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question))
-const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
+const it = testEffect(
+  AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
+    [PermissionV2.node, permission],
+    [QuestionV2.node, question],
+    [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+  ]),
+)
 
 describe("QuestionTool", () => {
   it.effect("omits a denied built-in question and terminally settles a stale call", () =>

+ 22 - 29
packages/core/test/tool-read.test.ts

@@ -3,6 +3,8 @@ import path from "path"
 import { Effect, Exit, Layer, PlatformError } from "effect"
 import { Config } from "@opencode-ai/core/config"
 import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Location } from "@opencode-ai/core/location"
@@ -14,6 +16,7 @@ import { Global } from "@opencode-ai/core/global"
 import { LocationMutation } from "@opencode-ai/core/location-mutation"
 import { location } from "./fixture/location"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ReadTool } from "@opencode-ai/core/tool/read"
 import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
 import { testEffect } from "./lib/effect"
@@ -69,9 +72,8 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
-const image = Image.layer.pipe(Layer.provide(config))
+const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
 const testFileSystem = Layer.effect(
   FSUtil.Service,
   FSUtil.Service.use((fs) =>
@@ -92,11 +94,10 @@ const testFileSystem = Layer.effect(
       }),
     ),
   ),
-).pipe(Layer.provide(FSUtil.defaultLayer))
-const infrastructure = Layer.mergeAll(
-  testFileSystem,
-  Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
-  Global.layerWith({ data: Global.Path.data }),
+).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
+const locationLayer = Layer.succeed(
+  Location.Service,
+  Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
 )
 const mutation = Layer.succeed(
   LocationMutation.Service,
@@ -128,28 +129,20 @@ const unavailableImage = Layer.succeed(
   Image.Service,
   Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
 )
-const read = ReadTool.layer.pipe(
-  Layer.provide(registry),
-  Layer.provide(reader),
-  Layer.provide(permission),
-  Layer.provide(config),
-  Layer.provide(image),
-  Layer.provide(mutation),
-  Layer.provide(infrastructure),
-)
-const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
-const unavailableRead = ReadTool.layer.pipe(
-  Layer.provide(registry),
-  Layer.provide(reader),
-  Layer.provide(permission),
-  Layer.provide(config),
-  Layer.provide(unavailableImage),
-  Layer.provide(mutation),
-  Layer.provide(infrastructure),
-)
-const itWithoutResizer = testEffect(
-  Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead),
-)
+const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
+  AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [
+    [ReadToolFileSystem.node, reader],
+    [PermissionV2.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 })],
+    [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+  ])
+const it = testEffect(readLayer(imageLayer))
+const itWithoutResizer = testEffect(readLayer(unavailableImage))
 const sessionID = SessionV2.ID.make("ses_read_tool_test")
 
 describe("ReadTool", () => {

+ 8 - 11
packages/core/test/tool-skill.test.ts

@@ -4,7 +4,6 @@ import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
-import { FSUtil } from "@opencode-ai/core/fs-util"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
@@ -66,16 +65,14 @@ describe("SkillTool", () => {
               list: () => Effect.succeed(current),
             }),
           )
-          const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [
-            [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
-          ])
-          const tool = SkillTool.layer.pipe(
-            Layer.provide(registry),
-            Layer.provide(permission),
-            Layer.provide(LayerNode.compile(FSUtil.node)),
-            Layer.provide(skills),
+          const skillToolLayer = AppNodeBuilder.build(
+            LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]),
+            [
+              [PermissionV2.node, permission],
+              [SkillV2.node, skills],
+              [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+            ],
           )
-          const layer = Layer.mergeAll(permission, skills, registry, tool)
 
           return yield* Effect.gen(function* () {
             const registry = yield* ToolRegistry.Service
@@ -144,7 +141,7 @@ describe("SkillTool", () => {
                 call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
               }),
             ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
-          }).pipe(Effect.provide(layer))
+          }).pipe(Effect.provide(skillToolLayer))
         }),
       ),
     ),

+ 17 - 7
packages/core/test/tool-todowrite.test.ts

@@ -1,6 +1,8 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { Database } from "@opencode-ai/core/database/database"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { Project } from "@opencode-ai/core/project"
@@ -11,6 +13,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionTodo } from "@opencode-ai/core/session/todo"
 import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { testEffect } from "./lib/effect"
 import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
 
@@ -32,14 +35,21 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
-const tool = TodoWriteTool.layer.pipe(
-  Layer.provide(registry),
-  Layer.provide(permission),
-  Layer.provide(SessionTodo.defaultLayer),
-)
 const it = testEffect(
-  Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer, permission, registry, tool),
+  AppNodeBuilder.build(
+    LayerNode.group([
+      Database.node,
+      EventV2.node,
+      SessionTodo.node,
+      ToolRegistry.node,
+      ToolRegistry.toolsNode,
+      TodoWriteTool.node,
+    ]),
+    [
+      [PermissionV2.node, permission],
+      [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+    ],
+  ),
 )
 
 const setup = Effect.gen(function* () {

+ 13 - 10
packages/core/test/tool-webfetch.test.ts

@@ -1,11 +1,15 @@
 import { describe, expect, test } from "bun:test"
 import { Duration, Effect, Fiber, Layer, Schema } from "effect"
 import * as TestClock from "effect/testing/TestClock"
-import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { testEffect } from "./lib/effect"
 import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
 
@@ -35,15 +39,14 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
-const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http))
-const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch))
-const fetchWebfetch = WebFetchTool.layer.pipe(
-  Layer.provide(registry),
-  Layer.provide(permission),
-  Layer.provide(FetchHttpClient.layer),
-)
-const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch))
+const toolLayer = (replacements: LayerNode.Replacements = []) =>
+  AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [
+    [PermissionV2.node, permission],
+    [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+    ...replacements,
+  ])
+const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]]))
+const live = testEffect(toolLayer())
 
 const reset = () => {
   requests.length = 0

+ 11 - 7
packages/core/test/tool-websearch.test.ts

@@ -1,10 +1,14 @@
 import { beforeEach, describe, expect, test } from "bun:test"
 import { Effect, Layer, Schema } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { testEffect } from "./lib/effect"
 import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
 
@@ -99,7 +103,6 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const websearchConfig = Layer.succeed(
   WebSearchTool.ConfigService,
   WebSearchTool.ConfigService.of({
@@ -120,13 +123,14 @@ const websearchConfig = Layer.succeed(
     },
   }),
 )
-const websearch = WebSearchTool.layer.pipe(
-  Layer.provide(registry),
-  Layer.provide(permission),
-  Layer.provide(http),
-  Layer.provide(websearchConfig),
+const it = testEffect(
+  AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]), [
+    [PermissionV2.node, permission],
+    [LayerNodePlatform.httpClient, http],
+    [WebSearchTool.configNode, websearchConfig],
+    [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+  ]),
 )
-const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch))
 
 describe("WebSearchTool registration", () => {
   it.effect("registers websearch, asserts query permission, and calls Exa", () =>

+ 14 - 11
packages/core/test/tool-write.test.ts

@@ -4,6 +4,8 @@ import { fileURLToPath } from "url"
 import { describe, expect, test } from "bun:test"
 import { Effect, Layer } from "effect"
 import { FileMutation } from "@opencode-ai/core/file-mutation"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Location } from "@opencode-ai/core/location"
 import { LocationMutation } from "@opencode-ai/core/location-mutation"
@@ -11,6 +13,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { WriteTool } from "@opencode-ai/core/tool/write"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
@@ -55,25 +58,25 @@ const filesystem = Layer.effect(
         Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
     })
   }),
-).pipe(Layer.provide(FSUtil.defaultLayer))
+).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
 
 const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
   const activeLocation = Layer.succeed(
     Location.Service,
     Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
   )
-  const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
-  const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
-  const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
-  const write = WriteTool.layer.pipe(
-    Layer.provide(registry),
-    Layer.provide(permission),
-    Layer.provide(resolution),
-    Layer.provide(mutation),
-  )
   return Effect.gen(function* () {
     return yield* body(yield* ToolRegistry.Service)
-  }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write)))
+  }).pipe(
+    Effect.provide(
+      AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, WriteTool.node]), [
+        [FSUtil.node, filesystem],
+        [Location.node, activeLocation],
+        [PermissionV2.node, permission],
+        [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+      ]),
+    ),
+  )
 }
 
 const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({