Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/os-notifier/__tests__/contract.test.ts

Raw
import {
	chmodSync,
	mkdirSync,
	mkdtempSync,
	readdirSync,
	readFileSync,
	rmSync,
	writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
	createPiSession as createSession,
	type PiSession,
} from "../../../test/fixture/create-pi-session.ts";
import {
	agentDoneNotification,
	agentWaitingNotification,
	deliverNative,
	deliverVibration,
	isTermux,
	nativeCommand,
	oscBackend,
	oscUserVar,
	TurnEligibility,
	termuxCommand,
} from "../index";
import { termuxFocused } from "../termux-focus.ts";

function createPiSession(options: Parameters<typeof createSession>[0]) {
	return createSession({
		...options,
		env: { TERMUX_VERSION: undefined, PREFIX: undefined, ...options.env },
	});
}

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

describe("os-notifier native commands", () => {
	it("detects Termux without confusing ordinary Linux", () => {
		expect(isTermux({ TERMUX_VERSION: "0.118" })).toBe(true);
		expect(isTermux({ PREFIX: "/data/data/com.termux/files/usr" })).toBe(true);
		expect(isTermux({ PREFIX: "/usr" })).toBe(false);
		expect(termuxCommand("Title", "Body")).toEqual({
			command: "termux-notification",
			args: ["--title", "Title", "--content", "Body", "--priority", "high"],
		});
	});
	it("constructs Linux notify-send arguments", () => {
		expect(nativeCommand("linux", "Title", "Body", "info")).toEqual({
			command: "notify-send",
			args: ["--app-name=Pi", "--urgency=low", "Title", "Body"],
		});
	});

	it("constructs macOS osascript arguments without interpolating content", () => {
		const command = nativeCommand(
			"darwin",
			`Title ' " $()`,
			"Body\nline",
			"warning",
		);
		expect(command.command).toBe("osascript");
		expect(command.args.slice(-2)).toEqual([`Title ' " $()`, "Body\nline"]);
		expect(command.args[1]).not.toContain("Title");
	});

	it("constructs Windows PowerShell arguments without interpolating content", () => {
		const command = nativeCommand(
			"win32",
			`Title ' " $()`,
			"Body\nline",
			"error",
		);
		expect(command.command).toBe("powershell.exe");
		expect(command.args).toContain("-EncodedCommand");
		expect(command.args.join(" ")).not.toContain("Title");
		expect(command.env).toMatchObject({
			PI_OS_NOTIFIER_TITLE: `Title ' " $()`,
			PI_OS_NOTIFIER_BODY: "Body\nline",
		});
	});

	it("maps natural Linux urgency", () => {
		expect(nativeCommand("linux", "T", "B", "info").args).toContain(
			"--urgency=low",
		);
		expect(nativeCommand("linux", "T", "B", "warning").args).toContain(
			"--urgency=normal",
		);
		expect(nativeCommand("linux", "T", "B", "error").args).toContain(
			"--urgency=critical",
		);
	});

	it("selects Kitty OSC 99 and generic OSC 777", () => {
		expect(oscBackend({ KITTY_WINDOW_ID: "1" })).toBe("osc99");
		expect(oscBackend({})).toBe("osc777");
	});

	it("encodes pane-scoped metadata as OSC user variables", () => {
		expect(oscUserVar("agent", "pi")).toBe(
			"\u001b]1337;SetUserVar=agent=cGk=\u0007",
		);
		expect(oscUserVar("agent_session", "")).toBe(
			"\u001b]1337;SetUserVar=agent_session=\u0007",
		);
	});

	it("falls back exactly once only when native delivery fails", async () => {
		const writes: string[] = [];
		expect(
			await deliverNative(
				{ command: process.execPath, args: ["-e", "process.exit(0)"] },
				"Title",
				"Body",
				"info",
				"osc777",
				(value) => writes.push(value),
			),
		).toBe("native");
		expect(writes).toEqual([]);

		expect(
			await deliverNative(
				{ command: process.execPath, args: ["-e", "process.exit(7)"] },
				"Title",
				"Body",
				"error",
				"osc777",
				(value) => writes.push(value),
			),
		).toBe("fallback");
		expect(writes).toHaveLength(1);
		expect(writes[0]).toContain("\u001b]777;notify;Title;❌ Body\u0007");
		expect(
			await deliverNative(undefined, "Title", "Body", "info", "osc777", () => {
				throw new Error("terminal unavailable");
			}),
		).toBe("failed");
	});

	it("reports missing CLI and falls back", async () => {
		const failure = vi.fn();
		const write = vi.fn();
		expect(
			await deliverNative(
				{ command: "pi-ext-nonexistent-notification-cli", args: [] },
				"Title",
				"Body",
				"info",
				"osc777",
				write,
				5000,
				failure,
			),
		).toBe("fallback");
		expect(failure).toHaveBeenCalledTimes(1);
		expect(write).toHaveBeenCalledTimes(1);
	});

	it("kills a hung native command and falls back", async () => {
		const writes: string[] = [];
		const failure = vi.fn();
		await deliverNative(
			{
				command: process.execPath,
				args: [
					"-e",
					"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)",
				],
			},
			"Title",
			"Body",
			"warning",
			"osc777",
			(value) => writes.push(value),
			20,
			failure,
		);
		expect(writes).toHaveLength(1);
		expect(failure).toHaveBeenCalledTimes(1);
	});
});

