From 756fed844c8fd9fd05a9fbcc8495555dc51d31c2 Mon Sep 17 00:00:00 2001 From: Hugo Lopes <4919100+HugoLopes45@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:31:00 +0200 Subject: [PATCH 1/6] fix(compaction): rescue trailing snapcompact archives past the maintenance threshold A branch whose last entry is a snapcompact CompactionEntry billed past the compaction threshold (FRAME_TOKEN_ESTIMATE x frames) dead-ended on every resume: prepareCompaction returns undefined (nothing after the entry to summarize), and the #4786 elide/image rescue tiers only inspect "message"/"custom_message" entries, so a type:"compaction" tail escaped both and the "Compaction freed too little context" warning re-fired forever. Add a dedicated first rescue tier that rebuilds the SAME archive locally (no LLM, no network) by re-running snapcompact.compact() over the entry's carried-forward source text at a maxFrames derived from the trigger threshold's recovery band instead of the window-fit budget: planArchive truncates the oldest chars to fit, so the rebuilt entry genuinely shrinks. Persisting through appendCompaction lets the write-time superseded-compaction elision drop the stale frame payload from the JSONL, and the pass skips the misleading no-progress warning. Fixes the loop reported in https://github.com/can1357/oh-my-pi/issues/4786#issuecomment-5056055342 Claude-Session: https://claude.ai/code/session_014rh4JyWFkxgMhgFaEf8VBY --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/session/agent-session.ts | 189 ++++++++- ...session-snapcompact-frame-dead-end.test.ts | 362 ++++++++++++++++++ packages/snapcompact/test/snapcompact.test.ts | 28 ++ 4 files changed, 563 insertions(+), 17 deletions(-) create mode 100644 packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 536e7fe87..0a477bf4a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Fixed +- Fixed auto-compaction re-triggering the "Compaction freed too little context" warning on every resume when the branch's last entry was an over-threshold snapcompact archive: the dead-end rescue now rebuilds the trailing archive locally at a threshold-derived frame budget (superseding the stale frame payload) instead of pausing, since the elide/image tiers can never touch a compaction entry (#4786). - Fixed the setup wizard hiding the selected row on short terminals (e.g. 24x80): the provider sign-in, theme, and web-search lists now fit their windows to the visible height, and decorative chrome (sign-in hint, theme mock preview) yields to the list when space is tight. ## [17.0.8] - 2026-07-22 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 4ccc36c67..8f51169bc 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13780,6 +13780,17 @@ export class AgentSession { options: { skipElide: boolean; hasProgress: () => boolean }, ): Promise { if (signal.aborted) return false; + // Tier 0 — a snapcompact pass whose just-written frame archive is itself + // the over-budget cost (each pass re-renders the carried-forward text + // into MORE frames, so the archive grows past the recovery band and the + // elide/image tiers below can never shrink it): rebuild the archive at + // a threshold-derived frame budget. + const frameRescued = await this.#rescueSnapcompactFrameOverflow( + this.sessionManager.getBranch(), + this.settings.getGroup("compaction"), + signal, + ); + if (frameRescued && options.hasProgress()) return true; let elided = 0; let elidedTokens = 0; let elideSink = "placeholders"; @@ -13836,6 +13847,124 @@ export class AgentSession { return `elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${tokensFreed.toLocaleString()} tokens) to ${sink}`; } + /** + * Frame budget for {@link #rescueSnapcompactFrameOverflow}: targets + * `COMPACTION_RECOVERY_BAND × threshold` (the same band + * {@link #compactionCreatedHeadroom} re-tests), not the window-fit budget + * {@link #computeSnapcompactMaxFrames} sizes against — a rebuilt archive + * must land back under the maintenance trigger, or the next settle + * re-enters the same dead-end. Cap reserve mirrors + * #computeSnapcompactMaxFrames (text edges + summary template). + */ + #computeSnapcompactRescueMaxFrames(settings: CompactionSettings): number { + const ctxWindow = this.model?.contextWindow ?? 0; + if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget()); + const thresholdTokens = resolveThresholdTokens(ctxWindow, settings); + const recoveryBandTokens = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND); + const baseTokens = computeNonMessageTokens(this); + const shape = snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")); + const edgeCap = snapcompact.geometry(shape).capacity; + const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4); + const SUMMARY_TEMPLATE_TOKENS = 2000; + const frameBudget = recoveryBandTokens - baseTokens - textEdgeTokens - SUMMARY_TEMPLATE_TOKENS; + if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1; + return Math.max(1, Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE)); + } + + /** + * Dead-end rescue for a branch whose latest snapcompact CompactionEntry is + * itself billed past the maintenance threshold + * (`FRAME_TOKEN_ESTIMATE × frames`). Reaching the `!preparation` dead-end + * proves everything after that entry is already kept-recent (nothing to + * summarize), so the archive is the irreducible cost — and the elide/image + * tiers can never touch it: `collectShakeRegions` and `dropImages()` only + * inspect "message"/"custom_message" entries, so a `type: "compaction"` + * entry falls through both and the session re-warns on every resume (the + * shape issue #4786's rescue does not cover). + * + * Rebuilds the SAME archive locally — no LLM, no network — by re-running + * `snapcompact.compact()` over the entry's carried-forward source text at + * a maxFrames derived from the trigger threshold instead of the window: + * `planArchive` truncates the oldest chars to fit, so the rebuilt entry + * genuinely shrinks. The rebuilt entry keeps the stale entry's + * `firstKeptEntryId`, so the kept tail is untouched, and persisting + * through `appendCompaction()` lets the write-time superseded-compaction + * elision drop the stale frame payload from the JSONL automatically. + */ + async #rescueSnapcompactFrameOverflow( + branchEntries: SessionEntry[], + settings: CompactionSettings, + signal: AbortSignal, + ): Promise { + if (signal.aborted) return false; + // Re-rendering frames needs a vision-capable model, same gate as the + // snapcompact strategy path. + if (!this.model?.input.includes("image")) return false; + const staleEntry = getLatestCompactionEntry(branchEntries); + if (!staleEntry) return false; + const archive = snapcompact.getPreservedArchive(staleEntry.preserveData); + if (!archive || archive.frames.length <= 1) return false; + const archiveText = snapcompact.archiveSourceText(archive); + if (!archiveText) return false; + const maxFrames = this.#computeSnapcompactRescueMaxFrames(settings); + if (maxFrames >= archive.frames.length) return false; + + const staleDetails = staleEntry.details as snapcompact.CompactionDetails | undefined; + const fileOps = snapcompact.createFileOps(); + for (const file of staleDetails?.readFiles ?? []) fileOps.read.add(file); + for (const file of staleDetails?.modifiedFiles ?? []) fileOps.edited.add(file); + const shapeSetting = this.settings.get("snapcompact.shape"); + const shape = snapcompact.resolveShapeForText(archiveText, this.model, shapeSetting); + let result: snapcompact.CompactionResult; + try { + result = await snapcompact.compact( + { + firstKeptEntryId: staleEntry.firstKeptEntryId, + messagesToSummarize: [], + turnPrefixMessages: [], + tokensBefore: staleEntry.tokensBefore, + previousSummary: staleEntry.summary, + previousPreserveData: staleEntry.preserveData, + fileOps, + }, + { + convertToLlm, + model: this.model, + ...(shapeSetting === "auto" ? {} : { shape }), + maxFrames, + }, + ); + } catch (error) { + logger.warn("Dead-end snapcompact frame rescue failed", { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + if (signal.aborted) return false; + const rebuilt = snapcompact.getPreservedArchive(result.preserveData); + if (!rebuilt || rebuilt.frames.length >= archive.frames.length) return false; + + this.sessionManager.appendCompaction( + result.summary, + result.shortSummary, + result.firstKeptEntryId, + result.tokensBefore, + result.details, + false, + result.preserveData, + ); + const sessionContext = this.buildDisplaySessionContext(); + this.agent.replaceMessages(sessionContext.messages); + this.#rebasePendingContextSnapshotAfterCompaction(); + this.#closeCodexProviderSessionsForHistoryRewrite(); + this.emitNotice( + "info", + `Compaction dead-end recovery: rebuilt the trailing snapcompact archive at a smaller frame budget (${archive.frames.length} → ${rebuilt.frames.length} frames) so maintenance could make progress.`, + "compaction", + ); + return true; + } + /** * Internal: Run auto-compaction with events. * @@ -14049,22 +14178,41 @@ export class AgentSession { // strategy pass (it tried and found nothing); skip entirely on the // idle timer (it re-checks usage on its own cadence). let rescueRewroteHistory = false; + // A trailing snapcompact CompactionEntry is invisible to both rescue + // tiers below (they only inspect message entries) and to + // prepareCompaction itself (last-entry-is-compaction guard), so a + // frame archive billed past the threshold dead-ends here on every + // resume. Rebuild it at a threshold-derived frame budget first; when + // that lands, the branch is already fully compacted — there is + // nothing left to summarize, so skip the elide/image tiers (provably + // no-ops on this shape) and the no-progress warning entirely. + let frameOverflowRescued = false; if (reason !== "idle") { - await this.#rescueCompactionDeadEnd(autoCompactionSignal, { - skipElide: fallbackFromShake, - hasProgress: () => { - // Only reached when a tier actually freed something, so the - // branch has been rewritten either way. - rescueRewroteHistory = true; - pathEntriesForCompaction = this.sessionManager.getBranch(); - preparation = prepareCompaction( - pathEntriesForCompaction, - compactionSettings, - autoCompactionCandidates, - ); - return preparation !== undefined; - }, - }); + frameOverflowRescued = await this.#rescueSnapcompactFrameOverflow( + pathEntriesForCompaction, + compactionSettings, + autoCompactionSignal, + ); + if (frameOverflowRescued) { + rescueRewroteHistory = true; + pathEntriesForCompaction = this.sessionManager.getBranch(); + } else { + await this.#rescueCompactionDeadEnd(autoCompactionSignal, { + skipElide: fallbackFromShake, + hasProgress: () => { + // Only reached when a tier actually freed something, so the + // branch has been rewritten either way. + rescueRewroteHistory = true; + pathEntriesForCompaction = this.sessionManager.getBranch(); + preparation = prepareCompaction( + pathEntriesForCompaction, + compactionSettings, + autoCompactionCandidates, + ); + return preparation !== undefined; + }, + }); + } } if (!preparation) { await this.#emitSessionEvent({ @@ -14075,9 +14223,16 @@ export class AgentSession { willRetry: false, skipped: true, }); - const noProgressDeadEnd = reason !== "idle"; + const noProgressDeadEnd = reason !== "idle" && !frameOverflowRescued; let continuationScheduled = false; - if (!suppressContinuation && this.agent.hasQueuedMessages()) { + if (frameOverflowRescued) { + continuationScheduled = this.#scheduleCompactionContinuation({ + generation, + autoContinue: shouldAutoContinue && this.#compactionCreatedHeadroom(), + terminalTextAnswer, + suppressContinuation, + }); + } else if (!suppressContinuation && this.agent.hasQueuedMessages()) { this.#scheduleAgentContinue({ delayMs: 100, generation, diff --git a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts new file mode 100644 index 000000000..3f03ccafb --- /dev/null +++ b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts @@ -0,0 +1,362 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Agent } from "@oh-my-pi/pi-agent-core"; +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 { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; +import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; +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 type { CompactionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils"; +import * as snapcompact from "@oh-my-pi/snapcompact"; + +/** + * Regression test for the snapcompact frame dead-end. + * + * A branch whose LAST entry is a snapcompact CompactionEntry billed past the + * maintenance threshold (FRAME_TOKEN_ESTIMATE × frames) dead-ends every pass: + * prepareCompaction returns undefined (nothing after the entry to summarize), + * and the elide/image rescue tiers only inspect "message"/"custom_message" + * entries, so the `type: "compaction"` tail escapes both and the no-progress + * warning re-fires on every resume — the shape issue #4786's rescue does not + * cover. + * + * The fix rebuilds the trailing archive locally via snapcompact.compact() at + * a threshold-derived frame budget (planArchive truncates the oldest chars), + * persists it through appendCompaction (write-time elision drops the stale + * frame payload), and skips the misleading no-progress warning. + */ +describe("AgentSession snapcompact frame dead-end rescue", () => { + let tempDir: TempDir; + let session: AgentSession; + let sessionManager: SessionManager; + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; + + const NOTICE_SOURCE = "compaction"; + const NO_PROGRESS_FRAGMENT = "Compaction freed too little context to make progress"; + const SEEDED_FRAME_COUNT = 16; + + function makeFrames(count: number): Record[] { + return Array.from({ length: count }, (_, i) => ({ + data: btoa(`stale-frame-${i}`), + mimeType: "image/png", + cols: 4, + rows: 2, + chars: 8, + })); + } + + function makeArchivePreserveData(frameCount: number): Record { + return { + snapcompact: { + frames: makeFrames(frameCount), + text: `HEAD sentinel. ${"Archived history line. ".repeat(200)}TAIL sentinel.`, + totalChars: 4600, + truncatedChars: 0, + }, + }; + } + + async function createSession(options: { + frameCount: number; + visionModel?: boolean; + /** Seed no compaction entry; instead a hook supplies one carrying this + * many frames — exercising the POST-PASS dead-end (a completed pass + * whose just-written archive is itself the over-budget cost). */ + hookArchiveFrames?: number; + }): Promise { + tempDir = TempDir.createSync("@pi-snapcompact-frame-dead-end-"); + 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()); + + let extensionRunner: ExtensionRunner | undefined; + if (options.hookArchiveFrames !== undefined) { + // Short-circuit the summarization LLM call with a hook-supplied + // compaction whose archive carries the oversized frame payload — + // mirrors agent-session-auto-compaction-progress-guard.test.ts. + const extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions"); + fs.mkdirSync(extensionsDir, { recursive: true }); + const extensionPath = path.join(extensionsDir, "compaction-short-circuit.ts"); + fs.writeFileSync( + extensionPath, + [ + "export default function(pi) {", + '\tpi.on("session_before_compact", async (event) => {', + "\t\treturn {", + "\t\t\tcompaction: {", + '\t\t\t\tsummary: "compacted",', + "\t\t\t\tshortSummary: undefined,", + "\t\t\t\tfirstKeptEntryId: event.preparation.firstKeptEntryId,", + "\t\t\t\ttokensBefore: event.preparation.tokensBefore,", + "\t\t\t\tdetails: {},", + `\t\t\t\tpreserveData: ${JSON.stringify(makeArchivePreserveData(options.hookArchiveFrames))},`, + "\t\t\t},", + "\t\t};", + "\t});", + "}", + ].join("\n"), + ); + const extensionsResult = await loadExtensions([extensionPath], tempDir.path()); + extensionRunner = new ExtensionRunner( + extensionsResult.extensions, + extensionsResult.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + } + + const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!bundled) { + throw new Error("Expected built-in anthropic model to exist"); + } + // Pin the window: threshold/band math below is tuned to 200k. + const model = { + ...bundled, + contextWindow: 200_000, + maxTokens: 64_000, + ...(options.visionModel === false ? { input: ["text" as const] } : {}), + }; + + // Seed the poisoned shape: one user turn, then (unless the hook supplies + // the archive) a trailing snapcompact CompactionEntry as the LAST branch + // entry — the real prepareCompaction must hit its + // last-entry-is-compaction guard organically. + const userEntryId = sessionManager.appendMessage({ + role: "user", + content: "hello", + timestamp: Date.now(), + }); + if (options.hookArchiveFrames === undefined) { + sessionManager.appendCompaction( + "Archived history onto stale snapcompact frames.", + "stale snapcompact archive", + userEntryId, + 150_000, + { readFiles: ["src/a.ts"], modifiedFiles: ["src/b.ts"] }, + false, + makeArchivePreserveData(options.frameCount), + ); + } + + const agent = new Agent({ + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }); + + session = new AgentSession({ + agent, + sessionManager, + settings: Settings.isolated({ + "compaction.autoContinue": true, + "compaction.strategy": "snapcompact", + // Fixed trigger so the rescue's threshold-derived frame budget is + // deterministic: band 0.8 × 60k = 48k minus base/edge reserves + // yields well under 16 frames — the rebuild must shrink. + "compaction.thresholdTokens": 60_000, + }), + modelRegistry, + extensionRunner, + }); + } + + afterEach(async () => { + try { + await session?.dispose(); + } finally { + authStorage?.close(); + await tempDir?.remove(); + vi.restoreAllMocks(); + } + }); + + function collectNotices() { + const notices: { level: string; message: string; source?: string }[] = []; + session.subscribe(event => { + if (event.type === "notice") { + notices.push({ level: event.level, message: event.message, source: event.source }); + } + }); + return notices; + } + + /** Threshold-tripping assistant turn against the 60k trigger. */ + function highUsageAssistant() { + return { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Done." }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + stopReason: "stop" as const, + usage: { + input: 190000, + output: 1000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 191000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + } + + async function triggerMaintenance(): Promise { + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_end") onCompactionDone(); + }); + const assistantMsg = highUsageAssistant(); + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); + await compactionDone; + await session.waitForIdle(); + } + + it("rebuilds a stale trailing snapcompact archive and skips the no-progress warning", async () => { + await createSession({ frameCount: SEEDED_FRAME_COUNT }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 }); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + const compactSpy = vi.spyOn(snapcompact, "compact").mockResolvedValue({ + summary: "Rebuilt archive at a smaller frame budget.", + shortSummary: "rebuilt snapcompact archive", + firstKeptEntryId: (sessionManager.getBranch()[0] as { id: string }).id, + tokensBefore: 150_000, + details: { readFiles: ["src/a.ts"], modifiedFiles: ["src/b.ts"] }, + preserveData: makeArchivePreserveData(4), + }); + + const notices = collectNotices(); + await triggerMaintenance(); + + expect(compactSpy).toHaveBeenCalledTimes(1); + const [, compactOptions] = compactSpy.mock.calls[0] as [unknown, { maxFrames?: number }]; + expect(compactOptions.maxFrames).toBeDefined(); + expect(compactOptions.maxFrames as number).toBeLessThan(SEEDED_FRAME_COUNT); + + // The rebuilt entry supersedes the stale one; write-time elision must + // have dropped the stale frame payload from the persisted branch. + const compactions = sessionManager.getBranch().filter(e => e.type === "compaction") as CompactionEntry[]; + expect(compactions.length).toBe(2); + const [stale, rebuilt] = compactions; + expect(stale.summary).toContain("Superseded compaction summary elided"); + expect(stale.preserveData).toBeUndefined(); + const rebuiltArchive = snapcompact.getPreservedArchive(rebuilt.preserveData); + expect(rebuiltArchive?.frames.length).toBe(4); + + // The frame rescue fired first: the elide/image tiers (provable no-ops + // on a compaction tail) were skipped, and no misleading warning. + expect(shakeSpy).not.toHaveBeenCalled(); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(0); + const recovery = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes("dead-end recovery")); + expect(recovery.length).toBe(1); + expect(recovery[0].level).toBe("info"); + }); + + it("rebuilds the just-written archive when a completed pass dead-ends on its own frames", async () => { + // POST-PASS shape (observed live on 17.0.8): compaction ran and wrote a + // frame archive, but the archive itself is the over-budget cost — each + // pass re-renders the carried-forward text into MORE frames. The + // elide/image tiers can't shrink it; tier 0 of the dead-end rescue must. + await createSession({ frameCount: 0, hookArchiveFrames: SEEDED_FRAME_COUNT }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + let rebuiltArchiveApplied = false; + vi.spyOn(session, "getContextUsage").mockImplementation(() => + rebuiltArchiveApplied + ? { tokens: 30000, contextWindow: 200000, percent: 15 } + : { tokens: 190000, contextWindow: 200000, percent: 95 }, + ); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + const compactSpy = vi.spyOn(snapcompact, "compact").mockImplementation(async () => { + rebuiltArchiveApplied = true; + return { + summary: "Rebuilt archive at a smaller frame budget.", + shortSummary: "rebuilt snapcompact archive", + firstKeptEntryId: (sessionManager.getBranch()[0] as { id: string }).id, + tokensBefore: 150_000, + details: { readFiles: [], modifiedFiles: [] }, + preserveData: makeArchivePreserveData(4), + }; + }); + + const notices = collectNotices(); + await triggerMaintenance(); + + expect(compactSpy).toHaveBeenCalledTimes(1); + const compactions = sessionManager.getBranch().filter(e => e.type === "compaction") as CompactionEntry[]; + expect(compactions.length).toBe(2); + const [hookWritten, rebuilt] = compactions; + expect(hookWritten.summary).toContain("Superseded compaction summary elided"); + expect(hookWritten.preserveData).toBeUndefined(); + expect(snapcompact.getPreservedArchive(rebuilt.preserveData)?.frames.length).toBe(4); + expect(shakeSpy).not.toHaveBeenCalled(); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(0); + const recovery = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes("dead-end recovery")); + expect(recovery.length).toBe(1); + }); + + it("still warns once when the trailing archive is already at the minimum frame count", async () => { + await createSession({ frameCount: 1 }); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue(); + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 }); + vi.spyOn(session, "shake").mockResolvedValue({ + mode: "elide", + toolResultsDropped: 0, + blocksDropped: 0, + tokensFreed: 0, + }); + const compactSpy = vi.spyOn(snapcompact, "compact"); + + const notices = collectNotices(); + await triggerMaintenance(); + + expect(compactSpy).not.toHaveBeenCalled(); + expect(promptSpy).not.toHaveBeenCalled(); + expect(continueSpy).not.toHaveBeenCalled(); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(1); + expect(noProgress[0].level).toBe("warning"); + }); + + it("skips the frame rescue when the active model is not vision-capable", async () => { + await createSession({ frameCount: SEEDED_FRAME_COUNT, visionModel: false }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 }); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + const compactSpy = vi.spyOn(snapcompact, "compact"); + + const notices = collectNotices(); + await triggerMaintenance(); + + // Text-only model: no frame re-render; existing tiers still run and the + // existing dead-end warning is preserved. + expect(compactSpy).not.toHaveBeenCalled(); + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(1); + }); +}); diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index e49dcb573..971041fd9 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -944,6 +944,34 @@ describe("compact", () => { expect(archive?.frames.length).toBe(5); }); + it("re-compacting with a smaller maxFrames than the previous archive shrinks the frame count", async () => { + const first = await snapcompact.compact( + makePreparation({ + messagesToSummarize: [ + createUserMessage(`HEAD SENTINEL. ${"Important fact number one. ".repeat(1000)}TAIL SENTINEL.`), + ], + }), + { frameSize: TEST_FRAME_SIZE, maxFrames: 7 }, + ); + const firstArchive = snapcompact.getPreservedArchive(first.preserveData); + expect(firstArchive?.frames.length).toBe(7); + + // No new messages: rebuild the SAME archive at a reduced budget — the + // dead-end rescue path for a trailing over-threshold archive. + const shrunk = await snapcompact.compact( + makePreparation({ + messagesToSummarize: [], + previousSummary: first.summary, + previousPreserveData: first.preserveData, + }), + { frameSize: TEST_FRAME_SIZE, maxFrames: 3 }, + ); + const shrunkArchive = snapcompact.getPreservedArchive(shrunk.preserveData); + expect(shrunkArchive?.frames.length).toBeGreaterThan(0); + expect(shrunkArchive?.frames.length).toBeLessThanOrEqual(3); + expect(shrunkArchive?.textHead ?? shrunkArchive?.text).toContain("HEAD SENTINEL."); + }); + it("keeps the original text head across later compactions", async () => { const first = await snapcompact.compact( makePreparation({ From 42f530f1878da6bbcac551096a56cca8e6f777df Mon Sep 17 00:00:00 2001 From: Hugo Lopes <4919100+HugoLopes45@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:35:09 +0200 Subject: [PATCH 2/6] fix(compaction): gate frame-rescue success on real headroom and cap rescue frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (Codex on #6362): - The !preparation frame rescue now counts as complete only when the rebuild actually created headroom; otherwise the elide/image tiers still run and the no-progress warning stays — a frame-count shrink alone must not suppress it when the oversized tail is a kept message/tool result the archive rescue cannot touch. - #computeSnapcompactRescueMaxFrames now applies the same MAX_FRAMES_DEFAULT / maxFramesForDataBudget caps as #computeSnapcompactMaxFrames, so a threshold-derived count can never exceed what the rebuilt prompt can attach. Claude-Session: https://claude.ai/code/session_014rh4JyWFkxgMhgFaEf8VBY --- .../coding-agent/src/session/agent-session.ts | 41 ++++++++----- ...session-snapcompact-frame-dead-end.test.ts | 57 ++++++++++++++++--- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 8f51169bc..f77998704 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13868,7 +13868,17 @@ export class AgentSession { const SUMMARY_TEMPLATE_TOKENS = 2000; const frameBudget = recoveryBandTokens - baseTokens - textEdgeTokens - SUMMARY_TEMPLATE_TOKENS; if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1; - return Math.max(1, Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE)); + // Same hard caps as #computeSnapcompactMaxFrames: a threshold-derived + // count above the per-request payload budget would "shrink" a huge + // archive to a frame count the rebuilt prompt can never attach anyway. + return Math.max( + 1, + Math.min( + Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), + snapcompact.MAX_FRAMES_DEFAULT, + snapcompact.maxFramesForDataBudget(), + ), + ); } /** @@ -14178,15 +14188,18 @@ export class AgentSession { // strategy pass (it tried and found nothing); skip entirely on the // idle timer (it re-checks usage on its own cadence). let rescueRewroteHistory = false; - // A trailing snapcompact CompactionEntry is invisible to both rescue - // tiers below (they only inspect message entries) and to - // prepareCompaction itself (last-entry-is-compaction guard), so a - // frame archive billed past the threshold dead-ends here on every - // resume. Rebuild it at a threshold-derived frame budget first; when - // that lands, the branch is already fully compacted — there is - // nothing left to summarize, so skip the elide/image tiers (provably - // no-ops on this shape) and the no-progress warning entirely. + // A snapcompact CompactionEntry is invisible to both rescue tiers + // below (they only inspect message entries) and to prepareCompaction + // itself (last-entry-is-compaction guard), so a frame archive billed + // past the threshold dead-ends here on every resume. Rebuild it at a + // threshold-derived frame budget first — but treat that as complete + // only when it actually created headroom: the latest archive may not + // be the oversized tail (e.g. a huge kept tool result after it), and + // declaring victory on a mere frame-count shrink would skip the + // elide/image tiers that can still reach that tail and suppress a + // warning the user should see. let frameOverflowRescued = false; + let frameRescueCreatedHeadroom = false; if (reason !== "idle") { frameOverflowRescued = await this.#rescueSnapcompactFrameOverflow( pathEntriesForCompaction, @@ -14196,7 +14209,9 @@ export class AgentSession { if (frameOverflowRescued) { rescueRewroteHistory = true; pathEntriesForCompaction = this.sessionManager.getBranch(); - } else { + frameRescueCreatedHeadroom = this.#compactionCreatedHeadroom(); + } + if (!frameRescueCreatedHeadroom) { await this.#rescueCompactionDeadEnd(autoCompactionSignal, { skipElide: fallbackFromShake, hasProgress: () => { @@ -14223,12 +14238,12 @@ export class AgentSession { willRetry: false, skipped: true, }); - const noProgressDeadEnd = reason !== "idle" && !frameOverflowRescued; + const noProgressDeadEnd = reason !== "idle" && !frameRescueCreatedHeadroom; let continuationScheduled = false; - if (frameOverflowRescued) { + if (frameRescueCreatedHeadroom) { continuationScheduled = this.#scheduleCompactionContinuation({ generation, - autoContinue: shouldAutoContinue && this.#compactionCreatedHeadroom(), + autoContinue: shouldAutoContinue, terminalTextAnswer, suppressContinuation, }); diff --git a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts index 3f03ccafb..ebfdb4ac9 100644 --- a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts +++ b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts @@ -228,17 +228,27 @@ describe("AgentSession snapcompact frame dead-end rescue", () => { await createSession({ frameCount: SEEDED_FRAME_COUNT }); vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); vi.spyOn(session.agent, "continue").mockResolvedValue(); - vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 }); + // Over the band until the rescue rebuilds the archive, then well under — + // the rescue only counts as complete when it creates real headroom. + let rebuiltArchiveApplied = false; + vi.spyOn(session, "getContextUsage").mockImplementation(() => + rebuiltArchiveApplied + ? { tokens: 30000, contextWindow: 200000, percent: 15 } + : { tokens: 190000, contextWindow: 200000, percent: 95 }, + ); const shakeSpy = vi .spyOn(session, "shake") .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); - const compactSpy = vi.spyOn(snapcompact, "compact").mockResolvedValue({ - summary: "Rebuilt archive at a smaller frame budget.", - shortSummary: "rebuilt snapcompact archive", - firstKeptEntryId: (sessionManager.getBranch()[0] as { id: string }).id, - tokensBefore: 150_000, - details: { readFiles: ["src/a.ts"], modifiedFiles: ["src/b.ts"] }, - preserveData: makeArchivePreserveData(4), + const compactSpy = vi.spyOn(snapcompact, "compact").mockImplementation(async () => { + rebuiltArchiveApplied = true; + return { + summary: "Rebuilt archive at a smaller frame budget.", + shortSummary: "rebuilt snapcompact archive", + firstKeptEntryId: (sessionManager.getBranch()[0] as { id: string }).id, + tokensBefore: 150_000, + details: { readFiles: ["src/a.ts"], modifiedFiles: ["src/b.ts"] }, + preserveData: makeArchivePreserveData(4), + }; }); const notices = collectNotices(); @@ -315,6 +325,37 @@ describe("AgentSession snapcompact frame dead-end rescue", () => { expect(recovery.length).toBe(1); }); + it("still runs the elide tiers and warns when the frame rebuild frees too little", async () => { + // Codex review on #6362: the latest archive may not be the oversized + // tail (e.g. a huge kept tool result sits after it). A frame-count + // shrink alone must NOT count as success — the elide/image tiers still + // get their shot at the real tail, and the no-progress warning stays. + await createSession({ frameCount: SEEDED_FRAME_COUNT }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + // Usage stays over the band even after the rebuild. + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 }); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + vi.spyOn(snapcompact, "compact").mockResolvedValue({ + summary: "Rebuilt archive at a smaller frame budget.", + shortSummary: "rebuilt snapcompact archive", + firstKeptEntryId: (sessionManager.getBranch()[0] as { id: string }).id, + tokensBefore: 150_000, + details: { readFiles: [], modifiedFiles: [] }, + preserveData: makeArchivePreserveData(4), + }); + + const notices = collectNotices(); + await triggerMaintenance(); + + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(1); + expect(noProgress[0].level).toBe("warning"); + }); + it("still warns once when the trailing archive is already at the minimum frame count", async () => { await createSession({ frameCount: 1 }); const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); From ef952c6d88d269b501d0a568b82b7322957ae310 Mon Sep 17 00:00:00 2001 From: Hugo Lopes <4919100+HugoLopes45@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:37:59 +0200 Subject: [PATCH 3/6] fix(compaction): mirror post-compaction bookkeeping in the frame rescue Review follow-up (Codex on #6362): the rescue's replaceMessages() rebuild drops the transient plan-reference message, so clear #planReferenceSent (#1246) and reset advisor runtimes / todo phases exactly like the regular compaction append path. Claude-Session: https://claude.ai/code/session_014rh4JyWFkxgMhgFaEf8VBY --- packages/coding-agent/src/session/agent-session.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f77998704..862ebe1f1 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13966,6 +13966,13 @@ export class AgentSession { const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); this.#rebasePendingContextSnapshotAfterCompaction(); + // Same post-rewrite bookkeeping as the regular compaction append: the + // rebuilt context no longer carries the transient plan reference (#1246), + // and advisor cursors / todo phases were derived from the replaced + // history. + this.#planReferenceSent = false; + this.#resetAllAdvisorRuntimes(); + this.#syncTodoPhasesFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); this.emitNotice( "info", From d9417927bcacf75e6caeaa0e4259248449ea6f11 Mon Sep 17 00:00:00 2001 From: Hugo Lopes <4919100+HugoLopes45@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:59:15 +0200 Subject: [PATCH 4/6] fix(compaction): surface the frame rescue as a real compaction to the TUI and extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (Codex on #6362): - #rescueSnapcompactFrameOverflow now returns the CompactionResult and emits session_compact for the rebuilt entry, so extensions see the entry that is actually active instead of (only) the one the rescue superseded. - The no-preparation auto_compaction_end now carries that result instead of {result: undefined, skipped: true} when the rescue rewrote history — the TUI rebuilds the transcript on result, so a successful rescue is no longer presented as a benign no-op. Claude-Session: https://claude.ai/code/session_014rh4JyWFkxgMhgFaEf8VBY --- .../coding-agent/src/session/agent-session.ts | 54 ++++++++++++------- ...session-snapcompact-frame-dead-end.test.ts | 18 +++++++ 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 862ebe1f1..f23a9f413 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13785,12 +13785,12 @@ export class AgentSession { // into MORE frames, so the archive grows past the recovery band and the // elide/image tiers below can never shrink it): rebuild the archive at // a threshold-derived frame budget. - const frameRescued = await this.#rescueSnapcompactFrameOverflow( + const frameRescue = await this.#rescueSnapcompactFrameOverflow( this.sessionManager.getBranch(), this.settings.getGroup("compaction"), signal, ); - if (frameRescued && options.hasProgress()) return true; + if (frameRescue !== undefined && options.hasProgress()) return true; let elided = 0; let elidedTokens = 0; let elideSink = "placeholders"; @@ -13905,19 +13905,19 @@ export class AgentSession { branchEntries: SessionEntry[], settings: CompactionSettings, signal: AbortSignal, - ): Promise { - if (signal.aborted) return false; + ): Promise { + if (signal.aborted) return undefined; // Re-rendering frames needs a vision-capable model, same gate as the // snapcompact strategy path. - if (!this.model?.input.includes("image")) return false; + if (!this.model?.input.includes("image")) return undefined; const staleEntry = getLatestCompactionEntry(branchEntries); - if (!staleEntry) return false; + if (!staleEntry) return undefined; const archive = snapcompact.getPreservedArchive(staleEntry.preserveData); - if (!archive || archive.frames.length <= 1) return false; + if (!archive || archive.frames.length <= 1) return undefined; const archiveText = snapcompact.archiveSourceText(archive); - if (!archiveText) return false; + if (!archiveText) return undefined; const maxFrames = this.#computeSnapcompactRescueMaxFrames(settings); - if (maxFrames >= archive.frames.length) return false; + if (maxFrames >= archive.frames.length) return undefined; const staleDetails = staleEntry.details as snapcompact.CompactionDetails | undefined; const fileOps = snapcompact.createFileOps(); @@ -13948,13 +13948,13 @@ export class AgentSession { logger.warn("Dead-end snapcompact frame rescue failed", { error: error instanceof Error ? error.message : String(error), }); - return false; + return undefined; } - if (signal.aborted) return false; + if (signal.aborted) return undefined; const rebuilt = snapcompact.getPreservedArchive(result.preserveData); - if (!rebuilt || rebuilt.frames.length >= archive.frames.length) return false; + if (!rebuilt || rebuilt.frames.length >= archive.frames.length) return undefined; - this.sessionManager.appendCompaction( + const rebuiltEntryId = this.sessionManager.appendCompaction( result.summary, result.shortSummary, result.firstKeptEntryId, @@ -13974,12 +13974,24 @@ export class AgentSession { this.#resetAllAdvisorRuntimes(); this.#syncTodoPhasesFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); + // Extensions must see the entry that is now active, not (only) the one + // this rebuild just superseded — mirror the regular append path's hook. + const rebuiltEntry = this.sessionManager.getEntries().find(e => e.id === rebuiltEntryId) as + | CompactionEntry + | undefined; + if (this.#extensionRunner && rebuiltEntry) { + await this.#extensionRunner.emit({ + type: "session_compact", + compactionEntry: rebuiltEntry, + fromExtension: false, + }); + } this.emitNotice( "info", `Compaction dead-end recovery: rebuilt the trailing snapcompact archive at a smaller frame budget (${archive.frames.length} → ${rebuilt.frames.length} frames) so maintenance could make progress.`, "compaction", ); - return true; + return result; } /** @@ -14205,15 +14217,15 @@ export class AgentSession { // declaring victory on a mere frame-count shrink would skip the // elide/image tiers that can still reach that tail and suppress a // warning the user should see. - let frameOverflowRescued = false; + let frameRescueResult: snapcompact.CompactionResult | undefined; let frameRescueCreatedHeadroom = false; if (reason !== "idle") { - frameOverflowRescued = await this.#rescueSnapcompactFrameOverflow( + frameRescueResult = await this.#rescueSnapcompactFrameOverflow( pathEntriesForCompaction, compactionSettings, autoCompactionSignal, ); - if (frameOverflowRescued) { + if (frameRescueResult) { rescueRewroteHistory = true; pathEntriesForCompaction = this.sessionManager.getBranch(); frameRescueCreatedHeadroom = this.#compactionCreatedHeadroom(); @@ -14237,13 +14249,17 @@ export class AgentSession { } } if (!preparation) { + // A successful frame rescue rewrote history and activated a new + // compaction entry — surface it as a real (non-skipped) result so + // the TUI rebuilds the transcript instead of treating the pass as + // a benign no-op. await this.#emitSessionEvent({ type: "auto_compaction_end", action, - result: undefined, + result: frameRescueResult, aborted: false, willRetry: false, - skipped: true, + skipped: frameRescueResult === undefined, }); const noProgressDeadEnd = reason !== "idle" && !frameRescueCreatedHeadroom; let continuationScheduled = false; diff --git a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts index ebfdb4ac9..d74703314 100644 --- a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts +++ b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts @@ -252,9 +252,20 @@ describe("AgentSession snapcompact frame dead-end rescue", () => { }); const notices = collectNotices(); + const compactionEnds: { result?: unknown; skipped?: boolean }[] = []; + session.subscribe(event => { + if (event.type === "auto_compaction_end") { + compactionEnds.push({ result: event.result, skipped: event.skipped }); + } + }); await triggerMaintenance(); expect(compactSpy).toHaveBeenCalledTimes(1); + // The rescue rewrote history: the pass must surface a real result (TUI + // rebuilds on it), not a skipped no-op. + expect(compactionEnds.length).toBe(1); + expect(compactionEnds[0].result).toBeTruthy(); + expect(compactionEnds[0].skipped).toBeFalsy(); const [, compactOptions] = compactSpy.mock.calls[0] as [unknown, { maxFrames?: number }]; expect(compactOptions.maxFrames).toBeDefined(); expect(compactOptions.maxFrames as number).toBeLessThan(SEEDED_FRAME_COUNT); @@ -309,6 +320,7 @@ describe("AgentSession snapcompact frame dead-end rescue", () => { }); const notices = collectNotices(); + const emitSpy = vi.spyOn(ExtensionRunner.prototype, "emit"); await triggerMaintenance(); expect(compactSpy).toHaveBeenCalledTimes(1); @@ -318,6 +330,12 @@ describe("AgentSession snapcompact frame dead-end rescue", () => { expect(hookWritten.summary).toContain("Superseded compaction summary elided"); expect(hookWritten.preserveData).toBeUndefined(); expect(snapcompact.getPreservedArchive(rebuilt.preserveData)?.frames.length).toBe(4); + // Extensions must be notified about the entry that is now active, not + // only the hook-written one the rescue superseded. + const compactEvents = emitSpy.mock.calls + .map(c => c[0] as { type?: string; compactionEntry?: CompactionEntry }) + .filter(e => e.type === "session_compact"); + expect(compactEvents.some(e => e.compactionEntry?.id === rebuilt.id)).toBe(true); expect(shakeSpy).not.toHaveBeenCalled(); const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); expect(noProgress.length).toBe(0); From f48004a1828977e4d535a71aca211a6e939a87c4 Mon Sep 17 00:00:00 2001 From: Hugo Lopes <4919100+HugoLopes45@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:15:23 +0200 Subject: [PATCH 5/6] fix(compaction): only frame-rescue archives that are the actual overflow source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up (Codex on #6362, round 4): rebuilding a non-tail archive appends the replacement compaction at the leaf, so the branch tail becomes a compaction entry that prepareCompaction's last-entry guard can never summarize past — even after elide shrinks the oversized kept tool result that was the real culprit. The rescue now estimates the kept tail AFTER the latest archive and bails when it alone exceeds the recovery band, leaving that shape to the elide/image tiers. Claude-Session: https://claude.ai/code/session_014rh4JyWFkxgMhgFaEf8VBY --- .../coding-agent/src/session/agent-session.ts | 19 +++++++++ ...session-snapcompact-frame-dead-end.test.ts | 42 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f23a9f413..300343bd4 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13912,6 +13912,25 @@ export class AgentSession { if (!this.model?.input.includes("image")) return undefined; const staleEntry = getLatestCompactionEntry(branchEntries); if (!staleEntry) return undefined; + // Only rescue when the archive is the actual source of the overflow. + // If the kept tail AFTER it is itself over the recovery band (e.g. a + // huge kept tool result), rebuilding the archive would append the + // replacement compaction at the leaf — turning the branch tail into a + // compaction entry, which prepareCompaction's last-entry guard can + // never summarize past even after an elide shrinks the real culprit. + // Bail and let the elide/image tiers handle that tail instead. + const ctxWindow = this.model.contextWindow ?? 0; + if (ctxWindow > 0) { + const tailBar = Math.floor(resolveThresholdTokens(ctxWindow, settings) * COMPACTION_RECOVERY_BAND); + let tailTokens = 0; + for (let i = branchEntries.length - 1; i >= 0; i--) { + const entry = branchEntries[i]; + if (entry.id === staleEntry.id) break; + const message = (entry as { message?: AgentMessage }).message; + if (message) tailTokens += estimateTokens(message); + } + if (tailTokens > tailBar) return undefined; + } const archive = snapcompact.getPreservedArchive(staleEntry.preserveData); if (!archive || archive.frames.length <= 1) return undefined; const archiveText = snapcompact.archiveSourceText(archive); diff --git a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts index d74703314..9d2b6d53e 100644 --- a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts +++ b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; +import * as compactionModule 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"; @@ -374,6 +375,47 @@ describe("AgentSession snapcompact frame dead-end rescue", () => { expect(noProgress[0].level).toBe("warning"); }); + it("leaves an oversized non-archive tail to the elide tiers instead of rescuing the archive", async () => { + // Codex review on #6362 (round 4): with […, archive, HUGE kept tool + // result], rebuilding the archive would append the replacement at the + // leaf — making the branch tail a compaction entry that + // prepareCompaction's last-entry guard can never summarize past, even + // after elide shrinks the real culprit. The rescue must bail when the + // post-archive tail alone exceeds the recovery band. + await createSession({ frameCount: SEEDED_FRAME_COUNT }); + // Seed a kept tool-result tail far above the 0.8 × 60k band. + sessionManager.appendMessage({ + role: "toolResult", + toolCallId: "call-huge", + toolName: "bash", + content: [{ type: "text", text: "x".repeat(400_000) }], + isError: false, + timestamp: Date.now(), + }); + // Force the no-preparation dead-end (as in the #4786 guard tests): the + // oversized turn leaves nothing summarizable, which is the shape where + // a premature archive rebuild would wedge prepareCompaction. + vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 }); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + const compactSpy = vi.spyOn(snapcompact, "compact"); + + const notices = collectNotices(); + await triggerMaintenance(); + + // The archive was NOT rebuilt; the elide tier got its shot at the tail. + expect(compactSpy).not.toHaveBeenCalled(); + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + const lastEntry = sessionManager.getBranch().at(-1); + expect(lastEntry?.type).not.toBe("compaction"); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(1); + }); + it("still warns once when the trailing archive is already at the minimum frame count", async () => { await createSession({ frameCount: 1 }); const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); From d8554c92d09db8ffa5f4ddb139edf4758db26f5d Mon Sep 17 00:00:00 2001 From: Hugo Lopes <4919100+HugoLopes45@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:03:12 +0200 Subject: [PATCH 6/6] fix(compaction): charge the kept tail in the rescue budget and badge the active entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (Codex on #6362, round 5): - #computeSnapcompactRescueMaxFrames now subtracts the kept tail AFTER the archive (plus the existing fixed-context reserves) so the budget mirrors what #compactionCreatedHeadroom will measure, and returns 0 when not even one frame fits — the rescue bails instead of appending a rebuild that can never create headroom (and would wedge prepareCompaction behind its last-entry guard once elide fixes the real tail). - Dead-end warnings now stamp the branch's LATEST compaction entry: the post-pass path no longer badges the entry the rescue just superseded, and the no-preparation path badges the rebuilt entry when the rescue appended without creating headroom. Claude-Session: https://claude.ai/code/session_014rh4JyWFkxgMhgFaEf8VBY --- .../coding-agent/src/session/agent-session.ts | 90 +++++++++++-------- ...session-snapcompact-frame-dead-end.test.ts | 38 ++++++++ 2 files changed, 89 insertions(+), 39 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 300343bd4..a163abedf 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13854,9 +13854,13 @@ export class AgentSession { * {@link #computeSnapcompactMaxFrames} sizes against — a rebuilt archive * must land back under the maintenance trigger, or the next settle * re-enters the same dead-end. Cap reserve mirrors - * #computeSnapcompactMaxFrames (text edges + summary template). + * #computeSnapcompactMaxFrames (text edges + summary template), and + * `keptTailTokens` charges the kept entries AFTER the archive so the + * budget mirrors what #compactionCreatedHeadroom will actually measure. + * Returns 0 when not even one frame fits that budget — the rebuild could + * never create headroom, so the caller must not append it. */ - #computeSnapcompactRescueMaxFrames(settings: CompactionSettings): number { + #computeSnapcompactRescueMaxFrames(settings: CompactionSettings, keptTailTokens: number): number { const ctxWindow = this.model?.contextWindow ?? 0; if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget()); const thresholdTokens = resolveThresholdTokens(ctxWindow, settings); @@ -13866,18 +13870,15 @@ export class AgentSession { const edgeCap = snapcompact.geometry(shape).capacity; const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4); const SUMMARY_TEMPLATE_TOKENS = 2000; - const frameBudget = recoveryBandTokens - baseTokens - textEdgeTokens - SUMMARY_TEMPLATE_TOKENS; - if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1; + const frameBudget = recoveryBandTokens - baseTokens - keptTailTokens - textEdgeTokens - SUMMARY_TEMPLATE_TOKENS; + if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 0; // Same hard caps as #computeSnapcompactMaxFrames: a threshold-derived // count above the per-request payload budget would "shrink" a huge // archive to a frame count the rebuilt prompt can never attach anyway. - return Math.max( - 1, - Math.min( - Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), - snapcompact.MAX_FRAMES_DEFAULT, - snapcompact.maxFramesForDataBudget(), - ), + return Math.min( + Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), + snapcompact.MAX_FRAMES_DEFAULT, + snapcompact.maxFramesForDataBudget(), ); } @@ -13913,30 +13914,27 @@ export class AgentSession { const staleEntry = getLatestCompactionEntry(branchEntries); if (!staleEntry) return undefined; // Only rescue when the archive is the actual source of the overflow. - // If the kept tail AFTER it is itself over the recovery band (e.g. a - // huge kept tool result), rebuilding the archive would append the - // replacement compaction at the leaf — turning the branch tail into a - // compaction entry, which prepareCompaction's last-entry guard can - // never summarize past even after an elide shrinks the real culprit. - // Bail and let the elide/image tiers handle that tail instead. - const ctxWindow = this.model.contextWindow ?? 0; - if (ctxWindow > 0) { - const tailBar = Math.floor(resolveThresholdTokens(ctxWindow, settings) * COMPACTION_RECOVERY_BAND); - let tailTokens = 0; - for (let i = branchEntries.length - 1; i >= 0; i--) { - const entry = branchEntries[i]; - if (entry.id === staleEntry.id) break; - const message = (entry as { message?: AgentMessage }).message; - if (message) tailTokens += estimateTokens(message); - } - if (tailTokens > tailBar) return undefined; + // The frame budget below charges the kept tail AFTER the archive plus + // the fixed context, mirroring what #compactionCreatedHeadroom will + // measure. When not even one frame fits (e.g. a huge kept tool result + // dominates), rebuilding would append the replacement compaction at + // the leaf — turning the branch tail into a compaction entry, which + // prepareCompaction's last-entry guard can never summarize past even + // after an elide shrinks the real culprit. Bail and let the + // elide/image tiers handle that tail instead. + let keptTailTokens = 0; + for (let i = branchEntries.length - 1; i >= 0; i--) { + const entry = branchEntries[i]; + if (entry.id === staleEntry.id) break; + const message = (entry as { message?: AgentMessage }).message; + if (message) keptTailTokens += estimateTokens(message); } const archive = snapcompact.getPreservedArchive(staleEntry.preserveData); if (!archive || archive.frames.length <= 1) return undefined; const archiveText = snapcompact.archiveSourceText(archive); if (!archiveText) return undefined; - const maxFrames = this.#computeSnapcompactRescueMaxFrames(settings); - if (maxFrames >= archive.frames.length) return undefined; + const maxFrames = this.#computeSnapcompactRescueMaxFrames(settings, keptTailTokens); + if (maxFrames < 1 || maxFrames >= archive.frames.length) return undefined; const staleDetails = staleEntry.details as snapcompact.CompactionDetails | undefined; const fileOps = snapcompact.createFileOps(); @@ -14298,11 +14296,19 @@ export class AgentSession { continuationScheduled = true; } if (noProgressDeadEnd) { - this.emitNotice( - "warning", - compactionDeadEndWarning("shrink it (e.g. clear large tool output)"), - "compaction", - ); + const deadEndWarning = compactionDeadEndWarning("shrink it (e.g. clear large tool output)"); + this.emitNotice("warning", deadEndWarning, "compaction"); + // A rescue that appended a rebuilt archive without creating + // headroom must carry the dead-end badge on the entry the + // transcript actually shows (the rebuilt one), or the pause + // loses its explanation once the notice scrolls away. + if (frameRescueResult) { + const stampEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); + if (stampEntry) { + stampEntry.warning = deadEndWarning; + await this.sessionManager.rewriteEntries(); + } + } } // A rescue that offloaded content but still could not produce a // preparation rewrote the branch; flag it so the overflow-recovery @@ -14723,12 +14729,18 @@ export class AgentSession { } const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("clear large tool output") : undefined; - if (deadEndWarning && savedCompactionEntry) { + if (deadEndWarning) { // Stamp the divider: the compaction bar badges the dead-end and // carries the full warning in its ctrl+o detail, so the pause - // stays explained even after the notice row scrolls away. - savedCompactionEntry.warning = deadEndWarning; - await this.sessionManager.rewriteEntries(); + // stays explained even after the notice row scrolls away. Stamp + // the branch's LATEST compaction entry — a frame rescue may have + // superseded `savedCompactionEntry` with a rebuilt one, and the + // collapsed transcript badges only the active entry. + const stampEntry = getLatestCompactionEntry(this.sessionManager.getBranch()) ?? savedCompactionEntry; + if (stampEntry) { + stampEntry.warning = deadEndWarning; + await this.sessionManager.rewriteEntries(); + } } await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry }); diff --git a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts index 9d2b6d53e..e9af615c4 100644 --- a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts +++ b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts @@ -373,6 +373,44 @@ describe("AgentSession snapcompact frame dead-end rescue", () => { const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); expect(noProgress.length).toBe(1); expect(noProgress[0].level).toBe("warning"); + // The dead-end badge must live on the ACTIVE (rebuilt) entry — the + // collapsed transcript only shows the latest compaction divider. + const compactions = sessionManager.getBranch().filter(e => e.type === "compaction") as CompactionEntry[]; + const active = compactions.at(-1); + expect(snapcompact.getPreservedArchive(active?.preserveData)?.frames.length).toBe(4); + expect(active?.warning).toContain(NO_PROGRESS_FRAGMENT); + }); + + it("bails when the kept tail plus fixed context leaves no frame budget", async () => { + // Codex review on #6362 (round 5): a tail just under the recovery band + // still cannot coexist with the fixed context + a minimum rebuilt + // archive. The budget now charges the kept tail like + // #compactionCreatedHeadroom does, so the rescue must bail instead of + // appending a rebuild that can never create headroom. + await createSession({ frameCount: SEEDED_FRAME_COUNT }); + // ~40k estimated tokens: under the 48k band, but over band − edges/template. + sessionManager.appendMessage({ + role: "toolResult", + toolCallId: "call-mid", + toolName: "bash", + content: [{ type: "text", text: "y".repeat(160_000) }], + isError: false, + timestamp: Date.now(), + }); + vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 }); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + const compactSpy = vi.spyOn(snapcompact, "compact"); + + await triggerMaintenance(); + + expect(compactSpy).not.toHaveBeenCalled(); + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + expect(sessionManager.getBranch().at(-1)?.type).not.toBe("compaction"); }); it("leaves an oversized non-archive tail to the elide tiers instead of rescuing the archive", async () => {