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(), dcpStateBySession: 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, } const dcpForCompaction = true // #when: Execute compaction with experimental flag await executeCompact( sessionID, msg, autoCompactState, mockClient, directory, experimental, dcpForCompaction, ) // #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) }) })