Merge branch 'main' into prompt-template-defaults

This commit is contained in:
Mario Zechner
2026-06-09 14:23:00 +02:00
committed by GitHub
65 changed files with 2021 additions and 551 deletions

View File

@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import { type ChangelogEntry, normalizeChangelogLinks } from "../src/utils/changelog.ts";
const entry: ChangelogEntry = {
major: 0,
minor: 79,
patch: 0,
content: "",
};
describe("normalizeChangelogLinks", () => {
test("rewrites package-relative changelog links to tag-pinned GitHub source links", () => {
const markdown = [
"[Project Trust](README.md#project-trust)",
"[Extensions](docs/extensions.md#project_trust)",
"[Examples](examples/extensions/)",
"[Root README](../../README.md#supply-chain-hardening)",
].join("\n");
expect(normalizeChangelogLinks(markdown, entry)).toBe(
[
"[Project Trust](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/README.md#project-trust)",
"[Extensions](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/docs/extensions.md#project_trust)",
"[Examples](https://github.com/earendil-works/pi/tree/v0.79.0/packages/coding-agent/examples/extensions/)",
"[Root README](https://github.com/earendil-works/pi/blob/v0.79.0/README.md#supply-chain-hardening)",
].join("\n"),
);
});
test("canonicalizes old repository URLs without changing external links", () => {
const markdown = [
"[#5167](https://github.com/earendil-works/pi-mono/pull/5167)",
"[#4163](https://github.com/badlogic/pi-mono/issues/4163)",
"[Agent README](https://github.com/badlogic/pi-mono/blob/main/packages/agent/README.md)",
"[External](https://example.com/docs)",
"[Local anchor](#settings)",
].join("\n");
expect(normalizeChangelogLinks(markdown, "0.79.0")).toBe(
[
"[#5167](https://github.com/earendil-works/pi/pull/5167)",
"[#4163](https://github.com/earendil-works/pi/issues/4163)",
"[Agent README](https://github.com/earendil-works/pi/blob/v0.79.0/packages/agent/README.md)",
"[External](https://example.com/docs)",
"[Local anchor](#settings)",
].join("\n"),
);
});
});

View File

