From 04cc9b8a6ff5220518ee849e9e7e6038db7e8e90 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 14 Jul 2026 17:24:12 +0000 Subject: [PATCH] fix(coding-agent): rescue snapcompact dead-end when nothing is summarizable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the single most-recent turn is itself over budget, prepareCompaction returns undefined (findCutPoint never cuts inside a tool result, so the kept tail has nothing on the summarizable side) and summary compaction cannot start. The !preparation short-circuit in #runAutoCompaction emitted the "Compaction freed too little context to make progress" warning and paused, never running the artifact-backed shake elide rescue that #3786 wired into the post-maintenance guard — so snapcompact/context-full maintenance looped the warning with no attempt to shrink the oversized tail. Run the same elide rescue before pausing, re-prepare on the shrunken branch, and fall through to a normal compaction when the tail became summarizable (writing a compaction entry anchors the stale billed usage so the auto-continue re-check cannot re-trip). Only pause with a single warning when nothing is elide-eligible. Flag historyRewritten on a rescue that offloaded content so overflow recovery does not re-restore the just-failed turn onto the elided tail. Fixes #4786 --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/session/agent-session.ts | 92 ++++++++++++------ ...ion-auto-compaction-progress-guard.test.ts | 93 +++++++++++++++++++ 3 files changed, 162 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3a03a1977..ed9f74da5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Memoized non-message token totals (system prompt, tool schemas, skills) so the per-turn compaction and context-threshold paths recompute them at most once per input change instead of on every call. `getContextBreakdown` and `#estimateStoredContextTokens` previously re-tokenized the system prompt and every tool's wire schema (per-tool `JSON.stringify`) several times per turn over inputs that change at most once per turn. +### Fixed + +- Fixed auto-compaction dead-ending in a warning loop ("Compaction freed too little context to make progress") when the single most-recent turn is itself over budget so `prepareCompaction` has nothing to summarize (`findCutPoint` never cuts inside a tool result). This `!preparation` short-circuit never ran the artifact-backed `shake` elide rescue that #3786 added to the post-maintenance guard, so snapcompact/context-full maintenance paused with no attempt to shrink the oversized tail. The dead-end now runs the same elide pass, re-prepares on the shrunken branch, and falls through to a normal compaction when the tail became summarizable — only pausing (single warning) when nothing is elide-eligible. ([#4786](https://github.com/can1357/oh-my-pi/issues/4786)) + ## [16.3.11] - 2026-07-06 ### Changed diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c11d0bf36..be68bba0f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -12385,35 +12385,73 @@ export class AgentSession { this.#getCompactionModelCandidates(availableModels), this.sessionId, ); - const preparation = prepareCompaction(pathEntries, compactionSettings, autoCompactionCandidates); + let pathEntriesForCompaction = pathEntries; + let preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, autoCompactionCandidates); if (!preparation) { - await this.#emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - skipped: true, - }); - const noProgressDeadEnd = reason !== "idle"; - let continuationScheduled = false; - if (!suppressContinuation && this.agent.hasQueuedMessages()) { - this.#scheduleAgentContinue({ - delayMs: 100, - generation, - shouldContinue: () => this.agent.hasQueuedMessages(), + // prepareCompaction found nothing to summarize because the kept region + // is a single oversized recent turn — findCutPoint never cuts inside a + // tool result, so a huge tool-result / fenced block tail leaves nothing + // on the summarizable side and summary compaction cannot even start. + // That is exactly the dead-end the elide shake rescues: it reaches + // INSIDE the tail and offloads heavy content to an artifact placeholder, + // shrinking the tail so findCutPoint can then move the cut and leave + // older turns to summarize. Run the same rescue the post-maintenance + // guard uses, then re-prepare on the elided branch and fall through to + // the normal compaction body when it now succeeds (writing a compaction + // entry anchors the stale billed usage so the auto-continue re-check + // cannot re-trip and loop the warning — issue #4786). Skip when we + // already fell through from a shake strategy pass (it tried and found + // nothing) or on the idle timer (it re-checks usage on its own cadence). + let rescued: ShakeResult | undefined; + if (reason !== "idle" && !fallbackFromShake) { + rescued = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal); + if (rescued && !autoCompactionSignal.aborted) { + pathEntriesForCompaction = this.sessionManager.getBranch(); + preparation = prepareCompaction( + pathEntriesForCompaction, + compactionSettings, + autoCompactionCandidates, + ); + if (preparation) this.#emitShakeRescueNotice(rescued); + } + } + if (!preparation) { + await this.#emitSessionEvent({ + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + skipped: true, }); - continuationScheduled = true; + const noProgressDeadEnd = reason !== "idle"; + let continuationScheduled = false; + if (!suppressContinuation && this.agent.hasQueuedMessages()) { + this.#scheduleAgentContinue({ + delayMs: 100, + generation, + shouldContinue: () => this.agent.hasQueuedMessages(), + }); + continuationScheduled = true; + } + if (noProgressDeadEnd) { + this.emitNotice( + "warning", + compactionDeadEndWarning("clear large tool output, run `/shake images` to drop attached images,"), + "compaction", + ); + } + // A rescue that offloaded content but still could not produce a + // preparation rewrote the branch; flag it so the overflow-recovery + // rollback does not re-restore the just-failed assistant turn on top + // of the elided tail. + const base = continuationScheduled + ? COMPACTION_CHECK_CONTINUATION + : noProgressDeadEnd + ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION + : COMPACTION_CHECK_NONE; + return rescued ? { ...base, historyRewritten: true } : base; } - if (noProgressDeadEnd) { - this.emitNotice( - "warning", - compactionDeadEndWarning("shrink it (e.g. clear large tool output)"), - "compaction", - ); - } - if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION; - return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; } let hookCompaction: CompactionResult | undefined; @@ -12424,7 +12462,7 @@ export class AgentSession { const hookResult = (await this.#extensionRunner.emit({ type: "session_before_compact", preparation, - branchEntries: pathEntries, + branchEntries: pathEntriesForCompaction, customInstructions: undefined, signal: autoCompactionSignal, })) as SessionBeforeCompactResult | undefined; diff --git a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts index e537902df..cf64a1576 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts @@ -1162,4 +1162,97 @@ describe("AgentSession auto-compaction progress guard", () => { const recovery = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes("dead-end recovery")); expect(recovery.length).toBe(0); }); + + it("re-prepares and compacts after a shake rescue frees the un-summarizable tail", async () => { + // Issue #4786: the kept region is a single oversized recent turn, so the + // first prepareCompaction returns undefined (nothing on the summarizable + // side) and summary compaction cannot start. The dead-end runs the elide + // shake rescue INSIDE the tail; once it frees enough, prepareCompaction is + // retried on the elided branch, now succeeds, and the pass falls through to + // a normal (hook-supplied) compaction that creates headroom and + // auto-continues instead of looping the no-progress warning. + const branch = sessionManager.getBranch(); + const firstKeptEntryId = branch[branch.length - 1].id; + if (!firstKeptEntryId) throw new Error("seeded entry has no id"); + let shaken = false; + const preparation: compactionModule.CompactionPreparation = { + firstKeptEntryId, + messagesToSummarize: [{ role: "user", content: "old", timestamp: Date.now() }], + turnPrefixMessages: [], + recentMessages: [], + isSplitTurn: false, + tokensBefore: 190000, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: session.settings.getGroup("compaction"), + }; + vi.spyOn(compactionModule, "prepareCompaction").mockImplementation(() => (shaken ? preparation : undefined)); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + // Residual is over the band until the rescue elides the tail, then drops. + vi.spyOn(session, "getContextUsage").mockImplementation(() => + shaken + ? { tokens: 1000, contextWindow: 200000, percent: 0.5 } + : { tokens: 190000, contextWindow: 200000, percent: 95 }, + ); + const shakeSpy = vi.spyOn(session, "shake").mockImplementation(async () => { + shaken = true; + return { mode: "elide", toolResultsDropped: 1, blocksDropped: 0, tokensFreed: 160000, artifactId: "art-1" }; + }); + + const notices = collectNotices(); + + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_end" && event.result) 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(); + + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + expect(promptSpy).toHaveBeenCalledTimes(1); + 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 a no-preparation dead-end cannot be shaken", async () => { + // prepareCompaction returns undefined AND the oversized tail has nothing + // elide-eligible: the rescue frees nothing, prepareCompaction still returns + // undefined, and the guard MUST pause with a single no-progress warning + // (not loop) instead of re-firing on the same oversized tail. + vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined); + 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 }); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + + const notices = collectNotices(); + + 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(); + + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + 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"); + }); });