Skip to content

Commit ab3cc79

Browse files
committed
🤖 feat: add 5% buffer between auto-compaction threshold and force-compaction
1 parent 76d8779 commit ab3cc79

File tree

5 files changed

+80
-64
lines changed

5 files changed

+80
-64
lines changed

‎src/browser/components/AIView.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
594594
<CompactionWarning
595595
usagePercentage={autoCompactionResult.usagePercentage}
596596
thresholdPercentage={autoCompactionResult.thresholdPercentage}
597+
isStreaming={canInterrupt}
597598
onCompactClick={handleCompactClick}
598599
/>
599600
)}

‎src/browser/components/CompactionWarning.tsx‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,58 @@
11
import React from "react";
2+
import { FORCE_COMPACTION_BUFFER_PERCENT } from "@/common/constants/ui";
23

34
/**
45
* Warning indicator shown when context usage is approaching the compaction threshold.
56
*
67
* Displays as subtle right-aligned text:
78
* - Below threshold: "Auto-Compact in X% usage" (where X = threshold - current)
8-
* - At/above threshold: Bold "Next message will Auto-Compact"
9+
* - At/above threshold (not streaming): Bold "Next message will Auto-Compact"
10+
* - At/above threshold (streaming): "Force-compacting in N%" (where N = force threshold - current usage)
911
*
10-
* Both states are clickable to insert /compact command.
12+
* All states are clickable to insert /compact command.
1113
*
12-
* @param usagePercentage - Current token usage as percentage (0-100)
14+
* @param usagePercentage - Current token usage as percentage (0-100), reflects live usage when streaming
1315
* @param thresholdPercentage - Auto-compaction trigger threshold (0-100, default 70)
16+
* @param isStreaming - Whether currently streaming a response
1417
* @param onCompactClick - Callback when user clicks to trigger manual compaction
1518
*/
1619
export const CompactionWarning: React.FC<{
1720
usagePercentage: number;
1821
thresholdPercentage: number;
22+
isStreaming: boolean;
1923
onCompactClick?: () => void;
2024
}> = (props) => {
2125
// At threshold or above, next message will trigger compaction
2226
const willCompactNext = props.usagePercentage >= props.thresholdPercentage;
2327
const remaining = props.thresholdPercentage - props.usagePercentage;
2428

25-
const text = willCompactNext
26-
? "Next message will Auto-Compact"
27-
: `Auto-Compact in ${Math.round(remaining)}% usage`;
29+
// When streaming and above threshold, show countdown to force-compaction
30+
const forceCompactThreshold = props.thresholdPercentage + FORCE_COMPACTION_BUFFER_PERCENT;
31+
const showForceCompactCountdown =
32+
props.isStreaming && willCompactNext && props.usagePercentage < forceCompactThreshold;
33+
const forceCompactRemaining = forceCompactThreshold - props.usagePercentage;
34+
35+
let text: string;
36+
let isUrgent: boolean;
37+
38+
if (showForceCompactCountdown) {
39+
text = `Force-compacting in ${Math.round(forceCompactRemaining)}%`;
40+
isUrgent = false;
41+
} else if (willCompactNext) {
42+
text = "Next message will Auto-Compact";
43+
isUrgent = true;
44+
} else {
45+
text = `Auto-Compact in ${Math.round(remaining)}% usage`;
46+
isUrgent = false;
47+
}
2848

2949
return (
3050
<div className="mx-4 mt-2 mb-1 text-right text-[10px]">
3151
<button
3252
type="button"
3353
onClick={props.onCompactClick}
3454
className={`cursor-pointer hover:underline ${
35-
willCompactNext ? "text-plan-mode font-semibold" : "text-muted"
55+
isUrgent ? "text-plan-mode font-semibold" : "text-muted"
3656
}`}
3757
title="Click to insert /compact command"
3858
>

‎src/browser/utils/compaction/autoCompactionCheck.test.ts‎

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { checkAutoCompaction } from "./autoCompactionCheck";
33
import type { WorkspaceUsageState } from "@/browser/stores/WorkspaceStore";
44
import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator";
55
import { KNOWN_MODELS } from "@/common/constants/knownModels";
6-
import { FORCE_COMPACTION_TOKEN_BUFFER } from "@/common/constants/ui";
76

87
// Helper to create a mock usage entry
98
const createUsageEntry = (
@@ -302,63 +301,72 @@ describe("checkAutoCompaction", () => {
302301
});
303302
});
304303

305-
describe("Force Compaction (Live Usage)", () => {
304+
describe("Force Compaction (threshold + 5% buffer)", () => {
305+
// Force-compact triggers at threshold + 5%
306+
// With default 70% threshold, force-compact at 75%
306307
const SONNET_MAX_TOKENS = 200_000;
307-
const BUFFER = FORCE_COMPACTION_TOKEN_BUFFER;
308308

309-
test("shouldForceCompact is false when no liveUsage (falls back to lastUsage with room)", () => {
310-
const usage = createMockUsage(100_000); // 100k remaining - plenty of room
309+
test("shouldForceCompact is false when usage below threshold + 5%", () => {
310+
// 70% usage, threshold 70%, force at 75% - should NOT trigger
311+
const usage = createMockUsage(140_000); // 70%
311312
const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false);
312313

313314
expect(result.shouldForceCompact).toBe(false);
314315
});
315316

316-
test("shouldForceCompact is false when currentUsage has plenty of room", () => {
317-
const liveUsage = createUsageEntry(100_000); // 100k remaining
318-
const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage);
317+
test("shouldForceCompact is false when usage just below force threshold", () => {
318+
// 74% usage, threshold 70%, force at 75% - should NOT trigger
319+
const usage = createMockUsage(148_000); // 74%
319320
const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false);
320321

321322
expect(result.shouldForceCompact).toBe(false);
322323
});
323324

324-
test("shouldForceCompact is true when remaining <= buffer", () => {
325-
// Exactly at buffer threshold
326-
const liveUsage = createUsageEntry(SONNET_MAX_TOKENS - BUFFER);
327-
const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage);
325+
test("shouldForceCompact is true when usage at force threshold", () => {
326+
// 75% usage, threshold 70%, force at 75% - should trigger
327+
const usage = createMockUsage(150_000); // 75%
328328
const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false);
329329

330330
expect(result.shouldForceCompact).toBe(true);
331331
});
332332

333-
test("shouldForceCompact is true when over context limit", () => {
334-
const liveUsage = createUsageEntry(SONNET_MAX_TOKENS + 5000);
335-
const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage);
333+
test("shouldForceCompact is true when usage above force threshold", () => {
334+
// 80% usage, threshold 70%, force at 75% - should trigger
335+
const usage = createMockUsage(160_000); // 80%
336336
const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false);
337337

338338
expect(result.shouldForceCompact).toBe(true);
339339
});
340340

341-
test("shouldForceCompact is false when just above buffer", () => {
342-
// 1 token above buffer threshold
343-
const liveUsage = createUsageEntry(SONNET_MAX_TOKENS - BUFFER - 1);
344-
const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage);
341+
test("shouldForceCompact uses liveUsage when available", () => {
342+
// lastUsage at 50%, liveUsage at 75% - should trigger based on live
343+
const liveUsage = createUsageEntry(150_000); // 75%
344+
const usage = createMockUsage(100_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage);
345345
const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false);
346346

347-
expect(result.shouldForceCompact).toBe(false);
347+
expect(result.shouldForceCompact).toBe(true);
348+
expect(result.usagePercentage).toBe(75); // usagePercentage reflects live when streaming
349+
});
350+
351+
test("shouldForceCompact respects custom threshold", () => {
352+
// 55% usage with 50% threshold - force at 55%, should trigger
353+
const usage = createMockUsage(110_000); // 55%
354+
const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, 0.5);
355+
356+
expect(result.shouldForceCompact).toBe(true);
348357
});
349358

350359
test("shouldForceCompact respects 1M context mode", () => {
351-
// With 1M context, exactly at buffer threshold
352-
const liveUsage = createUsageEntry(1_000_000 - BUFFER);
360+
// 75% of 1M = 750k tokens
361+
const liveUsage = createUsageEntry(750_000);
353362
const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage);
354363
const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, true);
355364

356365
expect(result.shouldForceCompact).toBe(true);
357366
});
358367

359-
test("shouldForceCompact triggers with empty history but liveUsage near limit", () => {
360-
// Bug fix: empty history but liveUsage should still trigger
361-
const liveUsage = createUsageEntry(SONNET_MAX_TOKENS - BUFFER);
368+
test("shouldForceCompact triggers with empty history but liveUsage at force threshold", () => {
369+
const liveUsage = createUsageEntry(150_000); // 75%
362370
const usage: WorkspaceUsageState = {
363371
usageHistory: [],
364372
totalTokens: 0,
@@ -367,12 +375,11 @@ describe("checkAutoCompaction", () => {
367375
const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false);
368376

369377
expect(result.shouldForceCompact).toBe(true);
370-
expect(result.usagePercentage).toBe(0); // No lastUsage for percentage
378+
expect(result.usagePercentage).toBe(75); // usagePercentage reflects live even with empty history
371379
});
372380

373381
test("shouldForceCompact is false when auto-compaction disabled", () => {
374-
const liveUsage = createUsageEntry(199_000); // Very close to limit
375-
const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage);
382+
const usage = createMockUsage(190_000); // 95% - would trigger if enabled
376383
const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, 1.0); // disabled
377384

378385
expect(result.shouldForceCompact).toBe(false);

‎src/browser/utils/compaction/autoCompactionCheck.ts‎

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { getModelStats } from "@/common/utils/tokens/modelStats";
2121
import { supports1MContext } from "@/common/utils/ai/models";
2222
import {
2323
DEFAULT_AUTO_COMPACTION_THRESHOLD,
24-
FORCE_COMPACTION_TOKEN_BUFFER,
24+
FORCE_COMPACTION_BUFFER_PERCENT,
2525
} from "@/common/constants/ui";
2626

2727
/** Sum all token components from a ChatUsageDisplay */
@@ -37,8 +37,9 @@ function getTotalTokens(usage: ChatUsageDisplay): number {
3737

3838
export interface AutoCompactionCheckResult {
3939
shouldShowWarning: boolean;
40-
/** True when live usage shows ≤FORCE_COMPACTION_TOKEN_BUFFER remaining in context */
40+
/** True when usage exceeds threshold + buffer (gives user control before force-compact) */
4141
shouldForceCompact: boolean;
42+
/** Current usage percentage - live when streaming, otherwise last completed */
4243
usagePercentage: number;
4344
thresholdPercentage: number;
4445
}
@@ -94,30 +95,20 @@ export function checkAutoCompaction(
9495
};
9596
}
9697

97-
// Current usage: live when streaming, else last historical
98-
// Use lastContextUsage (last step) for accurate context window size
98+
// Current usage: live when streaming, else last completed
9999
const lastUsage = usage.lastContextUsage;
100100
const currentUsage = usage.liveUsage ?? lastUsage;
101101

102-
// Force-compact when approaching context limit (can trigger even with empty history if streaming)
103-
let shouldForceCompact = false;
104-
if (currentUsage) {
105-
const remainingTokens = maxTokens - getTotalTokens(currentUsage);
106-
shouldForceCompact = remainingTokens <= FORCE_COMPACTION_TOKEN_BUFFER;
107-
}
102+
// Usage percentage from current context (live when streaming, otherwise last completed)
103+
const usagePercentage = currentUsage ? (getTotalTokens(currentUsage) / maxTokens) * 100 : 0;
108104

109-
// Warning/percentage based on lastUsage (completed requests only)
110-
if (!lastUsage) {
111-
return {
112-
shouldShowWarning: false,
113-
shouldForceCompact,
114-
usagePercentage: 0,
115-
thresholdPercentage,
116-
};
117-
}
105+
// Force-compact when usage exceeds threshold + buffer
106+
const forceCompactThreshold = thresholdPercentage + FORCE_COMPACTION_BUFFER_PERCENT;
107+
const shouldForceCompact = usagePercentage >= forceCompactThreshold;
118108

119-
const usagePercentage = (getTotalTokens(lastUsage) / maxTokens) * 100;
120-
const shouldShowWarning = usagePercentage >= thresholdPercentage - warningAdvancePercent;
109+
// Warning based on last completed usage (not live) to avoid flickering
110+
const lastUsagePercentage = lastUsage ? (getTotalTokens(lastUsage) / maxTokens) * 100 : 0;
111+
const shouldShowWarning = lastUsagePercentage >= thresholdPercentage - warningAdvancePercent;
121112

122113
return {
123114
shouldShowWarning,

‎src/common/constants/ui.ts‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,11 @@ export const DEFAULT_COMPACTION_WORD_TARGET = 2000;
4141
export const WORDS_TO_TOKENS_RATIO = 1.3;
4242

4343
/**
44-
* Force-compaction token buffer.
45-
* When auto-compaction is enabled and live usage shows this many tokens or fewer
46-
* remaining in the context window, force a compaction immediately.
47-
* Set to 2x the expected compaction output size to ensure room for the summary.
48-
*/
49-
export const FORCE_COMPACTION_TOKEN_BUFFER = Math.round(
50-
2 * DEFAULT_COMPACTION_WORD_TARGET * WORDS_TO_TOKENS_RATIO
51-
); // = 5200 tokens
44+
* Force-compact this many percentage points after threshold.
45+
* Gives user a buffer zone between warning and force-compaction.
46+
* E.g., with 70% threshold, force-compact triggers at 75%.
47+
*/
48+
export const FORCE_COMPACTION_BUFFER_PERCENT = 5;
5249

5350
/**
5451
* Duration (ms) to show "copied" feedback after copying to clipboard

0 commit comments

Comments
 (0)