Skip to content

Commit 9431e34

Browse files
🤖 feat: add auto-compaction with progressive warnings (#683)
## Stack 1. #685 1. #683 ⬅ This PR 1. #670 1. #650 (base) ## Summary Adds automatic context compaction that triggers at 70% usage, with progressive countdown warnings starting at 60%. <img width="905" height="155" alt="image" src="https://github.com/user-attachments/assets/b0db20c5-c377-44bb-891c-f8ddadd561c8" /> <img width="891" height="194" alt="image" src="https://github.com/user-attachments/assets/6385cfd2-5e3c-45ec-afce-935dae56ad1a" /> Relates to #651. ## Key Changes **Auto-Compaction:** - Triggers automatically when current context usage reaches 70% of model's context window - Queues user's message to send after compaction completes - Includes image parts in continue messages **Progressive Warnings:** - Shows countdown at 60-69% usage: "Context left until Auto-Compact: X% remaining" - Shows urgent message at 70%+: "⚠️ Approaching context limit. Next message will trigger auto-compaction." **Implementation:** - New `shouldAutoCompact()` utility centralizes threshold logic with configurable constants - Returns `{ shouldShowWarning, usagePercentage, thresholdPercentage }` - Uses **last usage entry** (current context size) to match UI token meter display - Excludes historical usage from threshold check to prevent infinite compaction loops - `ContinueMessage` type now includes optional `imageParts` ## Technical Details **Usage Calculation:** The auto-compaction check uses the most recent usage entry from `usageHistory` to calculate the current context size. This matches the percentage displayed in the UI token meter and correctly handles post-compaction scenarios: - **Before compaction**: Last entry represents full context → triggers at 70% correctly - **After compaction**: Last entry excludes historical usage → resets to actual context size - **Historical usage preserved**: Remains in usage history for cost tracking, but not used for threshold calculations This prevents the infinite loop where post-compaction workspaces would continuously re-compact because historical usage tokens were being included in the threshold check. ## Future Work Future PRs will add user settings to configure auto-compaction (enable/disable, custom threshold). _Generated with `mux`_
1 parent eac4b56 commit 9431e34

18 files changed

+744
-131
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"lockfileVersion": 1,
3+
"configVersion": 0,
34
"workspaces": {
45
"": {
56
"name": "@coder/cmux",

mobile/src/utils/slashCommandHelpers.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,37 @@ describe("buildMobileCompactionPayload", () => {
5555
expect(payload.metadata.parsed).toEqual({
5656
model: "anthropic:claude-opus-4-1",
5757
maxOutputTokens: 800,
58-
continueMessage: parsed.continueMessage,
59-
resumeModel: baseOptions.model,
58+
continueMessage: {
59+
text: parsed.continueMessage,
60+
imageParts: [],
61+
model: baseOptions.model,
62+
},
6063
});
6164
expect(payload.sendOptions.model).toBe("anthropic:claude-opus-4-1");
6265
expect(payload.sendOptions.mode).toBe("compact");
6366
expect(payload.sendOptions.maxOutputTokens).toBe(800);
6467
});
68+
69+
it("omits continueMessage when no text provided", () => {
70+
const baseOptions: SendMessageOptions = {
71+
model: "anthropic:claude-sonnet-4-5",
72+
mode: "plan",
73+
thinkingLevel: "default",
74+
};
75+
76+
const parsed = {
77+
type: "compact" as const,
78+
maxOutputTokens: 1000,
79+
continueMessage: undefined,
80+
model: undefined,
81+
};
82+
83+
const payload = buildMobileCompactionPayload(parsed, baseOptions);
84+
85+
if (payload.metadata.type !== "compaction-request") {
86+
throw new Error("Expected compaction metadata");
87+
}
88+
89+
expect(payload.metadata.parsed.continueMessage).toBeUndefined();
90+
});
6591
});

mobile/src/utils/slashCommandHelpers.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,13 @@ export function buildMobileCompactionPayload(
5656
parsed: {
5757
model: parsed.model,
5858
maxOutputTokens: parsed.maxOutputTokens,
59-
continueMessage: parsed.continueMessage,
60-
resumeModel: baseOptions.model,
59+
continueMessage: parsed.continueMessage
60+
? {
61+
text: parsed.continueMessage,
62+
imageParts: [],
63+
model: baseOptions.model,
64+
}
65+
: undefined,
6166
},
6267
};
6368

src/browser/components/AIView.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,22 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
2323
import { useAutoScroll } from "@/browser/hooks/useAutoScroll";
2424
import { usePersistedState } from "@/browser/hooks/usePersistedState";
2525
import { useThinking } from "@/browser/contexts/ThinkingContext";
26-
import { useWorkspaceState, useWorkspaceAggregator } from "@/browser/stores/WorkspaceStore";
26+
import {
27+
useWorkspaceState,
28+
useWorkspaceAggregator,
29+
useWorkspaceUsage,
30+
} from "@/browser/stores/WorkspaceStore";
2731
import { WorkspaceHeader } from "./WorkspaceHeader";
2832
import { getModelName } from "@/common/utils/ai/models";
2933
import type { DisplayedMessage } from "@/common/types/message";
3034
import type { RuntimeConfig } from "@/common/types/runtime";
3135
import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds";
3236
import { evictModelFromLRU } from "@/browser/hooks/useModelLRU";
3337
import { QueuedMessage } from "./Messages/QueuedMessage";
38+
import { CompactionWarning } from "./CompactionWarning";
39+
import { shouldAutoCompact } from "@/browser/utils/compaction/autoCompactionCheck";
40+
import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
41+
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
3442

3543
interface AIViewProps {
3644
workspaceId: string;
@@ -74,6 +82,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
7482

7583
const workspaceState = useWorkspaceState(workspaceId);
7684
const aggregator = useWorkspaceAggregator(workspaceId);
85+
const workspaceUsage = useWorkspaceUsage(workspaceId);
86+
const { options } = useProviderOptions();
87+
const use1M = options.anthropic?.use1MContext ?? false;
7788
const handledModelErrorsRef = useRef<Set<string>>(new Set());
7889

7990
useEffect(() => {
@@ -130,6 +141,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
130141
markUserInteraction,
131142
} = useAutoScroll();
132143

144+
// Use send options for auto-compaction check
145+
const pendingSendOptions = useSendMessageOptions(workspaceId);
146+
133147
// ChatInput API for focus management
134148
const chatInputAPI = useRef<ChatInputAPI | null>(null);
135149
const handleChatInputReady = useCallback((api: ChatInputAPI) => {
@@ -318,6 +332,18 @@ const AIViewInner: React.FC<AIViewProps> = ({
318332
// Get active stream message ID for token counting
319333
const activeStreamMessageId = aggregator.getActiveStreamMessageId();
320334

335+
// Use pending send model for auto-compaction check, not the last stream's model.
336+
// This ensures the threshold is based on the model the user will actually send with,
337+
// preventing context-length errors when switching from a large-context to smaller model.
338+
const pendingModel = pendingSendOptions.model;
339+
340+
const autoCompactionCheck = pendingModel
341+
? shouldAutoCompact(workspaceUsage, pendingModel, use1M)
342+
: { shouldShowWarning: false, usagePercentage: 0, thresholdPercentage: 70 };
343+
344+
// Show warning when: shouldShowWarning flag is true AND not currently compacting
345+
const shouldShowCompactionWarning = !isCompacting && autoCompactionCheck.shouldShowWarning;
346+
321347
// Note: We intentionally do NOT reset autoRetry when streams start.
322348
// If user pressed the interrupt key, autoRetry stays false until they manually retry.
323349
// This makes state transitions explicit and predictable.
@@ -503,6 +529,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
503529
</button>
504530
)}
505531
</div>
532+
{shouldShowCompactionWarning && (
533+
<CompactionWarning
534+
usagePercentage={autoCompactionCheck.usagePercentage}
535+
thresholdPercentage={autoCompactionCheck.thresholdPercentage}
536+
/>
537+
)}
506538
<ChatInput
507539
variant="workspace"
508540
workspaceId={workspaceId}
@@ -516,6 +548,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
516548
onEditLastUserMessage={() => void handleEditLastUserMessage()}
517549
canInterrupt={canInterrupt}
518550
onReady={handleChatInputReady}
551+
autoCompactionCheck={autoCompactionCheck}
519552
/>
520553
</div>
521554

src/browser/components/ChatInput/index.tsx

Lines changed: 97 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from "@/common/constants/storage";
2828
import {
2929
prepareCompactionMessage,
30+
executeCompaction,
3031
processSlashCommand,
3132
type SlashCommandContext,
3233
} from "@/browser/utils/chatCommands";
@@ -478,12 +479,39 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
478479
const isSlashCommand = normalizedCommandInput.startsWith("/");
479480
const parsed = isSlashCommand ? parseCommand(normalizedCommandInput) : null;
480481

482+
// Prepare image parts early so slash commands can access them
483+
const imageParts = imageAttachments.map((img, index) => {
484+
// Validate before sending to help with debugging
485+
if (!img.url || typeof img.url !== "string") {
486+
console.error(
487+
`Image attachment [${index}] has invalid url:`,
488+
typeof img.url,
489+
img.url?.slice(0, 50)
490+
);
491+
}
492+
if (!img.url?.startsWith("data:")) {
493+
console.error(`Image attachment [${index}] url is not a data URL:`, img.url?.slice(0, 100));
494+
}
495+
if (!img.mediaType || typeof img.mediaType !== "string") {
496+
console.error(
497+
`Image attachment [${index}] has invalid mediaType:`,
498+
typeof img.mediaType,
499+
img.mediaType
500+
);
501+
}
502+
return {
503+
url: img.url,
504+
mediaType: img.mediaType,
505+
};
506+
});
507+
481508
if (parsed) {
482509
const context: SlashCommandContext = {
483510
variant,
484511
workspaceId: variant === "workspace" ? props.workspaceId : undefined,
485512
sendMessageOptions,
486513
setInput,
514+
setImageAttachments,
487515
setIsSending,
488516
setToast,
489517
setVimEnabled,
@@ -493,6 +521,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
493521
onTruncateHistory: variant === "workspace" ? props.onTruncateHistory : undefined,
494522
onCancelEdit: variant === "workspace" ? props.onCancelEdit : undefined,
495523
editMessageId: editingMessage?.id,
524+
imageParts: imageParts.length > 0 ? imageParts : undefined,
496525
resetInputHeight: () => {
497526
if (inputRef.current) {
498527
inputRef.current.style.height = "36px";
@@ -540,36 +569,71 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
540569
// Save current state for restoration on error
541570
const previousImageAttachments = [...imageAttachments];
542571

543-
try {
544-
// Prepare image parts if any
545-
const imageParts = imageAttachments.map((img, index) => {
546-
// Validate before sending to help with debugging
547-
if (!img.url || typeof img.url !== "string") {
548-
console.error(
549-
`Image attachment [${index}] has invalid url:`,
550-
typeof img.url,
551-
img.url?.slice(0, 50)
552-
);
553-
}
554-
if (!img.url?.startsWith("data:")) {
555-
console.error(
556-
`Image attachment [${index}] url is not a data URL:`,
557-
img.url?.slice(0, 100)
558-
);
559-
}
560-
if (!img.mediaType || typeof img.mediaType !== "string") {
561-
console.error(
562-
`Image attachment [${index}] has invalid mediaType:`,
563-
typeof img.mediaType,
564-
img.mediaType
565-
);
572+
// Auto-compaction check (workspace variant only)
573+
// Check if we should auto-compact before sending this message
574+
// Result is computed in parent (AIView) and passed down to avoid duplicate calculation
575+
const shouldAutoCompact =
576+
props.autoCompactionCheck &&
577+
props.autoCompactionCheck.usagePercentage >=
578+
props.autoCompactionCheck.thresholdPercentage &&
579+
!isCompacting; // Skip if already compacting to prevent double-compaction queue
580+
if (variant === "workspace" && !editingMessage && shouldAutoCompact) {
581+
// Clear input immediately for responsive UX
582+
setInput("");
583+
setImageAttachments([]);
584+
setIsSending(true);
585+
586+
try {
587+
const result = await executeCompaction({
588+
workspaceId: props.workspaceId,
589+
continueMessage: {
590+
text: messageText,
591+
imageParts,
592+
model: sendMessageOptions.model,
593+
},
594+
sendMessageOptions,
595+
});
596+
597+
if (!result.success) {
598+
// Restore on error
599+
setInput(messageText);
600+
setImageAttachments(previousImageAttachments);
601+
setToast({
602+
id: Date.now().toString(),
603+
type: "error",
604+
title: "Auto-Compaction Failed",
605+
message: result.error ?? "Failed to start auto-compaction",
606+
});
607+
} else {
608+
setToast({
609+
id: Date.now().toString(),
610+
type: "success",
611+
message: `Context threshold reached - auto-compacting...`,
612+
});
613+
props.onMessageSent?.();
566614
}
567-
return {
568-
url: img.url,
569-
mediaType: img.mediaType,
570-
};
571-
});
615+
} catch (error) {
616+
// Restore on unexpected error
617+
setInput(messageText);
618+
setImageAttachments(previousImageAttachments);
619+
setToast({
620+
id: Date.now().toString(),
621+
type: "error",
622+
title: "Auto-Compaction Failed",
623+
message:
624+
error instanceof Error ? error.message : "Unexpected error during auto-compaction",
625+
});
626+
} finally {
627+
setIsSending(false);
628+
}
572629

630+
return; // Skip normal send
631+
}
632+
633+
// Regular message - send directly via API
634+
setIsSending(true);
635+
636+
try {
573637
// When editing a /compact command, regenerate the actual summarization request
574638
let actualMessageText = messageText;
575639
let muxMetadata: MuxFrontendMetadata | undefined;
@@ -585,7 +649,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
585649
} = prepareCompactionMessage({
586650
workspaceId: props.workspaceId,
587651
maxOutputTokens: parsedEditingCommand.maxOutputTokens,
588-
continueMessage: parsedEditingCommand.continueMessage,
652+
continueMessage: {
653+
text: parsedEditingCommand.continueMessage ?? "",
654+
imageParts,
655+
model: sendMessageOptions.model,
656+
},
589657
model: parsedEditingCommand.model,
590658
sendMessageOptions,
591659
});

src/browser/components/ChatInput/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ImagePart } from "@/common/types/ipc";
22
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
3+
import type { AutoCompactionCheckResult } from "@/browser/utils/compaction/autoCompactionCheck";
34

45
export interface ChatInputAPI {
56
focus: () => void;
@@ -23,6 +24,7 @@ export interface ChatInputWorkspaceVariant {
2324
canInterrupt?: boolean;
2425
disabled?: boolean;
2526
onReady?: (api: ChatInputAPI) => void;
27+
autoCompactionCheck?: AutoCompactionCheckResult; // Computed in parent (AIView) to avoid duplicate calculation
2628
}
2729

2830
// Creation variant: simplified for first message / workspace creation
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from "react";
2+
3+
/**
4+
* Warning banner shown when context usage is approaching the compaction threshold.
5+
*
6+
* Displays progressive warnings:
7+
* - Below threshold: "Context left until Auto-Compact: X% remaining" (where X = threshold - current)
8+
* - At/above threshold: "Approaching context limit. Next message will trigger auto-compaction."
9+
*
10+
* Displayed above ChatInput when:
11+
* - Token usage >= (threshold - 10%) of model's context window
12+
* - Not currently compacting (user can still send messages)
13+
*
14+
* @param usagePercentage - Current token usage as percentage (0-100)
15+
* @param thresholdPercentage - Auto-compaction trigger threshold (0-100, default 70)
16+
*/
17+
export const CompactionWarning: React.FC<{
18+
usagePercentage: number;
19+
thresholdPercentage: number;
20+
}> = (props) => {
21+
// At threshold or above, next message will trigger compaction
22+
const willCompactNext = props.usagePercentage >= props.thresholdPercentage;
23+
24+
// Urgent warning at/above threshold - prominent blue box
25+
if (willCompactNext) {
26+
return (
27+
<div className="text-plan-mode bg-plan-mode/10 mx-4 my-4 rounded-sm px-4 py-3 text-center text-xs font-medium">
28+
⚠️ Context limit reached. Next message will trigger Auto-Compaction.
29+
</div>
30+
);
31+
}
32+
33+
// Countdown warning below threshold - subtle grey text, right-aligned
34+
const remaining = props.thresholdPercentage - props.usagePercentage;
35+
return (
36+
<div className="text-muted mx-4 mt-2 mb-1 text-right text-[10px]">
37+
Context left until Auto-Compact: {Math.round(remaining)}%
38+
</div>
39+
);
40+
};

src/browser/hooks/useResumeManager.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,15 @@ export function useResumeManager() {
171171
if (lastUserMsg?.compactionRequest) {
172172
// Apply compaction overrides using shared function (same as ChatInput)
173173
// This ensures custom model/tokens are preserved across resume
174-
options = applyCompactionOverrides(options, lastUserMsg.compactionRequest.parsed);
174+
options = applyCompactionOverrides(options, {
175+
model: lastUserMsg.compactionRequest.parsed.model,
176+
maxOutputTokens: lastUserMsg.compactionRequest.parsed.maxOutputTokens,
177+
continueMessage: {
178+
text: lastUserMsg.compactionRequest.parsed.continueMessage?.text ?? "",
179+
imageParts: lastUserMsg.compactionRequest.parsed.continueMessage?.imageParts,
180+
model: lastUserMsg.compactionRequest.parsed.continueMessage?.model ?? options.model,
181+
},
182+
});
175183
}
176184
}
177185

0 commit comments

Comments
 (0)