test(core): add config and watcher test services (#39157)

This commit is contained in:
Kit Langton 2026-07-27 14:32:38 -04:00 committed by GitHub
parent f5700808c5
commit 713658c07b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 468 additions and 498 deletions

View file

@ -4,7 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
import { Context, Effect, Fiber, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
@ -176,6 +176,31 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
export interface TestInterface extends Interface {
/** Replaces the entries returned by subsequent entries() calls. */
readonly setEntries: (entries: Entry[]) => Effect.Effect<void>
/** Emits one filesystem update to every changes() subscriber. */
readonly emitChange: (update: Watcher.Update) => Effect.Effect<void>
}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/Config/Test") {}
/** In-memory config for tests: static entries with replaceable state and a test-driven change feed. */
export const testLayer = (initial: Entry[] = []) =>
Layer.effectContext(
Effect.gen(function* () {
const entries = yield* Ref.make(initial)
const updates = yield* PubSub.unbounded<Watcher.Update>()
const service = Test.of({
entries: () => Ref.get(entries),
changes: () => Stream.fromPubSub(updates),
setEntries: (next) => Ref.set(entries, next),
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
})
return Context.empty().pipe(Context.add(Service, service), Context.add(Test, service))
}),
)
export const layer = (options?: Options) => Layer.effect(
Service,
Effect.gen(function* () {

View file

@ -56,6 +56,32 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
export interface TestInterface extends Interface {
/** Broadcasts one update to every subscriber. */
readonly emit: (update: Update) => Effect.Effect<void>
/** Returns every subscribe call observed so far, in order. */
readonly subscriptions: () => Effect.Effect<readonly WatchInput[]>
}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/Watcher/Test") {}
/** In-memory watcher for tests: records subscribe calls and broadcasts emitted updates. */
export const testLayer = Layer.effectContext(
Effect.gen(function* () {
const updates = yield* PubSub.unbounded<Update>()
const subscriptions: WatchInput[] = []
const service = Test.of({
subscribe: (input) => {
subscriptions.push(input)
return Stream.fromPubSub(updates)
},
emit: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
subscriptions: () => Effect.sync(() => [...subscriptions]),
})
return Context.empty().pipe(Context.add(Service, service), Context.add(Test, service))
}),
)
export const layer = (options?: Options) => Layer.effect(
Service,
Effect.gen(function* () {

View file

@ -31,9 +31,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches POSIX paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("/home/test")
expect(Permission.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe(
"allow",
)
expect(Permission.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe("allow")
expect(Permission.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
@ -66,49 +64,45 @@ describe("ConfigAgentPlugin.Plugin", () => {
}),
)
const config = Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
agents: {
build: {
permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
},
reviewer: {
model: "openrouter/openai/gpt-5",
description: "Review changes",
mode: "subagent",
permissions: [
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "deny" },
],
},
removed: { description: "Removed later" },
},
}),
}),
new Config.Document({
type: "document",
info: decode({
permissions: [{ action: "read", resource: "*", effect: "allow" }],
agents: {
reviewer: { model: "openrouter/openai/gpt-5#high", hidden: true },
removed: { disabled: true },
late: {
permissions: [{ action: "edit", resource: "*", effect: "allow" }],
},
},
}),
}),
]),
})
const entries = [
new Config.Document({
type: "document",
info: decode({
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
agents: {
build: {
permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
},
reviewer: {
model: "openrouter/openai/gpt-5",
description: "Review changes",
mode: "subagent",
permissions: [
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "deny" },
],
},
removed: { description: "Removed later" },
},
}),
}),
new Config.Document({
type: "document",
info: decode({
permissions: [{ action: "read", resource: "*", effect: "allow" }],
agents: {
reviewer: { model: "openrouter/openai/gpt-5#high", hidden: true },
removed: { disabled: true },
late: {
permissions: [{ action: "edit", resource: "*", effect: "allow" }],
},
},
}),
}),
]
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
Effect.provide(Config.testLayer(entries)),
)
const buildAgent = yield* agents.get(build)
@ -152,48 +146,44 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const config = Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
agents: {
reviewer: {
model: "anthropic/claude-sonnet",
system: "Review carefully.",
description: "Reviews changes",
mode: "subagent",
hidden: true,
color: "#ff6b6b",
steps: 12,
request: {
headers: { first: "one", shared: "first" },
body: { enabled: true, profile: "review", effort: "medium" },
},
},
const entries = [
new Config.Document({
type: "document",
info: decode({
agents: {
reviewer: {
model: "anthropic/claude-sonnet",
system: "Review carefully.",
description: "Reviews changes",
mode: "subagent",
hidden: true,
color: "#ff6b6b",
steps: 12,
request: {
headers: { first: "one", shared: "first" },
body: { enabled: true, profile: "review", effort: "medium" },
},
}),
}),
new Config.Document({
type: "document",
info: decode({
agents: {
reviewer: {
request: {
headers: { shared: "last", second: "two" },
body: { retries: 2, effort: "high" },
},
},
},
},
}),
}),
new Config.Document({
type: "document",
info: decode({
agents: {
reviewer: {
request: {
headers: { shared: "last", second: "two" },
body: { retries: 2, effort: "high" },
},
}),
}),
]),
})
},
},
}),
}),
]
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
Effect.provide(Config.testLayer(entries)),
)
const reviewer = yield* agents.get(Agent.ID.make("reviewer"))
@ -221,19 +211,15 @@ describe("ConfigAgentPlugin.Plugin", () => {
const build = Agent.ID.make("build")
yield* agents.transform((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ agents: { build: { disabled: true } } }),
}),
]),
})
const entries = [
new Config.Document({
type: "document",
info: decode({ agents: { build: { disabled: true } } }),
}),
]
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
Effect.provide(Config.testLayer(entries)),
)
expect(yield* agents.get(build)).toBeUndefined()
@ -281,20 +267,16 @@ Use native v2 fields.`,
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
})
const agents = yield* Agent.Service
const config = Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ agents: { reviewer: { description: "JSON description" } } }),
}),
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
]),
})
const entries = [
new Config.Document({
type: "document",
info: decode({ agents: { reviewer: { description: "JSON description" } } }),
}),
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
]
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
Effect.provide(Config.testLayer(entries)),
)
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
@ -323,41 +305,37 @@ function loadHomePermissions(home: string) {
const agents = yield* Agent.Service
const build = Agent.ID.make("build")
yield* agents.transform((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
const entries = [
new Config.Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
permission: {
external_directory: {
"~/p/**": "allow",
"/some/~/path": "deny",
"$HOMELESS/**": "deny",
},
bash: {
"$HOME/private/**": "deny",
},
},
agent: {
build: {
permission: {
external_directory: {
"~/p/**": "allow",
"/some/~/path": "deny",
"$HOMELESS/**": "deny",
},
bash: {
"$HOME/private/**": "deny",
"$HOME/cache/**": "deny",
},
},
agent: {
build: {
permission: {
external_directory: {
"$HOME/cache/**": "deny",
},
},
},
},
}),
),
},
},
}),
]),
})
),
}),
]
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
Effect.provide(Config.testLayer(entries)),
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
)

View file

@ -68,19 +68,14 @@ Review files`,
event: { subscribe: () => Stream.fromPubSub(updates) },
}),
).pipe(
Effect.provideService(
Config.Service,
Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ commands: { review: { template: "Inline review" } } }),
}),
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
]),
}),
Effect.provide(
Config.testLayer([
new Config.Document({
type: "document",
info: decode({ commands: { review: { template: "Inline review" } } }),
}),
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
]),
),
)

