refactor(core): separate out location node functionality and integrate into v2 (#34119)

This commit is contained in:
James Long 2026-06-26 22:46:07 -04:00 committed by GitHub
commit ecdfff5a42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
117 changed files with 1450 additions and 1196 deletions

View file

@ -27,7 +27,7 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Reference } from "@opencode-ai/core/reference"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
@ -93,7 +93,7 @@ export const layer = Layer.effect(
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
const locations = yield* LocationServiceMap
const locations = yield* LocationServiceMap.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) {
@ -444,10 +444,10 @@ export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(locationServiceMapLayer),
)
const locationServiceMapNode = LayerNode.make({ service: Service, layer: LocationServiceMap.layer, deps: [] })
const locationServiceMapNode = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, deps: [] })
export const node = LayerNode.make({
service: Service,

View file

@ -1,7 +1,7 @@
import { EOL } from "os"
import { Effect } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { effectCmd } from "../../effect-cmd"
@ -9,8 +9,8 @@ import { cmd } from "../cmd"
const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))),
Effect.provide(LocationServiceMap.layer),
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))),
Effect.provide(locationServiceMapLayer),
)
const FileSearchCommand = effectCmd({

View file

@ -1,7 +1,7 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { effectCmd } from "../../effect-cmd"
@ -31,12 +31,12 @@ export const V2Command = effectCmd({
}).pipe(
Effect.withSpan("Cli.debug.v2"),
Effect.provide(
LocationServiceMap.get(
LocationServiceMap.Service.get(
Location.Ref.make({
directory: AbsolutePath.make(process.cwd()),
}),
),
),
Effect.provide(LocationServiceMap.layer),
Effect.provide(locationServiceMapLayer),
),
})

View file

@ -1,6 +1,6 @@
import * as InstanceState from "@/effect/instance-state"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
@ -14,7 +14,7 @@ import { InstanceHttpApi } from "../api"
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
Effect.gen(function* () {
const ripgrep = yield* Ripgrep.Service
const locations = yield* LocationServiceMap
const locations = yield* LocationServiceMap.Service
const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
return yield* effect.pipe(
@ -136,4 +136,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
.handle("content", content)
.handle("status", status)
}),
).pipe(Layer.provide(LocationServiceMap.layer))
).pipe(Layer.provide(locationServiceMapLayer))

View file

@ -6,7 +6,7 @@ import { Pty } from "@opencode-ai/core/pty"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Shell } from "@opencode-ai/core/shell"
@ -43,7 +43,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const plugin = yield* Plugin.Service
const locations = yield* LocationServiceMap
const locations = yield* LocationServiceMap.Service
const unregister = registerDisposer((directory) =>
Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
)
@ -158,13 +158,13 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
.handle("remove", remove)
.handle("connectToken", connectToken)
}),
).pipe(Layer.provide(LocationServiceMap.layer))
).pipe(Layer.provide(locationServiceMapLayer))
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const locations = yield* LocationServiceMap
const locations = yield* LocationServiceMap.Service
const unregister = registerDisposer((directory) =>
Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
)
@ -270,4 +270,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
}),
)
}),
).pipe(Layer.provide(LocationServiceMap.layer))
).pipe(Layer.provide(locationServiceMapLayer))

View file

@ -52,15 +52,19 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Npm } from "@opencode-ai/core/npm"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionV2 } from "@opencode-ai/core/session"
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
import { lazy } from "@/util/lazy"
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors"
import { serveUIEffect } from "@/server/shared/ui"
@ -95,6 +99,10 @@ import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { handlers } from "@opencode-ai/server/handlers"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { layer as locationLayer } from "@opencode-ai/server/location"
import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location"
import { PtyEnvironment } from "@opencode-ai/server/pty-environment"
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer } from "./middleware/instance-context"
@ -221,6 +229,7 @@ const app = LayerNode.group([
Discovery.node,
Question.node,
Permission.node,
PermissionSaved.node,
Todo.node,
Session.node,
SessionProjector.node,
@ -279,9 +288,21 @@ export function createRoutes(
MoveSession.defaultLayer,
HttpServer.layerServices,
]),
Layer.provide(LayerNode.buildLayer(app)),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provideMerge(Observability.layer),
Layer.provide(sessionLocationLayer),
Layer.provide(locationLayer),
Layer.provide(PtyEnvironment.layer),
Layer.provide(
SessionV2.defaultLayer.pipe(
Layer.provide(SessionExecutionLocal.defaultLayer),
Layer.provide(locationServiceMapLayer),
),
),
Layer.provide(locationServiceMapLayer),
Layer.provide(LayerNodeTree.compile(app)),
)
}

