# Conflicts:
#	packages/coding-agent/CHANGELOG.md
This commit is contained in:
Mario Zechner
2026-04-17 00:28:14 +02:00
80 changed files with 2747 additions and 883 deletions

View File

@@ -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();
});
});

View File

@@ -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" }],
});

View File

@@ -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 {}
});
});

View File

@@ -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;

View File

@@ -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"]);
});
});

View File

@@ -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"]);
});
});
});

View File

@@ -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", () => {