diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index 21397ac573..5beae18518 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/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], +}) diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index e8b874840f..00a759f9f3 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -272,7 +272,48 @@ export function compile( } 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()) ?? new Map() +} + +function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap) { + if (replacements.size === 0) return root + const cache = new Map() + const visiting = new Set() + const stack: AnyNode[] = [] + + const recur = (node: AnyNode, isRoot = false): AnyNode => { + const target = isRoot ? node : (replacements.get(node.name) ?? node) + const cached = cache.get(target) + if (cached !== undefined || cache.has(target)) return cached! + if (visiting.has(target)) { + const start = stack.indexOf(target) + throw new Error( + `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`, + ) + } + + visiting.add(target) + stack.push(target) + try { + const dependencies = target.dependencies.map((dependency) => recur(dependency)) + const result = dependencies.every((dependency, index) => dependency === target.dependencies[index]) + ? target + : { ...target, dependencies } + cache.set(target, result) + return result + } finally { + stack.pop() + visiting.delete(target) + } + } + + return recur(root, true) } export function hasUnbound(root: Node, source: AnyNode): boolean { diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 05e8e59fbb..c0368f5b16 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/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) => ({ diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index 19507310c2..2acd8c5ee7 100644 --- a/packages/core/src/tool/bash.ts +++ b/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], +}) diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index 1551e278ff..da887d3824 100644 --- a/packages/core/src/tool/builtins.ts +++ b/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, ], }) diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index fb5af18706..240ca59f83 100644 --- a/packages/core/src/tool/edit.ts +++ b/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], +}) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index ea27dfe889..90e666c811 100644 --- a/packages/core/src/tool/glob.ts +++ b/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], +}) diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index ffd59bb489..4bd0a1d2da 100644 --- a/packages/core/src/tool/grep.ts +++ b/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], +}) diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 6c50a809ec..d92a8263cb 100644 --- a/packages/core/src/tool/question.ts +++ b/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], +}) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 563159039c..259fdd54ec 100644 --- a/packages/core/src/tool/read.ts +++ b/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], +}) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 0cf9ee354f..f42d731250 100644 --- a/packages/core/src/tool/skill.ts +++ b/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], +}) diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index a746d524f0..809ba69e8b 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/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], +}) diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 2ce0868d99..37d4bf4f3f 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/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 diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 14c10377ee..4ec77cdc57 100644 --- a/packages/core/src/tool/websearch.ts +++ b/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 = { 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], +}) diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 1438e52247..01c9eb4fca 100644 --- a/packages/core/src/tool/write.ts +++ b/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], +}) diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index ef8d0747d8..f4c023e5d5 100644 --- a/packages/core/test/agent.test.ts +++ b/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", () => diff --git a/packages/core/test/background-job.test.ts b/packages/core/test/background-job.test.ts index 1c4f93f019..5ad1e061a5 100644 --- a/packages/core/test/background-job.test.ts +++ b/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", () => diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index 814da3a787..3da8523c5b 100644 --- a/packages/core/test/command.test.ts +++ b/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", () => diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index ac5ec8c212..a9e5cfc3d7 100644 --- a/packages/core/test/config/config.test.ts +++ b/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 = { diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 6376871935..b381cc7418 100644 --- a/packages/core/test/database-migration.test.ts +++ b/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]]), ), ) diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts index f44a85b6d6..366bf746da 100644 --- a/packages/core/test/effect/layer-node/layer-node-types.test.ts +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -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()("test/TagA") {} class TagB extends Context.Service()("test/TagB") {} diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts index 5221474868..b671792c59 100644 --- a/packages/core/test/effect/layer-node/layer-node.test.ts +++ b/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") diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index c6eb0d9c82..e4329a2dde 100644 --- a/packages/core/test/event.test.ts +++ b/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))) }), ) diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index ba12d7c13c..bcd6ce97ed 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/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(f: (directory: string) => Effect.Effect) { @@ -359,5 +364,5 @@ function instrumentWrites(run: (write: Effect.Effect, target: string run(filesystem.writeFileString(target, content, options), target), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) } diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index a189442e8b..c8eaa579fe 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/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 }, ), diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts index 3b3f74711d..7e42e80098 100644 --- a/packages/core/test/fixture/effect-flock-worker.ts +++ b/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)) diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index f21567f1f5..da0649e567 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/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; filesystemLayer?: Layer.Layer }, +) => + 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), [])) })), + 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") }), + ), ), - ).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") }), - ), - ), + }), ), ) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 5c4f21ef1f..e28e758c87 100644 --- a/packages/core/test/location-layer.test.ts +++ b/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", () => { diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index 327c5bff9f..77e9e96745 100644 --- a/packages/core/test/location.test.ts +++ b/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", () => diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 20dac6666d..a3ed86dc8c 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -88,7 +88,7 @@ const makeMockClient = (state: Ref.Ref) => ) const buildLayer = (state: Ref.Ref) => - // 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( diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 07f1459335..92beb1fa53 100644 --- a/packages/core/test/move-session.test.ts +++ b/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, + ]), ), ) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 349d9ab7e4..7e4a5763bf 100644 --- a/packages/core/test/npm.test.ts +++ b/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) => ) 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", () => { diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index dca9f8ead4..c627483c4a 100644 --- a/packages/core/test/permission.test.ts +++ b/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* () { diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index d4e2500c21..4a49fe48db 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/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", () => { diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 2edafd4e99..0d5b52180a 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/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], + ], ) diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index 3ba6e5aae4..9eda035143 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/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({ diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 45d423f958..20af84d02f 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/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(value: T | undefined): T { diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts index 42736eb7d8..9b5309c6d6 100644 --- a/packages/core/test/policy.test.ts +++ b/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", () => { diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index ab15e16c48..6d049b49ae 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/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 diff --git a/packages/core/test/pty/ticket.test.ts b/packages/core/test/pty/ticket.test.ts index e36808fa2d..10849f2f61 100644 --- a/packages/core/test/pty/ticket.test.ts +++ b/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", () => diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 6f11cbc431..2a317af2ca 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/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) => + 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("/docs") expect(generation.baseline).toContain("Use for product documentation") }).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") }), + }), + ]), + }), + ), ), ), ) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 81efeb7623..54ff364901 100644 --- a/packages/core/test/session-create.test.ts +++ b/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))) }), ) diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts index 9fbfb786d3..e5cb7eccc4 100644 --- a/packages/core/test/session-history.test.ts +++ b/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") }) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 66c8f76914..7c0c824b71 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/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() diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index a0610dd465..d45cc8c734 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/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") diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index d286c29285..68597f8452 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/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"), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 2cb286804c..7fdf4dfe74 100644 --- a/packages/core/test/session-runner.test.ts +++ b/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") diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index 4ebd0e4574..b059201b30 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/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", () => { diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 55e4ead7ed..db0a43168d 100644 --- a/packages/core/test/snapshot.test.ts +++ b/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") })], + ], ) } diff --git a/packages/core/test/system-context/registry.test.ts b/packages/core/test/system-context/registry.test.ts index b77b4132a4..3e68493078 100644 --- a/packages/core/test/system-context/registry.test.ts +++ b/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", () => diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 9b666f0917..5efcf8538a 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/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 = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { 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") => ({ diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index f15621ef58..8b602f858b 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/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 = ( body: (registry: ToolRegistry.Interface) => Effect.Effect, processLayer: Layer.Layer = 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(() => { diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index f95cd1c923..4b05f9579d 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/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 = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { 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") => ({ diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 1df2c8331b..5c5c95da61 100644 --- a/packages/core/test/tool-question.test.ts +++ b/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", () => diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index f3b16c6546..3c94888d57 100644 --- a/packages/core/test/tool-read.test.ts +++ b/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) => + 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", () => { diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index e235265b11..4831acb799 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/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)) }), ), ), diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index 174d6139c8..170d1230a3 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/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* () { diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 5a856ffaf4..2fe46f0ae5 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/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 diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 9715dd5c7f..4f3c8f156c 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/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", () => diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index a81b32d37d..fcbe31b684 100644 --- a/packages/core/test/tool-write.test.ts +++ b/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 = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { 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") => ({