skill.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
  5. import { Agent } from "@opencode-ai/core/agent"
  6. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  8. import { Bus } from "@opencode-ai/core/bus"
  9. import { AbsolutePath } from "@opencode-ai/core/schema"
  10. import { Skill } from "@opencode-ai/core/skill"
  11. import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
  12. import { Watcher } from "@opencode-ai/core/filesystem/watcher"
  13. import { tmpdir } from "./fixture/tmpdir"
  14. import { testEffect } from "./lib/effect"
  15. const urls = new Map<string, AbsolutePath[]>()
  16. let pulls = 0
  17. const discovery = Layer.succeed(
  18. SkillDiscovery.Service,
  19. SkillDiscovery.Service.of({
  20. pull: (url) => {
  21. pulls++
  22. return Effect.succeed(urls.get(url) ?? [])
  23. },
  24. }),
  25. )
  26. const watcherLayer = Watcher.testLayer
  27. const it = testEffect(
  28. Layer.mergeAll(
  29. AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
  30. [SkillDiscovery.node, discovery],
  31. [Watcher.node, watcherLayer],
  32. ]),
  33. watcherLayer,
  34. ),
  35. )
  36. function write(directory: string, name: string, description: string) {
  37. return fs.writeFile(
  38. path.join(directory, name, "SKILL.md"),
  39. `---
  40. name: ${name}
  41. description: ${description}
  42. ---
  43. # ${name}`,
  44. )
  45. }
  46. function waitForSkillUpdate() {
  47. return Effect.gen(function* () {
  48. const bus = yield* Bus.Service
  49. const deferred = yield* Deferred.make<void>()
  50. const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
  51. Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
  52. Effect.forkScoped,
  53. )
  54. yield* Effect.yieldNow
  55. return { deferred, fiber }
  56. })
  57. }
  58. function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
  59. return Effect.gen(function* () {
  60. const watcher = yield* Watcher.Test
  61. expect((yield* watcher.subscriptions()).some(check)).toBe(true)
  62. })
  63. }
  64. function emitAndWait(update: Watcher.Update) {
  65. return Effect.gen(function* () {
  66. const watcher = yield* Watcher.Test
  67. yield* Effect.acquireUseRelease(
  68. waitForSkillUpdate(),
  69. ({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
  70. ({ fiber }) => Fiber.interrupt(fiber),
  71. )
  72. })
  73. }
  74. describe("Skill", () => {
  75. it.live("publishes updates when skill sources change", () =>
  76. Effect.gen(function* () {
  77. const skill = yield* Skill.Service
  78. yield* Effect.acquireUseRelease(
  79. waitForSkillUpdate(),
  80. ({ deferred }) =>
  81. skill
  82. .transform((editor) =>
  83. editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }),
  84. )
  85. .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
  86. ({ fiber }) => Fiber.interrupt(fiber),
  87. )
  88. }),
  89. )
  90. it.live("registers sources and resolves later source precedence", () =>
  91. Effect.acquireRelease(
  92. Effect.promise(() => tmpdir()),
  93. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  94. ).pipe(
  95. Effect.flatMap((tmp) =>
  96. Effect.gen(function* () {
  97. const first = path.join(tmp.path, "first")
  98. const second = path.join(tmp.path, "second")
  99. yield* Effect.promise(async () => {
  100. await fs.mkdir(path.join(first, "review"), { recursive: true })
  101. await fs.mkdir(path.join(second, "review"), { recursive: true })
  102. await write(first, "review", "First")
  103. await write(second, "review", "Second")
  104. await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
  105. })
  106. const skill = yield* Skill.Service
  107. const watcher = yield* Watcher.Test
  108. yield* skill.transform((editor) => {
  109. editor.source({ type: "directory", path: AbsolutePath.make(first) })
  110. editor.source({ type: "directory", path: AbsolutePath.make(first) })
  111. editor.source({ type: "directory", path: AbsolutePath.make(second) })
  112. expect(editor.list()).toEqual([
  113. { type: "directory", path: AbsolutePath.make(first) },
  114. { type: "directory", path: AbsolutePath.make(second) },
  115. ])
  116. })
  117. expect(yield* skill.sources()).toEqual([
  118. { type: "directory", path: AbsolutePath.make(first) },
  119. { type: "directory", path: AbsolutePath.make(second) },
  120. ])
  121. expect(yield* skill.list()).toEqual([
  122. Skill.Info.make({
  123. id: Skill.ID.make("foo"),
  124. name: Skill.Name.make("foo"),
  125. slash: true,
  126. location: AbsolutePath.make(path.join(first, "foo.md")),
  127. content: "# foo",
  128. }),
  129. {
  130. id: Skill.ID.make("review"),
  131. name: Skill.Name.make("review"),
  132. description: "Second",
  133. location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
  134. content: "# review",
  135. },
  136. ])
  137. expect(yield* watcher.subscriptions()).toEqual([
  138. { path: first, type: "directory" },
  139. { path: second, type: "directory" },
  140. ])
  141. yield* Effect.promise(() => write(second, "review", "Updated Second"))
  142. yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
  143. expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
  144. expect(yield* watcher.subscriptions()).toEqual([
  145. { path: first, type: "directory" },
  146. { path: second, type: "directory" },
  147. { path: first, type: "directory" },
  148. { path: second, type: "directory" },
  149. ])
  150. }),
  151. ),
  152. ),
  153. )
  154. it.live("loads URL sources and filters skills for agents", () =>
  155. Effect.acquireRelease(
  156. Effect.promise(() => tmpdir()),
  157. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  158. ).pipe(
  159. Effect.flatMap((tmp) =>
  160. Effect.gen(function* () {
  161. yield* Effect.promise(async () => {
  162. await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
  163. await write(tmp.path, "deploy", "Deploy production")
  164. })
  165. pulls = 0
  166. urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])
  167. const agents = yield* Agent.Service
  168. yield* agents.transform((editor) =>
  169. editor.update(Agent.ID.make("reviewer"), (agent) => {
  170. agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
  171. }),
  172. )
  173. const skill = yield* Skill.Service
  174. yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
  175. expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
  176. expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
  177. expect(pulls).toBe(1)
  178. expect(Skill.available(yield* skill.list(), (yield* agents.get(Agent.ID.make("reviewer")))!)).toEqual([])
  179. }),
  180. ),
  181. ),
  182. )
  183. it.live("parses opencode metadata flags from skill frontmatter", () =>
  184. Effect.acquireRelease(
  185. Effect.promise(() => tmpdir()),
  186. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  187. ).pipe(
  188. Effect.flatMap((tmp) =>
  189. Effect.gen(function* () {
  190. yield* Effect.promise(async () => {
  191. await fs.mkdir(path.join(tmp.path, "manual"), { recursive: true })
  192. await fs.writeFile(
  193. path.join(tmp.path, "manual", "SKILL.md"),
  194. `---
  195. name: manual
  196. description: Manual only
  197. metadata:
  198. opencode/slash: true
  199. opencode/autoinvoke: false
  200. ---
  201. # manual`,
  202. )
  203. })
  204. const skill = yield* Skill.Service
  205. yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
  206. expect(yield* skill.list()).toEqual([
  207. {
  208. id: Skill.ID.make("manual"),
  209. name: Skill.Name.make("manual"),
  210. description: "Manual only",
  211. slash: true,
  212. autoinvoke: false,
  213. location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")),
  214. content: "# manual",
  215. },
  216. ])
  217. }),
  218. ),
  219. ),
  220. )
  221. it.live("clears cached skills when sources reload", () =>
  222. Effect.acquireRelease(
  223. Effect.promise(() => tmpdir()),
  224. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  225. ).pipe(
  226. Effect.flatMap((tmp) =>
  227. Effect.gen(function* () {
  228. yield* Effect.promise(async () => {
  229. await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
  230. await write(tmp.path, "deploy", "Initial deploy")
  231. })
  232. const skill = yield* Skill.Service
  233. const watcher = yield* Watcher.Test
  234. const bus = yield* Bus.Service
  235. yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
  236. expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
  237. expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
  238. let refreshed: Skill.Info[] = []
  239. const unsubscribe = yield* bus.listen((event) => {
  240. if (event.type !== Skill.Event.Updated.type) return Effect.void
  241. return skill.list().pipe(
  242. Effect.tap((items) => Effect.sync(() => (refreshed = items))),
  243. Effect.asVoid,
  244. )
  245. })
  246. yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
  247. yield* skill.reload().pipe(Effect.timeout("1 second"))
  248. yield* unsubscribe
  249. expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
  250. expect(yield* watcher.subscriptions()).toEqual([
  251. { path: tmp.path, type: "directory" },
  252. { path: tmp.path, type: "directory" },
  253. ])
  254. }),
  255. ),
  256. ),
  257. )
  258. it.live("reloads project sources created after their missing parent", () =>
  259. Effect.acquireRelease(
  260. Effect.promise(() => tmpdir()),
  261. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  262. ).pipe(
  263. Effect.flatMap((tmp) =>
  264. Effect.gen(function* () {
  265. const source = path.join(tmp.path, "generated", "skills")
  266. const file = path.join(source, "deploy", "SKILL.md")
  267. const skill = yield* Skill.Service
  268. const watcher = yield* Watcher.Test
  269. yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
  270. expect(yield* skill.list()).toEqual([])
  271. expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
  272. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
  273. yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
  274. expect(yield* skill.list()).toEqual([])
  275. expect(yield* watcher.subscriptions()).toEqual([
  276. { path: path.join(tmp.path, "generated"), type: "file" },
  277. { path: source, type: "file" },
  278. ])
  279. yield* Effect.promise(async () => {
  280. await fs.mkdir(path.dirname(file), { recursive: true })
  281. await write(source, "deploy", "Deploy production")
  282. })
  283. yield* emitAndWait({ type: "create", path: source })
  284. expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
  285. expect(yield* watcher.subscriptions()).toEqual([
  286. { path: path.join(tmp.path, "generated"), type: "file" },
  287. { path: source, type: "file" },
  288. { path: source, type: "directory" },
  289. ])
  290. }),
  291. ),
  292. ),
  293. )
  294. it.live("watches directory sources for added and changed skills", () =>
  295. Effect.acquireRelease(
  296. Effect.promise(() => tmpdir()),
  297. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  298. ).pipe(
  299. Effect.flatMap((tmp) =>
  300. Effect.gen(function* () {
  301. yield* Effect.promise(async () => {
  302. await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
  303. await write(tmp.path, "deploy", "Initial deploy")
  304. })
  305. const skill = yield* Skill.Service
  306. yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
  307. expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
  308. yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
  309. const deploy = path.join(tmp.path, "deploy", "SKILL.md")
  310. yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
  311. yield* emitAndWait({ type: "update", path: deploy })
  312. expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
  313. yield* Effect.promise(async () => {
  314. await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
  315. await write(tmp.path, "review", "Review changes")
  316. })
  317. const review = path.join(tmp.path, "review", "SKILL.md")
  318. yield* emitAndWait({ type: "create", path: review })
  319. expect((yield* skill.list()).map((item) => item.id)).toEqual([
  320. Skill.ID.make("deploy"),
  321. Skill.ID.make("review"),
  322. ])
  323. yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
  324. yield* emitAndWait({ type: "delete", path: review })
  325. expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
  326. }),
  327. ),
  328. ),
  329. )
  330. it.live("watches canonical directories behind symlinked skills", () =>
  331. Effect.acquireRelease(
  332. Effect.promise(() => tmpdir()),
  333. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  334. ).pipe(
  335. Effect.flatMap((tmp) =>
  336. Effect.gen(function* () {
  337. const source = path.join(tmp.path, "source")
  338. const target = path.join(tmp.path, "target", "bro")
  339. const file = path.join(target, "SKILL.md")
  340. yield* Effect.promise(async () => {
  341. await fs.mkdir(source, { recursive: true })
  342. await fs.mkdir(target, { recursive: true })
  343. await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
  344. await fs.symlink(target, path.join(source, "bro"))
  345. })
  346. const skill = yield* Skill.Service
  347. yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
  348. expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
  349. yield* expectSubscription((input) => input.type === "directory" && input.path === target)
  350. yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
  351. yield* emitAndWait({ type: "update", path: file })
  352. expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
  353. }),
  354. ),
  355. ),
  356. )
  357. it.live("invalidates symlinked sources when their target changes", () =>
  358. Effect.acquireRelease(
  359. Effect.promise(() => tmpdir()),
  360. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  361. ).pipe(
  362. Effect.flatMap((tmp) =>
  363. Effect.gen(function* () {
  364. const source = path.join(tmp.path, "source")
  365. const first = path.join(tmp.path, "first")
  366. const second = path.join(tmp.path, "second")
  367. yield* Effect.promise(async () => {
  368. await fs.mkdir(path.join(first, "bro"), { recursive: true })
  369. await fs.mkdir(path.join(second, "bro"), { recursive: true })
  370. await write(first, "bro", "First")
  371. await write(second, "bro", "Second")
  372. await fs.symlink(first, source)
  373. })
  374. const skill = yield* Skill.Service
  375. const watcher = yield* Watcher.Test
  376. yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
  377. expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
  378. expect(yield* watcher.subscriptions()).toEqual([
  379. { path: first, type: "directory" },
  380. { path: source, type: "file" },
  381. ])
  382. yield* Effect.promise(async () => {
  383. await fs.unlink(source)
  384. await fs.symlink(second, source)
  385. })
  386. yield* emitAndWait({ type: "update", path: source })
  387. expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
  388. expect(yield* watcher.subscriptions()).toEqual([
  389. { path: first, type: "directory" },
  390. { path: source, type: "file" },
  391. { path: second, type: "directory" },
  392. { path: source, type: "file" },
  393. ])
  394. }),
  395. ),
  396. ),
  397. )
  398. })