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