diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7a223685821..f27a42c3a5a 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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()("@opencode/Config") {} +export interface TestInterface extends Interface { + /** Replaces the entries returned by subsequent entries() calls. */ + readonly setEntries: (entries: Entry[]) => Effect.Effect + /** Emits one filesystem update to every changes() subscriber. */ + readonly emitChange: (update: Watcher.Update) => Effect.Effect +} + +export class Test extends Context.Service()("@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() + 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* () { diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index e4ef94c5435..5da2ef86783 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -56,6 +56,32 @@ export type Options = typeof Options.Type export class Service extends Context.Service()("@opencode/Watcher") {} +export interface TestInterface extends Interface { + /** Broadcasts one update to every subscriber. */ + readonly emit: (update: Update) => Effect.Effect + /** Returns every subscribe call observed so far, in order. */ + readonly subscriptions: () => Effect.Effect +} + +export class Test extends Context.Service()("@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() + 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* () { diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 2c9f9bb5421..161748e2306 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -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 })), ) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index f59531ae56c..e8202ff81a9 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -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) }), + ]), ), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 1f0d8e95e2d..bdb9bde7d0b 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -67,7 +67,7 @@ function testLayer( globalDirectory = path.join(directory, "global"), projectDirectory = directory, vcs?: Project.Vcs, - watcher?: Layer.Layer, + watcher: Layer.Layer = 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() - 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() - 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))) }), ), ), diff --git a/packages/core/test/config/policy.test.ts b/packages/core/test/config/policy.test.ts index 5a9080697e1..e85b8271a9d 100644 --- a/packages/core/test/config/policy.test.ts +++ b/packages/core/test/config/policy.test.ts @@ -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" })]))), ) }) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 055dde23660..de10dbcd368 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -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(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)) diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts index 4aca089378f..399b60a1d28 100644 --- a/packages/core/test/config/reload.test.ts +++ b/packages/core/test/config/reload.test.ts @@ -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 = (effect: Effect.Effect) => - 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())), + ), ) }) diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 937463477e7..bf4264f3eb6 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -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/"], + }), + }), + ]), ), ) diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 26d03d8cd25..c2a97740c8a 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -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( diff --git a/packages/core/test/fixture/mcp.ts b/packages/core/test/fixture/mcp.ts index 84f7bda1a12..3fc50105ca0 100644 --- a/packages/core/test/fixture/mcp.ts +++ b/packages/core/test/fixture/mcp.ts @@ -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, diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 1383ee0bbcb..0d99c00f2d5 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -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, diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index e5b087332d4..f9478c8b7f0 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -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 diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 9e606ff610f..e048177efd7 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -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) })), diff --git a/packages/core/test/plugin/websearch-fixture.ts b/packages/core/test/plugin/websearch-fixture.ts index 2ef7b6a01e3..c6734e57a5b 100644 --- a/packages/core/test/plugin/websearch-fixture.ts +++ b/packages/core/test/plugin/websearch-fixture.ts @@ -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(), ], ], ), diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 704f58d5735..1ccfec20683 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -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( diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index d00387fac82..6fd9ef67dad 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -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: { diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index e7c8e057835..68f03f4c607 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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, diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 374c12155ac..387ad59bbc7 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -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) => - 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( diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts index c7a0424f2e0..e21b5f3b065 100644 --- a/packages/simulation/test/simulated-provider.test.ts +++ b/packages/simulation/test/simulated-provider.test.ts @@ -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(), ], ], )