instructions.test.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import path from "path"
  2. import { describe, expect } from "bun:test"
  3. import { Effect, Layer } from "effect"
  4. import { AgentV2 } from "@opencode-ai/core/agent"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { AbsolutePath } from "@opencode-ai/core/schema"
  7. import { SkillV2 } from "@opencode-ai/core/skill"
  8. import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
  9. import { it } from "../lib/effect"
  10. import { readInitial, readUpdate } from "../lib/instructions"
  11. const build = AgentV2.ID.make("build")
  12. const effect = SkillV2.Info.make({
  13. id: SkillV2.ID.make("effect"),
  14. name: SkillV2.Name.make("Effect"),
  15. description: "Build applications with Effect",
  16. location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
  17. content: "Effect guidance",
  18. })
  19. const hidden = SkillV2.Info.make({
  20. id: SkillV2.ID.make("hidden"),
  21. name: SkillV2.Name.make("Hidden"),
  22. location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")),
  23. content: "Undescribed guidance",
  24. })
  25. const denied = SkillV2.Info.make({
  26. id: SkillV2.ID.make("denied"),
  27. name: SkillV2.Name.make("Denied"),
  28. description: "Must not be advertised",
  29. location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
  30. content: "Denied guidance",
  31. })
  32. const manual = SkillV2.Info.make({
  33. id: SkillV2.ID.make("manual"),
  34. name: SkillV2.Name.make("Manual"),
  35. description: "Load only when explicitly selected",
  36. autoinvoke: false,
  37. location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")),
  38. content: "Manual guidance",
  39. })
  40. const layer = (list: () => SkillV2.Info[]) =>
  41. AppNodeBuilder.build(SkillInstructions.node, [
  42. [SkillV2.node, Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })],
  43. ])
  44. describe("SkillInstructions", () => {
  45. it.effect("renders described agent skills and updates the complete available list", () => {
  46. const agent = AgentV2.Info.make({
  47. ...AgentV2.Info.empty(build),
  48. permissions: [{ action: "skill", resource: "denied", effect: "deny" }],
  49. })
  50. let skills = [hidden, denied, manual, effect]
  51. return Effect.gen(function* () {
  52. const instructions = yield* SkillInstructions.Service
  53. const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
  54. expect(initialized.text).toBe(
  55. [
  56. "Skills provide specialized instructions and workflows for specific tasks.",
  57. "Use the skill tool to load a skill when a task matches its description.",
  58. "<available_skills>",
  59. " <skill>",
  60. " <id>effect</id>",
  61. " <name>Effect</name>",
  62. " <description>Build applications with Effect</description>",
  63. " </skill>",
  64. "</available_skills>",
  65. ].join("\n"),
  66. )
  67. expect(initialized.text).not.toContain("manual")
  68. skills = []
  69. expect(
  70. yield* instructions
  71. .load({ id: agent.id, info: agent })
  72. .pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
  73. ).toMatchObject({ text: "Skill guidance is no longer available. Do not use any previously listed skill." })
  74. }).pipe(Effect.provide(layer(() => skills)))
  75. })
  76. it.effect("announces added and removed skills as deltas without restating the list", () => {
  77. const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
  78. const debugging = SkillV2.Info.make({
  79. id: SkillV2.ID.make("debugging"),
  80. name: SkillV2.Name.make("Debugging"),
  81. description: "Diagnose hard bugs",
  82. location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
  83. content: "Debugging guidance",
  84. })
  85. let skills = [effect]
  86. return Effect.gen(function* () {
  87. const instructions = yield* SkillInstructions.Service
  88. const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
  89. skills = [effect, debugging]
  90. const added = yield* instructions
  91. .load({ id: agent.id, info: agent })
  92. .pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
  93. expect(added.text).toBe(
  94. [
  95. "New skills are available in addition to those previously listed:",
  96. " <skill>",
  97. " <id>debugging</id>",
  98. " <name>Debugging</name>",
  99. " <description>Diagnose hard bugs</description>",
  100. " </skill>",
  101. ].join("\n"),
  102. )
  103. skills = [debugging]
  104. const removed = yield* instructions
  105. .load({ id: agent.id, info: agent })
  106. .pipe(Effect.flatMap((context) => readUpdate(context, added)))
  107. expect(removed.text).toBe("The following skill IDs are no longer available and must not be used: effect.")
  108. }).pipe(Effect.provide(layer(() => skills)))
  109. })
  110. it.effect("restates the full skill list when a description changes", () => {
  111. const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
  112. let skills = [effect]
  113. return Effect.gen(function* () {
  114. const instructions = yield* SkillInstructions.Service
  115. const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
  116. skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })]
  117. expect(
  118. yield* instructions
  119. .load({ id: agent.id, info: agent })
  120. .pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
  121. ).toMatchObject({
  122. text: expect.stringContaining(
  123. "The available skills have changed. This list supersedes the previous available skills list.",
  124. ),
  125. })
  126. }).pipe(Effect.provide(layer(() => skills)))
  127. })
  128. it.effect("omits instructions when the selected agent denies all skills", () => {
  129. const agent = AgentV2.Info.make({
  130. ...AgentV2.Info.empty(build),
  131. permissions: [{ action: "skill", resource: "*", effect: "deny" }],
  132. })
  133. return Effect.gen(function* () {
  134. const instructions = yield* SkillInstructions.Service
  135. expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
  136. }).pipe(Effect.provide(layer(() => [effect])))
  137. })
  138. it.effect("omits instructions when a resource-specific denial follows the global denial", () => {
  139. const agent = AgentV2.Info.make({
  140. ...AgentV2.Info.empty(build),
  141. permissions: [
  142. { action: "skill", resource: "*", effect: "deny" },
  143. { action: "skill", resource: "hidden", effect: "deny" },
  144. ],
  145. })
  146. return Effect.gen(function* () {
  147. const instructions = yield* SkillInstructions.Service
  148. expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
  149. }).pipe(Effect.provide(layer(() => [effect])))
  150. })
  151. it.effect("retains specifically allowed skills after a global denial", () => {
  152. const agent = AgentV2.Info.make({
  153. ...AgentV2.Info.empty(build),
  154. permissions: [
  155. { action: "skill", resource: "*", effect: "deny" },
  156. { action: "skill", resource: "effect", effect: "allow" },
  157. ],
  158. })
  159. return Effect.gen(function* () {
  160. const instructions = yield* SkillInstructions.Service
  161. expect(
  162. (yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text,
  163. ).toContain("<name>Effect</name>")
  164. }).pipe(Effect.provide(layer(() => [effect])))
  165. })
  166. it.effect("omits instructions when a specifically allowed skill is denied again", () => {
  167. const agent = AgentV2.Info.make({
  168. ...AgentV2.Info.empty(build),
  169. permissions: [
  170. { action: "skill", resource: "*", effect: "deny" },
  171. { action: "skill", resource: "effect", effect: "allow" },
  172. { action: "skill", resource: "effect", effect: "deny" },
  173. ],
  174. })
  175. return Effect.gen(function* () {
  176. const instructions = yield* SkillInstructions.Service
  177. expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
  178. }).pipe(Effect.provide(layer(() => [effect])))
  179. })
  180. })