View file

@ -67,7 +67,7 @@ function testLayer(
globalDirectory = path.join(directory, "global"),
projectDirectory = directory,
vcs?: Project.Vcs,
watcher?: Layer.Layer<Watcher.Service>,
watcher: Layer.Layer<Watcher.Service | Watcher.Test> = Watcher.testLayer,
credentialNode = emptyCredentialNode,
wellknownNode = emptyWellknownNode,
options?: Config.Options,
@ -81,14 +81,17 @@ function testLayer(
),
),
)
return AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
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 ? ([[Watcher.node, watcher]] as const) : []),
[Watcher.node, watcher],
])
// Merge the watcher layer by reference so Watcher.Test resolves to the same
// memoized instance the built graph uses.
return Layer.mergeAll(built, watcher)
}
const provider = {
@ -184,32 +187,22 @@ describe("Config", () => {
await fs.mkdir(project, { recursive: true })
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
})
const updates = yield* PubSub.unbounded<Watcher.Update>()
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: () => Stream.fromPubSub(updates),
}),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Test
const changed = yield* bus
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, {
type: "update",
path: path.join(global, "commands", "review.md"),
} satisfies Watcher.Update)
yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
yield* watcher.emit({ type: "update", path: file })
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
}).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
}).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
}),
),
),
@ -228,32 +221,44 @@ describe("Config", () => {
await fs.mkdir(path.join(global, "commands"), { recursive: true })
await fs.mkdir(project, { recursive: true })
})
const updates = yield* PubSub.unbounded<Watcher.Update>()
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: () => Stream.fromPubSub(updates),
}),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const watcher = yield* Watcher.Test
const received = yield* config
.changes()
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
yield* Effect.sleep("10 millis")
const file = path.join(global, "commands", "review.md")
yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
yield* watcher.emit({ type: "update", path: file })
const collected = yield* Fiber.join(received).pipe(Effect.timeout("1 second"))
expect(Array.from(collected)).toEqual([{ type: "update", path: file }])
}).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
}).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
}),
),
),
)
it.effect("backs Config.Service and Config.Test with one shared test implementation", () =>
Effect.gen(function* () {
const config = yield* Config.Service
const test = yield* Config.Test
expect(yield* config.entries()).toEqual([])
const entry = new Config.Document({ type: "document", info: new Config.Info({}) })
yield* test.setEntries([entry])
expect(yield* config.entries()).toEqual([entry])
const received = yield* config
.changes()
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
yield* test.emitChange({ type: "create", path: "/root/commands/review.md" })
expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "create", path: "/root/commands/review.md" }])
}).pipe(Effect.provide(Config.testLayer())),
)
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => {
const entries = [
@ -544,23 +549,15 @@ describe("Config", () => {
fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
]),
)
const targets: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => {
targets.push(input)
return Stream.never
},
}),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const watcher = yield* Watcher.Test
yield* config.entries()
expect(targets).toEqual([{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }])
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher)))
expect(yield* watcher.subscriptions()).toEqual([
{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) },
])
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
}),
),
),

