instruction.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import { describe, expect, test } from "bun:test"
  2. import { SessionV1 } from "@kirincode-ai/core/v1/session"
  3. import path from "path"
  4. import { Effect, FileSystem, Layer } from "effect"
  5. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  6. import { Instruction } from "../../src/session/instruction"
  7. import type { MessageV2 } from "../../src/session/message-v2"
  8. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  9. import { Global } from "@kirincode-ai/core/global"
  10. import { RuntimeFlags } from "../../src/effect/runtime-flags"
  11. import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
  12. import { testEffect } from "../lib/effect"
  13. import { TestConfig } from "../fixture/config"
  14. import { ProviderV2 } from "@kirincode-ai/core/provider"
  15. import { ModelV2 } from "@kirincode-ai/core/model"
  16. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  17. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  18. import { LayerNodePlatform } from "@kirincode-ai/core/effect/app-node-platform"
  19. import { InstanceStore } from "@/project/instance-store"
  20. import { InstanceBootstrap } from "@/project/bootstrap"
  21. import { Config } from "@/config/config"
  22. const it = testEffect(
  23. AppNodeBuilder.build(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem, InstanceStore.node]), [
  24. [
  25. InstanceBootstrap.node,
  26. Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })),
  27. ],
  28. ]),
  29. )
  30. const configLayer = Layer.succeed(Config.Service, TestConfig.make())
  31. const instructionLayer = (global: Partial<Global.Interface>, flags: Partial<RuntimeFlags.Info> = {}) =>
  32. AppNodeBuilder.build(Instruction.node, [
  33. [Config.node, configLayer],
  34. [Global.node, Global.layerWith(global)],
  35. [RuntimeFlags.node, RuntimeFlags.layer(flags)],
  36. ])
  37. const provideInstruction =
  38. (global: Partial<Global.Interface>, flags?: Partial<RuntimeFlags.Info>) =>
  39. <A, E, R>(self: Effect.Effect<A, E, R>) =>
  40. self.pipe(Effect.provide(instructionLayer(global, flags)))
  41. const write = (filepath: string, content: string) =>
  42. Effect.gen(function* () {
  43. const fs = yield* FileSystem.FileSystem
  44. yield* fs.makeDirectory(path.dirname(filepath), { recursive: true })
  45. yield* fs.writeFileString(filepath, content)
  46. })
  47. const writeFiles = (dir: string, files: Record<string, string>) =>
  48. Effect.all(
  49. Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)),
  50. { discard: true },
  51. )
  52. const withFiles = <A, E, R>(files: Record<string, string>, self: (dir: string) => Effect.Effect<A, E, R>) =>
  53. provideTmpdirInstance((dir) =>
  54. Effect.gen(function* () {
  55. yield* writeFiles(dir, files)
  56. return yield* self(dir).pipe(provideInstruction({ home: dir, config: dir }))
  57. }),
  58. )
  59. const tmpWithFiles = (files: Record<string, string>) =>
  60. Effect.gen(function* () {
  61. const dir = yield* tmpdirScoped()
  62. yield* writeFiles(dir, files)
  63. return dir
  64. })
  65. function loaded(filepath: string): SessionV1.WithParts[] {
  66. const sessionID = SessionID.make("session-loaded-1")
  67. const messageID = MessageID.make("msg_message-loaded-1")
  68. return [
  69. {
  70. info: {
  71. id: messageID,
  72. sessionID,
  73. role: "user",
  74. time: { created: 0 },
  75. agent: "build",
  76. model: {
  77. providerID: ProviderV2.ID.make("anthropic"),
  78. modelID: ModelV2.ID.make("claude-sonnet-4-20250514"),
  79. },
  80. },
  81. parts: [
  82. {
  83. id: PartID.make("prt_part-loaded-1"),
  84. messageID,
  85. sessionID,
  86. type: "tool",
  87. callID: "call-loaded-1",
  88. tool: "read",
  89. state: {
  90. status: "completed",
  91. input: {},
  92. output: "done",
  93. title: "Read",
  94. metadata: { loaded: [filepath] },
  95. time: { start: 0, end: 1 },
  96. },
  97. },
  98. ],
  99. },
  100. ]
  101. }
  102. describe("Instruction.resolve", () => {
  103. it.live("returns empty when AGENTS.md is at project root (already in systemPaths)", () =>
  104. withFiles({ "AGENTS.md": "# Root Instructions", "src/file.ts": "const x = 1" }, (dir) =>
  105. Effect.gen(function* () {
  106. const svc = yield* Instruction.Service
  107. const system = yield* svc.systemPaths()
  108. expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true)
  109. const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("msg_message-test-1"))
  110. expect(results).toEqual([])
  111. }),
  112. ),
  113. )
  114. it.live("returns AGENTS.md from subdirectory (not in systemPaths)", () =>
  115. withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
  116. Effect.gen(function* () {
  117. const svc = yield* Instruction.Service
  118. const system = yield* svc.systemPaths()
  119. expect(system.has(path.join(dir, "subdir", "AGENTS.md"))).toBe(false)
  120. const results = yield* svc.resolve(
  121. [],
  122. path.join(dir, "subdir", "nested", "file.ts"),
  123. MessageID.make("msg_message-test-2"),
  124. )
  125. expect(results.length).toBe(1)
  126. expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
  127. }),
  128. ),
  129. )
  130. it.live("doesn't reload AGENTS.md when reading it directly", () =>
  131. withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
  132. Effect.gen(function* () {
  133. const svc = yield* Instruction.Service
  134. const filepath = path.join(dir, "subdir", "AGENTS.md")
  135. const system = yield* svc.systemPaths()
  136. expect(system.has(filepath)).toBe(false)
  137. const results = yield* svc.resolve([], filepath, MessageID.make("msg_message-test-3"))
  138. expect(results).toEqual([])
  139. }),
  140. ),
  141. )
  142. it.live("does not reattach the same nearby instructions twice for one message", () =>
  143. withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
  144. Effect.gen(function* () {
  145. const svc = yield* Instruction.Service
  146. const filepath = path.join(dir, "subdir", "nested", "file.ts")
  147. const id = MessageID.make("msg_message-claim-1")
  148. const first = yield* svc.resolve([], filepath, id)
  149. const second = yield* svc.resolve([], filepath, id)
  150. expect(first).toHaveLength(1)
  151. expect(first[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
  152. expect(second).toEqual([])
  153. }),
  154. ),
  155. )
  156. it.live("clear allows nearby instructions to be attached again for the same message", () =>
  157. withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
  158. Effect.gen(function* () {
  159. const svc = yield* Instruction.Service
  160. const filepath = path.join(dir, "subdir", "nested", "file.ts")
  161. const id = MessageID.make("msg_message-claim-2")
  162. const first = yield* svc.resolve([], filepath, id)
  163. yield* svc.clear(id)
  164. const second = yield* svc.resolve([], filepath, id)
  165. expect(first).toHaveLength(1)
  166. expect(second).toHaveLength(1)
  167. expect(second[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
  168. }),
  169. ),
  170. )
  171. it.live("skips instructions already reported by prior read metadata", () =>
  172. withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
  173. Effect.gen(function* () {
  174. const svc = yield* Instruction.Service
  175. const agents = path.join(dir, "subdir", "AGENTS.md")
  176. const filepath = path.join(dir, "subdir", "nested", "file.ts")
  177. const id = MessageID.make("msg_message-claim-3")
  178. const results = yield* svc.resolve(loaded(agents), filepath, id)
  179. expect(results).toEqual([])
  180. }),
  181. ),
  182. )
  183. test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
  184. })
  185. describe("Instruction.system", () => {
  186. it.live("loads both project and global AGENTS.md when both exist", () =>
  187. Effect.gen(function* () {
  188. const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
  189. const projectTmp = yield* tmpWithFiles({ "AGENTS.md": "# Project Instructions" })
  190. yield* Effect.gen(function* () {
  191. const svc = yield* Instruction.Service
  192. const paths = yield* svc.systemPaths()
  193. expect(paths.has(path.join(projectTmp, "AGENTS.md"))).toBe(true)
  194. expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
  195. const rules = yield* svc.system()
  196. expect(rules).toHaveLength(2)
  197. expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`)
  198. expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`)
  199. }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }))
  200. }),
  201. )
  202. it.live("skips project and global CLAUDE.md when Claude Code prompt is disabled", () =>
  203. Effect.gen(function* () {
  204. const globalTmp = yield* tmpWithFiles({ ".claude/CLAUDE.md": "# Global Claude" })
  205. const projectTmp = yield* tmpWithFiles({ "CLAUDE.md": "# Project Claude" })
  206. yield* Effect.gen(function* () {
  207. const svc = yield* Instruction.Service
  208. const paths = yield* svc.systemPaths()
  209. expect(paths.has(path.join(globalTmp, ".claude", "CLAUDE.md"))).toBe(false)
  210. expect(paths.has(path.join(projectTmp, "CLAUDE.md"))).toBe(false)
  211. expect(yield* svc.system()).toEqual([])
  212. }).pipe(
  213. provideInstance(projectTmp),
  214. provideInstruction({ home: globalTmp, config: globalTmp }, { disableClaudeCodePrompt: true }),
  215. )
  216. }),
  217. )
  218. })
  219. describe("Instruction.systemPaths global config", () => {
  220. it.live("uses Global.Service config AGENTS.md", () =>
  221. Effect.gen(function* () {
  222. const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
  223. const projectTmp = yield* tmpdirScoped()
  224. yield* Effect.gen(function* () {
  225. const svc = yield* Instruction.Service
  226. const paths = yield* svc.systemPaths()
  227. expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
  228. }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }))
  229. }),
  230. )
  231. })