fix(core): reload compatibility skills only for their own sources (#59086)

Co-authored-by: Kit Langton <kit.langton@gmail.com>
This commit is contained in:
Luke Parker
2026-10-26 09:03:21 +10:00
committed by GitHub
Unverified
parent 01cd723db9
commit eba3399588
2 changed files with 69 additions and 2 deletions
@@ -20,6 +20,8 @@ export const Plugin = define({
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const skills: Skill.Info[] = []
const compatibility = () =>
config.compatibility ? config.compatibility() : Effect.succeed({ claude: [], agents: [] })
const watch = Effect.fn("ConfigCompatibilityPlugin.watch")(function* (
target: string,
@@ -39,7 +41,7 @@ export const Plugin = define({
const refresh = Effect.fn("ConfigCompatibilityPlugin.refresh")(
function* () {
yield* FiberMap.clear(watches)
const roots = config.compatibility ? yield* config.compatibility() : { claude: [], agents: [] }
const roots = yield* compatibility()
const directories = [...roots.claude, ...roots.agents].map((root) => path.join(root, "skills"))
const loaded = new Map<Skill.ID, Skill.Info>()
for (const directory of directories) {
@@ -66,7 +68,10 @@ export const Plugin = define({
Effect.forkScoped({ startImmediately: true }),
)
yield* config.changes().pipe(
Stream.runForEach(() => reload),
Stream.filterEffect((update) =>
Effect.map(compatibility(), (roots) => isCompatibilitySource(roots, update.path)),
),
Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
@@ -75,3 +80,15 @@ export const Plugin = define({
})
}),
})
const sourceDirectories = [".claude", ".agents"] as const
function isCompatibilitySource(
roots: { readonly claude: readonly string[]; readonly agents: readonly string[] },
file: string,
) {
return (
sourceDirectories.some((name) => path.basename(file) === name) ||
[...roots.claude, ...roots.agents].some((root) => FSUtil.contains(root, file))
)
}
+50
View File
@@ -21,6 +21,7 @@ import { Skill } from "@opencode/core/skill"
import { SkillDiscovery } from "@opencode/core/skill/discovery"
import { WellKnown } from "@opencode/core/wellknown"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { advance, drain } from "../lib/clock"
import { tmpdir } from "../fixture/tmpdir"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
@@ -427,4 +428,53 @@ describe("ConfigSkillPlugin.Plugin", () => {
),
),
)
it.effect("ignores unrelated config updates and coalesces compatibility bursts into one reload", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const claude = path.join(tmp.path, ".claude")
const skills = path.join(claude, "skills")
const service = yield* Skill.Service
const configTest = yield* Config.Test
let reloads = 0
yield* ConfigCompatibilityPlugin.Plugin.effect(
host({
skill: {
list: () => Effect.die("unused skill.list"),
transform: service.transform,
reload: () => service.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
},
}),
)
yield* Effect.yieldNow
yield* configTest.emitChange({ type: "update", path: path.join(tmp.path, "opencode.json") })
yield* configTest.emitChange({ type: "create", path: path.join(tmp.path, "commands", "review.md") })
yield* configTest.emitChange({ type: "create", path: path.join(tmp.path, "agents", "reviewer.md") })
yield* drain
expect(reloads).toBe(0)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(skills, "review"), { recursive: true })
await write(skills, "review", "Review once")
})
yield* configTest.emitChange({ type: "create", path: claude })
yield* configTest.emitChange({ type: "create", path: path.join(skills, "review", "SKILL.md") })
yield* configTest.emitChange({ type: "update", path: path.join(skills, "review", "SKILL.md") })
yield* advance(() => reloads >= 1)
yield* drain
expect(reloads).toBe(1)
expect((yield* service.list()).find((item) => item.id === "review")?.description).toBe("Review once")
}).pipe(
Effect.provide(
Config.testLayer(
[new Directory({ type: "directory", path: AbsolutePath.make(tmp.path) })],
{ claude: [AbsolutePath.make(path.join(tmp.path, ".claude"))], agents: [] },
),
),
),
),
),
)
})