import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" import { LocationSearch } from "@opencode-ai/core/location-search" import { PermissionV2 } from "@opencode-ai/core/permission" import { RelativePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { GlobTool } from "@opencode-ai/core/tool/glob" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_glob_tool_test") const assertions: PermissionV2.AssertInput[] = [] const resolutions: FileSystem.ListInput[] = [] const searches: LocationSearch.FilesInput[] = [] let allow = true let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false }) const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ assert: (input) => Effect.sync(() => assertions.push(input)).pipe( Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] }))), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), get: () => Effect.die("unused"), forSession: () => Effect.die("unused"), list: () => Effect.die("unused"), }), ) const filesystem = Layer.succeed( FileSystem.Service, FileSystem.Service.of({ read: () => Effect.die("unused"), resolveReadPath: () => Effect.die("unused"), readTool: () => Effect.die("unused"), list: () => Effect.die("unused"), resolveRoot: (input = {}) => Effect.sync(() => { resolutions.push(input) const relative = input.path ?? RelativePath.make(".") const resource = input.reference === undefined ? relative : `${input.reference}:${relative}` return new FileSystem.RootTarget({ real: `/project/${relative}`, root: "/project", resource, reference: input.reference, type: "directory", }) }), resolveList: () => Effect.die("unused"), listResolved: () => Effect.die("unused"), listPage: () => Effect.die("unused"), listPageResolved: () => Effect.die("unused"), find: () => Effect.die("unused"), grep: () => Effect.die("unused"), isIgnored: () => false, }), ) const search = Layer.succeed( LocationSearch.Service, LocationSearch.Service.of({ files: (input) => Effect.sync(() => { searches.push(input) return result }), grep: () => Effect.die("unused"), }), ) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const glob = GlobTool.layer.pipe( Layer.provide(registry), Layer.provide(permission), Layer.provide(filesystem), Layer.provide(search), ) const it = testEffect(Layer.mergeAll(registry, permission, filesystem, search, glob)) const reset = () => { assertions.length = 0 resolutions.length = 0 searches.length = 0 allow = true result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false }) } const call = (input: typeof GlobTool.Input.Type, id = "call-glob") => ({ sessionID, ...toolIdentity, call: { type: "tool-call" as const, id, name: "glob", input }, }) describe("GlobTool", () => { it.effect("registers the glob definition", () => Effect.gen(function* () { reset() expect((yield* toolDefinitions(yield* ToolRegistry.Service)).map((tool) => tool.name)).toEqual(["glob"]) }), ) it.effect("authorizes the active Location pattern and delegates traversal only to LocationSearch.files", () => Effect.gen(function* () { reset() const registry = yield* ToolRegistry.Service expect( yield* executeTool(registry, call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 })), ).toEqual({ type: "text", value: "No files found", }) expect(assertions).toMatchObject([ { sessionID, action: "glob", resources: ["**/*.ts"], save: ["*"], metadata: { root: "src", reference: undefined, path: "src", limit: 12 }, }, ]) expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }]) expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }]) }), ) it.effect("prevents Location search when permission is denied", () => Effect.gen(function* () { reset() allow = false expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.secret" }))).toEqual({ type: "error", value: "Unable to find files matching *.secret", }) expect(searches).toEqual([]) }), ) it.effect("returns active Location glob resources", () => Effect.gen(function* () { reset() result = new LocationSearch.FilesResult({ items: [ new LocationSearch.File({ path: RelativePath.make("src/index.ts"), canonical: "/project/src/index.ts", resource: "src/index.ts", mtime: 1, }), ], truncated: false, partial: false, }) expect(yield* settleTool(yield* ToolRegistry.Service, call({ pattern: "*.ts" }))).toEqual({ result: { type: "text", value: "src/index.ts" }, output: { structured: result, content: [{ type: "text", text: "src/index.ts" }], }, }) }), ) it.effect("searches named references with root and reference metadata", () => Effect.gen(function* () { reset() result = new LocationSearch.FilesResult({ items: [ new LocationSearch.File({ path: RelativePath.make("guide.md"), canonical: "/project/docs/guide.md", resource: "docs:guide.md", mtime: 1, }), ], truncated: false, partial: false, }) expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({ type: "text", value: "docs:guide.md", }) expect(assertions).toMatchObject([ { sessionID, action: "glob", resources: ["*.md"], save: ["*"], metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined }, }, ]) expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }]) }), ) it.effect("formats bounded and partial results without discarding structured output", () => Effect.sync(() => { const output = new LocationSearch.FilesResult({ items: [ new LocationSearch.File({ path: RelativePath.make("one.ts"), canonical: "/project/one.ts", resource: "one.ts", mtime: 1, }), ], truncated: true, partial: true, }) expect(GlobTool.toModelOutput(output)).toBe( "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.)", ) }), ) })