grep.test.ts 7.3 KB

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