grep.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import { PermissionV1 } from "@opencode-ai/core/v1/permission"
  2. import { describe, expect } from "bun:test"
  3. import fs from "fs/promises"
  4. import os from "os"
  5. import path from "path"
  6. import { Effect, Layer } from "effect"
  7. import { GrepTool } from "../../src/tool/grep"
  8. import { provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture"
  9. import { SessionID, MessageID } from "../../src/session/schema"
  10. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  11. import { Global } from "@opencode-ai/core/global"
  12. import { Truncate } from "@/tool/truncate"
  13. import { Agent } from "../../src/agent/agent"
  14. import { Ripgrep } from "@opencode-ai/core/ripgrep"
  15. import { FSUtil } from "@opencode-ai/core/fs-util"
  16. import { testEffect } from "../lib/effect"
  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 toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
  24. Layer.mergeAll(
  25. CrossSpawnSpawner.defaultLayer,
  26. FSUtil.defaultLayer,
  27. Ripgrep.defaultLayer,
  28. Truncate.defaultLayer,
  29. Agent.defaultLayer,
  30. Git.defaultLayer,
  31. )
  32. const it = testEffect(toolLayer())
  33. const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer))
  34. const ctx = {
  35. sessionID: SessionID.make("ses_test"),
  36. messageID: MessageID.make("msg_test"),
  37. callID: "",
  38. agent: "build",
  39. abort: AbortSignal.any([]),
  40. messages: [],
  41. metadata: () => Effect.void,
  42. ask: () => Effect.void,
  43. }
  44. const root = path.join(__dirname, "../..")
  45. const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
  46. const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
  47. Effect.acquireUseRelease(
  48. Effect.sync(() => {
  49. const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  50. process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
  51. return previous
  52. }),
  53. () => self,
  54. (previous) =>
  55. Effect.sync(() => {
  56. if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
  57. else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  58. }),
  59. )
  60. const git = Effect.fn("GrepToolTest.git")(function* (cwd: string, args: string[]) {
  61. return yield* Effect.promise(async () => {
  62. const proc = Bun.spawn(["git", ...args], {
  63. cwd,
  64. stdout: "pipe",
  65. stderr: "pipe",
  66. })
  67. const [stdout, stderr, code] = await Promise.all([
  68. new Response(proc.stdout).text(),
  69. new Response(proc.stderr).text(),
  70. proc.exited,
  71. ])
  72. if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
  73. return stdout.trim()
  74. })
  75. })
  76. describe("tool.grep", () => {
  77. rooted.live("basic search", () =>
  78. Effect.gen(function* () {
  79. const info = yield* GrepTool
  80. const grep = yield* info.init()
  81. const result = yield* provideInstance(root)(
  82. grep.execute(
  83. {
  84. pattern: "export",
  85. path: path.join(root, "src/tool"),
  86. include: "*.ts",
  87. },
  88. ctx,
  89. ),
  90. )
  91. expect(result.metadata.matches).toBeGreaterThan(0)
  92. expect(result.output).toContain("Found")
  93. }),
  94. )
  95. it.instance("no matches returns correct output", () =>
  96. Effect.gen(function* () {
  97. const test = yield* TestInstance
  98. yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world"))
  99. const info = yield* GrepTool
  100. const grep = yield* info.init()
  101. const result = yield* grep.execute(
  102. {
  103. pattern: "xyznonexistentpatternxyz123",
  104. path: test.directory,
  105. },
  106. ctx,
  107. )
  108. expect(result.metadata.matches).toBe(0)
  109. expect(result.output).toBe("No files found")
  110. }),
  111. )
  112. it.instance("finds matches in tmp instance", () =>
  113. Effect.gen(function* () {
  114. const test = yield* TestInstance
  115. yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3"))
  116. const info = yield* GrepTool
  117. const grep = yield* info.init()
  118. const result = yield* grep.execute(
  119. {
  120. pattern: "line",
  121. path: test.directory,
  122. },
  123. ctx,
  124. )
  125. expect(result.metadata.matches).toBeGreaterThan(0)
  126. }),
  127. )
  128. it.instance("does not report an unknown total when results are truncated", () =>
  129. Effect.gen(function* () {
  130. const test = yield* TestInstance
  131. yield* Effect.promise(() =>
  132. Promise.all(
  133. Array.from({ length: 101 }, (_, index) =>
  134. Bun.write(path.join(test.directory, `match-${index}.txt`), "needle"),
  135. ),
  136. ),
  137. )
  138. const info = yield* GrepTool
  139. const grep = yield* info.init()
  140. const result = yield* grep.execute({ pattern: "needle", path: test.directory, include: "*.txt" }, ctx)
  141. expect(result.output).toContain("(Results truncated. Consider using a more specific path or pattern.)")
  142. expect(result.output).not.toMatch(/showing \d+ of \d+ matches/)
  143. }),
  144. )
  145. it.instance("supports exact file paths", () =>
  146. Effect.gen(function* () {
  147. const test = yield* TestInstance
  148. const file = path.join(test.directory, "test.txt")
  149. yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
  150. const info = yield* GrepTool
  151. const grep = yield* info.init()
  152. const result = yield* grep.execute(
  153. {
  154. pattern: "line2",
  155. path: file,
  156. },
  157. ctx,
  158. )
  159. expect(result.metadata.matches).toBe(1)
  160. expect(result.output).toContain(file)
  161. expect(result.output).toContain("Line 2: line2")
  162. }),
  163. )
  164. it.instance("does not ask for external_directory when alias path is allowed", () =>
  165. Effect.gen(function* () {
  166. if (process.platform === "win32") return
  167. yield* TestInstance
  168. const tmp = yield* Effect.acquireRelease(
  169. Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "opencode-grep-alias-"))),
  170. (dir) => Effect.promise(() => fs.rm(dir, { recursive: true, force: true })),
  171. )
  172. const real = path.join(tmp, "real")
  173. const alias = path.join(tmp, "alias")
  174. yield* Effect.promise(() => fs.mkdir(real))
  175. yield* Effect.promise(() => fs.symlink(real, alias, "dir"))
  176. yield* Effect.promise(() => Bun.write(path.join(real, "test.txt"), "needle"))
  177. const ruleset = Permission.fromConfig({
  178. grep: "allow",
  179. external_directory: {
  180. [path.join(alias, "*")]: "allow",
  181. },
  182. })
  183. const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
  184. const next: Tool.Context = {
  185. ...ctx,
  186. ask: (req) =>
  187. Effect.sync(() => {
  188. const needsAsk = req.patterns.some(
  189. (pattern) => Permission.evaluate(req.permission, pattern, ruleset).action !== "allow",
  190. )
  191. if (needsAsk) requests.push(req)
  192. }),
  193. }
  194. const info = yield* GrepTool
  195. const grep = yield* info.init()
  196. const result = yield* grep.execute(
  197. {
  198. pattern: "needle",
  199. path: alias,
  200. include: "*.txt",
  201. },
  202. next,
  203. )
  204. expect(result.metadata.matches).toBe(1)
  205. expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
  206. }),
  207. )
  208. })