instruction-discovery.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. import { describe, expect } from "bun:test"
  2. import { Deferred, Effect, Fiber, Layer, Stream } 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 { Bus } from "@opencode-ai/core/bus"
  7. import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
  8. import { Watcher } from "@opencode-ai/core/filesystem/watcher"
  9. import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
  10. import { Instructions } from "@opencode-ai/core/instructions"
  11. import { Location } from "@opencode-ai/core/location"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { FSUtil } from "@opencode-ai/util/fs-util"
  14. import { Global } from "@opencode-ai/util/global"
  15. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  16. import { tempGlobalLayer } from "./fixture/global"
  17. import { location } from "./fixture/location"
  18. import { tmpdir } from "./fixture/tmpdir"
  19. import { readInitial, readUpdate, state } from "./lib/instructions"
  20. import { testEffect } from "./lib/effect"
  21. import { host } from "./plugin/host"
  22. const it = testEffect(Layer.empty)
  23. const instructionLayer = (input: {
  24. config?: string
  25. locationServiceLayer: Layer.Layer<Location.Service>
  26. filesystemLayer?: Layer.Layer<FSUtil.Service>
  27. project?: boolean
  28. }) => {
  29. const watcher = Watcher.testLayer
  30. return Layer.mergeAll(
  31. AppNodeBuilder.build(
  32. LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
  33. [
  34. [InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
  35. [Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
  36. [Location.node, input.locationServiceLayer],
  37. [Watcher.node, watcher],
  38. ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
  39. ],
  40. ),
  41. watcher,
  42. )
  43. }
  44. const start = Effect.fnUntraced(function* () {
  45. yield* ConfigInstructionPlugin.Plugin.effect(host())
  46. return yield* InstructionDiscovery.Service
  47. })
  48. const file = (path: string, content: string) =>
  49. new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
  50. function emitAndWait(update: Watcher.Update) {
  51. return Effect.gen(function* () {
  52. const watcher = yield* Watcher.Test
  53. const bus = yield* Bus.Service
  54. const updated = yield* Deferred.make<void>()
  55. const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
  56. Stream.runForEach(() => Deferred.succeed(updated, undefined).pipe(Effect.asVoid)),
  57. Effect.forkScoped,
  58. )
  59. yield* Effect.yieldNow
  60. yield* watcher.emit(update)
  61. yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
  62. yield* Fiber.interrupt(fiber)
  63. })
  64. }
  65. describe("InstructionDiscovery", () => {
  66. it.effect("stores ordered values with last-write-wins precedence", () =>
  67. Effect.gen(function* () {
  68. const discovery = yield* InstructionDiscovery.Service
  69. yield* discovery.transform((draft) => {
  70. draft.add(file("/repo/AGENTS.md", "first"))
  71. draft.add(file("/repo/packages/AGENTS.md", "package"))
  72. draft.add(file("/repo/AGENTS.md", "last"))
  73. draft.update("/repo/packages/AGENTS.md", (current) => {
  74. current.content = "updated"
  75. current.path = AbsolutePath.make("/ignored")
  76. })
  77. draft.remove("/missing")
  78. })
  79. expect(yield* discovery.list()).toEqual([
  80. file("/repo/AGENTS.md", "last"),
  81. file("/repo/packages/AGENTS.md", "updated"),
  82. ])
  83. }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
  84. )
  85. it.effect("preserves admitted values while the source is unavailable", () =>
  86. Effect.gen(function* () {
  87. const discovery = yield* InstructionDiscovery.Service
  88. yield* discovery.transform((draft) => draft.unavailable())
  89. expect(
  90. (yield* readUpdate(
  91. yield* discovery.load(),
  92. state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
  93. )).changed,
  94. ).toBe(false)
  95. }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
  96. )
  97. })
  98. describe("ConfigInstructionPlugin.Plugin", () => {
  99. it.live("loads global and upward project files and rescans them on change", () =>
  100. Effect.acquireRelease(
  101. Effect.promise(() => tmpdir()),
  102. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  103. ).pipe(
  104. Effect.flatMap((tmp) => {
  105. const global = path.join(tmp.path, "global")
  106. const project = path.join(tmp.path, "project")
  107. const directory = path.join(project, "packages", "core")
  108. const outside = path.join(tmp.path, "AGENTS.md")
  109. const globalFile = path.join(global, "AGENTS.md")
  110. const projectFile = path.join(project, "AGENTS.md")
  111. const packageFile = path.join(directory, "AGENTS.md")
  112. return Effect.gen(function* () {
  113. yield* Effect.promise(async () => {
  114. await fs.mkdir(global, { recursive: true })
  115. await fs.mkdir(directory, { recursive: true })
  116. await fs.writeFile(outside, "outside")
  117. await fs.writeFile(globalFile, "global")
  118. await fs.writeFile(projectFile, "project")
  119. await fs.writeFile(packageFile, "package")
  120. })
  121. const discovery = yield* start()
  122. const watcher = yield* Watcher.Test
  123. expect(yield* watcher.subscriptions()).toEqual([
  124. { path: globalFile, type: "file" },
  125. { path: packageFile, type: "file" },
  126. { path: path.join(project, "packages", "AGENTS.md"), type: "file" },
  127. { path: projectFile, type: "file" },
  128. ])
  129. const initialized = yield* readInitial(yield* discovery.load())
  130. expect(initialized.text).toBe(
  131. [
  132. `Instructions from: ${globalFile}\nglobal`,
  133. `Instructions from: ${packageFile}\npackage`,
  134. `Instructions from: ${projectFile}\nproject`,
  135. ].join("\n\n"),
  136. )
  137. expect(initialized.text).not.toContain("outside")
  138. yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
  139. yield* emitAndWait({ type: "update", path: packageFile })
  140. expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
  141. `Instructions from: ${packageFile}\nchanged`,
  142. )
  143. yield* Effect.promise(() => fs.rm(packageFile))
  144. yield* emitAndWait({ type: "delete", path: packageFile })
  145. expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
  146. [
  147. "These instructions replace all previously loaded ambient instructions.",
  148. `Instructions from: ${globalFile}\nglobal`,
  149. `Instructions from: ${projectFile}\nproject`,
  150. ].join("\n\n"),
  151. )
  152. yield* Effect.promise(() => fs.rm(globalFile))
  153. yield* emitAndWait({ type: "delete", path: globalFile })
  154. yield* Effect.promise(() => fs.rm(projectFile))
  155. yield* emitAndWait({ type: "delete", path: projectFile })
  156. expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
  157. "Previously loaded instructions no longer apply.",
  158. )
  159. }).pipe(
  160. Effect.provide(
  161. instructionLayer({
  162. config: global,
  163. locationServiceLayer: Layer.succeed(
  164. Location.Service,
  165. Location.Service.of(
  166. location(
  167. { directory: AbsolutePath.make(directory) },
  168. { projectDirectory: AbsolutePath.make(project) },
  169. ),
  170. ),
  171. ),
  172. }),
  173. ),
  174. )
  175. }),
  176. ),
  177. )
  178. it.live("keeps an empty AGENTS.md as available context", () =>
  179. Effect.acquireRelease(
  180. Effect.promise(() => tmpdir()),
  181. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  182. ).pipe(
  183. Effect.flatMap((tmp) =>
  184. Effect.gen(function* () {
  185. const file = path.join(tmp.path, "AGENTS.md")
  186. yield* Effect.promise(() => fs.writeFile(file, ""))
  187. const discovery = yield* start()
  188. expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${file}\n`)
  189. }).pipe(
  190. Effect.provide(
  191. instructionLayer({
  192. config: path.join(tmp.path, "global"),
  193. locationServiceLayer: Layer.succeed(
  194. Location.Service,
  195. Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
  196. ),
  197. }),
  198. ),
  199. ),
  200. ),
  201. ),
  202. )
  203. it.live("discovers a newly created instruction file in an intermediate directory", () =>
  204. Effect.acquireRelease(
  205. Effect.promise(() => tmpdir()),
  206. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  207. ).pipe(
  208. Effect.flatMap((tmp) => {
  209. const project = path.join(tmp.path, "project")
  210. const intermediate = path.join(project, "packages", "AGENTS.md")
  211. const directory = path.join(project, "packages", "core")
  212. const projectFile = path.join(project, "AGENTS.md")
  213. return Effect.gen(function* () {
  214. yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
  215. yield* Effect.promise(() => fs.writeFile(projectFile, "project"))
  216. const discovery = yield* start()
  217. expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${projectFile}\nproject`)
  218. yield* Effect.promise(() => fs.writeFile(intermediate, "intermediate"))
  219. yield* emitAndWait({ type: "create", path: intermediate })
  220. expect((yield* readInitial(yield* discovery.load())).text).toBe(
  221. [`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
  222. "\n\n",
  223. ),
  224. )
  225. }).pipe(
  226. Effect.provide(
  227. instructionLayer({
  228. config: path.join(tmp.path, "global"),
  229. locationServiceLayer: Layer.succeed(
  230. Location.Service,
  231. Location.Service.of(
  232. location(
  233. { directory: AbsolutePath.make(directory) },
  234. { projectDirectory: AbsolutePath.make(project) },
  235. ),
  236. ),
  237. ),
  238. }),
  239. ),
  240. )
  241. }),
  242. ),
  243. )
  244. it.effect("isolates source failure without failing activation", () => {
  245. const failingFS = Layer.effect(
  246. FSUtil.Service,
  247. FSUtil.Service.pipe(
  248. Effect.map((fs) =>
  249. FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
  250. ),
  251. ),
  252. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  253. return Effect.gen(function* () {
  254. const discovery = yield* start()
  255. expect(
  256. (yield* readUpdate(
  257. yield* discovery.load(),
  258. state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
  259. )).changed,
  260. ).toBe(false)
  261. }).pipe(
  262. Effect.provide(
  263. instructionLayer({
  264. filesystemLayer: failingFS,
  265. locationServiceLayer: Layer.succeed(
  266. Location.Service,
  267. Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
  268. ),
  269. }),
  270. ),
  271. )
  272. })
  273. it.effect("marks a discovered file that disappears before read as unavailable", () => {
  274. const discovered = AbsolutePath.make("/repo/AGENTS.md")
  275. const racingFS = Layer.effect(
  276. FSUtil.Service,
  277. FSUtil.Service.pipe(
  278. Effect.map((fs) =>
  279. FSUtil.Service.of({
  280. ...fs,
  281. up: () => Effect.succeed([discovered]),
  282. readFileStringSafe: () => Effect.succeed(undefined),
  283. }),
  284. ),
  285. ),
  286. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  287. return Effect.gen(function* () {
  288. const discovery = yield* start()
  289. expect(
  290. (yield* readUpdate(
  291. yield* discovery.load(),
  292. state({ "core/instructions": [{ path: discovered, content: "old" }] }),
  293. )).changed,
  294. ).toBe(false)
  295. }).pipe(
  296. Effect.provide(
  297. instructionLayer({
  298. filesystemLayer: racingFS,
  299. locationServiceLayer: Layer.succeed(
  300. Location.Service,
  301. Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
  302. ),
  303. }),
  304. ),
  305. )
  306. })
  307. it.effect("canonicalizes boundaries and honors project opt-out", () =>
  308. Effect.gen(function* () {
  309. const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
  310. const observingFS = Layer.effect(
  311. FSUtil.Service,
  312. FSUtil.Service.pipe(
  313. Effect.map((fs) =>
  314. FSUtil.Service.of({
  315. ...fs,
  316. up: (options) => Effect.sync(() => (observed.values.push(options), [])),
  317. }),
  318. ),
  319. ),
  320. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  321. yield* start().pipe(
  322. Effect.provide(
  323. instructionLayer({
  324. filesystemLayer: observingFS,
  325. locationServiceLayer: Layer.succeed(
  326. Location.Service,
  327. Location.Service.of(
  328. location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
  329. ),
  330. ),
  331. }),
  332. ),
  333. )
  334. yield* start().pipe(
  335. Effect.provide(
  336. instructionLayer({
  337. filesystemLayer: observingFS,
  338. project: false,
  339. locationServiceLayer: Layer.succeed(
  340. Location.Service,
  341. Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
  342. ),
  343. }),
  344. ),
  345. )
  346. yield* start().pipe(
  347. Effect.provide(
  348. instructionLayer({
  349. filesystemLayer: observingFS,
  350. locationServiceLayer: Layer.succeed(
  351. Location.Service,
  352. Location.Service.of(
  353. location(
  354. { directory: AbsolutePath.make("/outside") },
  355. { projectDirectory: AbsolutePath.make("/repo") },
  356. ),
  357. ),
  358. ),
  359. }),
  360. ),
  361. )
  362. const repo = path.resolve("/repo")
  363. expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
  364. }),
  365. )
  366. })