View file

@ -36,8 +36,6 @@ import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { Effect, Schema } from "effect"
export const node = LayerNode.group([Database.node])
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
code: "ZlibError"
@ -733,3 +731,4 @@ export function fromError(
}
export * as MessageV2 from "./message-v2"
export const node = LayerNode.group([Database.node])

View file

@ -13,7 +13,7 @@ import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionV2 } from "@opencode-ai/core/session"
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { NotFoundError } from "@/storage/storage"
import { eq } from "drizzle-orm"
@ -947,7 +947,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(
SessionV2.defaultLayer.pipe(
Layer.provide(SessionExecutionLocal.defaultLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(locationServiceMapLayer),
),
),
Layer.provide(RuntimeFlags.defaultLayer),

View file

@ -18,7 +18,7 @@ import { Permission } from "@/permission"
import { Skill } from "@/skill"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Reference } from "@opencode-ai/core/reference"
import { MCP } from "@/mcp"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
@ -52,7 +52,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const skill = yield* Skill.Service
const mcp = yield* MCP.Service
const locations = yield* LocationServiceMap
const locations = yield* LocationServiceMap.Service
return Service.of({
environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) {
@ -131,10 +131,10 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(locationServiceMapLayer),
)
const locationServiceMapNode = LayerNode.make({ service: Service, layer: LocationServiceMap.layer, deps: [] })
const locationServiceMapNode = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, deps: [] })
export const node = LayerNode.make({
service: Service,

View file

@ -14,7 +14,7 @@ import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider/provider"
import { Skill } from "../../src/skill"
import { Truncate } from "../../src/tool/truncate"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
const agentLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Agent.layer.pipe(
@ -23,7 +23,7 @@ const agentLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(locationServiceMapLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)

View file

@ -1,6 +1,6 @@
import { expect } from "bun:test"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import path from "path"
@ -44,7 +44,7 @@ const agentLayer = Agent.layer.pipe(
Layer.provide(SkillTest.empty),
Layer.provide(provider.layer),
Layer.provide(pluginLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(locationServiceMapLayer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
)

View file

@ -1,19 +0,0 @@
import { test } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
class A extends Context.Service<A, {}>()("test/TierA") {}
class B extends Context.Service<B, {}>()("test/TierB") {}
const tiers = LayerNode.tiers(["request", "global"])
const request = tiers.make("request")
const global = tiers.make("global")
const globalA = global({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
request({ service: B, layer: bLayer, deps: [globalA] })
// @ts-expect-error Global cannot depend on request
global({ service: B, layer: bLayer, deps: [request({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })] })
test("type exploration compiles", () => {})

View file

@ -1,169 +0,0 @@
import { expect, test } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
class Value extends Context.Service<Value, { readonly value: string }>()("test/TierValue") {}
class Result extends Context.Service<Result, { readonly value: string }>()("test/TierResult") {}
class Left extends Context.Service<Left, { readonly value: string }>()("test/TierLeft") {}
class Right extends Context.Service<Right, { readonly value: string }>()("test/TierRight") {}
class Last extends Context.Service<Last, { readonly value: string }>()("test/TierLast") {}
test("builds tiers with a custom builder", async () => {
let locationBuilds = 0
const tiers = LayerNode.tiers(["location", "global"])
const global = tiers.make("global")
const location = tiers.make("location")
const value = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "value" })), deps: [] })
const result = location({
service: Result,
layer: Layer.effect(
Result,
Effect.gen(function* () {
return Result.of({ value: (yield* Value).value })
}),
),
deps: [value],
})
const layer = LayerNode.buildLayer(LayerNode.group([result]), {
tiers,
buildTier: (tier, layers) => {
if (tier !== "location") return LayerNode.combine(layers)
locationBuilds++
return LayerNode.combine(layers).pipe(Layer.fresh)
},
})
const program = Effect.gen(function* () {
return (yield* Result).value
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toBe("value")
expect(locationBuilds).toBe(1)
})
test("rejects conflicting higher-tier service implementations", () => {
const tiers = LayerNode.tiers(["location", "global"])
const global = tiers.make("global")
const location = tiers.make("location")
const first = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
const left = location({
service: Left,
layer: Layer.effect(Left, Effect.as(Value, Left.of({ value: "left" }))),
deps: [first],
})
const right = location({
service: Right,
layer: Layer.effect(Right, Effect.as(Value, Right.of({ value: "right" }))),
deps: [second],
})
expect(() => LayerNode.buildLayer(LayerNode.group([left, right]), { tiers })).toThrow(
"conflicting implementations for test/TierValue",
)
})
test("validates tier dependencies through groups", () => {
const tiers = LayerNode.tiers(["location", "global"])
const global = tiers.make("global")
const location = tiers.make("location")
const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] })
const invalid = global({
service: Result,
layer: Layer.effect(
Result,
Effect.map(Value, (value) => Result.of({ value: value.value })),
),
deps: [LayerNode.group([local])],
})
expect(() => LayerNode.buildLayer(invalid, { tiers })).toThrow("Tier global cannot depend on lower tier location")
})
test("validates shared groups in each consumer tier", () => {
const tiers = LayerNode.tiers(["location", "global"])
const global = tiers.make("global")
const location = tiers.make("location")
const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] })
const shared = LayerNode.group([local])
const valid = location({
service: Left,
layer: Layer.effect(
Left,
Effect.map(Value, (value) => Left.of({ value: value.value })),
),
deps: [shared],
})
const invalid = global({
service: Result,
layer: Layer.effect(
Result,
Effect.map(Value, (value) => Result.of({ value: value.value })),
),
deps: [shared],
})
expect(() => LayerNode.buildLayer(LayerNode.group([valid, invalid]), { tiers })).toThrow(
"Tier global cannot depend on lower tier location",
)
})
test("rejects a service assigned to multiple tiers", () => {
const tiers = LayerNode.tiers(["location", "global"])
const global = tiers.make("global")
const location = tiers.make("location")
const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] })
const shared = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "global" })), deps: [] })
expect(() => LayerNode.buildLayer(LayerNode.group([local, shared]), { tiers })).toThrow(
"Service test/TierValue belongs to both tier location and tier global",
)
})
test("rebinds same-tier providers without reacquiring them", async () => {
let firstAcquisitions = 0
const tiers = LayerNode.tiers(["global"])
const global = tiers.make("global")
const first = global({
service: Value,
layer: Layer.effect(
Value,
Effect.sync(() => {
firstAcquisitions++
return Value.of({ value: "first" })
}),
),
deps: [],
})
const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
const left = global({
service: Left,
layer: Layer.effect(
Left,
Effect.map(Value, (value) => Left.of({ value: value.value })),
),
deps: [first],
})
const right = global({
service: Right,
layer: Layer.effect(
Right,
Effect.map(Value, (value) => Right.of({ value: value.value })),
),
deps: [second],
})
const last = global({
service: Last,
layer: Layer.effect(
Last,
Effect.map(Value, (value) => Last.of({ value: value.value })),
),
deps: [first],
})
const layer = LayerNode.buildLayer(LayerNode.group([left, right, last]), { tiers })
const values = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value, (yield* Last).value]
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(values)).toEqual(["first", "second", "first"])
expect(firstAcquisitions).toBe(1)
})

