repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/os-notifier/__tests__/contract.test.ts
Rawimport {
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");
}