grep.test.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import { describe, expect } from "bun:test"
  2. import fs from "fs/promises"
  3. import os from "os"
  4. import path from "path"
  5. import { Effect, Layer } from "effect"
  6. import { GrepTool } from "../../src/tool/grep"
  7. import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
  8. import { SessionID, MessageID } from "../../src/session/schema"
  9. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  10. import { Global } from "@opencode-ai/core/global"
  11. import { Truncate } from "@/tool/truncate"
  12. import { Agent } from "../../src/agent/agent"
  13. import { Ripgrep } from "../../src/file/ripgrep"
  14. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  15. import { testEffect } from "../lib/effect"
  16. import { Reference } from "@/reference/reference"
  17. import { Permission } from "../../src/permission"
  18. import type * as Tool from "../../src/tool/tool"
  19. import { Config } from "@/config/config"
  20. import { RuntimeFlags } from "@/effect/runtime-flags"
  21. import { Git } from "@/git"
  22. import { Filesystem } from "@/util/filesystem"
  23. const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
  24. Reference.layer.pipe(
  25. Layer.provide(Config.defaultLayer),
  26. Layer.provide(AppFileSystem.defaultLayer),
  27. Layer.provide(Git.defaultLayer),
  28. Layer.provide(RuntimeFlags.layer(flags)),
  29. )
  30. const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
  31. Layer.mergeAll(
  32. CrossSpawnSpawner.defaultLayer,
  33. AppFileSystem.defaultLayer,
  34. Ripgrep.defaultLayer,
  35. Truncate.defaultLayer,
  36. Agent.defaultLayer,
  37. Git.defaultLayer,
  38. referenceLayer(flags),
  39. )
  40. const it = testEffect(toolLayer())
  41. const scout = testEffect(toolLayer({ experimentalScout: true }))
  42. const ctx = {
  43. sessionID: SessionID.make("ses_test"),
  44. messageID: MessageID.make("msg_test"),
  45. callID: "",
  46. agent: "build",
  47. abort: AbortSignal.any([]),
  48. messages: [],
  49. metadata: () => Effect.void,
  50. ask: () => Effect.void,
  51. }
  52. const root = path.join(__dirname, "../..")
  53. const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
  54. const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
  55. Effect.acquireUseRelease(
  56. Effect.sync(() => {
  57. const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  58. process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
  59. return previous
  60. }),
  61. () => self,
  62. (previous) =>
  63. Effect.sync(() => {
  64. if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
  65. else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  66. }),
  67. )
  68. const git = Effect.fn("GrepToolTest.git")(function* (cwd: string, args: string[]) {
  69. return yield* Effect.promise(async () => {
  70. const proc = Bun.spawn(["git", ...args], {
  71. cwd,
  72. stdout: "pipe",
  73. stderr: "pipe",
  74. })
  75. const [stdout, stderr, code] = await Promise.all([
  76. new Response(proc.stdout).text(),
  77. new Response(proc.stderr).text(),
  78. proc.exited,
  79. ])
  80. if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
  81. return stdout.trim()
  82. })
  83. })
  84. describe("tool.grep", () => {
  85. it.live("basic search", () =>
  86. Effect.gen(function* () {
  87. const info = yield* GrepTool
  88. const grep = yield* info.init()
  89. const result = yield* provideInstance(root)(
  90. grep.execute(
  91. {
  92. pattern: "export",
  93. path: path.join(root, "src/tool"),
  94. include: "*.ts",
  95. },
  96. ctx,
  97. ),
  98. )
  99. expect(result.metadata.matches).toBeGreaterThan(0)
  100. expect(result.output).toContain("Found")
  101. }),
  102. )
  103. it.instance("no matches returns correct output", () =>
  104. Effect.gen(function* () {
  105. const test = yield* TestInstance
  106. yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world"))
  107. const info = yield* GrepTool
  108. const grep = yield* info.init()
  109. const result = yield* grep.execute(
  110. {
  111. pattern: "xyznonexistentpatternxyz123",
  112. path: test.directory,
  113. },
  114. ctx,
  115. )
  116. expect(result.metadata.matches).toBe(0)
  117. expect(result.output).toBe("No files found")
  118. }),
  119. )
  120. it.instance("finds matches in tmp instance", () =>
  121. Effect.gen(function* () {
  122. const test = yield* TestInstance
  123. yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3"))
  124. const info = yield* GrepTool
  125. const grep = yield* info.init()
  126. const result = yield* grep.execute(
  127. {
  128. pattern: "line",
  129. path: test.directory,
  130. },
  131. ctx,
  132. )
  133. expect(result.metadata.matches).toBeGreaterThan(0)
  134. }),
  135. )
  136. it.instance("supports exact file paths", () =>
  137. Effect.gen(function* () {
  138. const test = yield* TestInstance
  139. const file = path.join(test.directory, "test.txt")
  140. yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
  141. const info = yield* GrepTool
  142. const grep = yield* info.init()
  143. const result = yield* grep.execute(
  144. {
  145. pattern: "line2",
  146. path: file,
  147. },
  148. ctx,
  149. )
  150. expect(result.metadata.matches).toBe(1)
  151. expect(result.output).toContain(file)
  152. expect(result.output).toContain("Line 2: line2")
  153. }),
  154. )
  155. it.instance("does not ask for external_directory when alias path is allowed", () =>
  156. Effect.gen(function* () {
  157. if (process.platform === "win32") return
  158. yield* TestInstance
  159. const tmp = yield* Effect.acquireRelease(
  160. Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "opencode-grep-alias-"))),
  161. (dir) => Effect.promise(() => fs.rm(dir, { recursive: true, force: true })),
  162. )
  163. const real = path.join(tmp, "real")
  164. const alias = path.join(tmp, "alias")
  165. yield* Effect.promise(() => fs.mkdir(real))
  166. yield* Effect.promise(() => fs.symlink(real, alias, "dir"))
  167. yield* Effect.promise(() => Bun.write(path.join(real, "test.txt"), "needle"))
  168. const ruleset = Permission.fromConfig({
  169. grep: "allow",
  170. external_directory: {
  171. [path.join(alias, "*")]: "allow",
  172. },
  173. })
  174. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  175. const next: Tool.Context = {
  176. ...ctx,
  177. ask: (req) =>
  178. Effect.sync(() => {
  179. const needsAsk = req.patterns.some(
  180. (pattern) => Permission.evaluate(req.permission, pattern, ruleset).action !== "allow",
  181. )
  182. if (needsAsk) requests.push(req)
  183. }),
  184. }
  185. const info = yield* GrepTool
  186. const grep = yield* info.init()
  187. const result = yield* grep.execute(
  188. {
  189. pattern: "needle",
  190. path: alias,
  191. include: "*.txt",
  192. },
  193. next,
  194. )
  195. expect(result.metadata.matches).toBe(1)
  196. expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
  197. }),
  198. )
  199. scout.instance(
  200. "does not ask for external_directory permission inside configured git references",
  201. () =>
  202. Effect.gen(function* () {
  203. yield* TestInstance
  204. const appfs = yield* AppFileSystem.Service
  205. const cache = path.join(Global.Path.repos, "github.com", "opencode-grep-reference", "repo")
  206. yield* appfs.remove(cache, { recursive: true }).pipe(Effect.ignore)
  207. yield* Effect.addFinalizer(() => appfs.remove(cache, { recursive: true }).pipe(Effect.ignore))
  208. const source = yield* tmpdirScoped({ git: true })
  209. const remoteRoot = yield* tmpdirScoped()
  210. const remoteDir = path.join(remoteRoot, "opencode-grep-reference")
  211. const remoteRepo = path.join(remoteDir, "repo.git")
  212. yield* appfs.writeWithDirs(path.join(source, "src", "notes.md"), "needle\n")
  213. yield* git(source, ["add", "."])
  214. yield* git(source, ["commit", "-m", "add notes"])
  215. yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
  216. yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
  217. const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  218. const next: Tool.Context = {
  219. ...ctx,
  220. ask: (req) =>
  221. Effect.sync(() => {
  222. requests.push(req)
  223. }),
  224. }
  225. const info = yield* GrepTool
  226. const grep = yield* info.init()
  227. const result = yield* githubBase(
  228. `file://${remoteRoot}/`,
  229. grep.execute({ pattern: "needle", path: path.join(cache, "src"), include: "*.md" }, next),
  230. )
  231. expect(result.metadata.matches).toBe(1)
  232. expect(full(result.output)).toContain(full(path.join(cache, "src", "notes.md")))
  233. expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
  234. }),
  235. {
  236. config: {
  237. reference: {
  238. docs: "opencode-grep-reference/repo",
  239. },
  240. },
  241. },
  242. )
  243. })