chore: merge main into async-file-tools

This commit is contained in:
Armin Ronacher
2026-05-22 18:04:16 +02:00
Unverified
81 changed files with 1768 additions and 1156 deletions
@@ -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)", () => {
+14 -2
View File
@@ -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", () => {
+57 -3
View File
@@ -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",
+29
View File
@@ -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 = {