repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/prompt-autopsy/__tests__/contract.test.ts
Rawimport {
lstatSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { initTheme } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createPiSession,
type PiSession,
} from "../../../test/fixture/create-pi-session.ts";
import { groupSystemPrompt, resolveEditor } from "../index.ts";
function extensionPath(): string {
return fileURLToPath(new URL("../index.ts", import.meta.url));
}
describe("prompt-autopsy contract", () => {
let session: PiSession | undefined;
afterEach(async () => {
await session?.dispose();
session = undefined;
vi.restoreAllMocks();
});
it("groups Pi 0.86 top-level prompt sections without mistaking nested XML", () => {
const grouped = groupSystemPrompt(`Custom identity
<tools>
- read: Read files
</tools>
<rules>
- Be concise
</rules>
<general_guidelines>
General guidance
</general_guidelines>
<tool_guidelines>
<tool name="read">
Read carefully
</tool>
</tool_guidelines>
<project_context>
Project-specific instructions and guidelines:
<project_instructions path="/repo/AGENTS.md">
PROJECT RULE
</project_instructions>
</project_context>
<skills>
<available_skills>
</available_skills>
</skills>
<cwd>
/repo
</cwd>
<custom_context>
CUSTOM
</custom_context>`);
expect(grouped).toContain("[PREAMBLE]\nCustom identity");
expect(grouped).toContain("[TOOLS]\n<tools>");
expect(grouped).toContain("[RULES]\n<rules>");
expect(grouped).toContain("[GENERAL GUIDELINES]\n<general_guidelines>");
expect(grouped).toContain("[TOOL GUIDELINES]\n<tool_guidelines>");
expect(grouped).toContain("[CTX]\n<project_context>");
expect(grouped).toContain("[SKILLS]\n<skills>");
expect(grouped).toContain("[CWD]\n<cwd>");
expect(grouped).toContain("[SECTION custom_context]\n<custom_context>");
expect(grouped).not.toContain("[SECTION project_instructions]");
});
it("keeps nested same-name sections intact", () => {
const grouped = groupSystemPrompt(`<addendum>
outer
<addendum>
inner
</addendum>
outer tail
</addendum>
<cwd>
/repo
</cwd>`);
expect(grouped).toBe(`[APPEND]
<addendum>
outer
<addendum>
inner
</addendum>
outer tail
</addendum>
[CWD]
<cwd>
/repo
</cwd>`);
});
it("prefers Pi's externalEditor setting over $VISUAL and $EDITOR", async () => {
session = await createPiSession({
extensionPaths: [extensionPath()],
userSettings: { externalEditor: "configured-editor --wait" },
env: { VISUAL: "visual-editor", EDITOR: "editor-editor" },
});
const ctx = session.session.extensionRunner.createCommandContext();
expect(resolveEditor(ctx)).toBe("configured-editor --wait");
});
it("falls back from $VISUAL to $EDITOR to Pi's default without a setting", async () => {
session = await createPiSession({
extensionPaths: [extensionPath()],
userSettings: { externalEditor: " " },
env: { VISUAL: "visual-editor", EDITOR: "editor-editor" },
});
const ctx = session.session.extensionRunner.createCommandContext();
expect(resolveEditor(ctx)).toBe("visual-editor");
delete process.env["VISUAL"];
expect(resolveEditor(ctx)).toBe("editor-editor");
delete process.env["EDITOR"];
expect(resolveEditor(ctx)).toBe(
process.platform === "win32" ? "notepad" : "nano",
);
});
it("writes a private output file and opens it", async () => {
session = await createPiSession({
extensionPaths: [extensionPath()],
env: { VISUAL: `${process.execPath} -e "process.exit(0)"` },
});
await session.session.extensionRunner.emitBeforeAgentStart(
{ messageType: "user", prompt: "prompt" },
{
cwd: session.cwd,
forceSystemPrompt: "<tools>\n- read\n</tools>",
},
);
await session.session.extensionRunner.emit({
type: "agent_start",
} as never);
initTheme(undefined, false);
const ctx = session.session.extensionRunner.createCommandContext();
const notify = vi.spyOn(ctx.ui, "notify");
const extension = session.extensions[0];
if (!extension) throw new Error("missing extension");
const command = extension.commands.get("prompt-autopsy");
if (!command) throw new Error("missing prompt-autopsy command");
await command.handler("", ctx);
const notification = notify.mock.calls.at(-1);
expect(notification?.[1]).toBe("info");
const outputPath = String(notification?.[0]).replace(/^wrote /, "");
expect(outputPath).not.toBe("/tmp/sysprompt-autopsy.md");
const directory = lstatSync(path.dirname(outputPath));
const file = lstatSync(outputPath);
expect(directory.isDirectory()).toBe(true);
expect(file.isFile()).toBe(true);
// Windows synthesizes mode bits; chmod does not represent its ACLs.
if (process.platform !== "win32") {
expect(directory.mode & 0o777).toBe(0o700);
expect(file.mode & 0o777).toBe(0o600);
}
expect(readFileSync(outputPath, "utf8")).toContain("[TOOLS]");
rmSync(path.dirname(outputPath), { recursive: true, force: true });
});
it("writes its opt-in lifecycle trace inside the fixture sandbox", async () => {
const root = mkdtempSync(
path.join(tmpdir(), "pi-ext-prompt-autopsy-debug-"),
);
try {
session = await createPiSession({
cwd: root,
extensionPaths: [extensionPath()],
env: { PI_PROMPT_AUTOPSY_DEBUG: "1" },
});
const runner = session.session.extensionRunner;
await runner.emit({ type: "session_start", reason: "startup" });
initTheme(undefined, false);
const extension = session.extensions[0];
if (!extension) throw new Error("missing extension");
await extension.commands
.get("prompt-autopsy")
?.handler("", runner.createCommandContext());
await runner.emit({ type: "session_shutdown", reason: "quit" });
const stateHome = session.env["XDG_STATE_HOME"];
if (!stateHome) throw new Error("missing sandbox state home");
const directory = path.join(
stateHome,
"pi-ext",
"debug",
"prompt-autopsy",
);
const [file] = readdirSync(directory);
if (!file) throw new Error("missing debug file");
const events = readFileSync(path.join(directory, file), "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line).event);
expect(events).toEqual(
expect.arrayContaining([
"session.start",
"session.shutdown",
"prompt.skip",
]),
);
} finally {
await session?.dispose();
session = undefined;
rmSync(root, { recursive: true, force: true });
}
});
it("loads in real Pi extension runtime", async () => {
const extPath = extensionPath();
session = await createPiSession({ extensionPaths: [extPath] });
expect(session.extensions).toHaveLength(1);
const extension = session.extensions[0];
if (!extension) throw new Error("missing extension");
expect(extension.path).toBe(extPath);
initTheme(undefined, false);
const ctx = session.session.extensionRunner.createCommandContext();
const notify = vi.spyOn(ctx.ui, "notify");
const command = extension.commands.get("prompt-autopsy");
if (!command) throw new Error("missing prompt-autopsy command");
await command.handler("", ctx);
expect(notify.mock.calls.at(-1)).toEqual([
"No provider-effective prompt captured since session start or reload. Send a prompt, then retry.",
"warning",
]);
});
});