instruction-discovery.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { FSUtil } from "@opencode-ai/util/fs-util"
  8. import { Global } from "@opencode-ai/util/global"
  9. import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { AbsolutePath } from "@opencode-ai/core/schema"
  12. import { location } from "./fixture/location"
  13. import { tmpdir } from "./fixture/tmpdir"
  14. import { testEffect } from "./lib/effect"
  15. import { readInitial, readUpdate, state } from "./lib/instructions"
  16. const it = testEffect(Layer.empty)
  17. const instructionLayer = (input: {
  18. config: string
  19. locationServiceLayer: Layer.Layer<Location.Service>
  20. filesystemLayer?: Layer.Layer<FSUtil.Service>
  21. project?: boolean
  22. }) =>
  23. AppNodeBuilder.build(InstructionDiscovery.node, [
  24. [InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
  25. [Global.node, Global.layerWith({ config: input.config })],
  26. [Location.node, input.locationServiceLayer],
  27. ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
  28. ])
  29. describe("InstructionDiscovery", () => {
  30. it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
  31. Effect.acquireRelease(
  32. Effect.promise(() => tmpdir()),
  33. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  34. ).pipe(
  35. Effect.flatMap((tmp) =>
  36. Effect.gen(function* () {
  37. const global = path.join(tmp.path, "global")
  38. const project = path.join(tmp.path, "project")
  39. const directory = path.join(project, "packages", "core")
  40. const outside = path.join(tmp.path, "AGENTS.md")
  41. const globalFile = path.join(global, "AGENTS.md")
  42. const projectFile = path.join(project, "AGENTS.md")
  43. const packageFile = path.join(directory, "AGENTS.md")
  44. yield* Effect.promise(async () => {
  45. await fs.mkdir(global, { recursive: true })
  46. await fs.mkdir(directory, { recursive: true })
  47. await fs.writeFile(outside, "outside")
  48. await fs.writeFile(globalFile, "global")
  49. await fs.writeFile(projectFile, "project")
  50. await fs.writeFile(packageFile, "package")
  51. })
  52. const load = InstructionDiscovery.Service.pipe(
  53. Effect.flatMap((service) => service.load()),
  54. Effect.provide(
  55. instructionLayer({
  56. config: global,
  57. locationServiceLayer: Layer.succeed(
  58. Location.Service,
  59. Location.Service.of(
  60. location(
  61. { directory: AbsolutePath.make(directory) },
  62. { projectDirectory: AbsolutePath.make(project) },
  63. ),
  64. ),
  65. ),
  66. }),
  67. ),
  68. )
  69. const initialized = yield* readInitial(yield* load)
  70. expect(initialized.text).toBe(
  71. [
  72. `Instructions from: ${globalFile}\nglobal`,
  73. `Instructions from: ${packageFile}\npackage`,
  74. `Instructions from: ${projectFile}\nproject`,
  75. ].join("\n\n"),
  76. )
  77. expect(initialized.text).not.toContain("outside")
  78. yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
  79. expect((yield* readUpdate(yield* load, initialized)).text).toContain(
  80. `Instructions from: ${packageFile}\nchanged`,
  81. )
  82. yield* Effect.promise(() => fs.rm(packageFile))
  83. const partial = yield* readUpdate(yield* load, initialized)
  84. expect(partial.text).toBe(
  85. [
  86. "These instructions replace all previously loaded ambient instructions.",
  87. `Instructions from: ${globalFile}\nglobal`,
  88. `Instructions from: ${projectFile}\nproject`,
  89. ].join("\n\n"),
  90. )
  91. yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
  92. expect((yield* readUpdate(yield* load, initialized)).text).toBe(
  93. "Previously loaded instructions no longer apply.",
  94. )
  95. }),
  96. ),
  97. ),
  98. )
  99. it.live("keeps an empty AGENTS.md as available context", () =>
  100. Effect.acquireRelease(
  101. Effect.promise(() => tmpdir()),
  102. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  103. ).pipe(
  104. Effect.flatMap((tmp) =>
  105. Effect.gen(function* () {
  106. const file = path.join(tmp.path, "AGENTS.md")
  107. yield* Effect.promise(() => fs.writeFile(file, ""))
  108. const context = yield* InstructionDiscovery.Service.pipe(
  109. Effect.flatMap((service) => service.load()),
  110. Effect.provide(
  111. instructionLayer({
  112. config: path.join(tmp.path, "global"),
  113. locationServiceLayer: Layer.succeed(
  114. Location.Service,
  115. Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
  116. ),
  117. }),
  118. ),
  119. )
  120. expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`)
  121. }),
  122. ),
  123. ),
  124. )
  125. it.effect("preserves admitted instructions while observation is unavailable", () =>
  126. Effect.gen(function* () {
  127. const failingFS = Layer.effect(
  128. FSUtil.Service,
  129. FSUtil.Service.pipe(
  130. Effect.map((fs) =>
  131. FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
  132. ),
  133. ),
  134. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  135. const context = yield* InstructionDiscovery.Service.pipe(
  136. Effect.flatMap((service) => service.load()),
  137. Effect.provide(
  138. instructionLayer({
  139. config: "/global",
  140. filesystemLayer: failingFS,
  141. locationServiceLayer: Layer.succeed(
  142. Location.Service,
  143. Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
  144. ),
  145. }),
  146. ),
  147. )
  148. expect(
  149. (yield* readUpdate(context, state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] })))
  150. .changed,
  151. ).toBe(false)
  152. }),
  153. )
  154. it.effect("preserves admitted instructions when a discovered file disappears before read", () =>
  155. Effect.gen(function* () {
  156. const file = AbsolutePath.make("/repo/AGENTS.md")
  157. const racingFS = Layer.effect(
  158. FSUtil.Service,
  159. FSUtil.Service.pipe(
  160. Effect.map((fs) =>
  161. FSUtil.Service.of({
  162. ...fs,
  163. up: () => Effect.succeed([file]),
  164. readFileStringSafe: () => Effect.succeed(undefined),
  165. }),
  166. ),
  167. ),
  168. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  169. const context = yield* InstructionDiscovery.Service.pipe(
  170. Effect.flatMap((service) => service.load()),
  171. Effect.provide(
  172. instructionLayer({
  173. config: "/global",
  174. filesystemLayer: racingFS,
  175. locationServiceLayer: Layer.succeed(
  176. Location.Service,
  177. Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
  178. ),
  179. }),
  180. ),
  181. )
  182. expect(
  183. (yield* readUpdate(context, state({ "core/instructions": [{ path: file, content: "old" }] }))).changed,
  184. ).toBe(false)
  185. }),
  186. )
  187. it.effect("canonicalizes upward discovery boundaries", () =>
  188. Effect.gen(function* () {
  189. let observed: { targets: string[]; start: string; stop?: string } | undefined
  190. const observingFS = Layer.effect(
  191. FSUtil.Service,
  192. FSUtil.Service.pipe(
  193. Effect.map((fs) =>
  194. FSUtil.Service.of({
  195. ...fs,
  196. up: (options) =>
  197. Effect.sync(() => {
  198. observed = options
  199. return []
  200. }),
  201. }),
  202. ),
  203. ),
  204. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  205. yield* InstructionDiscovery.Service.pipe(
  206. Effect.flatMap((service) => service.load()),
  207. Effect.provide(
  208. instructionLayer({
  209. config: "/global",
  210. filesystemLayer: observingFS,
  211. locationServiceLayer: Layer.succeed(
  212. Location.Service,
  213. Location.Service.of(
  214. location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
  215. ),
  216. ),
  217. }),
  218. ),
  219. )
  220. expect(observed).toEqual({
  221. targets: ["AGENTS.md"],
  222. start: FSUtil.resolve("/repo"),
  223. stop: FSUtil.resolve("/repo"),
  224. })
  225. }),
  226. )
  227. it.effect("honors the project instruction opt-out", () =>
  228. Effect.gen(function* () {
  229. let scanned = false
  230. yield* InstructionDiscovery.Service.pipe(
  231. Effect.flatMap((service) => service.load()),
  232. Effect.provide(
  233. instructionLayer({
  234. config: "/global",
  235. project: false,
  236. filesystemLayer: Layer.effect(
  237. FSUtil.Service,
  238. FSUtil.Service.pipe(
  239. Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
  240. ),
  241. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
  242. locationServiceLayer: Layer.succeed(
  243. Location.Service,
  244. Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
  245. ),
  246. }),
  247. ),
  248. )
  249. expect(scanned).toBe(false)
  250. }),
  251. )
  252. it.effect("does not discover project instructions outside the canonical project root", () =>
  253. Effect.gen(function* () {
  254. let scanned = false
  255. yield* InstructionDiscovery.Service.pipe(
  256. Effect.flatMap((service) => service.load()),
  257. Effect.provide(
  258. instructionLayer({
  259. config: "/global",
  260. filesystemLayer: Layer.effect(
  261. FSUtil.Service,
  262. FSUtil.Service.pipe(
  263. Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
  264. ),
  265. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
  266. locationServiceLayer: Layer.succeed(
  267. Location.Service,
  268. Location.Service.of(
  269. location(
  270. { directory: AbsolutePath.make("/outside") },
  271. { projectDirectory: AbsolutePath.make("/repo") },
  272. ),
  273. ),
  274. ),
  275. }),
  276. ),
  277. )
  278. expect(scanned).toBe(false)
  279. }),
  280. )
  281. })