describe("os-notifier turn eligibility", () => {
	it("allows direct interactive TUI runs only", () => {
		const state = new TurnEligibility();
		state.input("interactive");
		state.prepare();
		state.start("tui");
		expect(state.settle()).toBe(true);

		state.input("rpc");
		state.prepare();
		state.start("tui");
		expect(state.settle()).toBe(false);

		state.input("interactive");
		state.prepare();
		state.start("rpc");
		state.input("interactive", "followUp");
		expect(state.settle()).toBe(false);
	});

	it("keeps eligibility bound to the accepted input that started the lifecycle", () => {
		const extensionRun = new TurnEligibility();
		extensionRun.input("extension");
		extensionRun.prepare();
		extensionRun.start("tui");
		extensionRun.input("interactive", "followUp");
		expect(extensionRun.settle()).toBe(false);

		const interactiveRun = new TurnEligibility();
		interactiveRun.input("interactive");
		interactiveRun.prepare();
		interactiveRun.start("tui");
		interactiveRun.input("extension", "followUp");
		expect(interactiveRun.settle()).toBe(true);
	});

	it("preserves eligibility across low-level retries until settlement", () => {
		const state = new TurnEligibility();
		state.input("interactive");
		state.prepare();
		state.start("tui");
		expect(state.isEligible()).toBe(true);
		state.start("tui");
		expect(state.settle()).toBe(true);
		expect(state.isEligible()).toBe(false);
	});

	it("accepts input prepared after a pre-prompt compaction run starts", () => {
		const state = new TurnEligibility();
		state.input("interactive");
		state.start("tui");
		state.prepare();
		expect(state.settle()).toBe(true);
	});

	it("does not consume handled interactive input for an extension-triggered run", () => {
		const state = new TurnEligibility();
		state.input("interactive");
		state.start("tui");
		expect(state.settle()).toBe(false);

		state.prepare();
		state.start("tui");
		expect(state.settle()).toBe(false);
	});
});

