1
0

instruction-context.test.ts 12 KB

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