fix(compaction): reject stale pre-compaction anchor in context breakdown

getContextBreakdown used message position (anchorIndex >= pending.cutoffCount) as a proxy for usage freshness. After a mid-run compaction rebased the in-flight snapshot, an in-flight provider response whose request predated the compaction landed past the rebase cutoff carrying pre-compaction usage, so it out-ranked the rebased estimate and reported the pre-compaction token count (~2.6x the real one). That phantom overflow tripped the "freed too little context to make progress" guard and drove the frame-rescue path on a byte-identical tokensBefore.

Assistant context snapshots now carry a monotonic compaction epoch, bumped in rebaseAfterCompaction and stamped at message-record time. A post-cutoff anchor whose epoch predates the pending snapshot's epoch is no longer trusted over the rebased estimate.

Fixes #8887
This commit is contained in:
roboomp
2026-08-19 09:13:33 +00:00
parent d94bdfa1bb
commit e6c0cf90a4
5 changed files with 103 additions and 3 deletions
@@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import { Agent } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import { type CompactionPreparation, resolveThresholdTokens, shouldCompact } from "@oh-my-pi/pi-agent-core/compaction";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
@@ -505,6 +506,75 @@ describe("AgentSession auto-compaction progress guard", () => {
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(0);
});
it("rejects a stale pre-compaction anchor that lands past the rebase cutoff", async () => {
// Regression (#8887): after a mid-run compaction rebases the in-flight
// snapshot, an in-flight provider response whose request was assembled
// BEFORE the compaction lands past the rebase cutoff carrying
// pre-compaction usage. getContextBreakdown used message position as a
// freshness proxy (anchorIndex >= cutoffCount), so that stale anchor
// out-ranked the rebased estimate and reported a ~2.6x phantom overflow —
// tripping the "freed too little context" guard / frame-rescue path.
seedPriorTurns();
activateOngoingGoal("stale-anchor");
const gate = Promise.withResolvers<void>();
const firstPromptCall = Promise.withResolvers<void>();
vi.spyOn(session.agent, "prompt").mockImplementation(() => {
firstPromptCall.resolve();
return gate.promise as never;
});
vi.spyOn(session.agent, "continue").mockResolvedValue();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
// Hold a request in flight so the pending snapshot survives the compaction.
const inFlight = session.prompt("x".repeat(600_000));
await firstPromptCall.promise;
// Mid-run compaction fires and rebases the pending snapshot to the summary.
const trigger = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: trigger });
session.agent.emitExternalEvent({ type: "agent_end", messages: [trigger] });
await compactionDone;
const rebasedTokens = session.getContextBreakdown()?.usedTokens ?? 0;
expect(rebasedTokens).toBeLessThan(50_000);
// The trigger was persisted before the compaction, so its snapshot carries
// the pre-compaction epoch — the exact stamp a real in-flight response has.
const preCompactionEpoch = (trigger as AssistantMessage).contextSnapshot?.compactionEpoch ?? 0;
const staleAnchor = {
role: "assistant",
content: [{ type: "text", text: "stale in-flight response" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 360000,
output: 500,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 360500,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
contextSnapshot: { promptTokens: 360000, nonMessageTokens: 100, compactionEpoch: preCompactionEpoch },
timestamp: Date.now() + 1,
} as AssistantMessage;
sessionManager.appendMessage(staleAnchor);
session.agent.replaceMessages([...session.agent.state.messages, staleAnchor]);
// Freshness marker rejects the stale anchor: usage tracks the rebased
// estimate, not the ~360k pre-compaction figure.
expect(session.getContextBreakdown()?.usedTokens ?? 0).toBeLessThan(50_000);
gate.resolve();
await inFlight.catch(() => {});
await session.waitForIdle();
});
/**
* Seed several large prior turns into the session branch so `prepareCompaction`
* returns a real preparation after the overflow recovery drops the failed