@hypercarrier/rarebit 0.1.0-alpha.6 → 0.2.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,456 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Real Pi TUI coverage for the Rarebit recap projection.
5
+ *
6
+ * The fixture provider runs on loopback and never uses a credential. Pi runs
7
+ * in a private tmux server with a disposable agent directory, project, and
8
+ * Session directory. The test intentionally leaves recap.delay_ms unset so
9
+ * the one-minute default is exercised.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { createServer } from "node:http";
14
+ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { basename, dirname, join, resolve } from "node:path";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+ import { spawn } from "node:child_process";
19
+ import { execFile as execFileCallback } from "node:child_process";
20
+ import { promisify } from "node:util";
21
+
22
+ const execFile = promisify(execFileCallback);
23
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
24
+ const piEntry = fileURLToPath(
25
+ await import.meta.resolve("@earendil-works/pi-coding-agent"),
26
+ );
27
+ const piRoot = resolve(dirname(dirname(piEntry)));
28
+ const piCli = join(piRoot, "dist/cli.js");
29
+ const tmux = process.env.TMUX_BIN ?? "tmux";
30
+
31
+ const sleep = (milliseconds) =>
32
+ new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
33
+
34
+ async function capturePane(socket, session) {
35
+ try {
36
+ const { stdout } = await execFile(tmux, [
37
+ "-L",
38
+ socket,
39
+ "capture-pane",
40
+ "-p",
41
+ "-J",
42
+ "-t",
43
+ session,
44
+ "-S",
45
+ "-200",
46
+ ]);
47
+ return stdout;
48
+ } catch {
49
+ return "";
50
+ }
51
+ }
52
+
53
+ async function sendKeys(socket, session, text) {
54
+ await execFile(tmux, ["-L", socket, "send-keys", "-l", "-t", session, text]);
55
+ }
56
+
57
+ async function press(socket, session, key) {
58
+ await execFile(tmux, ["-L", socket, "send-keys", "-t", session, key]);
59
+ }
60
+
61
+ async function waitFor(label, predicate, timeout = 5_000, interval = 100) {
62
+ const deadline = Date.now() + timeout;
63
+ let lastValue;
64
+ while (Date.now() < deadline) {
65
+ lastValue = await predicate();
66
+ if (lastValue) return lastValue;
67
+ await sleep(interval);
68
+ }
69
+ throw new Error(`Timed out waiting for ${label}${lastValue ? ` (${lastValue})` : ""}`);
70
+ }
71
+
72
+ async function walkFiles(root) {
73
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
74
+ const files = [];
75
+ for (const entry of entries) {
76
+ const path = join(root, entry.name);
77
+ if (entry.isDirectory()) files.push(...(await walkFiles(path)));
78
+ else files.push(path);
79
+ }
80
+ return files;
81
+ }
82
+
83
+ const temp = await mkdtemp(join(tmpdir(), "rarebit-recap-pi-e2e-"));
84
+ const agentDir = join(temp, "agent");
85
+ const projectDir = join(temp, "project");
86
+ const sessionDir = join(temp, "sessions");
87
+ const rarebitRoot = join(temp, "receipts");
88
+ const fixtureExtension = join(temp, "rarebit-fixture-extension.mjs");
89
+ const requestLog = join(temp, "provider-requests.jsonl");
90
+ const socket = `rarebit-recap-${process.pid}`;
91
+ const session = "rarebit-recap-e2e";
92
+ let provider;
93
+ let tmuxStarted = false;
94
+ let keepTemp = process.env.RAREBIT_KEEP_E2E_TEMP === "1";
95
+
96
+ try {
97
+ await mkdir(agentDir, { recursive: true });
98
+ await mkdir(join(projectDir, ".pi"), { recursive: true });
99
+ await mkdir(sessionDir, { recursive: true });
100
+
101
+ const requests = [];
102
+ provider = createServer(async (request, response) => {
103
+ let body = "";
104
+ for await (const chunk of request) body += chunk;
105
+ const payload = JSON.parse(body);
106
+ const messages = Array.isArray(payload.messages) ? payload.messages : [];
107
+ // The normal Pi system prompt contains the word "summary". Classify only
108
+ // user content so an owner prompt cannot be mistaken for the detached
109
+ // Rarebit model request.
110
+ const userText = messages
111
+ .filter((message) => message?.role === "user")
112
+ .map((message) =>
113
+ typeof message.content === "string"
114
+ ? message.content
115
+ : JSON.stringify(message.content ?? ""),
116
+ )
117
+ .join("\n");
118
+ const isSummary = /rarebit|materializ|all_requests_accomplished/i.test(
119
+ userText,
120
+ );
121
+ const interactiveNumber =
122
+ requests.filter((entry) => entry.kind === "interactive").length + 1;
123
+ const text = isSummary
124
+ ? JSON.stringify({
125
+ summary: "RECAP_SENTINEL: display-only current Summary",
126
+ sessionStatus: "finished",
127
+ statusReason: "all_requests_accomplished",
128
+ })
129
+ : `INTERACTIVE_${interactiveNumber}_DONE`;
130
+ const entry = {
131
+ kind: isSummary ? "summary" : "interactive",
132
+ body: payload,
133
+ text,
134
+ requestAt: Date.now(),
135
+ };
136
+ entry.responseAt = Date.now();
137
+ requests.push(entry);
138
+ await writeFile(
139
+ requestLog,
140
+ `${JSON.stringify(entry)}\n`,
141
+ { flag: "a", mode: 0o600 },
142
+ );
143
+
144
+ const first = JSON.stringify({
145
+ id: `rarebit-e2e-${requests.length}`,
146
+ object: "chat.completion.chunk",
147
+ choices: [
148
+ {
149
+ index: 0,
150
+ delta: { role: "assistant", content: text },
151
+ finish_reason: null,
152
+ },
153
+ ],
154
+ });
155
+ const finish = JSON.stringify({
156
+ id: `rarebit-e2e-${requests.length}`,
157
+ object: "chat.completion.chunk",
158
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
159
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
160
+ });
161
+ response.writeHead(200, {
162
+ "content-type": "text/event-stream",
163
+ "cache-control": "no-cache",
164
+ connection: "keep-alive",
165
+ });
166
+ response.write(`data: ${first}\n\n`);
167
+ response.write(`data: ${finish}\n\n`);
168
+ response.end("data: [DONE]\n\n");
169
+ });
170
+ await new Promise((resolvePromise, reject) => {
171
+ provider.once("error", reject);
172
+ provider.listen(0, "127.0.0.1", resolvePromise);
173
+ });
174
+ const port = provider.address().port;
175
+
176
+ await writeFile(
177
+ join(agentDir, "models.json"),
178
+ JSON.stringify(
179
+ {
180
+ providers: {
181
+ local: {
182
+ baseUrl: `http://127.0.0.1:${port}/v1`,
183
+ api: "openai-completions",
184
+ apiKey: "fixture",
185
+ models: [
186
+ {
187
+ id: "fixture",
188
+ name: "Rarebit recap fixture",
189
+ reasoning: false,
190
+ input: ["text"],
191
+ contextWindow: 100_000,
192
+ maxTokens: 512,
193
+ },
194
+ ],
195
+ },
196
+ },
197
+ },
198
+ null,
199
+ 2,
200
+ ),
201
+ { mode: 0o600 },
202
+ );
203
+ await writeFile(
204
+ join(agentDir, "settings.json"),
205
+ JSON.stringify(
206
+ {
207
+ rarebit: {
208
+ model: "local/fixture",
209
+ auto_title: false,
210
+ min_total_length: 0,
211
+ max_rarebit_ratio: 1,
212
+ recap: {
213
+ timezone: "Asia/Hong_Kong",
214
+ },
215
+ },
216
+ },
217
+ null,
218
+ 2,
219
+ ),
220
+ { mode: 0o600 },
221
+ );
222
+ // Keep the project settings file present to exercise Pi's trusted-project
223
+ // settings loader. It does not override recap.delay_ms, so 60 seconds is
224
+ // the effective default under test.
225
+ await writeFile(join(projectDir, ".pi", "settings.json"), "{}\n", {
226
+ mode: 0o600,
227
+ });
228
+
229
+ // The fixture adapter keeps the real settings loader and model registry, but
230
+ // gives Rarebit private roots that match the disposable Pi Session.
231
+ await writeFile(
232
+ fixtureExtension,
233
+ `import registerPiRarebit from ${JSON.stringify(
234
+ pathToFileURL(join(packageRoot, "src/extension.mjs")).href,
235
+ )};\n\nexport default function registerFixtureRarebit(pi) {\n return registerPiRarebit(pi, {\n sessionRoot: ${JSON.stringify(sessionDir)},\n rarebitRoot: ${JSON.stringify(rarebitRoot)},\n });\n}\n`,
236
+ { mode: 0o600 },
237
+ );
238
+ const childEnv = {
239
+ ...process.env,
240
+ PI_CODING_AGENT_DIR: agentDir,
241
+ PI_OFFLINE: "1",
242
+ PI_TELEMETRY: "0",
243
+ };
244
+ const command = [
245
+ "env",
246
+ `PI_CODING_AGENT_DIR=${agentDir}`,
247
+ "PI_OFFLINE=1",
248
+ "PI_TELEMETRY=0",
249
+ process.execPath,
250
+ piCli,
251
+ "--no-extensions",
252
+ "--no-skills",
253
+ "--no-prompt-templates",
254
+ "--no-themes",
255
+ "--no-context-files",
256
+ "--approve",
257
+ "--extension",
258
+ fixtureExtension,
259
+ "--session-dir",
260
+ sessionDir,
261
+ "--model",
262
+ "local/fixture",
263
+ "--tui-mode",
264
+ "regular",
265
+ ];
266
+ const tmuxProcess = spawn(
267
+ tmux,
268
+ ["-L", socket, "new-session", "-d", "-x", "140", "-y", "48", "-s", session, ...command],
269
+ { cwd: projectDir, env: childEnv, stdio: "ignore" },
270
+ );
271
+ await new Promise((resolvePromise, reject) => {
272
+ tmuxProcess.once("error", reject);
273
+ tmuxProcess.once("exit", (code) =>
274
+ code === 0
275
+ ? resolvePromise()
276
+ : reject(new Error(`tmux new-session exited ${code}`)),
277
+ );
278
+ });
279
+ tmuxStarted = true;
280
+ await waitFor(
281
+ "Pi editor",
282
+ // Pi 0.84.2 leaves the empty editor line blank in regular mode. The
283
+ // footer's token counter is the stable rendered readiness marker.
284
+ async () => (await capturePane(socket, session)).includes("0.0%/100k"),
285
+ 10_000,
286
+ );
287
+ // The footer paints before the extension/resource startup queue finishes.
288
+ // Give Pi one event-loop turn before injecting the first owner prompt.
289
+ await sleep(750);
290
+
291
+ await sendKeys(socket, session, "first recap turn");
292
+ await press(socket, session, "Enter");
293
+ await waitFor(
294
+ "first provider response",
295
+ async () => (await capturePane(socket, session)).includes("INTERACTIVE_1_DONE"),
296
+ 15_000,
297
+ );
298
+ const requestCountAtWidget = () => requests.length;
299
+ await waitFor(
300
+ "display-only recap widget after default 60 seconds",
301
+ async () => (await capturePane(socket, session)).includes("RECAP_SENTINEL"),
302
+ 75_000,
303
+ );
304
+ const widgetRenderedAt = Date.now();
305
+ const widgetPane = await capturePane(socket, session);
306
+ assert.match(widgetPane, /RECAP_SENTINEL/);
307
+ assert.match(widgetPane, /Recap/);
308
+ assert.match(widgetPane, /Asia\/Hong_Kong/);
309
+ const summaryRequestsAtWidget = requests.filter(
310
+ (entry) => entry.kind === "summary",
311
+ );
312
+ assert.ok(summaryRequestsAtWidget.length > 0, "summary provider request was recorded");
313
+ const latestSummaryResponse = summaryRequestsAtWidget.at(-1).responseAt;
314
+ assert.ok(
315
+ widgetRenderedAt - latestSummaryResponse >= 59_750,
316
+ `recap rendered before its 60s default (${widgetRenderedAt - latestSummaryResponse}ms)`,
317
+ );
318
+ const providerRequestCountBeforeTyping = requestCountAtWidget();
319
+
320
+ // Keystrokes do not emit a Rarebit lifecycle event. The human can keep
321
+ // editing while the display-only widget remains above the editor.
322
+ await sendKeys(socket, session, "typed while recap is visible");
323
+ const typingPane = await waitFor(
324
+ "typed editor text while recap remains",
325
+ async () => {
326
+ const pane = await capturePane(socket, session);
327
+ return pane.includes("typed while recap is visible") ? pane : false;
328
+ },
329
+ 5_000,
330
+ );
331
+ assert.match(typingPane, /RECAP_SENTINEL/);
332
+ assert.equal(
333
+ requestCountAtWidget(),
334
+ providerRequestCountBeforeTyping,
335
+ "displaying recap must not trigger another provider request",
336
+ );
337
+ assert.equal(
338
+ requests.filter((entry) => entry.kind === "summary").length,
339
+ summaryRequestsAtWidget.length,
340
+ "typing/display must not trigger another Summary",
341
+ );
342
+
343
+ // Submitted input clears the widget before the next provider request.
344
+ await press(socket, session, "Enter");
345
+ await waitFor(
346
+ "second provider request",
347
+ async () => {
348
+ const entries = JSON.parse(`[${(await readFile(requestLog, "utf8")).trim().replace(/\n/g, ",")}]`);
349
+ return entries.filter((entry) => entry.kind === "interactive").length >= 2;
350
+ },
351
+ 15_000,
352
+ );
353
+ const afterSubmitPane = await waitFor(
354
+ "recap clear after submitted input",
355
+ async () => {
356
+ const pane = await capturePane(socket, session);
357
+ return !pane.includes("RECAP_SENTINEL") ? pane : false;
358
+ },
359
+ 5_000,
360
+ );
361
+ assert.doesNotMatch(afterSubmitPane, /RECAP_SENTINEL/);
362
+
363
+ const loggedRequests = (await readFile(requestLog, "utf8"))
364
+ .trim()
365
+ .split("\n")
366
+ .filter(Boolean)
367
+ .map((line) => JSON.parse(line));
368
+ assert.ok(loggedRequests.some((entry) => entry.kind === "summary"));
369
+ const secondInteractive = loggedRequests.filter(
370
+ (entry) => entry.kind === "interactive",
371
+ )[1];
372
+ assert.ok(secondInteractive, "the submitted second turn reached the fixture provider");
373
+ assert.doesNotMatch(
374
+ JSON.stringify(secondInteractive.body),
375
+ /RECAP_SENTINEL/,
376
+ "display-only recap content must not enter the next provider context",
377
+ );
378
+
379
+ const sessionFiles = (await walkFiles(sessionDir)).filter((path) =>
380
+ path.endsWith(".jsonl"),
381
+ );
382
+ const sessionText = (
383
+ await Promise.all(sessionFiles.map((path) => readFile(path, "utf8")))
384
+ ).join("\n");
385
+ assert.doesNotMatch(sessionText, /RECAP_SENTINEL/);
386
+ const receiptFiles = await walkFiles(rarebitRoot);
387
+ assert.ok(receiptFiles.length > 0, "the real Rarebit sidecar was written");
388
+
389
+ const evidence = {
390
+ providerRequests: loggedRequests,
391
+ summaryResponseAt: latestSummaryResponse,
392
+ widgetRenderedAt,
393
+ elapsedFromLatestSummaryMs: widgetRenderedAt - latestSummaryResponse,
394
+ widgetPane,
395
+ typingPane,
396
+ afterSubmitPane,
397
+ sessionFiles,
398
+ receiptFiles,
399
+ };
400
+ if (keepTemp) {
401
+ await writeFile(join(temp, "pane-widget.txt"), widgetPane, { mode: 0o600 });
402
+ await writeFile(join(temp, "pane-typing.txt"), typingPane, { mode: 0o600 });
403
+ await writeFile(join(temp, "pane-after-submit.txt"), afterSubmitPane, {
404
+ mode: 0o600,
405
+ });
406
+ await writeFile(join(temp, "evidence.json"), JSON.stringify(evidence, null, 2), {
407
+ mode: 0o600,
408
+ });
409
+ }
410
+
411
+ console.log(
412
+ JSON.stringify({
413
+ ok: true,
414
+ pi: JSON.parse(await readFile(join(piRoot, "package.json"), "utf8")).version,
415
+ defaultDelayMs: 60_000,
416
+ providerRequests: loggedRequests.length,
417
+ summaryRequests: loggedRequests.filter((entry) => entry.kind === "summary").length,
418
+ elapsedFromLatestSummaryMs: evidence.elapsedFromLatestSummaryMs,
419
+ evidenceRoot: keepTemp ? temp : null,
420
+ sessionFiles: sessionFiles.map((path) => basename(path)),
421
+ receiptFiles: receiptFiles.map((path) => path.slice(temp.length + 1)),
422
+ checks: [
423
+ "real Pi TUI rendered recap widget after 60 seconds",
424
+ "typing preserved widget",
425
+ "submitted input cleared widget",
426
+ "next provider request excluded recap sentinel",
427
+ "Session JSONL excluded recap sentinel",
428
+ ],
429
+ }, null, 2),
430
+ );
431
+ } catch (error) {
432
+ keepTemp = true;
433
+ const failurePane = tmuxStarted ? await capturePane(socket, session) : "";
434
+ console.error(`Rarebit recap Pi E2E failed: ${error?.stack ?? error}`);
435
+ if (failurePane) console.error(`Pi pane at failure:\n${failurePane}`);
436
+ console.error(`Temporary evidence retained at ${temp}`);
437
+ throw error;
438
+ } finally {
439
+ if (tmuxStarted) {
440
+ await press(socket, session, "C-c").catch(() => {});
441
+ await sleep(100);
442
+ await press(socket, session, "C-c").catch(() => {});
443
+ await execFile(tmux, ["-L", socket, "kill-server"]).catch(() => {});
444
+ }
445
+ if (provider?.listening)
446
+ await new Promise((resolvePromise) => provider.close(resolvePromise));
447
+ if (keepTemp) {
448
+ const tmuxCleanup = await execFile(tmux, ["-L", socket, "has-session"])
449
+ .then(() => "server_still_running")
450
+ .catch(() => "clean");
451
+ await writeFile(join(temp, "tmux-cleanup.txt"), `${tmuxCleanup}\n`, {
452
+ mode: 0o600,
453
+ });
454
+ }
455
+ if (!keepTemp) await rm(temp, { recursive: true, force: true });
456
+ }