- Add 'dcp-for-compaction' to HookNameSchema - Remove dcp_for_compaction from ExperimentalConfigSchema - Update executor.ts to use dcpForCompaction parameter - Enable DCP by default (can be disabled via disabled_hooks) - Update all 4 README files (EN, KO, JA, ZH-CN) 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
264 lines
8.7 KiB
TypeScript
264 lines
8.7 KiB
TypeScript
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<string>(),
|
|
errorDataBySession: new Map(),
|
|
retryStateBySession: new Map(),
|
|
fallbackStateBySession: 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("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)
|
|
})
|
|
})
|