mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-18 16:33:31 +00:00
722 lines
29 KiB
TypeScript
722 lines
29 KiB
TypeScript
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { describe, expect } from "bun:test"
|
|
import { Config } from "@opencode-ai/schema/config"
|
|
import { Plugin } from "@opencode-ai/schema/plugin"
|
|
import { Money } from "@opencode-ai/schema/money"
|
|
import { DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
|
|
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
|
import { Catalog } from "@opencode-ai/core/catalog"
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
|
import { Location } from "@opencode-ai/core/location"
|
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
|
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
|
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
|
import { ModelV2 } from "@opencode-ai/core/model"
|
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
import { SessionV2 } from "@opencode-ai/core/session"
|
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
|
import { tmpdir } from "./fixture/tmpdir"
|
|
import { testEffect } from "./lib/effect"
|
|
import { toolDefinitions, waitForTool } from "./lib/tool"
|
|
import { Database } from "../src/database/database"
|
|
import { EventV2 } from "../src/event"
|
|
import { Reference } from "../src/reference"
|
|
import { ToolRegistry } from "../src/tool/registry"
|
|
|
|
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
|
|
const itWithSdk = testEffect(
|
|
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
|
|
)
|
|
|
|
describe("LocationServiceMap", () => {
|
|
itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const sdk = yield* SdkPlugins.Service
|
|
const locations = yield* LocationServiceMap.Service
|
|
const id = AgentV2.ID.make("persistent-sdk-agent")
|
|
const plugin = EffectPlugin.define({
|
|
id: "persistent-sdk-plugin",
|
|
effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
|
|
})
|
|
yield* sdk.register(plugin)
|
|
|
|
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
|
const read = Effect.gen(function* () {
|
|
const supervisor = yield* PluginSupervisor.Service
|
|
yield* supervisor.flush
|
|
const agents = yield* AgentV2.Service
|
|
return yield* agents.get(id)
|
|
})
|
|
|
|
expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined()
|
|
yield* locations.invalidate(ref)
|
|
expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined()
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
itWithSdk.live("waits for explorer activation to complete", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const started = yield* Deferred.make<void>()
|
|
const release = yield* Deferred.make<void>()
|
|
const sdk = yield* SdkPlugins.Service
|
|
yield* sdk.register(
|
|
EffectPlugin.define({
|
|
id: "blocked-initial-activation",
|
|
effect: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
|
}),
|
|
)
|
|
|
|
const locations = yield* LocationServiceMap.Service
|
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
|
yield* Deferred.await(started)
|
|
|
|
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
|
Effect.provide(context),
|
|
Effect.forkChild,
|
|
)
|
|
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
|
yield* Deferred.succeed(release, undefined)
|
|
yield* Fiber.join(flushFiber)
|
|
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
|
Effect.provide(context),
|
|
Effect.timeout("1 second"),
|
|
)
|
|
|
|
const explorer = yield* Effect.gen(function* () {
|
|
const agents = yield* AgentV2.Service
|
|
return yield* agents.resolve("explore")
|
|
}).pipe(Effect.provide(context))
|
|
|
|
expect(explorer).toBeDefined()
|
|
expect(explorer?.permissions.length).toBeGreaterThan(0)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
itWithSdk.live("reruns activation for SDK plugins registered during startup", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const firstStarted = yield* Deferred.make<void>()
|
|
const releaseFirst = yield* Deferred.make<void>()
|
|
const secondStarted = yield* Deferred.make<void>()
|
|
const releaseSecond = yield* Deferred.make<void>()
|
|
const sdk = yield* SdkPlugins.Service
|
|
yield* sdk.register(
|
|
EffectPlugin.define({
|
|
id: "fixed-target-first-plugin",
|
|
effect: () =>
|
|
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
|
}),
|
|
)
|
|
|
|
const locations = yield* LocationServiceMap.Service
|
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
|
yield* Deferred.await(firstStarted)
|
|
|
|
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
|
Effect.provide(context),
|
|
Effect.forkChild({ startImmediately: true }),
|
|
)
|
|
yield* Effect.yieldNow
|
|
yield* sdk.register(
|
|
EffectPlugin.define({
|
|
id: "fixed-target-second-plugin",
|
|
effect: () =>
|
|
Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
|
|
}),
|
|
)
|
|
|
|
yield* Deferred.succeed(releaseFirst, undefined)
|
|
yield* Deferred.await(secondStarted)
|
|
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
|
|
|
yield* Deferred.succeed(releaseSecond, undefined)
|
|
yield* Fiber.join(flushFiber)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
itWithSdk.live("reruns activation for Config updates during startup", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const activations = { count: 0 }
|
|
const file = path.join(dir.path, "opencode.json")
|
|
yield* Effect.promise(() => fs.writeFile(file, "{}"))
|
|
const firstStarted = yield* Deferred.make<void>()
|
|
const releaseFirst = yield* Deferred.make<void>()
|
|
const secondStarted = yield* Deferred.make<void>()
|
|
const releaseSecond = yield* Deferred.make<void>()
|
|
const sdk = yield* SdkPlugins.Service
|
|
yield* sdk.register(
|
|
EffectPlugin.define({
|
|
id: "blocked-config-reload",
|
|
effect: () =>
|
|
Effect.sync(() => ++activations.count).pipe(
|
|
Effect.flatMap((activation) =>
|
|
activation === 1
|
|
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
|
|
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
|
|
),
|
|
),
|
|
}),
|
|
)
|
|
|
|
const locations = yield* LocationServiceMap.Service
|
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
|
yield* Deferred.await(firstStarted)
|
|
|
|
const events = yield* EventV2.Service
|
|
const updated = yield* events.subscribe(Config.Event.Updated).pipe(
|
|
Stream.filter((event) => event.location?.directory === dir.path),
|
|
Stream.runHead,
|
|
Effect.forkChild({ startImmediately: true }),
|
|
)
|
|
yield* Effect.promise(() =>
|
|
fs.writeFile(
|
|
file,
|
|
JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect-plugin.ts")] }),
|
|
),
|
|
)
|
|
yield* Fiber.join(updated)
|
|
|
|
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
|
Effect.provide(context),
|
|
Effect.forkChild,
|
|
)
|
|
yield* Deferred.succeed(releaseFirst, undefined)
|
|
yield* Deferred.await(secondStarted)
|
|
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
|
yield* Deferred.succeed(releaseSecond, undefined)
|
|
yield* Fiber.join(flushFiber)
|
|
expect(activations.count).toBe(2)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
itWithSdk.live("keeps flush pending while startup updates continue", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const locations = yield* LocationServiceMap.Service
|
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
|
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
|
Effect.provide(context),
|
|
Effect.forkChild({ startImmediately: true }),
|
|
)
|
|
const events = yield* EventV2.Service
|
|
|
|
yield* Effect.forEach(
|
|
Array.from({ length: 5 }),
|
|
() => events.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
|
|
{ discard: true },
|
|
)
|
|
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
|
yield* Fiber.join(flushFiber)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
itWithSdk.live("keeps flush open while later hot reload runs", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const locations = yield* LocationServiceMap.Service
|
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
|
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
|
|
|
const started = yield* Deferred.make<void>()
|
|
const release = yield* Deferred.make<void>()
|
|
const completed = yield* Deferred.make<void>()
|
|
const sdk = yield* SdkPlugins.Service
|
|
yield* sdk.register(
|
|
EffectPlugin.define({
|
|
id: "post-ready-plugin",
|
|
effect: () =>
|
|
Deferred.succeed(started, undefined).pipe(
|
|
Effect.andThen(Deferred.await(release)),
|
|
Effect.andThen(Deferred.succeed(completed, undefined)),
|
|
),
|
|
}),
|
|
)
|
|
yield* Deferred.await(started)
|
|
|
|
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
|
Effect.provide(context),
|
|
Effect.timeout("1 second"),
|
|
)
|
|
yield* Deferred.succeed(release, undefined)
|
|
yield* Deferred.await(completed)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const started = yield* Deferred.make<void>()
|
|
const release = yield* Deferred.make<void>()
|
|
const completed = yield* Deferred.make<void>()
|
|
const sdk = yield* SdkPlugins.Service
|
|
yield* sdk.register(
|
|
EffectPlugin.define({
|
|
id: "interrupted-waiter-plugin",
|
|
effect: () =>
|
|
Deferred.succeed(started, undefined).pipe(
|
|
Effect.andThen(Deferred.await(release)),
|
|
Effect.andThen(Deferred.succeed(completed, undefined)),
|
|
),
|
|
}),
|
|
)
|
|
|
|
const locations = yield* LocationServiceMap.Service
|
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
|
yield* Deferred.await(started)
|
|
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
|
Effect.provide(context),
|
|
Effect.forkChild({ startImmediately: true }),
|
|
)
|
|
yield* Fiber.interrupt(flushFiber)
|
|
|
|
yield* Deferred.succeed(release, undefined)
|
|
yield* Deferred.await(completed)
|
|
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
|
Effect.provide(context),
|
|
Effect.timeout("500 millis"),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("applies ordered plugin config operations during boot", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.promise(() =>
|
|
fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })),
|
|
)
|
|
const plugins = yield* Effect.gen(function* () {
|
|
const plugins = yield* PluginV2.Service
|
|
yield* (yield* PluginSupervisor.Service).flush
|
|
return yield* plugins.list()
|
|
}).pipe(
|
|
Effect.scoped,
|
|
Effect.provide(
|
|
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
|
|
),
|
|
)
|
|
|
|
expect(plugins.map((plugin) => plugin.id)).toEqual([Plugin.ID.make("opencode.agent")])
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("reloads the plugin generation after config updates", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const file = path.join(dir.path, "opencode.json")
|
|
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
|
|
yield* Effect.gen(function* () {
|
|
const registry = yield* PluginV2.Service
|
|
const supervisor = yield* PluginSupervisor.Service
|
|
yield* supervisor.flush
|
|
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
|
|
|
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.command")) break
|
|
yield* Effect.sleep("20 millis")
|
|
}
|
|
|
|
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.command"])
|
|
|
|
yield* Effect.promise(() =>
|
|
fs.writeFile(
|
|
file,
|
|
JSON.stringify({
|
|
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
|
|
}),
|
|
),
|
|
)
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if ((yield* registry.list()).length === 0) break
|
|
yield* Effect.sleep("20 millis")
|
|
}
|
|
expect(yield* registry.list()).toEqual([])
|
|
|
|
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.agent")) break
|
|
yield* Effect.sleep("20 millis")
|
|
}
|
|
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
|
}).pipe(
|
|
Effect.scoped,
|
|
Effect.provide(
|
|
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("routes located events only to their location", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
|
).pipe(
|
|
Effect.flatMap(([first, second]) =>
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const locations = yield* LocationServiceMap.Service
|
|
const events = yield* EventV2.Service
|
|
const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) })
|
|
const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) })
|
|
const firstContext = yield* locations.contextEffect(firstRef)
|
|
const secondContext = yield* locations.contextEffect(secondRef)
|
|
const received = { first: 0, second: 0 }
|
|
yield* events.subscribe(Config.Event.Updated).pipe(
|
|
Stream.runForEach(() => Effect.sync(() => received.first++)),
|
|
Effect.provideContext(firstContext),
|
|
Effect.forkScoped({ startImmediately: true }),
|
|
)
|
|
yield* events.subscribe(Config.Event.Updated).pipe(
|
|
Stream.runForEach(() => Effect.sync(() => received.second++)),
|
|
Effect.provideContext(secondContext),
|
|
Effect.forkScoped({ startImmediately: true }),
|
|
)
|
|
yield* Effect.sleep("10 millis")
|
|
|
|
yield* events.publish(Config.Event.Updated, {}, { location: firstRef })
|
|
yield* Effect.sleep("10 millis")
|
|
|
|
expect(received).toEqual({ first: 1, second: 0 })
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("reuses cached services for constructed and decoded location refs", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const locations = yield* LocationServiceMap.Service
|
|
const directory = AbsolutePath.make(dir.path)
|
|
const constructed = Location.Ref.make({ directory })
|
|
const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory })
|
|
|
|
expect(constructed).toEqual({ directory, workspaceID: undefined })
|
|
expect(decoded).toEqual(constructed)
|
|
expect(Equal.equals(constructed, decoded)).toBe(true)
|
|
expect(Hash.hash(constructed)).toBe(Hash.hash(decoded))
|
|
expect(yield* locations.contextEffect(constructed)).toBe(yield* locations.contextEffect(decoded))
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("normalizes ref key shapes to one cached location graph", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const locations = yield* LocationServiceMap.Service
|
|
const directory = AbsolutePath.make(dir.path)
|
|
const absent = Location.Ref.make({ directory })
|
|
const present = Location.Ref.make({ directory, workspaceID: undefined })
|
|
// The two shapes are not structurally Equal: own-key sets differ.
|
|
expect(Object.keys(absent)).toEqual(["directory"])
|
|
expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
|
|
expect(Equal.equals(absent, present)).toBe(false)
|
|
|
|
const first = yield* locations.contextEffect(absent)
|
|
expect(yield* locations.contextEffect(present)).toBe(first)
|
|
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1)
|
|
|
|
// Invalidating with the shape opposite to the one that booted must evict.
|
|
yield* locations.invalidate(present)
|
|
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("isolates catalog state by location", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
|
).pipe(
|
|
Effect.flatMap(([blocked, allowed]) =>
|
|
Effect.gen(function* () {
|
|
const update = (directory: string, providerID: ProviderV2.ID) =>
|
|
Effect.gen(function* () {
|
|
yield* Reference.Service
|
|
const catalog = yield* Catalog.Service
|
|
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
|
|
const registry = yield* ToolRegistry.Service
|
|
// Tool plugins register during the forked PluginSupervisor boot; wait for
|
|
// every expected tool rather than relying on batch ordering.
|
|
yield* Effect.forEach(
|
|
[
|
|
"edit",
|
|
"glob",
|
|
"grep",
|
|
"question",
|
|
"read",
|
|
"shell",
|
|
"skill",
|
|
"subagent",
|
|
"webfetch",
|
|
"websearch",
|
|
"write",
|
|
],
|
|
(name) => waitForTool(registry, name),
|
|
)
|
|
return {
|
|
providers: yield* catalog.provider.all(),
|
|
tools: yield* toolDefinitions(registry),
|
|
}
|
|
}).pipe(
|
|
Effect.scoped,
|
|
Effect.provide(
|
|
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
|
|
),
|
|
)
|
|
|
|
const blockedID = ProviderV2.ID.make("blocked-location")
|
|
const allowedID = ProviderV2.ID.make("allowed-location")
|
|
const blockedState = yield* update(blocked.path, blockedID)
|
|
expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
|
|
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
|
|
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
|
|
"edit",
|
|
"glob",
|
|
"grep",
|
|
"patch",
|
|
"question",
|
|
"read",
|
|
"shell",
|
|
"skill",
|
|
"subagent",
|
|
"webfetch",
|
|
"websearch",
|
|
"write",
|
|
])
|
|
const allowedState = yield* update(allowed.path, allowedID)
|
|
expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
|
|
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
|
|
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
|
|
"edit",
|
|
"glob",
|
|
"grep",
|
|
"patch",
|
|
"question",
|
|
"read",
|
|
"shell",
|
|
"skill",
|
|
"subagent",
|
|
"webfetch",
|
|
"websearch",
|
|
"write",
|
|
])
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("rejects an unavailable selected model during location model resolution", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
|
yield* Effect.promise(() =>
|
|
fs.writeFile(
|
|
path.join(dir.path, "opencode.json"),
|
|
JSON.stringify({
|
|
providers: {
|
|
unavailable: {
|
|
name: "Unavailable",
|
|
package: "test-provider",
|
|
models: { chat: { disabled: true } },
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
)
|
|
const failure = yield* SessionRunnerModel.Service.use((models) =>
|
|
models.resolve(
|
|
SessionV2.Info.make({
|
|
id: SessionV2.ID.make("ses_unavailable_model"),
|
|
projectID: ProjectV2.ID.global,
|
|
title: "test",
|
|
model: {
|
|
id: ModelV2.ID.make("chat"),
|
|
providerID: ProviderV2.ID.make("unavailable"),
|
|
},
|
|
cost: Money.USD.zero,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
|
location,
|
|
}),
|
|
),
|
|
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
|
|
|
|
expect(failure).toMatchObject({
|
|
_tag: "SessionRunnerModel.ModelUnavailableError",
|
|
providerID: "unavailable",
|
|
modelID: "chat",
|
|
})
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("preserves the selected catalog identity when the package model id differs", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
|
const resolved = yield* Effect.gen(function* () {
|
|
const catalog = yield* Catalog.Service
|
|
yield* catalog.transform((editor) => {
|
|
editor.provider.update(ProviderV2.ID.make("aliased"), (provider) => {
|
|
provider.package = ProviderV2.aisdk("@ai-sdk/openai")
|
|
})
|
|
editor.model.update(ProviderV2.ID.make("aliased"), ModelV2.ID.make("fast"), (model) => {
|
|
// Catalog id and package model id intentionally differ, like gpt-5.5-fast -> gpt-5.5.
|
|
model.modelID = ModelV2.ID.make("base")
|
|
model.variants = [{ id: ModelV2.VariantID.make("high") }]
|
|
})
|
|
})
|
|
const models = yield* SessionRunnerModel.Service
|
|
return yield* models.resolve(
|
|
SessionV2.Info.make({
|
|
id: SessionV2.ID.make("ses_aliased_model"),
|
|
projectID: ProjectV2.ID.global,
|
|
title: "test",
|
|
model: {
|
|
id: ModelV2.ID.make("fast"),
|
|
providerID: ProviderV2.ID.make("aliased"),
|
|
variant: ModelV2.VariantID.make("high"),
|
|
},
|
|
cost: Money.USD.zero,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
|
location,
|
|
}),
|
|
)
|
|
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
|
|
|
|
expect(resolved.ref).toEqual(
|
|
ModelV2.Ref.make({
|
|
id: ModelV2.ID.make("fast"),
|
|
providerID: ProviderV2.ID.make("aliased"),
|
|
variant: ModelV2.VariantID.make("high"),
|
|
}),
|
|
)
|
|
expect(String(resolved.model.id)).toBe("base")
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("installs public plugins into a location", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((dir) =>
|
|
Effect.gen(function* () {
|
|
const plugins = yield* PluginV2.Service
|
|
const reviewer = EffectPlugin.define({
|
|
id: "reviewer",
|
|
effect: (ctx) =>
|
|
ctx.agent
|
|
.transform((agent) => {
|
|
agent.update("reviewer", (item) => {
|
|
item.description = "Reviews code"
|
|
item.mode = "subagent"
|
|
})
|
|
})
|
|
.pipe(Effect.asVoid),
|
|
})
|
|
yield* plugins.activate([reviewer])
|
|
|
|
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
|
|
description: "Reviews code",
|
|
mode: "subagent",
|
|
})
|
|
}).pipe(
|
|
Effect.scoped,
|
|
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
})
|