tool-grep.test.ts 10 KB

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