tool-grep.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Exit, 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 { Ripgrep } from "@opencode-ai/core/ripgrep"
  13. import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
  14. import { SessionV2 } from "@opencode-ai/core/session"
  15. import { GrepTool } from "@opencode-ai/core/tool/grep"
  16. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  17. import { location } from "./fixture/location"
  18. import { tmpdir } from "./fixture/tmpdir"
  19. import { it as runtimeIt } from "./lib/effect"
  20. import { testEffect } from "./lib/effect"
  21. import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
  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.path ?? ".",
  40. type: "directory",
  41. }),
  42. ),
  43. resolveList: () => Effect.die("unused"),
  44. listResolved: () => Effect.die("unused"),
  45. listPage: () => Effect.die("unused"),
  46. listPageResolved: () => Effect.die("unused"),
  47. find: () => Effect.die("unused"),
  48. grep: () => Effect.die("unused"),
  49. isIgnored: () => false,
  50. }),
  51. )
  52. const search = Layer.succeed(
  53. LocationSearch.Service,
  54. LocationSearch.Service.of({
  55. files: () => Effect.die("unused"),
  56. grep: (input) =>
  57. Effect.sync(() => {
  58. searches.push(input)
  59. if (searchFailure) throw searchFailure
  60. return result
  61. }),
  62. }),
  63. )
  64. const permission = Layer.succeed(
  65. PermissionV2.Service,
  66. PermissionV2.Service.of({
  67. assert: (input) =>
  68. Effect.sync(() => {
  69. assertions.push(input)
  70. }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
  71. ask: () => Effect.die("unused"),
  72. reply: () => Effect.die("unused"),
  73. get: () => Effect.die("unused"),
  74. forSession: () => Effect.die("unused"),
  75. list: () => Effect.die("unused"),
  76. }),
  77. )
  78. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  79. const grep = GrepTool.layer.pipe(
  80. Layer.provide(registry),
  81. Layer.provide(filesystem),
  82. Layer.provide(search),
  83. Layer.provide(permission),
  84. )
  85. const it = testEffect(Layer.mergeAll(registry, filesystem, search, permission, grep))
  86. const sessionID = SessionV2.ID.make("ses_grep_tool_test")
  87. const execute = (input: Record<string, unknown>) =>
  88. ToolRegistry.Service.use((registry) =>
  89. executeTool(registry, {
  90. sessionID,
  91. ...toolIdentity,
  92. call: { type: "tool-call", id: "call-grep", name: "grep", input },
  93. }),
  94. )
  95. const settle = (input: Record<string, unknown>) =>
  96. ToolRegistry.Service.use((registry) =>
  97. settleTool(registry, {
  98. sessionID,
  99. ...toolIdentity,
  100. call: { type: "tool-call", id: "call-grep", name: "grep", input },
  101. }),
  102. )
  103. const reset = () => {
  104. assertions.length = 0
  105. searches.length = 0
  106. allow = true
  107. searchFailure = undefined
  108. result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
  109. }
  110. function provideLive(directory: string) {
  111. const dependencies = Layer.mergeAll(
  112. FSUtil.defaultLayer,
  113. FileSystemRipgrep.defaultLayer,
  114. AppProcess.defaultLayer,
  115. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  116. )
  117. const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
  118. const search = LocationSearch.layer.pipe(
  119. Layer.provide(filesystem),
  120. Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
  121. Layer.provide(FSUtil.defaultLayer),
  122. Layer.provide(dependencies),
  123. )
  124. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  125. const grep = GrepTool.layer.pipe(
  126. Layer.provide(registry),
  127. Layer.provide(filesystem),
  128. Layer.provide(search),
  129. Layer.provide(permission),
  130. )
  131. return Layer.mergeAll(registry, filesystem, search, permission, grep)
  132. }
  133. describe("GrepTool", () => {
  134. it.effect("registers grep", () =>
  135. Effect.gen(function* () {
  136. reset()
  137. expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }])
  138. }),
  139. )
  140. it.effect("authorizes the regex resource and delegates an active Location grep", () =>
  141. Effect.gen(function* () {
  142. reset()
  143. const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 }
  144. expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" })
  145. expect(assertions).toMatchObject([
  146. {
  147. sessionID,
  148. action: "grep",
  149. resources: ["needle"],
  150. save: ["*"],
  151. metadata: { root: "src", path: RelativePath.make("src"), include: "*.ts", limit: 2 },
  152. },
  153. ])
  154. expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
  155. }),
  156. )
  157. it.effect("does not search when permission is denied", () =>
  158. Effect.gen(function* () {
  159. reset()
  160. allow = false
  161. expect(yield* execute({ pattern: "secret" })).toEqual({ type: "error", value: "Unable to grep for secret" })
  162. expect(assertions).toHaveLength(1)
  163. expect(searches).toEqual([])
  164. }),
  165. )
  166. it.effect("keeps structured results raw while formatting bounded partial previews for models", () =>
  167. Effect.gen(function* () {
  168. reset()
  169. result = new LocationSearch.GrepResult({
  170. items: [
  171. new LocationSearch.Match({
  172. path: RelativePath.make("src/index.ts"),
  173. canonical: "/project/src/index.ts",
  174. resource: "src/index.ts",
  175. lines: "needle preview",
  176. linePreviewTruncated: true,
  177. line: 3,
  178. offset: 8,
  179. submatches: [new LocationSearch.Submatch({ text: "needle", start: 0, end: 6 })],
  180. mtime: 1,
  181. }),
  182. ],
  183. truncated: true,
  184. partial: true,
  185. })
  186. const settlement = yield* settle({ pattern: "needle" })
  187. expect(settlement.output?.structured).toEqual(result)
  188. expect(settlement.result).toEqual({
  189. type: "text",
  190. value:
  191. "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)",
  192. })
  193. }),
  194. )
  195. it.effect("preserves an unexpected search defect", () =>
  196. Effect.gen(function* () {
  197. reset()
  198. searchFailure = new Ripgrep.InvalidPatternError({
  199. pattern: "[",
  200. message: "regex parse error: unclosed character class",
  201. })
  202. expect(Exit.isFailure(yield* execute({ pattern: "[" }).pipe(Effect.exit))).toBe(true)
  203. expect(searches).toEqual([{ pattern: "[" }])
  204. }),
  205. )
  206. runtimeIt.live("greps active Location files with include globs", () =>
  207. Effect.acquireRelease(
  208. Effect.promise(() => tmpdir()),
  209. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  210. ).pipe(
  211. Effect.flatMap((tmp) => {
  212. const docs = path.join(tmp.path, "docs")
  213. return Effect.gen(function* () {
  214. reset()
  215. yield* Effect.promise(async () => {
  216. await fs.mkdir(path.join(tmp.path, "src"))
  217. await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
  218. await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
  219. })
  220. expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
  221. type: "text",
  222. value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n",
  223. })
  224. }).pipe(Effect.provide(provideLive(tmp.path)))
  225. }),
  226. ),
  227. )
  228. })