tool-grep.test.ts 10 KB

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