View file

@ -1,66 +0,0 @@
import { test } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
class A extends Context.Service<A, {}>()("test/LayerNodeA") {}
class B extends Context.Service<B, {}>()("test/LayerNodeB") {}
class C extends Context.Service<C, {}>()("test/LayerNodeC") {}
class LayerError {
readonly _tag = "LayerError"
}
class OtherError {
readonly _tag = "OtherError"
}
const tiers = LayerNode.tiers(["app"])
const make = tiers.make("app")
const aLayer = Layer.succeed(A, A.of({}))
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
const cLayer = Layer.effect(
C,
Effect.gen(function* () {
yield* A
yield* B
return C.of({})
}),
)
const failingA = Layer.effect(A, Effect.fail(new LayerError()))
const a = make({ service: A, layer: aLayer, deps: [] })
const b = make({ service: B, layer: bLayer, deps: [a] })
const c = make({ service: C, layer: cLayer, deps: [a, b] })
const failing = make({ service: A, layer: failingA, deps: [] })
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
make({ name: "manual-a", layer: aLayer, deps: [] })
// @ts-expect-error A node must have a service or name
make({ layer: aLayer, deps: [] })
// @ts-expect-error Service and name are mutually exclusive
make({ service: A, name: "a", layer: aLayer, deps: [] })
// @ts-expect-error B requires A
make({ service: B, layer: bLayer, deps: [] })
// @ts-expect-error C requires A and B
make({ service: C, layer: cLayer, deps: [a] })
const closed = LayerNode.buildLayer(c, { tiers })
const closedWithError = LayerNode.buildLayer(dependent, { tiers })
const checkClosed: Layer.Layer<C, never, never> = closed
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
void checkClosed
void checkError
LayerNode.replace(aLayer, Layer.succeed(A, A.of({})))
// @ts-expect-error Replacement must provide A
LayerNode.replace(aLayer, Layer.succeed(B, B.of({})))
// @ts-expect-error Replacement cannot introduce a new error
LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError())))
// @ts-expect-error Replacement must be closed
LayerNode.replace(bLayer, bLayer)
test("type exploration compiles", () => {})

