89a60a6613
The `search` tool required `paths` via a strict `union([string, array.min(1)])`, so any call that omitted `paths` (or passed an empty array) was rejected at schema validation with `paths: Invalid input` and never ran — turning a recoverable "no scope given" into a fatal tool-call failure. Make `paths` optional and default an omitted/empty value to the workspace root (`.`), matching the documented prompt contract. Behavior is unchanged when paths are supplied. Adds regression coverage for the omitted and empty-array cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
5.2 KiB
TypeScript
138 lines
5.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
import { resolveExplicitSearchPaths } from "@oh-my-pi/pi-coding-agent/tools/path-utils";
|
|
|
|
const isWindows = process.platform === "win32";
|
|
|
|
function createTestSession(cwd: string, overrides: Partial<ToolSession> = {}): ToolSession {
|
|
return {
|
|
cwd,
|
|
hasUI: false,
|
|
getSessionFile: () => null,
|
|
getSessionSpawns: () => "*",
|
|
settings: Settings.isolated(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function getText(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
return result.content
|
|
.filter(entry => entry.type === "text")
|
|
.map(entry => entry.text ?? "")
|
|
.join("\n");
|
|
}
|
|
|
|
describe.skipIf(isWindows)("resolveExplicitSearchPaths cross-tree degeneracy", () => {
|
|
it("returns per-path targets when commonBasePath collapses to filesystem root", async () => {
|
|
// Two real top-level directories that exist on every Unix host. Their only
|
|
// shared ancestor is `/`. A naive shared-base scan would walk the entire
|
|
// filesystem; the resolver must surface explicit `targets` so callers can
|
|
// fan out instead.
|
|
const cwd = os.tmpdir();
|
|
const resolved = await resolveExplicitSearchPaths(["/tmp", "/usr"], cwd);
|
|
|
|
expect(resolved).toBeDefined();
|
|
if (!resolved) throw new Error("expected resolveExplicitSearchPaths to resolve");
|
|
expect(resolved.basePath).toBe(path.parse(resolved.basePath).root);
|
|
expect(resolved.targets).toBeDefined();
|
|
const targetBases = (resolved.targets ?? []).map(target => target.basePath).sort();
|
|
expect(targetBases).toEqual(["/tmp", "/usr"]);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(isWindows)("search with omitted paths", () => {
|
|
let cwd: string;
|
|
|
|
beforeEach(async () => {
|
|
cwd = await fs.mkdtemp(path.join(os.tmpdir(), "pi-search-default-cwd-"));
|
|
await Bun.write(path.join(cwd, "rooted.txt"), "default-needle here\n");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
it("defaults to the workspace root when paths is omitted", async () => {
|
|
const tools = await createTools(createTestSession(cwd));
|
|
const tool = tools.find(entry => entry.name === "search");
|
|
if (!tool) throw new Error("Missing search tool");
|
|
|
|
// Callers that omit `paths` would otherwise be rejected at schema
|
|
// validation with `paths: Invalid input` and never run. Omission must
|
|
// degrade to a workspace-root scan rather than fail the tool call.
|
|
const result = await tool.execute("search-default-paths", { pattern: "default-needle" });
|
|
|
|
const text = getText(result);
|
|
const details = result.details as { fileCount?: number } | undefined;
|
|
expect(text).toContain("default-needle here");
|
|
expect(details?.fileCount).toBe(1);
|
|
});
|
|
|
|
it("defaults to the workspace root when paths is an empty array", async () => {
|
|
const tools = await createTools(createTestSession(cwd));
|
|
const tool = tools.find(entry => entry.name === "search");
|
|
if (!tool) throw new Error("Missing search tool");
|
|
|
|
const result = await tool.execute("search-empty-paths", {
|
|
pattern: "default-needle",
|
|
paths: [],
|
|
});
|
|
|
|
expect(getText(result)).toContain("default-needle here");
|
|
});
|
|
});
|
|
|
|
describe.skipIf(isWindows)("search across unrelated filesystem trees", () => {
|
|
let dirA: string;
|
|
let dirB: string;
|
|
let cwd: string;
|
|
|
|
beforeEach(async () => {
|
|
// Place fixtures in two unrelated top-level subtrees so their only shared
|
|
// ancestor is the filesystem root. Without the multi-target fanout, the
|
|
// search tool would scan from `/` and walk the entire filesystem.
|
|
dirA = await fs.mkdtemp(path.join("/tmp", "pi-search-multi-A-"));
|
|
dirB = await fs.mkdtemp(path.join("/var/tmp", "pi-search-multi-B-"));
|
|
cwd = await fs.mkdtemp(path.join(os.tmpdir(), "pi-search-multi-cwd-"));
|
|
await Bun.write(path.join(dirA, "alpha.txt"), "shared-needle alpha\n");
|
|
await Bun.write(path.join(dirB, "beta.txt"), "shared-needle beta\n");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await Promise.all([
|
|
fs.rm(dirA, { recursive: true, force: true }),
|
|
fs.rm(dirB, { recursive: true, force: true }),
|
|
fs.rm(cwd, { recursive: true, force: true }),
|
|
]);
|
|
});
|
|
|
|
it("returns matches from both trees without rooting the scan at /", async () => {
|
|
const tools = await createTools(createTestSession(cwd));
|
|
const tool = tools.find(entry => entry.name === "search");
|
|
if (!tool) throw new Error("Missing search tool");
|
|
|
|
const start = performance.now();
|
|
const result = await tool.execute("search-cross-tree", {
|
|
pattern: "shared-needle",
|
|
paths: [dirA, dirB],
|
|
});
|
|
const durationMs = performance.now() - start;
|
|
|
|
const text = getText(result);
|
|
const details = result.details as { fileCount?: number; matchCount?: number } | undefined;
|
|
|
|
expect(text).toContain("shared-needle alpha");
|
|
expect(text).toContain("shared-needle beta");
|
|
expect(details?.fileCount).toBe(2);
|
|
expect(details?.matchCount).toBe(2);
|
|
// Defense-in-depth: a regression that re-roots the scan at `/` typically
|
|
// takes seconds. Two-fixture targeted scans complete in well under a
|
|
// second on every supported platform.
|
|
expect(durationMs).toBeLessThan(5000);
|
|
});
|
|
});
|