Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/prompt-autopsy/__tests__/contract.test.ts

Raw
import {
	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",
		]);
	});
});