tool-grep.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  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 { 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. import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
  23. const assertions: PermissionV2.AssertInput[] = []
  24. const searches: LocationSearch.GrepInput[] = []
  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. readTool: () => Effect.die("unused"),
  34. list: () => Effect.die("unused"),
  35. resolveRoot: (input = {}) =>
  36. Effect.succeed(
  37. new FileSystem.RootTarget({
  38. real: `/project/${input.path ?? "."}`,
  39. root: "/project",
  40. resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
  41. reference: input.reference,
  42. type: "directory",
  43. }),
  44. ),
  45. resolveList: () => Effect.die("unused"),
  46. listResolved: () => Effect.die("unused"),
  47. listPage: () => Effect.die("unused"),
  48. listPageResolved: () => Effect.die("unused"),
  49. find: () => Effect.die("unused"),
  50. grep: () => Effect.die("unused"),
  51. isIgnored: () => false,
  52. }),
  53. )
  54. const search = Layer.succeed(
  55. LocationSearch.Service,
  56. LocationSearch.Service.of({
  57. files: () => Effect.die("unused"),
  58. grep: (input) =>
  59. Effect.sync(() => {
  60. searches.push(input)
  61. if (searchFailure) throw searchFailure
  62. return result
  63. }),
  64. }),
  65. )
  66. const permission = Layer.succeed(
  67. PermissionV2.Service,
  68. PermissionV2.Service.of({
  69. assert: (input) =>
  70. Effect.sync(() => {
  71. assertions.push(input)
  72. }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
  73. ask: () => Effect.die("unused"),
  74. reply: () => Effect.die("unused"),
  75. get: () => Effect.die("unused"),
  76. forSession: () => Effect.die("unused"),
  77. list: () => Effect.die("unused"),
  78. }),
  79. )
  80. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  81. const grep = GrepTool.layer.pipe(
  82. Layer.provide(registry),
  83. Layer.provide(filesystem),
  84. Layer.provide(search),
  85. Layer.provide(permission),
  86. )
  87. const it = testEffect(Layer.mergeAll(registry, filesystem, search, permission, grep))
  88. const sessionID = SessionV2.ID.make("ses_grep_tool_test")
  89. const execute = (input: Record<string, unknown>) =>
  90. ToolRegistry.Service.use((registry) =>
  91. executeTool(registry, {
  92. sessionID,
  93. ...toolIdentity,
  94. call: { type: "tool-call", id: "call-grep", name: "grep", input },
  95. }),
  96. )
  97. const settle = (input: Record<string, unknown>) =>
  98. ToolRegistry.Service.use((registry) =>
  99. settleTool(registry, {
  100. sessionID,
  101. ...toolIdentity,
  102. call: { type: "tool-call", id: "call-grep", name: "grep", input },
  103. }),
  104. )
  105. const reset = () => {
  106. assertions.length = 0
  107. searches.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 grep", () =>
  147. Effect.gen(function* () {
  148. reset()
  149. expect(yield* toolDefinitions(yield* ToolRegistry.Service)).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).toMatchObject([
  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. }),
  168. )
  169. it.effect("delegates named reference grep and exposes the canonical selected root in metadata", () =>
  170. Effect.gen(function* () {
  171. reset()
  172. yield* execute({ pattern: "guide", path: "docs", reference: "manual", include: "*.md" })
  173. expect(assertions[0]).toMatchObject({
  174. resources: ["guide"],
  175. metadata: { root: "manual:docs", reference: "manual", path: RelativePath.make("docs"), include: "*.md" },
  176. })
  177. expect(searches).toEqual([
  178. { pattern: "guide", path: RelativePath.make("docs"), reference: "manual", include: "*.md" },
  179. ])
  180. }),
  181. )
  182. it.effect("does not search when permission is denied", () =>
  183. Effect.gen(function* () {
  184. reset()
  185. allow = false
  186. expect(yield* execute({ pattern: "secret" })).toEqual({ type: "error", value: "Unable to grep for secret" })
  187. expect(assertions).toHaveLength(1)
  188. expect(searches).toEqual([])
  189. }),
  190. )
  191. it.effect("keeps structured results raw while formatting bounded partial previews for models", () =>
  192. Effect.gen(function* () {
  193. reset()
  194. result = new LocationSearch.GrepResult({
  195. items: [
  196. new LocationSearch.Match({
  197. path: RelativePath.make("src/index.ts"),
  198. canonical: "/project/src/index.ts",
  199. resource: "src/index.ts",
  200. lines: "needle preview",
  201. linePreviewTruncated: true,
  202. line: 3,
  203. offset: 8,
  204. submatches: [new LocationSearch.Submatch({ text: "needle", start: 0, end: 6 })],
  205. mtime: 1,
  206. }),
  207. ],
  208. truncated: true,
  209. partial: true,
  210. })
  211. const settlement = yield* settle({ pattern: "needle" })
  212. expect(settlement.output?.structured).toEqual(result)
  213. expect(settlement.result).toEqual({
  214. type: "text",
  215. value:
  216. "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)",
  217. })
  218. }),
  219. )
  220. it.effect("preserves an unexpected search defect", () =>
  221. Effect.gen(function* () {
  222. reset()
  223. searchFailure = new Ripgrep.InvalidPatternError({
  224. pattern: "[",
  225. message: "regex parse error: unclosed character class",
  226. })
  227. expect(Exit.isFailure(yield* execute({ pattern: "[" }).pipe(Effect.exit))).toBe(true)
  228. expect(searches).toEqual([{ pattern: "[" }])
  229. }),
  230. )
  231. runtimeIt.live("greps active Location and named-reference files with include globs", () =>
  232. Effect.acquireRelease(
  233. Effect.promise(() => tmpdir()),
  234. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  235. ).pipe(
  236. Effect.flatMap((tmp) => {
  237. const docs = path.join(tmp.path, "docs")
  238. return Effect.gen(function* () {
  239. reset()
  240. yield* Effect.promise(async () => {
  241. await fs.mkdir(path.join(tmp.path, "src"))
  242. await fs.mkdir(docs)
  243. await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
  244. await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
  245. await fs.writeFile(path.join(docs, "guide.md"), "needle docs\n")
  246. })
  247. expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
  248. type: "text",
  249. value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n",
  250. })
  251. expect(yield* execute({ pattern: "needle", reference: "docs", include: "*.md" })).toEqual({
  252. type: "text",
  253. value: "Found 1 matches\ndocs:guide.md:\n Line 1: needle docs\n",
  254. })
  255. }).pipe(
  256. Effect.provide(provideLive(tmp.path, references({ docs: { name: "docs", kind: "local", path: docs } }))),
  257. )
  258. }),
  259. ),
  260. )
  261. })