instruction.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. import { afterEach, beforeEach, describe, expect, test } from "bun:test"
  2. import path from "path"
  3. import { ModelID, ProviderID } from "../../src/provider/schema"
  4. import { Instruction } from "../../src/session/instruction"
  5. import type { MessageV2 } from "../../src/session/message-v2"
  6. import { Instance } from "../../src/project/instance"
  7. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  8. import { Global } from "../../src/global"
  9. import { tmpdir } from "../fixture/fixture"
  10. function loaded(filepath: string): MessageV2.WithParts[] {
  11. const sessionID = SessionID.make("session-loaded-1")
  12. const messageID = MessageID.make("message-loaded-1")
  13. return [
  14. {
  15. info: {
  16. id: messageID,
  17. sessionID,
  18. role: "user",
  19. time: { created: 0 },
  20. agent: "build",
  21. model: {
  22. providerID: ProviderID.make("anthropic"),
  23. modelID: ModelID.make("claude-sonnet-4-20250514"),
  24. },
  25. },
  26. parts: [
  27. {
  28. id: PartID.make("part-loaded-1"),
  29. messageID,
  30. sessionID,
  31. type: "tool",
  32. callID: "call-loaded-1",
  33. tool: "read",
  34. state: {
  35. status: "completed",
  36. input: {},
  37. output: "done",
  38. title: "Read",
  39. metadata: { loaded: [filepath] },
  40. time: { start: 0, end: 1 },
  41. },
  42. },
  43. ],
  44. },
  45. ]
  46. }
  47. describe("Instruction.resolve", () => {
  48. test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
  49. await using tmp = await tmpdir({
  50. init: async (dir) => {
  51. await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
  52. await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
  53. },
  54. })
  55. await Instance.provide({
  56. directory: tmp.path,
  57. fn: async () => {
  58. const system = await Instruction.systemPaths()
  59. expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
  60. const results = await Instruction.resolve(
  61. [],
  62. path.join(tmp.path, "src", "file.ts"),
  63. MessageID.make("message-test-1"),
  64. )
  65. expect(results).toEqual([])
  66. },
  67. })
  68. })
  69. test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
  70. await using tmp = await tmpdir({
  71. init: async (dir) => {
  72. await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
  73. await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
  74. },
  75. })
  76. await Instance.provide({
  77. directory: tmp.path,
  78. fn: async () => {
  79. const system = await Instruction.systemPaths()
  80. expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
  81. const results = await Instruction.resolve(
  82. [],
  83. path.join(tmp.path, "subdir", "nested", "file.ts"),
  84. MessageID.make("message-test-2"),
  85. )
  86. expect(results.length).toBe(1)
  87. expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
  88. },
  89. })
  90. })
  91. test("doesn't reload AGENTS.md when reading it directly", async () => {
  92. await using tmp = await tmpdir({
  93. init: async (dir) => {
  94. await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
  95. await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
  96. },
  97. })
  98. await Instance.provide({
  99. directory: tmp.path,
  100. fn: async () => {
  101. const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
  102. const system = await Instruction.systemPaths()
  103. expect(system.has(filepath)).toBe(false)
  104. const results = await Instruction.resolve([], filepath, MessageID.make("message-test-3"))
  105. expect(results).toEqual([])
  106. },
  107. })
  108. })
  109. test("does not reattach the same nearby instructions twice for one message", async () => {
  110. await using tmp = await tmpdir({
  111. init: async (dir) => {
  112. await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
  113. await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
  114. },
  115. })
  116. await Instance.provide({
  117. directory: tmp.path,
  118. fn: async () => {
  119. const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
  120. const id = MessageID.make("message-claim-1")
  121. const first = await Instruction.resolve([], filepath, id)
  122. const second = await Instruction.resolve([], filepath, id)
  123. expect(first).toHaveLength(1)
  124. expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
  125. expect(second).toEqual([])
  126. },
  127. })
  128. })
  129. test("clear allows nearby instructions to be attached again for the same message", async () => {
  130. await using tmp = await tmpdir({
  131. init: async (dir) => {
  132. await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
  133. await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
  134. },
  135. })
  136. await Instance.provide({
  137. directory: tmp.path,
  138. fn: async () => {
  139. const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
  140. const id = MessageID.make("message-claim-2")
  141. const first = await Instruction.resolve([], filepath, id)
  142. await Instruction.clear(id)
  143. const second = await Instruction.resolve([], filepath, id)
  144. expect(first).toHaveLength(1)
  145. expect(second).toHaveLength(1)
  146. expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
  147. },
  148. })
  149. })
  150. test("skips instructions already reported by prior read metadata", async () => {
  151. await using tmp = await tmpdir({
  152. init: async (dir) => {
  153. await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
  154. await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
  155. },
  156. })
  157. await Instance.provide({
  158. directory: tmp.path,
  159. fn: async () => {
  160. const agents = path.join(tmp.path, "subdir", "AGENTS.md")
  161. const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
  162. const id = MessageID.make("message-claim-3")
  163. const results = await Instruction.resolve(loaded(agents), filepath, id)
  164. expect(results).toEqual([])
  165. },
  166. })
  167. })
  168. test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
  169. })
  170. describe("Instruction.systemPaths KILO_CONFIG_DIR", () => {
  171. let originalConfigDir: string | undefined
  172. beforeEach(() => {
  173. originalConfigDir = process.env["KILO_CONFIG_DIR"]
  174. })
  175. afterEach(() => {
  176. if (originalConfigDir === undefined) {
  177. delete process.env["KILO_CONFIG_DIR"]
  178. } else {
  179. process.env["KILO_CONFIG_DIR"] = originalConfigDir
  180. }
  181. })
  182. test("prefers KILO_CONFIG_DIR AGENTS.md over global when both exist", async () => {
  183. await using profileTmp = await tmpdir({
  184. init: async (dir) => {
  185. await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions")
  186. },
  187. })
  188. await using globalTmp = await tmpdir({
  189. init: async (dir) => {
  190. await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
  191. },
  192. })
  193. await using projectTmp = await tmpdir()
  194. process.env["KILO_CONFIG_DIR"] = profileTmp.path
  195. const originalGlobalConfig = Global.Path.config
  196. ;(Global.Path as { config: string }).config = globalTmp.path
  197. try {
  198. await Instance.provide({
  199. directory: projectTmp.path,
  200. fn: async () => {
  201. const paths = await Instruction.systemPaths()
  202. expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
  203. expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
  204. },
  205. })
  206. } finally {
  207. ;(Global.Path as { config: string }).config = originalGlobalConfig
  208. }
  209. })
  210. test("falls back to global AGENTS.md when KILO_CONFIG_DIR has no AGENTS.md", async () => {
  211. await using profileTmp = await tmpdir()
  212. await using globalTmp = await tmpdir({
  213. init: async (dir) => {
  214. await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
  215. },
  216. })
  217. await using projectTmp = await tmpdir()
  218. process.env["KILO_CONFIG_DIR"] = profileTmp.path
  219. const originalGlobalConfig = Global.Path.config
  220. ;(Global.Path as { config: string }).config = globalTmp.path
  221. try {
  222. await Instance.provide({
  223. directory: projectTmp.path,
  224. fn: async () => {
  225. const paths = await Instruction.systemPaths()
  226. expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
  227. expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
  228. },
  229. })
  230. } finally {
  231. ;(Global.Path as { config: string }).config = originalGlobalConfig
  232. }
  233. })
  234. test("uses global AGENTS.md when KILO_CONFIG_DIR is not set", async () => {
  235. await using globalTmp = await tmpdir({
  236. init: async (dir) => {
  237. await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
  238. },
  239. })
  240. await using projectTmp = await tmpdir()
  241. delete process.env["KILO_CONFIG_DIR"]
  242. const originalGlobalConfig = Global.Path.config
  243. ;(Global.Path as { config: string }).config = globalTmp.path
  244. try {
  245. await Instance.provide({
  246. directory: projectTmp.path,
  247. fn: async () => {
  248. const paths = await Instruction.systemPaths()
  249. expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
  250. },
  251. })
  252. } finally {
  253. ;(Global.Path as { config: string }).config = originalGlobalConfig
  254. }
  255. })
  256. })