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[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 { 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 { 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"); }