diff --git a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts index 8c958aa..8ddd397 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts @@ -1,6 +1,7 @@ -import { describe, test, expect, mock, beforeEach } from "bun:test" +import { describe, test, expect, mock, beforeEach, spyOn } from "bun:test" import { executeCompact } from "./executor" import type { AutoCompactState } from "./types" +import * as storage from "./storage" describe("executeCompact lock management", () => { let autoCompactState: AutoCompactState @@ -224,4 +225,86 @@ describe("executeCompact lock management", () => { // The continuation happens in setTimeout, but lock is cleared in finally before that expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) }) + + test("falls through to summarize when truncation is insufficient", async () => { + // #given: Over token limit with truncation returning insufficient + autoCompactState.errorDataBySession.set(sessionID, { + errorType: "token_limit", + currentTokens: 250000, + maxTokens: 200000, + }) + + const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({ + success: true, + sufficient: false, + truncatedCount: 3, + totalBytesRemoved: 10000, + targetBytesToRemove: 50000, + truncatedTools: [ + { toolName: "Grep", originalSize: 5000 }, + { toolName: "Read", originalSize: 3000 }, + { toolName: "Bash", originalSize: 2000 }, + ], + }) + + // #when: Execute compaction + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // #then: Truncation was attempted + expect(truncateSpy).toHaveBeenCalled() + + // #then: Summarize should be called (fall through from insufficient truncation) + expect(mockClient.session.summarize).toHaveBeenCalledWith( + expect.objectContaining({ + path: { id: sessionID }, + body: { providerID: "anthropic", modelID: "claude-opus-4-5" }, + }), + ) + + // #then: Lock should be cleared + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) + + truncateSpy.mockRestore() + }) + + test("does NOT call summarize when truncation is sufficient", async () => { + // #given: Over token limit with truncation returning sufficient + autoCompactState.errorDataBySession.set(sessionID, { + errorType: "token_limit", + currentTokens: 250000, + maxTokens: 200000, + }) + + const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({ + success: true, + sufficient: true, + truncatedCount: 5, + totalBytesRemoved: 60000, + targetBytesToRemove: 50000, + truncatedTools: [ + { toolName: "Grep", originalSize: 30000 }, + { toolName: "Read", originalSize: 30000 }, + ], + }) + + // #when: Execute compaction + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // Wait for setTimeout callback + await new Promise((resolve) => setTimeout(resolve, 600)) + + // #then: Truncation was attempted + expect(truncateSpy).toHaveBeenCalled() + + // #then: Summarize should NOT be called (early return from sufficient truncation) + expect(mockClient.session.summarize).not.toHaveBeenCalled() + + // #then: prompt_async should be called (Continue after successful truncation) + expect(mockClient.session.prompt_async).toHaveBeenCalled() + + // #then: Lock should be cleared + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) + + truncateSpy.mockRestore() + }) }) diff --git a/src/hooks/anthropic-context-window-limit-recovery/executor.ts b/src/hooks/anthropic-context-window-limit-recovery/executor.ts index 09608f4..1c5abd6 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/executor.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/executor.ts @@ -401,21 +401,31 @@ export async function executeCompact( log("[auto-compact] aggressive truncation completed", aggressiveResult); - clearSessionState(autoCompactState, sessionID); - setTimeout(async () => { - try { - await (client as Client).session.prompt_async({ - path: { sessionID }, - body: { parts: [{ type: "text", text: "Continue" }] }, - query: { directory }, - }); - } catch {} - }, 500); - return; + // Only return early if truncation was sufficient to get under token limit + // Otherwise fall through to PHASE 3 (Summarize) + if (aggressiveResult.sufficient) { + clearSessionState(autoCompactState, sessionID); + setTimeout(async () => { + try { + await (client as Client).session.prompt_async({ + path: { sessionID }, + body: { parts: [{ type: "text", text: "Continue" }] }, + query: { directory }, + }); + } catch {} + }, 500); + return; + } + // Truncation was insufficient - fall through to Summarize + log("[auto-compact] truncation insufficient, falling through to summarize", { + sessionID, + truncatedCount: aggressiveResult.truncatedCount, + sufficient: aggressiveResult.sufficient, + }); } } - // PHASE 3: Summarize - fallback when no tool outputs to truncate + // PHASE 3: Summarize - fallback when truncation insufficient or no tool outputs const retryState = getOrCreateRetryState(autoCompactState, sessionID); if (errorData?.errorType?.includes("non-empty content")) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/types.ts b/src/hooks/anthropic-context-window-limit-recovery/types.ts index 5a3ec73..024fd54 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/types.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/types.ts @@ -44,5 +44,5 @@ export const TRUNCATE_CONFIG = { maxTruncateAttempts: 20, minOutputSizeToTruncate: 500, targetTokenRatio: 0.5, - charsPerToken: 2, + charsPerToken: 4, } as const