diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 9c6c958025..3aebf88fbe 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -2,7 +2,7 @@ export * as ConfigAgentPlugin from "./agent" import { define } from "../../plugin/internal" import path from "path" -import { Effect, Option, Schema } from "effect" +import { Effect, Option, Schema, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { ConfigAgent } from "../agent" @@ -38,31 +38,34 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([entry]) - return Effect.gen(function* () { - const files = yield* discover(fs, entry.path) - return yield* Effect.forEach(files, (file) => - fs.readFileStringSafe(file.filepath).pipe( - Effect.map((content) => content && decode(file, content)), - Effect.catch(() => Effect.succeed(undefined)), - ), - ).pipe( - Effect.map((documents) => - documents.filter((document): document is Config.Document => document !== undefined), - ), - ) - }) - }).pipe(Effect.map((documents) => documents.flat())) - const global = documents.flatMap((document) => document.info.permissions ?? []) - const configuredDefault = Config.latest(documents, "default_agent") + const load = Effect.fn("ConfigAgentPlugin.load")(function* () { + return yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([entry]) + return Effect.gen(function* () { + const files = yield* discover(fs, entry.path) + return yield* Effect.forEach(files, (file) => + fs.readFileStringSafe(file.filepath).pipe( + Effect.map((content) => content && decode(file, content)), + Effect.catch(() => Effect.succeed(undefined)), + ), + ).pipe( + Effect.map((documents) => + documents.filter((document): document is Config.Document => document !== undefined), + ), + ) + }) + }).pipe(Effect.map((documents) => documents.flat())) + }) + const loaded = { documents: yield* load() } yield* ctx.agent.transform((draft) => { + const global = loaded.documents.flatMap((document) => document.info.permissions ?? []) + const configuredDefault = Config.latest(loaded.documents, "default_agent") if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) for (const current of draft.list()) { draft.update(current.id, (agent) => agent.permissions.push(...global)) } - for (const document of documents) { + for (const document of loaded.documents) { for (const [id, item] of Object.entries(document.info.agents ?? {})) { const agentID = AgentV2.ID.make(id) if (item.disabled) { @@ -95,6 +98,16 @@ export const Plugin = define({ } } }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + load().pipe( + Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))), + Effect.andThen(ctx.agent.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index 22f49bbf16..c67f67d481 100644 --- a/packages/core/src/config/plugin/external.ts +++ b/packages/core/src/config/plugin/external.ts @@ -2,7 +2,7 @@ export * as ConfigExternalPlugin from "./external" import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise" -import { Effect, Schema } from "effect" +import { Effect, Schema, Stream } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" import { Config } from "../../config" @@ -42,8 +42,9 @@ export const Plugin = define({ const fs = yield* FSUtil.Service const location = yield* Location.Service const npm = yield* Npm.Service - yield* Effect.gen(function* () { - const configured: { package: string; options?: Record }[] = [] + const active = new Set() + const load = Effect.fn("ConfigExternalPlugin.load")(function* () { + const configured: { package: string; options?: Record }[] = [] for (const entry of yield* config.entries()) { if (entry.type === "document") { @@ -98,8 +99,8 @@ export const Plugin = define({ } } - for (const ref of configured) { - yield* Effect.gen(function* () { + return yield* Effect.forEach(configured, (ref) => + Effect.gen(function* () { const entrypoint = path.isAbsolute(ref.package) ? pathToFileURL(ref.package).href : (yield* npm.add(ref.package)).entrypoint @@ -108,13 +109,31 @@ export const Plugin = define({ const mod = yield* Effect.promise(() => import(entrypoint)) const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) - yield* ctx.plugin.add({ + return { id: plugin.id, - effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), - }) - }).pipe(Effect.ignoreCause) - } + effect: (host: Parameters[0]) => + plugin.effect({ ...host, options: ref.options ?? {} }), + } + }).pipe(Effect.catchCause(() => Effect.succeed(undefined))), + ).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined))) }) + const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () { + const plugins = yield* load() + const next = new Set(plugins.map((plugin) => plugin.id)) + for (const id of active) { + if (!next.has(id)) yield* ctx.plugin.remove(id) + } + for (const plugin of plugins) yield* ctx.plugin.add(plugin) + active.clear() + for (const id of next) active.add(id) + }) + + yield* reconcile() + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => reconcile()), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 4992233a1f..d2d6a26029 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,7 +1,7 @@ export * as ConfigProviderPlugin from "./provider" import { define } from "../../plugin/internal" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" @@ -9,14 +9,16 @@ export const Plugin = define({ id: "config-provider", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const entries = yield* config.entries() - const files = entries.filter((entry): entry is Config.Document => entry.type === "document") - const configuredIntegrations = new Set( - files.flatMap((file) => - Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), - ), - ) + const loaded = { entries: yield* config.entries() } yield* ctx.integration.transform((integrations) => { + const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredIntegrations = new Set( + files.flatMap((file) => + Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => + provider.env === undefined ? [] : [id], + ), + ), + ) for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) { const integrationID = id @@ -34,8 +36,9 @@ export const Plugin = define({ } }) - const configuredDefault = Config.latest(entries, "model") yield* ctx.catalog.transform((catalog) => { + const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredDefault = Config.latest(loaded.entries, "model") if (configuredDefault !== undefined) { const model = ModelV2.parse(configuredDefault) catalog.model.default.set(model.providerID, model.modelID) @@ -105,5 +108,16 @@ export const Plugin = define({ } } }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.integration.reload()), + Effect.andThen(ctx.catalog.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index 1c599dab7f..d33332f92d 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -2,7 +2,7 @@ export * as ConfigReferencePlugin from "./reference" import { define } from "../../plugin/internal" import path from "path" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { Config } from "../../config" import { ConfigReference } from "../reference" import { Reference } from "../../reference" @@ -16,35 +16,48 @@ export const Plugin = define({ const config = yield* Config.Service const location = yield* Location.Service const global = yield* Global.Service - const entries = new Map() - for (const doc of (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")) { - const directory = doc.path ? path.dirname(doc.path) : location.directory - for (const [name, entry] of Object.entries(doc.info.references ?? {})) { - if (!validAlias(name)) continue - const description = typeof entry === "string" ? undefined : entry.description - const hidden = typeof entry === "string" ? undefined : entry.hidden - entries.set( - name, - local(entry) - ? Reference.LocalSource.make({ - type: "local", - path: AbsolutePath.make(localPath(directory, global.home, typeof entry === "string" ? entry : entry.path)), - ...(description === undefined ? {} : { description }), - ...(hidden === undefined ? {} : { hidden }), - }) - : Reference.GitSource.make({ - type: "git", - repository: typeof entry === "string" ? entry : entry.repository, - ...(entry.branch === undefined ? {} : { branch: entry.branch }), - ...(description === undefined ? {} : { description }), - ...(hidden === undefined ? {} : { hidden }), - }), - ) - } - } + const loaded = { entries: yield* config.entries() } yield* ctx.reference.transform((draft) => { + const entries = new Map() + for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) { + const directory = doc.path ? path.dirname(doc.path) : location.directory + for (const [name, entry] of Object.entries(doc.info.references ?? {})) { + if (!validAlias(name)) continue + const description = typeof entry === "string" ? undefined : entry.description + const hidden = typeof entry === "string" ? undefined : entry.hidden + entries.set( + name, + local(entry) + ? Reference.LocalSource.make({ + type: "local", + path: AbsolutePath.make( + localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), + ), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), + }) + : Reference.GitSource.make({ + type: "git", + repository: typeof entry === "string" ? entry : entry.repository, + ...(entry.branch === undefined ? {} : { branch: entry.branch }), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), + }), + ) + } + } for (const [name, source] of entries) draft.add(name, source) }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.reference.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index c4b7ba95c1..ff83c53ff1 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -2,7 +2,7 @@ export * as ConfigSkillPlugin from "./skill" import { define } from "../../plugin/internal" import path from "path" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { Config } from "../../config" import { AbsolutePath } from "../../schema" import { SkillV2 } from "../../skill" @@ -15,10 +15,10 @@ export const Plugin = define({ const config = yield* Config.Service const global = yield* Global.Service const location = yield* Location.Service - const entries = yield* config.entries() - const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + const loaded = { entries: yield* config.entries() } yield* ctx.skill.transform((draft) => { + const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) + const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) for (const directory of directories) { draft.source( SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), @@ -44,5 +44,15 @@ export const Plugin = define({ ) } }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.skill.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts new file mode 100644 index 0000000000..0ee2f0467e --- /dev/null +++ b/packages/core/test/config/reload.test.ts @@ -0,0 +1,125 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { CommandV2 } from "@opencode-ai/core/command" +import { Config } from "@opencode-ai/core/config" +import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" +import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" +import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" +import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" +import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference" +import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill" +import { EventV2 } from "@opencode-ai/core/event" +import { Global } from "@opencode-ai/core/global" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Reference } from "@opencode-ai/core/reference" +import { SkillV2 } from "@opencode-ai/core/skill" +import { Effect, Schema } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) +const decode = Schema.decodeUnknownSync(Config.Info) +const document = path.join(import.meta.dir, "opencode.json") + +describe("config plugin reloads", () => { + it.live("reloads every config-backed domain", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const events = yield* EventV2.Service + const plugins = yield* PluginV2.Service + const references = yield* Reference.Service + const skills = yield* SkillV2.Service + const host = yield* PluginHost.make(plugins) + let entries: Config.Entry[] = [config("first", "First plugin")] + const service = Config.Service.of({ entries: () => Effect.sync(() => entries) }) + const setup = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(Config.Service, service)) + + 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* setup(ConfigExternalPlugin.Plugin.effect(host)) + + expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent") + expect((yield* commands.get("first"))?.description).toBe("First command") + expect( + (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"), + ).toBe(true) + expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"]) + expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined() + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin") + + entries = [config("second", "Second plugin")] + yield* events.publish(ConfigSchema.Event.Updated, {}) + yield* waitUntil( + Effect.gen(function* () { + return ( + (yield* agents.get(AgentV2.ID.make("first"))) === undefined && + (yield* agents.get(AgentV2.ID.make("second")))?.description === "Second agent" && + (yield* commands.get("first")) === undefined && + (yield* commands.get("second"))?.description === "Second command" && + (yield* references.list()).some((reference) => reference.name === "second") && + (yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined && + (yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined && + (yield* agents.get(AgentV2.ID.make("configured")))?.description === "Second plugin" + ) + }), + ) + + expect( + (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"), + ).toBe(false) + expect( + (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"), + ).toBe(true) + + entries = [config("second")] + yield* events.publish(ConfigSchema.Event.Updated, {}) + yield* waitUntil(agents.get(AgentV2.ID.make("configured")).pipe(Effect.map((agent) => agent === undefined))) + }).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))), + ) +}) + +function config(name: string, pluginDescription?: string) { + return new Config.Document({ + type: "document", + path: document, + info: decode({ + agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } }, + commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } }, + skills: [`/skills/${name}`], + references: { [name]: `/references/${name}` }, + providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } }, + plugins: + pluginDescription === undefined + ? [] + : [ + { + package: "../plugin/fixtures/config-promise-plugin.ts", + options: { description: pluginDescription }, + }, + ], + }), + }) +} + +function title(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect) { + for (let attempt = 0; attempt < 100; attempt++) { + if (yield* condition) return + yield* Effect.sleep("10 millis") + } + return yield* Effect.die("Timed out waiting for config plugin reloads") +})