fix(agent): size snapcompact maxFrames by the live model window

Snapcompact's bundled MAX_FRAMES_DEFAULT (80) × FRAME_TOKEN_ESTIMATE (5024)
≈ 402k tokens worth of frames. AgentSession was calling snapcompact.compact()
with no maxFrames override, so the post-render projection inside #runAuto
Compaction / compact() always overflowed the budget on any sub-1M-token
window (Claude Sonnet 4.5's 200k = 170k usable, the 80-frame projection
alone clears that 2.4×), looping the 'snapcompact could not bring the
context under the limit — using an LLM summary instead' warning on every
threshold tick.

AgentSession.#computeSnapcompactMaxFrames now sizes the frame cap from
the resolved budget — (window − reserve − non-message − kept-recent −
summary-text reserve) / FRAME_TOKEN_ESTIMATE, clamped to MAX_FRAMES_DEFAULT
— and threads it into snapcompact.compact() in both the auto-compaction
and manual /compact paths. When the kept-recent slice already exceeds the
budget, snapcompact is skipped outright instead of running just to be
rejected: the projection guard remains as a defensive check.

Fixes #3247
This commit is contained in:
roboomp
2026-06-22 10:03:44 +00:00
parent 320261fca9
commit 5cce507582
3 changed files with 297 additions and 30 deletions
@@ -0,0 +1,198 @@
/**
* Regression test for issue #3247.
*
* Snapcompact's bundled `MAX_FRAMES_DEFAULT = 80` × `FRAME_TOKEN_ESTIMATE = 5024`
* ≈ 402k tokens worth of frames. On any sub-1M-token window (e.g. Claude
* Sonnet 4.5's 200k), passing the default cap to `snapcompact.compact()` made
* the post-render projection in `AgentSession` always overflow the budget,
* emit the "snapcompact could not bring the context under the limit" warning
* on every threshold tick, and downgrade to an LLM summary. The fix sizes the
* `maxFrames` cap from the live model window (window − reserve − non-message
* overhead − kept-recent − summary-text reserve) before calling
* `snapcompact.compact()`.
*
* The contract this test defends: for a 200k-window vision model with sane
* kept-recent traffic, AgentSession MUST pass a budget-sized `maxFrames`
* (smaller than `MAX_FRAMES_DEFAULT`, and with `maxFrames × FRAME_TOKEN_ESTIMATE`
* inside the resolved budget) so the projection accepts the snapcompact
* result instead of falling back to the LLM summarizer.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { effectiveReserveTokens } from "@oh-my-pi/pi-agent-core/compaction";
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";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import * as snapcompact from "@oh-my-pi/snapcompact";
describe("AgentSession snapcompact frame-budget sizing", () => {
let tempDir: TempDir;
let session: AgentSession;
let sessionManager: SessionManager;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-snapcompact-budget-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected bundled claude-sonnet-4-5 model");
// Sanity: the contract only holds for vision models with a window
// genuinely smaller than the snapcompact upper bound. If the bundled
// catalog ever raises Sonnet's window past 1M, this test no longer
// covers the failure mode the fix targets.
expect(model.input).toContain("image");
expect(model.contextWindow).toBeLessThan(1_000_000);
const agent = new Agent({
initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] },
});
// Seed a representative long-running session: many turn-pairs with
// substantial filler so prepareCompaction() splits the branch into
// "discard + summarize" (oldest) vs "kept-recent" (newest).
const filler = "the quick brown fox jumps over the lazy dog. ".repeat(64);
for (let i = 0; i < 64; i++) {
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: `turn ${i}: ${filler}` }],
timestamp: Date.now() - (64 - i) * 1000,
});
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: `reply ${i}: ${filler}` }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1000,
output: 1000,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now() - (64 - i) * 1000 + 100,
});
}
session = new AgentSession({
agent,
sessionManager,
settings: Settings.isolated({
"compaction.strategy": "snapcompact",
"compaction.autoContinue": false,
// Force a small kept-recent window so the seeded conversation
// definitely splits into discard + kept and prepareCompaction()
// returns a non-empty preparation.
"compaction.keepRecentTokens": 4000,
}),
modelRegistry,
});
});
afterEach(async () => {
try {
await session?.dispose();
} finally {
authStorage?.close();
await tempDir?.remove();
vi.restoreAllMocks();
}
});
it("passes a window-sized maxFrames to snapcompact.compact() on sub-1M-token models", async () => {
// Capture the options snapcompact.compact() is invoked with, and short-
// circuit it so the projection downstream evaluates against a known
// empty-frame archive (which fits any budget). The contract is about
// what the caller asks for, not what snapcompact then chooses to emit.
const model = session.model;
if (!model) throw new Error("Expected model to be set on session");
const ctxWindow = model.contextWindow ?? 0;
expect(ctxWindow).toBeGreaterThan(0);
const branchEntries = sessionManager.getBranch();
const firstKeptEntry = branchEntries[branchEntries.length - 1];
if (!firstKeptEntry?.id) throw new Error("Expected branch entry with id");
const compactSpy = vi.spyOn(snapcompact, "compact").mockResolvedValue({
summary: "stubbed snapcompact",
shortSummary: "stub",
firstKeptEntryId: firstKeptEntry.id,
tokensBefore: 100_000,
details: { readFiles: [], modifiedFiles: [] },
preserveData: {
snapcompact: { frames: [], totalChars: 0, truncatedChars: 0 },
},
});
await session.compact(undefined, { mode: "snapcompact" });
expect(compactSpy).toHaveBeenCalledTimes(1);
const opts = compactSpy.mock.calls[0]?.[1];
expect(opts).toBeDefined();
const maxFrames = opts?.maxFrames;
expect(maxFrames).toBeDefined();
expect(maxFrames).toBeLessThan(snapcompact.MAX_FRAMES_DEFAULT);
expect(maxFrames).toBeGreaterThan(0);
// The chosen cap MUST keep the projected frame budget inside the
// resolved (window − reserve) envelope — otherwise the projection
// guard would reject and loop back to the LLM summary every tick.
const reserve = effectiveReserveTokens(ctxWindow, {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 4000,
});
const budget = ctxWindow - reserve;
expect((maxFrames ?? 0) * snapcompact.FRAME_TOKEN_ESTIMATE).toBeLessThan(budget);
});
it("skips snapcompact entirely when kept-recent already exceeds the budget", async () => {
// Append one synthetic message large enough to overflow the model window
// on its own (kept by findCutPoint since keepRecentTokens=4000 falls
// well short of it). Snapcompact CANNOT fit even a single frame; the
// session MUST skip it instead of running and emitting "could not bring
// the context under the limit" every tick.
const model = session.model;
if (!model) throw new Error("Expected model");
const ctxWindow = model.contextWindow ?? 0;
const huge = "a".repeat(ctxWindow * 4);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: huge }],
timestamp: Date.now(),
});
const compactSpy = vi.spyOn(snapcompact, "compact");
const notices: { level: string; message: string }[] = [];
session.subscribe(event => {
if (event.type === "notice") {
notices.push({ level: event.level, message: event.message });
}
});
await expect(session.compact(undefined, { mode: "snapcompact" })).rejects.toThrow();
// snapcompact.compact() MUST NOT be invoked when the budget cannot
// fit even one frame — running it just to reject the result and
// re-emit the warning is the exact loop issue #3247 reports.
expect(compactSpy).not.toHaveBeenCalled();
// The user-facing notice MUST explain the kept-history overflow rather
// than the misleading "could not bring the context under the limit"
// (which implied snapcompact had run and produced an oversized result).
expect(notices.some(n => n.level === "warning" && n.message.includes("kept history"))).toBe(true);
});
});