View file

@ -1,86 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
class Value extends Context.Service<Value, { readonly value: string }>()("test/LayerNodeValue") {}
class Greeting extends Context.Service<Greeting, { readonly value: string }>()("test/LayerNodeGreeting") {}
class Left extends Context.Service<Left, { readonly value: string }>()("test/LayerNodeLeft") {}
class Right extends Context.Service<Right, { readonly value: string }>()("test/LayerNodeRight") {}
const tiers = LayerNode.tiers(["app"])
const make = tiers.make("app")
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
const greetingLayer = Layer.effect(
Greeting,
Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })),
)
const value = make({ service: Value, layer: valueLayer, deps: [] })
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
describe("layer node", () => {
test("builds an untiered graph", async () => {
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(LayerNode.buildLayer(greeting)))
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("builds a dependency graph", async () => {
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(LayerNode.buildLayer(greeting, { tiers })),
)
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("replaces a layer by identity", async () => {
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(
LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(valueLayer, replacement)] }),
),
)
expect(await Effect.runPromise(program)).toBe("hello simulation")
})
test("replaces every use of the same layer", async () => {
const leftLayer = Layer.effect(
Left,
Effect.map(Value, (item) => Left.of({ value: item.value })),
)
const rightLayer = Layer.effect(
Right,
Effect.map(Value, (item) => Right.of({ value: item.value })),
)
const left = make({ service: Left, layer: leftLayer, deps: [value] })
const right = make({ service: Right, layer: rightLayer, deps: [value] })
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
const layer = LayerNode.buildLayer(LayerNode.group([left, right]), {
tiers,
replacements: [LayerNode.replace(valueLayer, replacement)],
})
const program = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"])
})
test("does not acquire an unused replacement", async () => {
let acquisitions = 0
const other = Layer.succeed(Value, Value.of({ value: "other" }))
const replacement = Layer.effect(
Value,
Effect.sync(() => {
acquisitions++
return Value.of({ value: "replacement" })
}),
)
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(
LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(other, replacement)] }),
),
),
)
expect(acquisitions).toBe(0)
})
})

View file

