Compare commits

...
6 changed files with 399 additions and 114 deletions
+6 -17
View File
@@ -8,6 +8,7 @@ import { Effect, Option, Schema, Stream } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { ConfigMarkdown } from "../markdown.js"
import { ConfigSourceWatch } from "../source-watch.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigAgentV1 } from "../../v1/config/agent.js"
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
@@ -52,6 +53,7 @@ export const Plugin = define({
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const sources = yield* ConfigSourceWatch.make(sourceDirectories)
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
if (entry.type === "document") return [entry]
if (entry.type !== "directory") return []
@@ -64,7 +66,9 @@ export const Plugin = define({
).pipe(Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)))
})
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
const entries = yield* config.entries()
yield* sources.reconcile(entries)
return yield* Effect.forEach(entries, loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as Document[] }
const reload = load().pipe(
@@ -74,13 +78,8 @@ export const Plugin = define({
// One merged trigger stream serializes reloads and shares one debounce
// window; subscribing before the initial scan means updates racing the
// scan still trigger a rebuild.
const sourceChanges = config
.changes()
.pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isAgentSource(entries, update.path))),
)
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
yield* Stream.merge(sourceChanges, configUpdates).pipe(
yield* Stream.merge(sources.changes, configUpdates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
@@ -134,16 +133,6 @@ export const Plugin = define({
}),
})
// Matches anything at or under <root>/{agent,agents,mode,modes}. No file-suffix
// check: directory-level events such as renames carry no per-file paths.
function isAgentSource(entries: Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((name) => FSUtil.contains(path.join(entry.path, name), file)),
)
}
function expandPermissions(rules: Permission.Ruleset, home: string): Permission.Ruleset {
// Expand only resources tools resolve as filesystem paths. Bash resources are raw shell text:
// rewriting `$HOME/private/**` would miss `$HOME/private/key`, and safe expansion needs shell-aware parsing.
+8 -21
View File
@@ -15,6 +15,7 @@ import { Location } from "../../location.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
import { ConfigSourceWatch } from "../source-watch.js"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
@@ -23,6 +24,7 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const sources = yield* ConfigSourceWatch.make(sourceDirectories)
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
if (entry.type === "document") return [{ commands: entry.info.commands }]
if (entry.type !== "directory") return []
@@ -33,7 +35,9 @@ export const Plugin = define({
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
const entries = yield* config.entries()
yield* sources.reconcile(entries)
return yield* Effect.forEach(entries, loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as { commands: Info["commands"] }[] }
const reload = load().pipe(
@@ -43,15 +47,8 @@ export const Plugin = define({
// One merged trigger stream serializes reloads and shares one debounce
// window; subscribing before the initial scan means updates racing the
// scan still trigger a rebuild.
const sourceChanges = config
.changes()
.pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isCommandSource(entries, update.path)),
),
)
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
yield* Stream.merge(sourceChanges, configUpdates).pipe(
yield* Stream.merge(sources.changes, configUpdates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
@@ -105,16 +102,6 @@ export const Plugin = define({
// Keep in sync with the loadDirectory scan pattern and the name-strip regex in decode.
const sourceDirectories = ["command", "commands"] as const
// Matches anything at or under <root>/{command,commands}. No file-suffix check:
// directory-level events such as renames carry no per-file paths.
function isCommandSource(entries: Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((name) => FSUtil.contains(path.join(entry.path, name), file)),
)
}
function loadDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
@@ -191,8 +178,8 @@ function evaluateTemplate(
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
Effect.mapError(
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
+49
View File
@@ -0,0 +1,49 @@
export * as ConfigSourceWatch from "./source-watch.js"
import path from "path"
import type { Entry } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, FiberMap, PubSub, Stream } from "effect"
import { Watcher } from "../filesystem/watcher.js"
/** Scoped root subscriptions for the source directories a config plugin reads. */
export const make = Effect.fn("ConfigSourceWatch.make")(function* (directories: readonly string[]) {
const watcher = yield* Watcher.Service
const watches = yield* FiberMap.make<string>()
const changes = yield* PubSub.sliding<void>(1)
// Match Config's ignores so equivalent subscriptions share an OS watch.
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
return {
changes: Stream.fromPubSub(changes),
reconcile: Effect.fn("ConfigSourceWatch.reconcile")(function* (entries: readonly Entry[]) {
const roots = new Set(entries.flatMap((entry) => (entry.type === "directory" ? [path.resolve(entry.path)] : [])))
yield* Effect.forEach(
Array.from(watches).filter(([root]) => !roots.has(root)),
([root]) => FiberMap.remove(watches, root),
{ discard: true },
)
yield* Effect.forEach(
roots,
Effect.fnUntraced(function* (root) {
if (yield* FiberMap.has(watches, root)) return
// Watch the root even before a source subdirectory exists. Directory
// rename events have no suffix and must trigger the same rebuild.
const updates = yield* watcher.subscribe({ path: root, type: "directory", ignore })
yield* FiberMap.run(
watches,
root,
updates.pipe(
Stream.filter((update) =>
directories.some((name) => FSUtil.contains(path.join(root, name), update.path)),
),
Stream.runForEach(() => PubSub.publish(changes, undefined)),
),
{ onlyIfMissing: true, startImmediately: true },
)
}),
{ discard: true },
)
}),
}
})
+122 -15
View File
@@ -1,13 +1,14 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Fiber, Schema, Stream } from "effect"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Directory, Document, Info } from "@opencode-ai/schema/config"
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
@@ -16,11 +17,18 @@ import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { advance, drain } from "../lib/clock"
import { tmpdir } from "../fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
const it = testEffect(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node, Watcher.node]), [
[Watcher.node, Watcher.testLayer],
]),
Watcher.testLayer,
),
)
const decode = Schema.decodeUnknownSync(Info)
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
...Agent.Info.default(Agent.ID.make("test")).permissions,
@@ -419,7 +427,7 @@ Use native v2 fields.`,
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const configTest = yield* Config.Test
const watcher = yield* Watcher.Test
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
// Verify inside the subscription so the update event is a read barrier:
@@ -435,7 +443,7 @@ Use native v2 fields.`,
yield* Effect.yieldNow
const updates = yield* testCase.mutate(directory)
yield* Effect.forEach(updates, (update) => configTest.emitChange(update), { discard: true })
yield* Effect.forEach(updates, (update) => watcher.emit(update), { discard: true })
yield* advance(() => received === 1)
yield* Fiber.join(changed)
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
@@ -455,7 +463,7 @@ Use native v2 fields.`,
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
const agents = yield* Agent.Service
const configTest = yield* Config.Test
const watcher = yield* Watcher.Test
let reloads = 0
yield* ConfigAgentPlugin.Plugin.effect(
host({
@@ -468,14 +476,14 @@ Use native v2 fields.`,
yield* Effect.yieldNow
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
yield* watcher.emit({ type: "create", path: path.join(directory, "reviewer.md") })
yield* watcher.emit({ type: "update", path: path.join(directory, "reviewer.md") })
yield* watcher.emit({ type: "update", path: path.join(directory, "reviewer.md") })
yield* advance(() => reloads >= 1)
expect(reloads).toBe(1)
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review twice"))
yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
yield* watcher.emit({ type: "update", path: path.join(directory, "reviewer.md") })
yield* advance(() => reloads >= 2)
expect(reloads).toBe(2)
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review twice" })
@@ -495,7 +503,7 @@ Use native v2 fields.`,
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
const agents = yield* Agent.Service
const configTest = yield* Config.Test
const watcher = yield* Watcher.Test
let reloads = 0
yield* ConfigAgentPlugin.Plugin.effect(
host({
@@ -506,20 +514,119 @@ Use native v2 fields.`,
}),
)
yield* configTest.emitChange({ type: "create", path: path.join(tmp.path, "commands", "review.md") })
yield* configTest.emitChange({ type: "update", path: path.join(tmp.path, "opencode.json") })
yield* watcher.emit({ type: "create", path: path.join(tmp.path, "commands", "review.md") })
yield* watcher.emit({ type: "update", path: path.join(tmp.path, "opencode.json") })
yield* drain
expect(reloads).toBe(0)
// The feed stays live after unrelated updates.
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review related"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
yield* watcher.emit({ type: "create", path: path.join(directory, "reviewer.md") })
yield* advance(() => reloads >= 1)
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review related" })
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
),
),
)
it.effect("loads a source directory created after startup without consuming Config.changes", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const agents = yield* Agent.Service
const watcher = yield* Watcher.Test
let changesCalls = 0
let reloads = 0
yield* ConfigAgentPlugin.Plugin.effect(
host({
agent: {
...agentHost(agents),
reload: () => agents.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
},
}),
).pipe(
Effect.provideService(Config.Service, {
entries: () => Effect.succeed([directoryEntry(tmp.path)]),
changes: () => {
changesCalls++
return Stream.die("unused Config.changes")
},
}),
)
expect((yield* watcher.subscriptions()).map((input) => input.path)).toEqual([tmp.path])
expect(yield* agents.get(Agent.ID.make("team/helper"))).toBeUndefined()
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true })
await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help after startup")
})
yield* watcher.emit({ type: "create", path: path.join(tmp.path, "agents") })
yield* advance(() => reloads >= 1)
expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help after startup" })
expect(changesCalls).toBe(0)
}),
)
it.effect("reconciles supplied roots and inline agents on config.updated without reacquiring unchanged roots", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const previous = path.join(tmp.path, "previous")
const next = path.join(tmp.path, "next")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(previous, "agents"), { recursive: true })
await fs.mkdir(path.join(next, "agents"), { recursive: true })
await fs.writeFile(path.join(previous, "agents", "reviewer.md"), "Review previous root")
await fs.writeFile(path.join(next, "agents", "release.md"), "Review next root")
})
yield* Effect.gen(function* () {
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const watcher = yield* Watcher.Test
let reloads = 0
yield* ConfigAgentPlugin.Plugin.effect(
host({
agent: {
...agentHost(agents),
reload: () => agents.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
},
event: { subscribe: () => bus.subscribe(Event.Updated) },
}),
)
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review previous root" })
const initialSubscriptions = yield* watcher.subscriptions()
expect(initialSubscriptions.map((input) => input.path)).toEqual([previous])
yield* config.setEntries([
directoryEntry(previous),
new Document({ type: "document", info: decode({ agents: { inline: { system: "Inline refreshed" } } }) }),
])
yield* bus.publish(Event.Updated, {})
yield* advance(() => reloads >= 1)
expect(yield* agents.get(Agent.ID.make("inline"))).toMatchObject({ system: "Inline refreshed" })
expect(yield* watcher.subscriptions()).toEqual(initialSubscriptions)
yield* config.setEntries([
directoryEntry(next),
new Document({ type: "document", info: decode({ agents: { inline: { system: "Inline destination" } } }) }),
])
yield* bus.publish(Event.Updated, {})
yield* advance(() => reloads >= 2)
expect(yield* agents.get(Agent.ID.make("reviewer"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("release"))).toMatchObject({ system: "Review next root" })
expect(yield* agents.get(Agent.ID.make("inline"))).toMatchObject({ system: "Inline destination" })
expect((yield* watcher.subscriptions()).map((input) => input.path)).toEqual([previous, next])
yield* watcher.emit({ type: "update", path: path.join(previous, "agents", "reviewer.md") })
yield* drain
expect(reloads).toBe(2)
yield* Effect.promise(() => fs.writeFile(path.join(next, "agents", "release.md"), "Review destination update"))
yield* watcher.emit({ type: "update", path: path.join(next, "agents", "release.md") })
yield* advance(() => reloads >= 3)
expect(yield* agents.get(Agent.ID.make("release"))).toMatchObject({ system: "Review destination update" })
}).pipe(Effect.provide(Config.testLayer([directoryEntry(previous)])))
}),
)
})
function directoryEntry(directory: string) {
+119 -61
View File
@@ -14,18 +14,13 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "@opencode-ai/core/location"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
@@ -41,14 +36,23 @@ const shellLayer = Layer.succeed(
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
LayerNode.group([
Command.node,
Bus.node,
FSUtil.node,
AppProcess.node,
Location.node,
ShellSelect.node,
Watcher.node,
]),
[
[Mcp.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
[ShellSelect.node, shellLayer],
[Watcher.node, Watcher.testLayer],
],
),
).pipe(Layer.merge(Watcher.testLayer)),
)
const decode = Schema.decodeUnknownSync(Info)
@@ -178,7 +182,7 @@ Review files`,
const command = yield* Command.Service
const bus = yield* Bus.Service
const configTest = yield* Config.Test
const watcher = yield* Watcher.Test
yield* ConfigCommandPlugin.Plugin.effect(
host({
command: {
@@ -202,7 +206,7 @@ Review files`,
yield* Effect.yieldNow
const updates = yield* testCase.mutate(directory)
yield* Effect.forEach(updates, (update) => configTest.emitChange(update), { discard: true })
yield* Effect.forEach(updates, (update) => watcher.emit(update), { discard: true })
yield* advance(() => received === 1)
yield* Fiber.join(changed)
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
@@ -222,7 +226,7 @@ Review files`,
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
const command = yield* Command.Service
const configTest = yield* Config.Test
const watcher = yield* Watcher.Test
let reloads = 0
yield* ConfigCommandPlugin.Plugin.effect(
host({
@@ -235,16 +239,16 @@ Review files`,
)
yield* Effect.yieldNow
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review once"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
yield* watcher.emit({ type: "create", path: path.join(directory, "review.md") })
yield* watcher.emit({ type: "update", path: path.join(directory, "review.md") })
yield* watcher.emit({ type: "update", path: path.join(directory, "review.md") })
yield* advance(() => reloads >= 1)
expect(reloads).toBe(1)
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review twice", "Review twice")),
)
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
yield* watcher.emit({ type: "update", path: path.join(directory, "review.md") })
yield* advance(() => reloads >= 2)
expect(reloads).toBe(2)
expect((yield* command.get("review"))?.description).toBe("Review twice")
@@ -264,7 +268,7 @@ Review files`,
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
const command = yield* Command.Service
const configTest = yield* Config.Test
const watcher = yield* Watcher.Test
let reloads = 0
yield* ConfigCommandPlugin.Plugin.effect(
host({
@@ -276,8 +280,8 @@ Review files`,
}),
)
yield* configTest.emitChange({ type: "create", path: path.join(tmp.path, "notes", "todo.md") })
yield* configTest.emitChange({ type: "update", path: path.join(tmp.path, "opencode.json") })
yield* watcher.emit({ type: "create", path: path.join(tmp.path, "notes", "todo.md") })
yield* watcher.emit({ type: "update", path: path.join(tmp.path, "opencode.json") })
yield* drain
expect(reloads).toBe(0)
@@ -285,32 +289,99 @@ Review files`,
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review related", "Review related")),
)
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
yield* watcher.emit({ type: "create", path: path.join(directory, "review.md") })
yield* advance(() => reloads >= 1)
expect((yield* command.get("review"))?.description).toBe("Review related")
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
),
),
)
it.effect("reconciles supplied roots and inline commands on config updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const previous = path.join(tmp.path, "previous")
const next = path.join(tmp.path, "next")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(previous, "commands"), { recursive: true })
await fs.writeFile(path.join(previous, "commands", "old.md"), "Old command")
await fs.mkdir(next)
})
const command = yield* Command.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const watcher = yield* Watcher.Test
const inline = new Document({
type: "document",
info: decode({ commands: { inline: { template: "Inline command", description: "Inline command" } } }),
})
yield* config.setEntries([directoryEntry(previous)])
let reloads = 0
yield* ConfigCommandPlugin.Plugin.effect(
host({
command: {
list: () => Effect.die("unused command.list"),
transform: command.transform,
reload: () => command.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
},
event: { subscribe: () => bus.subscribe(Event.Updated) },
}),
)
expect(yield* command.get("old")).toBeDefined()
const subscriptions = yield* watcher.subscriptions()
expect(subscriptions.map((input) => input.path)).toEqual([previous])
yield* config.setEntries([directoryEntry(previous), inline])
yield* bus.publish(Event.Updated, {})
yield* advance(() => reloads === 1)
expect((yield* command.get("inline"))?.description).toBe("Inline command")
expect(yield* watcher.subscriptions()).toEqual(subscriptions)
yield* config.setEntries([directoryEntry(next), inline])
yield* bus.publish(Event.Updated, {})
yield* advance(() => reloads === 2)
expect(yield* command.get("old")).toBeUndefined()
expect((yield* command.get("inline"))?.description).toBe("Inline command")
expect((yield* watcher.subscriptions()).map((input) => input.path)).toEqual([previous, next])
// Only the root existed when subscribed; a directory event must reload it.
yield* Effect.promise(async () => {
await fs.mkdir(path.join(next, "commands"))
await fs.writeFile(path.join(next, "commands", "new.md"), markdown("New command", "New command"))
})
yield* watcher.emit({ type: "create", path: path.join(next, "commands") })
yield* advance(() => reloads === 3)
expect((yield* command.get("new"))?.description).toBe("New command")
yield* watcher.emit({ type: "update", path: path.join(previous, "commands", "old.md") })
yield* drain
expect(reloads).toBe(3)
expect(yield* command.get("old")).toBeUndefined()
expect((yield* watcher.subscriptions()).map((input) => input.path)).toEqual([previous, next])
}).pipe(Effect.provide(Config.testLayer())),
),
),
)
})
const describeNative = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
// End-to-end proof for #37429: a real file edit reaches the command registry
// through the native watcher, Config's watch topology, the source filter, and
// the debounced reload — no mocked change feed.
// through the plugin's own root watch, source filter, and debounced reload.
describeNative("ConfigCommandPlugin native watcher", () => {
it.live("reloads commands from real file edits", () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
// Watcher events report real paths, so resolve the tempdir symlink up front.
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-core-test-" }).pipe(Effect.flatMap(fs.realPath))
const global = path.join(tmp, "global")
yield* fs.makeDirectory(path.join(global, "commands"), { recursive: true })
yield* fs.makeDirectory(path.join(tmp, "project"))
yield* Effect.gen(function* () {
const command = yield* Command.Service
const config = yield* Config.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
yield* ConfigCommandPlugin.Plugin.effect(
host({
@@ -321,51 +392,32 @@ describeNative("ConfigCommandPlugin native watcher", () => {
},
}),
)
yield* watchReady(config, global)
yield* watchReady(watcher, tmp)
const created = yield* nextCommandUpdate(bus)
yield* fs.writeFileString(
path.join(global, "commands", "review.md"),
markdown("Review native", "Review native"),
)
yield* fs.makeDirectory(path.join(tmp, "commands"))
yield* fs.writeFileString(path.join(tmp, "commands", "review.md"), markdown("Review native", "Review native"))
yield* Fiber.join(created).pipe(Effect.timeout("10 seconds"))
expect((yield* command.get("review"))?.description).toBe("Review native")
const updated = yield* nextCommandUpdate(bus)
yield* fs.writeFileString(
path.join(global, "commands", "review.md"),
path.join(tmp, "commands", "review.md"),
markdown("Review native again", "Review native again"),
)
yield* Fiber.join(updated).pipe(Effect.timeout("10 seconds"))
expect((yield* command.get("review"))?.description).toBe("Review native again")
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
Command.node,
Config.node,
Bus.node,
FSUtil.node,
AppProcess.node,
Global.node,
Location.node,
ShellSelect.node,
]),
[
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[ShellSelect.node, shellLayer],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
],
Effect.provide([
AppNodeBuilder.build(Watcher.node),
Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed([directoryEntry(tmp)]),
changes: () => Stream.die("unused config.changes"),
}),
),
),
]),
)
}),
)
@@ -378,17 +430,23 @@ function nextCommandUpdate(bus: Bus.Interface) {
}
// Native directory watches start asynchronously; probe with unrelated files
// until the change feed delivers so command edits afterwards cannot be missed.
function watchReady(config: Config.Interface, directory: string) {
// until the shared root watch delivers so command edits afterwards cannot be missed.
function watchReady(watcher: Watcher.Interface, directory: string) {
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
const seen = yield* Deferred.make<void>()
const listener = yield* config.changes().pipe(
const probe = path.join(directory, ".watch-probe")
const updates = yield* watcher.subscribe({
path: directory,
type: "directory",
ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"],
})
const listener = yield* updates.pipe(
Stream.filter((update) => update.path === probe),
Stream.runForEach(() => Deferred.succeed(seen, undefined).pipe(Effect.asVoid)),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.yieldNow
const probe = path.join(directory, ".watch-probe")
while (true) {
yield* fs.writeFileString(probe, `ready-${Math.random()}`)
const result = yield* Deferred.await(seen).pipe(Effect.timeoutOption("250 millis"))
@@ -399,7 +457,7 @@ function watchReady(config: Config.Interface, directory: string) {
}).pipe(
Effect.timeoutOrElse({
duration: "10 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for the config watch to become ready")),
orElse: () => Effect.fail(new Error("timed out waiting for the command source watch to become ready")),
}),
)
}
@@ -0,0 +1,95 @@
import path from "path"
import { describe, expect } from "bun:test"
import { ConfigSourceWatch } from "@opencode-ai/core/config/source-watch"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Directory, Document, Info } from "@opencode-ai/schema/config"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { it } from "../lib/effect"
describe("ConfigSourceWatch", () => {
it.effect("shares root watches, retains unchanged roots, and releases removed roots and the plugin scope", () => {
const counts = { starts: 0, stops: 0 }
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.sync(() => {
expect(input.type).toBe("directory")
expect(input.ignore).toEqual(["**/{node_modules,.git}/**", ".git", "node_modules"])
counts.starts++
return {
unsubscribe: () => {
counts.stops++
return Promise.resolve()
},
}
}),
})
return Effect.gen(function* () {
yield* Effect.gen(function* () {
const root = directory("source")
const agents = yield* ConfigSourceWatch.make(["agents"])
const commands = yield* ConfigSourceWatch.make(["commands"])
yield* agents.reconcile([root])
yield* commands.reconcile([root, root, new Document({ type: "document", info: new Info({}) })])
yield* Effect.yieldNow
expect(counts).toEqual({ starts: 1, stops: 0 })
yield* agents.reconcile([root])
yield* Effect.yieldNow
expect(counts).toEqual({ starts: 1, stops: 0 })
yield* agents.reconcile([])
expect(counts.stops).toBe(0)
yield* commands.reconcile([])
expect(counts.stops).toBe(1)
yield* agents.reconcile([root])
yield* Effect.yieldNow
expect(counts).toEqual({ starts: 2, stops: 1 })
}).pipe(Effect.scoped)
expect(counts).toEqual({ starts: 2, stops: 2 })
}).pipe(withNative(native))
})
it.effect("scope shutdown interrupts pending native watch acquisition", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const stopped = yield* Deferred.make<void>()
const native = Watcher.Native.of({
subscribe: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)),
),
})
yield* Effect.gen(function* () {
const plugin = yield* Effect.gen(function* () {
const sources = yield* ConfigSourceWatch.make(["agents"])
yield* sources.reconcile([directory("pending")])
yield* Effect.never
}).pipe(Effect.scoped, Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
yield* Fiber.interrupt(plugin)
expect(yield* Deferred.isDone(stopped)).toBe(true)
}).pipe(withNative(native))
}),
)
it.live("disabled watching does not prevent source reconciliation", () =>
Effect.gen(function* () {
const sources = yield* ConfigSourceWatch.make(["agents"])
yield* sources.reconcile([directory("disabled")])
yield* sources.reconcile([])
expect(yield* sources.changes.pipe(Stream.runHead, Effect.timeoutOption("1 millis"))).toMatchObject({
_tag: "None",
})
}).pipe(Effect.provide(Watcher.layer({ enabled: false }).pipe(Layer.provide(Watcher.nativeLayer)))),
)
})
function withNative(native: Watcher.NativeInterface) {
return Effect.provide(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))
}
function directory(name: string) {
return new Directory({ type: "directory", path: AbsolutePath.make(path.resolve(name)) })
}