Merge branch 'main' of https://github.com/badlogic/pi-mono
# Conflicts: # packages/coding-agent/CHANGELOG.md
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionImportFileNotFoundError } from "../src/core/agent-session-runtime.js";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||
|
||||
type PathCommand = "/export" | "/import";
|
||||
|
||||
type InteractiveModePrototype = {
|
||||
getPathCommandArgument(this: unknown, text: string, command: PathCommand): string | undefined;
|
||||
handleImportCommand(this: ImportCommandContext, text: string): Promise<void>;
|
||||
};
|
||||
|
||||
type ImportCommandContext = {
|
||||
loadingAnimation?: { stop: () => void };
|
||||
statusContainer: { clear: () => void };
|
||||
runtimeHost: { importFromJsonl: (inputPath: string, cwdOverride?: string) => Promise<{ cancelled: boolean }> };
|
||||
showError: (message: string) => void;
|
||||
showStatus: (message: string) => void;
|
||||
showExtensionConfirm: (title: string, message: string) => Promise<boolean>;
|
||||
handleRuntimeSessionChange: () => Promise<void>;
|
||||
renderCurrentSessionState: () => void;
|
||||
handleFatalRuntimeError: (prefix: string, error: unknown) => Promise<never>;
|
||||
promptForMissingSessionCwd: (error: unknown) => Promise<string | undefined>;
|
||||
getPathCommandArgument: (text: string, command: PathCommand) => string | undefined;
|
||||
};
|
||||
|
||||
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype;
|
||||
|
||||
describe("InteractiveMode /import parsing", () => {
|
||||
it("strips quotes from /import path arguments", () => {
|
||||
expect(interactiveModePrototype.getPathCommandArgument('/import "path/to/session.jsonl"', "/import")).toBe(
|
||||
"path/to/session.jsonl",
|
||||
);
|
||||
expect(
|
||||
interactiveModePrototype.getPathCommandArgument('/import "path with spaces/session.jsonl"', "/import"),
|
||||
).toBe("path with spaces/session.jsonl");
|
||||
});
|
||||
|
||||
it("preserves apostrophes in unquoted /import path arguments", () => {
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/import john's/session.jsonl", "/import")).toBe(
|
||||
"john's/session.jsonl",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces command token boundaries", () => {
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/important /tmp/session.jsonl", "/import")).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/exporter out.html", "/export")).toBe(undefined);
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/import /tmp/session.jsonl", "/import")).toBe(
|
||||
"/tmp/session.jsonl",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes unquoted path to runtimeHost.importFromJsonl", async () => {
|
||||
const importFromJsonl = vi.fn(async () => ({ cancelled: false }));
|
||||
const showExtensionConfirm = vi.fn(async () => true);
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
|
||||
const context: ImportCommandContext = {
|
||||
statusContainer: { clear: vi.fn() },
|
||||
runtimeHost: { importFromJsonl },
|
||||
showError,
|
||||
showStatus,
|
||||
showExtensionConfirm,
|
||||
handleRuntimeSessionChange: vi.fn(async () => {}),
|
||||
renderCurrentSessionState: vi.fn(),
|
||||
handleFatalRuntimeError: vi.fn(async () => {
|
||||
throw new Error("unexpected fatal error");
|
||||
}),
|
||||
promptForMissingSessionCwd: vi.fn(async () => undefined),
|
||||
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
|
||||
};
|
||||
|
||||
await interactiveModePrototype.handleImportCommand.call(context, '/import "path/to/session.jsonl"');
|
||||
|
||||
expect(showExtensionConfirm).toHaveBeenCalledWith(
|
||||
"Import session",
|
||||
"Replace current session with path/to/session.jsonl?",
|
||||
);
|
||||
expect(importFromJsonl).toHaveBeenCalledWith("path/to/session.jsonl");
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(showStatus).toHaveBeenCalledWith("Session imported from: path/to/session.jsonl");
|
||||
});
|
||||
|
||||
it("passes unquoted apostrophe path to runtimeHost.importFromJsonl unchanged", async () => {
|
||||
const importFromJsonl = vi.fn(async () => ({ cancelled: false }));
|
||||
const showExtensionConfirm = vi.fn(async () => true);
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
|
||||
const context: ImportCommandContext = {
|
||||
statusContainer: { clear: vi.fn() },
|
||||
runtimeHost: { importFromJsonl },
|
||||
showError,
|
||||
showStatus,
|
||||
showExtensionConfirm,
|
||||
handleRuntimeSessionChange: vi.fn(async () => {}),
|
||||
renderCurrentSessionState: vi.fn(),
|
||||
handleFatalRuntimeError: vi.fn(async () => {
|
||||
throw new Error("unexpected fatal error");
|
||||
}),
|
||||
promptForMissingSessionCwd: vi.fn(async () => undefined),
|
||||
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
|
||||
};
|
||||
|
||||
await interactiveModePrototype.handleImportCommand.call(context, "/import john's/session.jsonl");
|
||||
|
||||
expect(importFromJsonl).toHaveBeenCalledWith("john's/session.jsonl");
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(showStatus).toHaveBeenCalledWith("Session imported from: john's/session.jsonl");
|
||||
});
|
||||
|
||||
it("shows a non-fatal error when /import path does not exist", async () => {
|
||||
const importFromJsonl = vi.fn(async () => {
|
||||
throw new SessionImportFileNotFoundError("/tmp/missing-session.jsonl");
|
||||
});
|
||||
const showExtensionConfirm = vi.fn(async () => true);
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const handleFatalRuntimeError = vi.fn(async () => {
|
||||
throw new Error("unexpected fatal error");
|
||||
});
|
||||
|
||||
const context: ImportCommandContext = {
|
||||
statusContainer: { clear: vi.fn() },
|
||||
runtimeHost: { importFromJsonl },
|
||||
showError,
|
||||
showStatus,
|
||||
showExtensionConfirm,
|
||||
handleRuntimeSessionChange: vi.fn(async () => {}),
|
||||
renderCurrentSessionState: vi.fn(),
|
||||
handleFatalRuntimeError,
|
||||
promptForMissingSessionCwd: vi.fn(async () => undefined),
|
||||
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
|
||||
};
|
||||
|
||||
await interactiveModePrototype.handleImportCommand.call(context, "/import /tmp/missing-session.jsonl");
|
||||
|
||||
expect(showError).toHaveBeenCalledWith("Failed to import session: File not found: /tmp/missing-session.jsonl");
|
||||
expect(showStatus).not.toHaveBeenCalled();
|
||||
expect(handleFatalRuntimeError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { homedir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Container } from "@mariozechner/pi-tui";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||
@@ -60,6 +62,27 @@ describe("InteractiveMode.showStatus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode.setToolsExpanded", () => {
|
||||
test("applies expansion state to the active header and chat entries", () => {
|
||||
const header = { setExpanded: vi.fn() };
|
||||
const chatChild = { setExpanded: vi.fn() };
|
||||
const fakeThis: any = {
|
||||
toolOutputExpanded: false,
|
||||
customHeader: undefined,
|
||||
builtInHeader: header,
|
||||
chatContainer: { children: [chatChild] },
|
||||
ui: { requestRender: vi.fn() },
|
||||
};
|
||||
|
||||
(InteractiveMode as any).prototype.setToolsExpanded.call(fakeThis, true);
|
||||
|
||||
expect(fakeThis.toolOutputExpanded).toBe(true);
|
||||
expect(header.setExpanded).toHaveBeenCalledWith(true);
|
||||
expect(chatChild.setExpanded).toHaveBeenCalledWith(true);
|
||||
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode.createExtensionUIContext setTheme", () => {
|
||||
test("persists theme changes to settings manager", () => {
|
||||
initTheme("dark");
|
||||
@@ -116,31 +139,41 @@ describe("InteractiveMode.showLoadedResources", () => {
|
||||
function createShowLoadedResourcesThis(options: {
|
||||
quietStartup: boolean;
|
||||
verbose?: boolean;
|
||||
skills?: Array<{ filePath: string }>;
|
||||
toolOutputExpanded?: boolean;
|
||||
cwd?: string;
|
||||
contextFiles?: Array<{ path: string; content?: string }>;
|
||||
extensions?: Array<{ path: string }>;
|
||||
skills?: Array<{ filePath: string; name: string }>;
|
||||
skillDiagnostics?: Array<{ type: "warning" | "error" | "collision"; message: string }>;
|
||||
}) {
|
||||
const fakeThis: any = {
|
||||
options: { verbose: options.verbose ?? false },
|
||||
toolOutputExpanded: options.toolOutputExpanded ?? false,
|
||||
chatContainer: new Container(),
|
||||
settingsManager: {
|
||||
getQuietStartup: () => options.quietStartup,
|
||||
},
|
||||
sessionManager: {
|
||||
getCwd: () => options.cwd ?? "/tmp/project",
|
||||
},
|
||||
session: {
|
||||
promptTemplates: [],
|
||||
extensionRunner: undefined,
|
||||
resourceLoader: {
|
||||
getPathMetadata: () => new Map(),
|
||||
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||
getAgentsFiles: () => ({ agentsFiles: options.contextFiles ?? [] }),
|
||||
getSkills: () => ({
|
||||
skills: options.skills ?? [],
|
||||
diagnostics: options.skillDiagnostics ?? [],
|
||||
}),
|
||||
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||
getExtensions: () => ({ extensions: [], errors: [], runtime: {} }),
|
||||
getExtensions: () => ({ extensions: options.extensions ?? [], errors: [], runtime: {} }),
|
||||
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||
},
|
||||
},
|
||||
formatDisplayPath: (p: string) => p,
|
||||
formatDisplayPath: (p: string) => (InteractiveMode as any).prototype.formatDisplayPath.call(fakeThis, p),
|
||||
formatContextPath: (p: string) => (InteractiveMode as any).prototype.formatContextPath.call(fakeThis, p),
|
||||
getStartupExpansionState: () => (InteractiveMode as any).prototype.getStartupExpansionState.call(fakeThis),
|
||||
buildScopeGroups: () => [],
|
||||
formatScopeGroups: () => "resource-list",
|
||||
getShortPath: (p: string) => p,
|
||||
@@ -151,10 +184,117 @@ describe("InteractiveMode.showLoadedResources", () => {
|
||||
return fakeThis;
|
||||
}
|
||||
|
||||
test("shows a compact resource listing by default", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Skills]");
|
||||
expect(output).toContain("commit");
|
||||
expect(output).not.toContain("resource-list");
|
||||
});
|
||||
|
||||
test("shows full resource listing when expanded", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
toolOutputExpanded: true,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Skills]");
|
||||
expect(output).toContain("resource-list");
|
||||
expect(output).not.toContain("commit");
|
||||
});
|
||||
|
||||
test("shows full resource listing on verbose startup even when tool output is collapsed", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: true,
|
||||
verbose: true,
|
||||
toolOutputExpanded: false,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Skills]");
|
||||
expect(output).toContain("resource-list");
|
||||
expect(output).not.toContain("commit");
|
||||
});
|
||||
|
||||
test("abbreviates extensions in compact listing", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
extensions: [{ path: "/tmp/extensions/answer.ts" }, { path: "/tmp/extensions/btw.ts" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Extensions]");
|
||||
expect(output).toContain("answer.ts, btw.ts");
|
||||
expect(output).not.toContain("extensions/answer.ts");
|
||||
});
|
||||
|
||||
test("shows context paths relative to cwd while preserving full external paths", () => {
|
||||
const home = homedir();
|
||||
const cwd = path.join(home, "Development", "pi-mono");
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
cwd,
|
||||
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
|
||||
expect(output).toContain("[Context]");
|
||||
expect(output).toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
|
||||
expect(output).not.toContain(`${cwd.replace(/\\/g, "/")}/AGENTS.md`);
|
||||
});
|
||||
|
||||
test("shows full context paths when expanded", () => {
|
||||
const home = homedir();
|
||||
const cwd = path.join(home, "Development", "pi-mono");
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
toolOutputExpanded: true,
|
||||
cwd,
|
||||
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
|
||||
expect(output).toContain("[Context]");
|
||||
expect(output).toContain("~/.pi/agent/AGENTS.md");
|
||||
expect(output).toContain("~/Development/pi-mono/AGENTS.md");
|
||||
expect(output).not.toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
|
||||
});
|
||||
|
||||
test("does not show verbose listing on quiet startup during reload", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: true,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md" }],
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
@@ -169,7 +309,7 @@ describe("InteractiveMode.showLoadedResources", () => {
|
||||
test("still shows diagnostics on quiet startup when requested", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: true,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md" }],
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
skillDiagnostics: [{ type: "warning", message: "duplicate skill name" }],
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
* - Edge cases and integration between parsing and substitution
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterAll, describe, expect, test } from "vitest";
|
||||
import { loadPromptTemplates, parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js";
|
||||
|
||||
// ============================================================================
|
||||
// substituteArgs
|
||||
@@ -379,3 +382,123 @@ describe("parseCommandArgs + substituteArgs integration", () => {
|
||||
expect(substituteArgs(template1, args)).toBe(substituteArgs(template2, args));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// loadPromptTemplates - argument-hint frontmatter
|
||||
// ============================================================================
|
||||
|
||||
describe("loadPromptTemplates - argument-hint", () => {
|
||||
const testDir = join(tmpdir(), `pi-test-prompts-${Date.now()}`);
|
||||
|
||||
function writeTemplate(name: string, content: string) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
writeFileSync(join(testDir, `${name}.md`), content);
|
||||
}
|
||||
|
||||
test("should parse required argument-hint from frontmatter", () => {
|
||||
writeTemplate(
|
||||
"pr",
|
||||
`---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
You are given one or more GitHub PR URLs: $@`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const pr = templates.find((t) => t.name === "pr");
|
||||
expect(pr).toBeDefined();
|
||||
expect(pr!.argumentHint).toBe("<PR-URL>");
|
||||
expect(pr!.description).toBe("Review PRs from URLs with structured issue and code analysis");
|
||||
});
|
||||
|
||||
test("should parse optional argument-hint from frontmatter", () => {
|
||||
writeTemplate(
|
||||
"wr",
|
||||
`---
|
||||
description: Finish the current task end-to-end with changelog, commit, and push
|
||||
argument-hint: "[instructions]"
|
||||
---
|
||||
Wrap it. Additional instructions: $ARGUMENTS`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const wr = templates.find((t) => t.name === "wr");
|
||||
expect(wr).toBeDefined();
|
||||
expect(wr!.argumentHint).toBe("[instructions]");
|
||||
expect(wr!.description).toBe("Finish the current task end-to-end with changelog, commit, and push");
|
||||
});
|
||||
|
||||
test("should leave argumentHint undefined when not specified", () => {
|
||||
writeTemplate(
|
||||
"cl",
|
||||
`---
|
||||
description: Audit changelog entries before release
|
||||
---
|
||||
Audit changelog entries for all commits since the last release.`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const cl = templates.find((t) => t.name === "cl");
|
||||
expect(cl).toBeDefined();
|
||||
expect(cl!.argumentHint).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should ignore empty argument-hint", () => {
|
||||
writeTemplate(
|
||||
"empty-hint",
|
||||
`---
|
||||
description: A command with empty hint
|
||||
argument-hint: ""
|
||||
---
|
||||
Do something`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const tmpl = templates.find((t) => t.name === "empty-hint");
|
||||
expect(tmpl).toBeDefined();
|
||||
expect(tmpl!.argumentHint).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should preserve argument-hint with special characters", () => {
|
||||
writeTemplate(
|
||||
"is",
|
||||
`---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
argument-hint: "<issue>"
|
||||
---
|
||||
Analyze GitHub issue(s): $ARGUMENTS`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const is = templates.find((t) => t.name === "is");
|
||||
expect(is).toBeDefined();
|
||||
expect(is!.argumentHint).toBe("<issue>");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,6 +142,17 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
|
||||
}
|
||||
return runner.emitBeforeProviderRequest(payload);
|
||||
},
|
||||
onResponse: async (response) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner?.hasHandlers("after_provider_response")) {
|
||||
return;
|
||||
}
|
||||
await runner.emit({
|
||||
type: "after_provider_response",
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
},
|
||||
transformContext: async (messages: AgentMessage[]) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner) return messages;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
|
||||
|
||||
/**
|
||||
* Regression test for https://github.com/badlogic/pi-mono/issues/3302
|
||||
*
|
||||
* The `find` tool advertises glob patterns like `src/**\/*.spec.ts`, but the
|
||||
* default fd-backed implementation used `fd --glob <pattern>` without
|
||||
* `--full-path`, which makes fd match only against the basename. Any pattern
|
||||
* containing a `/` therefore silently returned no matches.
|
||||
*
|
||||
* The fix switches fd into full-path mode when the pattern contains a `/`
|
||||
* and prepends `**\/` so the pattern can match against the absolute candidate
|
||||
* path that fd feeds to the matcher.
|
||||
*/
|
||||
describe("issue #3302 find returns no results for path-based glob patterns", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-3302-"));
|
||||
mkdirSync(join(tempRoot, "some", "parent", "child"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "src", "foo", "bar"), { recursive: true });
|
||||
writeFileSync(join(tempRoot, "some", "parent", "child", "file.ext"), "");
|
||||
writeFileSync(join(tempRoot, "some", "parent", "child", "test.spec.ts"), "");
|
||||
writeFileSync(join(tempRoot, "src", "foo", "bar", "example.spec.ts"), "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runFind(pattern: string): Promise<string[]> {
|
||||
const def = createFindToolDefinition(tempRoot);
|
||||
// The find tool implementation does not touch ctx; pass a minimal stub.
|
||||
const ctx = {} as Parameters<typeof def.execute>[4];
|
||||
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = result.content[0]?.text ?? "";
|
||||
if (text === "No files found matching pattern") return [];
|
||||
return text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("["));
|
||||
}
|
||||
|
||||
it("basename pattern still matches (regression-safe)", async () => {
|
||||
const files = await runFind("*.spec.ts");
|
||||
expect(files.sort()).toEqual(["some/parent/child/test.spec.ts", "src/foo/bar/example.spec.ts"]);
|
||||
});
|
||||
|
||||
it("directory-prefixed pattern with ** tail matches subtree", async () => {
|
||||
const files = await runFind("some/parent/child/**");
|
||||
// Matches files (and possibly directories) under the subtree. Assert the two files are present.
|
||||
expect(files).toContain("some/parent/child/file.ext");
|
||||
expect(files).toContain("some/parent/child/test.spec.ts");
|
||||
});
|
||||
|
||||
it("leading ** wildcard with path segments matches", async () => {
|
||||
const files = await runFind("**/parent/child/*");
|
||||
expect(files.sort()).toContain("some/parent/child/file.ext");
|
||||
expect(files.sort()).toContain("some/parent/child/test.spec.ts");
|
||||
});
|
||||
|
||||
it("src/**/*.spec.ts matches nested spec file", async () => {
|
||||
const files = await runFind("src/**/*.spec.ts");
|
||||
expect(files).toEqual(["src/foo/bar/example.spec.ts"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
|
||||
|
||||
/**
|
||||
* Regression test for https://github.com/badlogic/pi-mono/issues/3303
|
||||
*
|
||||
* The `find` tool previously collected every `.gitignore` under the search
|
||||
* path and passed them to `fd` via `--ignore-file`. fd treats `--ignore-file`
|
||||
* entries as a single global ignore source, so rules from `a/.gitignore`
|
||||
* also filtered files under sibling `b/`. The fix switches to fd's
|
||||
* hierarchical `.gitignore` handling via `--no-require-git` and drops the
|
||||
* manual collection.
|
||||
*/
|
||||
describe("issue #3303 nested .gitignore rules leak into sibling directories", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
async function runFind(pattern: string): Promise<string[]> {
|
||||
const def = createFindToolDefinition(tempRoot);
|
||||
const ctx = {} as Parameters<typeof def.execute>[4];
|
||||
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = result.content[0]?.text ?? "";
|
||||
if (text === "No files found matching pattern") return [];
|
||||
return text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("["))
|
||||
.sort();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("flat sibling case", () => {
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-flat-"));
|
||||
mkdirSync(join(tempRoot, "a"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "b"), { recursive: true });
|
||||
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
|
||||
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "root.txt"), "");
|
||||
});
|
||||
|
||||
it("applies a/.gitignore only inside a/ and leaves b/ untouched", async () => {
|
||||
const files = await runFind("**/*.txt");
|
||||
expect(files).toEqual(["a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deeply nested case", () => {
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-deep-"));
|
||||
mkdirSync(join(tempRoot, "a", "deep"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "b"), { recursive: true });
|
||||
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
|
||||
writeFileSync(join(tempRoot, "a", "deep", ".gitignore"), "secret.txt\n");
|
||||
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "deep", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "deep", "secret.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "deep", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "root.txt"), "");
|
||||
});
|
||||
|
||||
it("scopes each .gitignore to its own subtree", async () => {
|
||||
const files = await runFind("**/*.txt");
|
||||
// a/.gitignore ignores 'ignored.txt' within a/ and a/deep/.
|
||||
// a/deep/.gitignore additionally ignores 'secret.txt' within a/deep/.
|
||||
// b/ is untouched by either.
|
||||
expect(files).toEqual(["a/deep/kept.txt", "a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -557,6 +557,15 @@ describe("Coding Agent Tools", () => {
|
||||
expect(output).toContain("kept.txt");
|
||||
expect(output).not.toContain("ignored.txt");
|
||||
});
|
||||
|
||||
it("should surface fd glob parse errors", async () => {
|
||||
await expect(
|
||||
findTool.execute("test-call-15", {
|
||||
pattern: "[",
|
||||
path: testDir,
|
||||
}),
|
||||
).rejects.toThrow(/error parsing glob|fd exited with code 1|fd error/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ls tool", () => {
|
||||
|
||||
Reference in New Issue
Block a user