From 06b77643bafe0be05ebfaa7524bca715abb76c7b Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 25 Dec 2025 19:36:22 +0900 Subject: [PATCH] fix: ensure anthropic-auto-compact lock is always cleared (#232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #200 ## Problem When executeCompact() recovery fails unexpectedly or gets interrupted, the compactionInProgress lock is never cleared, permanently blocking both auto-compact AND manual /compact for the session. ## Root Cause - No try/finally around lock acquisition (line 261) - Silent blocking when lock held - no user feedback - Lock cleanup scattered across 7 manual deletion points - Any unexpected exception bypasses cleanup, leaving lock stuck forever ## Solution 1. **Try/Finally Lock Guarantee**: Wrapped entire executeCompact body in try/finally block to guarantee lock cleanup, following the pattern used in preemptive-compaction hook 2. **User Feedback**: Added toast notification when compact attempt is blocked by existing lock, replacing silent failure with clear warning 3. **Removed Redundancy**: Removed 6 redundant manual lock deletions (kept only clearSessionState and finally block) ## Testing Evidence ✅ 10/10 comprehensive tests pass ✅ Lock cleared on successful completion ✅ Lock cleared when summarize throws ✅ Lock cleared when revert throws ✅ Lock cleared when fixEmptyMessages executes ✅ Lock cleared when truncation is sufficient ✅ Lock cleared after max recovery attempts ✅ Lock cleared when toast fails ✅ Lock cleared when prompt_async throws ✅ Toast shown when lock already held ✅ TypeScript type check passes with zero errors ## Files Changed - executor.ts: Added try/finally, toast notification, removed 6 redundant deletions - executor.test.ts: New comprehensive test suite (10 tests, 13 assertions) ## Impact - Severity: High → Fixed - User Experience: No more stuck sessions requiring restart - Behavior: Identical except lock now guaranteed to clear Co-authored-by: sisyphus-dev-ai --- .../anthropic-auto-compact/executor.test.ts | 260 ++++++ src/hooks/anthropic-auto-compact/executor.ts | 866 ++++++++++-------- 2 files changed, 731 insertions(+), 395 deletions(-) create mode 100644 src/hooks/anthropic-auto-compact/executor.test.ts diff --git a/src/hooks/anthropic-auto-compact/executor.test.ts b/src/hooks/anthropic-auto-compact/executor.test.ts new file mode 100644 index 0000000..054f263 --- /dev/null +++ b/src/hooks/anthropic-auto-compact/executor.test.ts @@ -0,0 +1,260 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test" +import { executeCompact } from "./executor" +import type { AutoCompactState } from "./types" + +describe("executeCompact lock management", () => { + let autoCompactState: AutoCompactState + let mockClient: any + const sessionID = "test-session-123" + const directory = "/test/dir" + const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" } + + beforeEach(() => { + // #given: Fresh state for each test + autoCompactState = { + pendingCompact: new Set(), + errorDataBySession: new Map(), + retryStateBySession: new Map(), + fallbackStateBySession: new Map(), + truncateStateBySession: new Map(), + emptyContentAttemptBySession: new Map(), + compactionInProgress: new Set(), + } + + mockClient = { + session: { + messages: mock(() => Promise.resolve({ data: [] })), + summarize: mock(() => Promise.resolve()), + revert: mock(() => Promise.resolve()), + prompt_async: mock(() => Promise.resolve()), + }, + tui: { + showToast: mock(() => Promise.resolve()), + }, + } + }) + + test("clears lock on successful summarize completion", async () => { + // #given: Valid session with providerID/modelID + autoCompactState.errorDataBySession.set(sessionID, { + errorType: "token_limit", + currentTokens: 100000, + maxTokens: 200000, + }) + + // #when: Execute compaction successfully + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // #then: Lock should be cleared + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) + }) + + test("clears lock when summarize throws exception", async () => { + // #given: Summarize will fail + mockClient.session.summarize = mock(() => + Promise.reject(new Error("Network timeout")), + ) + autoCompactState.errorDataBySession.set(sessionID, { + errorType: "token_limit", + currentTokens: 100000, + maxTokens: 200000, + }) + + // #when: Execute compaction + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // #then: Lock should still be cleared despite exception + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) + }) + + test("clears lock when revert throws exception", async () => { + // #given: Force revert path by exhausting retry attempts and making revert fail + mockClient.session.revert = mock(() => + Promise.reject(new Error("Revert failed")), + ) + mockClient.session.messages = mock(() => + Promise.resolve({ + data: [ + { info: { id: "msg1", role: "user" } }, + { info: { id: "msg2", role: "assistant" } }, + ], + }), + ) + + // Exhaust retry attempts + autoCompactState.retryStateBySession.set(sessionID, { + attempt: 5, + lastAttemptTime: Date.now(), + }) + autoCompactState.errorDataBySession.set(sessionID, { + errorType: "token_limit", + currentTokens: 100000, + maxTokens: 200000, + }) + + // #when: Execute compaction + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // #then: Lock cleared even though revert failed + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) + }) + + test("shows toast when lock already held", async () => { + // #given: Lock already held + autoCompactState.compactionInProgress.add(sessionID) + + // #when: Try to execute compaction + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // #then: Toast should be shown with warning message + expect(mockClient.tui.showToast).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + title: "Compact In Progress", + message: expect.stringContaining("Recovery already running"), + variant: "warning", + }), + }), + ) + + // #then: compactionInProgress should still have the lock + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true) + }) + + test("clears lock when fixEmptyMessages path executes", async () => { + // #given: Empty content error scenario + autoCompactState.errorDataBySession.set(sessionID, { + errorType: "non-empty content required", + messageIndex: 0, + currentTokens: 100000, + maxTokens: 200000, + }) + + // #when: Execute compaction (fixEmptyMessages will be called) + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // #then: Lock should be cleared + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) + }) + + test("clears lock when truncation is sufficient", async () => { + // #given: Aggressive truncation scenario with sufficient truncation + // This test verifies the early return path in aggressive truncation + autoCompactState.errorDataBySession.set(sessionID, { + errorType: "token_limit", + currentTokens: 250000, + maxTokens: 200000, + }) + + const experimental = { + truncate_all_tool_outputs: false, + aggressive_truncation: true, + } + + // #when: Execute compaction with experimental flag + await executeCompact( + sessionID, + msg, + autoCompactState, + mockClient, + directory, + experimental, + ) + + // #then: Lock should be cleared even on early return + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) + }) + + test("prevents concurrent compaction attempts", async () => { + // #given: Lock already held (simpler test) + autoCompactState.compactionInProgress.add(sessionID) + + // #when: Try to execute compaction while lock is held + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // #then: Toast should be shown + const toastCalls = (mockClient.tui.showToast as any).mock.calls + const blockedToast = toastCalls.find( + (call: any) => call[0]?.body?.title === "Compact In Progress", + ) + expect(blockedToast).toBeDefined() + + // #then: Lock should still be held (not cleared by blocked attempt) + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true) + }) + + test("clears lock after max recovery attempts exhausted", async () => { + // #given: All retry/revert attempts exhausted + mockClient.session.messages = mock(() => Promise.resolve({ data: [] })) + + // Max out all attempts + autoCompactState.retryStateBySession.set(sessionID, { + attempt: 5, + lastAttemptTime: Date.now(), + }) + autoCompactState.fallbackStateBySession.set(sessionID, { + revertAttempt: 5, + }) + autoCompactState.truncateStateBySession.set(sessionID, { + truncateAttempt: 5, + }) + autoCompactState.errorDataBySession.set(sessionID, { + errorType: "token_limit", + currentTokens: 100000, + maxTokens: 200000, + }) + + // #when: Execute compaction + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // #then: Should show failure toast + const toastCalls = (mockClient.tui.showToast as any).mock.calls + const failureToast = toastCalls.find( + (call: any) => call[0]?.body?.title === "Auto Compact Failed", + ) + expect(failureToast).toBeDefined() + + // #then: Lock should still be cleared + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) + }) + + test("clears lock when client.tui.showToast throws", async () => { + // #given: Toast will fail (this should never happen but testing robustness) + mockClient.tui.showToast = mock(() => + Promise.reject(new Error("Toast failed")), + ) + autoCompactState.errorDataBySession.set(sessionID, { + errorType: "token_limit", + currentTokens: 100000, + maxTokens: 200000, + }) + + // #when: Execute compaction + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // #then: Lock should be cleared even if toast fails + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) + }) + + test("clears lock when prompt_async in continuation throws", async () => { + // #given: prompt_async will fail during continuation + mockClient.session.prompt_async = mock(() => + Promise.reject(new Error("Prompt failed")), + ) + autoCompactState.errorDataBySession.set(sessionID, { + errorType: "token_limit", + currentTokens: 100000, + maxTokens: 200000, + }) + + // #when: Execute compaction + await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) + + // Wait for setTimeout callback + await new Promise((resolve) => setTimeout(resolve, 600)) + + // #then: Lock should be cleared + // The continuation happens in setTimeout, but lock is cleared in finally before that + expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) + }) +}) diff --git a/src/hooks/anthropic-auto-compact/executor.ts b/src/hooks/anthropic-auto-compact/executor.ts index c0ef482..8bdf9fd 100644 --- a/src/hooks/anthropic-auto-compact/executor.ts +++ b/src/hooks/anthropic-auto-compact/executor.ts @@ -1,206 +1,238 @@ -import type { AutoCompactState, FallbackState, RetryState, TruncateState } from "./types" -import type { ExperimentalConfig } from "../../config" -import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types" -import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage" +import type { + AutoCompactState, + FallbackState, + RetryState, + TruncateState, +} from "./types"; +import type { ExperimentalConfig } from "../../config"; +import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"; +import { + findLargestToolResult, + truncateToolResult, + truncateUntilTargetTokens, +} from "./storage"; import { findEmptyMessages, findEmptyMessageByIndex, injectTextPart, replaceEmptyTextParts, -} from "../session-recovery/storage" -import { log } from "../../shared/logger" +} from "../session-recovery/storage"; +import { log } from "../../shared/logger"; type Client = { session: { - messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise + messages: (opts: { + path: { id: string }; + query?: { directory?: string }; + }) => Promise; summarize: (opts: { - path: { id: string } - body: { providerID: string; modelID: string } - query: { directory: string } - }) => Promise + path: { id: string }; + body: { providerID: string; modelID: string }; + query: { directory: string }; + }) => Promise; revert: (opts: { - path: { id: string } - body: { messageID: string; partID?: string } - query: { directory: string } - }) => Promise + path: { id: string }; + body: { messageID: string; partID?: string }; + query: { directory: string }; + }) => Promise; prompt_async: (opts: { - path: { sessionID: string } - body: { parts: Array<{ type: string; text: string }> } - query: { directory: string } - }) => Promise - } + path: { sessionID: string }; + body: { parts: Array<{ type: string; text: string }> }; + query: { directory: string }; + }) => Promise; + }; tui: { showToast: (opts: { - body: { title: string; message: string; variant: string; duration: number } - }) => Promise - } -} + body: { + title: string; + message: string; + variant: string; + duration: number; + }; + }) => Promise; + }; +}; function getOrCreateRetryState( autoCompactState: AutoCompactState, - sessionID: string + sessionID: string, ): RetryState { - let state = autoCompactState.retryStateBySession.get(sessionID) + let state = autoCompactState.retryStateBySession.get(sessionID); if (!state) { - state = { attempt: 0, lastAttemptTime: 0 } - autoCompactState.retryStateBySession.set(sessionID, state) + state = { attempt: 0, lastAttemptTime: 0 }; + autoCompactState.retryStateBySession.set(sessionID, state); } - return state + return state; } function getOrCreateFallbackState( autoCompactState: AutoCompactState, - sessionID: string + sessionID: string, ): FallbackState { - let state = autoCompactState.fallbackStateBySession.get(sessionID) + let state = autoCompactState.fallbackStateBySession.get(sessionID); if (!state) { - state = { revertAttempt: 0 } - autoCompactState.fallbackStateBySession.set(sessionID, state) + state = { revertAttempt: 0 }; + autoCompactState.fallbackStateBySession.set(sessionID, state); } - return state + return state; } function getOrCreateTruncateState( autoCompactState: AutoCompactState, - sessionID: string + sessionID: string, ): TruncateState { - let state = autoCompactState.truncateStateBySession.get(sessionID) + let state = autoCompactState.truncateStateBySession.get(sessionID); if (!state) { - state = { truncateAttempt: 0 } - autoCompactState.truncateStateBySession.set(sessionID, state) + state = { truncateAttempt: 0 }; + autoCompactState.truncateStateBySession.set(sessionID, state); } - return state + return state; } async function getLastMessagePair( sessionID: string, client: Client, - directory: string + directory: string, ): Promise<{ userMessageID: string; assistantMessageID?: string } | null> { try { const resp = await client.session.messages({ path: { id: sessionID }, query: { directory }, - }) + }); - const data = (resp as { data?: unknown[] }).data - if (!Array.isArray(data) || data.length < FALLBACK_CONFIG.minMessagesRequired) { - return null + const data = (resp as { data?: unknown[] }).data; + if ( + !Array.isArray(data) || + data.length < FALLBACK_CONFIG.minMessagesRequired + ) { + return null; } - const reversed = [...data].reverse() + const reversed = [...data].reverse(); const lastAssistant = reversed.find((m) => { - const msg = m as Record - const info = msg.info as Record | undefined - return info?.role === "assistant" - }) + const msg = m as Record; + const info = msg.info as Record | undefined; + return info?.role === "assistant"; + }); const lastUser = reversed.find((m) => { - const msg = m as Record - const info = msg.info as Record | undefined - return info?.role === "user" - }) + const msg = m as Record; + const info = msg.info as Record | undefined; + return info?.role === "user"; + }); - if (!lastUser) return null - const userInfo = (lastUser as { info?: Record }).info - const userMessageID = userInfo?.id as string | undefined - if (!userMessageID) return null + if (!lastUser) return null; + const userInfo = (lastUser as { info?: Record }).info; + const userMessageID = userInfo?.id as string | undefined; + if (!userMessageID) return null; - let assistantMessageID: string | undefined + let assistantMessageID: string | undefined; if (lastAssistant) { - const assistantInfo = (lastAssistant as { info?: Record }).info - assistantMessageID = assistantInfo?.id as string | undefined + const assistantInfo = ( + lastAssistant as { info?: Record } + ).info; + assistantMessageID = assistantInfo?.id as string | undefined; } - return { userMessageID, assistantMessageID } + return { userMessageID, assistantMessageID }; } catch { - return null + return null; } } function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes}B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` - return `${(bytes / (1024 * 1024)).toFixed(1)}MB` + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } export async function getLastAssistant( sessionID: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, - directory: string + directory: string, ): Promise | null> { try { const resp = await (client as Client).session.messages({ path: { id: sessionID }, query: { directory }, - }) + }); - const data = (resp as { data?: unknown[] }).data - if (!Array.isArray(data)) return null + const data = (resp as { data?: unknown[] }).data; + if (!Array.isArray(data)) return null; - const reversed = [...data].reverse() + const reversed = [...data].reverse(); const last = reversed.find((m) => { - const msg = m as Record - const info = msg.info as Record | undefined - return info?.role === "assistant" - }) - if (!last) return null - return (last as { info?: Record }).info ?? null + const msg = m as Record; + const info = msg.info as Record | undefined; + return info?.role === "assistant"; + }); + if (!last) return null; + return (last as { info?: Record }).info ?? null; } catch { - return null + return null; } } -function clearSessionState(autoCompactState: AutoCompactState, sessionID: string): void { - autoCompactState.pendingCompact.delete(sessionID) - autoCompactState.errorDataBySession.delete(sessionID) - autoCompactState.retryStateBySession.delete(sessionID) - autoCompactState.fallbackStateBySession.delete(sessionID) - autoCompactState.truncateStateBySession.delete(sessionID) - autoCompactState.emptyContentAttemptBySession.delete(sessionID) - autoCompactState.compactionInProgress.delete(sessionID) +function clearSessionState( + autoCompactState: AutoCompactState, + sessionID: string, +): void { + autoCompactState.pendingCompact.delete(sessionID); + autoCompactState.errorDataBySession.delete(sessionID); + autoCompactState.retryStateBySession.delete(sessionID); + autoCompactState.fallbackStateBySession.delete(sessionID); + autoCompactState.truncateStateBySession.delete(sessionID); + autoCompactState.emptyContentAttemptBySession.delete(sessionID); + autoCompactState.compactionInProgress.delete(sessionID); } function getOrCreateEmptyContentAttempt( autoCompactState: AutoCompactState, - sessionID: string + sessionID: string, ): number { - return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0 + return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0; } async function fixEmptyMessages( sessionID: string, autoCompactState: AutoCompactState, client: Client, - messageIndex?: number + messageIndex?: number, ): Promise { - const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID) - autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1) + const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID); + autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1); - let fixed = false - const fixedMessageIds: string[] = [] + let fixed = false; + const fixedMessageIds: string[] = []; if (messageIndex !== undefined) { - const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex) + const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex); if (targetMessageId) { - const replaced = replaceEmptyTextParts(targetMessageId, "[user interrupted]") + const replaced = replaceEmptyTextParts( + targetMessageId, + "[user interrupted]", + ); if (replaced) { - fixed = true - fixedMessageIds.push(targetMessageId) + fixed = true; + fixedMessageIds.push(targetMessageId); } else { - const injected = injectTextPart(sessionID, targetMessageId, "[user interrupted]") + const injected = injectTextPart( + sessionID, + targetMessageId, + "[user interrupted]", + ); if (injected) { - fixed = true - fixedMessageIds.push(targetMessageId) + fixed = true; + fixedMessageIds.push(targetMessageId); } } } } if (!fixed) { - const emptyMessageIds = findEmptyMessages(sessionID) + const emptyMessageIds = findEmptyMessages(sessionID); if (emptyMessageIds.length === 0) { await client.tui .showToast({ @@ -211,20 +243,24 @@ async function fixEmptyMessages( duration: 5000, }, }) - .catch(() => {}) - return false + .catch(() => {}); + return false; } for (const messageID of emptyMessageIds) { - const replaced = replaceEmptyTextParts(messageID, "[user interrupted]") + const replaced = replaceEmptyTextParts(messageID, "[user interrupted]"); if (replaced) { - fixed = true - fixedMessageIds.push(messageID) + fixed = true; + fixedMessageIds.push(messageID); } else { - const injected = injectTextPart(sessionID, messageID, "[user interrupted]") + const injected = injectTextPart( + sessionID, + messageID, + "[user interrupted]", + ); if (injected) { - fixed = true - fixedMessageIds.push(messageID) + fixed = true; + fixedMessageIds.push(messageID); } } } @@ -240,10 +276,10 @@ async function fixEmptyMessages( duration: 3000, }, }) - .catch(() => {}) + .catch(() => {}); } - return fixed + return fixed; } export async function executeCompact( @@ -253,325 +289,365 @@ export async function executeCompact( // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, directory: string, - experimental?: ExperimentalConfig + experimental?: ExperimentalConfig, ): Promise { if (autoCompactState.compactionInProgress.has(sessionID)) { - return + await (client as Client).tui + .showToast({ + body: { + title: "Compact In Progress", + message: + "Recovery already running. Please wait or start new session if stuck.", + variant: "warning", + duration: 5000, + }, + }) + .catch(() => {}); + return; } - autoCompactState.compactionInProgress.add(sessionID) + autoCompactState.compactionInProgress.add(sessionID); - const errorData = autoCompactState.errorDataBySession.get(sessionID) - const truncateState = getOrCreateTruncateState(autoCompactState, sessionID) + try { + const errorData = autoCompactState.errorDataBySession.get(sessionID); + const truncateState = getOrCreateTruncateState(autoCompactState, sessionID); - if ( - experimental?.aggressive_truncation && - errorData?.currentTokens && - errorData?.maxTokens && - errorData.currentTokens > errorData.maxTokens && - truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts - ) { - log("[auto-compact] aggressive truncation triggered (experimental)", { - currentTokens: errorData.currentTokens, - maxTokens: errorData.maxTokens, - targetRatio: TRUNCATE_CONFIG.targetTokenRatio, - }) + if ( + experimental?.aggressive_truncation && + errorData?.currentTokens && + errorData?.maxTokens && + errorData.currentTokens > errorData.maxTokens && + truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts + ) { + log("[auto-compact] aggressive truncation triggered (experimental)", { + currentTokens: errorData.currentTokens, + maxTokens: errorData.maxTokens, + targetRatio: TRUNCATE_CONFIG.targetTokenRatio, + }); - const aggressiveResult = truncateUntilTargetTokens( - sessionID, - errorData.currentTokens, - errorData.maxTokens, - TRUNCATE_CONFIG.targetTokenRatio, - TRUNCATE_CONFIG.charsPerToken - ) - - if (aggressiveResult.truncatedCount > 0) { - truncateState.truncateAttempt += aggressiveResult.truncatedCount - - const toolNames = aggressiveResult.truncatedTools.map((t) => t.toolName).join(", ") - const statusMsg = aggressiveResult.sufficient - ? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})` - : `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...` - - await (client as Client).tui - .showToast({ - body: { - title: aggressiveResult.sufficient ? "Aggressive Truncation" : "Partial Truncation", - message: `${statusMsg}: ${toolNames}`, - variant: "warning", - duration: 4000, - }, - }) - .catch(() => {}) - - log("[auto-compact] aggressive truncation completed", aggressiveResult) - - if (aggressiveResult.sufficient) { - autoCompactState.compactionInProgress.delete(sessionID) - - setTimeout(async () => { - try { - await (client as Client).session.prompt_async({ - path: { sessionID }, - body: { parts: [{ type: "text", text: "Continue" }] }, - query: { directory }, - }) - } catch {} - }, 500) - return - } - } else { - await (client as Client).tui - .showToast({ - body: { - title: "Truncation Skipped", - message: "No tool outputs found to truncate.", - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) - } - } - - let skipSummarize = false - - if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) { - const largest = findLargestToolResult(sessionID) - - if (largest && largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate) { - const result = truncateToolResult(largest.partPath) - - if (result.success) { - truncateState.truncateAttempt++ - truncateState.lastTruncatedPartId = largest.partId - - await (client as Client).tui - .showToast({ - body: { - title: "Truncating Large Output", - message: `Truncated ${result.toolName} (${formatBytes(result.originalSize ?? 0)}). Retrying...`, - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) - - autoCompactState.compactionInProgress.delete(sessionID) - - setTimeout(async () => { - try { - await (client as Client).session.prompt_async({ - path: { sessionID }, - body: { parts: [{ type: "text", text: "Continue" }] }, - query: { directory }, - }) - } catch {} - }, 500) - return - } - } else if (errorData?.currentTokens && errorData?.maxTokens && errorData.currentTokens > errorData.maxTokens) { - skipSummarize = true - await (client as Client).tui - .showToast({ - body: { - title: "Summarize Skipped", - message: `Over token limit (${errorData.currentTokens}/${errorData.maxTokens}) with nothing to truncate. Going to revert...`, - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) - } else if (!errorData?.currentTokens) { - await (client as Client).tui - .showToast({ - body: { - title: "Truncation Skipped", - message: "No large tool outputs found.", - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) - } - } - - const retryState = getOrCreateRetryState(autoCompactState, sessionID) - - if (errorData?.errorType?.includes("non-empty content")) { - const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID) - if (attempt < 3) { - const fixed = await fixEmptyMessages( + const aggressiveResult = truncateUntilTargetTokens( sessionID, - autoCompactState, - client as Client, - errorData.messageIndex - ) - if (fixed) { - autoCompactState.compactionInProgress.delete(sessionID) - setTimeout(() => { - executeCompact(sessionID, msg, autoCompactState, client, directory, experimental) - }, 500) - return - } - } else { - await (client as Client).tui - .showToast({ - body: { - title: "Recovery Failed", - message: "Max recovery attempts (3) reached for empty content error. Please start a new session.", - variant: "error", - duration: 10000, - }, - }) - .catch(() => {}) - autoCompactState.compactionInProgress.delete(sessionID) - return - } - } + errorData.currentTokens, + errorData.maxTokens, + TRUNCATE_CONFIG.targetTokenRatio, + TRUNCATE_CONFIG.charsPerToken, + ); - if (Date.now() - retryState.lastAttemptTime > 300000) { - retryState.attempt = 0 - autoCompactState.fallbackStateBySession.delete(sessionID) - autoCompactState.truncateStateBySession.delete(sessionID) - } + if (aggressiveResult.truncatedCount > 0) { + truncateState.truncateAttempt += aggressiveResult.truncatedCount; - if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) { - retryState.attempt++ - retryState.lastAttemptTime = Date.now() + const toolNames = aggressiveResult.truncatedTools + .map((t) => t.toolName) + .join(", "); + const statusMsg = aggressiveResult.sufficient + ? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})` + : `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`; - const providerID = msg.providerID as string | undefined - const modelID = msg.modelID as string | undefined - - if (providerID && modelID) { - try { await (client as Client).tui .showToast({ body: { - title: "Auto Compact", - message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`, + title: aggressiveResult.sufficient + ? "Aggressive Truncation" + : "Partial Truncation", + message: `${statusMsg}: ${toolNames}`, + variant: "warning", + duration: 4000, + }, + }) + .catch(() => {}); + + log("[auto-compact] aggressive truncation completed", aggressiveResult); + + if (aggressiveResult.sufficient) { + setTimeout(async () => { + try { + await (client as Client).session.prompt_async({ + path: { sessionID }, + body: { parts: [{ type: "text", text: "Continue" }] }, + query: { directory }, + }); + } catch {} + }, 500); + return; + } + } else { + await (client as Client).tui + .showToast({ + body: { + title: "Truncation Skipped", + message: "No tool outputs found to truncate.", variant: "warning", duration: 3000, }, }) - .catch(() => {}) + .catch(() => {}); + } + } - await (client as Client).session.summarize({ - path: { id: sessionID }, - body: { providerID, modelID }, - query: { directory }, - }) + let skipSummarize = false; - autoCompactState.compactionInProgress.delete(sessionID) + if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) { + const largest = findLargestToolResult(sessionID); - setTimeout(async () => { - try { - await (client as Client).session.prompt_async({ - path: { sessionID }, - body: { parts: [{ type: "text", text: "Continue" }] }, - query: { directory }, + if ( + largest && + largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate + ) { + const result = truncateToolResult(largest.partPath); + + if (result.success) { + truncateState.truncateAttempt++; + truncateState.lastTruncatedPartId = largest.partId; + + await (client as Client).tui + .showToast({ + body: { + title: "Truncating Large Output", + message: `Truncated ${result.toolName} (${formatBytes(result.originalSize ?? 0)}). Retrying...`, + variant: "warning", + duration: 3000, + }, }) - } catch {} - }, 500) - return - } catch { - autoCompactState.compactionInProgress.delete(sessionID) + .catch(() => {}); - const delay = RETRY_CONFIG.initialDelayMs * Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1) - const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs) - - setTimeout(() => { - executeCompact(sessionID, msg, autoCompactState, client, directory, experimental) - }, cappedDelay) - return - } - } else { - await (client as Client).tui - .showToast({ - body: { - title: "Summarize Skipped", - message: "Missing providerID or modelID. Skipping to revert...", - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) - } - } - - const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID) - - if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) { - const pair = await getLastMessagePair(sessionID, client as Client, directory) - - if (pair) { - try { + setTimeout(async () => { + try { + await (client as Client).session.prompt_async({ + path: { sessionID }, + body: { parts: [{ type: "text", text: "Continue" }] }, + query: { directory }, + }); + } catch {} + }, 500); + return; + } + } else if ( + errorData?.currentTokens && + errorData?.maxTokens && + errorData.currentTokens > errorData.maxTokens + ) { + skipSummarize = true; await (client as Client).tui .showToast({ body: { - title: "Emergency Recovery", - message: "Removing last message pair...", + title: "Summarize Skipped", + message: `Over token limit (${errorData.currentTokens}/${errorData.maxTokens}) with nothing to truncate. Going to revert...`, variant: "warning", duration: 3000, }, }) - .catch(() => {}) + .catch(() => {}); + } else if (!errorData?.currentTokens) { + await (client as Client).tui + .showToast({ + body: { + title: "Truncation Skipped", + message: "No large tool outputs found.", + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}); + } + } + + const retryState = getOrCreateRetryState(autoCompactState, sessionID); + + if (errorData?.errorType?.includes("non-empty content")) { + const attempt = getOrCreateEmptyContentAttempt( + autoCompactState, + sessionID, + ); + if (attempt < 3) { + const fixed = await fixEmptyMessages( + sessionID, + autoCompactState, + client as Client, + errorData.messageIndex, + ); + if (fixed) { + setTimeout(() => { + executeCompact( + sessionID, + msg, + autoCompactState, + client, + directory, + experimental, + ); + }, 500); + return; + } + } else { + await (client as Client).tui + .showToast({ + body: { + title: "Recovery Failed", + message: + "Max recovery attempts (3) reached for empty content error. Please start a new session.", + variant: "error", + duration: 10000, + }, + }) + .catch(() => {}); + return; + } + } + + if (Date.now() - retryState.lastAttemptTime > 300000) { + retryState.attempt = 0; + autoCompactState.fallbackStateBySession.delete(sessionID); + autoCompactState.truncateStateBySession.delete(sessionID); + } + + if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) { + retryState.attempt++; + retryState.lastAttemptTime = Date.now(); + + const providerID = msg.providerID as string | undefined; + const modelID = msg.modelID as string | undefined; + + if (providerID && modelID) { + try { + await (client as Client).tui + .showToast({ + body: { + title: "Auto Compact", + message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`, + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}); + + await (client as Client).session.summarize({ + path: { id: sessionID }, + body: { providerID, modelID }, + query: { directory }, + }); + + setTimeout(async () => { + try { + await (client as Client).session.prompt_async({ + path: { sessionID }, + body: { parts: [{ type: "text", text: "Continue" }] }, + query: { directory }, + }); + } catch {} + }, 500); + return; + } catch { + const delay = + RETRY_CONFIG.initialDelayMs * + Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1); + const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs); + + setTimeout(() => { + executeCompact( + sessionID, + msg, + autoCompactState, + client, + directory, + experimental, + ); + }, cappedDelay); + return; + } + } else { + await (client as Client).tui + .showToast({ + body: { + title: "Summarize Skipped", + message: "Missing providerID or modelID. Skipping to revert...", + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}); + } + } + + const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID); + + if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) { + const pair = await getLastMessagePair( + sessionID, + client as Client, + directory, + ); + + if (pair) { + try { + await (client as Client).tui + .showToast({ + body: { + title: "Emergency Recovery", + message: "Removing last message pair...", + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}); + + if (pair.assistantMessageID) { + await (client as Client).session.revert({ + path: { id: sessionID }, + body: { messageID: pair.assistantMessageID }, + query: { directory }, + }); + } - if (pair.assistantMessageID) { await (client as Client).session.revert({ path: { id: sessionID }, - body: { messageID: pair.assistantMessageID }, + body: { messageID: pair.userMessageID }, query: { directory }, + }); + + fallbackState.revertAttempt++; + fallbackState.lastRevertedMessageID = pair.userMessageID; + + // Clear all state after successful revert - don't recurse + clearSessionState(autoCompactState, sessionID); + + // Send "Continue" prompt to resume session + setTimeout(async () => { + try { + await (client as Client).session.prompt_async({ + path: { sessionID }, + body: { parts: [{ type: "text", text: "Continue" }] }, + query: { directory }, + }); + } catch {} + }, 500); + return; + } catch {} + } else { + await (client as Client).tui + .showToast({ + body: { + title: "Revert Skipped", + message: "Could not find last message pair to revert.", + variant: "warning", + duration: 3000, + }, }) - } - - await (client as Client).session.revert({ - path: { id: sessionID }, - body: { messageID: pair.userMessageID }, - query: { directory }, - }) - - fallbackState.revertAttempt++ - fallbackState.lastRevertedMessageID = pair.userMessageID - - // Clear all state after successful revert - don't recurse - clearSessionState(autoCompactState, sessionID) - - // Send "Continue" prompt to resume session - setTimeout(async () => { - try { - await (client as Client).session.prompt_async({ - path: { sessionID }, - body: { parts: [{ type: "text", text: "Continue" }] }, - query: { directory }, - }) - } catch {} - }, 500) - return - } catch {} - } else { - await (client as Client).tui - .showToast({ - body: { - title: "Revert Skipped", - message: "Could not find last message pair to revert.", - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) + .catch(() => {}); + } } + + clearSessionState(autoCompactState, sessionID); + + await (client as Client).tui + .showToast({ + body: { + title: "Auto Compact Failed", + message: "All recovery attempts failed. Please start a new session.", + variant: "error", + duration: 5000, + }, + }) + .catch(() => {}); + } finally { + autoCompactState.compactionInProgress.delete(sessionID); } - - clearSessionState(autoCompactState, sessionID) - - await (client as Client).tui - .showToast({ - body: { - title: "Auto Compact Failed", - message: "All recovery attempts failed. Please start a new session.", - variant: "error", - duration: 5000, - }, - }) - .catch(() => {}) }