Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
Expand Down Expand Up @@ -159,6 +160,12 @@ export namespace Command {

const cache = yield* InstanceState.make<State>((ctx) => init(ctx))

// Fix for #19050: rebuild commands when skills are reloaded from disk
Bus.subscribe(Skill.Event.Invalidated, async () => {
await Effect.runPromise(InstanceState.invalidate(cache)).catch(() => {})
log.info("invalidated command cache after skill reload")
})

const get = Effect.fn("Command.get")(function* (name: string) {
const state = yield* InstanceState.get(cache)
return state.commands[name]
Expand Down
38 changes: 36 additions & 2 deletions packages/opencode/src/skill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { Effect, Layer, ServiceMap } from "effect"
import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { FileWatcher } from "@/file/watcher"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Permission } from "@/permission"
Expand All @@ -20,6 +22,10 @@ import { Discovery } from "./discovery"

export namespace Skill {
const log = Log.create({ service: "skill" })
export const Event = {
Invalidated: BusEvent.define("skill.invalidated", z.object({})),
}

const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
Expand Down Expand Up @@ -59,13 +65,15 @@ export namespace Skill {

type Cache = State & {
ensure: () => Promise<void>
invalidate: () => void
}

export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<Info[]>
readonly dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
readonly invalidate: () => Effect.Effect<void>
}

const add = async (state: State, match: string) => {
Expand Down Expand Up @@ -175,7 +183,15 @@ export namespace Skill {
return state.task
}

return { ...state, ensure }
// Fix for #19050: allow cache invalidation so skills are re-read from disk
const invalidate = () => {
state.task = undefined
for (const key of Object.keys(state.skills)) delete state.skills[key]
state.dirs.clear()
log.info("invalidated skill cache")
}

return { ...state, ensure, invalidate }
}

export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
Expand All @@ -188,6 +204,15 @@ export namespace Skill {
Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))),
)

// Fix for #19050: invalidate skill cache when SKILL.md files change on disk
Bus.subscribe(FileWatcher.Event.Updated, async (evt) => {
if (!evt.properties.file.endsWith("SKILL.md")) return
const cache = await Effect.runPromise(InstanceState.get(state)).catch(() => undefined)
if (!cache) return
cache.invalidate()
Bus.publish(Event.Invalidated, {})
})

const ensure = Effect.fn("Skill.ensure")(function* () {
const cache = yield* InstanceState.get(state)
yield* Effect.promise(() => cache.ensure())
Expand Down Expand Up @@ -216,7 +241,12 @@ export namespace Skill {
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
})

return Service.of({ get, all, dirs, available })
const invalidate = Effect.fn("Skill.invalidate")(function* () {
const cache = yield* InstanceState.get(state)
cache.invalidate()
})

return Service.of({ get, all, dirs, available, invalidate })
}),
)

Expand Down Expand Up @@ -259,4 +289,8 @@ export namespace Skill {
export async function available(agent?: Agent.Info) {
return runPromise((skill) => skill.available(agent))
}

export async function invalidate() {
return runPromise((skill) => skill.invalidate())
}
}
206 changes: 206 additions & 0 deletions packages/opencode/test/skill/skill-stale-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { afterEach, test, expect } from "bun:test"
import { Skill } from "../../src/skill"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import path from "path"

afterEach(async () => {
await Instance.disposeAll()
})

test("skill content updates after invalidate + re-read", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "my-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: my-skill
description: Original description.
---

# My Skill - Version 1

Original content here.
`,
)
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
// First load — should see "Version 1"
const skillsBefore = await Skill.all()
expect(skillsBefore.length).toBe(1)
expect(skillsBefore[0].description).toBe("Original description.")
expect(skillsBefore[0].content).toContain("Version 1")

// Edit the skill file on disk
const skillFile = path.join(tmp.path, ".opencode", "skill", "my-skill", "SKILL.md")
await Bun.write(
skillFile,
`---
name: my-skill
description: Updated description.
---

# My Skill - Version 2

Updated content with test1 label.
`,
)

// Invalidate the cache (simulates what FileWatcher triggers)
await Skill.invalidate()

// Now skills should reflect the updated file
const skillsAfter = await Skill.all()
expect(skillsAfter.length).toBe(1)
expect(skillsAfter[0].description).toBe("Updated description.")
expect(skillsAfter[0].content).toContain("Version 2")
expect(skillsAfter[0].content).toContain("test1 label")
},
})
})

test("Skill.get() returns fresh content after invalidate", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "cached-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: cached-skill
description: A cached skill.
---

# Cached Skill

Initial instructions.
`,
)
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Skill.get("cached-skill")
expect(before).toBeDefined()
expect(before!.content).toContain("Initial instructions")

// Edit the file
const skillFile = path.join(tmp.path, ".opencode", "skill", "cached-skill", "SKILL.md")
await Bun.write(
skillFile,
`---
name: cached-skill
description: An updated cached skill.
---

# Cached Skill

Updated instructions with new workflow.
`,
)

// Invalidate and re-fetch
await Skill.invalidate()

const after = await Skill.get("cached-skill")
expect(after).toBeDefined()
expect(after!.content).toContain("Updated instructions")
expect(after!.description).toBe("An updated cached skill.")
},
})
})

test("newly added skill is discovered after invalidate", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "existing-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: existing-skill
description: Already exists.
---

# Existing Skill
`,
)
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Skill.all()
expect(before.length).toBe(1)

// Add a new skill on disk
const newSkillDir = path.join(tmp.path, ".opencode", "skill", "new-skill")
await Bun.write(
path.join(newSkillDir, "SKILL.md"),
`---
name: new-skill
description: Brand new skill.
---

# New Skill
`,
)

// Invalidate and re-fetch
await Skill.invalidate()

const after = await Skill.all()
expect(after.length).toBe(2)
expect(after.find((s) => s.name === "existing-skill")).toBeDefined()
expect(after.find((s) => s.name === "new-skill")).toBeDefined()
expect(after.find((s) => s.name === "new-skill")!.description).toBe("Brand new skill.")
},
})
})

test("deleted skill is removed after invalidate", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "doomed-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: doomed-skill
description: Will be deleted.
---

# Doomed Skill
`,
)
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Skill.all()
expect(before.length).toBe(1)
expect(before[0].name).toBe("doomed-skill")

// Delete the skill file
const fs = await import("fs/promises")
await fs.rm(path.join(tmp.path, ".opencode", "skill", "doomed-skill"), { recursive: true })

// Invalidate and re-fetch
await Skill.invalidate()

const after = await Skill.all()
expect(after.length).toBe(0)
},
})
})
Loading