@@ -3,6 +3,8 @@ import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR } from "../src/config.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { runMigrations } from "../src/migrations.ts";
describe("config value env var syntax migration", () => {
@@ -68,6 +70,23 @@ describe("config value env var syntax migration", () => {
expect(logMessage).toContain('auth.json["anthropic"].key: ANTHROPIC_API_KEY -> $ANTHROPIC_API_KEY');
});
it.each([
["malformed", '{\n "providers": {\n'],
["blank", ""],
])("does not throw on %s models.json during config migration", (_name, content) => {
const agentDir = createAgentDir();
const modelsPath = path.join(agentDir, "models.json");
fs.writeFileSync(modelsPath, content, "utf-8");
withAgentDir(agentDir, () => expect(() => runMigrations(agentDir)).not.toThrow());
expect(fs.readFileSync(modelsPath, "utf-8")).toBe(content);
const registry = ModelRegistry.create(AuthStorage.create(path.join(agentDir, "auth.json")), modelsPath);
const loadError = registry.getError();
expect(loadError).toContain("Failed to parse models.json");
expect(loadError).toContain(`File: ${modelsPath}`);
});
it("rewrites legacy uppercase models.json API key and header values", () => {
const agentDir = createAgentDir();
fs.writeFileSync(

View File

@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from "vitest";
import { areExperimentalFeaturesEnabled } from "../src/core/experimental.ts";
describe("areExperimentalFeaturesEnabled", () => {
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
afterEach(() => {
if (originalPiExperimental === undefined) {
delete process.env.PI_EXPERIMENTAL;
} else {
process.env.PI_EXPERIMENTAL = originalPiExperimental;
}
});
it("returns false when PI_EXPERIMENTAL is unset", () => {
delete process.env.PI_EXPERIMENTAL;
expect(areExperimentalFeaturesEnabled()).toBe(false);
});
it("returns false when PI_EXPERIMENTAL is empty", () => {
process.env.PI_EXPERIMENTAL = "";
expect(areExperimentalFeaturesEnabled()).toBe(false);
});
it("returns true when PI_EXPERIMENTAL is set to 1", () => {
process.env.PI_EXPERIMENTAL = "1";
expect(areExperimentalFeaturesEnabled()).toBe(true);
});
it("returns false when PI_EXPERIMENTAL is set to 0", () => {
process.env.PI_EXPERIMENTAL = "0";
expect(areExperimentalFeaturesEnabled()).toBe(false);
});
it("returns false when PI_EXPERIMENTAL is set to a non-1 value", () => {
process.env.PI_EXPERIMENTAL = "true";
expect(areExperimentalFeaturesEnabled()).toBe(false);
});
});

View File

@@ -76,6 +76,7 @@ describe("ExtensionRunner", () => {
const extensionContextActions: ExtensionContextActions = {
getModel: () => undefined,
isIdle: () => true,
isProjectTrusted: () => true,
getSignal: () => undefined,
abort: () => {},
hasPendingMessages: () => false,
@@ -496,6 +497,18 @@ describe("ExtensionRunner", () => {
expect(ctx.hasUI).toBe(false);
});
it("exposes project trust state on ExtensionContext", async () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
runner.bindCore(extensionActions, {
...extensionContextActions,
isProjectTrusted: () => false,
});
const ctx = runner.createContext();
expect(ctx.isProjectTrusted()).toBe(false);
});
it("exposes rpc mode with hasUI true when an RPC UI context is provided", async () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);

View File

@@ -324,6 +324,36 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true);
expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]);
});
test("merges triggerCharacters from wrapper factories", () => {
const defaultEditor = { setAutocompleteProvider: vi.fn() };
const customEditor = { setAutocompleteProvider: vi.fn() };
const passThrough =
(triggerCharacters: string[]): AutocompleteProviderFactory =>
(current) => ({
triggerCharacters,
getSuggestions: (lines, cursorLine, cursorCol, options) =>
current.getSuggestions(lines, cursorLine, cursorCol, options),
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) =>
current.applyCompletion(lines, cursorLine, cursorCol, item, prefix),
});
const fakeThis = {
createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined),
defaultEditor,
editor: customEditor,
autocompleteProviderWrappers: [passThrough(["$"]), passThrough(["!"])],
};
(
InteractiveMode as unknown as {
prototype: { setupAutocompleteProvider: (this: typeof fakeThis) => void };
}
).prototype.setupAutocompleteProvider.call(fakeThis);
const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider;
expect(provider.triggerCharacters).toEqual(["$", "!"]);
});
});
describe("InteractiveMode.showLoadedResources", () => {

View File

@@ -157,6 +157,70 @@ describe("package commands", () => {
}
});
it("uses default project trust for list", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("uses project_trust extensions for package commands", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(
main(["list"], {
extensionFactories: [
(pi) => {
pi.on("project_trust", () => ({ trusted: "yes" }));
},
],
}),
).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("lets trust.json override default project trust", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
new ProjectTrustStore(agentDir).set(projectDir, false);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("No packages installed.");
expect(stdout).not.toContain("Project packages:");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("blocks local package changes when project is untrusted", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

View File

@@ -376,7 +376,7 @@ Content`,
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
});
it("should skip project resources when project is not trusted", async () => {
it("should skip trust-gated project resources when project is not trusted", async () => {
const piDir = join(cwd, ".pi");
const extensionsDir = join(piDir, "extensions");
const skillDir = join(piDir, "skills", "project-skill");
@@ -414,7 +414,7 @@ Project skill content`,
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe(
true,
);
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(false);
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(true);
expect(loader.getExtensions().extensions).toHaveLength(0);
expect(loader.getExtensions().errors).toEqual([]);
expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false);

View File

@@ -250,6 +250,23 @@ describe("SettingsManager", () => {
expect(manager.getProjectSettings()).toEqual({});
expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] });
});
it("should read default project trust from global settings only", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultProjectTrust: "never" }));
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getDefaultProjectTrust()).toBe("always");
});
it("should default invalid project trust settings to ask", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "sometimes" }));
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getDefaultProjectTrust()).toBe("ask");
});
});
describe("project settings directory creation", () => {

View File

@@ -80,6 +80,22 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string;
}
describe("stdout cleanliness in non-interactive modes", () => {
it("prints --version to stdout when stdout is redirected", async () => {
const result = await runCli(["--version"]);
expect(result.code).toBe(0);
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
expect(result.stderr).toBe("");
});
it("prints plain --help to stdout when stdout is redirected", async () => {
const result = await runCli(["--help"]);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Usage:");
expect(result.stderr).not.toContain("Usage:");
});
it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => {
const result = await runCli(["--mode", "json", "--help", "--approve"]);

View File

@@ -0,0 +1,93 @@
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { LoginDialogComponent } from "../../../src/modes/interactive/components/login-dialog.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
vi.mock("../../../src/utils/open-browser.ts", () => ({
openBrowser: vi.fn(),
}));
function createDialog(): LoginDialogComponent {
return new LoginDialogComponent(
{ requestRender: vi.fn() } as unknown as TUI,
"prompt-repro",
() => {},
"Prompt Repro",
);
}
function renderDialog(dialog: LoginDialogComponent): string[] {
return stripAnsi(dialog.render(120).join("\n"))
.split("\n")
.map((line) => line.trimEnd());
}
function countRenderedValue(lines: string[], value: string): number {
return lines.filter((line) => line.trim() === `> ${value}`).length;
}
describe("LoginDialogComponent OAuth prompts", () => {
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
test("keeps previous prompt input stable when a later prompt is active", async () => {
const dialog = createDialog();
const firstPrompt = dialog.showPrompt("First prompt:", "first-value");
dialog.handleInput("first-value");
dialog.handleInput("\n");
await expect(firstPrompt).resolves.toBe("first-value");
const secondPrompt = dialog.showPrompt("Second prompt:");
dialog.handleInput("second-secret-demo");
const lines = renderDialog(dialog);
expect(lines.join("\n")).toContain("First prompt:");
expect(lines.join("\n")).toContain("Second prompt:");
expect(countRenderedValue(lines, "first-value")).toBe(1);
expect(countRenderedValue(lines, "second-secret-demo")).toBe(1);
dialog.handleInput("\n");
await expect(secondPrompt).resolves.toBe("second-secret-demo");
});
test("preserves auth instructions when showing a prompt", () => {
const dialog = createDialog();
dialog.showAuth("https://example.invalid/login", "Authorize the extension");
dialog.showPrompt("First prompt:");
const output = renderDialog(dialog).join("\n");
expect(output).toContain("https://example.invalid/login");
expect(output).toContain("Authorize the extension");
expect(output).toContain("First prompt:");
});
test("keeps previous manual input stable when a later prompt is active", async () => {
const dialog = createDialog();
const manualInput = dialog.showManualInput("Paste callback URL:");
dialog.handleInput("callback-value");
dialog.handleInput("\n");
await expect(manualInput).resolves.toBe("callback-value");
const prompt = dialog.showPrompt("Second prompt:");
dialog.handleInput("second-secret-demo");
const lines = renderDialog(dialog);
expect(lines.join("\n")).toContain("Paste callback URL:");
expect(lines.join("\n")).toContain("Second prompt:");
expect(countRenderedValue(lines, "callback-value")).toBe(1);
expect(countRenderedValue(lines, "second-secret-demo")).toBe(1);
dialog.handleInput("\n");
await expect(prompt).resolves.toBe("second-secret-demo");
});
});

View File

@@ -12,6 +12,7 @@ function createContext(tokens: number | null, compact = vi.fn()): ExtensionConte
modelRegistry: {} as ExtensionContext["modelRegistry"],
model: undefined,
isIdle: () => true,
isProjectTrusted: () => true,
signal: undefined,
abort: vi.fn(),
hasPendingMessages: () => false,

View File

@@ -2,7 +2,12 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts";
import {
getProjectTrustPath,
hasProjectConfigDir,
hasProjectTrustInputs,
ProjectTrustStore,
} from "../src/core/trust-manager.ts";
describe("ProjectTrustStore", () => {
let tempDir: string;
@@ -25,12 +30,52 @@ describe("ProjectTrustStore", () => {
const store = new ProjectTrustStore(agentDir);
expect(store.get(cwd)).toBeNull();
expect(store.getEntry(cwd)).toBeNull();
store.set(cwd, true);
expect(store.get(cwd)).toBe(true);
expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: true });
store.set(cwd, false);
expect(store.get(cwd)).toBe(false);
expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: false });
store.set(cwd, null);
expect(store.get(cwd)).toBeNull();
expect(store.getEntry(cwd)).toBeNull();
});
it("inherits the closest saved decision from parent directories", () => {
const store = new ProjectTrustStore(agentDir);
const parentDir = join(tempDir, "trusted-parent");
const childDir = join(parentDir, "project");
const grandchildDir = join(childDir, "nested");
mkdirSync(grandchildDir, { recursive: true });
store.set(parentDir, true);
expect(store.get(childDir)).toBe(true);
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
expect(store.get(grandchildDir)).toBe(true);
expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
store.set(childDir, false);
expect(store.get(grandchildDir)).toBe(false);
expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false });
});
it("can clear a child override to inherit parent trust", () => {
const store = new ProjectTrustStore(agentDir);
const parentDir = join(tempDir, "trusted-parent");
const childDir = join(parentDir, "project");
mkdirSync(childDir, { recursive: true });
store.set(parentDir, true);
store.set(childDir, false);
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false });
store.setMany([
{ path: parentDir, decision: true },
{ path: childDir, decision: null },
]);
expect(store.get(childDir)).toBe(true);
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
});
it("fails loudly without overwriting malformed trust stores", () => {
@@ -53,9 +98,13 @@ describe("ProjectTrustStore", () => {
rmSync(join(cwd, ".pi"), { recursive: true, force: true });
writeFileSync(join(cwd, "AGENTS.md"), "Project instructions");
expect(hasProjectTrustInputs(cwd)).toBe(true);
expect(hasProjectTrustInputs(cwd)).toBe(false);
rmSync(join(cwd, "AGENTS.md"), { force: true });
writeFileSync(join(cwd, "CLAUDE.md"), "Legacy project instructions");
expect(hasProjectTrustInputs(cwd)).toBe(false);
rmSync(join(cwd, "CLAUDE.md"), { force: true });
mkdirSync(join(cwd, ".agents", "skills"), { recursive: true });
expect(hasProjectTrustInputs(cwd)).toBe(true);
});

View File

@@ -17,7 +17,7 @@ describe("TrustSelectorComponent", () => {
it("marks the saved trusted decision", () => {
const selector = new TrustSelectorComponent({
cwd: "/project",
savedDecision: true,
savedDecision: { path: "/project", decision: true },
projectTrusted: true,
onSelect: () => {},
onCancel: () => {},
@@ -25,7 +25,7 @@ describe("TrustSelectorComponent", () => {
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("Saved decision: trusted");
expect(output).toContain("Saved decision: trusted (/project)");
expect(output).toContain("Current session: trusted");
expect(output).toContain("Trust ✓");
expect(output).not.toContain("Do not trust ✓");
@@ -43,6 +43,45 @@ describe("TrustSelectorComponent", () => {
selector.handleInput("\n");
expect(onSelect).toHaveBeenCalledWith(true);
expect(onSelect).toHaveBeenCalledWith({ trusted: true, updates: [{ path: "/project", decision: true }] });
});
it("labels saved ancestor decisions as inherited", () => {
const selector = new TrustSelectorComponent({
cwd: "/parent/project/nested",
savedDecision: { path: "/parent", decision: true },
projectTrusted: true,
onSelect: () => {},
onCancel: () => {},
});
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("Saved decision: trusted (inherited from /parent)");
});
it("adds a trust parent option", () => {
const onSelect = vi.fn();
const selector = new TrustSelectorComponent({
cwd: "/parent/project",
savedDecision: { path: "/parent", decision: true },
projectTrusted: true,
onSelect,
onCancel: () => {},
});
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("Saved decision: trusted (inherited from /parent)");
expect(output).toContain("Trust parent folder (/parent) ✓");
selector.handleInput("\n");
expect(onSelect).toHaveBeenCalledWith({
trusted: true,
updates: [
{ path: "/parent", decision: true },
{ path: "/parent/project", decision: null },
],
});
});
});