View file

@ -24,15 +24,10 @@ const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
}),
})
const addPlugin = Effect.fn(function* (entries: () => Config.Entry[]) {
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(
Effect.provideService(
Config.Service,
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.sync(entries) }),
),
)
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
})
describe("ConfigPolicyPlugin.Plugin", () => {
@ -44,7 +39,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
catalog.provider.update(Provider.ID.anthropic, () => {})
catalog.provider.update(Provider.ID.make("company-internal"), () => {})
})
yield* addPlugin(() => [
yield* addPlugin([
policies(
{ effect: "deny", resource: "*" },
{ effect: "allow", resource: "anthropic" },
@ -62,7 +57,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
yield* addPlugin(() => [
yield* addPlugin([
policies({ effect: "deny", resource: "openai" }),
policies({ effect: "allow", resource: "openai" }),
])
@ -75,17 +70,17 @@ describe("ConfigPolicyPlugin.Plugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
let entries: Config.Entry[] = [policies({ effect: "deny", resource: "openai" })]
const test = yield* Config.Test
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
yield* addPlugin(() => entries)
yield* ConfigPolicyPlugin.Plugin.effect(host)
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
entries = [policies({ effect: "allow", resource: "openai" })]
yield* test.setEntries([policies({ effect: "allow", resource: "openai" })])
yield* bus.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
catalog.provider.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)),
)
}),
yield* waitUntil(catalog.provider.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)))
}).pipe(Effect.provide(Config.testLayer([policies({ effect: "deny", resource: "openai" })]))),
)
})

View file

