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 - read: Read files - Be concise General guidance Read carefully Project-specific instructions and guidelines: PROJECT RULE /repo CUSTOM `); expect(grouped).toContain("[PREAMBLE]\nCustom identity"); expect(grouped).toContain("[TOOLS]\n"); expect(grouped).toContain("[RULES]\n"); expect(grouped).toContain("[GENERAL GUIDELINES]\n"); expect(grouped).toContain("[TOOL GUIDELINES]\n"); expect(grouped).toContain("[CTX]\n"); expect(grouped).toContain("[SKILLS]\n"); expect(grouped).toContain("[CWD]\n"); expect(grouped).toContain("[SECTION custom_context]\n"); expect(grouped).not.toContain("[SECTION project_instructions]"); }); it("keeps nested same-name sections intact", () => { const grouped = groupSystemPrompt(` outer inner outer tail /repo `); expect(grouped).toBe(`[APPEND] outer inner outer tail [CWD] /repo `); }); 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: "\n- read\n", }, ); 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", ]); }); });