grep.test.ts 7.4 KB

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