@ -14,10 +14,10 @@ import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (config: Config.Interface) {
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config))
yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
})
function required<T>(value: T | undefined): T {
@ -54,25 +54,21 @@ describe("ConfigProviderPlugin.Plugin", () => {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("chat")
const config = Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
models: { chat: {} },
},
},
}),
}),
]),
})
const entries = [
new Config.Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
models: { chat: {} },
},
},
}),
}),
]
yield* addPlugin(config)
yield* addPlugin(entries)
const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] })
@ -93,30 +89,26 @@ describe("ConfigProviderPlugin.Plugin", () => {
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
})
})
const config = Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
models: {
inherited: { name: "Inherited" },
overridden: {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
},
},
const entries = [
new Config.Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
models: {
inherited: { name: "Inherited" },
overridden: {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
},
},
}),
}),
]),
})
},
},
}),
}),
]
yield* addPlugin(config)
yield* addPlugin(entries)
expect((yield* catalog.model.get(providerID, inheritedID))?.capabilities).toEqual({
tools: false,
@ -136,39 +128,35 @@ describe("ConfigProviderPlugin.Plugin", () => {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.opencode
const modelID = Model.ID.make("alpha-gpt-next")
const config = Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
package: "aisdk:@ai-sdk/openai",
settings: { baseURL: "https://opencode.test/v1" },
models: {
"alpha-gpt-next": {
variants: [
{
id: "high",
body: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
],
const entries = [
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
package: "aisdk:@ai-sdk/openai",
settings: { baseURL: "https://opencode.test/v1" },
models: {
"alpha-gpt-next": {
variants: [
{
id: "high",
body: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
},
],
},
},
}),
}),
]),
})
},
},
}),
}),
]
yield* addPlugin(config)
yield* addPlugin(entries)
const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.variants).toMatchObject([
@ -189,39 +177,35 @@ describe("ConfigProviderPlugin.Plugin", () => {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.opencode
const modelID = Model.ID.make("alpha-gpt-next")
const config = Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
package: "aisdk:@ai-sdk/openai",
settings: { baseURL: "https://opencode.test/v1" },
const entries = [
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
package: "aisdk:@ai-sdk/openai",
settings: { baseURL: "https://opencode.test/v1" },
},
},
}),
}),
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
models: {
"alpha-gpt-next": {
variants: [{ id: "high", body: { reasoningEffort: "high" } }],
},
},
}),
}),
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
models: {
"alpha-gpt-next": {
variants: [{ id: "high", body: { reasoningEffort: "high" } }],
},
},
},
},
}),
}),
]),
})
},
},
}),
}),
]
yield* addPlugin(config)
yield* addPlugin(entries)
const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.variants?.[0]).toMatchObject({
@ -238,88 +222,84 @@ describe("ConfigProviderPlugin.Plugin", () => {
const integrations = yield* Integration.Service
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("chat")
const config = Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
model: "custom/first",
providers: {
custom: {
name: "Configured",
env: ["CUSTOM_API_KEY"],
package: "native",
const entries = [
new Config.Document({
type: "document",
info: decode({
model: "custom/first",
providers: {
custom: {
name: "Configured",
env: ["CUSTOM_API_KEY"],
package: "native",
headers: { first: "first", shared: "first" },
models: {
chat: {
name: "First",
compatibility: { reasoningField: "vendor_reasoning" },
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
settings: { retained: true },
headers: { first: "first", shared: "first" },
models: {
chat: {
name: "First",
compatibility: { reasoningField: "vendor_reasoning" },
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
settings: { retained: true },
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
},
],
},
},
],
},
},
}),
}),
new Config.Document({
type: "document",
info: decode({
model: "custom/default",
providers: {
custom: {
package: "aisdk:custom-sdk",
settings: { baseURL: "https://example.test" },
},
},
}),
}),
new Config.Document({
type: "document",
info: decode({
model: "custom/default",
providers: {
custom: {
package: "aisdk:custom-sdk",
settings: { baseURL: "https://example.test" },
headers: { last: "last", shared: "last" },
models: {
default: {
name: "Default",
},
chat: {
modelID: "api-chat",
name: "Last",
limit: { output: 75 },
headers: { last: "last", shared: "last" },
models: {
default: {
name: "Default",
},
chat: {
modelID: "api-chat",
name: "Last",
limit: { output: 75 },
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
},
{
id: "slow",
headers: { slow: "slow" },
},
],
},
},
{
id: "slow",
headers: { slow: "slow" },
},
],
},
},
}),
}),
new Config.Document({
type: "document",
info: decode({
providers: {
custom: { name: "Renamed" },
},
}),
}),
]),
})
},
},
}),
}),
new Config.Document({
type: "document",
info: decode({
providers: {
custom: { name: "Renamed" },
},
}),
}),
]
yield* addPlugin(config)
yield* addPlugin(entries)
const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID))