describe("os-notifier payload", () => {
	it("includes bounded context when Pi needs input", () => {
		expect(
			agentWaitingNotification(
				{ kind: "confirm", title: "Allow destructive operation?" },
				{
					cwd: "/home/oli/Workspace/dotfiles",
					branch: "trunk",
					sessionName: "notifier work",
				},
			),
		).toEqual({
			title: "Pi needs input",
			message:
				"Confirmation • Allow destructive operation? • dotfiles • trunk • notifier work",
			severity: "warning",
		});
	});

	it("includes rich bounded context in completion body", () => {
		expect(
			agentDoneNotification(
				{
					aborted: false,
					messages: [
						{ role: "user", content: "implement richer Pi notifications" },
						{ role: "assistant", stopReason: "stop" },
					],
				},
				{
					cwd: "/home/oli/Workspace/dotfiles/pi-ext",
					model: { name: "GPT-5.5", provider: "openai", id: "gpt-5.5" },
					durationMs: 45_000,
					branch: "trunk",
					sessionName: "notifier work",
				},
			),
		).toEqual({
			title: "Pi finished",
			message:
				"Ready after 45s • pi-ext • trunk • notifier work • GPT-5.5\n↳ implement richer Pi notifications",
			severity: "info",
		});
	});

	it("sanitizes and bounds prompt and error detail", () => {
		const notification = agentDoneNotification(
			{
				aborted: false,
				messages: [
					{ role: "user", content: `prompt\u0000\u009d${"x".repeat(100)}` },
					{
						role: "assistant",
						stopReason: "error",
						errorMessage: `failure\n${"y".repeat(200)}`,
					},
				],
			},
			{},
		);
		expect(notification.title).toBe("Pi failed");
		expect(notification.severity).toBe("error");
		expect(notification.message).not.toContain("\u0000");
		expect(notification.message).not.toContain("\u009d");
		expect(notification.message.match(/\n/gu)).toHaveLength(1);
		expect(notification.message.length).toBeLessThan(240);
		expect(notification.message).toContain("…");
	});

	it("retains the accepted prompt when a final retry event has no user message", () => {
		const notification = agentDoneNotification(
			{ aborted: false, messages: [{ role: "assistant", stopReason: "stop" }] },
			{ prompt: "original interactive prompt" },
		);
		expect(notification.message).toContain("↳ original interactive prompt");
	});

	it("classifies aborted settlements without assistant messages as warnings", () => {
		expect(agentDoneNotification({ aborted: true }, {})).toMatchObject({
			title: "Pi stopped",
			severity: "warning",
		});
	});

	it("ignores a stale assistant abort when settlement was not aborted", () => {
		expect(
			agentDoneNotification(
				{
					aborted: false,
					messages: [{ role: "assistant", stopReason: "aborted" }],
				},
				{},
			),
		).toMatchObject({ title: "Pi finished", severity: "info" });
	});

	it("gives authoritative cancellation precedence over an assistant error", () => {
		expect(
			agentDoneNotification(
				{
					aborted: true,
					messages: [{ role: "assistant", stopReason: "error" }],
				},
				{},
			),
		).toMatchObject({ title: "Pi stopped", severity: "warning" });
	});
});

