#!/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);