diff --git a/src/hooks/preemptive-compaction.test.ts b/src/hooks/preemptive-compaction.test.ts index ef6e695b0..63bfd3710 100644 --- a/src/hooks/preemptive-compaction.test.ts +++ b/src/hooks/preemptive-compaction.test.ts @@ -712,4 +712,166 @@ describe("preemptive-compaction", () => { expect(ctx.client.session.summarize).toHaveBeenCalled() }) + + // #given successful compaction at 180k and no new message.updated + // #when tool.execute.after fires again with tokens below 15% regrowth + // #then should NOT re-compact (within-turn guard) + it("should block re-compaction when tokens have not grown 15% past last compaction", async () => { + //#given + const hook = createPreemptiveCompactionHook(ctx as never, {} as never) + const sessionID = "ses_regrowth_block" + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_regrowth_1" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + + //#when - advance past cooldown without a new message.updated (lastCompactedTokens still set) + const originalNow = Date.now + Date.now = () => originalNow() + 61_000 + try { + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_regrowth_2" }, + { title: "", output: "test", metadata: null } + ) + + //#then - regrowth gate blocks re-compaction because tokens unchanged + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + } finally { + Date.now = originalNow + } + }) + + // #given compaction fails with an error + // #when tool.execute.after runs + // #then a warning toast should be shown + it("should show a warning toast when compaction fails", async () => { + //#given + const hook = createPreemptiveCompactionHook(ctx as never, {} as never) + const sessionID = "ses_toast_fail" + const summarizeError = new Error("boom") + ctx.client.session.summarize.mockRejectedValueOnce(summarizeError) + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + //#when + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_toast" }, + { title: "", output: "test", metadata: null } + ) + + //#then + expect(ctx.client.tui.showToast).toHaveBeenCalledTimes(1) + expect(ctx.client.tui.showToast).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + title: "Compaction failed", + variant: "warning", + duration: 8000, + message: expect.stringContaining("boom"), + }), + }), + ) + }) + + // #given session has cached compaction state and last-compacted tokens + // #when session.deleted event fires + // #then all per-session state should be cleared and subsequent tool.execute.after should no-op + it("should clear lastCompactedTokens state on session.deleted", async () => { + //#given + const hook = createPreemptiveCompactionHook(ctx as never, {} as never) + const sessionID = "ses_cleanup" + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_cleanup_1" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + + //#when + await hook.event({ + event: { + type: "session.deleted", + properties: { info: { id: sessionID } }, + }, + }) + + const originalNow = Date.now + Date.now = () => originalNow() + 61_000 + try { + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_cleanup_2" }, + { title: "", output: "test", metadata: null } + ) + + //#then - tokenCache wiped on deletion → no-op, summarize not re-invoked + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + } finally { + Date.now = originalNow + } + }) }) diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index ef58b1a95..4dbc6342d 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -8,9 +8,10 @@ import { import { resolveCompactionModel } from "./shared/compaction-model-resolver" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" -const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000 +const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000 const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78 const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000 +const PREEMPTIVE_COMPACTION_REGROWTH_RATIO = 1.15 declare function setTimeout(handler: () => void, timeout?: number): unknown declare function clearTimeout(timeoutID: unknown): void @@ -68,7 +69,7 @@ export function createPreemptiveCompactionHook( modelCacheState?: ContextLimitModelCacheState, ) { const compactionInProgress = new Set() - const compactedSessions = new Set() + const lastCompactedTokens = new Map() const lastCompactionTime = new Map() const tokenCache = new Map() @@ -85,7 +86,7 @@ export function createPreemptiveCompactionHook( _output: { title: string; output: string; metadata: unknown } ) => { const { sessionID } = input - if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return + if (compactionInProgress.has(sessionID)) return const lastTime = lastCompactionTime.get(sessionID) if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return @@ -108,6 +109,15 @@ export function createPreemptiveCompactionHook( } const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0) + + const previousCompactedTokens = lastCompactedTokens.get(sessionID) + if ( + previousCompactedTokens !== undefined + && totalInputTokens < previousCompactedTokens * PREEMPTIVE_COMPACTION_REGROWTH_RATIO + ) { + return + } + const usageRatio = totalInputTokens / actualLimit if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return @@ -132,9 +142,17 @@ export function createPreemptiveCompactionHook( `Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`, ) - compactedSessions.add(sessionID) + lastCompactedTokens.set(sessionID, totalInputTokens) } catch (error) { log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) }) + ctx.client.tui.showToast({ + body: { + title: "Compaction failed", + message: `Preemptive compaction timed out or failed for session. Context may grow large. Error: ${String(error)}`, + variant: "warning", + duration: 8000, + }, + }).catch(() => {}) } finally { compactionInProgress.delete(sessionID) } @@ -147,7 +165,7 @@ export function createPreemptiveCompactionHook( const sessionID = (props?.info as { id?: string } | undefined)?.id if (sessionID) { compactionInProgress.delete(sessionID) - compactedSessions.delete(sessionID) + lastCompactedTokens.delete(sessionID) lastCompactionTime.delete(sessionID) tokenCache.delete(sessionID) postCompactionMonitor.clear(sessionID) @@ -184,7 +202,7 @@ export function createPreemptiveCompactionHook( tokens: info.tokens, }) } - compactedSessions.delete(info.sessionID) + lastCompactedTokens.delete(info.sessionID) await postCompactionMonitor.onAssistantMessageUpdated({ sessionID: info.sessionID,