fix(agent): preserve snapcompact text-only path when budget is near full

chatgpt-codex review on #3249: the helper returned 0 when frameBudget
< FRAME_TOKEN_ESTIMATE, causing the caller to skip snapcompact entirely.
But snapcompact.planArchive has a 'text.length <= 2 * edgeCap' short-
circuit that produces a valid frames:[] archive when the discarded
history is small enough — and the projection charges 0 for that. Hard
return-0 blocked that opportunity, forcing the LLM summarizer fallback
in offline/no-credential sessions where the text-only path would have
landed cleanly.

#computeSnapcompactMaxFrames now distinguishes two near-full cases:
  - frameBudget < 0 → return 0 (kept-recent already exhausted budget;
    no text-only summary can fit either) → caller still skips outright.
  - 0 ≤ frameBudget < FRAME_TOKEN_ESTIMATE → return 1 → snapcompact runs
    and picks the frame-less planArchive branch automatically for small
    discarded histories; the projection guard rejects any actual
    frame-bearing archive that overflows.

Added regression test pinning maxFrames=1 (not 0) in the near-full
window case.
This commit is contained in:
roboomp
2026-06-22 10:16:25 +00:00
parent 5cce507582
commit 65f945f1b7
3 changed files with 72 additions and 9 deletions
@@ -195,4 +195,58 @@ describe("AgentSession snapcompact frame-budget sizing", () => {
// (which implied snapcompact had run and produced an oversized result).
expect(notices.some(n => n.level === "warning" && n.message.includes("kept history"))).toBe(true);
});
it("still invokes snapcompact with maxFrames=1 when the budget can only fit a text-only archive", async () => {
// Reviewer (chatgpt-codex on #3249): when kept-recent leaves less than
// one FRAME_TOKEN_ESTIMATE of headroom but still some room for a
// summary, snapcompact's `text.length <= 2 * edgeCap` short-circuit in
// `planArchive` can still produce a valid frame-less archive that the
// projection accepts (0 frame tokens billed). The helper MUST NOT
// return 0 in that case — it must give snapcompact the minimum
// `maxFrames = 1` cap so that text-only opportunity is taken.
const model = session.model;
if (!model) throw new Error("Expected model");
const ctxWindow = model.contextWindow ?? 0;
// Tune the kept-recent message so frameBudget lands in the
// `[0, FRAME_TOKEN_ESTIMATE)` window: kept-recent + non-message +
// summary reserve is just under `ctxWindow - reserve` but the
// residual is below one frame's token charge. Aim for ~3000 tokens
// of headroom (less than FRAME_TOKEN_ESTIMATE = 5024).
const reserve = Math.max(Math.floor(ctxWindow * 0.15), 16384);
const headroomTokens = 3000;
const targetRecentTokens = ctxWindow - reserve - 4000 /* SUMMARY_TEXT_RESERVE */ - headroomTokens;
// Rough 4-chars-per-token rule for the tiktoken estimator on ASCII.
const filler = "x".repeat(targetRecentTokens * 4);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: filler }],
timestamp: Date.now(),
});
const branchEntries = sessionManager.getBranch();
const lastEntry = branchEntries[branchEntries.length - 1];
if (!lastEntry?.id) throw new Error("Expected branch entry with id");
const compactSpy = vi.spyOn(snapcompact, "compact").mockResolvedValue({
summary: "stubbed snapcompact",
shortSummary: "stub",
firstKeptEntryId: lastEntry.id,
tokensBefore: 100_000,
// Text-only archive: zero frames, modest text edges. The projection
// charges 0 for frames, so the post-compaction context fits.
details: { readFiles: [], modifiedFiles: [] },
preserveData: {
snapcompact: { frames: [], totalChars: 1000, truncatedChars: 0 },
},
});
await session.compact(undefined, { mode: "snapcompact" });
expect(compactSpy).toHaveBeenCalledTimes(1);
const opts = compactSpy.mock.calls[0]?.[1];
// Snapcompact MUST be invoked with the floor cap, never skipped,
// even though one frame charge would overflow the budget — the
// text-only `planArchive` path makes this case recoverable.
expect(opts?.maxFrames).toBe(1);
});
});