mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
chore: merge main into async-file-tools
This commit is contained in:
@@ -123,6 +123,31 @@ describe("extensions discovery", () => {
|
||||
expect(result.extensions[0].path).toContain("main.ts");
|
||||
});
|
||||
|
||||
it("keeps package.json pi extension entries with leading tilde package-relative", async () => {
|
||||
const subdir = path.join(extensionsDir, "tilde-package");
|
||||
const directExtensionPath = path.join(subdir, "~entry.ts");
|
||||
const slashExtensionPath = path.join(subdir, "~", "entry.ts");
|
||||
fs.mkdirSync(path.join(subdir, "~"), { recursive: true });
|
||||
fs.writeFileSync(directExtensionPath, extensionCode);
|
||||
fs.writeFileSync(slashExtensionPath, extensionCode);
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "tilde-package",
|
||||
pi: {
|
||||
extensions: ["~entry.ts", "~/entry.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions.map((extension) => extension.path).sort()).toEqual(
|
||||
[directExtensionPath, slashExtensionPath].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("package.json can declare multiple extensions", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
fs.mkdirSync(subdir);
|
||||
|
||||
@@ -25,6 +25,16 @@ class MockSpawnedProcess extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
interface PackageManagerInternals {
|
||||
runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void>;
|
||||
runCommandCapture(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },
|
||||
): Promise<string>;
|
||||
getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string; fetchArgs: string[] }>;
|
||||
}
|
||||
|
||||
// Helper to check if a resource is enabled
|
||||
const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => {
|
||||
const normalizedPath = normalizeForMatch(r.path);
|
||||
@@ -574,6 +584,40 @@ Content`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep pi manifest entries with leading tilde package-relative", async () => {
|
||||
const pkgDir = join(tempDir, "tilde-manifest-package");
|
||||
const directExtensionPath = join(pkgDir, "~extensions", "main.ts");
|
||||
const slashExtensionPath = join(pkgDir, "~", "extensions", "alt.ts");
|
||||
const directSkillPath = join(pkgDir, "~skills", "direct-skill", "SKILL.md");
|
||||
const slashSkillPath = join(pkgDir, "~", "skills", "slash-skill", "SKILL.md");
|
||||
|
||||
mkdirSync(join(pkgDir, "~extensions"), { recursive: true });
|
||||
mkdirSync(join(pkgDir, "~", "extensions"), { recursive: true });
|
||||
mkdirSync(join(pkgDir, "~skills", "direct-skill"), { recursive: true });
|
||||
mkdirSync(join(pkgDir, "~", "skills", "slash-skill"), { recursive: true });
|
||||
writeFileSync(directExtensionPath, "export default function() {}");
|
||||
writeFileSync(slashExtensionPath, "export default function() {}");
|
||||
writeFileSync(directSkillPath, "---\nname: direct-skill\ndescription: Direct\n---\nContent");
|
||||
writeFileSync(slashSkillPath, "---\nname: slash-skill\ndescription: Slash\n---\nContent");
|
||||
writeFileSync(
|
||||
join(pkgDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "tilde-manifest-package",
|
||||
pi: {
|
||||
extensions: ["~extensions/main.ts", "~/extensions/alt.ts"],
|
||||
skills: ["~skills", "~/skills"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||
|
||||
expect(result.extensions.some((r) => r.path === directExtensionPath && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => r.path === slashExtensionPath && r.enabled)).toBe(true);
|
||||
expect(result.skills.some((r) => r.path === directSkillPath && r.enabled)).toBe(true);
|
||||
expect(result.skills.some((r) => r.path === slashSkillPath && r.enabled)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle directories with auto-discovery layout", async () => {
|
||||
const pkgDir = join(tempDir, "auto-pkg");
|
||||
mkdirSync(join(pkgDir, "extensions"), { recursive: true });
|
||||
@@ -693,6 +737,62 @@ Content`,
|
||||
expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir });
|
||||
});
|
||||
|
||||
it("should reconcile an existing git checkout to a pinned ref during install", async () => {
|
||||
const source = "git:github.com/user/repo@v2";
|
||||
const targetDir = join(agentDir, "git", "github.com", "user", "repo");
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
writeFileSync(join(targetDir, "package.json"), JSON.stringify({ name: "repo", version: "1.0.0" }));
|
||||
|
||||
const managerWithInternals = packageManager as unknown as PackageManagerInternals;
|
||||
vi.spyOn(managerWithInternals, "runCommandCapture").mockImplementation(async (_command, args) => {
|
||||
if (args[0] === "rev-parse" && args[1] === "HEAD") {
|
||||
return "old-head";
|
||||
}
|
||||
if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD") {
|
||||
return "new-head";
|
||||
}
|
||||
throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`);
|
||||
});
|
||||
const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined);
|
||||
|
||||
await packageManager.install(source);
|
||||
|
||||
expect(runCommandSpy).toHaveBeenCalledWith("git", ["fetch", "origin", "v2"], { cwd: targetDir });
|
||||
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD"], { cwd: targetDir });
|
||||
expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir });
|
||||
expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir });
|
||||
});
|
||||
|
||||
it("should reconcile an existing git checkout to its update target when installing without a ref", async () => {
|
||||
const source = "git:github.com/user/repo";
|
||||
const targetDir = join(agentDir, "git", "github.com", "user", "repo");
|
||||
const fetchArgs = ["fetch", "--prune", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"];
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
const managerWithInternals = packageManager as unknown as PackageManagerInternals;
|
||||
vi.spyOn(managerWithInternals, "getLocalGitUpdateTarget").mockResolvedValue({
|
||||
ref: "origin/HEAD",
|
||||
head: "new-head",
|
||||
fetchArgs,
|
||||
});
|
||||
vi.spyOn(managerWithInternals, "runCommandCapture").mockImplementation(async (_command, args) => {
|
||||
if (args[0] === "rev-parse" && args[1] === "HEAD") {
|
||||
return "old-head";
|
||||
}
|
||||
if (args[0] === "rev-parse" && args[1] === "origin/HEAD") {
|
||||
return "new-head";
|
||||
}
|
||||
throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`);
|
||||
});
|
||||
const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined);
|
||||
|
||||
await packageManager.install(source);
|
||||
|
||||
expect(runCommandSpy).toHaveBeenCalledWith("git", fetchArgs, { cwd: targetDir });
|
||||
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD"], { cwd: targetDir });
|
||||
expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir });
|
||||
});
|
||||
|
||||
it("should use plain install for git package dependencies when npmCommand is configured", async () => {
|
||||
settingsManager = SettingsManager.inMemory({
|
||||
npmCommand: ["pnpm"],
|
||||
@@ -1061,6 +1161,47 @@ Content`,
|
||||
expect(removed).toBe(true);
|
||||
expect(settingsManager.getGlobalSettings().packages ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return false when adding the same git source with the same ref", () => {
|
||||
const first = packageManager.addSourceToSettings("git:github.com/user/repo@v1");
|
||||
expect(first).toBe(true);
|
||||
|
||||
const second = packageManager.addSourceToSettings("git:github.com/user/repo@v1");
|
||||
expect(second).toBe(false);
|
||||
expect(settingsManager.getGlobalSettings().packages).toEqual(["git:github.com/user/repo@v1"]);
|
||||
});
|
||||
|
||||
it("should update the ref when adding the same git source with a different ref", () => {
|
||||
packageManager.addSourceToSettings("git:github.com/user/repo@v1");
|
||||
|
||||
const updated = packageManager.addSourceToSettings("git:github.com/user/repo@v2");
|
||||
expect(updated).toBe(true);
|
||||
expect(settingsManager.getGlobalSettings().packages).toEqual(["git:github.com/user/repo@v2"]);
|
||||
});
|
||||
|
||||
it("should preserve package filters when replacing a package source ref", () => {
|
||||
settingsManager.setPackages([
|
||||
{
|
||||
source: "git:github.com/user/repo@v1",
|
||||
extensions: ["extensions/main.ts"],
|
||||
skills: [],
|
||||
prompts: ["prompts/review.md"],
|
||||
themes: ["themes/dark.json"],
|
||||
},
|
||||
]);
|
||||
|
||||
const updated = packageManager.addSourceToSettings("git:github.com/user/repo@v2");
|
||||
expect(updated).toBe(true);
|
||||
expect(settingsManager.getGlobalSettings().packages).toEqual([
|
||||
{
|
||||
source: "git:github.com/user/repo@v2",
|
||||
extensions: ["extensions/main.ts"],
|
||||
skills: [],
|
||||
prompts: ["prompts/review.md"],
|
||||
themes: ["themes/dark.json"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTPS git URL parsing (old behavior)", () => {
|
||||
|
||||
@@ -16,6 +16,11 @@ describe("path-utils", () => {
|
||||
expect(result).not.toContain("~/");
|
||||
});
|
||||
|
||||
it("should keep tilde-prefixed filenames literal", () => {
|
||||
expect(expandPath("~draft.md")).toBe("~draft.md");
|
||||
expect(expandPath("@~draft.md")).toBe("~draft.md");
|
||||
});
|
||||
|
||||
it("should normalize Unicode spaces", () => {
|
||||
// Non-breaking space (U+00A0) should become regular space
|
||||
const withNBSP = "file\u00A0name.txt";
|
||||
@@ -26,14 +31,21 @@ describe("path-utils", () => {
|
||||
|
||||
describe("resolveToCwd", () => {
|
||||
it("should resolve absolute paths as-is", () => {
|
||||
const result = resolveToCwd("/absolute/path/file.txt", "/some/cwd");
|
||||
expect(result).toBe("/absolute/path/file.txt");
|
||||
const absolutePath = resolve(tmpdir(), "absolute", "path", "file.txt");
|
||||
const result = resolveToCwd(absolutePath, resolve(tmpdir(), "some", "cwd"));
|
||||
expect(result).toBe(absolutePath);
|
||||
});
|
||||
|
||||
it("should resolve relative paths against cwd", () => {
|
||||
const result = resolveToCwd("relative/file.txt", "/some/cwd");
|
||||
expect(result).toBe(resolve("/some/cwd", "relative/file.txt"));
|
||||
});
|
||||
|
||||
it("should resolve tilde-prefixed filenames against cwd", () => {
|
||||
const cwd = join(tmpdir(), "pi-path-utils-cwd");
|
||||
expect(resolveToCwd("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
|
||||
expect(resolveToCwd("@~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReadPath", () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { canonicalizePath, getCwdRelativePath, isLocalPath } from "../src/utils/paths.ts";
|
||||
import { canonicalizePath, getCwdRelativePath, isLocalPath, normalizePath, resolvePath } from "../src/utils/paths.ts";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
@@ -73,6 +74,55 @@ describe("getCwdRelativePath", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePath", () => {
|
||||
it("expands only home tilde shortcuts", () => {
|
||||
const cwd = join(tmpdir(), "pi-paths-cwd");
|
||||
expect(normalizePath("~")).toBe(homedir());
|
||||
expect(normalizePath("~/file.txt")).toBe(join(homedir(), "file.txt"));
|
||||
expect(resolvePath("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
|
||||
expect(normalizePath("~draft.md")).toBe("~draft.md");
|
||||
});
|
||||
|
||||
it("resolves relative paths against the base directory", () => {
|
||||
const cwd = join(tmpdir(), "pi-paths-cwd");
|
||||
expect(resolvePath("subdir/file.txt", cwd)).toBe(resolve(cwd, "subdir/file.txt"));
|
||||
expect(resolvePath("subdir/file.txt", pathToFileURL(cwd).href)).toBe(resolve(cwd, "subdir/file.txt"));
|
||||
});
|
||||
|
||||
it("accepts file URLs", () => {
|
||||
const dir = createTempDir();
|
||||
const filePath = join(dir, "file with spaces.txt");
|
||||
expect(resolvePath(pathToFileURL(filePath).href, join(dir, "base"))).toBe(resolve(filePath));
|
||||
});
|
||||
|
||||
it("throws for invalid file URLs", () => {
|
||||
expect(() => resolvePath("file:///%E0%A4%A")).toThrow();
|
||||
});
|
||||
|
||||
it("preserves POSIX absolute paths with literal percent sequences", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = createTempDir();
|
||||
for (const filePath of [join(dir, "report%2026.md"), join(dir, "foo%2Fbar"), join(dir, "malformed%A.md")]) {
|
||||
expect(resolvePath(filePath, join(dir, "base"))).toBe(resolve(filePath));
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat Windows file URL pathname strings as native paths", () => {
|
||||
if (process.platform !== "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = createTempDir();
|
||||
const filePath = join(dir, "dir", "SKILL.md");
|
||||
const pathname = pathToFileURL(filePath).pathname;
|
||||
expect(pathname).toMatch(/^\/[A-Za-z]:/);
|
||||
expect(resolvePath(pathname, "E:\\project")).toBe(resolve(pathname));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalPath", () => {
|
||||
it("returns true for bare names", () => {
|
||||
expect(isLocalPath("my-package")).toBe(true);
|
||||
@@ -82,6 +132,10 @@ describe("isLocalPath", () => {
|
||||
expect(isLocalPath("./foo")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for file URLs", () => {
|
||||
expect(isLocalPath("file:///tmp/foo")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for npm: protocol", () => {
|
||||
expect(isLocalPath("npm:package")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
|
||||
@@ -405,6 +406,44 @@ Extra prompt content`,
|
||||
expect(loadedPrompt?.sourceInfo?.source).toBe("extension:extra");
|
||||
expect(loadedPrompt?.sourceInfo?.path).toBe(promptPath);
|
||||
});
|
||||
|
||||
it("should load extension resources returned as file URLs", async () => {
|
||||
const extraSkillDir = join(tempDir, "extra skills", "file-url-skill");
|
||||
mkdirSync(extraSkillDir, { recursive: true });
|
||||
const skillPath = join(extraSkillDir, "SKILL.md");
|
||||
writeFileSync(
|
||||
skillPath,
|
||||
`---
|
||||
name: file-url-skill
|
||||
description: File URL skill
|
||||
---
|
||||
Extra content`,
|
||||
);
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
loader.extendResources({
|
||||
skillPaths: [
|
||||
{
|
||||
path: pathToFileURL(extraSkillDir).href,
|
||||
metadata: {
|
||||
source: "extension:file-url",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
baseDir: extraSkillDir,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { skills, diagnostics } = loader.getSkills();
|
||||
expect(diagnostics).toEqual([]);
|
||||
const loadedSkill = skills.find((skill) => skill.name === "file-url-skill");
|
||||
expect(loadedSkill).toBeDefined();
|
||||
expect(loadedSkill?.filePath).toBe(skillPath);
|
||||
expect(loadedSkill?.sourceInfo?.source).toBe("extension:file-url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("noSkills option", () => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getAvailableThemes,
|
||||
getAvailableThemesWithPaths,
|
||||
setRegisteredThemes,
|
||||
} from "../src/modes/interactive/theme/theme.ts";
|
||||
|
||||
type ThemeFile = {
|
||||
name: string;
|
||||
vars?: Record<string, string | number>;
|
||||
colors: Record<string, string | number>;
|
||||
};
|
||||
|
||||
describe("theme picker", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-theme-picker-"));
|
||||
const agentDir = join(tempRoot, "agent");
|
||||
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
||||
mkdirSync(join(agentDir, "themes"), { recursive: true });
|
||||
setRegisteredThemes([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setRegisteredThemes([]);
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses custom theme content names instead of file names", () => {
|
||||
const darkTheme = JSON.parse(
|
||||
readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf-8"),
|
||||
) as ThemeFile;
|
||||
const customTheme: ThemeFile = {
|
||||
...darkTheme,
|
||||
name: "bar",
|
||||
};
|
||||
|
||||
const themePath = join(process.env.PI_CODING_AGENT_DIR!, "themes", "foo.json");
|
||||
writeFileSync(themePath, JSON.stringify(customTheme, null, 2));
|
||||
|
||||
expect(getAvailableThemes()).toContain("bar");
|
||||
expect(getAvailableThemes()).not.toContain("foo");
|
||||
expect(getAvailableThemesWithPaths()).toContainEqual({ name: "bar", path: themePath });
|
||||
expect(getAvailableThemesWithPaths().some((theme) => theme.name === "foo")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -123,6 +123,43 @@ describe("ToolExecutionComponent parity", () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
test("bash renderer does not duplicate final full output truncation details", async () => {
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, { onData }) => {
|
||||
for (let i = 1; i <= 4000; i++) {
|
||||
onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`));
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
};
|
||||
const tool = createBashToolDefinition(process.cwd(), { operations });
|
||||
const result = await tool.execute(
|
||||
"tool-bash-1b",
|
||||
{ command: "generate output" },
|
||||
undefined,
|
||||
undefined,
|
||||
{} as never,
|
||||
);
|
||||
const component = new ToolExecutionComponent(
|
||||
"bash",
|
||||
"tool-bash-1b",
|
||||
{ command: "generate output" },
|
||||
{},
|
||||
tool,
|
||||
createFakeTui(),
|
||||
process.cwd(),
|
||||
);
|
||||
component.setExpanded(true);
|
||||
component.updateResult({ ...result, isError: false }, false);
|
||||
|
||||
const rendered = stripAnsi(component.render(200).join("\n"));
|
||||
expect(rendered.match(/Full output:/g)?.length ?? 0).toBe(1);
|
||||
expect(rendered).toMatch(/line-4000[^\n]*\n[^\S\n]*\n \[Full output:/);
|
||||
expect(rendered).not.toMatch(/line-4000[^\n]*\n[^\S\n]*\n[^\S\n]*\n \[Full output:/);
|
||||
expect(rendered).toContain("Truncated: showing 2000 of 4000 lines");
|
||||
expect(rendered).not.toContain("[Showing lines 2001-4000 of 4000. Full output:");
|
||||
});
|
||||
|
||||
test("does not duplicate built-in headers when passed the active built-in definition", () => {
|
||||
const component = new ToolExecutionComponent(
|
||||
"read",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applyPatch } from "diff";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
@@ -239,6 +240,12 @@ describe("Coding Agent Tools", () => {
|
||||
expect(result.details.diff).toBeDefined();
|
||||
expect(typeof result.details.diff).toBe("string");
|
||||
expect(result.details.diff).toContain("testing");
|
||||
expect(result.details.patch).toContain("--- ");
|
||||
expect(result.details.patch).toContain("+++ ");
|
||||
expect(result.details.patch).toContain("@@");
|
||||
expect(result.details.patch).toContain("-Hello, world!");
|
||||
expect(result.details.patch).toContain("+Hello, testing!");
|
||||
expect(applyPatch(originalContent, result.details.patch)).toBe("Hello, testing!");
|
||||
});
|
||||
|
||||
it("should fail if text not found", async () => {
|
||||
@@ -573,6 +580,28 @@ describe("Coding Agent Tools", () => {
|
||||
expect(getTextOutput(result)).toContain("line 4999");
|
||||
});
|
||||
|
||||
it("should not count a trailing newline as an extra truncated bash output line", async () => {
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, { onData }) => {
|
||||
for (let i = 1; i <= 4000; i++) {
|
||||
onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`, "utf-8"));
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
};
|
||||
const bash = createBashTool(testDir, { operations });
|
||||
|
||||
const result = await bash.execute("test-call-trailing-newline-line-count", { command: "many-lines" });
|
||||
const output = getTextOutput(result);
|
||||
|
||||
expect(result.details?.truncation?.totalLines).toBe(4000);
|
||||
expect(result.details?.truncation?.outputLines).toBe(2000);
|
||||
expect(output).toContain("line-2001");
|
||||
expect(output).toContain("line-4000");
|
||||
expect(output).toMatch(/\[Showing lines 2001-4000 of 4000\. Full output: /);
|
||||
expect(output).not.toContain("4001");
|
||||
});
|
||||
|
||||
it("should decode UTF-8 characters split across output chunks", async () => {
|
||||
const euro = Buffer.from("€\n", "utf-8");
|
||||
const operations: BashOperations = {
|
||||
|
||||
Reference in New Issue
Block a user