mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 04:56:20 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15fbbad20a | ||
|
|
346d121ec3 | ||
|
|
849824efd2 |
@@ -98,12 +98,13 @@ Effect.gen(function* () {
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [
|
||||
[
|
||||
Global.node,
|
||||
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), {
|
||||
replacements: [
|
||||
Global.node.replace(
|
||||
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
),
|
||||
Effect.provide(
|
||||
Observability.layer({
|
||||
|
||||
@@ -30,12 +30,13 @@ export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* processEffect(options).pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [
|
||||
[
|
||||
Global.node,
|
||||
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
|
||||
replacements: [
|
||||
Global.node.replace(
|
||||
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
),
|
||||
Effect.provide(NodeServices.layer),
|
||||
)
|
||||
|
||||
@@ -46,12 +46,12 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
const servers = new Map<string, ServerConfig>()
|
||||
for (const document of documents) {
|
||||
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
|
||||
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
servers.set(name, server)
|
||||
}
|
||||
}
|
||||
for (const [name, server] of servers) {
|
||||
if (draft.get(name)) continue
|
||||
draft.set(name, server)
|
||||
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { buildLocationServiceMap } from "../location-services.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
|
||||
// Only build the location service map if it's actually needed
|
||||
if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node))
|
||||
return LayerNode.compile(root, replacements)
|
||||
|
||||
const locationMap = buildLocationServiceMap(replacements)
|
||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||
return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]]))
|
||||
}
|
||||
|
||||
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
|
||||
return replacements.some(([source]) => source.name === node.name)
|
||||
export function build<A, E>(root: LayerNode.Graph<A, E>, replacements: LayerNode.Replacements = []) {
|
||||
return LayerNode.compile(root, {
|
||||
replacements: [LocationServiceMap.node.replace(buildLocationServiceMap(replacements)), ...replacements],
|
||||
})
|
||||
}
|
||||
|
||||
export * as AppNodeBuilder from "./app-node-builder.js"
|
||||
|
||||
@@ -108,9 +108,9 @@ const nodes = [
|
||||
Vcs.node,
|
||||
// Start repository watches only after boot-critical filesystem and Git work.
|
||||
LocationWatcher.node,
|
||||
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
|
||||
] as const satisfies readonly Node.LocationGraph<never, unknown>[]
|
||||
|
||||
export const graph = LayerNode.group<typeof nodes>(nodes)
|
||||
export const graph = LayerNode.group(nodes)
|
||||
|
||||
export type Services = LayerNode.Output<typeof graph>
|
||||
export type Error = LayerNode.Error<typeof graph>
|
||||
@@ -139,29 +139,23 @@ export interface Options {
|
||||
// source still honors explicit plugin operations from wellknown and
|
||||
// host-injected config.
|
||||
const vanillaReplacements: LayerNode.Replacements = [
|
||||
[Config.node, Config.configured({ project: false, global: false })],
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })],
|
||||
Config.node.replace(Config.configured({ project: false, global: false })),
|
||||
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: false, global: false })),
|
||||
]
|
||||
|
||||
// One instance is one compiled, fresh copy of the graph standing on a directory.
|
||||
export function layer(ref: Location.Ref, options: Options = {}) {
|
||||
const startedAt = performance.now()
|
||||
// Ordered: vanilla defaults, then caller replacements (which win over the
|
||||
// defaults), then bound pairs (which win over everything).
|
||||
const allReplacements: LayerNode.Replacements = [
|
||||
// defaults), then instance bindings (which win over everything).
|
||||
const replacements: LayerNode.Replacements = [
|
||||
...(options.discovery === false ? vanillaReplacements : []),
|
||||
...(options.replacements ?? []),
|
||||
[Location.node, Location.boundNode(ref, { discovery: options.discovery })],
|
||||
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
|
||||
Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })),
|
||||
InstancePlugins.node.replace(InstancePlugins.bound(options.plugins ?? [])),
|
||||
]
|
||||
// Apply replacements during hoist, not afterward: replacements can
|
||||
// introduce new tagged dependencies (Location.boundNode depends on
|
||||
// Project), and the hoist walk is the only pass that can still slice
|
||||
// those back out.
|
||||
const location = LayerNode.hoist(graph, Node.tags.values.global, allReplacements)
|
||||
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
@@ -169,6 +163,5 @@ export function layer(ref: Location.Ref, options: Options = {}) {
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
|
||||
|
||||
export const configured = (options: Options = {}) =>
|
||||
const makeLayer = (options: Options = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -361,8 +361,14 @@ export const configured = (options: Options = {}) =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = configured()
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] })
|
||||
export const layer = makeLayer()
|
||||
export const configured = (options?: Options) =>
|
||||
makeGlobalNode({
|
||||
service: Service,
|
||||
layer: options === undefined ? layer : makeLayer(options),
|
||||
deps: [Bus.node, Global.node],
|
||||
})
|
||||
export const node = configured()
|
||||
|
||||
const request = (daemon: DaemonTransport, value: object, start = false) =>
|
||||
daemon.request(value, start).pipe(Effect.mapError(unavailable))
|
||||
|
||||
@@ -95,7 +95,7 @@ export function convertHTMLToMarkdown(html: string) {
|
||||
const remaining = limit - outputBytes
|
||||
const next = bytes.byteLength <= remaining ? value : sliceBytes(value, remaining)
|
||||
output.push(next)
|
||||
outputBytes += bytes.byteLength <= remaining ? bytes.byteLength : encoder.encode(next).byteLength
|
||||
outputBytes += encoder.encode(next).byteLength
|
||||
last = next.at(-1) ?? last
|
||||
}
|
||||
const appendRaw = (value: string) => {
|
||||
|
||||
@@ -22,8 +22,8 @@ const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global))
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
|
||||
[Global.node, globalLayer],
|
||||
[Location.node, locationLayer],
|
||||
Global.node.replace(globalLayer),
|
||||
Location.node.replace(locationLayer),
|
||||
]) as unknown as Layer.Layer<unknown, never>,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
)
|
||||
const a = Location.Ref.make({ directory: AbsolutePath.make("/a") })
|
||||
|
||||
@@ -100,12 +100,14 @@ const tail = (bus: Bus.Interface, input: { aggregateID: string; after?: number }
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Location.node]), [
|
||||
[Location.node, locationLayer],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Location.node.replace(locationLayer),
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
)
|
||||
const itWithoutLocation = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]),
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
)
|
||||
const itWithoutPersistence = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])))
|
||||
|
||||
@@ -631,8 +633,7 @@ describe("Bus", () => {
|
||||
const continueRead = yield* Deferred.make<void>()
|
||||
let pause = true
|
||||
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
|
||||
[
|
||||
Bus.node,
|
||||
Bus.node.replace(
|
||||
Bus.configured({
|
||||
persist: true,
|
||||
beforeAggregateRead: () =>
|
||||
@@ -640,7 +641,7 @@ describe("Bus", () => {
|
||||
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
|
||||
: Effect.void,
|
||||
}),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -1318,7 +1319,7 @@ describe("Bus", () => {
|
||||
it.effect("log replays across configured read pages", () =>
|
||||
Effect.gen(function* () {
|
||||
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true, logReadPageSize: 2 })],
|
||||
Bus.node.replace(Bus.configured({ persist: true, logReadPageSize: 2 })),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -1351,8 +1352,7 @@ describe("Bus", () => {
|
||||
const releaseRead = yield* Deferred.make<void>()
|
||||
const firstRead = yield* Ref.make(true)
|
||||
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
|
||||
[
|
||||
Bus.node,
|
||||
Bus.node.replace(
|
||||
Bus.configured({
|
||||
persist: true,
|
||||
beforeAggregateRead: () =>
|
||||
@@ -1363,7 +1363,7 @@ describe("Bus", () => {
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
|
||||
@@ -25,7 +25,7 @@ const locationLayer = Layer.succeed(
|
||||
)
|
||||
const catalogLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Catalog.node, Bus.node, Credential.node, Integration.node]),
|
||||
[[Location.node, locationLayer]],
|
||||
[Location.node.replace(locationLayer)],
|
||||
)
|
||||
const it = testEffect(catalogLayer)
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("Catalog", () => {
|
||||
it.effect("derives availability from active credentials without changing provider state", () => {
|
||||
const integrationID = Integration.ID.make("test")
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]),
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [Location.node.replace(locationLayer)]),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
@@ -78,7 +78,7 @@ describe("Catalog", () => {
|
||||
const providerID = Provider.ID.make("remote")
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
|
||||
[Location.node, locationLayer],
|
||||
Location.node.replace(locationLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("Catalog", () => {
|
||||
const providerID = Provider.ID.make("remote")
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
|
||||
[Location.node, locationLayer],
|
||||
Location.node.replace(locationLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("CodeMode", () => {
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Tool.node, [
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("CodeModeInstructions", () => {
|
||||
execute: () => Effect.succeed({ output: "zeta" }),
|
||||
}
|
||||
const layer = AppNodeBuilder.build(Tool.node, [
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
])
|
||||
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -43,10 +43,10 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
|
||||
[
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
[ShellSelect.node, shellLayer],
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
Config.node.replace(emptyConfigLayer),
|
||||
Location.node.replace(testLocationLayer),
|
||||
ShellSelect.node.replace(shellLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -340,17 +340,16 @@ describeNative("ConfigCommandPlugin native watcher", () => {
|
||||
ShellSelect.node,
|
||||
]),
|
||||
[
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
|
||||
),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[ShellSelect.node, shellLayer],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
),
|
||||
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
|
||||
ShellSelect.node.replace(shellLayer),
|
||||
Credential.node.replace(emptyCredentialNode),
|
||||
WellKnown.node.replace(emptyWellknownNode),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -40,13 +40,12 @@ const it = testEffect(
|
||||
Layer.merge(
|
||||
config,
|
||||
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
|
||||
[
|
||||
llmClient,
|
||||
llmClient.replace(
|
||||
Layer.mock(LLMClient.Service)({
|
||||
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
|
||||
}),
|
||||
],
|
||||
[Config.node, config],
|
||||
),
|
||||
Config.node.replace(config),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -55,12 +55,12 @@ function testLayer(
|
||||
),
|
||||
)
|
||||
const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
|
||||
[Config.node, Config.configured(options)],
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
|
||||
[Credential.node, credentialNode],
|
||||
[WellKnown.node, wellknownNode],
|
||||
[Watcher.node, watcher],
|
||||
Config.node.replace(Config.configured(options)),
|
||||
Location.node.replace(locationLayer),
|
||||
Global.node.replace(Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })),
|
||||
Credential.node.replace(credentialNode),
|
||||
WellKnown.node.replace(wellknownNode),
|
||||
Watcher.node.replace(watcher),
|
||||
])
|
||||
// Merge the watcher layer by reference so Watcher.Test resolves to the same
|
||||
// memoized instance the built graph uses.
|
||||
@@ -311,16 +311,15 @@ describe("Config", () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(project) })),
|
||||
),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
),
|
||||
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
|
||||
Credential.node.replace(emptyCredentialNode),
|
||||
WellKnown.node.replace(emptyWellknownNode),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -28,13 +28,13 @@ import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
const staticIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[ConfigPluginSource.node, ConfigPluginSource.empty],
|
||||
[Global.node, tempGlobalLayer],
|
||||
ConfigPluginSource.node.replace(ConfigPluginSource.empty),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
const refreshNpm = makeGlobalNode({
|
||||
@@ -65,10 +65,7 @@ const refreshNpm = makeGlobalNode({
|
||||
const refreshIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
|
||||
[
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Npm.node, refreshNpm],
|
||||
],
|
||||
[Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -86,14 +86,13 @@ const discover = (directory: string, global: string) =>
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
[Watcher.node, Watcher.testLayer],
|
||||
),
|
||||
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
|
||||
Credential.node.replace(emptyCredentialNode),
|
||||
WellKnown.node.replace(emptyWellknownNode),
|
||||
Watcher.node.replace(Watcher.testLayer),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -51,8 +51,8 @@ describe("ConfigSnapshotPlugin.Plugin", () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Snapshot.node, [
|
||||
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))],
|
||||
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
|
||||
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
|
||||
Global.node.replace(Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -44,7 +44,9 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for tool output config reload"))
|
||||
}).pipe(
|
||||
Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])),
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(ToolOutput.node, [Global.node.replace(Global.layerWith({ data: tmp.path }))]),
|
||||
),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
|
||||
@@ -13,131 +13,218 @@ class OtherError {
|
||||
readonly _tag = "OtherError"
|
||||
}
|
||||
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root) as Layer.Layer<A, E>
|
||||
const aLayer = Layer.succeed(A, A.of({}))
|
||||
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
|
||||
const cLayer = Layer.effect(
|
||||
C,
|
||||
Effect.gen(function* () {
|
||||
yield* A
|
||||
yield* B
|
||||
return C.of({})
|
||||
}),
|
||||
)
|
||||
const failingA = Layer.effect(A, Effect.fail(new LayerError()))
|
||||
const a = make({ service: A, layer: aLayer, deps: [] })
|
||||
const b = make({ service: B, layer: bLayer, deps: [a] })
|
||||
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
||||
const failing = make({ service: A, layer: failingA, deps: [] })
|
||||
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
||||
const inputA = LayerNode.unbound(A, tags.values.app)
|
||||
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
|
||||
// Keep intentionally invalid expressions out of runtime execution.
|
||||
const contracts = (tag: LayerNode.Tag<"app"> | LayerNode.Tag<"other">, flag: boolean) => {
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const aLayer = Layer.succeed(A, A.of({}))
|
||||
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
|
||||
const cLayer = Layer.effect(
|
||||
C,
|
||||
Effect.gen(function* () {
|
||||
yield* A
|
||||
yield* B
|
||||
return C.of({})
|
||||
}),
|
||||
)
|
||||
const a = make({ service: A, layer: aLayer, deps: [] })
|
||||
const b = make({ service: B, layer: bLayer, deps: [a] })
|
||||
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
||||
const ab = make({ name: "a-and-b", layer: Layer.mergeAll(aLayer, Layer.succeed(B, {})), deps: [] })
|
||||
const failing = make({ service: A, layer: Layer.effect(A, Effect.fail(new LayerError())), deps: [] })
|
||||
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
||||
const inputA = LayerNode.unbound(A, tags.values.app)
|
||||
const group = LayerNode.group([a, b])
|
||||
|
||||
make({ name: "manual-a", layer: aLayer, deps: [] })
|
||||
make({ name: "manual-a", layer: aLayer, deps: [] })
|
||||
// @ts-expect-error A node must have a service or name
|
||||
make({ layer: aLayer, deps: [] })
|
||||
// @ts-expect-error Service and name are mutually exclusive
|
||||
make({ service: A, name: "a", layer: aLayer, deps: [] })
|
||||
// @ts-expect-error An explicit tagged contract requires a corresponding runtime tag
|
||||
LayerNode.make<typeof aLayer, readonly [], typeof tags.values.app>({ service: A, layer: aLayer, deps: [] })
|
||||
// @ts-expect-error B requires A
|
||||
make({ service: B, layer: bLayer, deps: [] })
|
||||
// @ts-expect-error C requires A and B
|
||||
make({ service: C, layer: cLayer, deps: [a] })
|
||||
const erasedLayer: Layer.Any = bLayer
|
||||
// @ts-expect-error Erasing a Layer's contract cannot hide its inputs and errors
|
||||
make({ service: B, layer: erasedLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error A node must have a service or name
|
||||
make({ layer: aLayer, deps: [] })
|
||||
LayerNode.compile(c) satisfies Layer.Layer<C, never, never>
|
||||
LayerNode.compile(dependent) satisfies Layer.Layer<B, LayerError, never>
|
||||
LayerNode.compile(group) satisfies Layer.Layer<A | B, never, never>
|
||||
LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer<never>
|
||||
// @ts-expect-error An empty graph cannot supply arbitrary services
|
||||
LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer<A>
|
||||
LayerNode.compile(inputA, { replacements: [inputA.replace(a)] }) satisfies Layer.Layer<A, never, never>
|
||||
// @ts-expect-error A is a private dependency, not a root output
|
||||
LayerNode.compile(c) satisfies Layer.Layer<A | C>
|
||||
// @ts-expect-error Dependency failures are not erased
|
||||
LayerNode.compile(dependent) satisfies Layer.Layer<B>
|
||||
|
||||
// @ts-expect-error Service and name are mutually exclusive
|
||||
make({ service: A, name: "a", layer: aLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error B requires A
|
||||
make({ service: B, layer: bLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error C requires A and B
|
||||
make({ service: C, layer: cLayer, deps: [a] })
|
||||
|
||||
const closed = build(LayerNode.group([c]))
|
||||
const closedWithError = build(LayerNode.group([dependent]))
|
||||
const checkClosed: Layer.Layer<C, never, never> = closed
|
||||
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
|
||||
void checkClosed
|
||||
void checkError
|
||||
|
||||
LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
|
||||
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
|
||||
|
||||
// @ts-expect-error Replacement must provide A
|
||||
LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
|
||||
|
||||
// @ts-expect-error Node replacement must provide A
|
||||
const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]])
|
||||
void invalidNodeReplacement
|
||||
|
||||
// @ts-expect-error Replacement cannot introduce a new error
|
||||
LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
|
||||
|
||||
const invalidNodeErrorReplacement = () =>
|
||||
const replacements: LayerNode.Replacements = [a.replace(aLayer), a.replace(ab), failing.replace(a)]
|
||||
const replacement: LayerNode.Replacement = a.replace(Layer.mergeAll(aLayer, Layer.succeed(B, {})))
|
||||
LayerNode.compile(a, { replacements: [...replacements, replacement] })
|
||||
inputA.replace(a)
|
||||
a.replace(a)
|
||||
// @ts-expect-error Closed layer replacements must provide every source output
|
||||
ab.replace(aLayer)
|
||||
// @ts-expect-error Node replacements must provide every source output
|
||||
ab.replace(a)
|
||||
// @ts-expect-error Replacement must provide A
|
||||
a.replace(Layer.succeed(B, {}))
|
||||
// @ts-expect-error Node replacement must provide A
|
||||
a.replace(b)
|
||||
// @ts-expect-error Raw layers with inputs are not closed
|
||||
a.replace(Layer.effect(A, Effect.as(B, A.of({}))))
|
||||
// @ts-expect-error Replacement cannot introduce a new error
|
||||
a.replace(Layer.effect(A, Effect.fail(new OtherError())))
|
||||
// @ts-expect-error Node replacement cannot introduce a new error
|
||||
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
|
||||
void invalidNodeErrorReplacement
|
||||
a.replace(failing)
|
||||
// @ts-expect-error Existing errors do not authorize unrelated replacement errors
|
||||
failing.replace(Layer.effect(A, Effect.fail(new OtherError())))
|
||||
// @ts-expect-error Every alternative of a node replacement must supply A
|
||||
a.replace(flag ? a : b)
|
||||
// @ts-expect-error Every alternative of a raw-layer replacement must supply A
|
||||
a.replace(flag ? aLayer : Layer.succeed(B, {}))
|
||||
// @ts-expect-error A valid alternative cannot hide a new error in another alternative
|
||||
a.replace(flag ? a : failing)
|
||||
a.replace(flag ? a : ab)
|
||||
failing.replace(flag ? a : failing)
|
||||
// @ts-expect-error Storing replacements must not erase their validation
|
||||
const invalidStored: LayerNode.Replacements = [a.replace(b)]
|
||||
// @ts-expect-error Raw tuples cannot be stored as opaque replacements
|
||||
const rawStored: LayerNode.Replacements = [[a, aLayer]]
|
||||
// @ts-expect-error Raw tuples cannot be supplied to compile
|
||||
LayerNode.compile(a, { replacements: [[a, aLayer]] })
|
||||
// @ts-expect-error Replacements are not structurally forgeable
|
||||
const forged: LayerNode.Replacement = { source: a, target: a }
|
||||
// @ts-expect-error Groups are not replaceable nodes
|
||||
group.replace(a)
|
||||
// @ts-expect-error Groups cannot be replacement targets
|
||||
a.replace(group)
|
||||
// @ts-expect-error Groups cannot be widened to nodes
|
||||
const groupNode: LayerNode.Node<A | B, never, typeof tags.values.app> = group
|
||||
// @ts-expect-error Graphs are opaque
|
||||
const forgedGraph: LayerNode.Graph<A> = { name: "a" }
|
||||
|
||||
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
|
||||
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
|
||||
class TagC extends Context.Service<TagC, {}>()("test/TagC") {}
|
||||
const aContract: LayerNode.Node<A, never, typeof tags.values.app> = a
|
||||
aContract.replace(aLayer)
|
||||
// @ts-expect-error A method cannot be rebound to a declaration with a stronger contract
|
||||
a.replace.call(ab, aLayer)
|
||||
const detached = a.replace
|
||||
// @ts-expect-error Replacement authority requires its checked receiver
|
||||
detached(aLayer)
|
||||
// @ts-expect-error Output narrowing cannot forget B before replacement
|
||||
const narrowedOutput: LayerNode.Node<A, never, typeof tags.values.app> = ab
|
||||
// @ts-expect-error Output widening cannot add B before replacement
|
||||
const widenedOutput: LayerNode.Node<A | B, never, typeof tags.values.app> = a
|
||||
// @ts-expect-error Error widening cannot authorize a new replacement error
|
||||
const widenedError: LayerNode.Node<A, LayerError, typeof tags.values.app> = a
|
||||
// @ts-expect-error Error narrowing cannot forget an existing failure
|
||||
const narrowedError: LayerNode.Node<A, never, typeof tags.values.app> = failing
|
||||
// @ts-expect-error Tag widening cannot authorize replacement across tags
|
||||
const widenedTag: LayerNode.Node<A, never, LayerNode.Tag | undefined> = a
|
||||
const unionTag = LayerNode.unbound(A, tag)
|
||||
// @ts-expect-error Tag narrowing cannot forget a possible tag
|
||||
const narrowedTag: LayerNode.Node<A, never, typeof tags.values.app> = unionTag
|
||||
|
||||
const scopedTags = LayerNode.tags({ request: ["global"], global: [] })
|
||||
const request = scopedTags.make("request")
|
||||
const global = scopedTags.make("global")
|
||||
const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
|
||||
const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
|
||||
const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] })
|
||||
const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({})))
|
||||
const tagCLayer = Layer.effect(
|
||||
TagC,
|
||||
Effect.gen(function* () {
|
||||
yield* TagA
|
||||
yield* TagB
|
||||
return TagC.of({})
|
||||
}),
|
||||
)
|
||||
const outputProjection: LayerNode.Graph<A, never, typeof tags.values.app> = group
|
||||
// @ts-expect-error Graph output projection cannot invent a service
|
||||
const widenedGraph: LayerNode.Graph<A | B, never, typeof tags.values.app> = a
|
||||
// @ts-expect-error A projected Graph has no replacement authority
|
||||
outputProjection.replace(aLayer)
|
||||
|
||||
request({ service: TagB, layer: tagBLayer, deps: [globalA] })
|
||||
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] })
|
||||
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] })
|
||||
const choice = flag ? a : b
|
||||
// @ts-expect-error Choosing one dependency does not provide both services
|
||||
make({ service: C, layer: cLayer, deps: [choice] })
|
||||
// @ts-expect-error A conditional root promises only outputs present in every alternative
|
||||
LayerNode.compile(LayerNode.group([choice])) satisfies Layer.Layer<A | B>
|
||||
const conditional = make({ name: "conditional", layer: flag ? aLayer : Layer.succeed(B, {}), deps: [] })
|
||||
LayerNode.compile(conditional) satisfies Layer.Layer<never>
|
||||
// @ts-expect-error A conditional implementation does not acquire both branches
|
||||
LayerNode.compile(conditional) satisfies Layer.Layer<A | B>
|
||||
LayerNode.compile(LayerNode.group([flag ? a : ab])) satisfies Layer.Layer<A>
|
||||
const dynamic: Array<typeof a> = []
|
||||
// @ts-expect-error An unbounded array may contain no roots
|
||||
LayerNode.compile(LayerNode.group(dynamic)) satisfies Layer.Layer<A>
|
||||
|
||||
// @ts-expect-error Tag configuration can only reference declared tags
|
||||
LayerNode.tags({ request: ["missing"], global: [] })
|
||||
const decorated = b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.void)))
|
||||
LayerNode.compile(decorated) satisfies Layer.Layer<B>
|
||||
b.replace(decorated)
|
||||
// @ts-expect-error A layer mapper cannot be rebound to a weaker declaration
|
||||
ab.mapLayer.call(a, (layer) => layer)
|
||||
// @ts-expect-error mapLayer cannot add an input requirement
|
||||
b.mapLayer((layer) => layer.pipe(Layer.tap(() => C)))
|
||||
// @ts-expect-error mapLayer cannot grow the error channel
|
||||
b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.fail(new OtherError()))))
|
||||
// @ts-expect-error mapLayer cannot drop an output
|
||||
ab.mapLayer(() => aLayer)
|
||||
// @ts-expect-error Unbound declarations have no implementation to map
|
||||
inputA.mapLayer((layer: Layer.Layer<A>) => layer)
|
||||
|
||||
// @ts-expect-error An unrelated dependency cannot satisfy TagA
|
||||
request({ service: TagB, layer: tagBLayer, deps: [requestB] })
|
||||
const scopedTags = LayerNode.tags({ request: ["global"], global: [] })
|
||||
const request = scopedTags.make("request")
|
||||
const global = scopedTags.make("global")
|
||||
const globalA = global({ service: A, layer: aLayer, deps: [] })
|
||||
const requestA = request({ service: A, layer: aLayer, deps: [] })
|
||||
const requestB = request({ service: B, layer: Layer.succeed(B, {}), deps: [] })
|
||||
request({ service: B, layer: bLayer, deps: [globalA] })
|
||||
request({ service: C, layer: cLayer, deps: [globalA, requestB] })
|
||||
request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA, requestB])] })
|
||||
LayerNode.compile(LayerNode.group([globalA, requestB]), { shared: scopedTags.values.global }) satisfies Layer.Layer<
|
||||
A | B
|
||||
>
|
||||
// @ts-expect-error Tag configuration can only reference declared tags
|
||||
LayerNode.tags({ request: ["missing"], global: [] })
|
||||
// @ts-expect-error Shared tags must be branded
|
||||
LayerNode.compile(globalA, { shared: "global" })
|
||||
// @ts-expect-error Replacement targets must keep the source tag
|
||||
globalA.replace(requestA)
|
||||
// @ts-expect-error Replacement targets must keep the source tag in either direction
|
||||
requestA.replace(globalA)
|
||||
// @ts-expect-error Every alternative must keep the source tag
|
||||
globalA.replace(flag ? globalA : requestA)
|
||||
// @ts-expect-error Providing only A leaves B missing
|
||||
request({ service: C, layer: cLayer, deps: [globalA] })
|
||||
// @ts-expect-error Providing only B leaves A missing
|
||||
request({ service: C, layer: cLayer, deps: [requestB] })
|
||||
// @ts-expect-error Duplicate A providers still leave B missing
|
||||
request({ service: C, layer: cLayer, deps: [globalA, requestA] })
|
||||
// @ts-expect-error A group with only A still leaves B missing
|
||||
request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA])] })
|
||||
// @ts-expect-error Global cannot depend on request
|
||||
global({ service: B, layer: bLayer, deps: [requestA] })
|
||||
// @ts-expect-error Groups preserve their child tags
|
||||
global({ service: B, layer: bLayer, deps: [LayerNode.group([requestA])] })
|
||||
|
||||
// @ts-expect-error Providing only TagA leaves TagB missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [globalA] })
|
||||
const globalScopedA = makeGlobalNode({ service: A, layer: aLayer, deps: [] })
|
||||
const locationScopedA = makeLocationNode({ service: A, layer: aLayer, deps: [] })
|
||||
makeGlobalNode({ service: B, layer: bLayer, deps: [globalScopedA] })
|
||||
makeLocationNode({ service: B, layer: bLayer, deps: [globalScopedA] })
|
||||
makeLocationNode({ service: B, layer: bLayer, deps: [locationScopedA] })
|
||||
// @ts-expect-error Global nodes cannot depend on location nodes
|
||||
makeGlobalNode({ service: B, layer: bLayer, deps: [locationScopedA] })
|
||||
// @ts-expect-error B requires A
|
||||
makeLocationNode({ service: B, layer: bLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error Providing only TagB leaves TagA missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [requestB] })
|
||||
void [
|
||||
invalidStored,
|
||||
rawStored,
|
||||
forged,
|
||||
groupNode,
|
||||
forgedGraph,
|
||||
narrowedOutput,
|
||||
widenedOutput,
|
||||
widenedError,
|
||||
narrowedError,
|
||||
widenedTag,
|
||||
narrowedTag,
|
||||
widenedGraph,
|
||||
]
|
||||
}
|
||||
|
||||
// @ts-expect-error Duplicate TagA providers still leave TagB missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] })
|
||||
|
||||
// @ts-expect-error A group with only TagA still leaves TagB missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] })
|
||||
|
||||
// @ts-expect-error Global cannot depend on request
|
||||
global({ service: TagB, layer: tagBLayer, deps: [requestA] })
|
||||
|
||||
// @ts-expect-error Groups preserve their child tags
|
||||
global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] })
|
||||
|
||||
class ScopedA extends Context.Service<ScopedA, {}>()("test/ScopedA") {}
|
||||
class ScopedB extends Context.Service<ScopedB, {}>()("test/ScopedB") {}
|
||||
|
||||
const scopedA = Layer.succeed(ScopedA, ScopedA.of({}))
|
||||
const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({})))
|
||||
const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] })
|
||||
const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] })
|
||||
|
||||
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
|
||||
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
|
||||
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
|
||||
|
||||
// @ts-expect-error Global nodes cannot depend on location nodes
|
||||
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
|
||||
|
||||
// @ts-expect-error ScopedB requires ScopedA
|
||||
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] })
|
||||
|
||||
test("type exploration compiles", () => {})
|
||||
test("layer node type contracts compile", () => {
|
||||
void contracts
|
||||
})
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, Option } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
class Value extends Context.Service<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") {}
|
||||
class Database extends Context.Service<Database, { readonly name: string }>()("test/GraphDatabase") {}
|
||||
class Users extends Context.Service<Users, { readonly list: Effect.Effect<string[]> }>()("test/GraphUsers") {}
|
||||
class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }>()("test/GraphApp") {}
|
||||
class Memo extends Context.Service<Memo, Layer.MemoMap>()("test/LayerNodeMemo") {}
|
||||
class Support extends Context.Service<Support, {}>()("test/LayerNodeSupport") {}
|
||||
class Locations extends Context.Service<Locations, LayerMap.LayerMap<string, Value | Right, "failed location">>()(
|
||||
"test/LayerNodeLocations",
|
||||
) {}
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
|
||||
LayerNode.compile(root, replacements) as Layer.Layer<A, E>
|
||||
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
|
||||
const greetingLayer = Layer.effect(
|
||||
Greeting,
|
||||
@@ -23,240 +25,443 @@ const value = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
|
||||
describe("layer node", () => {
|
||||
test("builds an untagged graph", async () => {
|
||||
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(LayerNode.compile(LayerNode.group([greeting]))),
|
||||
it.effect("builds an untagged graph", () =>
|
||||
Effect.gen(function* () {
|
||||
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const result = yield* Greeting.pipe(Effect.provide(LayerNode.compile(LayerNode.group([greeting]))))
|
||||
expect(result.value).toBe("hello production")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes roots but hides transitive dependencies", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting])))
|
||||
expect(Context.get(context, Greeting).value).toBe("hello production")
|
||||
expect(Option.isNone(Context.getOption(context, Value))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces exact declarations, not sibling names or native layer identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const sibling = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const target = make({ name: "different-name", layer: Layer.succeed(Value, { value: "replaced" }), deps: [] })
|
||||
const left = make({
|
||||
service: Left,
|
||||
layer: Layer.effect(
|
||||
Left,
|
||||
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||
),
|
||||
deps: [value],
|
||||
})
|
||||
const right = make({
|
||||
service: Right,
|
||||
layer: Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
),
|
||||
deps: [sibling],
|
||||
})
|
||||
const context = yield* Layer.build(
|
||||
LayerNode.compile(LayerNode.group([left, right]), { replacements: [value.replace(target)] }),
|
||||
)
|
||||
expect(Context.get(context, Left).value).toBe("replaced")
|
||||
expect(Context.get(context, Right).value).toBe("production")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires reachable unbound nodes to be replaced", () =>
|
||||
Effect.gen(function* () {
|
||||
const unbound = LayerNode.unbound(Value, tags.values.app)
|
||||
const root = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
|
||||
expect(() => LayerNode.compile(root)).toThrow("Unbound layer node: test/LayerNodeValue")
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(LayerNode.compile(root, { replacements: [unbound.replace(value)] })),
|
||||
)
|
||||
expect(result.value).toBe("hello production")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces every use of a declaration with a stored closed-layer replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const replacements: LayerNode.Replacements = [value.replace(Layer.succeed(Value, { value: "replacement" }))]
|
||||
const right = make({
|
||||
service: Right,
|
||||
layer: Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
),
|
||||
deps: [value],
|
||||
})
|
||||
const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting, right]), { replacements }))
|
||||
expect(Context.get(context, Greeting).value).toBe("hello replacement")
|
||||
expect(Context.get(context, Right).value).toBe("replacement")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the last replacement and ignores unreachable unbound defaults and cycles", () =>
|
||||
Effect.gen(function* () {
|
||||
const unbound = LayerNode.unbound(Value, tags.values.app)
|
||||
const unused = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(greeting, {
|
||||
replacements: [
|
||||
value.replace(unbound),
|
||||
unbound.replace(unused),
|
||||
unused.replace(unbound),
|
||||
value.replace(Layer.succeed(Value, { value: "last" })),
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result.value).toBe("hello last")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves target chains independently of replacement order and treats self-replacement as identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const middle = make({ service: Value, layer: Layer.succeed(Value, { value: "middle" }), deps: [] })
|
||||
const target = make({ service: Value, layer: Layer.succeed(Value, { value: "target" }), deps: [] })
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(greeting, {
|
||||
replacements: [target.replace(target), middle.replace(target), value.replace(middle)],
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result.value).toBe("hello target")
|
||||
}),
|
||||
)
|
||||
|
||||
test("rejects reachable replacement and dependency cycles", () => {
|
||||
const other = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
expect(() => LayerNode.compile(greeting, { replacements: [value.replace(other), other.replace(value)] })).toThrow(
|
||||
"Cycle detected in layer graph",
|
||||
)
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("builds a dependency graph", async () => {
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting]))))
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("exposes roots but hides transitive dependencies", () => {
|
||||
const layer = build(LayerNode.group([greeting]))
|
||||
const check: Layer.Layer<Greeting> = layer
|
||||
void check
|
||||
})
|
||||
|
||||
test("preserves branch-specific implementations across roots", async () => {
|
||||
const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
|
||||
const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
|
||||
const leftLayer = Layer.effect(
|
||||
Left,
|
||||
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||
)
|
||||
const rightLayer = Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
)
|
||||
const left = make({ service: Left, layer: leftLayer, deps: [firstValue] })
|
||||
const right = make({ service: Right, layer: rightLayer, deps: [secondValue] })
|
||||
const layer = build(LayerNode.group([left, right]))
|
||||
const program = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toEqual(["first", "second"])
|
||||
})
|
||||
|
||||
test("requires unbound nodes to be replaced before compilation", async () => {
|
||||
const unbound = LayerNode.unbound(Value, tags.values.app)
|
||||
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
|
||||
const tree = LayerNode.group([greeting])
|
||||
expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
|
||||
const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer<Greeting>
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("replaces a node with a closed layer", async () => {
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])),
|
||||
)
|
||||
expect(await Effect.runPromise(program)).toBe("hello simulation")
|
||||
})
|
||||
|
||||
test("replaces every use of the same layer", async () => {
|
||||
const leftLayer = Layer.effect(
|
||||
Left,
|
||||
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||
)
|
||||
const rightLayer = Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
)
|
||||
const left = make({ service: Left, layer: leftLayer, deps: [value] })
|
||||
const right = make({ service: Right, layer: rightLayer, deps: [value] })
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
|
||||
const layer = build(LayerNode.group([left, right]), [[value, replacement]])
|
||||
const program = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"])
|
||||
})
|
||||
|
||||
test("does not acquire an unused replacement", async () => {
|
||||
let acquisitions = 0
|
||||
const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] })
|
||||
const replacement = Layer.effect(
|
||||
Left,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Left.of({ value: "replacement" })
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])),
|
||||
),
|
||||
)
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
|
||||
test("replaces a node without acquiring its dependencies", async () => {
|
||||
let acquisitions = 0
|
||||
const dependencyLayer = Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Value.of({ value: "dependency" })
|
||||
}),
|
||||
)
|
||||
const dependency = make({ service: Value, layer: dependencyLayer, deps: [] })
|
||||
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
|
||||
const replacement = make({
|
||||
service: Greeting,
|
||||
layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([original]), [[original, replacement]])),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("replacement")
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
|
||||
test("applies later replacements inside earlier replacement nodes", async () => {
|
||||
const original = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(
|
||||
build(LayerNode.group([original]), [
|
||||
[original, replacement],
|
||||
[value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))],
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
|
||||
})
|
||||
|
||||
test("hoists and compiles tagged graphs", async () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const database = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
|
||||
deps: [],
|
||||
})
|
||||
const users = location({
|
||||
service: Users,
|
||||
const dependent = make({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Users,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* Database
|
||||
return Users.of({ list: Effect.succeed([db.name]) })
|
||||
}),
|
||||
Value,
|
||||
Effect.map(Greeting, (item) => Value.of({ value: item.value })),
|
||||
),
|
||||
deps: [database],
|
||||
deps: [greeting],
|
||||
})
|
||||
const app = location({
|
||||
service: App,
|
||||
layer: Layer.effect(
|
||||
App,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Users
|
||||
return App.of({ run: service.list })
|
||||
}),
|
||||
),
|
||||
deps: [users],
|
||||
})
|
||||
|
||||
const result = LayerNode.hoist(LayerNode.group([app]), tags.values.global)
|
||||
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
|
||||
kind: "group",
|
||||
dependencies: [],
|
||||
})
|
||||
expect(result.hoisted.dependencies).toEqual([database])
|
||||
|
||||
const layer = LayerNode.compile(result.node).pipe(
|
||||
Layer.provide(LayerNode.compile(result.hoisted)),
|
||||
) as unknown as Layer.Layer<App>
|
||||
const program = Effect.gen(function* () {
|
||||
const app = yield* App
|
||||
return yield* app.run
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
expect(await Effect.runPromise(program)).toEqual(["Alice"])
|
||||
})
|
||||
|
||||
test("rejects conflicting hoisted implementations", () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const first = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "first" })),
|
||||
deps: [],
|
||||
})
|
||||
const second = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "second" })),
|
||||
deps: [],
|
||||
})
|
||||
const left = location({
|
||||
service: Users,
|
||||
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
|
||||
deps: [first],
|
||||
})
|
||||
const right = location({
|
||||
service: App,
|
||||
layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))),
|
||||
deps: [second],
|
||||
})
|
||||
|
||||
expect(() => LayerNode.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow(
|
||||
"Tag global has conflicting implementations for test/GraphDatabase",
|
||||
expect(() => LayerNode.compile(greeting, { replacements: [value.replace(dependent)] })).toThrow(
|
||||
"Cycle detected in layer graph",
|
||||
)
|
||||
})
|
||||
|
||||
test("treats dependency groups as transparent while hoisting", () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const database = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
|
||||
deps: [],
|
||||
})
|
||||
const users = location({
|
||||
service: Users,
|
||||
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
|
||||
deps: [LayerNode.group([database])],
|
||||
})
|
||||
const result = LayerNode.hoist(LayerNode.group([users]), tags.values.global)
|
||||
it.effect("does not acquire replaced dependencies or unused replacement targets", () =>
|
||||
Effect.gen(function* () {
|
||||
const acquired: string[] = []
|
||||
const dependency = make({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquired.push("old dependency")
|
||||
return Value.of({ value: "dependency" })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(original, {
|
||||
replacements: [
|
||||
original.replace(Layer.succeed(Greeting, { value: "replacement" })),
|
||||
value.replace(
|
||||
Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquired.push("unused target")
|
||||
return Value.of({ value: "unused" })
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result.value).toBe("replacement")
|
||||
expect(acquired).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
|
||||
kind: "group",
|
||||
dependencies: [],
|
||||
})
|
||||
it.effect("mapLayer preserves dependency wiring and replacement traversal", () =>
|
||||
Effect.gen(function* () {
|
||||
const acquired: string[] = []
|
||||
const decorated = greeting.mapLayer((layer) =>
|
||||
layer.pipe(
|
||||
Layer.tap((context) =>
|
||||
Effect.sync(() => {
|
||||
acquired.push(Context.get(context, Greeting).value)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(greeting, {
|
||||
replacements: [
|
||||
greeting.replace(decorated),
|
||||
value.replace(Layer.succeed(Value, { value: "mapped dependency" })),
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result.value).toBe("hello mapped dependency")
|
||||
expect(acquired).toEqual(["hello mapped dependency"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("memoizes shared wiring instead of expanding a diamond into a tree", () =>
|
||||
Effect.gen(function* () {
|
||||
const acquisitions: string[] = []
|
||||
const shared = value.mapLayer((layer) =>
|
||||
layer.pipe(Layer.tap(() => Effect.sync(() => acquisitions.push("shared")))),
|
||||
)
|
||||
const left = make({ name: "left", layer: Layer.empty, deps: [shared] })
|
||||
const right = make({ name: "right", layer: Layer.empty, deps: [shared] })
|
||||
yield* Layer.build(LayerNode.compile(LayerNode.group([left, right])))
|
||||
expect(acquisitions).toEqual(["shared"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves declared memo-service outputs rather than filtering them as build metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const supplied = yield* Layer.makeMemoMap
|
||||
const memo = make({
|
||||
service: Layer.CurrentMemoMap,
|
||||
layer: Layer.succeed(Layer.CurrentMemoMap, supplied),
|
||||
deps: [],
|
||||
})
|
||||
const observer = make({ service: Memo, layer: Layer.effect(Memo, Layer.CurrentMemoMap), deps: [memo] })
|
||||
expect(yield* Memo.pipe(Effect.provide(LayerNode.compile(observer)))).toBe(supplied)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects one implementation wired to different effective dependencies in either memo domain", () =>
|
||||
Effect.gen(function* () {
|
||||
const other = make({ service: Value, layer: Layer.succeed(Value, { value: "other" }), deps: [] })
|
||||
const sibling = make({ service: Greeting, layer: greetingLayer, deps: [other] })
|
||||
const root = LayerNode.group([greeting, sibling])
|
||||
expect(() => LayerNode.compile(root)).toThrow("wired to different dependencies")
|
||||
expect(() => LayerNode.compile(root, { shared: tags.values.app })).toThrow("wired to different dependencies")
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(LayerNode.compile(root, { replacements: [value.replace(other)] })),
|
||||
)
|
||||
expect(result.value).toBe("hello other")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts dependencies in parallel and nested group roots in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const valueStarted = yield* Deferred.make<void>()
|
||||
const greetingStarted = yield* Deferred.make<void>()
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const events: string[] = []
|
||||
const value = make({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Value,
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(valueStarted, undefined)
|
||||
yield* Deferred.await(greetingStarted)
|
||||
return Value.of({ value: "value" })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const greeting = make({
|
||||
service: Greeting,
|
||||
layer: Layer.effect(
|
||||
Greeting,
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(greetingStarted, undefined)
|
||||
yield* Deferred.await(valueStarted)
|
||||
return Greeting.of({ value: "greeting" })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const first = make({
|
||||
service: Left,
|
||||
layer: Layer.effect(
|
||||
Left,
|
||||
Effect.gen(function* () {
|
||||
yield* Value
|
||||
yield* Greeting
|
||||
events.push("first started")
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(releaseFirst)
|
||||
events.push("first finished")
|
||||
return Left.of({ value: "first" })
|
||||
}),
|
||||
),
|
||||
deps: [value, greeting],
|
||||
})
|
||||
const second = make({
|
||||
service: Right,
|
||||
layer: Layer.effect(
|
||||
Right,
|
||||
Effect.sync(() => {
|
||||
expect(events).toEqual(["first started", "first finished"])
|
||||
events.push("second started")
|
||||
return Right.of({ value: "second" })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const fiber = yield* Layer.build(LayerNode.compile(LayerNode.group([LayerNode.group([first]), second]))).pipe(
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.await(firstStarted)
|
||||
expect(events).toEqual(["first started"])
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
const context = yield* Fiber.join(fiber)
|
||||
expect(events).toEqual(["first started", "first finished", "second started"])
|
||||
expect(Context.get(context, Left).value).toBe("first")
|
||||
expect(Context.get(context, Right).value).toBe("second")
|
||||
}),
|
||||
)
|
||||
;[false, true].forEach((topLevel) => {
|
||||
it.effect(
|
||||
`LayerMap isolates builds and retains resources ${topLevel ? "with" : "without"} a top-level global owner`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const acquired = { global: 0, local: 0, support: 0 }
|
||||
const released: string[] = []
|
||||
const startup: string[] = []
|
||||
yield* Effect.gen(function* () {
|
||||
const memoMap = yield* Layer.makeMemoMap
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const support = LayerNode.make({
|
||||
service: Support,
|
||||
layer: Layer.effect(
|
||||
Support,
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
acquired.support++
|
||||
return Support.of({})
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
released.push("support")
|
||||
}),
|
||||
),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const value = global({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Value,
|
||||
Effect.andThen(
|
||||
Support,
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
startup.push("global")
|
||||
return Value.of({ value: `global-${++acquired.global}` })
|
||||
}),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
released.push(value.value)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
deps: [support],
|
||||
})
|
||||
const local = location({
|
||||
service: Greeting,
|
||||
layer: Layer.effect(
|
||||
Greeting,
|
||||
Effect.gen(function* () {
|
||||
yield* Value
|
||||
return yield* Effect.acquireRelease(
|
||||
Effect.sync(() => Greeting.of({ value: `local-${++acquired.local}` })),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
released.push(value.value)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
deps: [LayerNode.group([value])],
|
||||
})
|
||||
const root = location({
|
||||
service: Right,
|
||||
layer: Layer.effect(
|
||||
Right,
|
||||
Effect.gen(function* () {
|
||||
const local = yield* Greeting
|
||||
if (local.value === "local-2") return yield* Effect.fail("failed location" as const)
|
||||
return Right.of(local)
|
||||
}),
|
||||
),
|
||||
deps: [local],
|
||||
})
|
||||
// Every key builds the same compiled Layer, not a new graph per lookup.
|
||||
const compiled = LayerNode.compile(LayerNode.group([value, root]), { shared: tags.values.global })
|
||||
const locations = location({
|
||||
service: Locations,
|
||||
layer: Layer.effect(
|
||||
Locations,
|
||||
Effect.gen(function* () {
|
||||
startup.push("map")
|
||||
expect(Option.getOrUndefined(yield* Effect.serviceOption(Layer.CurrentMemoMap))).toBe(memoMap)
|
||||
return yield* LayerMap.make((_: string) => compiled, { idleTimeToLive: Duration.infinity })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const scope = yield* Effect.scope
|
||||
const context = yield* Layer.buildWithMemoMap(
|
||||
LayerNode.compile(LayerNode.group([locations, ...(topLevel ? [value] : [])]), {
|
||||
shared: tags.values.global,
|
||||
}),
|
||||
memoMap,
|
||||
scope,
|
||||
)
|
||||
expect(startup).toEqual(topLevel ? ["map", "global"] : ["map"])
|
||||
const map = Context.get(context, Locations)
|
||||
const first = yield* map.contextEffect("first").pipe(Effect.scoped)
|
||||
expect(Option.getOrUndefined(Context.getOption(context, Value))).toBe(
|
||||
topLevel ? Context.get(first, Value) : undefined,
|
||||
)
|
||||
expect(Option.isNone(Context.getOption(first, Greeting))).toBe(true)
|
||||
expect(Context.get(first, Right).value).toBe("local-1")
|
||||
|
||||
expect(yield* map.contextEffect("failed").pipe(Effect.scoped, Effect.flip)).toBe("failed location")
|
||||
expect(released).toEqual(["local-2"])
|
||||
expect(Context.get(yield* map.contextEffect("first").pipe(Effect.scoped), Right)).toBe(
|
||||
Context.get(first, Right),
|
||||
)
|
||||
|
||||
const second = yield* map.contextEffect("second").pipe(Effect.scoped)
|
||||
expect(Context.get(second, Value)).toBe(Context.get(first, Value))
|
||||
expect(Context.get(second, Right)).not.toBe(Context.get(first, Right))
|
||||
expect(acquired).toEqual({ global: 1, local: 3, support: 1 })
|
||||
|
||||
yield* map.invalidate("first")
|
||||
expect(released).toEqual(["local-2", "local-1"])
|
||||
expect(Context.get(yield* map.contextEffect("second").pipe(Effect.scoped), Right)).toBe(
|
||||
Context.get(second, Right),
|
||||
)
|
||||
const rebuilt = yield* map.contextEffect("first").pipe(Effect.scoped)
|
||||
expect(Context.get(rebuilt, Right).value).toBe("local-4")
|
||||
expect(Context.get(rebuilt, Value)).toBe(Context.get(first, Value))
|
||||
expect(acquired).toEqual({ global: 1, local: 4, support: 1 })
|
||||
expect(released).not.toContain("global-1")
|
||||
}).pipe(Effect.scoped)
|
||||
expect(released.toSorted()).toEqual(["global-1", "local-1", "local-2", "local-3", "local-4", "support"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer, LayerMap, Option } from "effect"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { buildLocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../../fixture/tmpdir"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
class Value extends Context.Service<Value, { readonly value: string }>()("test/TagValue") {}
|
||||
class Result extends Context.Service<Result, { readonly value: string }>()("test/TagResult") {}
|
||||
class CycleA extends Context.Service<CycleA, {}>()("test/NodeBuildA") {}
|
||||
class CycleB extends Context.Service<CycleB, { readonly directory: AbsolutePath }>()("test/NodeBuildB") {}
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("node build", () => {
|
||||
test("does not build a location service map when the graph does not require it", async () => {
|
||||
const result = Node.makeGlobalNode({
|
||||
@@ -31,7 +34,7 @@ describe("node build", () => {
|
||||
expect(await Effect.runPromise(program)).toBe("plain")
|
||||
})
|
||||
|
||||
test("detects cycles through a replaced location service map", async () => {
|
||||
test("detects cycles through a replaced location service map", () => {
|
||||
const a = Node.makeGlobalNode({
|
||||
service: CycleA,
|
||||
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
|
||||
@@ -45,31 +48,49 @@ describe("node build", () => {
|
||||
),
|
||||
deps: [a],
|
||||
})
|
||||
const mapLayer = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* CycleB
|
||||
return yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: service.directory, canonical: service.directory },
|
||||
}),
|
||||
),
|
||||
{ idleTimeToLive: "1 minute" },
|
||||
)
|
||||
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>,
|
||||
)
|
||||
const mapLayer = Layer.unwrap(Effect.as(CycleB, buildLocationServiceMap()))
|
||||
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
|
||||
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow(
|
||||
"Cycle detected in layer tree",
|
||||
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [LocationServiceMap.node.replace(map)])).toThrow(
|
||||
"Cycle detected in layer graph",
|
||||
)
|
||||
})
|
||||
|
||||
test("shares top-level project with location services", async () => {
|
||||
it.effect("supplies the lazy map when only a replacement introduces the dependency", () =>
|
||||
Effect.gen(function* () {
|
||||
const original = Node.makeGlobalNode({
|
||||
service: Result,
|
||||
layer: Layer.succeed(Result, { value: "original" }),
|
||||
deps: [],
|
||||
})
|
||||
const replacement = Node.makeGlobalNode({
|
||||
service: Result,
|
||||
layer: Layer.effect(Result, Effect.as(LocationServiceMap.Service, Result.of({ value: "has map" }))),
|
||||
deps: [LocationServiceMap.node],
|
||||
})
|
||||
const result = yield* Result.pipe(Effect.provide(AppNodeBuilder.build(original, [original.replace(replacement)])))
|
||||
expect(result.value).toBe("has map")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("caller replacements override the lazy default without building any locations", () =>
|
||||
Effect.gen(function* () {
|
||||
const acquisitions: string[] = []
|
||||
const override = buildLocationServiceMap().pipe(
|
||||
Layer.tap(() =>
|
||||
Effect.sync(() => {
|
||||
acquisitions.push("caller map")
|
||||
}),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LocationServiceMap.node, [LocationServiceMap.node.replace(override)]),
|
||||
)
|
||||
expect(Context.get(context, LocationServiceMap.Service)).toBeDefined()
|
||||
expect(acquisitions).toEqual(["caller map"])
|
||||
}),
|
||||
)
|
||||
|
||||
test("shares top-level project even when the location service map is built first", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let acquisitions = 0
|
||||
const projectLayer = Layer.effect(
|
||||
@@ -84,8 +105,8 @@ describe("node build", () => {
|
||||
}),
|
||||
)
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
|
||||
[Project.node, projectLayer],
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Project.node]), [
|
||||
Project.node.replace(projectLayer),
|
||||
])
|
||||
const program = Effect.gen(function* () {
|
||||
yield* Project.Service
|
||||
|
||||
@@ -21,8 +21,8 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
[Location.node, activeLocation],
|
||||
[Environment.node, transformEnvironmentFiles(transformFiles)],
|
||||
Location.node.replace(activeLocation),
|
||||
Environment.node.replace(transformEnvironmentFiles(transformFiles)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,16 +77,15 @@ describe("FileSystemSearch", () => {
|
||||
workspaceID: Workspace.ID.make("wrk_test"),
|
||||
})
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(ref, { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
|
||||
),
|
||||
Ripgrep.node.replace(ripgrepStub("remote.ts", (input) => (observed = input))),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -103,8 +102,7 @@ describe("FileSystemSearch", () => {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
@@ -114,8 +112,8 @@ describe("FileSystemSearch", () => {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
),
|
||||
Ripgrep.node.replace(ripgrepStub("src/index.ts", (input) => (observed = input))),
|
||||
])
|
||||
yield* Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
@@ -137,17 +135,15 @@ describe("FileSystemSearch", () => {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
),
|
||||
Ripgrep.node.replace(
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
@@ -169,7 +165,7 @@ describe("FileSystemSearch", () => {
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -208,17 +204,15 @@ describe("FileSystemSearch", () => {
|
||||
(value) => Effect.sync(() => value.mockRestore()),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
),
|
||||
Ripgrep.node.replace(
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
@@ -234,7 +228,7 @@ describe("FileSystemSearch", () => {
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } fr
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigLocationWatcherPlugin } from "@opencode-ai/core/config/plugin/location-watcher"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeLocationNode, type LocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -129,7 +129,7 @@ function provide(
|
||||
vcs?: Location.Interface["vcs"],
|
||||
watcher?: Layer.Layer<Watcher.Service>,
|
||||
config: Layer.Layer<Config.Service> = configLayer,
|
||||
plugins: LocationNode<PluginSupervisor.Service> = pluginNode,
|
||||
plugins: typeof pluginNode = pluginNode,
|
||||
) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -138,10 +138,10 @@ function provide(
|
||||
const built = AppNodeBuilder.build(
|
||||
LayerNode.group([LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]),
|
||||
[
|
||||
[Config.node, config],
|
||||
[Location.node, locationLayer],
|
||||
[PluginSupervisor.node, plugins],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
Config.node.replace(config),
|
||||
Location.node.replace(locationLayer),
|
||||
PluginSupervisor.node.replace(plugins),
|
||||
...(watcher ? ([Watcher.node.replace(watcher)] as const) : []),
|
||||
],
|
||||
)
|
||||
return Effect.provide(built)
|
||||
@@ -154,7 +154,7 @@ function withTmp<A, E, R>(
|
||||
init?: (directory: string) => Promise<void>
|
||||
watcher?: Layer.Layer<Watcher.Service>
|
||||
config?: Layer.Layer<Config.Service>
|
||||
plugins?: LocationNode<PluginSupervisor.Service>
|
||||
plugins?: typeof pluginNode
|
||||
},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
|
||||
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
|
||||
log: os.tmpdir(),
|
||||
})
|
||||
|
||||
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
const testLayer = LayerNode.compile(EffectFlock.node, { replacements: [Global.node.replace(testGlobal)] })
|
||||
|
||||
async function job() {
|
||||
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
|
||||
|
||||
@@ -25,9 +25,9 @@ export const promptLocationNode = makeGlobalNode({
|
||||
SessionPrompt.layer.pipe(
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), [
|
||||
[Bus.node, Layer.succeed(Bus.Service, bus)],
|
||||
]),
|
||||
LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), {
|
||||
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
|
||||
}),
|
||||
Layer.succeed(FSUtil.Service, fs),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
),
|
||||
|
||||
@@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
@@ -34,7 +34,7 @@ const instances = Layer.effect(
|
||||
(ref: Location.Ref) =>
|
||||
Instance.layer(ref, {
|
||||
plugins: path.basename(ref.directory) === "thread-a" ? [agentPlugin("thread-a-plugin", "thread-a-agent")] : [],
|
||||
replacements: [[Global.node, tempGlobalLayer]],
|
||||
replacements: [Global.node.replace(tempGlobalLayer)],
|
||||
}),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
),
|
||||
@@ -42,8 +42,8 @@ const instances = Layer.effect(
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[LocationServiceMap.node, instances],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,14 +23,13 @@ import { Bus } from "../src/bus"
|
||||
// Config the host hands the vanilla instance explicitly: a value and an
|
||||
// explicit plugin removal, both of which must survive discovery: false.
|
||||
const hostConfig: LayerNode.Replacements = [
|
||||
[
|
||||
Config.node,
|
||||
Config.node.replace(
|
||||
Config.configured({
|
||||
project: false,
|
||||
global: false,
|
||||
content: JSON.stringify({ shell: "vanilla-host", plugins: ["-opencode.tool.shell"] }),
|
||||
}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
// Same directory contents, two instances: one vanilla, one with discovery.
|
||||
@@ -43,7 +42,7 @@ const instances = Layer.effect(
|
||||
// "bare" exercises the vanilla defaults themselves: no caller Config.
|
||||
discovery: name !== "vanilla" && name !== "bare",
|
||||
// Caller replacements win over the vanilla defaults.
|
||||
replacements: [[Global.node, tempGlobalLayer], ...(name === "vanilla" ? hostConfig : [])],
|
||||
replacements: [Global.node.replace(tempGlobalLayer), ...(name === "vanilla" ? hostConfig : [])],
|
||||
})
|
||||
},
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
@@ -52,8 +51,8 @@ const instances = Layer.effect(
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[LocationServiceMap.node, instances],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -33,19 +33,18 @@ const instructionLayer = (input: {
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
|
||||
[
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
|
||||
[
|
||||
Global.node,
|
||||
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
|
||||
Global.node.replace(
|
||||
input.config || input.home
|
||||
? Global.layerWith({
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
...(input.home ? { home: input.home } : {}),
|
||||
})
|
||||
: tempGlobalLayer,
|
||||
],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
[Watcher.node, watcher],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
),
|
||||
Location.node.replace(input.locationServiceLayer),
|
||||
Watcher.node.replace(watcher),
|
||||
...(input.filesystemLayer ? [FSUtil.node.replace(input.filesystemLayer)] : []),
|
||||
],
|
||||
),
|
||||
watcher,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ const locationLayer = Layer.succeed(
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(InstructionBuiltIns.node, [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
|
||||
Location.node.replace(locationLayer),
|
||||
Global.node.replace(Global.layerWith({ config: temporary, tmp: temporary })),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const failingCredentialNode = makeGlobalNode({
|
||||
deps: [],
|
||||
})
|
||||
const failingIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [[Credential.node, failingCredentialNode]]),
|
||||
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [Credential.node.replace(failingCredentialNode)]),
|
||||
)
|
||||
|
||||
function eventually<A, E, R>(
|
||||
|
||||
@@ -13,15 +13,16 @@ import { it } from "./lib/effect"
|
||||
|
||||
const provide = (directory: string, workspaceID?: Workspace.ID) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(FileSystem.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
|
||||
LayerNode.compile(FileSystem.node, {
|
||||
replacements: [
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
)
|
||||
|
||||
const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
|
||||
@@ -51,12 +51,12 @@ import { Tool } from "../src/tool"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
const itWithSdk = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
const activityLocations = Layer.effect(
|
||||
@@ -77,7 +77,7 @@ const activityLocations = Layer.effect(
|
||||
)
|
||||
const itWithActivity = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]), [
|
||||
[LocationServiceMap.node, activityLocations],
|
||||
LocationServiceMap.node.replace(activityLocations),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,20 +13,21 @@ import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string, projectDirectory = directory) {
|
||||
return Effect.provide(
|
||||
LayerNode.compile(LocationMutation.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory) },
|
||||
LayerNode.compile(LocationMutation.node, {
|
||||
replacements: [
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory) },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const projectLayer = Layer.succeed(
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
|
||||
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [Project.node.replace(projectLayer)]))
|
||||
|
||||
describe("Location", () => {
|
||||
it.effect("resolves the current project and vcs information", () =>
|
||||
|
||||
@@ -23,13 +23,12 @@ const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.Ser
|
||||
|
||||
const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) =>
|
||||
AppNodeBuilder.build(McpInstructions.node, [
|
||||
[
|
||||
Mcp.node,
|
||||
Mcp.node.replace(
|
||||
Layer.mock(Mcp.Service, {
|
||||
instructions: () => Effect.succeed(catalog()),
|
||||
tools: () => Effect.succeed(tools()),
|
||||
}),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
describe("McpInstructions", () => {
|
||||
|
||||
@@ -376,10 +376,10 @@ const permissions = Layer.mock(Permission.Service, {
|
||||
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
|
||||
[Mcp.node, mcp],
|
||||
[Permission.node, permissions],
|
||||
[Bus.node, events],
|
||||
[Image.node, imagePassthrough],
|
||||
Mcp.node.replace(mcp),
|
||||
Permission.node.replace(permissions),
|
||||
Bus.node.replace(events),
|
||||
Image.node.replace(imagePassthrough),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -1207,6 +1207,68 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live(
|
||||
"merges MCP defaults into the winning configured server without changing runtime overrides",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
timeout: { startup: 10, catalog: 20, execution: 30 },
|
||||
servers: {
|
||||
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
timeout: { catalog: 40 },
|
||||
servers: {
|
||||
resources: { type: "local", command: ["later"], disabled: true, timeout: { startup: 50 } },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]
|
||||
const original = JSON.stringify(entries)
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
const check = yield* service.transform((draft) => {
|
||||
expect(draft.get("resources")).toEqual({
|
||||
type: "local",
|
||||
command: ["later"],
|
||||
disabled: true,
|
||||
timeout: { startup: 50, catalog: 40, execution: 30 },
|
||||
})
|
||||
})
|
||||
yield* check.dispose
|
||||
const runtime = {
|
||||
type: "local",
|
||||
command: ["runtime"],
|
||||
disabled: true,
|
||||
timeout: { catalog: 60 },
|
||||
} satisfies ConfigMCP.Local
|
||||
yield* service.add("resources", runtime)
|
||||
yield* service.reload()
|
||||
yield* service.transform((draft) => {
|
||||
expect(draft.get("resources")).toEqual(runtime)
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer("https://unused.example", undefined, undefined, {
|
||||
entries: () => Effect.succeed(entries),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(JSON.stringify(entries)).toBe(original)
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
|
||||
"manages live MCP servers entirely through scoped transforms",
|
||||
() =>
|
||||
@@ -1583,8 +1645,7 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
|
||||
Effect.provide(
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
[
|
||||
Mcp.node,
|
||||
Mcp.node.replace(
|
||||
Layer.mock(Mcp.Service, {
|
||||
tools: () => Ref.get(catalog),
|
||||
callTool: (input) =>
|
||||
@@ -1597,9 +1658,9 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
|
||||
[Image.node, imagePassthrough],
|
||||
),
|
||||
Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })),
|
||||
Image.node.replace(imagePassthrough),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -1626,8 +1687,7 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
[
|
||||
Mcp.node,
|
||||
Mcp.node.replace(
|
||||
Layer.mock(Mcp.Service, {
|
||||
tools: () =>
|
||||
Effect.sync(() => [
|
||||
@@ -1639,9 +1699,9 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
],
|
||||
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
|
||||
[Image.node, imagePassthrough],
|
||||
),
|
||||
Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })),
|
||||
Image.node.replace(imagePassthrough),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -182,9 +182,9 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
|
||||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [
|
||||
[ModelsDev.node, ModelsDev.configured(options)],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeMockKV(cache)],
|
||||
ModelsDev.node.replace(ModelsDev.configured(options)),
|
||||
LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
|
||||
KV.node.replace(makeMockKV(cache)),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -312,9 +312,9 @@ describe("ModelsDev Service", () => {
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const layer = Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeFailingWriteKV(cache)],
|
||||
ModelsDev.node.replace(ModelsDev.configured({ fetch: true, snapshot: false })),
|
||||
LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
|
||||
KV.node.replace(makeFailingWriteKV(cache)),
|
||||
]),
|
||||
)
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
|
||||
|
||||
@@ -20,7 +20,7 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
||||
)
|
||||
|
||||
const npmLayer = (cache: string) =>
|
||||
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
|
||||
AppNodeBuilder.build(Npm.node, [Global.node.replace(Global.layerWith({ cache, state: path.join(cache, "state") }))])
|
||||
|
||||
async function createGitFixture(directory: string) {
|
||||
const repository = path.join(directory, "repository")
|
||||
|
||||
@@ -37,7 +37,7 @@ const it = testEffect(
|
||||
PluginHooks.node,
|
||||
Permission.node,
|
||||
]),
|
||||
[[Location.node, current]],
|
||||
[Location.node.replace(current)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Effect } from "effect"
|
||||
import { PluginHooks } from "../src/plugin/hooks"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const layer = PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>
|
||||
const it = testEffect(layer)
|
||||
const it = testEffect(LayerNode.compile(PluginHooks.node))
|
||||
|
||||
describe("PluginHooks", () => {
|
||||
it.effect("registers scoped session hooks and triggers them sequentially", () =>
|
||||
|
||||
@@ -27,8 +27,8 @@ const locationLayer = Layer.succeed(
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Location.node, locationLayer],
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
Location.node.replace(locationLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -86,12 +86,14 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
]),
|
||||
[
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
[Config.node, Config.testLayer()],
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Generate.node, generateLayer],
|
||||
[Permission.node, permissionLayer],
|
||||
],
|
||||
{
|
||||
replacements: [
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Npm.node.replace(npmLayer),
|
||||
Config.node.replace(Config.testLayer()),
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
Generate.node.replace(generateLayer),
|
||||
Permission.node.replace(permissionLayer),
|
||||
],
|
||||
},
|
||||
) as unknown as Layer.Layer<unknown, never>
|
||||
|
||||
@@ -23,11 +23,11 @@ const it = testEffect(
|
||||
PluginRuntime.providerNodeWithCell(cell),
|
||||
]),
|
||||
[
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Watcher.node, Watcher.configured({ enabled: false })],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
|
||||
[PersistentPty.node, PersistentPty.configured()],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
PluginRuntime.node.replace(PluginRuntime.layerWithCell(cell)),
|
||||
PersistentPty.node.replace(PersistentPty.configured()),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -27,12 +27,12 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
|
||||
[Location.node, locationLayer],
|
||||
Location.node.replace(locationLayer),
|
||||
])
|
||||
const it = testEffect(layer)
|
||||
const real = testEffect(PluginTestLayer)
|
||||
const models = (file: string) =>
|
||||
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
|
||||
AppNodeBuilder.build(ModelsDev.node, [ModelsDev.node.replace(ModelsDev.configured({ file, fetch: false }))])
|
||||
|
||||
describe("ModelsDevPlugin", () => {
|
||||
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
|
||||
|
||||
@@ -15,7 +15,7 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(Catalog.node, [[Location.node, locationLayer]]))
|
||||
const it = testEffect(AppNodeBuilder.build(Catalog.node, [Location.node.replace(locationLayer)]))
|
||||
|
||||
describe("VariantPlugin", () => {
|
||||
it.effect("adds GLM 5.2 variants after catalog sources", () =>
|
||||
|
||||
@@ -42,7 +42,7 @@ const http = Layer.succeed(
|
||||
export const webSearchIntegrationTest = testEffect(
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]), [
|
||||
[Config.node, Config.testLayer()],
|
||||
Config.node.replace(Config.testLayer()),
|
||||
]),
|
||||
http,
|
||||
),
|
||||
|
||||
@@ -17,7 +17,9 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [[Location.node, locationLayer]]))
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [Location.node.replace(locationLayer)]),
|
||||
)
|
||||
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
|
||||
@@ -200,7 +202,7 @@ describe("pty", () => {
|
||||
|
||||
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
|
||||
const configuredIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [[Location.node, locationLayer]]),
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [Location.node.replace(locationLayer)]),
|
||||
)
|
||||
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(LayerNode.compile(PtyTicket.node))
|
||||
const itExpiring = testEffect(
|
||||
LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]),
|
||||
LayerNode.compile(PtyTicket.node, {
|
||||
replacements: [PtyTicket.node.replace(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))],
|
||||
}),
|
||||
)
|
||||
|
||||
describe("PTY websocket tickets", () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { it } from "./lib/effect"
|
||||
import { readInitial, readUpdate } from "./lib/instructions"
|
||||
|
||||
const instructionsLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
|
||||
AppNodeBuilder.build(ReferenceInstructions.node, [[Reference.node, referenceLayer]])
|
||||
AppNodeBuilder.build(ReferenceInstructions.node, [Reference.node.replace(referenceLayer)])
|
||||
|
||||
describe("ReferenceInstructions", () => {
|
||||
it.effect("lists available references in the instructions", () =>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { it } from "./lib/effect"
|
||||
const cache = Layer.mock(RepositoryCache.Service, {
|
||||
ensure: () => Effect.die("unexpected Git materialization"),
|
||||
})
|
||||
const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
|
||||
const referenceLayer = AppNodeBuilder.build(Reference.node, [RepositoryCache.node.replace(cache)])
|
||||
|
||||
describe("Reference", () => {
|
||||
it.effect("registers normalized sources for the owning scope", () =>
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("RepositoryCache", () => {
|
||||
|
||||
function cacheLayer(root: string) {
|
||||
return AppNodeBuilder.build(RepositoryCache.node, [
|
||||
[Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
|
||||
Global.node.replace(Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [[Location.node, tempLocationLayer]]))
|
||||
const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [Location.node.replace(tempLocationLayer)]))
|
||||
|
||||
describe("Ripgrep", () => {
|
||||
it.live("globs files as an array", () =>
|
||||
|
||||
@@ -65,9 +65,9 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[LocationServiceMap.node, locations],
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
LocationServiceMap.node.replace(locations),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -88,10 +88,7 @@ const it = testEffect(
|
||||
SessionCompaction.node,
|
||||
SessionModelRequest.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[llmClient, client],
|
||||
],
|
||||
[Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -51,30 +51,27 @@ const it = testEffect(
|
||||
InstructionEntry.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectNode],
|
||||
[LocationServiceMap.node, promptLocationNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Project.node.replace(globalProjectNode),
|
||||
LocationServiceMap.node.replace(promptLocationNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
const liveIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
[Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(SessionExecution.noopLayer)],
|
||||
),
|
||||
)
|
||||
const projectIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
// Project adoption needs plain-prompt admission, not live plugin/provider startup.
|
||||
[LocationServiceMap.node, promptLocationNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
LocationServiceMap.node.replace(promptLocationNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -968,8 +965,8 @@ describe("Session.create", () => {
|
||||
const targetLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })),
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -142,17 +142,17 @@ const it = testEffect(
|
||||
SessionGenerateNode.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[InstructionBuiltIns.node, builtins],
|
||||
[InstructionDiscovery.node, discovery],
|
||||
[SkillInstructions.node, skills],
|
||||
[ReferenceInstructions.node, references],
|
||||
[McpInstructions.node, mcp],
|
||||
[PluginSupervisor.node, plugins],
|
||||
[Tool.node, tools],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
llmClient.replace(client),
|
||||
SessionRunnerModel.node.replace(models),
|
||||
InstructionBuiltIns.node.replace(builtins),
|
||||
InstructionDiscovery.node.replace(discovery),
|
||||
SkillInstructions.node.replace(skills),
|
||||
ReferenceInstructions.node.replace(references),
|
||||
McpInstructions.node.replace(mcp),
|
||||
PluginSupervisor.node.replace(plugins),
|
||||
Tool.node.replace(tools),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ const readToolNode = makeLocationNode({
|
||||
|
||||
const permission = permissionLayer({ assert: () => Effect.void })
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [Config.node.replace(config)])
|
||||
|
||||
const testLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
@@ -74,12 +74,12 @@ const testLayer = AppNodeBuilder.build(
|
||||
Image.node,
|
||||
]),
|
||||
[
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[Location.node, tempLocationLayer],
|
||||
[Permission.node, permission],
|
||||
[Config.node, config],
|
||||
[Image.node, imageLayer],
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Permission.node.replace(permission),
|
||||
Config.node.replace(config),
|
||||
Image.node.replace(imageLayer),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -30,10 +30,9 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectNode],
|
||||
[
|
||||
SessionExecution.node,
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(
|
||||
Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
@@ -45,7 +44,7 @@ const it = testEffect(
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -154,8 +153,8 @@ describe("Session.updateMessage", () => {
|
||||
const target = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })),
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -20,9 +20,13 @@ import { testEffect } from "./lib/effect"
|
||||
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
|
||||
|
||||
const it = testEffect(
|
||||
LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), [
|
||||
[SessionModelTransport.node, SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") })],
|
||||
]),
|
||||
LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), {
|
||||
replacements: [
|
||||
SessionModelTransport.node.replace(
|
||||
SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const requestInput = (model: LanguageModel) => ({
|
||||
|
||||
@@ -24,10 +24,7 @@ import { globalProjectNode } from "./lib/project"
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
[Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer)],
|
||||
),
|
||||
)
|
||||
const unavailableLocations = Layer.effect(
|
||||
@@ -40,9 +37,9 @@ const itWithUnavailableDestination = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[LocationServiceMap.node, unavailableLocations],
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
LocationServiceMap.node.replace(unavailableLocations),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -49,10 +49,9 @@ const it = testEffect(
|
||||
SessionInbox.node,
|
||||
FSUtil.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Global.node, tempGlobalLayer],
|
||||
],
|
||||
{
|
||||
replacements: [Bus.node.replace(Bus.configured({ persist: true })), Global.node.replace(tempGlobalLayer)],
|
||||
},
|
||||
),
|
||||
)
|
||||
const sessionID = SessionSchema.ID.make("ses_owned")
|
||||
|
||||
@@ -33,10 +33,10 @@ import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
)
|
||||
const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
|
||||
const sessionsLayer = AppNodeBuilder.build(Session.node, [SessionExecution.node.replace(SessionExecution.noopLayer)])
|
||||
const sessionID = Session.ID.make("ses_projector_test")
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }
|
||||
|
||||
@@ -38,11 +38,11 @@ const it = testEffect(
|
||||
PluginRuntime.providerNodeWithCell(runtime),
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Watcher.node, Watcher.configured({ enabled: false })],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(runtime)],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
PluginRuntime.node.replace(PluginRuntime.layerWithCell(runtime)),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -86,9 +86,9 @@ const locations = makeGlobalNode({
|
||||
return Layer.merge(SessionRevert.layer, SessionPrompt.layer).pipe(
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), [
|
||||
[Bus.node, Layer.succeed(Bus.Service, bus)],
|
||||
]),
|
||||
LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), {
|
||||
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
|
||||
}),
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
ready
|
||||
@@ -122,9 +122,9 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[SessionExecution.node, execution],
|
||||
[LocationServiceMap.node, locations],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
SessionExecution.node.replace(execution),
|
||||
LocationServiceMap.node.replace(locations),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -39,9 +39,9 @@ const it = testEffect(
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
[
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[SessionModelTransport.node, transport],
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
SessionModelTransport.node.replace(transport),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -31,9 +31,9 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Global.node, tempGlobalLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { Auth, LLMClient, type LLMClientService, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -100,22 +100,22 @@ const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
small: () => Effect.undefined,
|
||||
},
|
||||
})
|
||||
const runnerLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
const runnerLayer = (llmClient: Layer.Layer<LLMClientService>) =>
|
||||
AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, llmClient],
|
||||
[SessionRunnerModel.node, models],
|
||||
[InstructionBuiltIns.node, systemContext],
|
||||
[InstructionDiscovery.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillInstructions.node, skillInstructions],
|
||||
[ReferenceInstructions.node, referenceInstructions],
|
||||
[McpInstructions.node, mcpInstructions],
|
||||
[Config.node, config],
|
||||
[Permission.node, permission],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
Snapshot.node.replace(Snapshot.noopLayer),
|
||||
LayerNodePlatform.llmClient.replace(llmClient),
|
||||
SessionRunnerModel.node.replace(models),
|
||||
InstructionBuiltIns.node.replace(systemContext),
|
||||
InstructionDiscovery.node.replace(instructionContext),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
SkillInstructions.node.replace(skillInstructions),
|
||||
ReferenceInstructions.node.replace(referenceInstructions),
|
||||
McpInstructions.node.replace(mcpInstructions),
|
||||
Config.node.replace(config),
|
||||
Permission.node.replace(permission),
|
||||
PluginSupervisor.node.replace(pluginSupervisor),
|
||||
])
|
||||
const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
const execution = (llmClient: Layer.Layer<LLMClientService>) =>
|
||||
Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -133,7 +133,7 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runnerLayer(llmClient)))
|
||||
const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
const testLayer = (llmClient: Layer.Layer<LLMClientService>) =>
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
@@ -155,21 +155,21 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
Session.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[LocationServiceMap.node, promptLocationNode],
|
||||
[LayerNodePlatform.llmClient, llmClient],
|
||||
[Permission.node, permission],
|
||||
[Catalog.node, promptCatalog],
|
||||
[SessionRunnerModel.node, models],
|
||||
[InstructionBuiltIns.node, systemContext],
|
||||
[InstructionDiscovery.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillInstructions.node, skillInstructions],
|
||||
[ReferenceInstructions.node, referenceInstructions],
|
||||
[Config.node, config],
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
[SessionExecution.node, execution(llmClient)],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
LocationServiceMap.node.replace(promptLocationNode),
|
||||
LayerNodePlatform.llmClient.replace(llmClient),
|
||||
Permission.node.replace(permission),
|
||||
Catalog.node.replace(promptCatalog),
|
||||
SessionRunnerModel.node.replace(models),
|
||||
InstructionBuiltIns.node.replace(systemContext),
|
||||
InstructionDiscovery.node.replace(instructionContext),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
SkillInstructions.node.replace(skillInstructions),
|
||||
ReferenceInstructions.node.replace(referenceInstructions),
|
||||
Config.node.replace(config),
|
||||
Snapshot.node.replace(Snapshot.noopLayer),
|
||||
PluginSupervisor.node.replace(pluginSupervisor),
|
||||
SessionExecution.node.replace(execution(llmClient)),
|
||||
],
|
||||
)
|
||||
const it = testEffect(testLayer(client))
|
||||
|
||||
@@ -127,7 +127,7 @@ test("provider-executed success derives content and retains provider result stat
|
||||
|
||||
testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
).effect("commits a hosted tool result when cancellation races with the aggregate lock", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -408,22 +408,22 @@ const layer = Layer.unwrap(
|
||||
},
|
||||
})
|
||||
const replacements: LayerNode.Replacements = [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, TestLLM.clientLayer],
|
||||
[SessionRunnerModel.node, models],
|
||||
[InstructionBuiltIns.node, systemContext],
|
||||
[InstructionDiscovery.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillInstructions.node, skillInstructions],
|
||||
[ReferenceInstructions.node, referenceInstructions],
|
||||
[Permission.node, permission],
|
||||
[Config.node, config],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
[SessionModelTransport.node, modelTransport],
|
||||
Snapshot.node.replace(Snapshot.noopLayer),
|
||||
LayerNodePlatform.llmClient.replace(TestLLM.clientLayer.pipe(Layer.provide(testLLM))),
|
||||
SessionRunnerModel.node.replace(models),
|
||||
InstructionBuiltIns.node.replace(systemContext),
|
||||
InstructionDiscovery.node.replace(instructionContext),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
SkillInstructions.node.replace(skillInstructions),
|
||||
ReferenceInstructions.node.replace(referenceInstructions),
|
||||
Permission.node.replace(permission),
|
||||
Config.node.replace(config),
|
||||
PluginSupervisor.node.replace(pluginSupervisor),
|
||||
SessionModelTransport.node.replace(modelTransport),
|
||||
]
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
...replacements,
|
||||
[McpInstructions.node, mcpInstructions],
|
||||
McpInstructions.node.replace(mcpInstructions),
|
||||
])
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
@@ -485,10 +485,10 @@ const layer = Layer.unwrap(
|
||||
]),
|
||||
[
|
||||
...replacements,
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[LocationServiceMap.node, promptLocationNode],
|
||||
[Catalog.node, promptCatalog],
|
||||
[SessionExecution.node, execution],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
LocationServiceMap.node.replace(promptLocationNode),
|
||||
Catalog.node.replace(promptCatalog),
|
||||
SessionExecution.node.replace(execution),
|
||||
],
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -54,8 +54,8 @@ const executionLayer = Layer.effect(
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Bus.node, Session.node, SessionExecution.node, LocationServiceMap.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[SessionExecution.node, executionLayer],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
SessionExecution.node.replace(executionLayer.pipe(Layer.provide(controlLayer))),
|
||||
]).pipe(Layer.provideMerge(controlLayer)),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,9 +67,9 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[LocationServiceMap.node, locations],
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
LocationServiceMap.node.replace(locations),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ import { testEffect } from "./lib/effect"
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, ToolOutput.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
TestLLM.testLayer(),
|
||||
),
|
||||
|
||||
@@ -126,11 +126,11 @@ const it = testEffect(
|
||||
SessionTitle.node,
|
||||
]),
|
||||
[
|
||||
[llmClient, client],
|
||||
[Catalog.node, catalog],
|
||||
[SessionRunnerModel.node, models],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[PluginSupervisor.node, Layer.mock(PluginSupervisor.Service, { flush: Effect.void })],
|
||||
llmClient.replace(client),
|
||||
Catalog.node.replace(catalog),
|
||||
SessionRunnerModel.node.replace(models),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { flush: Effect.void })),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
)
|
||||
const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }
|
||||
|
||||
@@ -25,9 +25,9 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -186,8 +186,8 @@ describe("Session.view", () => {
|
||||
const targetLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })),
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -22,10 +22,7 @@ const execution = Layer.mock(SessionExecution.Service, {
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, execution],
|
||||
],
|
||||
[Project.node.replace(globalProjectNode), SessionExecution.node.replace(execution)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const withStore = <A, E, R>(body: (fs: FSUtil.Interface, root: string) => Effect
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([FSUtil.node, Global.node]), [
|
||||
[Global.node, Global.layerWith({ data: tmp.path })],
|
||||
Global.node.replace(Global.layerWith({ data: tmp.path })),
|
||||
])
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
@@ -44,7 +44,7 @@ const fixture = Effect.gen(function* () {
|
||||
return yield* discovery.pull(base)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(SkillDiscovery.node, [[Global.node, Global.layerWith({ cache: tmp.path })]]),
|
||||
AppNodeBuilder.build(SkillDiscovery.node, [Global.node.replace(Global.layerWith({ cache: tmp.path }))]),
|
||||
),
|
||||
)
|
||||
return { directories, requests: state.requests.slice() }
|
||||
|
||||
@@ -41,7 +41,7 @@ const manual = Skill.Info.make({
|
||||
|
||||
const layer = (list: () => Skill.Info[]) =>
|
||||
AppNodeBuilder.build(SkillInstructions.node, [
|
||||
[Skill.node, Layer.mock(Skill.Service, { list: () => Effect.succeed(list()) })],
|
||||
Skill.node.replace(Layer.mock(Skill.Service, { list: () => Effect.succeed(list()) })),
|
||||
])
|
||||
|
||||
describe("SkillInstructions", () => {
|
||||
|
||||
@@ -54,9 +54,9 @@ describe("Snapshot", () => {
|
||||
},
|
||||
})
|
||||
const layer = AppNodeBuilder.build(Snapshot.node, [
|
||||
[Location.node, Layer.succeed(Location.Service, location)],
|
||||
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
|
||||
[Git.node, Layer.succeed(Git.Service, instrumented)],
|
||||
Location.node.replace(Layer.succeed(Location.Service, location)),
|
||||
Global.node.replace(Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })),
|
||||
Git.node.replace(Layer.succeed(Git.Service, instrumented)),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -239,8 +239,8 @@ describe("Snapshot", () => {
|
||||
|
||||
function snapshotLayer(data: string, directory: string) {
|
||||
return AppNodeBuilder.build(Snapshot.node, [
|
||||
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))],
|
||||
[Global.node, Global.layerWith({ data, config: path.join(data, "config") })],
|
||||
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -92,8 +92,7 @@ const withTool = <A, E, R>(
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [
|
||||
[
|
||||
Environment.node,
|
||||
Environment.node.replace(
|
||||
transformEnvironmentFiles((files) => ({
|
||||
read: (target, range) =>
|
||||
files
|
||||
@@ -104,10 +103,10 @@ const withTool = <A, E, R>(
|
||||
write: (target, content) =>
|
||||
Effect.sync(() => fixture.writes.push(target)).pipe(Effect.andThen(files.write(target, content))),
|
||||
})),
|
||||
],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, fixture.formatter],
|
||||
[Permission.node, fixture.permission],
|
||||
),
|
||||
Location.node.replace(activeLocation),
|
||||
Formatter.node.replace(fixture.formatter),
|
||||
Permission.node.replace(fixture.permission),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ const withStore = <A, E, R>(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
||||
[Global.node, Global.layerWith({ data: tmp.path })],
|
||||
Global.node.replace(Global.layerWith({ data: tmp.path })),
|
||||
])
|
||||
return Effect.gen(function* () {
|
||||
const output = yield* ToolOutput.Service
|
||||
|
||||
@@ -100,8 +100,7 @@ const withTool = <A, E, R>(
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
|
||||
[
|
||||
Environment.node,
|
||||
Environment.node.replace(
|
||||
transformEnvironmentFiles((files) => ({
|
||||
read: (target, range) =>
|
||||
Effect.sync(() => {
|
||||
@@ -120,10 +119,10 @@ const withTool = <A, E, R>(
|
||||
return files.write(target, content)
|
||||
},
|
||||
})),
|
||||
],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
),
|
||||
Location.node.replace(activeLocation),
|
||||
Formatter.node.replace(formatter),
|
||||
Permission.node.replace(permission),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -66,9 +66,9 @@ const questionToolNode = makeLocationNode({
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, questionToolNode]), [
|
||||
[Permission.node, permission],
|
||||
[Form.node, form],
|
||||
[Image.node, imagePassthrough],
|
||||
Permission.node.replace(permission),
|
||||
Form.node.replace(form),
|
||||
Image.node.replace(imagePassthrough),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -134,14 +134,14 @@ const unavailableImage = Layer.mock(Image.Service, {
|
||||
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [
|
||||
[ReadToolFileSystem.node, reader],
|
||||
[Permission.node, permission],
|
||||
[Config.node, config],
|
||||
[Image.node, imageLayer],
|
||||
[LocationMutation.node, mutation],
|
||||
[FSUtil.node, testFileSystem],
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ data: Global.Path.data })],
|
||||
ReadToolFileSystem.node.replace(reader),
|
||||
Permission.node.replace(permission),
|
||||
Config.node.replace(config),
|
||||
Image.node.replace(imageLayer),
|
||||
LocationMutation.node.replace(mutation),
|
||||
FSUtil.node.replace(testFileSystem),
|
||||
Location.node.replace(locationLayer),
|
||||
Global.node.replace(Global.layerWith({ data: Global.Path.data })),
|
||||
]),
|
||||
// Merge by reference so Config.Test and Image.Service resolve to the memoized instances.
|
||||
config,
|
||||
|
||||
@@ -43,7 +43,7 @@ const imageStore = Layer.mock(Image.Service, {
|
||||
},
|
||||
})
|
||||
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node, SessionModelRequest.node]), [
|
||||
[Image.node, imageStore],
|
||||
Image.node.replace(imageStore),
|
||||
])
|
||||
const it = testEffect(registryLayer)
|
||||
const identity = {
|
||||
|
||||
@@ -45,19 +45,17 @@ const withTools = <A, E, R>(
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, globToolNode, grepToolNode]), [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[
|
||||
Permission.node,
|
||||
),
|
||||
Permission.node.replace(
|
||||
permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions?.push(input)
|
||||
}),
|
||||
}),
|
||||
],
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -156,17 +156,19 @@ const nodes = LayerNode.group([
|
||||
Global.node,
|
||||
])
|
||||
const replacements = [
|
||||
[SessionExecution.node, executionNode],
|
||||
[Permission.node, permission],
|
||||
[Global.node, tempGlobalLayer],
|
||||
SessionExecution.node.replace(executionNode),
|
||||
Permission.node.replace(permission),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(nodes, [...replacements, PluginSupervisor.node.replace(shellPluginSupervisor)]),
|
||||
)
|
||||
const permissionIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([nodes, PermissionSaved.node]), [
|
||||
[SessionExecution.node, executionNode],
|
||||
[Global.node, tempGlobalLayer],
|
||||
[PluginSupervisor.node, shellPluginSupervisor],
|
||||
SessionExecution.node.replace(executionNode),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
PluginSupervisor.node.replace(shellPluginSupervisor),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@ describe("SkillTool", () => {
|
||||
list: () => Effect.succeed(current),
|
||||
})
|
||||
const skillToolLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, skillToolNode]), [
|
||||
[Permission.node, permission],
|
||||
[Skill.node, skills],
|
||||
[Image.node, imagePassthrough],
|
||||
Permission.node.replace(permission),
|
||||
Skill.node.replace(skills),
|
||||
Image.node.replace(imagePassthrough),
|
||||
])
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
|
||||
@@ -122,18 +122,19 @@ const nodes = LayerNode.group([
|
||||
LocationServiceMap.node,
|
||||
])
|
||||
const replacements = [
|
||||
[SessionExecution.node, executionNode],
|
||||
[Global.node, tempGlobalLayer],
|
||||
SessionExecution.node.replace(executionNode),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(nodes, [...replacements, PluginSupervisor.node.replace(subagentPluginSupervisor)]),
|
||||
)
|
||||
const completionIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([nodes, SessionRestart.node, KV.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[PluginSupervisor.node, subagentPluginSupervisor],
|
||||
[LayerNodePlatform.llmClient, TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })],
|
||||
[
|
||||
SessionRunnerModel.node,
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
PluginSupervisor.node.replace(subagentPluginSupervisor),
|
||||
LayerNodePlatform.llmClient.replace(TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })),
|
||||
SessionRunnerModel.node.replace(
|
||||
Layer.succeed(SessionRunnerModel.Service, {
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
@@ -147,7 +148,7 @@ const completionIt = testEffect(
|
||||
),
|
||||
),
|
||||
}),
|
||||
],
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -42,11 +42,11 @@ const http = Layer.succeed(
|
||||
const permission = permissionLayer({ assert: (input) => Effect.sync(() => assertions.push(input)) })
|
||||
const toolLayer = (replacements: LayerNode.Replacements = []) =>
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, webFetchToolNode]), [
|
||||
[Permission.node, permission],
|
||||
[Image.node, imagePassthrough],
|
||||
Permission.node.replace(permission),
|
||||
Image.node.replace(imagePassthrough),
|
||||
...replacements,
|
||||
])
|
||||
const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]]))
|
||||
const it = testEffect(toolLayer([LayerNodePlatform.httpClient.replace(http)]))
|
||||
const live = testEffect(toolLayer())
|
||||
|
||||
const reset = () => {
|
||||
@@ -128,15 +128,6 @@ describe("WebFetchTool helpers", () => {
|
||||
expect(output).toHaveLength(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024)
|
||||
})
|
||||
|
||||
test.each(["x", "\u00e9", "\u{1f600}"])("preserves UTF-8 boundaries at the content limit for %s", (character) => {
|
||||
const budget = WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024
|
||||
const fitting = "aa" + character.repeat(Math.floor((budget - 2) / Buffer.byteLength(character)))
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(fitting)).toBe(fitting)
|
||||
const truncated = WebFetchTool.convertHTMLToMarkdown(fitting + character)
|
||||
expect(truncated).toBe(fitting)
|
||||
expect(Buffer.byteLength(truncated)).toBe(Buffer.byteLength(fitting))
|
||||
})
|
||||
|
||||
test("bounds deeply nested list output and fragmented code fences", () => {
|
||||
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
|
||||
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
|
||||
|
||||
@@ -70,8 +70,7 @@ const setup = Effect.gen(function* () {
|
||||
)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, webSearchToolNode]), [
|
||||
[
|
||||
Permission.node,
|
||||
Permission.node.replace(
|
||||
permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
@@ -79,10 +78,9 @@ const setup = Effect.gen(function* () {
|
||||
fixture.assertions.push(input)
|
||||
}),
|
||||
}),
|
||||
],
|
||||
[WebSearch.node, Layer.succeed(WebSearch.Service, websearch)],
|
||||
[
|
||||
Form.node,
|
||||
),
|
||||
WebSearch.node.replace(Layer.succeed(WebSearch.Service, websearch)),
|
||||
Form.node.replace(
|
||||
Layer.mock(Form.Service, {
|
||||
ask: (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -91,8 +89,8 @@ const setup = Effect.gen(function* () {
|
||||
return fixture.formResponses.shift() ?? fixture.formResponse
|
||||
}),
|
||||
}),
|
||||
],
|
||||
[Image.node, imagePassthrough],
|
||||
),
|
||||
Image.node.replace(imagePassthrough),
|
||||
]),
|
||||
)
|
||||
return Object.assign(fixture, { websearch, kv, registry: Context.get(context, Tool.Service) })
|
||||
|
||||
@@ -80,16 +80,15 @@ const withTool = <A, E, R>(
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [
|
||||
[
|
||||
Environment.node,
|
||||
Environment.node.replace(
|
||||
transformEnvironmentFiles((files) => ({
|
||||
write: (target, content) =>
|
||||
Effect.sync(() => fixture.writes.push(target)).pipe(Effect.andThen(files.write(target, content))),
|
||||
})),
|
||||
],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, fixture.formatter],
|
||||
[Permission.node, fixture.permission],
|
||||
),
|
||||
Location.node.replace(activeLocation),
|
||||
Formatter.node.replace(fixture.formatter),
|
||||
Permission.node.replace(fixture.permission),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ const testGlobal = Global.layerWith({
|
||||
log: os.tmpdir(),
|
||||
})
|
||||
|
||||
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
const testLayer = LayerNode.compile(EffectFlock.node, { replacements: [Global.node.replace(testGlobal)] })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user