diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index e28733360..c6ee6b236 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -44,13 +44,20 @@ type CheckReplacementErrors = [Exclude } -export function replace( +export function replaceWithNode( source: Node, replacement: Node, E2> & CheckReplacementErrors>, ): Replacement { return { source, replacement } } +export function replace( + source: Node, + replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, +): Replacement { + return { source, replacement: make(replacement as Layer.Layer, []) } +} + export function buildLayer(node: Node, options?: { readonly replacements?: readonly Replacement[] }) { const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement])) const cache = new Map() diff --git a/packages/opencode/test/effect/app-graph-types.test.ts b/packages/opencode/test/effect/app-graph-types.test.ts index ecfe6af33..527c4daf5 100644 --- a/packages/opencode/test/effect/app-graph-types.test.ts +++ b/packages/opencode/test/effect/app-graph-types.test.ts @@ -91,14 +91,18 @@ void (0 as unknown as ClosedRequires) void (0 as unknown as ClosedError) const replacement = LayerNode.make(Layer.succeed(A, A.of({ value: "a" })), []) -LayerNode.replace(a, replacement) -LayerNode.replace(notFoundOrDiskA, notFoundA) -LayerNode.replace(notFoundOrDiskA, diskA) +LayerNode.replace(a, Layer.succeed(A, A.of({ value: "a" }))) +LayerNode.replace(notFoundOrDiskA, notFoundAImplementation) +LayerNode.replace(notFoundOrDiskA, diskAImplementation) +LayerNode.replaceWithNode(a, replacement) // @ts-expect-error An override for A must still provide A -LayerNode.replace(a, b) +LayerNode.replaceWithNode(a, b) // @ts-expect-error A replacement cannot introduce NetworkError -LayerNode.replace(notFoundOrDiskA, networkA) +LayerNode.replace(notFoundOrDiskA, networkAImplementation) + +// @ts-expect-error A replacement layer must not have unresolved dependencies +LayerNode.replace(b, bImplementation) test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/effect/app-graph.test.ts b/packages/opencode/test/effect/app-graph.test.ts index d89d2680b..7ae7a982b 100644 --- a/packages/opencode/test/effect/app-graph.test.ts +++ b/packages/opencode/test/effect/app-graph.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Cause, Context, Effect, Exit, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -const { buildLayer: build, group, replace } = LayerNode +const { buildLayer: build, group, replace, replaceWithNode } = LayerNode const node = LayerNode.make class Value extends Context.Service()("test/Value") {} @@ -30,7 +30,7 @@ describe("app graph", () => { }) test("applies overrides before dependency materialization", async () => { - const replacement = node(Layer.succeed(Value, Value.of({ value: "simulation" })), []) + const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) const graph = build(greeting, { replacements: [replace(value, replacement)] }) const result = Effect.gen(function* () { return (yield* Greeting).text @@ -102,7 +102,7 @@ describe("app graph", () => { ), [value], ) - const replacement = node(Layer.succeed(Value, Value.of({ value: "simulation" })), []) + const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) const graph = build(group([left, right]), { replacements: [replace(value, replacement)] }) const result = Effect.gen(function* () { @@ -153,7 +153,7 @@ describe("app graph", () => { ) const result = Effect.gen(function* () { return (yield* Greeting).text - }).pipe(Effect.provide(build(greeting, { replacements: [replace(value, replacement)] }))) + }).pipe(Effect.provide(build(greeting, { replacements: [replaceWithNode(value, replacement)] }))) expect(await Effect.runPromise(result)).toBe("hello replacement") }) @@ -161,15 +161,12 @@ describe("app graph", () => { test("does not acquire unreachable replacements", async () => { let acquisitions = 0 const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), []) - const replacement = node( - Layer.effect( - Value, - Effect.sync(() => { - acquisitions++ - return Value.of({ value: "replacement" }) - }), - ), - [], + const replacement = Layer.effect( + Value, + Effect.sync(() => { + acquisitions++ + return Value.of({ value: "replacement" }) + }), ) await Effect.runPromise( @@ -200,7 +197,7 @@ describe("app graph", () => { const consumer = node(greetingImplementation, [value]) ;(replacement.dependencies as LayerNode.Node[]).push(consumer) - expect(() => build(consumer, { replacements: [replace(value, replacement)] })).toThrow( + expect(() => build(consumer, { replacements: [replaceWithNode(value, replacement)] })).toThrow( "Cycle detected in app graph: layer#1 -> layer#2 -> layer#1", ) }) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 34f11c5a7..27447616f 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -3,31 +3,15 @@ import path from "path" import fs from "fs/promises" import { fileURLToPath, pathToFileURL } from "url" import { Effect, Layer, Result, Schema } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ToolRegistry } from "@/tool/registry" import { Tool } from "@/tool/tool" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Config } from "@/config/config" import { Plugin } from "@/plugin" -import { Question } from "@/question" -import { Todo } from "@/session/todo" -import { Skill } from "@/skill" import { Agent } from "@/agent/agent" -import { BackgroundJob } from "@/background/job" -import { Session } from "@/session/session" -import { SessionStatus } from "@/session/status" -import { Provider } from "@/provider/provider" -import { Git } from "@/git" -import { LSP } from "@/lsp/lsp" -import { Instruction } from "@/session/instruction" -import { EventV2Bridge } from "@/event-v2-bridge" -import { FetchHttpClient } from "effect/unstable/http" -import { Format } from "@/format" -import { Ripgrep } from "@opencode-ai/core/ripgrep" -import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" import { ToolJsonSchema } from "@/tool/json-schema" @@ -36,41 +20,10 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const node = CrossSpawnSpawner.defaultLayer const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), }) -type RegistryLayerOptions = { - flags?: Partial - plugin?: Layer.Layer -} - -const registryLayer = (opts: RegistryLayerOptions = {}) => - ToolRegistry.layer - .pipe( - Layer.provide(configLayer), - Layer.provide(opts.plugin ?? Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)), - Layer.provide(Provider.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Format.defaultLayer), - Layer.provide(Layer.mergeAll(node, Database.defaultLayer)), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Truncate.defaultLayer), - ) - .pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {}))) - // Fake Plugin.Service that returns a single plugin whose `tool` map contains // one definition with `args: undefined`. Used to exercise the plugin entry // point of `fromPlugin` for the #27451 / #27630 regression. @@ -95,9 +48,17 @@ const brokenPluginLayer = Layer.succeed( }), ) -const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer)) +const root = LayerNode.group([ToolRegistry.node, Agent.node]) +const replacements = [ + LayerNode.replace(Config.node, configLayer), + LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer()), +] + +const it = testEffect(LayerNode.buildLayer(root, { replacements })) const withBrokenPlugin = testEffect( - Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer), + LayerNode.buildLayer(root, { + replacements: [...replacements, LayerNode.replace(Plugin.node, brokenPluginLayer)], + }), ) afterEach(async () => {