instruction-context.test.ts 11 KB

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