guidance.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  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 { Instructions } from "@opencode-ai/core/instructions"
  9. import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
  10. import { it } from "../lib/effect"
  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(SkillGuidance.node, [
  42. [SkillV2.node, Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })],
  43. ])
  44. describe("SkillGuidance", () => {
  45. it.effect("renders described agent skills and reconciles 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 guidance = yield* SkillGuidance.Service
  53. const initialized = yield* guidance
  54. .load({ id: agent.id, info: agent })
  55. .pipe(Effect.flatMap(Instructions.initialize))
  56. expect(initialized.text).toBe(
  57. [
  58. "Skills provide specialized instructions and workflows for specific tasks.",
  59. "Use the skill tool to load a skill when a task matches its description.",
  60. "<available_skills>",
  61. " <skill>",
  62. " <id>effect</id>",
  63. " <name>Effect</name>",
  64. " <description>Build applications with Effect</description>",
  65. " </skill>",
  66. "</available_skills>",
  67. ].join("\n"),
  68. )
  69. expect(initialized.text).not.toContain("manual")
  70. skills = []
  71. expect(
  72. yield* guidance
  73. .load({ id: agent.id, info: agent })
  74. .pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
  75. ).toMatchObject({
  76. _tag: "Updated",
  77. text: "The following skill IDs are no longer available and must not be used: effect.",
  78. })
  79. }).pipe(Effect.provide(layer(() => skills)))
  80. })
  81. it.effect("announces added and removed skills as deltas without restating the list", () => {
  82. const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
  83. const debugging = SkillV2.Info.make({
  84. id: SkillV2.ID.make("debugging"),
  85. name: SkillV2.Name.make("Debugging"),
  86. description: "Diagnose hard bugs",
  87. location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
  88. content: "Debugging guidance",
  89. })
  90. let skills = [effect]
  91. return Effect.gen(function* () {
  92. const guidance = yield* SkillGuidance.Service
  93. const initialized = yield* guidance
  94. .load({ id: agent.id, info: agent })
  95. .pipe(Effect.flatMap(Instructions.initialize))
  96. skills = [effect, debugging]
  97. const added = yield* guidance
  98. .load({ id: agent.id, info: agent })
  99. .pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied)))
  100. expect(added).toMatchObject({
  101. _tag: "Updated",
  102. text: [
  103. "New skills are available in addition to those previously listed:",
  104. " <skill>",
  105. " <id>debugging</id>",
  106. " <name>Debugging</name>",
  107. " <description>Diagnose hard bugs</description>",
  108. " </skill>",
  109. ].join("\n"),
  110. })
  111. skills = [debugging]
  112. const removed = yield* guidance
  113. .load({ id: agent.id, info: agent })
  114. .pipe(
  115. Effect.flatMap((context) => Instructions.reconcile(context, added._tag === "Updated" ? added.applied : {})),
  116. )
  117. expect(removed).toMatchObject({
  118. _tag: "Updated",
  119. text: "The following skill IDs are no longer available and must not be used: effect.",
  120. })
  121. }).pipe(Effect.provide(layer(() => skills)))
  122. })
  123. it.effect("restates the full skill list when a description changes", () => {
  124. const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
  125. let skills = [effect]
  126. return Effect.gen(function* () {
  127. const guidance = yield* SkillGuidance.Service
  128. const initialized = yield* guidance
  129. .load({ id: agent.id, info: agent })
  130. .pipe(Effect.flatMap(Instructions.initialize))
  131. skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })]
  132. expect(
  133. yield* guidance
  134. .load({ id: agent.id, info: agent })
  135. .pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
  136. ).toMatchObject({
  137. _tag: "Updated",
  138. text: expect.stringContaining(
  139. "The available skills have changed. This list supersedes the previous available skills list.",
  140. ),
  141. })
  142. }).pipe(Effect.provide(layer(() => skills)))
  143. })
  144. it.effect("omits guidance when the selected agent denies all skills", () => {
  145. const agent = AgentV2.Info.make({
  146. ...AgentV2.Info.empty(build),
  147. permissions: [{ action: "skill", resource: "*", effect: "deny" }],
  148. })
  149. return Effect.gen(function* () {
  150. const guidance = yield* SkillGuidance.Service
  151. expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
  152. {
  153. text: "",
  154. applied: {},
  155. },
  156. )
  157. }).pipe(Effect.provide(layer(() => [effect])))
  158. })
  159. it.effect("omits guidance when a resource-specific denial follows the global denial", () => {
  160. const agent = AgentV2.Info.make({
  161. ...AgentV2.Info.empty(build),
  162. permissions: [
  163. { action: "skill", resource: "*", effect: "deny" },
  164. { action: "skill", resource: "hidden", effect: "deny" },
  165. ],
  166. })
  167. return Effect.gen(function* () {
  168. const guidance = yield* SkillGuidance.Service
  169. expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
  170. {
  171. text: "",
  172. applied: {},
  173. },
  174. )
  175. }).pipe(Effect.provide(layer(() => [effect])))
  176. })
  177. it.effect("retains specifically allowed skills after a global denial", () => {
  178. const agent = AgentV2.Info.make({
  179. ...AgentV2.Info.empty(build),
  180. permissions: [
  181. { action: "skill", resource: "*", effect: "deny" },
  182. { action: "skill", resource: "effect", effect: "allow" },
  183. ],
  184. })
  185. return Effect.gen(function* () {
  186. const guidance = yield* SkillGuidance.Service
  187. expect(
  188. (yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text,
  189. ).toContain("<name>Effect</name>")
  190. }).pipe(Effect.provide(layer(() => [effect])))
  191. })
  192. it.effect("omits guidance when a specifically allowed skill is denied again", () => {
  193. const agent = AgentV2.Info.make({
  194. ...AgentV2.Info.empty(build),
  195. permissions: [
  196. { action: "skill", resource: "*", effect: "deny" },
  197. { action: "skill", resource: "effect", effect: "allow" },
  198. { action: "skill", resource: "effect", effect: "deny" },
  199. ],
  200. })
  201. return Effect.gen(function* () {
  202. const guidance = yield* SkillGuidance.Service
  203. expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
  204. {
  205. text: "",
  206. applied: {},
  207. },
  208. )
  209. }).pipe(Effect.provide(layer(() => [effect])))
  210. })
  211. })