feat(core): expose config changes stream (#39131)

This commit is contained in:
Kit Langton 2026-07-27 13:38:08 -04:00 committed by GitHub
commit 02c37c401a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 123 additions and 25 deletions

View file

@ -159,6 +159,12 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
export interface Interface {
/** Returns location config documents and discovery sources from lowest to highest priority. */
readonly entries: () => Effect.Effect<Entry[]>
/**
* 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<Watcher.Update>
}
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),
})
}),
)

View file

@ -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({

View file

@ -71,6 +71,7 @@ Review files`,
Effect.provideService(
Config.Service,
Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({

View file

@ -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<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 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 = [

View file

@ -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) }),
),
)
})

View file

@ -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({

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 } 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 = <R>(effect: Effect.Effect<void, never, R>) =>
effect.pipe(Effect.provideService(Config.Service, service))

View file

@ -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") }),

View file

@ -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"]) {

View file

@ -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,

View file

@ -163,6 +163,7 @@ function resourceMcpLayer(
Layer.succeed(
Config.Service,
Config.Service.of({
changes: () => Stream.empty,
entries: () =>
Effect.succeed([
new Config.Document({

View file

@ -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<unknown, never>

View file

@ -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) })),

View file

@ -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,
),

View file

@ -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(

View file

@ -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: {

View file

@ -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({

View file

@ -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,

View file

@ -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([]) }),
),
],
],
)
}