session-instructions.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import { describe, expect, test } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { DateTime, Effect, Layer } from "effect"
  5. import { Message } from "@opencode-ai/ai"
  6. import { AgentV2 } from "@opencode-ai/core/agent"
  7. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  8. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  9. import { Config } from "@opencode-ai/core/config"
  10. import { Database } from "@opencode-ai/core/database/database"
  11. import { EventV2 } from "@opencode-ai/core/event"
  12. import { FSUtil } from "@opencode-ai/util/fs-util"
  13. import { Global } from "@opencode-ai/util/global"
  14. import { Image } from "@opencode-ai/core/image"
  15. import { Location } from "@opencode-ai/core/location"
  16. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  17. import { ModelV2 } from "@opencode-ai/core/model"
  18. import { PermissionV2 } from "@opencode-ai/core/permission"
  19. import { ProjectV2 } from "@opencode-ai/core/project"
  20. import { ProviderV2 } from "@opencode-ai/core/provider"
  21. import { ReadTool } from "@opencode-ai/core/tool/read"
  22. import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
  23. import { SessionEvent } from "@opencode-ai/core/session/event"
  24. import { SessionExecution } from "@opencode-ai/core/session/execution"
  25. import { SessionInstructions } from "@opencode-ai/core/session/instructions"
  26. import { SessionMessage } from "@opencode-ai/core/session/message"
  27. import { SessionProjector } from "@opencode-ai/core/session/projector"
  28. import { SessionStore } from "@opencode-ai/core/session/store"
  29. import { SessionV2 } from "@opencode-ai/core/session"
  30. import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
  31. import { ToolHooks } from "@opencode-ai/core/tool/hooks"
  32. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  33. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  34. import { tempLocationLayer } from "./fixture/location"
  35. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  36. import { testEffect } from "./lib/effect"
  37. import { registerToolPlugin, settleTool } from "./lib/tool"
  38. const readToolNode = makeLocationNode({
  39. name: "test/read-tool-plugin",
  40. layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
  41. deps: [
  42. ToolRegistry.toolsNode,
  43. ReadToolFileSystem.node,
  44. LocationMutation.node,
  45. Image.node,
  46. PermissionV2.node,
  47. SessionInstructions.node,
  48. FSUtil.node,
  49. Location.node,
  50. ],
  51. })
  52. const projects = Layer.succeed(
  53. ProjectV2.Service,
  54. ProjectV2.Service.of({
  55. list: () => Effect.succeed([]),
  56. resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
  57. directories: () => Effect.succeed([]),
  58. commit: () => Effect.void,
  59. }),
  60. )
  61. const permission = Layer.succeed(
  62. PermissionV2.Service,
  63. PermissionV2.Service.of({
  64. assert: () => Effect.void,
  65. ask: () => Effect.die("unused"),
  66. reply: () => Effect.die("unused"),
  67. get: () => Effect.die("unused"),
  68. forSession: () => Effect.die("unused"),
  69. list: () => Effect.die("unused"),
  70. }),
  71. )
  72. const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
  73. const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
  74. const testLayer = AppNodeBuilder.build(
  75. LayerNode.group([
  76. Database.node,
  77. EventV2.node,
  78. SessionProjector.node,
  79. SessionStore.node,
  80. SessionV2.node,
  81. Location.node,
  82. FSUtil.node,
  83. LocationMutation.node,
  84. ReadToolFileSystem.node,
  85. readToolNode,
  86. ToolRegistry.node,
  87. ToolRegistry.toolsNode,
  88. ToolHooks.node,
  89. SessionInstructions.node,
  90. Global.node,
  91. ToolOutputStore.node,
  92. Image.node,
  93. ]),
  94. [
  95. [ProjectV2.node, projects],
  96. [SessionExecution.node, SessionExecution.noopLayer],
  97. [Location.node, tempLocationLayer],
  98. [PermissionV2.node, permission],
  99. [Config.node, config],
  100. [Image.node, imageLayer],
  101. [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
  102. ],
  103. ) as unknown as Layer.Layer<unknown>
  104. const it = testEffect(testLayer)
  105. const identity = {
  106. agent: AgentV2.ID.make("build"),
  107. messageID: SessionMessage.ID.make("msg_nearby"),
  108. }
  109. const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({
  110. sessionID,
  111. ...identity,
  112. call: { type: "tool-call", id, name: "read", input: { path: readPath } },
  113. })
  114. const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content))
  115. const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
  116. const synthetics = (sessionID: SessionV2.ID) =>
  117. Effect.gen(function* () {
  118. const store = yield* SessionStore.Service
  119. return (yield* store.context(sessionID)).filter((message) => message.type === "synthetic")
  120. })
  121. // Seed a prior synthetic message with an instruction dedup ledger, simulating a prior turn
  122. // after the Location layer was reopened (in-memory set empty).
  123. const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) =>
  124. Effect.gen(function* () {
  125. const events = yield* EventV2.Service
  126. yield* events.publish(SessionEvent.Synthetic, {
  127. sessionID,
  128. text: `Instructions from: ${paths[0]}\nprior`,
  129. description: `Loaded ${paths[0]}`,
  130. metadata: { instruction: { paths } },
  131. })
  132. })
  133. describe("SessionInstructions", () => {
  134. it.effect("injects AGENTS.md files above a read, excludes the Location root, and dedups across reads", () =>
  135. Effect.gen(function* () {
  136. const location = yield* Location.Service
  137. const dir = location.directory
  138. const rootPath = path.resolve(dir, "AGENTS.md")
  139. const subPath = path.resolve(dir, "sub", "AGENTS.md")
  140. const deepPath = path.resolve(dir, "sub", "deep", "AGENTS.md")
  141. const otherPath = path.resolve(dir, "sub", "other", "AGENTS.md")
  142. yield* mkdir(path.dirname(deepPath))
  143. yield* mkdir(path.dirname(otherPath))
  144. yield* writeAgents(rootPath, "root-instructions")
  145. yield* writeAgents(subPath, "sub-instructions")
  146. yield* writeAgents(deepPath, "deep-instructions")
  147. yield* writeAgents(otherPath, "other-instructions")
  148. yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content"))
  149. yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2"))
  150. const session = yield* SessionV2.Service
  151. const registry = yield* ToolRegistry.Service
  152. const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
  153. // A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
  154. // excluding the Location root (already supplied by core initial instructions).
  155. yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
  156. const firstInjected = yield* synthetics(sessionID)
  157. expect(firstInjected).toHaveLength(1)
  158. expect(firstInjected[0]!.text).toBe(
  159. `Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`,
  160. )
  161. expect(firstInjected[0]!.description).toBe(
  162. `Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`,
  163. )
  164. // The synthetic's metadata carries the durable dedup ledger.
  165. expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
  166. expect(firstInjected[0]!.text).not.toContain("root-instructions")
  167. // A sibling read under sub/other discovers only the new AGENTS.md; sub is already
  168. // injected for this session so it is not re-emitted, and the root is still excluded.
  169. yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
  170. const secondInjected = yield* synthetics(sessionID)
  171. expect(secondInjected).toHaveLength(2)
  172. expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions`)
  173. expect(secondInjected[1]!.description).toBe(`Loaded ${path.relative(dir, otherPath)}`)
  174. expect(secondInjected[1]!.metadata).toEqual({ instruction: { paths: [otherPath] } })
  175. expect(secondInjected.some((message) => message.text.includes("root-instructions"))).toBe(false)
  176. }),
  177. )
  178. it.effect("does not re-inject paths already recorded in durable session history", () =>
  179. Effect.gen(function* () {
  180. const location = yield* Location.Service
  181. const dir = location.directory
  182. const rootPath = path.resolve(dir, "AGENTS.md")
  183. const subPath = path.resolve(dir, "sub", "AGENTS.md")
  184. yield* mkdir(path.resolve(dir, "sub"))
  185. yield* writeAgents(rootPath, "root-instructions")
  186. yield* writeAgents(subPath, "sub-instructions")
  187. yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
  188. const session = yield* SessionV2.Service
  189. const registry = yield* ToolRegistry.Service
  190. const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
  191. // Seed the durable history with a prior synthetic that already claims sub's AGENTS.md
  192. // via the instruction metadata ledger.
  193. yield* seedSynthetic(sessionID, [subPath])
  194. expect(yield* synthetics(sessionID)).toHaveLength(1)
  195. yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
  196. // The durable claim on the prior synthetic prevents re-injection; no new synthetic.
  197. expect(yield* synthetics(sessionID)).toHaveLength(1)
  198. }),
  199. )
  200. it.effect(
  201. "discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read",
  202. () =>
  203. Effect.gen(function* () {
  204. const location = yield* Location.Service
  205. const dir = location.directory
  206. const rootPath = path.resolve(dir, "AGENTS.md")
  207. const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
  208. yield* mkdir(path.resolve(dir, "packages", "foo"))
  209. yield* writeAgents(rootPath, "root-instructions")
  210. yield* writeAgents(pkgPath, "pkg-instructions")
  211. yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
  212. const session = yield* SessionV2.Service
  213. const registry = yield* ToolRegistry.Service
  214. const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
  215. // Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
  216. // the Location root (already supplied by core initial instructions).
  217. yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
  218. const firstInjected = yield* synthetics(sessionID)
  219. expect(firstInjected).toHaveLength(1)
  220. expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
  221. expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
  222. expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
  223. expect(firstInjected[0]!.text).not.toContain("root-instructions")
  224. // A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
  225. // already injected for this session, so nothing new is emitted.
  226. yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
  227. expect(yield* synthetics(sessionID)).toHaveLength(1)
  228. }),
  229. )
  230. it.effect("listing the Location root directory injects no instructions", () =>
  231. Effect.gen(function* () {
  232. const location = yield* Location.Service
  233. const dir = location.directory
  234. const rootPath = path.resolve(dir, "AGENTS.md")
  235. const subPath = path.resolve(dir, "sub", "AGENTS.md")
  236. yield* mkdir(path.resolve(dir, "sub"))
  237. yield* writeAgents(rootPath, "root-instructions")
  238. yield* writeAgents(subPath, "sub-instructions")
  239. const session = yield* SessionV2.Service
  240. const registry = yield* ToolRegistry.Service
  241. const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
  242. // The walk starts and stops at the Location root: the root AGENTS.md is searched but
  243. // dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
  244. yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
  245. expect(yield* synthetics(sessionID)).toHaveLength(0)
  246. }),
  247. )
  248. it.effect("loads instructions directly without a read", () =>
  249. Effect.gen(function* () {
  250. const location = yield* Location.Service
  251. const dir = location.directory
  252. const subPath = path.resolve(dir, "sub", "AGENTS.md")
  253. yield* mkdir(path.resolve(dir, "sub"))
  254. yield* writeAgents(subPath, "sub-instructions")
  255. const session = yield* SessionV2.Service
  256. const sessionInstructions = yield* SessionInstructions.Service
  257. const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
  258. yield* sessionInstructions.load({ sessionID, paths: [subPath] })
  259. const injected = yield* synthetics(sessionID)
  260. expect(injected).toHaveLength(1)
  261. expect(injected[0]!.text).toBe(`Instructions from: ${subPath}\nsub-instructions`)
  262. expect(injected[0]!.description).toBe(`Loaded ${path.relative(dir, subPath)}`)
  263. expect(injected[0]!.metadata).toEqual({ instruction: { paths: [subPath] } })
  264. }),
  265. )
  266. test("toLLMMessages does not forward synthetic metadata to the provider", () => {
  267. const created = DateTime.makeUnsafe(0)
  268. const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
  269. const synthetic = SessionMessage.Synthetic.make({
  270. id: SessionMessage.ID.make("msg_synthetic"),
  271. type: "synthetic",
  272. text: "Instructions from: /repo/sub/AGENTS.md\ncontent",
  273. description: "Loaded /repo/sub/AGENTS.md",
  274. metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } },
  275. time: { created },
  276. })
  277. const messages = toLLMMessages([synthetic], model)
  278. expect(messages).toHaveLength(1)
  279. expect(messages[0]!.role).toBe("user")
  280. expect(messages[0]!.content).toEqual([{ type: "text", text: "Instructions from: /repo/sub/AGENTS.md\ncontent" }])
  281. // Metadata is bookkeeping for the dedup ledger; the model must not see it.
  282. expect(messages[0]!.metadata).toBeUndefined()
  283. })
  284. })