fix(coding-agent): rescue snapcompact dead-end when nothing is summarizable

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
This commit is contained in:
roboomp
2026-07-14 17:24:12 +00:00
parent ac2ea80fa3
commit 04cc9b8a6f
3 changed files with 162 additions and 27 deletions
+4
View File
@@ -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
@@ -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;
@@ -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<void>();
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<void>();
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");
});
});