opencode/packages/core/test/instance-plugins.test.ts
2026-08-28 02:31:36 +00:00

104 lines
4.1 KiB
TypeScript

import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Duration, Effect, Layer, LayerMap } from "effect"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Instance } from "@opencode-ai/core/instance"
import { InstancePlugins } from "@opencode-ai/core/plugin/instance"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { Database } from "../src/database/database"
import { Bus } from "../src/bus"
const agentPlugin = (pluginID: string, agentID: string) =>
Plugin.define({
id: pluginID,
effect: (ctx) => ctx.agent.transform((agents) => agents.update(Agent.ID.make(agentID), () => {})),
})
// A host-owned assignment in miniature: the map decides per ref which plugins
// an instance is born with, the way an embedder will per Slack thread.
const instances = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) =>
Instance.layer(ref, {
plugins: path.basename(ref.directory) === "thread-a" ? [agentPlugin("thread-a-plugin", "thread-a-agent")] : [],
replacements: [[Global.node, tempGlobalLayer]],
}),
{ idleTimeToLive: Duration.infinity },
),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
[LocationServiceMap.node, instances],
]),
)
describe("InstancePlugins", () => {
it.live("binds plugins to one instance without leaking to siblings", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const sdk = yield* SdkPlugins.Service
yield* sdk.register(agentPlugin("global-plugin", "global-agent"))
const dirA = path.join(dir.path, "thread-a")
const dirB = path.join(dir.path, "thread-b")
yield* Effect.promise(() => fs.mkdir(dirA))
yield* Effect.promise(() => fs.mkdir(dirB))
const refA = Location.Ref.make({ directory: AbsolutePath.make(dirA) })
const refB = Location.Ref.make({ directory: AbsolutePath.make(dirB) })
const agents = (ref: Location.Ref) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
const service = yield* Agent.Service
return {
bound: yield* service.get(Agent.ID.make("thread-a-agent")),
global: yield* service.get(Agent.ID.make("global-agent")),
}
}).pipe(Effect.scoped, Effect.provide(locations.get(ref)))
const a = yield* agents(refA)
expect(a.bound).toBeDefined()
expect(a.global).toBeDefined()
const b = yield* agents(refB)
expect(b.bound).toBeUndefined()
expect(b.global).toBeDefined()
// Eviction and rebuild re-bind the same list.
yield* locations.invalidate(refA)
const rebuilt = yield* agents(refA)
expect(rebuilt.bound).toBeDefined()
expect(rebuilt.global).toBeDefined()
}),
),
),
)
})
describe("InstancePlugins.bound", () => {
test("rejects duplicate ids in one list", () => {
const plugin = Plugin.define({ id: "dup", effect: () => Effect.void })
expect(() => InstancePlugins.bound([plugin, plugin])).toThrow("duplicate instance plugin ids: dup")
})
})