View file

@ -17,7 +17,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Provider } from "@opencode-ai/core/provider"
import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { Effect, Schema, Stream } from "effect"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@ -36,16 +36,13 @@ describe("config plugin reloads", () => {
const references = yield* Reference.Service
const skills = yield* Skill.Service
const host = yield* PluginHost.make(plugins)
let entries: Config.Entry[] = [config("first")]
const service = Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.sync(() => entries) })
const setup = <R>(effect: Effect.Effect<void, never, R>) =>
effect.pipe(Effect.provideService(Config.Service, service))
const test = yield* Config.Test
yield* setup(ConfigAgentPlugin.Plugin.effect(host))
yield* setup(ConfigCommandPlugin.Plugin.effect(host))
yield* setup(ConfigSkillPlugin.Plugin.effect(host))
yield* setup(ConfigReferencePlugin.Plugin.effect(host))
yield* setup(ConfigProviderPlugin.Plugin.effect(host))
yield* ConfigAgentPlugin.Plugin.effect(host)
yield* ConfigCommandPlugin.Plugin.effect(host)
yield* ConfigSkillPlugin.Plugin.effect(host)
yield* ConfigReferencePlugin.Plugin.effect(host)
yield* ConfigProviderPlugin.Plugin.effect(host)
expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent")
expect((yield* commands.get("first"))?.description).toBe("First command")
@ -55,7 +52,7 @@ describe("config plugin reloads", () => {
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined()
entries = [config("second")]
yield* test.setEntries([config("second")])
yield* bus.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
Effect.gen(function* () {
@ -77,7 +74,10 @@ describe("config plugin reloads", () => {
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true)
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
}).pipe(
Effect.provide(Config.testLayer([config("first")])),
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
),
)
})

View file

@ -41,23 +41,18 @@ describe("ConfigSkillPlugin.Plugin", () => {
).pipe(
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
Effect.provideService(
Config.Service,
Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
new Config.Document({
type: "document",
info: decode({
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
}),
}),
]),
}),
Effect.provide(
Config.testLayer([
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
new Config.Document({
type: "document",
info: decode({
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
}),
}),
]),
),
)

View file

@ -23,10 +23,25 @@ type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
const configLayer = Layer.succeed(
Config.Service,
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
)
const configLayer = Config.testLayer()
describe("Watcher.testLayer", () => {
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
Effect.gen(function* () {
const watcher = yield* Watcher.Service
const test = yield* Watcher.Test
const received = yield* watcher
.subscribe({ path: "/root", type: "directory" })
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
yield* test.emit({ type: "update", path: "/root/file.md" })
expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "update", path: "/root/file.md" }])
expect(yield* test.subscriptions()).toEqual([{ path: "/root", type: "directory" }])
}).pipe(Effect.provide(Watcher.testLayer)),
)
})
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
const locationLayer = Layer.succeed(

View file

@ -23,10 +23,7 @@ export const emptyMcpLayer = Layer.succeed(
}),
)
export const emptyConfigLayer = Layer.succeed(
Config.Service,
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
)
export const emptyConfigLayer = Config.testLayer()
export const testLocationLayer = Layer.succeed(
Location.Service,

View file

@ -160,28 +160,21 @@ function resourceMcpLayer(
Layer.provideMerge(Form.layer),
Layer.provide(
Layer.mergeAll(
Layer.succeed(
Config.Service,
Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: new Config.Info({
mcp: new ConfigMCP.Info({
servers: {
resources:
typeof server === "string"
? new ConfigMCP.Remote({ type: "remote", url: server, oauth: false })
: server,
},
}),
}),
}),
]),
Config.testLayer([
new Config.Document({
type: "document",
info: new Config.Info({
mcp: new ConfigMCP.Info({
servers: {
resources:
typeof server === "string"
? new ConfigMCP.Remote({ type: "remote", url: server, oauth: false })
: server,
},
}),
}),
}),
),
]),
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
Layer.mock(Bus.Service, {
subscribe: () => Stream.never,

View file

@ -60,12 +60,6 @@ export const PluginTestLayer = AppNodeBuilder.build(
[
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
[
Config.node,
Layer.succeed(
Config.Service,
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
),
],
[Config.node, Config.testLayer()],
],
) as unknown as Layer.Layer<unknown, never>

View file

@ -28,10 +28,7 @@ describe("SkillPlugin.Plugin", () => {
},
}),
).pipe(
Effect.provideService(
Config.Service,
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
),
Effect.provide(Config.testLayer()),
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),

