Skip to content

Commit 9ed91d6

Browse files
authored
🤖 feat: add model selection to /compact command (#275)
## Overview Adds support for choosing the compaction model via `/compact -m <model>` flag. Reuses existing model abbreviation infrastructure for consistency and shows the model name in the streaming barrier during compaction. ## Changes - **Command Parser**: Added `-m` flag parsing to `/compact` command with model abbreviation resolution - **Type System**: Extended `ParsedCommand` to include optional `model` field for compact command - **Message Preparation**: Updated `prepareCompactionMessage()` to use custom model when specified - **UI Display**: Updated `StreamingBarrier` to show model name during compaction (e.g., "claude-haiku-4-5 compacting...") - **Tests**: Added 7 new tests for `-m` flag, updated 27 existing tests ## Usage ```bash # Use haiku for fast compaction /compact -m haiku # Use opus for detailed summary with token limit /compact -m opus -t 8000 # Use full model string /compact -m anthropic:claude-opus-4-1 # Combine with continue message /compact -m sonnet -t 5000 Continue implementing the auth system # Default behavior (uses current workspace model) /compact ``` ## Design Notes - **Reuses model abbreviations**: Leverages existing `MODEL_ABBREVIATIONS` map for consistency with `/model` command - **Optional override**: Model parameter defaults to workspace model when not specified - **UI consistency**: Compaction barrier now matches streaming barrier format with model name prefix - **Robust parsing**: Handles edge cases (empty flag, unknown abbreviations, etc.) ## Testing ✅ All 34 slash command tests passing ✅ All 547 unit tests passing ✅ TypeScript compilation successful ✅ ESLint checks passing _Generated with `cmux`_
1 parent e0caa13 commit 9ed91d6

File tree

12 files changed

+307
-29
lines changed

12 files changed

+307
-29
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ jobs:
3232
3333
- name: Install and setup shfmt
3434
run: |
35-
if [[ ! -f "$HOME/.local/bin/shfmt" ]]; then
35+
# Install shfmt if not cached or if cached binary is broken
36+
if [[ ! -f "$HOME/.local/bin/shfmt" ]] || ! "$HOME/.local/bin/shfmt" --version >/dev/null 2>&1; then
3637
curl -sS https://webinstall.dev/shfmt | bash
3738
fi
3839
echo "$HOME/.local/bin" >> $GITHUB_PATH

docs/context-management.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,14 @@ Compress conversation history using AI summarization. Replaces the conversation
4747
### Syntax
4848

4949
```
50-
/compact [-t <tokens>]
50+
/compact [-t <tokens>] [-m <model>]
5151
[continue message on subsequent lines]
5252
```
5353

5454
### Options
5555

5656
- `-t <tokens>` - Maximum output tokens for the summary (default: ~2000 words)
57+
- `-m <model>` - Model to use for compaction (default: workspace model). Supports abbreviations like `haiku`, `sonnet`, or full model strings
5758

5859
### Examples
5960

@@ -69,6 +70,14 @@ Compress conversation history using AI summarization. Replaces the conversation
6970
/compact -t 5000
7071
```
7172

73+
**Choose compaction model:**
74+
75+
```
76+
/compact -m haiku
77+
```
78+
79+
Use Haiku for faster, lower-cost compaction.
80+
7281
**Auto-continue with custom message:**
7382

7483
```
@@ -88,16 +97,18 @@ Make sure to add tests for the error cases.
8897

8998
Continue messages can span multiple lines for more detailed instructions.
9099

91-
**Combine token limit and auto-continue:**
100+
**Combine all options:**
92101

93102
```
94-
/compact -t 3000
103+
/compact -m haiku -t 8000
95104
Keep working on the feature
96105
```
97106

107+
Combine custom model, token limit, and auto-continue message.
108+
98109
### Notes
99110

100-
- Uses the selected LLM to summarize conversation history
111+
- Uses the specified model (or workspace model by default) to summarize conversation history
101112
- Preserves actionable context and specific details
102113
- **Irreversible** - original messages are replaced
103114
- Continue message is sent once after compaction completes (not persisted)

src/components/AIView.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
495495
<StreamingBarrier
496496
statusText={
497497
isCompacting
498-
? "compacting..."
498+
? currentModel
499+
? `${getModelName(currentModel)} compacting...`
500+
: "compacting..."
499501
: currentModel
500502
? `${getModelName(currentModel)} streaming...`
501503
: "streaming..."

src/components/ChatInput.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ import { VimTextArea } from "./VimTextArea";
2626
import { ImageAttachments, type ImageAttachment } from "./ImageAttachments";
2727

2828
import type { ThinkingLevel } from "@/types/thinking";
29-
import type { CmuxFrontendMetadata } from "@/types/message";
29+
import type { CmuxFrontendMetadata, CompactionRequestData } from "@/types/message";
3030
import type { SendMessageOptions } from "@/types/ipc";
31+
import { applyCompactionOverrides } from "@/utils/messages/compactionOptions";
3132

3233
const InputSection = styled.div`
3334
position: relative;
@@ -304,22 +305,22 @@ function prepareCompactionMessage(
304305

305306
const messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`;
306307

308+
// Create compaction metadata (will be stored in user message)
309+
const compactData: CompactionRequestData = {
310+
model: parsed.model,
311+
maxOutputTokens: parsed.maxOutputTokens,
312+
continueMessage: parsed.continueMessage,
313+
};
314+
307315
const metadata: CmuxFrontendMetadata = {
308316
type: "compaction-request",
309317
rawCommand: command,
310-
parsed: {
311-
maxOutputTokens: parsed.maxOutputTokens,
312-
continueMessage: parsed.continueMessage,
313-
},
318+
parsed: compactData,
314319
};
315320

316-
const isAnthropic = sendMessageOptions.model.startsWith("anthropic:");
317-
const options: Partial<SendMessageOptions> = {
318-
thinkingLevel: isAnthropic ? "off" : sendMessageOptions.thinkingLevel,
319-
toolPolicy: [{ regex_match: "compact_summary", action: "require" }],
320-
maxOutputTokens: parsed.maxOutputTokens,
321-
mode: "compact" as const,
322-
};
321+
// Apply compaction overrides using shared transformation function
322+
// This same function is used by useResumeManager to ensure consistency
323+
const options = applyCompactionOverrides(sendMessageOptions, compactData);
323324

324325
return { messageText, metadata, options };
325326
}

src/hooks/useResumeManager.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getAutoRetryKey, getRetryStateKey } from "@/constants/storage";
55
import { getSendOptionsFromStorage } from "@/utils/messages/sendOptions";
66
import { readPersistedState } from "./usePersistedState";
77
import { hasInterruptedStream } from "@/utils/messages/retryEligibility";
8+
import { applyCompactionOverrides } from "@/utils/messages/compactionOptions";
89

