skill.test.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Layer } from "effect"
  5. import { AgentV2 } from "@opencode-ai/core/agent"
  6. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  8. import { FSUtil } from "@opencode-ai/core/fs-util"
  9. import { AbsolutePath } from "@opencode-ai/core/schema"
  10. import { SkillV2 } from "@opencode-ai/core/skill"
  11. import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
  12. import { tmpdir } from "./fixture/tmpdir"
  13. import { testEffect } from "./lib/effect"
  14. const urls = new Map<string, AbsolutePath[]>()
  15. let pulls = 0
  16. const discovery = Layer.succeed(
  17. SkillDiscovery.Service,
  18. SkillDiscovery.Service.of({
  19. pull: (url) => {
  20. pulls++
  21. return Effect.succeed(urls.get(url) ?? [])
  22. },
  23. }),
  24. )
  25. const it = testEffect(
  26. AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [[SkillDiscovery.node, discovery]]),
  27. )
  28. function write(directory: string, name: string, description: string) {
  29. return fs.writeFile(
  30. path.join(directory, name, "SKILL.md"),
  31. `---
  32. name: ${name}
  33. description: ${description}
  34. ---
  35. # ${name}`,
  36. )
  37. }
  38. describe("SkillV2", () => {
  39. it.live("registers sources and resolves later source precedence", () =>
  40. Effect.acquireRelease(
  41. Effect.promise(() => tmpdir()),
  42. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  43. ).pipe(
  44. Effect.flatMap((tmp) =>
  45. Effect.gen(function* () {
  46. const first = path.join(tmp.path, "first")
  47. const second = path.join(tmp.path, "second")
  48. yield* Effect.promise(async () => {
  49. await fs.mkdir(path.join(first, "review"), { recursive: true })
  50. await fs.mkdir(path.join(second, "review"), { recursive: true })
  51. await write(first, "review", "First")
  52. await write(second, "review", "Second")
  53. await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
  54. })
  55. const skill = yield* SkillV2.Service
  56. yield* skill.transform((editor) => {
  57. editor.source({ type: "directory", path: AbsolutePath.make(first) })
  58. editor.source({ type: "directory", path: AbsolutePath.make(first) })
  59. editor.source({ type: "directory", path: AbsolutePath.make(second) })
  60. expect(editor.list()).toEqual([
  61. { type: "directory", path: AbsolutePath.make(first) },
  62. { type: "directory", path: AbsolutePath.make(second) },
  63. ])
  64. })
  65. expect(yield* skill.sources()).toEqual([
  66. { type: "directory", path: AbsolutePath.make(first) },
  67. { type: "directory", path: AbsolutePath.make(second) },
  68. ])
  69. expect(yield* skill.list()).toEqual([
  70. SkillV2.Info.make({
  71. name: "foo",
  72. slash: true,
  73. location: AbsolutePath.make(path.join(first, "foo.md")),
  74. content: "# foo",
  75. }),
  76. {
  77. name: "review",
  78. description: "Second",
  79. location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
  80. content: "# review",
  81. },
  82. ])
  83. }),
  84. ),
  85. ),
  86. )
  87. it.live("loads URL sources and filters skills for agents", () =>
  88. Effect.acquireRelease(
  89. Effect.promise(() => tmpdir()),
  90. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  91. ).pipe(
  92. Effect.flatMap((tmp) =>
  93. Effect.gen(function* () {
  94. yield* Effect.promise(async () => {
  95. await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
  96. await write(tmp.path, "deploy", "Deploy production")
  97. })
  98. pulls = 0
  99. urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])
  100. const agents = yield* AgentV2.Service
  101. yield* agents.transform((editor) =>
  102. editor.update(AgentV2.ID.make("reviewer"), (agent) => {
  103. agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
  104. }),
  105. )
  106. const skill = yield* SkillV2.Service
  107. yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
  108. expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
  109. expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
  110. expect(pulls).toBe(1)
  111. expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([])
  112. }),
  113. ),
  114. ),
  115. )
  116. })