Skip to content

Commit dc057e9

Browse files
committed
fix(recovery): restore compaction pipeline sufficient check and conservative charsPerToken
Fixes critical v2.10.0 compaction regression where truncation ALWAYS returned early without checking if it was sufficient, causing PHASE 3 (Summarize) to be skipped. This led to "history disappears" symptom where all context was lost after compaction. Changes: - Restored aggressiveResult.sufficient check before early return in executor - Only return from Truncate phase if truncation successfully reduced tokens below limit - Otherwise fall through to Summarize phase when truncation is insufficient - Restored conservative charsPerToken=4 (was changed to 2, too aggressive) - Added 2 regression tests: * Test 1: Verify Summarize is called when truncation is insufficient * Test 2: Verify Summarize is skipped when truncation is sufficient Regression details: - v2.10.0 changed charsPerToken from 4 to 2, making truncation too aggressive - Early return removed sufficient check, skipping fallback to Summarize - Users reported complete loss of conversation history after compaction 🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
1 parent d4787c4 commit dc057e9

File tree

3 files changed

+107
-14
lines changed

3 files changed

+107
-14
lines changed

src/hooks/anthropic-context-window-limit-recovery/executor.test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, test, expect, mock, beforeEach } from "bun:test"
1+
import { describe, test, expect, mock, beforeEach, spyOn } from "bun:test"
22
import { executeCompact } from "./executor"
33
import type { AutoCompactState } from "./types"
4+
import * as storage from "./storage"
45

56
describe("executeCompact lock management", () => {
67
let autoCompactState: AutoCompactState
@@ -224,4 +225,86 @@ describe("executeCompact lock management", () => {
224225
// The continuation happens in setTimeout, but lock is cleared in finally before that
225226
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
226227
})
228+
229+
test("falls through to summarize when truncation is insufficient", async () => {
230+
// #given: Over token limit with truncation returning insufficient
231+
autoCompactState.errorDataBySession.set(sessionID, {
232+
errorType: "token_limit",
233+
currentTokens: 250000,
234+
maxTokens: 200000,
235+
})
236+
237+
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
238+
success: true,
239+
sufficient: false,
240+
truncatedCount: 3,
241+
totalBytesRemoved: 10000,
242+
targetBytesToRemove: 50000,
243+
truncatedTools: [
244+
{ toolName: "Grep", originalSize: 5000 },
245+
{ toolName: "Read", originalSize: 3000 },
246+
{ toolName: "Bash", originalSize: 2000 },
247+
],
248+
})
249+
250+
// #when: Execute compaction
251+
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
252+
253+
// #then: Truncation was attempted
254+
expect(truncateSpy).toHaveBeenCalled()
255+
256+
// #then: Summarize should be called (fall through from insufficient truncation)
257+
expect(mockClient.session.summarize).toHaveBeenCalledWith(
258+
expect.objectContaining({
259+
path: { id: sessionID },
260+
body: { providerID: "anthropic", modelID: "claude-opus-4-5" },
261+
}),
262+
)
263+
264+
// #then: Lock should be cleared
265+
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
266+
267+
truncateSpy.mockRestore()
268+
})
269+
270+
test("does NOT call summarize when truncation is sufficient", async () => {
271+
// #given: Over token limit with truncation returning sufficient
272+
autoCompactState.errorDataBySession.set(sessionID, {
273+
errorType: "token_limit",
274+
currentTokens: 250000,
275+
maxTokens: 200000,
276+
})
277+
278+
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
279+
success: true,
280+
sufficient: true,
281+
truncatedCount: 5,
282+
totalBytesRemoved: 60000,
283+
targetBytesToRemove: 50000,
284+
truncatedTools: [
285+
{ toolName: "Grep", originalSize: 30000 },
286+
{ toolName: "Read", originalSize: 30000 },
287+
],
288+
})
289+
290+
// #when: Execute compaction
291+
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
292+
293+
// Wait for setTimeout callback
294+
await new Promise((resolve) => setTimeout(resolve, 600))
295+
296+
// #then: Truncation was attempted
297+
expect(truncateSpy).toHaveBeenCalled()
298+
299+
// #then: Summarize should NOT be called (early return from sufficient truncation)
300+
expect(mockClient.session.summarize).not.toHaveBeenCalled()
301+
302+
// #then: prompt_async should be called (Continue after successful truncation)
303+
expect(mockClient.session.prompt_async).toHaveBeenCalled()
304+
305+
// #then: Lock should be cleared
306+
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
307+
308+
truncateSpy.mockRestore()
309+
})
227310
})

src/hooks/anthropic-context-window-limit-recovery/executor.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -401,21 +401,31 @@ export async function executeCompact(
401401

402402
log("[auto-compact] aggressive truncation completed", aggressiveResult);
403403

404-
clearSessionState(autoCompactState, sessionID);
405-
setTimeout(async () => {
406-
try {
407-
await (client as Client).session.prompt_async({
408-
path: { sessionID },
409-
body: { parts: [{ type: "text", text: "Continue" }] },
410-
query: { directory },
411-
});
412-
} catch {}
413-
}, 500);
414-
return;
404+
// Only return early if truncation was sufficient to get under token limit
405+
// Otherwise fall through to PHASE 3 (Summarize)
406+
if (aggressiveResult.sufficient) {
407+
clearSessionState(autoCompactState, sessionID);
408+
setTimeout(async () => {
409+
try {
410+
await (client as Client).session.prompt_async({
411+
path: { sessionID },
412+
body: { parts: [{ type: "text", text: "Continue" }] },
413+
query: { directory },
414+
});
415+
} catch {}
416+
}, 500);
417+
return;
418+
}
419+
// Truncation was insufficient - fall through to Summarize
420+
log("[auto-compact] truncation insufficient, falling through to summarize", {
421+
sessionID,
422+
truncatedCount: aggressiveResult.truncatedCount,
423+
sufficient: aggressiveResult.sufficient,
424+
});
415425
}
416426
}
417427

418-
// PHASE 3: Summarize - fallback when no tool outputs to truncate
428+
// PHASE 3: Summarize - fallback when truncation insufficient or no tool outputs
419429
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
420430

421431
if (errorData?.errorType?.includes("non-empty content")) {

src/hooks/anthropic-context-window-limit-recovery/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,5 @@ export const TRUNCATE_CONFIG = {
4444
maxTruncateAttempts: 20,
4545
minOutputSizeToTruncate: 500,
4646
targetTokenRatio: 0.5,
47-
charsPerToken: 2,
47+
charsPerToken: 4,
4848
} as const

0 commit comments

Comments
 (0)