skill.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  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 { FSUtil } from "@opencode-ai/util/fs-util"
  10. import { AbsolutePath } from "@opencode-ai/core/schema"
  11. import { Skill } from "@opencode-ai/core/skill"
  12. import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
  13. import { FileSystem } from "@opencode-ai/schema/filesystem"
  14. import { tmpdir } from "./fixture/tmpdir"
  15. import { testEffect } from "./lib/effect"
  16. const urls = new Map<string, AbsolutePath[]>()
  17. let pulls = 0
  18. const discovery = Layer.succeed(
  19. SkillDiscovery.Service,
  20. SkillDiscovery.Service.of({
  21. pull: (url) => {
  22. pulls++
  23. return Effect.succeed(urls.get(url) ?? [])
  24. },
  25. }),
  26. )
  27. const it = testEffect(
  28. AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [[SkillDiscovery.node, discovery]]),
  29. )
  30. function write(directory: string, name: string, description: string) {
  31. return fs.writeFile(
  32. path.join(directory, name, "SKILL.md"),
  33. `---
  34. name: ${name}
  35. description: ${description}
  36. ---
  37. # ${name}`,
  38. )
  39. }
  40. function waitForSkillUpdate() {
  41. return Effect.gen(function* () {
  42. const bus = yield* Bus.Service
  43. const deferred = yield* Deferred.make<void>()
  44. const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
  45. Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
  46. Effect.forkScoped,
  47. )
  48. yield* Effect.yieldNow
  49. return { deferred, fiber }
  50. })
  51. }
  52. describe("Skill", () => {
  53. it.live("publishes updates when skill sources change", () =>
  54. Effect.gen(function* () {
  55. const skill = yield* Skill.Service
  56. yield* Effect.acquireUseRelease(
  57. waitForSkillUpdate(),
  58. ({ deferred }) =>
  59. skill
  60. .transform((editor) =>
  61. editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }),
  62. )
  63. .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
  64. ({ fiber }) => Fiber.interrupt(fiber),
  65. )
  66. }),
  67. )
  68. it.live("registers sources and resolves later source precedence", () =>
  69. Effect.acquireRelease(
  70. Effect.promise(() => tmpdir()),
  71. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  72. ).pipe(
  73. Effect.flatMap((tmp) =>
  74. Effect.gen(function* () {
  75. const first = path.join(tmp.path, "first")
  76. const second = path.join(tmp.path, "second")
  77. yield* Effect.promise(async () => {
  78. await fs.mkdir(path.join(first, "review"), { recursive: true })
  79. await fs.mkdir(path.join(second, "review"), { recursive: true })
  80. await write(first, "review", "First")
  81. await write(second, "review", "Second")
  82. await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
  83. })
  84. const skill = yield* Skill.Service
  85. yield* skill.transform((editor) => {
  86. editor.source({ type: "directory", path: AbsolutePath.make(first) })
  87. editor.source({ type: "directory", path: AbsolutePath.make(first) })
  88. editor.source({ type: "directory", path: AbsolutePath.make(second) })
  89. expect(editor.list()).toEqual([
  90. { type: "directory", path: AbsolutePath.make(first) },
  91. { type: "directory", path: AbsolutePath.make(second) },
  92. ])
  93. })
  94. expect(yield* skill.sources()).toEqual([
  95. { type: "directory", path: AbsolutePath.make(first) },
  96. { type: "directory", path: AbsolutePath.make(second) },
  97. ])
  98. expect(yield* skill.list()).toEqual([
  99. Skill.Info.make({
  100. id: Skill.ID.make("foo"),
  101. name: Skill.Name.make("foo"),
  102. slash: true,
  103. location: AbsolutePath.make(path.join(first, "foo.md")),
  104. content: "# foo",
  105. }),
  106. {
  107. id: Skill.ID.make("review"),
  108. name: Skill.Name.make("review"),
  109. description: "Second",
  110. location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
  111. content: "# review",
  112. },
  113. ])
  114. }),
  115. ),
  116. ),
  117. )
  118. it.live("loads URL sources and filters skills for agents", () =>
  119. Effect.acquireRelease(
  120. Effect.promise(() => tmpdir()),
  121. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  122. ).pipe(
  123. Effect.flatMap((tmp) =>
  124. Effect.gen(function* () {
  125. yield* Effect.promise(async () => {
  126. await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
  127. await write(tmp.path, "deploy", "Deploy production")
  128. })
  129. pulls = 0
  130. urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])
  131. const agents = yield* Agent.Service
  132. yield* agents.transform((editor) =>
  133. editor.update(Agent.ID.make("reviewer"), (agent) => {
  134. agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
  135. }),
  136. )
  137. const skill = yield* Skill.Service
  138. yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
  139. expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
  140. expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
  141. expect(pulls).toBe(1)
  142. expect(Skill.available(yield* skill.list(), (yield* agents.get(Agent.ID.make("reviewer")))!)).toEqual([])
  143. }),
  144. ),
  145. ),
  146. )
  147. it.live("parses opencode metadata flags from skill frontmatter", () =>
  148. Effect.acquireRelease(
  149. Effect.promise(() => tmpdir()),
  150. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  151. ).pipe(
  152. Effect.flatMap((tmp) =>
  153. Effect.gen(function* () {
  154. yield* Effect.promise(async () => {
  155. await fs.mkdir(path.join(tmp.path, "manual"), { recursive: true })
  156. await fs.writeFile(
  157. path.join(tmp.path, "manual", "SKILL.md"),
  158. `---
  159. name: manual
  160. description: Manual only
  161. metadata:
  162. opencode/slash: true
  163. opencode/autoinvoke: false
  164. ---
  165. # manual`,
  166. )
  167. })
  168. const skill = yield* Skill.Service
  169. yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
  170. expect(yield* skill.list()).toEqual([
  171. {
  172. id: Skill.ID.make("manual"),
  173. name: Skill.Name.make("manual"),
  174. description: "Manual only",
  175. slash: true,
  176. autoinvoke: false,
  177. location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")),
  178. content: "# manual",
  179. },
  180. ])
  181. }),
  182. ),
  183. ),
  184. )
  185. it.live("invalidates cached skills and publishes updates for watcher changes", () =>
  186. Effect.acquireRelease(
  187. Effect.promise(() => tmpdir()),
  188. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  189. ).pipe(
  190. Effect.flatMap((tmp) =>
  191. Effect.gen(function* () {
  192. yield* Effect.promise(async () => {
  193. await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
  194. await write(tmp.path, "deploy", "Initial deploy")
  195. })
  196. const bus = yield* Bus.Service
  197. const skill = yield* Skill.Service
  198. yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
  199. expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
  200. const file = path.join(tmp.path, "deploy", "SKILL.md")
  201. yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
  202. expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
  203. yield* Effect.acquireUseRelease(
  204. waitForSkillUpdate(),
  205. ({ deferred }) =>
  206. bus
  207. .publish(FileSystem.Event.Changed, { file, event: "change" })
  208. .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
  209. ({ fiber }) => Fiber.interrupt(fiber),
  210. )
  211. expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy")
  212. }),
  213. ),
  214. ),
  215. )
  216. })