@ -1,23 +0,0 @@
import { test } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/core/effect/scoped-node"
class A extends Context.Service<A, {}>()("test/ScopedA") {}
class B extends Context.Service<B, {}>()("test/ScopedB") {}
const a = Layer.succeed(A, A.of({}))
const b = Layer.effect(B, Effect.as(A, B.of({})))
const globalA = makeGlobalNode({ service: A, layer: a, deps: [] })
const locationA = makeLocationNode({ service: A, layer: a, deps: [] })
makeGlobalNode({ service: B, layer: b, deps: [globalA] })
makeLocationNode({ service: B, layer: b, deps: [globalA] })
makeLocationNode({ service: B, layer: b, deps: [locationA] })
// @ts-expect-error Global nodes cannot depend on location nodes
makeGlobalNode({ service: B, layer: b, deps: [locationA] })
// @ts-expect-error B requires A
makeLocationNode({ service: B, layer: b, deps: [] })
test("type exploration compiles", () => {})

View file

@ -1,4 +1,5 @@
import { $ } from "bun"
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
@ -9,7 +10,7 @@ import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
const it = testEffect(LayerNode.buildLayer(Git.node))
const it = testEffect(LayerNodeTree.compile(LayerNode.group([Git.node])))
const scopedTmpdir = (options?: Parameters<typeof tmpdir>[0]) =>
Effect.acquireRelease(

View file

@ -21,6 +21,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import type { Provider } from "@/provider/provider"
@ -613,7 +614,9 @@ describe("session.compaction.create", () => {
})
const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe(
Effect.provide(SessionV2.defaultLayer.pipe(Layer.provide(SessionExecution.noopLayer))),
Effect.provide(SessionV2.defaultLayer),
Effect.provide(SessionExecution.noopLayer),
Effect.provide(locationServiceMapLayer),
)
expect(v2.at(-1)).toMatchObject({
type: "compaction",

View file

@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@ -12,7 +13,7 @@ import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node])))
const it = testEffect(LayerNodeTree.compile(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node])))
const withSession = <A, E, R>(
fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect<A, E, R>,

View file

@ -1,6 +1,7 @@
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
import { EventV2Bridge } from "@/event-v2-bridge"
import { expect } from "bun:test"
import { tool } from "ai"
@ -179,9 +180,9 @@ const replacements = [
LayerNode.replace(SessionSummary.layer, summary),
LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })),
]
const env = LayerNode.buildLayer(
const env = LayerNodeTree.compile(
LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]),
{ replacements },
new Map(replacements.map((item) => [item.source, item.replacement])),
)
const it = testEffect(env)
@ -206,9 +207,12 @@ const providerErrorLLM = Layer.succeed(
),
}),
)
const providerErrorEnv = LayerNode.buildLayer(root, {
replacements: [...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)],
})
const providerErrorEnv = LayerNodeTree.compile(
root,
new Map(
[...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)].map((item) => [item.source, item.replacement]),
),
)
const itProviderError = testEffect(providerErrorEnv)
const fragmentFailureLLM = Layer.succeed(
@ -225,9 +229,15 @@ const fragmentFailureLLM = Layer.succeed(
),
}),
)
const fragmentFailureEnv = LayerNode.buildLayer(root, {
replacements: [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)],
})
const fragmentFailureEnv = LayerNodeTree.compile(
root,
new Map(
[...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)].map((item) => [
item.source,
item.replacement,
]),
),
)
const itFragmentFailure = testEffect(fragmentFailureEnv)
const boot = Effect.fn("test.boot")(function* () {

View file

@ -56,7 +56,7 @@ import { reply, TestLLMServer } from "../lib/llm-server"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
const summary = Layer.succeed(
SessionSummary.Service,
@ -230,7 +230,7 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces
Layer.provide(
SystemPrompt.layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(locationServiceMapLayer),
Layer.provide(deps),
),
),
@ -697,7 +697,9 @@ noLLMServer.instance.skip(
})
const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
Effect.provide(SessionV2.defaultLayer.pipe(Layer.provide(SessionExecution.noopLayer))),
Effect.provide(SessionV2.defaultLayer),
Effect.provide(SessionExecution.noopLayer),
Effect.provide(locationServiceMapLayer),
)
const { db } = yield* Database.Service
const row = yield* db

