1
0

tool-search.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  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 { FileSystem } from "@opencode-ai/core/filesystem"
  9. import { FSUtil } from "@opencode-ai/util/fs-util"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { PermissionV2 } from "@opencode-ai/core/permission"
  12. import { Ripgrep } from "@opencode-ai/core/ripgrep"
  13. import { AbsolutePath } from "@opencode-ai/core/schema"
  14. import { SessionV2 } from "@opencode-ai/core/session"
  15. import { GlobTool } from "@opencode-ai/core/tool/glob"
  16. import { GrepTool } from "@opencode-ai/core/tool/grep"
  17. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  18. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  19. import { location } from "./fixture/location"
  20. import { tmpdir } from "./fixture/tmpdir"
  21. import { testEffect } from "./lib/effect"
  22. import { executeTool, registerToolPlugin, settleTool, toolIdentity } from "./lib/tool"
  23. const globToolNode = makeLocationNode({
  24. name: "test/glob-tool-plugin",
  25. layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
  26. deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
  27. })
  28. const grepToolNode = makeLocationNode({
  29. name: "test/grep-tool-plugin",
  30. layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
  31. deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
  32. })
  33. const permission = Layer.succeed(
  34. PermissionV2.Service,
  35. PermissionV2.Service.of({
  36. assert: () => Effect.void,
  37. ask: () => Effect.die("unused"),
  38. reply: () => Effect.die("unused"),
  39. get: () => Effect.die("unused"),
  40. forSession: () => Effect.die("unused"),
  41. list: () => Effect.die("unused"),
  42. }),
  43. )
  44. const sessionID = SessionV2.ID.make("ses_search_tool_test")
  45. const withTools = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
  46. Effect.gen(function* () {
  47. return yield* body(yield* ToolRegistry.Service)
  48. }).pipe(
  49. Effect.provide(
  50. AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, globToolNode, grepToolNode]), [
  51. [
  52. Location.node,
  53. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  54. ],
  55. [PermissionV2.node, permission],
  56. [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
  57. ]),
  58. ),
  59. )
  60. const call = (name: "glob" | "grep", input: unknown) => ({
  61. sessionID,
  62. ...toolIdentity,
  63. call: { type: "tool-call" as const, id: `call-${name}`, name, input },
  64. })
  65. const it = testEffect(Layer.empty)
  66. describe("search tools", () => {
  67. it.live("bounds omitted glob and grep limits", () =>
  68. Effect.acquireUseRelease(
  69. Effect.promise(() => tmpdir()),
  70. (tmp) =>
  71. Effect.gen(function* () {
  72. yield* Effect.promise(() =>
  73. Promise.all(
  74. Array.from({ length: FileSystem.DEFAULT_SEARCH_LIMIT + 1 }, (_, index) =>
  75. fs.writeFile(path.join(tmp.path, `${index}.txt`), "needle\n"),
  76. ),
  77. ),
  78. )
  79. yield* withTools(tmp.path, (registry) =>
  80. Effect.gen(function* () {
  81. const glob = yield* settleTool(registry, call("glob", { pattern: "*" }))
  82. const grep = yield* settleTool(registry, call("grep", { pattern: "needle" }))
  83. expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
  84. expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
  85. expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }])
  86. expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }])
  87. expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
  88. expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
  89. }),
  90. )
  91. }),
  92. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  93. ),
  94. )
  95. for (const name of ["glob", "grep"] as const) {
  96. it.live(`${name} reports a missing search path`, () =>
  97. Effect.acquireUseRelease(
  98. Effect.promise(() => tmpdir()),
  99. (tmp) =>
  100. withTools(tmp.path, (registry) =>
  101. Effect.gen(function* () {
  102. const result = yield* executeTool(
  103. registry,
  104. call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
  105. )
  106. expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" })
  107. }),
  108. ),
  109. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  110. ),
  111. )
  112. }
  113. })