View file

@ -46,10 +46,7 @@ export const webSearchIntegrationTest = testEffect(
[
[
Config.node,
Layer.succeed(
Config.Service,
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
),
Config.testLayer(),
],
],
),

View file

@ -70,10 +70,7 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
)
const config = Config.testLayer()
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
const testLayer = AppNodeBuilder.build(

View file

@ -88,10 +88,7 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
load: () => Effect.succeed(Instructions.empty),
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Layer.succeed(
Config.Service,
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
)
const config = Config.testLayer()
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {

View file

@ -356,24 +356,17 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
load: () => Effect.succeed(Instructions.empty),
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Layer.succeed(
Config.Service,
Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: new Config.Info({
compaction: new ConfigCompaction.Info({
buffer: 3_000,
keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
}),
}),
}),
]),
const config = Config.testLayer([
new Config.Document({
type: "document",
info: new Config.Info({
compaction: new ConfigCompaction.Info({
buffer: 3_000,
keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
}),
}),
}),
)
])
let pluginFlushHook = Effect.void
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,

View file

@ -56,7 +56,6 @@ let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
mime: "text/plain",
}
let readFailure: ReadToolFileSystem.ReadError | undefined
let configEntries: Config.Entry[] = []
const reader = Layer.succeed(
ReadToolFileSystem.Service,
ReadToolFileSystem.Service.of({
@ -100,10 +99,7 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed(configEntries) }),
)
const config = Config.testLayer()
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
const testFileSystem = Layer.effect(
FSUtil.Service,
@ -161,16 +157,20 @@ const unavailableImage = Layer.succeed(
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
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 })],
])
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 })],
]),
// Merge by reference so Config.Test resolves to the memoized instance.
config,
)
const it = testEffect(readLayer(imageLayer))
const itWithoutResizer = testEffect(readLayer(unavailableImage))
const sessionID = Session.ID.make("ses_read_tool_test")
@ -192,7 +192,6 @@ describe("ReadTool", () => {
mime: "text/plain",
}
readFailure = undefined
configEntries = []
})
it.effect("registers, authorizes, and reads through the location filesystem", () =>
@ -409,7 +408,8 @@ describe("ReadTool", () => {
encoding: "base64",
mime: "image/png",
}
configEntries = [
const configTest = yield* Config.Test
yield* configTest.setEntries([
new Config.Document({
type: "document",
info: new Config.Info({
@ -418,7 +418,7 @@ describe("ReadTool", () => {
}),
}),
}),
]
])
const registry = yield* Tool.Service
expect(
@ -451,14 +451,15 @@ describe("ReadTool", () => {
encoding: "base64",
mime: "image/png",
}
configEntries = [
const configTest = yield* Config.Test
yield* configTest.setEntries([
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
}),
}),
]
])
const registry = yield* Tool.Service
const result = yield* executeTool(registry, {
sessionID,
@ -489,7 +490,8 @@ describe("ReadTool", () => {
encoding: "base64",
mime: "image/png",
}
configEntries = [
const configTest = yield* Config.Test
yield* configTest.setEntries([
new Config.Document({
type: "document",
info: new Config.Info({
@ -498,7 +500,7 @@ describe("ReadTool", () => {
}),
}),
}),
]
])
const registry = yield* Tool.Service
expect(

View file

@ -704,10 +704,7 @@ const toolLifecycleLayer = (endpoint: string) => {
[
[
Config.node,
Layer.succeed(
Config.Service,
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
),
Config.testLayer(),
],
],
)