instruction-discovery.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  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/index"
  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. home?: string
  26. locationServiceLayer: Layer.Layer<Location.Service>
  27. filesystemLayer?: Layer.Layer<FSUtil.Service>
  28. project?: boolean
  29. }) => {
  30. const watcher = Watcher.testLayer
  31. return Layer.mergeAll(
  32. AppNodeBuilder.build(
  33. LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
  34. [
  35. [InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
  36. [
  37. Global.node,
  38. input.config || input.home
  39. ? Global.layerWith({
  40. ...(input.config ? { config: input.config } : {}),
  41. ...(input.home ? { home: input.home } : {}),
  42. })
  43. : tempGlobalLayer,
  44. ],
  45. [Location.node, input.locationServiceLayer],
  46. [Watcher.node, watcher],
  47. ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
  48. ],
  49. ),
  50. watcher,
  51. )
  52. }
  53. const start = Effect.fnUntraced(function* () {
  54. yield* ConfigInstructionPlugin.Plugin.effect(host())
  55. return yield* InstructionDiscovery.Service
  56. })
  57. const file = (path: string, content: string) =>
  58. new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
  59. function emitAndWait(update: Watcher.Update) {
  60. return Effect.gen(function* () {
  61. const watcher = yield* Watcher.Test
  62. const bus = yield* Bus.Service
  63. const updated = yield* Deferred.make<void>()
  64. const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
  65. Stream.runForEach(() => Deferred.succeed(updated, undefined).pipe(Effect.asVoid)),
  66. Effect.forkScoped,
  67. )
  68. yield* Effect.yieldNow
  69. yield* watcher.emit(update)
  70. yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
  71. yield* Fiber.interrupt(fiber)
  72. })
  73. }
  74. describe("InstructionDiscovery", () => {
  75. it.effect("stores ordered values with last-write-wins precedence", () =>
  76. Effect.gen(function* () {
  77. const discovery = yield* InstructionDiscovery.Service
  78. yield* discovery.transform((draft) => {
  79. draft.add(file("/repo/AGENTS.md", "first"))
  80. draft.add(file("/repo/packages/AGENTS.md", "package"))
  81. draft.add(file("/repo/AGENTS.md", "last"))
  82. draft.update("/repo/packages/AGENTS.md", (current) => {
  83. current.content = "updated"
  84. current.path = AbsolutePath.make("/ignored")
  85. })
  86. draft.remove("/missing")
  87. })
  88. expect(yield* discovery.list()).toEqual([
  89. file("/repo/AGENTS.md", "last"),
  90. file("/repo/packages/AGENTS.md", "updated"),
  91. ])
  92. }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
  93. )
  94. it.effect("preserves admitted values while the source is unavailable", () =>
  95. Effect.gen(function* () {
  96. const discovery = yield* InstructionDiscovery.Service
  97. yield* discovery.transform((draft) => draft.unavailable())
  98. expect(
  99. (yield* readUpdate(
  100. yield* discovery.load(),
  101. state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
  102. )).changed,
  103. ).toBe(false)
  104. }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
  105. )
  106. it.effect("renders granular instruction updates", () =>
  107. Effect.gen(function* () {
  108. const discovery = yield* InstructionDiscovery.Service
  109. yield* discovery.transform((draft) => {
  110. draft.add(file("/global/AGENTS.md", "global"))
  111. draft.add(
  112. file("/repo/AGENTS.md", ["old", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")),
  113. )
  114. })
  115. const initial = yield* readInitial(yield* discovery.load())
  116. yield* discovery.transform((draft) => {
  117. draft.update("/repo/AGENTS.md", (current) => {
  118. current.content = ["new", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")
  119. })
  120. })
  121. const modified = (yield* readUpdate(yield* discovery.load(), initial)).text
  122. expect(modified).toContain("The instructions from /repo/AGENTS.md changed. Here's the diff:")
  123. expect(modified).toContain("-old\n+new")
  124. expect(modified).not.toContain("global")
  125. const rewritten = state({
  126. "core/instructions": [{ path: "/repo/AGENTS.md", content: "old one\nold two\nold three\nold four" }],
  127. })
  128. yield* discovery.transform((draft) => {
  129. draft.remove("/global/AGENTS.md")
  130. draft.update("/repo/AGENTS.md", (current) => {
  131. current.content = "new"
  132. })
  133. })
  134. expect((yield* readUpdate(yield* discovery.load(), rewritten)).text).toBe(
  135. "The instructions changed:\nInstructions from: /repo/AGENTS.md\nnew",
  136. )
  137. yield* discovery.transform((draft) => {
  138. draft.add(file("/repo/packages/AGENTS.md", "package"))
  139. })
  140. const structural = (yield* readUpdate(yield* discovery.load(), initial)).text
  141. expect(structural).toContain("The instructions from /global/AGENTS.md no longer apply.")
  142. expect(structural).toContain("New instructions apply from:\nInstructions from: /repo/packages/AGENTS.md\npackage")
  143. expect(structural).not.toContain("Instructions from: /global/AGENTS.md\nglobal")
  144. }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
  145. )
  146. })
  147. describe("ConfigInstructionPlugin.Plugin", () => {
  148. it.live("loads global and upward project files and rescans them on change", () =>
  149. Effect.acquireRelease(
  150. Effect.promise(() => tmpdir()),
  151. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  152. ).pipe(
  153. Effect.flatMap((tmp) => {
  154. const global = path.join(tmp.path, "global")
  155. const home = path.join(tmp.path, "home")
  156. const shared = path.join(home, "code")
  157. const project = path.join(shared, "repo")
  158. const directory = path.join(project, "packages", "core")
  159. const outside = path.join(tmp.path, "AGENTS.md")
  160. const globalFile = path.join(global, "AGENTS.md")
  161. const sharedFile = path.join(shared, "AGENTS.md")
  162. const projectFile = path.join(project, "AGENTS.md")
  163. const packageFile = path.join(directory, "AGENTS.md")
  164. return Effect.gen(function* () {
  165. yield* Effect.promise(async () => {
  166. await fs.mkdir(global, { recursive: true })
  167. await fs.mkdir(directory, { recursive: true })
  168. await fs.writeFile(outside, "outside")
  169. await fs.writeFile(globalFile, "global")
  170. await fs.writeFile(sharedFile, "shared")
  171. await fs.writeFile(projectFile, "project")
  172. await fs.writeFile(packageFile, "package")
  173. })
  174. const discovery = yield* start()
  175. const watcher = yield* Watcher.Test
  176. expect(yield* watcher.subscriptions()).toEqual([
  177. { path: globalFile, type: "file" },
  178. { path: packageFile, type: "file" },
  179. { path: path.join(project, "packages", "AGENTS.md"), type: "file" },
  180. { path: projectFile, type: "file" },
  181. { path: sharedFile, type: "file" },
  182. { path: path.join(home, "AGENTS.md"), type: "file" },
  183. ])
  184. expect(yield* watcher.subscriptions()).not.toContainEqual({
  185. path: path.join(tmp.path, "AGENTS.md"),
  186. type: "file",
  187. })
  188. const initialized = yield* readInitial(yield* discovery.load())
  189. expect(initialized.text).toBe(
  190. [
  191. `Instructions from: ${globalFile}\nglobal`,
  192. `Instructions from: ${packageFile}\npackage`,
  193. `Instructions from: ${projectFile}\nproject`,
  194. `Instructions from: ${sharedFile}\nshared`,
  195. ].join("\n\n"),
  196. )
  197. expect(initialized.text).not.toContain("outside")
  198. yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
  199. yield* emitAndWait({ type: "update", path: packageFile })
  200. const changed = (yield* readUpdate(yield* discovery.load(), initialized)).text
  201. expect(changed).toContain(`The instructions changed:\nInstructions from: ${packageFile}\nchanged`)
  202. expect(changed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
  203. yield* Effect.promise(() => fs.rm(packageFile))
  204. yield* emitAndWait({ type: "delete", path: packageFile })
  205. const removed = (yield* readUpdate(yield* discovery.load(), initialized)).text
  206. expect(removed).toContain(`The instructions from ${packageFile} no longer apply.`)
  207. expect(removed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
  208. yield* Effect.promise(() => fs.rm(globalFile))
  209. yield* emitAndWait({ type: "delete", path: globalFile })
  210. yield* Effect.promise(() => fs.rm(projectFile))
  211. yield* emitAndWait({ type: "delete", path: projectFile })
  212. yield* Effect.promise(() => fs.rm(sharedFile))
  213. yield* emitAndWait({ type: "delete", path: sharedFile })
  214. expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
  215. "Previously loaded instructions no longer apply.",
  216. )
  217. }).pipe(
  218. Effect.provide(
  219. instructionLayer({
  220. config: global,
  221. home,
  222. locationServiceLayer: Layer.succeed(
  223. Location.Service,
  224. Location.Service.of(
  225. location(
  226. { directory: AbsolutePath.make(directory) },
  227. { projectDirectory: AbsolutePath.make(project) },
  228. ),
  229. ),
  230. ),
  231. }),
  232. ),
  233. )
  234. }),
  235. ),
  236. )
  237. it.live("keeps an empty AGENTS.md as available context", () =>
  238. Effect.acquireRelease(
  239. Effect.promise(() => tmpdir()),
  240. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  241. ).pipe(
  242. Effect.flatMap((tmp) =>
  243. Effect.gen(function* () {
  244. const file = path.join(tmp.path, "AGENTS.md")
  245. yield* Effect.promise(() => fs.writeFile(file, ""))
  246. const discovery = yield* start()
  247. expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${file}\n`)
  248. }).pipe(
  249. Effect.provide(
  250. instructionLayer({
  251. config: path.join(tmp.path, "global"),
  252. locationServiceLayer: Layer.succeed(
  253. Location.Service,
  254. Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
  255. ),
  256. }),
  257. ),
  258. ),
  259. ),
  260. ),
  261. )
  262. it.live("discovers a newly created instruction file above the project root", () =>
  263. Effect.acquireRelease(
  264. Effect.promise(() => tmpdir()),
  265. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  266. ).pipe(
  267. Effect.flatMap((tmp) => {
  268. const home = path.join(tmp.path, "home")
  269. const shared = path.join(home, "code")
  270. const project = path.join(shared, "repo")
  271. const intermediate = path.join(shared, "AGENTS.md")
  272. const directory = path.join(project, "core")
  273. const projectFile = path.join(project, "AGENTS.md")
  274. return Effect.gen(function* () {
  275. yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
  276. yield* Effect.promise(() => fs.writeFile(projectFile, "project"))
  277. const discovery = yield* start()
  278. expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${projectFile}\nproject`)
  279. yield* Effect.promise(() => fs.writeFile(intermediate, "intermediate"))
  280. yield* emitAndWait({ type: "create", path: intermediate })
  281. expect((yield* readInitial(yield* discovery.load())).text).toBe(
  282. [`Instructions from: ${projectFile}\nproject`, `Instructions from: ${intermediate}\nintermediate`].join(
  283. "\n\n",
  284. ),
  285. )
  286. }).pipe(
  287. Effect.provide(
  288. instructionLayer({
  289. config: path.join(tmp.path, "global"),
  290. home,
  291. locationServiceLayer: Layer.succeed(
  292. Location.Service,
  293. Location.Service.of(
  294. location(
  295. { directory: AbsolutePath.make(directory) },
  296. { projectDirectory: AbsolutePath.make(project) },
  297. ),
  298. ),
  299. ),
  300. }),
  301. ),
  302. )
  303. }),
  304. ),
  305. )
  306. it.live("stops instruction candidates at the project root outside home", () =>
  307. Effect.acquireRelease(
  308. Effect.promise(() => tmpdir()),
  309. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  310. ).pipe(
  311. Effect.flatMap((tmp) => {
  312. const global = path.join(tmp.path, "global")
  313. const home = path.join(tmp.path, "home")
  314. const project = path.join(tmp.path, "scratch", "repo")
  315. const directory = path.join(project, "packages", "core")
  316. return Effect.gen(function* () {
  317. yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
  318. yield* start()
  319. const watcher = yield* Watcher.Test
  320. expect(yield* watcher.subscriptions()).toEqual([
  321. { path: path.join(global, "AGENTS.md"), type: "file" },
  322. { path: path.join(directory, "AGENTS.md"), type: "file" },
  323. { path: path.join(project, "packages", "AGENTS.md"), type: "file" },
  324. { path: path.join(project, "AGENTS.md"), type: "file" },
  325. ])
  326. }).pipe(
  327. Effect.provide(
  328. instructionLayer({
  329. config: global,
  330. home,
  331. locationServiceLayer: Layer.succeed(
  332. Location.Service,
  333. Location.Service.of(
  334. location(
  335. { directory: AbsolutePath.make(directory) },
  336. { projectDirectory: AbsolutePath.make(project) },
  337. ),
  338. ),
  339. ),
  340. }),
  341. ),
  342. )
  343. }),
  344. ),
  345. )
  346. it.effect("isolates source failure without failing activation", () => {
  347. const failingFS = Layer.effect(
  348. FSUtil.Service,
  349. FSUtil.Service.pipe(
  350. Effect.map((fs) =>
  351. FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
  352. ),
  353. ),
  354. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  355. return Effect.gen(function* () {
  356. const discovery = yield* start()
  357. expect(
  358. (yield* readUpdate(
  359. yield* discovery.load(),
  360. state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
  361. )).changed,
  362. ).toBe(false)
  363. }).pipe(
  364. Effect.provide(
  365. instructionLayer({
  366. filesystemLayer: failingFS,
  367. locationServiceLayer: Layer.succeed(
  368. Location.Service,
  369. Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
  370. ),
  371. }),
  372. ),
  373. )
  374. })
  375. it.effect("marks a discovered file that disappears before read as unavailable", () => {
  376. const discovered = AbsolutePath.make("/repo/AGENTS.md")
  377. const racingFS = Layer.effect(
  378. FSUtil.Service,
  379. FSUtil.Service.pipe(
  380. Effect.map((fs) =>
  381. FSUtil.Service.of({
  382. ...fs,
  383. up: () => Effect.succeed([discovered]),
  384. readFileStringSafe: () => Effect.succeed(undefined),
  385. }),
  386. ),
  387. ),
  388. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  389. return Effect.gen(function* () {
  390. const discovery = yield* start()
  391. expect(
  392. (yield* readUpdate(
  393. yield* discovery.load(),
  394. state({ "core/instructions": [{ path: discovered, content: "old" }] }),
  395. )).changed,
  396. ).toBe(false)
  397. }).pipe(
  398. Effect.provide(
  399. instructionLayer({
  400. filesystemLayer: racingFS,
  401. locationServiceLayer: Layer.succeed(
  402. Location.Service,
  403. Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
  404. ),
  405. }),
  406. ),
  407. )
  408. })
  409. it.effect("canonicalizes boundaries and honors project opt-out", () =>
  410. Effect.gen(function* () {
  411. const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
  412. const observingFS = Layer.effect(
  413. FSUtil.Service,
  414. FSUtil.Service.pipe(
  415. Effect.map((fs) =>
  416. FSUtil.Service.of({
  417. ...fs,
  418. up: (options) => Effect.sync(() => (observed.values.push(options), [])),
  419. }),
  420. ),
  421. ),
  422. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  423. yield* start().pipe(
  424. Effect.provide(
  425. instructionLayer({
  426. filesystemLayer: observingFS,
  427. locationServiceLayer: Layer.succeed(
  428. Location.Service,
  429. Location.Service.of(
  430. location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
  431. ),
  432. ),
  433. }),
  434. ),
  435. )
  436. yield* start().pipe(
  437. Effect.provide(
  438. instructionLayer({
  439. filesystemLayer: observingFS,
  440. project: false,
  441. locationServiceLayer: Layer.succeed(
  442. Location.Service,
  443. Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
  444. ),
  445. }),
  446. ),
  447. )
  448. yield* start().pipe(
  449. Effect.provide(
  450. instructionLayer({
  451. filesystemLayer: observingFS,
  452. locationServiceLayer: Layer.succeed(
  453. Location.Service,
  454. Location.Service.of(
  455. location(
  456. { directory: AbsolutePath.make("/outside") },
  457. { projectDirectory: AbsolutePath.make("/repo") },
  458. ),
  459. ),
  460. ),
  461. }),
  462. ),
  463. )
  464. const repo = path.resolve("/repo")
  465. expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
  466. }),
  467. )
  468. })