import { describe, expect } from "bun:test" import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { GlobTool } from "../../src/tool/glob" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" import { TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { Reference } from "@/reference/reference" import { RepositoryCache } from "@/reference/repository-cache" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { Git } from "@/git" import { Permission } from "../../src/permission" import type * as Tool from "../../src/tool/tool" const referenceLayer = (flags: Partial = {}) => Reference.layer.pipe( Layer.provide(Config.defaultLayer), Layer.provide(RepositoryCache.defaultLayer), Layer.provide(RuntimeFlags.layer(flags)), ) const toolLayer = (flags: Partial = {}) => Layer.mergeAll( CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, Git.defaultLayer, referenceLayer(flags), ) const it = testEffect(toolLayer()) const scout = testEffect(toolLayer({ experimentalScout: true })) const ctx = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), messages: [], metadata: () => Effect.void, ask: () => Effect.void, } const asks = () => { const items: Array> = [] return { items, next: { ...ctx, ask: (req: Omit) => Effect.sync(() => { items.push(req) }), } satisfies Tool.Context, } } const githubBase = (url: string, self: Effect.Effect) => Effect.acquireUseRelease( Effect.sync(() => { const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url return previous }), () => self, (previous) => Effect.sync(() => { if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL }), ) const git = Effect.fn("GlobToolTest.git")(function* (cwd: string, args: string[]) { return yield* Effect.promise(async () => { const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe", }) const [stdout, stderr, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]) if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) return stdout.trim() }) }) describe("tool.glob", () => { it.instance("matches files from a directory path", () => Effect.gen(function* () { const test = yield* TestInstance yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n")) yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n")) const info = yield* GlobTool const glob = yield* info.init() const result = yield* glob.execute( { pattern: "*.ts", path: test.directory, }, ctx, ) expect(result.metadata.count).toBe(1) expect(result.output).toContain(path.join(test.directory, "a.ts")) expect(result.output).not.toContain(path.join(test.directory, "b.txt")) }), ) it.instance("rejects exact file paths", () => Effect.gen(function* () { const test = yield* TestInstance const file = path.join(test.directory, "a.ts") yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) const info = yield* GlobTool const glob = yield* info.init() const exit = yield* glob .execute( { pattern: "*.ts", path: file, }, ctx, ) .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const err = Cause.squash(exit.cause) expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") } }), ) scout.instance( "does not ask for external_directory permission inside configured git references", () => Effect.gen(function* () { yield* TestInstance const fs = yield* AppFileSystem.Service const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo") yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) const source = yield* tmpdirScoped({ git: true }) const remoteRoot = yield* tmpdirScoped() const remoteDir = path.join(remoteRoot, "opencode-glob-reference") const remoteRepo = path.join(remoteDir, "repo.git") yield* fs.writeWithDirs(path.join(source, "src", "index.ts"), "export const value = 1\n") yield* git(source, ["add", "."]) yield* git(source, ["commit", "-m", "add source"]) yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) const { items, next } = asks() const info = yield* GlobTool const glob = yield* info.init() const result = yield* githubBase( `file://${remoteRoot}/`, glob.execute({ pattern: "*.ts", path: path.join(cache, "src") }, next), ) expect(result.metadata.count).toBe(1) expect(result.output).toContain(path.join(cache, "src", "index.ts")) expect(items.find((item) => item.permission === "external_directory")).toBeUndefined() }), { config: { reference: { docs: "opencode-glob-reference/repo", }, }, }, ) })