Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/session-rename/__tests__/contract.test.ts

Raw
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
import {
	type ExtensionCommandContext,
	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";

function extensionPath(): string {
	return fileURLToPath(new URL("../index.ts", import.meta.url));
}

describe("session-rename contract", () => {
	let session: PiSession | undefined;

	afterEach(async () => {
		await session?.dispose();
		session = undefined;
		vi.restoreAllMocks();
	});

	it("loads in real Pi extension runtime", async () => {
		const extPath = extensionPath();
		session = await createPiSession({ extensionPaths: [extPath] });

		expect(session.extensions).toHaveLength(1);
		expect(session.extensions[0]!.path).toBe(extPath);
	});

	it("writes its opt-in lifecycle trace inside the fixture sandbox", async () => {
		const root = mkdtempSync(
			path.join(tmpdir(), "pi-ext-session-rename-debug-"),
		);
		try {
			session = await createPiSession({
				cwd: root,
				extensionPaths: [extensionPath()],
				env: { PI_SESSION_RENAME_DEBUG: "1" },
			});
			const runner = session.session.extensionRunner;
			await runner.emit({ type: "session_start", reason: "startup" });
			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",
				"session-rename",
			);
			const [file] = readdirSync(directory);
			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"]),
			);
		} finally {
			await session?.dispose();
			session = undefined;
			rmSync(root, { recursive: true, force: true });
		}
	});

	it("routes naming through the active custom provider", async () => {
		session = await createPiSession({ extensionPaths: [extensionPath()] });
		const runner = session.session.extensionRunner;
		const base = runner.createContext();
		const faux = fauxProvider({
			provider: "session-rename-test",
			models: [{ id: "worker", maxTokens: 4_096 }],
		});
		faux.setResponses([fauxAssistantMessage("Klaus routing fix")]);
		const stream = vi.spyOn(faux.provider, "stream");
		base.modelRegistry.registerProvider(faux.provider);
		const model = base.modelRegistry.find("session-rename-test", "worker");
		if (!model) throw new Error("missing custom model");
		const manager = base.sessionManager as any;
		const sessionFile = path.join(session.cwd, "session.jsonl");
		const sessionManager = Object.create(manager) as typeof manager;
		sessionManager.getSessionFile = () => sessionFile;
		const ctx = Object.create(base) as typeof base;
		Object.defineProperties(ctx, {
			model: { value: model },
			sessionManager: { value: sessionManager },
		});
		manager.appendMessage({
			role: "user",
			content: "Fix Klaus routing",
			timestamp: Date.now(),
		});
		manager.appendMessage(fauxAssistantMessage("Inspecting custom providers."));

		const command = session.extensions[0]!.commands.get("session-rename");
		if (!command) throw new Error("missing session-rename command");
		await command.handler("", ctx as unknown as ExtensionCommandContext);

		expect(stream).toHaveBeenCalledOnce();
		expect(manager.getSessionName()).toBe("klaus routing fix");
	});

	it("reports naming failures through Pi UI", async () => {
		session = await createPiSession({
			extensionPaths: [extensionPath()],
			env: { PI_SESSION_RENAME_DEBUG: "1" },
		});
		const runner = session.session.extensionRunner;
		const base = runner.createContext();
		const faux = fauxProvider({
			provider: "session-rename-error",
			models: [{ id: "worker", maxTokens: 4_096 }],
		});
		base.modelRegistry.registerProvider(faux.provider);
		const model = base.modelRegistry.find("session-rename-error", "worker");
		if (!model) throw new Error("missing custom model");
		vi.spyOn(base.modelRegistry, "complete").mockRejectedValue(
			new Error("provider exploded"),
		);
		const manager = base.sessionManager as any;
		const sessionFile = path.join(session.cwd, "session.jsonl");
		const sessionManager = Object.create(manager) as typeof manager;
		sessionManager.getSessionFile = () => sessionFile;
		const ctx = Object.create(base) as typeof base;
		Object.defineProperties(ctx, {
			hasUI: { value: true },
			model: { value: model },
			sessionManager: { value: sessionManager },
		});
		manager.appendMessage({
			role: "user",
			content: "Trigger rename failure",
			timestamp: Date.now(),
		});
		manager.appendMessage(fauxAssistantMessage("Ready."));
		initTheme(undefined, false);
		const notify = vi.spyOn(ctx.ui, "notify");

		const command = session.extensions[0]!.commands.get("session-rename");
		if (!command) throw new Error("missing session-rename command");
		await command.handler("", ctx as unknown as ExtensionCommandContext);

		expect(notify).toHaveBeenCalledWith(
			"session-rename failed: provider exploded",
			"error",
		);
		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", "session-rename");
		const [file] = readdirSync(directory);
		const records = readFileSync(path.join(directory, file!), "utf8")
			.trim()
			.split("\n")
			.map((line) => JSON.parse(line));
		expect(records).toContainEqual(
			expect.objectContaining({ event: "name.outcome", outcome: "failed" }),
		);
	});
});