diff --git a/hooks/auto-injection.mjs b/hooks/auto-injection.mjs index e9eabfaf2..42238efb4 100644 --- a/hooks/auto-injection.mjs +++ b/hooks/auto-injection.mjs @@ -25,9 +25,12 @@ export function estimateTokens(text) { /** * Build auto-injection block from session events. * @param {Array<{category: string, data: string}>} events + * @param {"compaction"|"active_memory"} source — REQUIRED (fail-safe: no + * default, so no caller can silently emit the wrong label). "compaction" + * only after a real compact; "active_memory" for routine per-turn injection. * @returns {string} XML block or empty string */ -export function buildAutoInjection(events) { +export function buildAutoInjection(events, source) { // Single O(N) pass instead of 4× O(N) Array.filter() loops. UserPromptSubmit // fires this on every prompt; with N up to 100 events the prior implementation // walked the array 4 times per prompt — wasteful on macOS, painful on Windows @@ -98,5 +101,13 @@ export function buildAutoInjection(events) { } if (parts.length === 0) return ""; - return `\n\n${parts.join("\n\n")}\n\n`; + // Fidelity line only for genuine compaction: tells the agent what happened + // and where the history lives. Routine per-turn injection gets NO extra + // line — the label alone carries the meaning, and an always-present line + // would leak past the 500-token content budget (the wrapper is unbudgeted) + // and add noise to every turn. + const fidelity = source === "compaction" + ? "Context was compacted; full history persists in the session transcript.\n\n" + : ""; + return `\n\n${fidelity}${parts.join("\n\n")}\n\n`; } diff --git a/hooks/sessionstart.mjs b/hooks/sessionstart.mjs index 8083ce39c..9ee4565dd 100755 --- a/hooks/sessionstart.mjs +++ b/hooks/sessionstart.mjs @@ -206,7 +206,7 @@ await runHook(async () => { additionalContext += buildSessionDirective("compact", eventMeta, toolNamer); // Auto-inject behavioral state on compaction (role, decisions, skills, intent) - const autoInjection = buildAutoInjection(events); + const autoInjection = buildAutoInjection(events, "compaction"); if (autoInjection) { additionalContext += "\n\n" + autoInjection; } diff --git a/src/adapters/opencode/plugin.ts b/src/adapters/opencode/plugin.ts index 5459359cb..be8a3cbf9 100644 --- a/src/adapters/opencode/plugin.ts +++ b/src/adapters/opencode/plugin.ts @@ -650,7 +650,7 @@ async function createContextModePlugin(ctx: PluginContext) { // hooks/auto-injection.mjs). Pushed as a separate context entry so // OpenCode can fold it independently from the verbose snapshot. try { - const autoBlock: string = autoInjectionMod.buildAutoInjection(events); + const autoBlock: string = autoInjectionMod.buildAutoInjection(events, "compaction"); if (autoBlock && autoBlock.length > 0) { output.context.push(autoBlock); } diff --git a/src/adapters/pi/extension.ts b/src/adapters/pi/extension.ts index bd5d4d982..e78de2f9c 100644 --- a/src/adapters/pi/extension.ts +++ b/src/adapters/pi/extension.ts @@ -156,17 +156,27 @@ export let _mcpBridgeReady: Promise = Promise.resolve(); // Cached buildAutoInjection (500-token cap, prioritized). let _buildAutoInjection: - | ((events: Array<{ category: string; data: string }>) => string) + | (( + events: Array<{ category: string; data: string }>, + source: "compaction" | "active_memory", + ) => string) | null | undefined = undefined; +// Set by session_compact; consumed by the next before_agent_start so the +// FIRST post-compact injection is honestly labeled "compaction" (with the +// fidelity line telling the agent where its history lives). Read-and-cleared +// at the top of the handler so it can never strand true and mislabel a +// later, unrelated turn. +let _pendingCompactLabel = false; + // Pending context to inject via the 'context' hook (avoiding systemPrompt mutation // which breaks prefix prompt cache on DeepSeek/Anthropic/OpenAI). // See: https://github.com/mksglu/context-mode/issues/598 let _pendingContext = ""; async function getAutoInjection( pluginRoot: string, -): Promise<((events: Array<{ category: string; data: string }>) => string) | null> { +): Promise<((events: Array<{ category: string; data: string }>, source: "compaction" | "active_memory") => string) | null> { if (_buildAutoInjection !== undefined) return _buildAutoInjection; try { const mod = await import( @@ -607,6 +617,13 @@ export default function piExtension(pi: any): void { pi.on("before_agent_start", async (event: any, ctx: any) => { try { _pendingContext = ""; // Reset — will be filled below if events exist + // Consume any pending real-compaction label FIRST (fail-safe: even if + // this turn injects nothing, the flag can never leak into a later, + // unrelated turn and produce a false "compaction" signal). + const injectSource: "compaction" | "active_memory" = _pendingCompactLabel + ? "compaction" + : "active_memory"; + _pendingCompactLabel = false; // Lazily start and await the MCP bridge only when Pi is about to // dispatch a real agent turn. This is the non-brittle #534/#809 guard: // help/version/package/config CLI paths may load the extension, but they @@ -690,6 +707,7 @@ export default function piExtension(pi: any): void { category: String(e.category ?? ""), data: String(e.data ?? ""), })), + injectSource, ); } // Fallback (or if helper produced empty output): inline 500-token cap. @@ -848,6 +866,7 @@ export default function piExtension(pi: any): void { try { if (!_sessionId) return; db.incrementCompactCount(_sessionId); + _pendingCompactLabel = true; } catch { // best effort } @@ -863,6 +882,7 @@ export default function piExtension(pi: any): void { _db = null; _dbPath = ""; _sessionId = ""; + _pendingCompactLabel = false; } catch { // best effort — never throw during shutdown } diff --git a/tests/auto-injection-label.test.ts b/tests/auto-injection-label.test.ts new file mode 100644 index 000000000..812d8fb32 --- /dev/null +++ b/tests/auto-injection-label.test.ts @@ -0,0 +1,83 @@ +/** + * auto-injection source-label tests — fix: false `source="compaction"` label + * + * The wrapper was hardcoded to `source="compaction"` while the pi adapter + * injects the block on EVERY agent start, causing models to falsely believe + * their context was destroyed (false-loss cascade, 2026-07-24). + * + * Contract under test: + * - source is a REQUIRED parameter (fail-safe: no silent default) + * - routine per-turn injection is labeled "active_memory" with NO fidelity line + * - genuine post-compaction injection is labeled "compaction" WITH a fidelity + * line stating where the history lives + * - empty events still yield "" regardless of source + * - the block stays within the documented ~500-token content budget plus the + * (unbudgeted) wrapper/fidelity overhead + */ + +import { describe, test, expect } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = join(__dirname, ".."); + +const { + buildAutoInjection, + estimateTokens, + // @ts-expect-error — plain .mjs hook, no type declarations +} = await import(join(PROJECT_ROOT, "hooks", "auto-injection.mjs")); + +const EVENTS = [ + { category: "decision", data: "use sqlite for session store" }, + { category: "skills", data: "lint, test" }, + { category: "intent", data: "fixing the false compaction label" }, +]; + +describe("buildAutoInjection source label", () => { + test('routine injection is labeled "active_memory" with no fidelity line', () => { + const block = buildAutoInjection(EVENTS, "active_memory"); + expect(block).toContain(''); + expect(block).not.toContain('source="compaction"'); + expect(block).not.toContain("compacted"); + expect(block).not.toContain("transcript"); + expect(block).toContain("use sqlite for session store"); + }); + + test('post-compact injection is labeled "compaction" WITH fidelity line', () => { + const block = buildAutoInjection(EVENTS, "compaction"); + expect(block).toContain(''); + expect(block).toContain("Context was compacted"); + expect(block).toContain("session transcript"); + }); + + test("empty events yield empty string regardless of source", () => { + expect(buildAutoInjection([], "active_memory")).toBe(""); + expect(buildAutoInjection([], "compaction")).toBe(""); + }); + + test("source is required — no silent default label", () => { + // Calling without a source must not produce the reassuring + // "active_memory" label silently; the wrong usage is loud in output. + const block = buildAutoInjection(EVENTS, undefined); + expect(block).not.toContain('source="active_memory"'); + expect(block).not.toContain('source="compaction"'); + }); + + test("block stays within content budget + wrapper overhead", () => { + // The 500-token budget meters only content parts; the wrapper (~45 chars) + // and the compaction fidelity line are outside that accounting. Assert + // against the honest total: 500 + wrapper + fidelity. Small fixtures + // only — the P2-overflow path can exceed budget by design. + for (const source of ["active_memory", "compaction"]) { + const block = buildAutoInjection(EVENTS, source); + const wrapper = `\n\n\n\n`; + const fidelity = + source === "compaction" + ? "Context was compacted; full history persists in the session transcript." + : ""; + const overhead = estimateTokens(wrapper + fidelity); + expect(estimateTokens(block)).toBeLessThanOrEqual(500 + overhead); + } + }); +});