tool-glob.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { FileSystem } from "@opencode-ai/core/filesystem"
  4. import { LocationSearch } from "@opencode-ai/core/location-search"
  5. import { PermissionV2 } from "@opencode-ai/core/permission"
  6. import { RelativePath } from "@opencode-ai/core/schema"
  7. import { SessionV2 } from "@opencode-ai/core/session"
  8. import { GlobTool } from "@opencode-ai/core/tool/glob"
  9. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  10. import { testEffect } from "./lib/effect"
  11. import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
  12. const sessionID = SessionV2.ID.make("ses_glob_tool_test")
  13. const assertions: PermissionV2.AssertInput[] = []
  14. const resolutions: FileSystem.ListInput[] = []
  15. const searches: LocationSearch.FilesInput[] = []
  16. let allow = true
  17. let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
  18. const permission = Layer.succeed(
  19. PermissionV2.Service,
  20. PermissionV2.Service.of({
  21. assert: (input) =>
  22. Effect.sync(() => assertions.push(input)).pipe(
  23. Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] }))),
  24. ),
  25. ask: () => Effect.die("unused"),
  26. reply: () => Effect.die("unused"),
  27. get: () => Effect.die("unused"),
  28. forSession: () => Effect.die("unused"),
  29. list: () => Effect.die("unused"),
  30. }),
  31. )
  32. const filesystem = Layer.succeed(
  33. FileSystem.Service,
  34. FileSystem.Service.of({
  35. read: () => Effect.die("unused"),
  36. resolveReadPath: () => Effect.die("unused"),
  37. readTool: () => Effect.die("unused"),
  38. list: () => Effect.die("unused"),
  39. resolveRoot: (input = {}) =>
  40. Effect.sync(() => {
  41. resolutions.push(input)
  42. const relative = input.path ?? RelativePath.make(".")
  43. const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
  44. return new FileSystem.RootTarget({
  45. real: `/project/${relative}`,
  46. root: "/project",
  47. resource,
  48. reference: input.reference,
  49. type: "directory",
  50. })
  51. }),
  52. resolveList: () => Effect.die("unused"),
  53. listResolved: () => Effect.die("unused"),
  54. listPage: () => Effect.die("unused"),
  55. listPageResolved: () => Effect.die("unused"),
  56. find: () => Effect.die("unused"),
  57. grep: () => Effect.die("unused"),
  58. isIgnored: () => false,
  59. }),
  60. )
  61. const search = Layer.succeed(
  62. LocationSearch.Service,
  63. LocationSearch.Service.of({
  64. files: (input) =>
  65. Effect.sync(() => {
  66. searches.push(input)
  67. return result
  68. }),
  69. grep: () => Effect.die("unused"),
  70. }),
  71. )
  72. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  73. const glob = GlobTool.layer.pipe(
  74. Layer.provide(registry),
  75. Layer.provide(permission),
  76. Layer.provide(filesystem),
  77. Layer.provide(search),
  78. )
  79. const it = testEffect(Layer.mergeAll(registry, permission, filesystem, search, glob))
  80. const reset = () => {
  81. assertions.length = 0
  82. resolutions.length = 0
  83. searches.length = 0
  84. allow = true
  85. result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
  86. }
  87. const call = (input: typeof GlobTool.Input.Type, id = "call-glob") => ({
  88. sessionID,
  89. ...toolIdentity,
  90. call: { type: "tool-call" as const, id, name: "glob", input },
  91. })
  92. describe("GlobTool", () => {
  93. it.effect("registers the glob definition", () =>
  94. Effect.gen(function* () {
  95. reset()
  96. expect((yield* toolDefinitions(yield* ToolRegistry.Service)).map((tool) => tool.name)).toEqual(["glob"])
  97. }),
  98. )
  99. it.effect("authorizes the active Location pattern and delegates traversal only to LocationSearch.files", () =>
  100. Effect.gen(function* () {
  101. reset()
  102. const registry = yield* ToolRegistry.Service
  103. expect(
  104. yield* executeTool(registry, call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 })),
  105. ).toEqual({
  106. type: "text",
  107. value: "No files found",
  108. })
  109. expect(assertions).toMatchObject([
  110. {
  111. sessionID,
  112. action: "glob",
  113. resources: ["**/*.ts"],
  114. save: ["*"],
  115. metadata: { root: "src", reference: undefined, path: "src", limit: 12 },
  116. },
  117. ])
  118. expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
  119. expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
  120. }),
  121. )
  122. it.effect("prevents Location search when permission is denied", () =>
  123. Effect.gen(function* () {
  124. reset()
  125. allow = false
  126. expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.secret" }))).toEqual({
  127. type: "error",
  128. value: "Unable to find files matching *.secret",
  129. })
  130. expect(searches).toEqual([])
  131. }),
  132. )
  133. it.effect("returns active Location glob resources", () =>
  134. Effect.gen(function* () {
  135. reset()
  136. result = new LocationSearch.FilesResult({
  137. items: [
  138. new LocationSearch.File({
  139. path: RelativePath.make("src/index.ts"),
  140. canonical: "/project/src/index.ts",
  141. resource: "src/index.ts",
  142. mtime: 1,
  143. }),
  144. ],
  145. truncated: false,
  146. partial: false,
  147. })
  148. expect(yield* settleTool(yield* ToolRegistry.Service, call({ pattern: "*.ts" }))).toEqual({
  149. result: { type: "text", value: "src/index.ts" },
  150. output: {
  151. structured: result,
  152. content: [{ type: "text", text: "src/index.ts" }],
  153. },
  154. })
  155. }),
  156. )
  157. it.effect("searches named references with root and reference metadata", () =>
  158. Effect.gen(function* () {
  159. reset()
  160. result = new LocationSearch.FilesResult({
  161. items: [
  162. new LocationSearch.File({
  163. path: RelativePath.make("guide.md"),
  164. canonical: "/project/docs/guide.md",
  165. resource: "docs:guide.md",
  166. mtime: 1,
  167. }),
  168. ],
  169. truncated: false,
  170. partial: false,
  171. })
  172. expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({
  173. type: "text",
  174. value: "docs:guide.md",
  175. })
  176. expect(assertions).toMatchObject([
  177. {
  178. sessionID,
  179. action: "glob",
  180. resources: ["*.md"],
  181. save: ["*"],
  182. metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined },
  183. },
  184. ])
  185. expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }])
  186. }),
  187. )
  188. it.effect("formats bounded and partial results without discarding structured output", () =>
  189. Effect.sync(() => {
  190. const output = new LocationSearch.FilesResult({
  191. items: [
  192. new LocationSearch.File({
  193. path: RelativePath.make("one.ts"),
  194. canonical: "/project/one.ts",
  195. resource: "one.ts",
  196. mtime: 1,
  197. }),
  198. ],
  199. truncated: true,
  200. partial: true,
  201. })
  202. expect(GlobTool.toModelOutput(output)).toBe(
  203. "one.ts\n\n(Results are truncated: showing first 1 results. Consider using a more specific path or pattern.)\n\n(Results may be incomplete because some discovered files could not be read.)",
  204. )
  205. }),
  206. )
  207. })