910
interface RetryState {
1011
attempt: number;
@@ -139,7 +140,20 @@ export function useResumeManager() {
139140
const { attempt } = retryState;
140141

141142
try {
142-
const options = getSendOptionsFromStorage(workspaceId);
143+
// Start with workspace defaults
144+
let options = getSendOptionsFromStorage(workspaceId);
145+
146+
// Check if last user message was a compaction request
147+
const state = workspaceStatesRef.current.get(workspaceId);
148+
if (state) {
149+
const lastUserMsg = [...state.messages].reverse().find((msg) => msg.type === "user");
150+
if (lastUserMsg?.compactionRequest) {
151+
// Apply compaction overrides using shared function (same as ChatInput)
152+
// This ensures custom model/tokens are preserved across resume
153+
options = applyCompactionOverrides(options, lastUserMsg.compactionRequest.parsed);
154+
}
155+
}
156+
143157
const result = await window.api.workspace.resumeStream(workspaceId, options);
144158

145159
if (!result.success) {

src/services/agentSession.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { SendMessageError } from "@/types/errors";
1212
import { createUnknownSendMessageError } from "@/services/utils/sendMessageError";
1313
import type { Result } from "@/types/result";
1414
import { Ok, Err } from "@/types/result";
15+
import { enforceThinkingPolicy } from "@/utils/thinking/policy";
1516

1617
interface ImagePart {
1718
image: string;
@@ -297,11 +298,17 @@ export class AgentSession {
297298
return Err(createUnknownSendMessageError(historyResult.error));
298299
}
299300

301+
// Enforce thinking policy for the specified model (single source of truth)
302+
// This ensures model-specific requirements are met regardless of where the request originates
303+
const effectiveThinkingLevel = options?.thinkingLevel
304+
? enforceThinkingPolicy(modelString, options.thinkingLevel)
305+
: undefined;
306+
300307
const streamResult = await this.aiService.streamMessage(
301308
historyResult.data,
302309
this.workspaceId,
303310
modelString,
304-
options?.thinkingLevel,
311+
effectiveThinkingLevel,
305312
options?.toolPolicy,
306313
undefined,
307314
options?.additionalSystemInstructions,

src/types/message.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider";
33
import type { StreamErrorType } from "./errors";
44
import type { ToolPolicy } from "@/utils/tools/toolPolicy";
55

6+
// Parsed compaction request data (shared type for consistency)
7+
export interface CompactionRequestData {
8+
model?: string; // Custom model override for compaction
9+
maxOutputTokens?: number;
10+
continueMessage?: string;
11+
}
12+
613
// Frontend-specific metadata stored in cmuxMetadata field
714
// Backend stores this as-is without interpretation (black-box)
815
export type CmuxFrontendMetadata =
916
| {
1017
type: "compaction-request";
1118
rawCommand: string; // The original /compact command as typed by user (for display)
12-
parsed: {
13-
maxOutputTokens?: number;
14-
continueMessage?: string;
15-
};
19+
parsed: CompactionRequestData;
1620
}
1721
| {
1822
type: "compaction-result";
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Tests for compaction options transformation
3+
*/
4+
5+
import { applyCompactionOverrides } from "./compactionOptions";
6+
import type { SendMessageOptions } from "@/types/ipc";
7+
import type { CompactionRequestData } from "@/types/message";
8+
9+
describe("applyCompactionOverrides", () => {
10+
const baseOptions: SendMessageOptions = {
11+
model: "anthropic:claude-sonnet-4-5",
12+
thinkingLevel: "medium",
13+
toolPolicy: [],
14+
mode: "exec",
15+
};
16+
17+
it("uses workspace model when no override specified", () => {
18+
const compactData: CompactionRequestData = {};
19+
const result = applyCompactionOverrides(baseOptions, compactData);
20+
21+
expect(result.model).toBe("anthropic:claude-sonnet-4-5");
22+
expect(result.mode).toBe("compact");
23+
});
24+
25+
it("applies custom model override", () => {
26+
const compactData: CompactionRequestData = {
27+
model: "anthropic:claude-haiku-4-5",
28+
};
29+
const result = applyCompactionOverrides(baseOptions, compactData);
30+
31+
expect(result.model).toBe("anthropic:claude-haiku-4-5");
32+
});
33+
34+
it("sets thinking to off for Anthropic models", () => {
35+
const compactData: CompactionRequestData = {
36+
model: "anthropic:claude-haiku-4-5",
37+
};
38+
const result = applyCompactionOverrides(baseOptions, compactData);
39+
40+
expect(result.thinkingLevel).toBe("off");
41+
});
42+
43+
it("preserves workspace thinking level for non-Anthropic models", () => {
44+
const compactData: CompactionRequestData = {
45+
model: "openai:gpt-5-pro",
46+
};
47+
const result = applyCompactionOverrides(baseOptions, compactData);
48+
49+
expect(result.thinkingLevel).toBe("medium");
50+
});
51+
52+
it("applies maxOutputTokens override", () => {
53+
const compactData: CompactionRequestData = {
54+
maxOutputTokens: 8000,
55+
};
56+
const result = applyCompactionOverrides(baseOptions, compactData);
57+
58+
expect(result.maxOutputTokens).toBe(8000);
59+
});
60+
61+
it("sets compact mode and tool policy", () => {
62+
const compactData: CompactionRequestData = {};
63+
const result = applyCompactionOverrides(baseOptions, compactData);
64+
65+
expect(result.mode).toBe("compact");
66+
expect(result.toolPolicy).toEqual([{ regex_match: "compact_summary", action: "require" }]);
67+
});
68+
69+
it("applies all overrides together", () => {
70+
const compactData: CompactionRequestData = {
71+
model: "openai:gpt-5",
72+
maxOutputTokens: 5000,
73+
};
74+
const result = applyCompactionOverrides(baseOptions, compactData);
75+
76+
expect(result.model).toBe("openai:gpt-5");
77+
expect(result.maxOutputTokens).toBe(5000);
78+
expect(result.mode).toBe("compact");
79+
expect(result.thinkingLevel).toBe("medium"); // Non-Anthropic preserves original
80+
});
81+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Compaction options transformation
3+
*
4+
* Single source of truth for converting compaction metadata into SendMessageOptions.
5+
* Used by both ChatInput (initial send) and useResumeManager (resume after interruption).
6+
*/
7+
8+
import type { SendMessageOptions } from "@/types/ipc";
9+
import type { CompactionRequestData } from "@/types/message";
10+
11+
/**
12+
* Apply compaction-specific option overrides to base options.
13+
*
14+
* This function is the single source of truth for how compaction metadata
15+
* transforms workspace defaults. Both initial sends and stream resumption
16+
* use this function to ensure consistent behavior.
17+
*
18+
* @param baseOptions - Workspace default options (from localStorage or useSendMessageOptions)
19+
* @param compactData - Compaction request metadata from /compact command
20+
* @returns Final SendMessageOptions with compaction overrides applied
21+
*/
22+
export function applyCompactionOverrides(
23+
baseOptions: SendMessageOptions,
24+
compactData: CompactionRequestData
25+
): SendMessageOptions {
26+
// Use custom model if specified, otherwise use workspace default
27+
const compactionModel = compactData.model ?? baseOptions.model;
28+
29+
// Anthropic models don't support thinking, always use "off"
30+
// Non-Anthropic models keep workspace default (backend will enforce policy)
31+
const isAnthropic = compactionModel.startsWith("anthropic:");
32+
33+
return {
34+
...baseOptions,
35+
model: compactionModel,
36+
thinkingLevel: isAnthropic ? "off" : baseOptions.thinkingLevel,
37+
toolPolicy: [{ regex_match: "compact_summary", action: "require" }],
38+
maxOutputTokens: compactData.maxOutputTokens,
39+
mode: "compact" as const,
40+
};
41+
}

0 commit comments

Comments
 (0)