diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b24b30f27b..7a22368582 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -159,6 +159,12 @@ export function latest(entries: readonly Entry[], key: K): export interface Interface { /** Returns location config documents and discovery sources from lowest to highest priority. */ readonly entries: () => Effect.Effect + /** + * Streams raw filesystem updates under config roots. Config owns root + * topology and watch reconciliation; domain owners filter this feed for the + * source files they parse and rebuild their own state. + */ + readonly changes: () => Stream.Stream } export const Options = Schema.Struct({ @@ -430,6 +436,7 @@ export const layer = (options?: Options) => Layer.effect( entries: Effect.fn("Config.entries")(function* () { return configs }), + changes: () => Stream.fromPubSub(updates), }) }), ) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index a8b393bfb3..2c9f9bb542 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" -import { Effect, Schema } from "effect" +import { Effect, Schema, Stream } from "effect" import { Agent } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" @@ -67,6 +67,7 @@ describe("ConfigAgentPlugin.Plugin", () => { ) const config = Config.Service.of({ + changes: () => Stream.empty, entries: () => Effect.succeed([ new Config.Document({ @@ -152,6 +153,7 @@ describe("ConfigAgentPlugin.Plugin", () => { Effect.gen(function* () { const agents = yield* Agent.Service const config = Config.Service.of({ + changes: () => Stream.empty, entries: () => Effect.succeed([ new Config.Document({ @@ -220,6 +222,7 @@ describe("ConfigAgentPlugin.Plugin", () => { yield* agents.transform((editor) => editor.update(build, () => {})) const config = Config.Service.of({ + changes: () => Stream.empty, entries: () => Effect.succeed([ new Config.Document({ @@ -279,6 +282,7 @@ Use native v2 fields.`, }) const agents = yield* Agent.Service const config = Config.Service.of({ + changes: () => Stream.empty, entries: () => Effect.succeed([ new Config.Document({ @@ -320,6 +324,7 @@ function loadHomePermissions(home: string) { 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({ diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index aa3cf49145..f59531ae56 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -71,6 +71,7 @@ Review files`, Effect.provideService( Config.Service, Config.Service.of({ + changes: () => Stream.empty, entries: () => Effect.succeed([ new Config.Document({ diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 5c94cc59ea..1f0d8e95e2 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -215,6 +215,45 @@ describe("Config", () => { ), ) + it.live("exposes filesystem updates under config roots through changes", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + yield* Effect.promise(async () => { + 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 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) + + 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))) + }), + ), + ), + ) + it.effect("returns the latest defined scalar from priority-ordered documents", () => Effect.sync(() => { const entries = [ diff --git a/packages/core/test/config/policy.test.ts b/packages/core/test/config/policy.test.ts index a90d64ed99..5a9080697e 100644 --- a/packages/core/test/config/policy.test.ts +++ b/packages/core/test/config/policy.test.ts @@ -7,7 +7,7 @@ import { Bus } from "@opencode-ai/core/bus" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { Provider } from "@opencode-ai/core/provider" -import { Effect, Schema } from "effect" +import { Effect, Schema, Stream } from "effect" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "../plugin/fixture" @@ -28,7 +28,10 @@ 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({ entries: () => Effect.sync(entries) })), + Effect.provideService( + Config.Service, + Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.sync(entries) }), + ), ) }) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 152b34faff..055dde2366 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Money } from "@opencode-ai/schema/money" -import { Effect, Schema } from "effect" +import { Effect, Schema, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" @@ -55,6 +55,7 @@ describe("ConfigProviderPlugin.Plugin", () => { 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({ @@ -93,6 +94,7 @@ describe("ConfigProviderPlugin.Plugin", () => { }) }) const config = Config.Service.of({ + changes: () => Stream.empty, entries: () => Effect.succeed([ new Config.Document({ @@ -135,6 +137,7 @@ describe("ConfigProviderPlugin.Plugin", () => { 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({ @@ -187,6 +190,7 @@ describe("ConfigProviderPlugin.Plugin", () => { 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({ @@ -235,6 +239,7 @@ describe("ConfigProviderPlugin.Plugin", () => { 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({ diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts index 70d21a693b..4aca089378 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 } from "effect" +import { Effect, Schema, Stream } from "effect" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "../plugin/fixture" @@ -37,7 +37,7 @@ describe("config plugin reloads", () => { const skills = yield* Skill.Service const host = yield* PluginHost.make(plugins) let entries: Config.Entry[] = [config("first")] - const service = Config.Service.of({ entries: () => Effect.sync(() => entries) }) + const service = Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.sync(() => entries) }) const setup = (effect: Effect.Effect) => effect.pipe(Effect.provideService(Config.Service, service)) diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 2553498987..937463477e 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -1,6 +1,6 @@ import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer, Schema, Stream } from "effect" import { Config } from "@opencode-ai/core/config" import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill" import { Global } from "@opencode-ai/util/global" @@ -44,6 +44,7 @@ describe("ConfigSkillPlugin.Plugin", () => { Effect.provideService( Config.Service, Config.Service.of({ + changes: () => Stream.empty, entries: () => Effect.succeed([ new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }), diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index e475a8b166..26d03d8cd2 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -25,9 +25,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod const configLayer = Layer.succeed( Config.Service, - Config.Service.of({ - entries: () => Effect.succeed([]), - }), + Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }), ) function provide(directory: string, vcs?: Location.Interface["vcs"]) { diff --git a/packages/core/test/fixture/mcp.ts b/packages/core/test/fixture/mcp.ts index 1e76435dde..84f7bda1a1 100644 --- a/packages/core/test/fixture/mcp.ts +++ b/packages/core/test/fixture/mcp.ts @@ -1,4 +1,4 @@ -import { Effect, Layer } from "effect" +import { Effect, Layer, Stream } from "effect" import { Config } from "@opencode-ai/core/config" import { Location } from "@opencode-ai/core/location" import { MCP } from "@opencode-ai/core/mcp/index" @@ -23,7 +23,10 @@ export const emptyMcpLayer = Layer.succeed( }), ) -export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) +export const emptyConfigLayer = Layer.succeed( + Config.Service, + Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }), +) export const testLocationLayer = Layer.succeed( Location.Service, diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index b33fc860d7..1383ee0bbc 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -163,6 +163,7 @@ function resourceMcpLayer( Layer.succeed( Config.Service, Config.Service.of({ + changes: () => Stream.empty, entries: () => Effect.succeed([ new Config.Document({ diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 82ba8419d6..e5b087332d 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -21,7 +21,7 @@ import { Reference } from "@opencode-ai/core/reference" import { Skill } from "@opencode-ai/core/skill" import { Tool } from "@opencode-ai/core/tool" import { WebSearch } from "@opencode-ai/core/websearch" -import { Effect, Layer } from "effect" +import { Effect, Layer, Stream } from "effect" import { tempLocationLayer } from "../fixture/location" const npmLayer = Layer.succeed( @@ -60,6 +60,12 @@ export const PluginTestLayer = AppNodeBuilder.build( [ [Location.node, tempLocationLayer], [Npm.node, npmLayer], - [Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))], + [ + Config.node, + Layer.succeed( + Config.Service, + Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }), + ), + ], ], ) as unknown as Layer.Layer diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 7ce387f97f..9e606ff610 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -4,7 +4,7 @@ import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "@opencode-ai/core/location" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { Skill } from "@opencode-ai/core/skill" @@ -28,7 +28,10 @@ describe("SkillPlugin.Plugin", () => { }, }), ).pipe( - Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })), + Effect.provideService( + Config.Service, + Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }), + ), 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 61046aefc8..2ef7b6a01e 100644 --- a/packages/core/test/plugin/websearch-fixture.ts +++ b/packages/core/test/plugin/websearch-fixture.ts @@ -1,4 +1,4 @@ -import { Effect, Layer } from "effect" +import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" @@ -43,7 +43,15 @@ export const webSearchIntegrationTest = testEffect( Layer.merge( AppNodeBuilder.build( LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]), - [[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]], + [ + [ + Config.node, + Layer.succeed( + Config.Service, + Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }), + ), + ], + ], ), http, ), diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index bee32501ec..704f58d573 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" -import { DateTime, Effect, Layer } from "effect" +import { DateTime, Effect, Layer, Stream } from "effect" import { Message } from "@opencode-ai/ai" import { Agent } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -70,7 +70,10 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) +const config = Layer.succeed( + Config.Service, + Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }), +) 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 a430cb212a..d00387fac8 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -40,7 +40,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" -import { Effect, Layer } from "effect" +import { Effect, Layer, Stream } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" import { agentHost, catalogHost, host } from "./plugin/host" @@ -88,7 +88,10 @@ 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({ entries: () => Effect.succeed([]) })) +const config = Layer.succeed( + Config.Service, + Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }), +) 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 2c4876235c..7be6e0cc5e 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -358,6 +358,7 @@ const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect const config = Layer.succeed( Config.Service, Config.Service.of({ + changes: () => Stream.empty, entries: () => Effect.succeed([ new Config.Document({ diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 086e22aef7..374c12155a 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect } from "bun:test" import path from "path" -import { Effect, Exit, Layer, PlatformError } from "effect" +import { Effect, Exit, Layer, PlatformError, Stream } from "effect" import { Config } from "@opencode-ai/core/config" import { ConfigAttachments } from "@opencode-ai/core/config/attachments" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -100,7 +100,10 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) })) +const config = Layer.succeed( + Config.Service, + Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed(configEntries) }), +) const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) const testFileSystem = Layer.effect( FSUtil.Service, diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts index 39a6509c18..c7a0424f2e 100644 --- a/packages/simulation/test/simulated-provider.test.ts +++ b/packages/simulation/test/simulated-provider.test.ts @@ -701,7 +701,15 @@ const toolLifecycleLayer = (endpoint: string) => { }) return AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, provider]), - [[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]], + [ + [ + Config.node, + Layer.succeed( + Config.Service, + Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }), + ), + ], + ], ) }