describe("os-notifier contract", () => {
	let session: PiSession | undefined;
	const focusState = (
		globalThis as unknown as {
			__bugabinga_pi_focus: { focused: boolean };
		}
	).__bugabinga_pi_focus;
	const originalFocus = focusState.focused;

	afterEach(async () => {
		focusState.focused = originalFocus;
		await session?.dispose();
		session = undefined;
	});

	it("writes enabled lifecycle debug records in an isolated sandbox", async () => {
		const root = mkdtempSync(join(tmpdir(), "pi-ext-os-notifier-debug-"));
		try {
			session = await createPiSession({
				cwd: root,
				env: {
					PI_OS_NOTIFIER_DEBUG: "1",
					XDG_STATE_HOME: join(root, "state"),
				},
				extensionPaths: [extensionPath()],
			});
			await session.session.extensionRunner.emit({
				type: "session_start",
				reason: "startup",
			});
			await session.session.extensionRunner.emit({
				type: "session_shutdown",
				reason: "quit",
			});
			const events = readdirSync(
				join(root, "state", "pi-ext", "debug", "os-notifier"),
			).flatMap((file) =>
				readFileSync(
					join(root, "state", "pi-ext", "debug", "os-notifier", file),
					"utf8",
				)
					.trim()
					.split("\n")
					.map((line) => JSON.parse(line)),
			);
			expect(events).toEqual(
				expect.arrayContaining([
					expect.objectContaining({
						extension: "os-notifier",
						event: "session.start",
					}),
					expect.objectContaining({
						extension: "os-notifier",
						event: "session.shutdown",
					}),
				]),
			);
		} finally {
			await session?.dispose();
			session = undefined;
			rmSync(root, { recursive: true, force: true });
		}
	});

	it.skipIf(process.platform !== "linux")(
		"records detached fallback delivery after shutdown",
		async () => {
			const root = mkdtempSync(join(tmpdir(), "pi-ext-os-notifier-debug-"));
			try {
				session = await createPiSession({
					cwd: root,
					env: {
						PI_OS_NOTIFIER_DEBUG: "1",
						XDG_STATE_HOME: join(root, "state"),
					},
					extensionPaths: [extensionPath()],
				});
				const bin = join(root, "bin");
				mkdirSync(bin);
				writeFileSync(
					join(bin, "notify-send"),
					`#!${process.execPath}\nsetTimeout(() => process.exit(1), 250);\n`,
				);
				chmodSync(join(bin, "notify-send"), 0o755);
				process.env["PATH"] = `${bin}${delimiter}${process.env["PATH"] ?? ""}`;
				session.session.extensionRunner.setUIContext(
					session.session.extensionRunner.getUIContext(),
					"tui",
				);
				focusState.focused = false;
				await emitCompletion(session, "interactive");
				const readEvents = () =>
					readdirSync(
						join(root, "state", "pi-ext", "debug", "os-notifier"),
					).flatMap((file) =>
						readFileSync(
							join(root, "state", "pi-ext", "debug", "os-notifier", file),
							"utf8",
						)
							.trim()
							.split("\n")
							.filter(Boolean)
							.map((line) => JSON.parse(line)),
					);
				await session.session.extensionRunner.emit({
					type: "session_shutdown",
					reason: "quit",
				});
				expect(
					readEvents().some(
						(event) => event.event === "notification.deliver.finish",
					),
				).toBe(false);
				await waitFor(() =>
					readEvents().some(
						(event) =>
							event.event === "notification.deliver.finish" &&
							event.outcome === "fallback",
					),
				);
				const terminal = readEvents().filter(
					(event) => event.event === "notification.deliver.finish",
				);
				expect(terminal).toEqual([
					expect.objectContaining({ kind: "delivery", outcome: "fallback" }),
				]);
			} finally {
				await session?.dispose();
				session = undefined;
				rmSync(root, { recursive: true, force: true });
			}
		},
	);

	it.skipIf(process.platform === "win32")(
		"delivers Termux completion and input alerts through real CLI, suppresses focused work",
		async () => {
			session = await createPiSession({
				extensionPaths: [extensionPath()],
				env: { TERMUX_VERSION: "test" },
			});
			const bin = join(session.cwd, "bin");
			const log = join(session.cwd, "termux.log");
			mkdirSync(bin);
			const executable = join(bin, "termux-notification");
			writeFileSync(
				executable,
				`#!${process.execPath}\nrequire("node:fs").appendFileSync(process.env.TEST_NOTIFY_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");\n`,
			);
			chmodSync(executable, 0o755);
			const vibrator = join(bin, "termux-vibrate");
			const vibrationLog = join(session.cwd, "vibration.log");
			writeFileSync(
				vibrator,
				`#!${process.execPath}\nrequire("node:fs").appendFileSync(process.env.TEST_VIBRATE_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");\n`,
			);
			chmodSync(vibrator, 0o755);
			process.env["TEST_VIBRATE_LOG"] = vibrationLog;
			process.env["PATH"] = bin;
			process.env["TEST_NOTIFY_LOG"] = log;
			const focusLog = join(session.cwd, "focus.log");
			const appProcess = join(bin, "app_process");
			writeFileSync(
				appProcess,
				`#!${process.execPath}\nrequire("node:fs").appendFileSync(process.env.TEST_FOCUS_LOG, JSON.stringify({args: process.argv.slice(2), classpath: process.env.CLASSPATH, preload: process.env.LD_PRELOAD, libraries: process.env.LD_LIBRARY_PATH}) + "\\n");process.exit(Number(process.env.TEST_FOCUS_EXIT));\n`,
			);
			chmodSync(appProcess, 0o755);
			process.env["TEST_FOCUS_LOG"] = focusLog;
			process.env["TEST_FOCUS_EXIT"] = "0";
			const ui = session.session.extensionRunner.getUIContext();
			const notify = vi.fn();
			session.session.extensionRunner.setUIContext({ ...ui, notify }, "tui");
			await session.session.extensionRunner.emit({
				type: "session_start",
				reason: "startup",
			});
			expect(notify).not.toHaveBeenCalled();
			expect(() => readFileSync(focusLog, "utf8")).toThrow();
			focusState.focused = true;
			await emitCompletion(session, "interactive");
			await waitFor(
				() => readFileSync(log, "utf8").trim().split("\n").length === 1,
			);
			const args = JSON.parse(readFileSync(log, "utf8").trim());
			expect(args).toContain("Pi finished");
			expect(args).not.toContain("--vibrate");
			await waitFor(
				() =>
					readFileSync(vibrationLog, "utf8").trim().split("\n").length === 1,
			);
			expect(JSON.parse(readFileSync(vibrationLog, "utf8").trim())).toEqual([
				"-d",
				"500",
			]);
			process.env["TEST_FOCUS_EXIT"] = "3";
			await emitCompletion(session, "interactive");
			process.env["TEST_FOCUS_EXIT"] = "0";
			await emitCompletion(session, "extension");
			await session.session.extensionRunner.emit({
				type: "input",
				text: "question",
				source: "interactive",
			} as never);
			await session.session.extensionRunner.emitBeforeAgentStart(
				{ messageType: "user", prompt: "question" },
				{ cwd: session.cwd },
			);
			await session.session.extensionRunner.emit({
				type: "agent_start",
			} as never);
			await session.session.extensionRunner.emit({
				type: "ui_prompt_start",
				kind: "confirm",
				title: "Continue?",
			} as never);
			await waitFor(
				() => readFileSync(log, "utf8").trim().split("\n").length === 2,
			);
			expect(
				JSON.parse(readFileSync(log, "utf8").trim().split("\n")[1]!),
			).toContain("Pi needs input");
			await waitFor(
				() =>
					readFileSync(vibrationLog, "utf8").trim().split("\n").length === 2,
			);
			const queries = readFileSync(focusLog, "utf8")
				.trim()
				.split("\n")
				.map((line) => JSON.parse(line));
			expect(queries).toHaveLength(3);
			expect(queries[0]).toEqual({
				args: ["/", "F"],
				classpath: expect.stringContaining("android/focus.jar"),
			});
		},
	);

	it("warns once when Termux CLI is missing", async () => {
		session = await createPiSession({
			extensionPaths: [extensionPath()],
			env: { TERMUX_VERSION: "test", PATH: "" },
		});
		const notify = vi.fn();
		session.session.extensionRunner.setUIContext(
			{ ...session.session.extensionRunner.getUIContext(), notify },
			"tui",
		);
		await session.session.extensionRunner.emit({
			type: "session_start",
			reason: "startup",
		});
		await session.session.extensionRunner.emit({
			type: "session_start",
			reason: "startup",
		});
		expect(notify).toHaveBeenCalledTimes(1);
		expect(notify).toHaveBeenCalledWith(
			expect.stringContaining(
				"termux-notification, termux-vibrate is missing from PATH",
			),
			"warning",
		);
	});

	it.skipIf(process.platform === "win32")(
		"warns once for exit-zero Termux permission errors",
		async () => {
			session = await createPiSession({
				extensionPaths: [extensionPath()],
				env: { TERMUX_VERSION: "test" },
			});
			const bin = join(session.cwd, "bin");
			mkdirSync(bin);
			const executable = join(bin, "termux-notification");
			writeFileSync(
				executable,
				`#!${process.execPath}\nconsole.log(JSON.stringify({error: "Permission denied"}));\n`,
			);
			chmodSync(executable, 0o755);
			writeFileSync(
				join(bin, "termux-vibrate"),
				`#!${process.execPath}\nprocess.exit(0);\n`,
			);
			chmodSync(join(bin, "termux-vibrate"), 0o755);
			writeFileSync(
				join(bin, "app_process"),
				`#!${process.execPath}\nprocess.exit(0);\n`,
			);
			chmodSync(join(bin, "app_process"), 0o755);
			process.env["PATH"] = bin;
			const notify = vi.fn();
			session.session.extensionRunner.setUIContext(
				{ ...session.session.extensionRunner.getUIContext(), notify },
				"tui",
			);
			focusState.focused = false;
			await emitCompletion(session, "interactive");
			await waitFor(() => notify.mock.calls.length === 1);
			await emitCompletion(session, "interactive");
			await new Promise((resolve) => setTimeout(resolve, 100));
			expect(notify).toHaveBeenCalledTimes(1);
			expect(notify).toHaveBeenCalledWith(
				expect.stringContaining("enable Android notifications for Termux:API"),
				"warning",
			);
		},
	);

	it.skipIf(process.platform === "win32").each([
		[
			"API error",
			'console.log(JSON.stringify({error: "Permission denied"}))',
			5000,
		],
		["nonzero exit", "process.exit(1)", 5000],
		["timeout", "setInterval(() => {}, 1000)", 20],
	])(
		"reports direct vibration failure: %s",
		async (_label, script, timeout) => {
			session = await createPiSession({ extensionPaths: [extensionPath()] });
			const bin = join(session.cwd, "bin");
			mkdirSync(bin);
			const executable = join(bin, "termux-vibrate");
			writeFileSync(executable, `#!${process.execPath}\n${script}\n`);
			chmodSync(executable, 0o755);
			process.env["PATH"] = bin;
			expect(await deliverVibration(Number(timeout))).toBe(false);
		},
	);

	it.skipIf(process.platform === "win32").each([0, 3, 1, 2, 4])(
		"interprets Android focus exit code %s",
		async (code) => {
			session = await createPiSession({ extensionPaths: [extensionPath()] });
			const bin = join(session.cwd, "bin");
			mkdirSync(bin);
			writeFileSync(
				join(bin, "app_process"),
				`#!${process.execPath}\nprocess.exit(${code});\n`,
			);
			chmodSync(join(bin, "app_process"), 0o755);
			process.env["PATH"] = bin;
			expect(await termuxFocused()).toBe(
				code === 0 ? false : code === 3 ? true : undefined,
			);
		},
	);

	it.skipIf(process.platform === "win32")(
		"fails closed on Android focus timeout",
		async () => {
			session = await createPiSession({ extensionPaths: [extensionPath()] });
			const bin = join(session.cwd, "bin");
			mkdirSync(bin);
			writeFileSync(
				join(bin, "app_process"),
				`#!${process.execPath}\nsetInterval(() => {}, 1000);\n`,
			);
			chmodSync(join(bin, "app_process"), 0o755);
			process.env["PATH"] = bin;
			expect(await termuxFocused(20)).toBeUndefined();
		},
	);

	it.skipIf(process.platform === "win32")(
		"suppresses alerts and deduplicates Android focus failures",
		async () => {
			session = await createPiSession({
				extensionPaths: [extensionPath()],
				env: { TERMUX_VERSION: "test" },
			});
			const bin = join(session.cwd, "bin");
			mkdirSync(bin);
			writeFileSync(
				join(bin, "app_process"),
				`#!${process.execPath}\nprocess.exit(2);\n`,
			);
			chmodSync(join(bin, "app_process"), 0o755);
			const log = join(session.cwd, "delivery.log");
			for (const name of ["termux-notification", "termux-vibrate"]) {
				writeFileSync(
					join(bin, name),
					`#!${process.execPath}\nrequire("node:fs").appendFileSync(process.env.TEST_NOTIFY_LOG, "delivery");\n`,
				);
				chmodSync(join(bin, name), 0o755);
			}
			process.env["PATH"] = bin;
			process.env["TEST_NOTIFY_LOG"] = log;
			const notify = vi.fn();
			session.session.extensionRunner.setUIContext(
				{ ...session.session.extensionRunner.getUIContext(), notify },
				"tui",
			);
			await emitCompletion(session, "interactive");
			await emitCompletion(session, "interactive");
			expect(notify).toHaveBeenCalledTimes(1);
			expect(notify).toHaveBeenCalledWith(
				expect.stringContaining("Android focus query failed"),
				"warning",
			);
			expect(() => readFileSync(log, "utf8")).toThrow();
		},
	);

	it
		.skipIf(process.platform === "win32")
		.each(["prompt-end", "new-turn", "shutdown"])(
		"discards stale focus result after %s",
		async (change) => {
			session = await createPiSession({
				extensionPaths: [extensionPath()],
				env: { TERMUX_VERSION: "test" },
			});
			const bin = join(session.cwd, "bin");
			mkdirSync(bin);
			const query = join(session.cwd, "query.log");
			const delivery = join(session.cwd, "delivery.log");
			writeFileSync(
				join(bin, "app_process"),
				`#!${process.execPath}\nrequire("node:fs").writeFileSync(process.env.TEST_FOCUS_LOG, "query");setTimeout(() => process.exit(0), 300);\n`,
			);
			chmodSync(join(bin, "app_process"), 0o755);
			for (const name of ["termux-notification", "termux-vibrate"]) {
				writeFileSync(
					join(bin, name),
					`#!${process.execPath}\nrequire("node:fs").writeFileSync(process.env.TEST_NOTIFY_LOG, "delivery");\n`,
				);
				chmodSync(join(bin, name), 0o755);
			}
			process.env["PATH"] = bin;
			process.env["TEST_FOCUS_LOG"] = query;
			process.env["TEST_NOTIFY_LOG"] = delivery;
			session.session.extensionRunner.setUIContext(
				session.session.extensionRunner.getUIContext(),
				"tui",
			);
			await emitCompletion(session, "interactive", false);
			const pending = session.session.extensionRunner.emit(
				change === "prompt-end"
					? ({
							type: "ui_prompt_start",
							kind: "confirm",
							title: "Continue?",
						} as never)
					: { type: "agent_settled", aborted: false },
			);
			await waitFor(() => readFileSync(query, "utf8") === "query");
			await session.session.extensionRunner.emit(
				change === "prompt-end"
					? ({
							type: "ui_prompt_end",
							kind: "confirm",
							title: "Continue?",
						} as never)
					: change === "new-turn"
						? ({ type: "agent_start" } as never)
						: { type: "session_shutdown", reason: "quit" },
			);
			await pending;
			expect(() => readFileSync(delivery, "utf8")).toThrow();
		},
	);

	it("reports missing direct vibration CLI", async () => {
		session = await createPiSession({
			extensionPaths: [extensionPath()],
			env: { PATH: "" },
		});
		expect(await deliverVibration()).toBe(false);
	});

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

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

	it.skipIf(process.platform !== "linux")(
		"notifies once per uninterrupted unfocused interactive prompt wait",
		async () => {
			session = await createPiSession({ extensionPaths: [extensionPath()] });
			const bin = join(session.cwd, "bin");
			const log = join(session.cwd, "notify.log");
			const executable = join(bin, "notify-send");
			mkdirSync(bin);
			writeFileSync(
				executable,
				`#!${process.execPath}\nrequire("node:fs").appendFileSync(process.env.TEST_NOTIFY_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");\n`,
			);
			chmodSync(executable, 0o755);
			process.env["PATH"] = `${bin}${delimiter}${process.env["PATH"] ?? ""}`;
			process.env["TEST_NOTIFY_LOG"] = log;
			session.session.extensionRunner.setUIContext(
				session.session.extensionRunner.getUIContext(),
				"tui",
			);
			focusState.focused = false;

			await session.session.extensionRunner.emit({
				type: "input",
				text: "test notification",
				source: "interactive",
			} as never);
			await session.session.extensionRunner.emitBeforeAgentStart(
				{ messageType: "user", prompt: "test notification" },
				{ cwd: session.cwd },
			);
			await session.session.extensionRunner.emit({
				type: "agent_start",
			} as never);
			const prompt = {
				type: "ui_prompt_start",
				reason: "ui_prompt",
				kind: "confirm",
				title: "Continue?",
			} as const;
			await session.session.extensionRunner.emit(prompt as never);
			await waitFor(
				() => readFileSync(log, "utf8").trim().split("\n").length === 1,
			);
			await session.session.extensionRunner.emit(prompt as never);
			await new Promise((resolve) => setTimeout(resolve, 50));
			expect(readFileSync(log, "utf8").trim().split("\n")).toHaveLength(1);

			await session.session.extensionRunner.emit({
				type: "ui_prompt_end",
				reason: "ui_prompt",
				kind: "confirm",
				title: "Continue?",
			} as never);
			await session.session.extensionRunner.emit(prompt as never);
			await waitFor(
				() => readFileSync(log, "utf8").trim().split("\n").length === 2,
			);
			const args = JSON.parse(readFileSync(log, "utf8").trim().split("\n")[0]!);
			expect(args).toContain("Pi needs input");
			expect(args.join(" ")).toContain("Confirmation • Continue?");
		},
	);

	it.each([
		{ aborted: true, finalMessage: false, title: "Pi stopped" },
		{ aborted: false, finalMessage: true, title: "Pi finished" },
	])(
		"uses settled cancellation status: $title",
		async ({ aborted, finalMessage, title }) => {
			session = await createPiSession({ extensionPaths: [extensionPath()] });
			process.env["PATH"] = "";
			const writes: string[] = [];
			const write = vi
				.spyOn(process.stdout, "write")
				.mockImplementation((chunk) => {
					writes.push(String(chunk));
					return true;
				});
			try {
				const runner = session.session.extensionRunner;
				runner.setUIContext(runner.getUIContext(), "tui");
				focusState.focused = false;
				await runner.emitInput("test notification", undefined, "interactive");
				await runner.emitBeforeAgentStart(
					{ messageType: "user", prompt: "test notification" },
					{ cwd: session.cwd },
				);
				await runner.emit({ type: "agent_start" });
				if (finalMessage) {
					await runner.emit({
						type: "agent_end",
						messages: [{ role: "assistant", stopReason: "aborted" }],
					} as never);
				}
				await runner.emit({ type: "agent_settled", aborted });
				await waitFor(() => writes.some((value) => value.includes(title)));
				expect(writes.join("")).toContain("test notification");
				expect(writes.join("")).not.toContain(
					aborted ? "Pi finished" : "Pi stopped",
				);
			} finally {
				write.mockRestore();
			}
		},
	);

	it.skipIf(process.platform !== "linux")(
		"uses native delivery only for unfocused interactive TUI runs",
		async () => {
			session = await createPiSession({ extensionPaths: [extensionPath()] });
			const bin = join(session.cwd, "bin");
			const log = join(session.cwd, "notify.log");
			const executable = join(bin, "notify-send");
			mkdirSync(bin);
			writeFileSync(
				executable,
				`#!${process.execPath}\nrequire("node:fs").appendFileSync(process.env.TEST_NOTIFY_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");\n`,
			);
			chmodSync(executable, 0o755);
			process.env["PATH"] = `${bin}${delimiter}${process.env["PATH"] ?? ""}`;
			process.env["TEST_NOTIFY_LOG"] = log;
			session.session.extensionRunner.setUIContext(
				session.session.extensionRunner.getUIContext(),
				"tui",
			);

			focusState.focused = false;
			await emitCompletion(session, "interactive", false);
			await new Promise((resolve) => setTimeout(resolve, 50));
			expect(() => readFileSync(log, "utf8")).toThrow();
			await session.session.extensionRunner.emit({
				type: "agent_settled",
				aborted: false,
			});
			await waitFor(() => readFileSync(log, "utf8").trim().length > 0);
			const first = readFileSync(log, "utf8").trim().split("\n");
			expect(JSON.parse(first[0]!)).toContain("Pi finished");

			focusState.focused = true;
			await emitCompletion(session, "interactive");

			focusState.focused = false;
			await emitCompletion(session, "extension");
			session.session.extensionRunner.setUIContext(
				session.session.extensionRunner.getUIContext(),
				"rpc",
			);
			await emitCompletion(session, "interactive");

			await new Promise((resolve) => setTimeout(resolve, 50));
			expect(readFileSync(log, "utf8").trim().split("\n")).toHaveLength(1);
		},
	);
});

async function emitCompletion(
	session: PiSession,
	source: "interactive" | "rpc" | "extension",
	settle = true,
): Promise<void> {
	await session.session.extensionRunner.emit({
		type: "input",
		text: "test notification",
		source,
	} as never);
	await session.session.extensionRunner.emitBeforeAgentStart(
		{ messageType: "user", prompt: "test notification" },
		{ cwd: session.cwd },
	);
	await session.session.extensionRunner.emit({ type: "agent_start" } as never);
	await session.session.extensionRunner.emit({
		type: "agent_end",
		messages: [
			{ role: "user", content: "test notification" },
			{ role: "assistant", stopReason: "stop" },
		],
	} as never);
	if (settle) {
		await session.session.extensionRunner.emit({
			type: "agent_settled",
			aborted: false,
		});
	}
}

async function waitFor(
	assertion: () => boolean,
	timeoutMs = 2000,
): Promise<void> {
	const deadline = Date.now() + timeoutMs;
	while (Date.now() < deadline) {
		try {
			if (assertion()) return;
		} catch {
			// File may not exist until the child process starts.
		}
		await new Promise((resolve) => setTimeout(resolve, 10));
	}
	throw new Error("timed out waiting for native notification executable");
}