View file

@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { NamedError } from "@opencode-ai/core/util/error"
import { APICallError } from "ai"
@ -16,7 +17,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
const providerID = ProviderV2.ID.make("test")
const retryProvider = "test"
const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node])))
const it = testEffect(LayerNodeTree.compile(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node])))
function apiError(headers?: Record<string, string>): SessionV1.APIError {
return Schema.decodeUnknownSync(SessionV1.APIError.Schema)(

View file

@ -14,6 +14,7 @@
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
import fs from "fs/promises"
import path from "path"
import { Session } from "@/session/session"
@ -87,13 +88,16 @@ const root = LayerNode.group([
LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }),
])
const it = testEffect(
LayerNode.buildLayer(root, {
replacements: [
LayerNodeTree.compile(
root,
new Map(
[
LayerNode.replace(MCP.layer, mcp),
LayerNode.replace(LSP.layer, lsp),
LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })),
],
}),
].map((item) => [item.source, item.replacement]),
),
),
)
const providerCfg = (url: string) => ({

View file

@ -6,7 +6,7 @@ import { Skill } from "../../src/skill"
import { Permission } from "../../src/permission"
import { SystemPrompt } from "../../src/session/system"
import { MCP } from "../../src/mcp"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { testEffect } from "../lib/effect"
const skills: Skill.Info[] = [
@ -44,7 +44,7 @@ const build: Agent.Info = {
const it = testEffect(
SystemPrompt.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(locationServiceMapLayer),
Layer.provide(
Layer.mock(MCP.Service, {
instructions: () =>

View file

@ -2,6 +2,7 @@ import { beforeEach, describe, expect } from "bun:test"
import { Effect, Exit, Layer, Option } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@ -19,7 +20,7 @@ import { provideTmpdirInstance } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
import { pollWithTimeout, testEffect } from "../lib/effect"
const env = LayerNode.buildLayer(CrossSpawnSpawner.node)
const env = LayerNodeTree.compile(LayerNode.group([CrossSpawnSpawner.node]))
const it = testEffect(env)
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
@ -34,22 +35,18 @@ const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unkno
const none = HttpClient.make(() => Effect.die("unexpected http call"))
function requestLayer(client: HttpClient.HttpClient) {
return LayerNode.buildLayer(LayerNode.group([ShareNext.node, AccountRepo.node]), {
replacements: [LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))],
})
const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))
return LayerNodeTree.compile(
LayerNode.group([ShareNext.node, AccountRepo.node]),
new Map([[replacement.source, replacement.replacement]]),
)
}
function integrationLayer(client: HttpClient.HttpClient) {
return LayerNode.buildLayer(
LayerNode.group([
ShareNext.node,
EventV2Bridge.node,
Session.node,
SessionProjector.node,
AccountRepo.node,
Database.node,
]),
{ replacements: [LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))] },
const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))
return LayerNodeTree.compile(
LayerNode.group([ShareNext.node, EventV2Bridge.node, Session.node, SessionProjector.node, AccountRepo.node, Database.node]),
new Map([[replacement.source, replacement.replacement]]),
)
}

View file

@ -4,6 +4,7 @@ import fs from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import { Effect, Layer, Result, Schema } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
import { ToolRegistry } from "@/tool/registry"
import { Tool } from "@/tool/tool"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
@ -54,11 +55,17 @@ const replacements = [
LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer()),
]
const it = testEffect(LayerNode.buildLayer(root, { replacements }))
const it = testEffect(LayerNodeTree.compile(root, new Map(replacements.map((item) => [item.source, item.replacement]))))
const withBrokenPlugin = testEffect(
LayerNode.buildLayer(root, {
replacements: [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)],
}),
LayerNodeTree.compile(
root,
new Map(
[...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)].map((item) => [
item.source,
item.replacement,
]),
),
),
)
afterEach(async () => {