Files
pi-auto-reinject/scripts/b002-repro-post-fix.mjs
T
grayhook 1e5cd07784 Phase 14: B-002 post-fix RPC E2E — unit pass, compact kept-window limit
Post-fix script and doc: automated tests cover loose defer path; short RPC session keeps skill in kept so compact reinject not triggered.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:38:46 +07:00

264 lines
7.6 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Phase 14 post-fix E2E (B-002): RPC flow verifying loose reinject + defer path.
*/
import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const skillPath = resolve(process.env.B002_SKILL_PATH ?? `${process.env.HOME}/.cursor/skills/fup-blame-commits`);
const extensionPath = join(repoRoot, "src/index.ts");
const globalSettingsPath = join(process.env.HOME ?? "", ".pi/agent/settings.json");
const globalBackupPath = `${globalSettingsPath}.b002-post-backup`;
const suffix = "[skill-reinject] Re-applied after compaction.";
function patchGlobalSettings() {
if (!existsSync(globalSettingsPath)) {
throw new Error(`global settings not found: ${globalSettingsPath}`);
}
const original = readFileSync(globalSettingsPath, "utf8");
writeFileSync(globalBackupPath, original);
const settings = JSON.parse(original);
settings.skillReinject = {
...settings.skillReinject,
enabled: true,
debug: true,
autoCompactIntegration: "defer",
requireRegistered: false,
};
writeFileSync(globalSettingsPath, `${JSON.stringify(settings, null, 2)}\n`);
return original;
}
function restoreGlobalSettings(original) {
writeFileSync(globalSettingsPath, original);
rmSync(globalBackupPath, { force: true });
}
const sessionDir = mkdtempSync(join(tmpdir(), "b002-post-session-"));
let originalSettings;
try {
originalSettings = patchGlobalSettings();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(2);
}
let nextId = 1;
const pending = new Map();
const diagNotifies = [];
const userMessages = [];
let compactResponse;
let nowInjectSeen = false;
let afterCompactInjectSeen = false;
function send(child, cmd) {
const id = `req-${nextId++}`;
child.stdin.write(`${JSON.stringify({ id, ...cmd })}\n`);
return new Promise((resolvePromise, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`timeout waiting for ${cmd.type} (${id})`));
}, Number(process.env.B002_STEP_TIMEOUT_MS ?? 180_000));
pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolvePromise(value);
},
reject: (error) => {
clearTimeout(timer);
reject(error);
},
});
});
}
function captureDiagLine(line) {
if (line.includes("skill-reinject [session_compact]:") || line.includes("skill-reinject [before_agent_start]:")) {
diagNotifies.push(line.trim());
}
}
function inspectUserMessage(content) {
const text = typeof content === "string" ? content : JSON.stringify(content ?? "");
userMessages.push(text);
if (text.includes(suffix) && text.includes("fup-blame-commits")) {
afterCompactInjectSeen = true;
}
}
const piArgs = [
"--mode",
"rpc",
"-e",
extensionPath,
"--skill",
skillPath,
"--session-dir",
sessionDir,
"--model",
process.env.B002_MODEL ?? "Eltex-Coder-Senior",
"--no-session",
];
const child = spawn("pi", piArgs, {
cwd: repoRoot,
stdio: ["pipe", "pipe", "pipe"],
env: process.env,
});
let stdoutBuffer = "";
child.stdout.on("data", (chunk) => {
stdoutBuffer += chunk.toString("utf8");
let newlineIndex = stdoutBuffer.indexOf("\n");
while (newlineIndex >= 0) {
const line = stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
if (!line.trim()) {
newlineIndex = stdoutBuffer.indexOf("\n");
continue;
}
try {
const record = JSON.parse(line);
if (record.type === "extension_ui_request" && record.method === "notify") {
if (typeof record.message === "string") {
captureDiagLine(record.message);
if (record.message.includes('re-injected "fup-blame-commits" from disk')) {
nowInjectSeen = true;
}
}
}
if (record.type === "message" && record.message?.role === "user") {
inspectUserMessage(record.message.content);
}
if (record.type === "response" && record.id && pending.has(record.id)) {
pending.get(record.id).resolve(record);
pending.delete(record.id);
}
} catch {
// ignore non-JSON stdout
}
newlineIndex = stdoutBuffer.indexOf("\n");
}
});
const stderrChunks = [];
child.stderr.on("data", (chunk) => {
const text = chunk.toString("utf8");
stderrChunks.push(chunk);
for (const line of text.split("\n")) {
captureDiagLine(line);
}
});
child.on("close", () => {
for (const { reject } of pending.values()) {
reject(new Error("pi process exited"));
}
pending.clear();
});
async function runFlow() {
await new Promise((resolvePromise) => setTimeout(resolvePromise, 3000));
await send(child, { type: "prompt", message: "/skill-reinject on" });
await send(child, { type: "prompt", message: "/skill:fup-blame-commits" });
await send(child, { type: "prompt", message: "Reply with exactly: ack" });
// Push skill block out of likely kept window before compact.
for (let i = 1; i <= 6; i += 1) {
await send(child, {
type: "prompt",
message: `Filler turn ${i}: reply with exactly filler-${i}`,
});
}
await send(child, { type: "prompt", message: "/skill-reinject now" });
compactResponse = await send(child, { type: "compact" });
await send(child, { type: "prompt", message: "Reply with exactly: after-compact" });
}
let flowError;
let exitCode = 1;
try {
await Promise.race([
runFlow().then(() => child.stdin.end()),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error(`global timeout after ${process.env.B002_TIMEOUT_MS ?? 600_000}ms`)),
Number(process.env.B002_TIMEOUT_MS ?? 600_000),
),
),
]);
exitCode = await new Promise((resolvePromise) => {
child.on("close", (code) => resolvePromise(code ?? 1));
});
} catch (error) {
flowError = error instanceof Error ? error.message : String(error);
child.kill("SIGTERM");
} finally {
restoreGlobalSettings(originalSettings);
rmSync(sessionDir, { recursive: true, force: true });
}
const stderr = Buffer.concat(stderrChunks).toString("utf8");
const parsedDiag = diagNotifies.map((line) => {
const match = line.match(/skill-reinject \[(session_compact|before_agent_start)\]: (.+)$/);
if (!match) {
return { raw: line };
}
try {
return { phase: match[1], snapshot: JSON.parse(match[2]) };
} catch {
return { phase: match[1], raw: match[2] };
}
});
const compactDiag = [...parsedDiag].reverse().find((entry) => entry.phase === "session_compact");
const compactSnapshot = compactDiag?.snapshot;
const skillLeftKept =
compactSnapshot &&
Array.isArray(compactSnapshot.tracked) &&
compactSnapshot.tracked.includes("fup-blame-commits") &&
Array.isArray(compactSnapshot.kept) &&
!compactSnapshot.kept.includes("fup-blame-commits");
const pendingAfterCompact =
compactSnapshot && Array.isArray(compactSnapshot.pending) && compactSnapshot.pending.length > 0;
const checks = {
nowInjectSeen,
afterCompactInjectSeen,
skillLeftKept,
pendingAfterCompact,
plannedAfterCompact:
compactSnapshot &&
Array.isArray(compactSnapshot.planned) &&
compactSnapshot.planned.includes("fup-blame-commits"),
};
const pass =
!flowError &&
(checks.nowInjectSeen || userMessages.some((m) => m.includes("fup-blame-commits") && m.includes(suffix))) &&
(checks.afterCompactInjectSeen || (skillLeftKept && (checks.pendingAfterCompact || checks.plannedAfterCompact)));
const summary = {
pass,
exitCode,
flowError,
skillPath,
checks,
compactResponse,
compactSnapshot,
diagNotifies,
parsedDiag,
userMessageCount: userMessages.length,
stderrTail: stderr.trim().split("\n").filter((l) => !l.includes("Llama.cpp")).slice(-20).join("\n"),
};
console.log(JSON.stringify(summary, null, 2));
process.exit(pass ? 0 : 1);