@maxiaochao/pi-toolkit 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { BackgroundTasks } from "../src/background-tasks.ts";
5
+
6
+ export class FakeClock {
7
+ time = 1000;
8
+ next = 0;
9
+ timers = new Map();
10
+ now = () => this.time;
11
+ setTimeout = (callback, delay) => {
12
+ const id = ++this.next;
13
+ this.timers.set(id, { at: this.time + delay, callback });
14
+ return id;
15
+ };
16
+ clearTimeout = (id) => this.timers.delete(id);
17
+ advance(ms) {
18
+ const target = this.time + ms;
19
+ for (;;) {
20
+ const due = [...this.timers].filter(([, t]) => t.at <= target).sort((a, b) => a[1].at - b[1].at)[0];
21
+ if (!due) break;
22
+ this.time = due[1].at;
23
+ this.timers.delete(due[0]);
24
+ due[1].callback();
25
+ }
26
+ this.time = target;
27
+ }
28
+ }
29
+
30
+ export function fixture(options = {}) {
31
+ const root = mkdtempSync(join(tmpdir(), "pi-bg-test-"));
32
+ const clock = new FakeClock();
33
+ const calls = [];
34
+ const completed = [];
35
+ const operations = {
36
+ exec(command, cwd, opts) {
37
+ return new Promise((resolve, reject) => {
38
+ calls.push({ command, cwd, ...opts, resolve: (exitCode = 0) => resolve({ exitCode }), reject });
39
+ });
40
+ },
41
+ };
42
+ const tasks = new BackgroundTasks({ operations, clock, logRoot: root, onComplete: (task) => completed.push(task), ...options });
43
+ return {
44
+ root, clock, calls, completed, tasks, operations,
45
+ async close() {
46
+ for (const call of calls) call.resolve();
47
+ await tasks.shutdown();
48
+ rmSync(root, { recursive: true, force: true });
49
+ },
50
+ };
51
+ }
52
+
53
+ export async function flush() {
54
+ for (let i = 0; i < 8; i++) await Promise.resolve();
55
+ }
@@ -0,0 +1,28 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { piBinary } from "./runtime.mjs";
8
+
9
+ const directory = mkdtempSync(join(tmpdir(), "pi-bg-load-"));
10
+ const pkg = fileURLToPath(new URL("..", import.meta.url));
11
+ const toolkit = process.argv.includes("--toolkit");
12
+ const source = toolkit ? resolve(pkg, "../..") : resolve(pkg, "extensions/background-tasks.ts");
13
+ try {
14
+ writeFileSync(join(directory, "settings.json"), JSON.stringify({ quietStartup: true, defaultTools: ["+codemode"] }));
15
+ const output = execFileSync(piBinary, [
16
+ "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files",
17
+ "--no-session", "--offline", "-e", source,
18
+ "-e", resolve(pkg, "tests/fixtures/provider.ts"), "--model", "bg-fixture/deterministic", "-p", "ping",
19
+ ], { cwd: directory, encoding: "utf8", timeout: 30000, env: { ...process.env, PI_CODING_AGENT_DIR: directory, PI_OFFLINE: "1", BG_SMOKE_EVENTS: join(directory, "events.jsonl") }, stdio: ["ignore", "pipe", "pipe"] });
20
+ assert.match(output, /Main agent is ready/);
21
+ assert.doesNotMatch(output, /Failed to load|Error loading/);
22
+ const events = readFileSync(join(directory, "events.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line));
23
+ const loaded = events.find((entry) => entry.event === "loaded");
24
+ assert.equal(loaded.tools.filter((name) => name === "background_task").length, 1, "the real host registered exactly one background-task tool");
25
+ const request = events.find((entry) => entry.event === "request");
26
+ assert.equal(request.tools.filter((name) => name === "background_task").length, 1, "background_task is active in the model request without manual activation");
27
+ console.log(`Real Pi loaded ${toolkit ? "the toolkit default loadout" : "the standalone background-task extension"} with exactly one background-task tool and an offline fixture provider.`);
28
+ } finally { rmSync(directory, { recursive: true, force: true }); }
@@ -0,0 +1,55 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createBashTool } from "@earendil-works/pi-coding-agent";
6
+ import { fixture, flush } from "./helpers.mjs";
7
+
8
+ const cases = [
9
+ ["empty", Buffer.alloc(0)],
10
+ ["small", Buffer.from("first\nsecond\n")],
11
+ ["no-newline", Buffer.from("last")],
12
+ ["line-limit", Buffer.from(Array.from({ length: 2500 }, (_, i) => `line ${i}`).join("\n"))],
13
+ ["byte-limit", Buffer.from(("x".repeat(200) + "\n").repeat(500))],
14
+ ["long-last-line", Buffer.from("before\n" + "z".repeat(300000))],
15
+ ["long-line-with-newline", Buffer.from("z".repeat(150000) + "\n")],
16
+ ["huge-utf8", Buffer.from("中文🙂\n".repeat(160000))],
17
+ ["utf8-long-line", Buffer.from("中文🙂".repeat(130000))],
18
+ ["invalid-utf8", Buffer.from([65, 0xf0, 0x9f])],
19
+ ];
20
+ for (const [name, data] of cases) {
21
+ test(`output matches native bash model/structured views: ${name}`, async () => {
22
+ const f = fixture();
23
+ try {
24
+ const path = join(f.root, "fixture.log");
25
+ writeFileSync(path, data);
26
+ const native = await createBashTool(f.root).execute("reference", { command: `cat '${path}'` });
27
+ const job = f.tasks.start({ command: "fixture" }, f.root);
28
+ // Deliberately split multi-byte UTF-8 across chunks.
29
+ for (let offset = 0; offset < data.length; offset += 8191) f.calls[0].onData(data.subarray(offset, offset + 8191));
30
+ f.calls[0].resolve();
31
+ await flush();
32
+ const actual = f.tasks.output(job.id);
33
+ const normalize = (text) => text.replace(/Full output: [^\]]+/g, "Full output: <log>");
34
+ assert.equal(normalize(actual.text), normalize(native.content[0].text));
35
+ assert.equal(actual.output, native.structuredContent.output);
36
+ assert.equal(actual.truncated, native.structuredContent.truncated);
37
+ assert.equal(actual.capture_complete, true);
38
+ assert.ok(Buffer.byteLength(actual.truncation.content) <= 51200);
39
+ assert.ok(Buffer.byteLength(actual.output) < 1024 * 1024 + 100);
40
+ } finally { await f.close(); }
41
+ });
42
+ }
43
+
44
+ test("running output is a consistent snapshot and does not flush incomplete UTF-8", async () => {
45
+ const f = fixture();
46
+ try {
47
+ const job = f.tasks.start({ command: "stream" }, f.root);
48
+ const bytes = Buffer.from("中文");
49
+ f.calls[0].onData(bytes.subarray(0, 2));
50
+ assert.equal(f.tasks.output(job.id).output, "");
51
+ f.calls[0].onData(bytes.subarray(2));
52
+ assert.equal(f.tasks.output(job.id).output, "中文");
53
+ assert.equal(f.tasks.output(job.id).capture_complete, false);
54
+ } finally { await f.close(); }
55
+ });
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
8
+ import { BackgroundTasks } from "../src/background-tasks.ts";
9
+
10
+ async function waitFor(check) {
11
+ const deadline = Date.now() + 5000;
12
+ while (!check()) {
13
+ if (Date.now() > deadline) throw new Error("Real process did not reach expected state within 5 seconds");
14
+ await delay(20);
15
+ }
16
+ }
17
+ function alive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ // A reaped parent can temporarily leave a zombie child owned by PID 1.
21
+ if (existsSync(`/proc/${pid}/stat`) && readFileSync(`/proc/${pid}/stat`, "utf8").split(") ")[1].startsWith("Z ")) return false;
22
+ return true;
23
+ } catch (error) { if (error.code === "ESRCH") return false; throw error; }
24
+ }
25
+
26
+ test("native backend starts immediately and executes heredoc/PID/exit-file checks", async () => {
27
+ const root = mkdtempSync(join(tmpdir(), "pi-bg-process-"));
28
+ const completed = [];
29
+ const tasks = new BackgroundTasks({ operations: createLocalBashOperations(), onComplete: (task) => completed.push(task) });
30
+ try {
31
+ writeFileSync(join(root, "build.log"), "> Task :compile\nnoise\nBUILD SUCCESSFUL\n");
32
+ writeFileSync(join(root, "build.exit"), "0\n");
33
+ const task = tasks.start({ command: `sleep 1; python3 - <<'PY'
34
+ from pathlib import Path
35
+ p=Path('build.log')
36
+ lines=p.read_text(errors='replace').splitlines()
37
+ s=[x for x in lines if x.startswith('> Task') or 'BUILD SUCCESS' in x]
38
+ print('\\n'.join(s[-35:])); print('log bytes:',p.stat().st_size)
39
+ PY
40
+ ps -p $$ -o pid,etime; test ! -f build.exit || cat build.exit` }, root);
41
+ assert.equal(task.status, "running");
42
+ const other = tasks.start({ command: "printf concurrent" }, root);
43
+ await waitFor(() => tasks.get(other.id).status !== "running");
44
+ assert.equal(tasks.get(task.id).status, "running", "other work completed before the sleeping job");
45
+ await waitFor(() => tasks.get(task.id).status !== "running");
46
+ assert.equal(tasks.get(task.id).status, "succeeded");
47
+ assert.match(tasks.output(task.id).output, /> Task :compile\nBUILD SUCCESSFUL\nlog bytes:/);
48
+ assert.equal(completed.length, 2);
49
+ } finally { await tasks.shutdown(); rmSync(root, { recursive: true, force: true }); }
50
+ });
51
+
52
+ for (const stop of ["timeout", "cancel", "shutdown"]) {
53
+ test(`native ${stop} stops silent shell and its waiting child`, async () => {
54
+ const root = mkdtempSync(join(tmpdir(), "pi-bg-tree-"));
55
+ const tasks = new BackgroundTasks({ operations: createLocalBashOperations() });
56
+ let pids = [];
57
+ try {
58
+ const task = tasks.start({
59
+ command: `echo $$ > shell.pid; sleep 300 & echo $! > child.pid; wait`,
60
+ timeout: stop === "timeout" ? 1 : 30,
61
+ }, root);
62
+ await waitFor(() => existsSync(join(root, "child.pid")));
63
+ pids = ["shell.pid", "child.pid"].map((file) => Number(readFileSync(join(root, file), "utf8").trim()));
64
+ if (stop === "shutdown") await tasks.shutdown();
65
+ else if (stop === "cancel") await tasks.cancel(task.id);
66
+ else await waitFor(() => tasks.get(task.id).status !== "running");
67
+ await waitFor(() => pids.every((pid) => !alive(pid)));
68
+ assert.equal(tasks.get(task.id).status, stop === "timeout" ? "timed_out" : "cancelled");
69
+ } finally { await tasks.shutdown(); rmSync(root, { recursive: true, force: true }); }
70
+ });
71
+ }
72
+
73
+ test("silent busy loop times out without any agent polling or output", async () => {
74
+ const tasks = new BackgroundTasks({ operations: createLocalBashOperations() });
75
+ try {
76
+ const job = tasks.start({ command: "while :; do :; done", timeout: 1 }, process.cwd());
77
+ await waitFor(() => tasks.get(job.id).status !== "running");
78
+ assert.equal(tasks.get(job.id).status, "timed_out");
79
+ assert.equal(tasks.output(job.id).output, "");
80
+ } finally { await tasks.shutdown(); }
81
+ });
82
+
83
+ test("native large output truncates display, then stops at the 32 MiB disk budget", async () => {
84
+ const tasks = new BackgroundTasks({ operations: createLocalBashOperations() });
85
+ try {
86
+ const job = tasks.start({ command: "python3 -c \"import sys; sys.stdout.buffer.write(b'x' * (40 * 1024 * 1024))\"" }, process.cwd());
87
+ await waitFor(() => tasks.get(job.id).status !== "running");
88
+ const result = tasks.output(job.id);
89
+ assert.equal(result.status, "output_limit");
90
+ assert.equal(result.bytes, 32 * 1024 * 1024);
91
+ assert.equal(result.capture_complete, false);
92
+ assert.equal(result.truncated, true);
93
+ assert.ok(result.truncation.outputBytes <= 51200);
94
+ assert.ok(Buffer.byteLength(result.output) < 1024 * 1024 + 100);
95
+ } finally { await tasks.shutdown(); }
96
+ });
@@ -0,0 +1,30 @@
1
+ // Resolve host peers from the selected installed Pi; never install a second runtime.
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
4
+ import { registerHooks } from "node:module";
5
+ import { delimiter, dirname, join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ // npm prepends workspace .bin paths, which may contain an older development Pi.
9
+ const hostPath = (process.env.PATH ?? "").split(delimiter).filter((entry) => !entry.endsWith("node_modules/.bin")).join(delimiter);
10
+ const binary = process.env.PI_TEST_BINARY || execFileSync("sh", ["-c", "command -v pi"], { encoding: "utf8", env: { ...process.env, PATH: hostPath } }).trim();
11
+ let root = dirname(realpathSync(binary));
12
+ while (!existsSync(join(root, "package.json")) || JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name !== "@earendil-works/pi-coding-agent") {
13
+ const parent = dirname(root);
14
+ if (parent === root) throw new Error(`Cannot locate Pi runtime next to ${binary}`);
15
+ root = parent;
16
+ }
17
+ const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
18
+ const [major, minor] = version.split(".").map(Number);
19
+ if (major < 1 || (major === 1 && minor < 3)) throw new Error(`Background Tasks checks require Pi >=1.3.0; selected ${version} at ${binary}. Set PI_TEST_BINARY to a supported host.`);
20
+ export const piBinary = binary;
21
+ export const piEntry = join(root, "dist/index.js");
22
+ const hostURL = pathToFileURL(piEntry).href;
23
+ const peers = new Set(["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai", "@earendil-works/pi-tui", "typebox"]);
24
+ registerHooks({
25
+ resolve(specifier, context, nextResolve) {
26
+ return peers.has(specifier)
27
+ ? nextResolve(specifier, { ...context, parentURL: hostURL })
28
+ : nextResolve(specifier, context);
29
+ },
30
+ });
@@ -0,0 +1,201 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { existsSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { BackgroundTasks } from "../src/background-tasks.ts";
6
+ import { fixture, flush } from "./helpers.mjs";
7
+
8
+ test("start returns while execution is pending; two tasks own independent slots", async () => {
9
+ const f = fixture();
10
+ try {
11
+ const first = f.tasks.start({ command: "sleep 60; printf done" }, f.root);
12
+ const second = f.tasks.start({ command: "other", timeout: 60 }, f.root);
13
+ assert.equal(first.status, "running");
14
+ assert.equal(first.timeout, 1800);
15
+ assert.notEqual(first.id, second.id);
16
+ assert.equal(f.calls.length, 2);
17
+ assert.throws(() => f.tasks.start({ command: "third" }, f.root), /two|2.*running/i);
18
+ f.calls[0].onData(Buffer.from("hello\n"));
19
+ assert.equal(f.tasks.output(first.id).output, "hello\n");
20
+ f.calls[0].resolve();
21
+ await flush();
22
+ assert.equal(f.tasks.get(first.id).status, "succeeded");
23
+ assert.equal(f.completed.length, 1);
24
+ assert.equal(f.tasks.list().running, 1);
25
+ assert.equal(f.tasks.get(first.id).capture_complete, true);
26
+ assert.equal(readFileSync(first.log_path, "utf8"), "hello\n");
27
+ } finally { await f.close(); }
28
+ });
29
+
30
+ test("timeout records its cause, aborts without a model, and retains the slot until cleanup", async () => {
31
+ const f = fixture();
32
+ try {
33
+ const a = f.tasks.start({ command: "loop", timeout: 1 }, f.root);
34
+ f.tasks.start({ command: "second" }, f.root);
35
+ f.clock.advance(1000);
36
+ assert.equal(f.calls[0].signal.aborted, true);
37
+ assert.equal(f.tasks.get(a.id).status, "running");
38
+ assert.throws(() => f.tasks.start({ command: "third" }, f.root), /two|2.*running/i);
39
+ const cancel = f.tasks.cancel(a.id);
40
+ f.calls[0].reject(new Error("aborted"));
41
+ await cancel;
42
+ assert.equal(f.tasks.get(a.id).status, "timed_out");
43
+ assert.equal(f.completed.length, 1);
44
+ await f.tasks.cancel(a.id);
45
+ assert.equal(f.completed.length, 1);
46
+ } finally { await f.close(); }
47
+ });
48
+
49
+ test("invalid inputs have no execution or log side effects", async () => {
50
+ const f = fixture();
51
+ try {
52
+ for (const timeout of [0, -1, 1.5, 7201, Infinity, NaN, "30"]) {
53
+ assert.throws(() => f.tasks.start({ command: "x", timeout }, f.root), /timeout/i);
54
+ }
55
+ assert.throws(() => f.tasks.start({ command: " " }, f.root), /command/i);
56
+ assert.throws(() => f.tasks.start({ command: "x", cwd: "missing" }, f.root), /directory/i);
57
+ assert.throws(() => f.tasks.get("not-mine"), /unknown/i);
58
+ assert.equal(f.calls.length, 0);
59
+ assert.equal(f.tasks.list().total, 0);
60
+ } finally { await f.close(); }
61
+ });
62
+
63
+ test("shutdown is idempotent, suppresses notifications and removes only owned logs", async () => {
64
+ const f = fixture();
65
+ const task = f.tasks.start({ command: "pending" }, f.root);
66
+ const shutdown = f.tasks.shutdown();
67
+ assert.equal(f.calls[0].signal.aborted, true);
68
+ f.calls[0].resolve();
69
+ await shutdown;
70
+ assert.equal(f.completed.length, 0);
71
+ assert.equal(f.clock.timers.size, 0);
72
+ assert.equal(existsSync(task.log_path), false);
73
+ assert.equal(existsSync(f.root), true);
74
+ assert.throws(() => f.tasks.start({ command: "x" }, f.root), /closed/i);
75
+ await f.close();
76
+ });
77
+
78
+ for (const mode of ["task", "session"]) {
79
+ test(`${mode} byte budget stops only the overflowing task and marks captured output incomplete`, async () => {
80
+ const f = fixture({ limits: { taskBytes: mode === "task" ? 4 : 100, sessionBytes: mode === "session" ? 4 : 100 } });
81
+ try {
82
+ const job = f.tasks.start({ command: "large" }, f.root);
83
+ f.calls[0].onData(Buffer.from("1234567"));
84
+ assert.equal(f.calls[0].signal.aborted, true);
85
+ f.calls[0].reject(new Error("aborted"));
86
+ await flush();
87
+ const result = f.tasks.output(job.id);
88
+ assert.equal(result.status, "output_limit");
89
+ assert.equal(result.output, "1234");
90
+ assert.equal(result.bytes, 4);
91
+ assert.equal(result.capture_complete, false);
92
+ assert.equal(f.completed.length, 1);
93
+ if (mode === "session") assert.throws(() => f.tasks.start({ command: "more" }, f.root), /budget/i);
94
+ else assert.equal(f.tasks.start({ command: "more" }, f.root).status, "running");
95
+ } finally { await f.close(); }
96
+ });
97
+ }
98
+
99
+ test("log write failure aborts the job and reports bounded failure without escaping onData", async () => {
100
+ const f = fixture({ logWrite() { throw new Error("disk full " + "x".repeat(10000)); } });
101
+ try {
102
+ const job = f.tasks.start({ command: "write" }, f.root);
103
+ assert.doesNotThrow(() => f.calls[0].onData(Buffer.from("hello")));
104
+ assert.equal(f.calls[0].signal.aborted, true);
105
+ f.calls[0].reject(new Error("aborted"));
106
+ await flush();
107
+ assert.equal(f.tasks.get(job.id).status, "failed");
108
+ assert.match(f.tasks.get(job.id).reason, /disk full/);
109
+ assert.ok(f.tasks.get(job.id).reason.length <= 500);
110
+ assert.equal(f.tasks.get(job.id).capture_complete, false);
111
+ } finally { await f.close(); }
112
+ });
113
+
114
+ test("nonzero exit, null exit and spawn errors are final failures; first stop reason wins", async () => {
115
+ const f = fixture();
116
+ try {
117
+ for (const outcome of [12, null, "spawn failed"]) {
118
+ const task = f.tasks.start({ command: "failure" }, f.root);
119
+ const call = f.calls.at(-1);
120
+ if (typeof outcome === "string") call.reject(new Error(outcome));
121
+ else call.resolve(outcome);
122
+ await flush();
123
+ assert.equal(f.tasks.get(task.id).status, "failed");
124
+ }
125
+ const job = f.tasks.start({ command: "cancel race", timeout: 1 }, f.root);
126
+ const cancelled = f.tasks.cancel(job.id);
127
+ f.clock.advance(1000);
128
+ f.calls.at(-1).resolve(0);
129
+ assert.equal((await cancelled).status, "cancelled");
130
+ assert.equal(f.completed.length, 4);
131
+ } finally { await f.close(); }
132
+ });
133
+
134
+ test("history is bounded in list, but older task outputs remain addressable; previews are safe", async () => {
135
+ const f = fixture();
136
+ try {
137
+ let first;
138
+ for (let i = 0; i < 25; i++) {
139
+ const job = f.tasks.start({ command: "x".repeat(10000), description: "\u001b]0;bad\u0007hello\n" + "中".repeat(300), timeout: 7200 }, f.root);
140
+ first ??= job;
141
+ assert.ok(Array.from(job.description).length <= 200);
142
+ assert.ok(!/[\x00-\x1f]/.test(job.description));
143
+ f.calls.at(-1).resolve();
144
+ await flush();
145
+ }
146
+ assert.equal(f.tasks.list().tasks.length, 20);
147
+ assert.equal(f.tasks.list().total, 25);
148
+ assert.equal(f.tasks.list().omitted, 5);
149
+ assert.equal(f.tasks.output(first.id).status, "succeeded");
150
+ } finally { await f.close(); }
151
+ });
152
+
153
+ test("explicit null timeout is not an unlimited/default timeout", async () => {
154
+ const f = fixture();
155
+ try { assert.throws(() => f.tasks.start({ command: "x", timeout: null }, f.root), /timeout/i); }
156
+ finally { await f.close(); }
157
+ });
158
+
159
+ test("a log failure while cancellation drains output preserves the stop cause and reports lost capture", async () => {
160
+ const f = fixture({ logWrite() { throw new Error("disk full while draining"); } });
161
+ try {
162
+ const job = f.tasks.start({ command: "write" }, f.root);
163
+ const cancel = f.tasks.cancel(job.id);
164
+ f.calls[0].onData(Buffer.from("buffered output"));
165
+ f.calls[0].reject(new Error("aborted"));
166
+ const result = await cancel;
167
+ assert.equal(result.status, "cancelled");
168
+ assert.equal(result.capture_complete, false);
169
+ assert.match(result.reason, /disk full while draining/);
170
+ } finally { await f.close(); }
171
+ });
172
+
173
+ test("short OS writes preserve all UTF-8 output in owner-only logs", async () => {
174
+ const f = fixture({ logWrite: (fd, data) => writeSync(fd, data.subarray(0, 2)) });
175
+ try {
176
+ const job = f.tasks.start({ command: "unicode" }, f.root);
177
+ f.calls[0].onData(Buffer.from("中文🙂\n"));
178
+ f.calls[0].resolve();
179
+ await flush();
180
+ assert.equal(f.tasks.output(job.id).output, "中文🙂\n");
181
+ assert.equal(statSync(job.log_path).mode & 0o777, 0o600);
182
+ assert.equal(statSync(dirname(job.log_path)).mode & 0o777, 0o700);
183
+ } finally { await f.close(); }
184
+ });
185
+
186
+ test("log creation failure rejects before execution; missing active logs abort visibly", async () => {
187
+ const f = fixture();
188
+ const invalid = new BackgroundTasks({ operations: f.operations, logRoot: join(f.root, "missing") });
189
+ try {
190
+ assert.throws(() => invalid.start({ command: "x" }, f.root), /ENOENT/);
191
+ assert.equal(f.calls.length, 0);
192
+ const job = f.tasks.start({ command: "pending" }, f.root);
193
+ unlinkSync(job.log_path);
194
+ assert.throws(() => f.tasks.output(job.id), /Cannot read/);
195
+ assert.equal(f.calls[0].signal.aborted, true);
196
+ f.calls[0].reject(new Error("aborted"));
197
+ await flush();
198
+ assert.equal(f.tasks.get(job.id).status, "failed");
199
+ assert.equal(f.tasks.get(job.id).capture_complete, false);
200
+ } finally { await invalid.shutdown(); await f.close(); }
201
+ });
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env python3
2
+ """Exercise a real Pi TUI through a PTY and an offline fixture provider.
3
+
4
+ BG_SMOKE_RENDER=1 relays the actual ANSI stream into an isolated terminal window.
5
+ BG_SMOKE_CAPTURE=1 captures only that named window (requires X11 + ImageMagick).
6
+ """
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import traceback
16
+ import termios
17
+ import tty
18
+
19
+ import pexpect
20
+
21
+ PKG = Path(__file__).resolve().parents[1]
22
+ ROOT = PKG.parents[1]
23
+ OUT = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else ROOT / '.cache/verification/background-tui'
24
+ OUT.mkdir(parents=True, exist_ok=True)
25
+ work = Path(tempfile.mkdtemp(prefix='pi-bg-tui-'))
26
+ events_path = work / 'events.jsonl'
27
+ render = os.environ.get('BG_SMOKE_RENDER') == '1'
28
+ capture = os.environ.get('BG_SMOKE_CAPTURE') == '1'
29
+ env = dict(os.environ, PI_CODING_AGENT_DIR=str(work), PI_OFFLINE='1', BG_SMOKE_EVENTS=str(events_path), TERM='xterm-256color', COLORTERM='truecolor')
30
+ env.pop('PI_HERDR_SUBAGENTS_CHILD', None)
31
+ (work / 'settings.json').write_text(json.dumps({'quietStartup': True}))
32
+ args = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes', '--no-context-files', '--no-session', '--offline',
33
+ '-e', 'builtin:codemode', '-e', str(PKG / 'extensions/background-tasks.ts'),
34
+ '-e', str(ROOT / 'packages/scheduler/extensions/scheduler.ts'), '-e', str(PKG / 'tests/fixtures/provider.ts'),
35
+ '--tools', 'codemode,background_task,schedule', '--model', 'bg-fixture/deterministic', '--tui-mode', 'fullscreen']
36
+ size = os.get_terminal_size(sys.stdout.fileno()) if render else os.terminal_size((140, 42))
37
+ terminal_settings = termios.tcgetattr(sys.stdin.fileno()) if render else None
38
+ if render:
39
+ tty.setraw(sys.stdin.fileno())
40
+ child = pexpect.spawn(os.environ.get('PI_TEST_BINARY', shutil.which('pi')), args, cwd=str(work), env=env,
41
+ encoding='utf-8', codec_errors='replace', dimensions=(size.lines, size.columns), timeout=20)
42
+ child.linesep = '\r'
43
+ raw = ''
44
+ raw_file = (OUT / 'terminal.ansi').open('w')
45
+ screens = []
46
+
47
+
48
+ def pump(seconds=0.1):
49
+ global raw
50
+ deadline = time.monotonic() + seconds
51
+ while time.monotonic() < deadline:
52
+ try:
53
+ data = child.read_nonblocking(65536, timeout=min(0.05, max(0.001, deadline - time.monotonic())))
54
+ raw += data
55
+ raw_file.write(data)
56
+ raw_file.flush()
57
+ if render:
58
+ sys.stdout.write(data)
59
+ sys.stdout.flush()
60
+ except pexpect.TIMEOUT:
61
+ pass
62
+ except pexpect.EOF:
63
+ break
64
+
65
+
66
+ def events():
67
+ if not events_path.exists():
68
+ return []
69
+ return [json.loads(line) for line in events_path.read_text().splitlines() if line]
70
+
71
+
72
+ def wait(check, label, timeout=20):
73
+ deadline = time.monotonic() + timeout
74
+ while not check():
75
+ pump()
76
+ if not child.isalive():
77
+ raise AssertionError('Pi exited before ' + label)
78
+ if time.monotonic() > deadline:
79
+ raise AssertionError('Timeout waiting for ' + label + '\n' + raw[-4000:])
80
+ pump(0.15)
81
+
82
+
83
+ def send(text):
84
+ child.sendline(text)
85
+ pump(0.1)
86
+
87
+
88
+ def tools(name='background_task'):
89
+ return [e for e in events() if e.get('event') == 'tool_end' and e.get('name') == name]
90
+
91
+
92
+ def finished(status):
93
+ return [e for e in events() if e.get('event') == 'custom' and e.get('type') == 'background-task-finished'
94
+ and e.get('details', {}).get('task', {}).get('status') == status]
95
+
96
+
97
+ def shot(name):
98
+ pump(0.2)
99
+ if capture:
100
+ info = subprocess.check_output(['xwininfo', '-name', 'π - ' + work.name], text=True)
101
+ window = next(line.split()[3] for line in info.splitlines() if 'Window id:' in line)
102
+ path = OUT / (name + '.png')
103
+ subprocess.run(['import', '-window', window, str(path)], check=True)
104
+ screens.append(str(path))
105
+
106
+
107
+ def alive(pid):
108
+ try:
109
+ os.kill(pid, 0)
110
+ stat = Path(f'/proc/{pid}/stat')
111
+ return not (stat.exists() and stat.read_text().split(') ', 1)[1].startswith('Z '))
112
+ except ProcessLookupError:
113
+ return False
114
+
115
+
116
+ def start_long():
117
+ before = len(tools())
118
+ send('SMOKE_LONG')
119
+ wait(lambda: len(tools()) > before and (work / 'child.pid').exists(), 'lifecycle task')
120
+ return [int((work / file).read_text()) for file in ['shell.pid', 'child.pid']]
121
+
122
+
123
+ try:
124
+ wait(lambda: any(e.get('event') == 'loaded' for e in events()), 'extension startup')
125
+ loaded = next(e for e in events() if e.get('event') == 'loaded')
126
+ assert loaded['tools'].count('background_task') == 1
127
+ send('/schedule add 1h Check release status')
128
+ wait(lambda: 'active schedule' in raw, 'Scheduler widget')
129
+ send('SMOKE_START')
130
+ wait(lambda: len(tools()) >= 3 and any(e.get('name') == 'codemode' for e in tools('codemode')), 'Codemode startup batch')
131
+ starts = tools()[:3]
132
+ assert not starts[0]['isError'] and not starts[1]['isError'] and starts[2]['isError'], starts
133
+ first = starts[0]['result']['structuredContent']['task']
134
+ second = starts[1]['result']['structuredContent']['task']
135
+ assert first['status'] == second['status'] == 'running'
136
+ assert any('Script completed' in str(e.get('result')) for e in tools('codemode')), tools('codemode')
137
+ assert not finished('succeeded'), 'Sleeping job must survive Codemode completion'
138
+ shot('running')
139
+ send('/bg output ' + first['id'])
140
+ send('SMOKE_CANCEL')
141
+ wait(lambda: bool(finished('cancelled')), 'cancel follow-up')
142
+ wait(lambda: bool(finished('succeeded')), 'successful background follow-up')
143
+ shot('completed')
144
+ send('SMOKE_OUTPUT')
145
+ wait(lambda: any(e.get('result', {}).get('structuredContent', {}).get('output') == 'BUILD SUCCESSFUL\n' for e in tools()), 'explicit output query')
146
+ wait(lambda: 'BACKGROUND_FOLLOWUP_RECEIVED' in raw, 'main agent automatic response')
147
+ pump(5.3)
148
+ shot('history')
149
+
150
+ pids = start_long()
151
+ loaded_count = len([e for e in events() if e.get('event') == 'loaded'])
152
+ send('/reload')
153
+ wait(lambda: len([e for e in events() if e.get('event') == 'loaded']) > loaded_count, 'reload')
154
+ wait(lambda: all(not alive(pid) for pid in pids), 'reload process cleanup')
155
+ (work / 'child.pid').unlink(missing_ok=True)
156
+ pids = start_long()
157
+ send('/new')
158
+ wait(lambda: all(not alive(pid) for pid in pids), 'new-session process cleanup')
159
+ (work / 'child.pid').unlink(missing_ok=True)
160
+ pids = start_long()
161
+ child.sendcontrol('d')
162
+ pump(0.15)
163
+ if child.isalive():
164
+ child.sendcontrol('d')
165
+ deadline = time.monotonic() + 10
166
+ while child.isalive() and time.monotonic() < deadline:
167
+ pump()
168
+ assert not child.isalive(), 'Pi did not exit cleanly'
169
+ assert all(not alive(pid) for pid in pids), 'Quit leaked a shell or child process'
170
+ records = events()
171
+ logs = [e['result']['structuredContent']['task']['log_path'] for e in tools()
172
+ if e.get('result', {}).get('structuredContent', {}).get('task')]
173
+ assert all(not Path(path).exists() for path in logs), 'Session logs were not removed'
174
+ (OUT / 'events.jsonl').write_text('\n'.join(json.dumps(e) for e in records) + '\n')
175
+ (OUT / 'result.json').write_text(json.dumps({'passed': True, 'screenshots': screens,
176
+ 'checks': ['two jobs', 'third rejected', 'Codemode returns while jobs live', 'Scheduler coexistence', '/bg output',
177
+ 'cancel notification', 'success notification', 'main agent follow-up', '5-second terminal row', 'reload cleanup', 'new-session cleanup', 'quit cleanup']}, indent=2))
178
+ except Exception:
179
+ (OUT / 'error.txt').write_text(traceback.format_exc())
180
+ raise
181
+ finally:
182
+ if child.isalive():
183
+ child.sendcontrol('d')
184
+ pump(0.1)
185
+ if child.isalive():
186
+ child.sendcontrol('d')
187
+ pump(0.5)
188
+ if child.isalive():
189
+ child.terminate(force=True)
190
+ raw_file.close()
191
+ if terminal_settings is not None:
192
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, terminal_settings)
193
+ if events_path.exists() and not (OUT / 'events.jsonl').exists():
194
+ shutil.copyfile(events_path, OUT / 'events.jsonl')
195
+ shutil.rmtree(work)