diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6aa2c35a1..14e03deb8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed snapcompact auto-compaction looping the "snapcompact could not bring the context under the limit — using an LLM summary instead" warning on every threshold tick for sub-1M-token models (Claude Sonnet 4.5, GPT-5.x, Gemini 2.x). `snapcompact.compact()` was called with no `maxFrames` override, so it defaulted to `MAX_FRAMES_DEFAULT = 80`; the projection in `AgentSession` charges `FRAME_TOKEN_ESTIMATE = 5024` per frame block (the conservative high-res Anthropic ceiling), making 80 × 5024 ≈ 402k frame-token projections that always overflow a 200k budget. `AgentSession.#computeSnapcompactMaxFrames` now sizes the frame cap from the live `(window − reserve − non-message − kept-recent − summary-text reserve)` envelope before invoking snapcompact in both the auto-compaction and manual `/compact` paths, and skips snapcompact outright when the kept-recent slice alone already exceeds the budget (with a clearer notice explaining the cause). ([#3247](https://github.com/can1357/oh-my-pi/issues/3247)) +- Fixed snapcompact auto-compaction looping the "snapcompact could not bring the context under the limit — using an LLM summary instead" warning on every threshold tick for sub-1M-token models (Claude Sonnet 4.5, GPT-5.x, Gemini 2.x). `snapcompact.compact()` was called with no `maxFrames` override, so it defaulted to `MAX_FRAMES_DEFAULT = 80`; the projection in `AgentSession` charges `FRAME_TOKEN_ESTIMATE = 5024` per frame block (the conservative high-res Anthropic ceiling), making 80 × 5024 ≈ 402k frame-token projections that always overflow a 200k budget. `AgentSession.#computeSnapcompactMaxFrames` now sizes the frame cap from the live `(window − reserve − non-message − kept-recent − summary-text reserve)` envelope before invoking snapcompact in both the auto-compaction and manual `/compact` paths. When the residual is below one frame's token charge but still positive, the helper returns `maxFrames = 1` so snapcompact's frame-less `text.length <= 2 * edgeCap` short-circuit (`planArchive`) can still produce a valid text-only archive instead of being skipped; snapcompact is skipped outright only when even that text-only summary cannot fit (kept-recent already exhausted the budget), with a clearer notice explaining the cause. ([#3247](https://github.com/can1357/oh-my-pi/issues/3247)) ## [16.1.14] - 2026-06-22 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 5f9e2c874..a4ca5be32 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -9429,13 +9429,21 @@ export class AgentSession { * high-res Anthropic ceiling), so picking `maxFrames` from this helper makes * {@link #projectSnapcompactContextTokens} succeed by construction. * - * Returns `0` when the kept-recent slice plus the non-message overhead - * already eats the entire budget — at that point snapcompact cannot fit a - * single frame and the caller MUST skip it instead of running just to - * reject the result and re-emit the "could not bring the context under the - * limit" warning every threshold tick. Without this cap, the bundled - * `MAX_FRAMES_DEFAULT = 80` × 5024 tokens = ~402k frame-token projection - * always overflows any sub-1M-token window (issue #3247). + * Returns `0` only when the kept-recent slice plus the non-message overhead + * plus the summary lead-in reserve already exceed the budget — at that + * point even snapcompact's text-only archive path (frames `[]`, the + * `text.length <= 2 * edgeCap` short-circuit in `planArchive`) cannot fit + * and the caller MUST skip it instead of running just to re-emit the + * "could not bring the context under the limit" warning every threshold + * tick. Returns `1` when the frame charge would overflow but the text-only + * path still has room: snapcompact's planner picks the frame-less layout + * automatically when the discarded text fits in the edges, so giving it + * the minimum cap lets it succeed for small archives instead of being + * skipped outright (chatgpt-codex review on #3249). + * + * Without this cap, the bundled `MAX_FRAMES_DEFAULT = 80` × 5024 tokens = + * ~402k frame-token projection always overflows any sub-1M-token window + * (issue #3247). */ #computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number { const ctxWindow = this.model?.contextWindow ?? 0; @@ -9452,7 +9460,8 @@ export class AgentSession { // past the projection check below. const SUMMARY_TEXT_RESERVE = 4000; const frameBudget = ctxWindow - reserve - nonFrameTokens - SUMMARY_TEXT_RESERVE; - if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 0; + if (frameBudget < 0) return 0; + if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1; return Math.min(Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), snapcompact.MAX_FRAMES_DEFAULT); } diff --git a/packages/coding-agent/test/agent-session-snapcompact-budget.test.ts b/packages/coding-agent/test/agent-session-snapcompact-budget.test.ts index dba0a415a..bfde4dbc0 100644 --- a/packages/coding-agent/test/agent-session-snapcompact-budget.test.ts +++ b/packages/coding-agent/test/agent-session-snapcompact-budget.test.ts @@ -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); + }); });