dc057e9910
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)
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
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
|
|
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<string>(),
|
|
errorDataBySession: new Map(),
|
|
retryStateBySession: new Map(),
|
|
truncateStateBySession: new Map(),
|
|
dcpStateBySession: new Map(),
|
|
emptyContentAttemptBySession: new Map(),
|
|
compactionInProgress: new Set<string>(),
|
|
}
|
|
|
|
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("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.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)
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|