Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions hooks/auto-injection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -98,5 +101,13 @@ export function buildAutoInjection(events) {
}

if (parts.length === 0) return "";
return `<session_state source="compaction">\n\n${parts.join("\n\n")}\n\n</session_state>`;
// 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 `<session_state source="${source}">\n\n${fidelity}${parts.join("\n\n")}\n\n</session_state>`;
}
2 changes: 1 addition & 1 deletion hooks/sessionstart.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/opencode/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
24 changes: 22 additions & 2 deletions src/adapters/pi/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,17 +156,27 @@ export let _mcpBridgeReady: Promise<void> = 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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -848,6 +866,7 @@ export default function piExtension(pi: any): void {
try {
if (!_sessionId) return;
db.incrementCompactCount(_sessionId);
_pendingCompactLabel = true;
} catch {
// best effort
}
Expand All @@ -863,6 +882,7 @@ export default function piExtension(pi: any): void {
_db = null;
_dbPath = "";
_sessionId = "";
_pendingCompactLabel = false;
} catch {
// best effort — never throw during shutdown
}
Expand Down
83 changes: 83 additions & 0 deletions tests/auto-injection-label.test.ts
Original file line number Diff line number Diff line change
@@ -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('<session_state source="active_memory">');
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('<session_state source="compaction">');
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 = `<session_state source="${source}">\n\n\n\n</session_state>`;
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);
}
});
});