instruction-discovery.test.ts 11 KB

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