tool-grep.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Layer } from "effect"
  5. import { FSUtil } from "@opencode-ai/core/fs-util"
  6. import { Location } from "@opencode-ai/core/location"
  7. import { FileSystem } from "@opencode-ai/core/filesystem"
  8. import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
  9. import { LocationSearch } from "@opencode-ai/core/location-search"
  10. import { PermissionV2 } from "@opencode-ai/core/permission"
  11. import { AppProcess } from "@opencode-ai/core/process"
  12. import { ProjectReference } from "@opencode-ai/core/project-reference"
  13. import { Ripgrep } from "@opencode-ai/core/ripgrep"
  14. import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
  15. import { SessionV2 } from "@opencode-ai/core/session"
  16. import { GrepTool } from "@opencode-ai/core/tool/grep"
  17. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  18. import { location } from "./fixture/location"
  19. import { tmpdir } from "./fixture/tmpdir"
  20. import { it as runtimeIt } from "./lib/effect"
  21. import { testEffect } from "./lib/effect"
  22. const assertions: PermissionV2.AssertInput[] = []
  23. const searches: LocationSearch.GrepInput[] = []
  24. const roots: FileSystem.RootTarget[] = []
  25. let allow = true
  26. let result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
  27. let searchFailure: Ripgrep.InvalidPatternError | undefined
  28. const filesystem = Layer.succeed(
  29. FileSystem.Service,
  30. FileSystem.Service.of({
  31. read: () => Effect.die("unused"),
  32. resolveReadPath: () => Effect.die("unused"),
  33. resolveRead: () => Effect.die("unused"),
  34. readResolved: () => Effect.die("unused"),
  35. readSampleResolved: () => Effect.die("unused"),
  36. readTextPageResolved: () => Effect.die("unused"),
  37. readToolResolved: () => Effect.die("unused"),
  38. list: () => Effect.die("unused"),
  39. resolveRoot: (input = {}) =>
  40. Effect.succeed(
  41. new FileSystem.RootTarget({
  42. absolute: `/project/${input.path ?? "."}`,
  43. real: `/project/${input.path ?? "."}`,
  44. directory: "/project",
  45. root: "/project",
  46. resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
  47. reference: input.reference,
  48. type: "directory",
  49. dev: 1,
  50. }),
  51. ),
  52. revalidateRoot: Effect.succeed,
  53. resolveList: () => Effect.die("unused"),
  54. listResolved: () => Effect.die("unused"),
  55. listPage: () => Effect.die("unused"),
  56. listPageResolved: () => Effect.die("unused"),
  57. find: () => Effect.die("unused"),
  58. grep: () => Effect.die("unused"),
  59. isIgnored: () => false,
  60. }),
  61. )
  62. const search = Layer.succeed(
  63. LocationSearch.Service,
  64. LocationSearch.Service.of({
  65. files: () => Effect.die("unused"),
  66. grep: (input, root) =>
  67. Effect.sync(() => {
  68. searches.push(input)
  69. if (root) roots.push(root)
  70. if (searchFailure) throw searchFailure
  71. return result
  72. }),
  73. }),
  74. )
  75. const permission = Layer.succeed(
  76. PermissionV2.Service,
  77. PermissionV2.Service.of({
  78. assert: (input) =>
  79. Effect.sync(() => {
  80. assertions.push(input)
  81. }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
  82. ask: () => Effect.die("unused"),
  83. reply: () => Effect.die("unused"),
  84. get: () => Effect.die("unused"),
  85. forSession: () => Effect.die("unused"),
  86. list: () => Effect.die("unused"),
  87. }),
  88. )
  89. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  90. const grep = GrepTool.layer.pipe(
  91. Layer.provide(registry),
  92. Layer.provide(filesystem),
  93. Layer.provide(search),
  94. Layer.provide(permission),
  95. )
  96. const it = testEffect(Layer.mergeAll(registry, filesystem, search, permission, grep))
  97. const sessionID = SessionV2.ID.make("ses_grep_tool_test")
  98. const execute = (input: Record<string, unknown>) =>
  99. ToolRegistry.Service.use((registry) =>
  100. registry.execute({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
  101. )
  102. const settle = (input: Record<string, unknown>) =>
  103. ToolRegistry.Service.use((registry) =>
  104. registry.settle({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
  105. )
  106. const reset = () => {
  107. assertions.length = 0
  108. searches.length = 0
  109. roots.length = 0
  110. allow = true
  111. searchFailure = undefined
  112. result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
  113. }
  114. function references(entries: Record<string, ProjectReference.Resolved>) {
  115. return ProjectReference.Service.of({
  116. list: () => Effect.succeed(Object.values(entries)),
  117. get: (name) => Effect.succeed(entries[name]),
  118. resolveMention: () => Effect.succeed(undefined),
  119. ensurePath: () => Effect.void,
  120. containsManagedPath: () => Effect.succeed(false),
  121. })
  122. }
  123. function provideLive(directory: string, projectReferences = references({})) {
  124. const dependencies = Layer.mergeAll(
  125. FSUtil.defaultLayer,
  126. FileSystemRipgrep.defaultLayer,
  127. AppProcess.defaultLayer,
  128. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  129. Layer.succeed(ProjectReference.Service, projectReferences),
  130. )
  131. const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
  132. const search = LocationSearch.layer.pipe(
  133. Layer.provide(filesystem),
  134. Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
  135. Layer.provide(FSUtil.defaultLayer),
  136. Layer.provide(dependencies),
  137. )
  138. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  139. const grep = GrepTool.layer.pipe(
  140. Layer.provide(registry),
  141. Layer.provide(filesystem),
  142. Layer.provide(search),
  143. Layer.provide(permission),
  144. )
  145. return Layer.mergeAll(registry, filesystem, search, permission, grep)
  146. }
  147. describe("GrepTool", () => {
  148. it.effect("registers the grep contribution", () =>
  149. Effect.gen(function* () {
  150. reset()
  151. expect(yield* (yield* ToolRegistry.Service).definitions()).toMatchObject([{ name: "grep" }])
  152. }),
  153. )
  154. it.effect("authorizes the regex resource and delegates an active Location grep", () =>
  155. Effect.gen(function* () {
  156. reset()
  157. const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 }
  158. expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" })
  159. expect(assertions).toEqual([
  160. {
  161. sessionID,
  162. action: "grep",
  163. resources: ["needle"],
  164. save: ["*"],
  165. metadata: { root: "src", reference: undefined, path: RelativePath.make("src"), include: "*.ts", limit: 2 },
  166. },
  167. ])
  168. expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
  169. expect(roots).toMatchObject([{ resource: "src" }])
  170. }),
  171. )
  172. it.effect("delegates named reference grep and exposes the canonical selected root in metadata", () =>
  173. Effect.gen(function* () {
  174. reset()
  175. yield* execute({ pattern: "guide", path: "docs", reference: "manual", include: "*.md" })
  176. expect(assertions[0]).toMatchObject({
  177. resources: ["guide"],
  178. metadata: { root: "manual:docs", reference: "manual", path: RelativePath.make("docs"), include: "*.md" },
  179. })
  180. expect(searches).toEqual([
  181. { pattern: "guide", path: RelativePath.make("docs"), reference: "manual", include: "*.md" },
  182. ])
  183. }),
  184. )
  185. it.effect("does not search when permission is denied", () =>
  186. Effect.gen(function* () {
  187. reset()
  188. allow = false
  189. expect(yield* execute({ pattern: "secret" })).toEqual({ type: "error", value: "Unable to grep for secret" })
  190. expect(assertions).toHaveLength(1)
  191. expect(searches).toEqual([])
  192. }),
  193. )
  194. it.effect("keeps structured results raw while formatting bounded partial previews for models", () =>
  195. Effect.gen(function* () {
  196. reset()
  197. result = new LocationSearch.GrepResult({
  198. items: [
  199. new LocationSearch.Match({
  200. path: RelativePath.make("src/index.ts"),
  201. canonical: "/project/src/index.ts",
  202. resource: "src/index.ts",
  203. lines: "needle preview",
  204. linePreviewTruncated: true,
  205. line: 3,
  206. offset: 8,
  207. submatches: [new LocationSearch.Submatch({ text: "needle", start: 0, end: 6 })],
  208. mtime: 1,
  209. }),
  210. ],
  211. truncated: true,
  212. partial: true,
  213. })
  214. const settlement = yield* settle({ pattern: "needle" })
  215. expect(settlement.output?.structured).toEqual(result)
  216. expect(settlement.result).toEqual({
  217. type: "text",
  218. value:
  219. "Found 1 matches\nsrc/index.ts:\n Line 3: needle preview...\n\n(Results are truncated: showing first 1 matches. Consider using a more specific path or pattern.)\n\n(Some paths were inaccessible and skipped)",
  220. })
  221. }),
  222. )
  223. it.effect("returns a useful tool error for an invalid regex", () =>
  224. Effect.gen(function* () {
  225. reset()
  226. searchFailure = new Ripgrep.InvalidPatternError({
  227. pattern: "[",
  228. message: "regex parse error: unclosed character class",
  229. })
  230. expect(yield* execute({ pattern: "[" })).toEqual({
  231. type: "error",
  232. value: 'Invalid grep pattern "[": regex parse error: unclosed character class',
  233. })
  234. expect(searches).toEqual([{ pattern: "[" }])
  235. }),
  236. )
  237. runtimeIt.live("greps active Location and named-reference files with include globs", () =>
  238. Effect.acquireRelease(
  239. Effect.promise(() => tmpdir()),
  240. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  241. ).pipe(
  242. Effect.flatMap((tmp) => {
  243. const docs = path.join(tmp.path, "docs")
  244. return Effect.gen(function* () {
  245. reset()
  246. yield* Effect.promise(async () => {
  247. await fs.mkdir(path.join(tmp.path, "src"))
  248. await fs.mkdir(docs)
  249. await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
  250. await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
  251. await fs.writeFile(path.join(docs, "guide.md"), "needle docs\n")
  252. })
  253. expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
  254. type: "text",
  255. value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n",
  256. })
  257. expect(yield* execute({ pattern: "needle", reference: "docs", include: "*.md" })).toEqual({
  258. type: "text",
  259. value: "Found 1 matches\ndocs:guide.md:\n Line 1: needle docs\n",
  260. })
  261. }).pipe(
  262. Effect.provide(provideLive(tmp.path, references({ docs: { name: "docs", kind: "local", path: docs } }))),
  263. )
  264. }),
  265. ),
  266. )
  267. })