tool-search.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import { describe, expect } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { Effect, Layer } from "effect"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  7. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  8. import { Environment } from "@opencode-ai/core/environment"
  9. import { FileSystem } from "@opencode-ai/core/filesystem"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  12. import { Permission } from "@opencode-ai/core/permission"
  13. import { Ripgrep } from "@opencode-ai/core/ripgrep"
  14. import { AbsolutePath } from "@opencode-ai/core/schema"
  15. import { Session } from "@opencode-ai/core/session"
  16. import { GlobTool } from "@opencode-ai/core/tool/plugin/glob"
  17. import { GrepTool } from "@opencode-ai/core/tool/plugin/grep"
  18. import { Tool } from "@opencode-ai/core/tool"
  19. import { location } from "./fixture/location"
  20. import { tmpdir } from "./fixture/tmpdir"
  21. import { testEffect } from "./lib/effect"
  22. import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
  23. const globToolNode = makeLocationNode({
  24. name: "test/glob-tool-plugin",
  25. layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
  26. deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
  27. })
  28. const grepToolNode = makeLocationNode({
  29. name: "test/grep-tool-plugin",
  30. layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
  31. deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
  32. })
  33. const sessionID = Session.ID.make("ses_search_tool_test")
  34. const withTools = <A, E, R>(
  35. directory: string,
  36. body: (registry: Tool.Interface) => Effect.Effect<A, E, R>,
  37. assertions?: Permission.AssertInput[],
  38. ) =>
  39. Effect.gen(function* () {
  40. return yield* body(yield* Tool.Service)
  41. }).pipe(
  42. Effect.provide(
  43. AppNodeBuilder.build(LayerNode.group([Tool.node, globToolNode, grepToolNode]), [
  44. [
  45. Location.node,
  46. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  47. ],
  48. [
  49. Permission.node,
  50. Layer.succeed(
  51. Permission.Service,
  52. Permission.Service.of({
  53. assert: (input) =>
  54. Effect.sync(() => {
  55. assertions?.push(input)
  56. }),
  57. ask: () => Effect.die("unused"),
  58. reply: () => Effect.die("unused"),
  59. get: () => Effect.die("unused"),
  60. forSession: () => Effect.die("unused"),
  61. list: () => Effect.die("unused"),
  62. }),
  63. ),
  64. ],
  65. ]),
  66. ),
  67. )
  68. const call = (name: "glob" | "grep", input: unknown) => ({
  69. sessionID,
  70. ...toolIdentity,
  71. call: { type: "tool-call" as const, id: `call-${name}`, name, input },
  72. })
  73. const it = testEffect(Layer.empty)
  74. describe("search tools", () => {
  75. it.live("bounds omitted glob and grep limits", () =>
  76. Effect.acquireUseRelease(
  77. Effect.promise(() => tmpdir()),
  78. (tmp) =>
  79. Effect.gen(function* () {
  80. yield* Effect.promise(() =>
  81. Promise.all(
  82. Array.from({ length: FileSystem.DEFAULT_SEARCH_LIMIT + 1 }, (_, index) =>
  83. fs.writeFile(path.join(tmp.path, `${index}.txt`), "needle\n"),
  84. ),
  85. ),
  86. )
  87. yield* withTools(tmp.path, (registry) =>
  88. Effect.gen(function* () {
  89. const glob = yield* executeTool(registry, call("glob", { pattern: "*" }))
  90. const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
  91. expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
  92. expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
  93. expect(glob.content).toHaveLength(1)
  94. expect(grep.content).toHaveLength(1)
  95. const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
  96. const grepText = grep.content?.[0]?.type === "text" ? grep.content[0].text : ""
  97. expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT + 2)
  98. expect(globText).toEndWith(
  99. `(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
  100. )
  101. expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
  102. expect(grepText).toEndWith(
  103. `(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
  104. )
  105. }),
  106. )
  107. }),
  108. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  109. ),
  110. )
  111. it.live("rejects an empty grep pattern", () =>
  112. Effect.acquireUseRelease(
  113. Effect.promise(() => tmpdir()),
  114. (tmp) =>
  115. withTools(tmp.path, (registry) =>
  116. Effect.gen(function* () {
  117. expect(yield* executeTool(registry, call("grep", { pattern: "" }))).toEqual({
  118. status: "error",
  119. error: {
  120. type: "tool.execution",
  121. message: 'Invalid tool input: Pattern must not be empty\n at ["pattern"]',
  122. },
  123. })
  124. }),
  125. ),
  126. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  127. ),
  128. )
  129. it.live("handles explicit grep file and directory paths", () =>
  130. Effect.acquireUseRelease(
  131. Effect.promise(() => tmpdir()),
  132. (tmp) =>
  133. Effect.promise(() =>
  134. Promise.all([
  135. fs.writeFile(path.join(tmp.path, "target.txt"), "needle\n"),
  136. fs.writeFile(path.join(tmp.path, "other.txt"), "needle\n"),
  137. ]),
  138. ).pipe(
  139. Effect.andThen(
  140. withTools(tmp.path, (registry) =>
  141. Effect.gen(function* () {
  142. const file = yield* executeTool(registry, call("grep", { path: "target.txt", pattern: "needle" }))
  143. expect(file).toMatchObject({
  144. status: "completed",
  145. output: [{ entry: { path: "target.txt" }, line: 1, text: "needle\n" }],
  146. metadata: { matches: 1, truncated: false },
  147. })
  148. const directory = yield* executeTool(registry, call("grep", { path: ".", pattern: "needle" }))
  149. expect(directory).toMatchObject({
  150. status: "completed",
  151. metadata: { matches: 2, truncated: false },
  152. })
  153. if (directory.status !== "completed") return
  154. expect(directory.output).toEqual(
  155. expect.arrayContaining([
  156. expect.objectContaining({ entry: expect.objectContaining({ path: "target.txt" }) }),
  157. expect.objectContaining({ entry: expect.objectContaining({ path: "other.txt" }) }),
  158. ]),
  159. )
  160. }),
  161. ),
  162. ),
  163. ),
  164. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  165. ),
  166. )
  167. it.live("reports no grep matches", () =>
  168. Effect.acquireUseRelease(
  169. Effect.promise(() => tmpdir()),
  170. (tmp) =>
  171. Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
  172. Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
  173. Effect.tap((result) =>
  174. Effect.sync(() => {
  175. expect(result).toMatchObject({
  176. status: "completed",
  177. content: [{ type: "text", text: "No matches found" }],
  178. metadata: { matches: 0, truncated: false },
  179. })
  180. }),
  181. ),
  182. ),
  183. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  184. ),
  185. )
  186. it.live("reports invalid grep regex details", () =>
  187. Effect.acquireUseRelease(
  188. Effect.promise(() => tmpdir()),
  189. (tmp) =>
  190. withTools(tmp.path, (registry) =>
  191. Effect.gen(function* () {
  192. const result = yield* executeTool(registry, call("grep", { pattern: "[" }))
  193. expect(result).toMatchObject({
  194. status: "error",
  195. error: { type: "tool.execution" },
  196. })
  197. if (result.status !== "error" || !result.error) return
  198. expect(result.error.message).toStartWith("Invalid regex pattern:")
  199. expect(result.error.message).toContain("unclosed character class")
  200. }),
  201. ),
  202. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  203. ),
  204. )
  205. it.live("requires external_directory approval for external grep files and directories", () =>
  206. Effect.acquireUseRelease(
  207. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  208. ([active, outside]) => {
  209. const assertions: Permission.AssertInput[] = []
  210. return Effect.promise(() => fs.writeFile(path.join(outside.path, "outside.txt"), "needle\n")).pipe(
  211. Effect.andThen(
  212. withTools(
  213. active.path,
  214. (registry) =>
  215. Effect.gen(function* () {
  216. const directory = yield* executeTool(
  217. registry,
  218. call("grep", { path: outside.path, pattern: "needle" }),
  219. )
  220. const file = yield* executeTool(
  221. registry,
  222. call("grep", { path: path.join(outside.path, "outside.txt"), pattern: "needle" }),
  223. )
  224. expect(directory.status).toBe("completed")
  225. expect(file.status).toBe("completed")
  226. }),
  227. assertions,
  228. ),
  229. ),
  230. Effect.tap(() =>
  231. Effect.sync(() => {
  232. expect(assertions.map((input) => input.action)).toEqual([
  233. "external_directory",
  234. "grep",
  235. "external_directory",
  236. "grep",
  237. ])
  238. expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
  239. expect(assertions[2]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
  240. }),
  241. ),
  242. )
  243. },
  244. ([active, outside]) =>
  245. Effect.promise(() =>
  246. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  247. ),
  248. ),
  249. )
  250. for (const name of ["glob", "grep"] as const) {
  251. it.live(`${name} reports a missing search path`, () =>
  252. Effect.acquireUseRelease(
  253. Effect.promise(() => tmpdir()),
  254. (tmp) =>
  255. withTools(tmp.path, (registry) =>
  256. Effect.gen(function* () {
  257. const result = yield* executeTool(
  258. registry,
  259. call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
  260. )
  261. expect(result).toEqual({
  262. status: "error",
  263. error: { type: "tool.execution", message: "Search path does not exist: missing" },
  264. })
  265. }),
  266. ),
  267. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  268. ),
  269. )
  270. }
  271. it.live("reports a file used as the glob search path", () =>
  272. Effect.acquireUseRelease(
  273. Effect.promise(() => tmpdir()),
  274. (tmp) =>
  275. Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
  276. Effect.andThen(
  277. withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
  278. ),
  279. Effect.tap((result) =>
  280. Effect.sync(() => {
  281. expect(result).toEqual({
  282. status: "error",
  283. error: { type: "tool.execution", message: "Search path is not a directory: file.txt" },
  284. })
  285. }),
  286. ),
  287. ),
  288. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  289. ),
  290. )
  291. it.live("requires external_directory approval for an explicit external glob path", () =>
  292. Effect.acquireUseRelease(
  293. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  294. ([active, outside]) => {
  295. const assertions: Permission.AssertInput[] = []
  296. return Effect.promise(() => fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")).pipe(
  297. Effect.andThen(
  298. withTools(
  299. active.path,
  300. (registry) => executeTool(registry, call("glob", { path: outside.path, pattern: "*.txt" })),
  301. assertions,
  302. ),
  303. ),
  304. Effect.tap((result) =>
  305. Effect.sync(() => {
  306. expect(result.status).toBe("completed")
  307. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
  308. expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
  309. }),
  310. ),
  311. )
  312. },
  313. ([active, outside]) =>
  314. Effect.promise(() =>
  315. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  316. ),
  317. ),
  318. )
  319. it.live("globs through an in-location external symlink without external approval", () =>
  320. Effect.acquireUseRelease(
  321. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  322. ([active, outside]) => {
  323. if (process.platform === "win32") return Effect.void
  324. const assertions: Permission.AssertInput[] = []
  325. return Effect.promise(async () => {
  326. await fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")
  327. await fs.symlink(outside.path, path.join(active.path, "linked"))
  328. }).pipe(
  329. Effect.andThen(
  330. withTools(
  331. active.path,
  332. (registry) => executeTool(registry, call("glob", { path: "linked", pattern: "*.txt" })),
  333. assertions,
  334. ),
  335. ),
  336. Effect.tap((result) =>
  337. Effect.sync(() => {
  338. expect(result.status).toBe("completed")
  339. expect(assertions.map((input) => input.action)).toEqual(["glob"])
  340. expect(result).toMatchObject({
  341. output: [{ path: path.join("linked", "outside.txt"), type: "file" }],
  342. content: [{ type: "text", text: path.join(active.path, "linked", "outside.txt") }],
  343. })
  344. }),
  345. ),
  346. )
  347. },
  348. ([active, outside]) =>
  349. Effect.promise(() =>
  350. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  351. ),
  352. ),
  353. )
  354. })