From 1e5cd0778476c109449ebb70927387e1b28be2cb Mon Sep 17 00:00:00 2001 From: GRayHook Date: Wed, 17 Jun 2026 17:38:46 +0700 Subject: [PATCH] =?UTF-8?q?Phase=2014:=20B-002=20post-fix=20RPC=20E2E=20?= =?UTF-8?q?=E2=80=94=20unit=20pass,=20compact=20kept-window=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/e2e-b002-post-fix.md | 21 +++ scripts/b002-repro-post-fix.mjs | 263 ++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 docs/e2e-b002-post-fix.md create mode 100755 scripts/b002-repro-post-fix.mjs diff --git a/docs/e2e-b002-post-fix.md b/docs/e2e-b002-post-fix.md new file mode 100644 index 0000000..79ac071 --- /dev/null +++ b/docs/e2e-b002-post-fix.md @@ -0,0 +1,21 @@ +# B-002 post-fix E2E (Phase 14) + +Run: `node scripts/b002-repro-post-fix.mjs` (LiteLLM `Eltex-Coder-Senior`, skill at `~/.cursor/skills/fup-blame-commits`). + +Patches `~/.pi/agent/settings.json` temporarily (`skillReinject.debug: true`, `requireRegistered: false`, `autoCompactIntegration: defer`). + +## 2026-06-17 — RPC post-fix + +Flow: `/skill-reinject on` → `/skill:fup-blame-commits` → 6 filler turns → `/skill-reinject now` → RPC `compact` → `after-compact` prompt. + +| Check | Result | +|-------|--------| +| Unit regression (`test/reinject*.ts`, `test/reinject-deferred-consume.test.ts`) | **pass** (84 tests) | +| `session_compact` diag: skill left kept window | **no** — `kept` still includes `fup-blame-commits` on small session (444 tokens before compact) | +| `planned` / `pending` after compact | `[]` (expected when skill still in kept) | +| `/skill-reinject now` inject visible in RPC stdout | **not captured** — injected blocks use extension custom message path; script does not parse them yet | +| Defer loose path (unregistered + disk) | **covered by unit tests** — RPC `--skill` keeps skill in `registered` | + +**Conclusion:** Code fix for B-002 (defer plan without registry at compact + consume/build loose fallback) is validated by automated tests. Full RPC proof of compact→reinject requires a session where compaction drops the skill block from the kept window (threshold / long history); short RPC repro still shows `kept` retaining the skill. + +**Pre-fix vs post-fix:** When `kept` excludes a tracked skill, `planDeferredReinject` now sets `pending` even if `registered=[]` (see `test/b002-repro-pre-fix.test.ts` case 3 + `test/reinject.test.ts`). diff --git a/scripts/b002-repro-post-fix.mjs b/scripts/b002-repro-post-fix.mjs new file mode 100755 index 0000000..5609ff0 --- /dev/null +++ b/scripts/b002-repro-post-fix.mjs @@ -0,0 +1,263 @@ +#!/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);