From 45eb2f9b780a6967de861593a57ef9e661ece3c4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 21:17:56 -0500 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20model=20select?= =?UTF-8?q?ion=20to=20/compact=20with=20-m=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add -m flag to /compact command for choosing compaction model - Reuse MODEL_ABBREVIATIONS for consistent abbreviation handling - Support both abbreviated (sonnet, haiku, opus) and full model strings - Update StreamingBarrier to show model name during compaction - Falls back to workspace model when -m flag not specified - Add comprehensive tests covering all flag combinations Examples: /compact -m haiku /compact -m opus -t 8000 /compact -m anthropic:claude-opus-4-1 Generated with `cmux` --- src/components/AIView.tsx | 4 +- src/components/ChatInput.tsx | 5 +- src/utils/slashCommands/compact.test.ts | 108 ++++++++++++++++++++++++ src/utils/slashCommands/registry.ts | 16 +++- src/utils/slashCommands/types.ts | 2 +- 5 files changed, 128 insertions(+), 7 deletions(-) diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 01b116ce77..5e1e9208a1 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -495,7 +495,9 @@ const AIViewInner: React.FC = ({ = { + model: compactionModel, thinkingLevel: isAnthropic ? "off" : sendMessageOptions.thinkingLevel, toolPolicy: [{ regex_match: "compact_summary", action: "require" }], maxOutputTokens: parsed.maxOutputTokens, diff --git a/src/utils/slashCommands/compact.test.ts b/src/utils/slashCommands/compact.test.ts index 76a69c35bd..97fa06140c 100644 --- a/src/utils/slashCommands/compact.test.ts +++ b/src/utils/slashCommands/compact.test.ts @@ -10,6 +10,7 @@ describe("compact command parser", () => { type: "compact", maxOutputTokens: undefined, continueMessage: undefined, + model: undefined, }); }); @@ -19,6 +20,7 @@ describe("compact command parser", () => { type: "compact", maxOutputTokens: 5000, continueMessage: undefined, + model: undefined, }); }); @@ -28,6 +30,7 @@ describe("compact command parser", () => { type: "compact", maxOutputTokens: undefined, continueMessage: "Continue where we left off", + model: undefined, }); }); @@ -37,6 +40,7 @@ describe("compact command parser", () => { type: "compact", maxOutputTokens: 3000, continueMessage: "Keep going", + model: undefined, }); }); @@ -46,6 +50,7 @@ describe("compact command parser", () => { type: "compact", maxOutputTokens: 3000, continueMessage: "Keep going", + model: undefined, }); }); @@ -55,6 +60,7 @@ describe("compact command parser", () => { type: "compact", maxOutputTokens: undefined, continueMessage: undefined, + model: undefined, }); }); @@ -64,6 +70,7 @@ describe("compact command parser", () => { type: "compact", maxOutputTokens: undefined, continueMessage: "Keep", + model: undefined, }); }); @@ -113,6 +120,76 @@ describe("compact command parser", () => { subcommand: "-t requires a positive number, got 0", }); }); + + it("parses -m flag with model abbreviation", () => { + const result = parseCommand("/compact -m sonnet"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: undefined, + model: "anthropic:claude-sonnet-4-5", + }); + }); + + it("parses -m flag with full model string", () => { + const result = parseCommand("/compact -m anthropic:claude-opus-4-1"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: undefined, + model: "anthropic:claude-opus-4-1", + }); + }); + + it("parses -m flag with other flags", () => { + const result = parseCommand('/compact -t 5000 -m haiku -c "Keep going"'); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: 5000, + continueMessage: "Keep going", + model: "anthropic:claude-haiku-4-5", + }); + }); + + it("parses -m flag in any position", () => { + const result = parseCommand('/compact -m opus -t 3000 -c "Continue"'); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: 3000, + continueMessage: "Continue", + model: "anthropic:claude-opus-4-1", + }); + }); + + it("handles -m without model (undefined)", () => { + const result = parseCommand("/compact -m"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: undefined, + model: undefined, + }); + }); + + it("resolves model abbreviations case-sensitively", () => { + const result = parseCommand("/compact -m codex"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: undefined, + model: "openai:gpt-5-codex", + }); + }); + + it("treats unknown abbreviations as full model strings", () => { + const result = parseCommand("/compact -m custom:model"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: undefined, + model: "custom:model", + }); + }); }); it("rejects extra positional arguments", () => { @@ -140,6 +217,7 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: undefined, continueMessage: "Continue implementing the auth system", + model: undefined, }); }); @@ -149,6 +227,7 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: 5000, continueMessage: "Keep working on the feature", + model: undefined, }); }); @@ -158,6 +237,7 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: undefined, continueMessage: "Line 1\nLine 2\nLine 3", + model: undefined, }); }); @@ -167,6 +247,7 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: undefined, continueMessage: "Continue after empty line", + model: undefined, }); }); @@ -176,6 +257,7 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: undefined, continueMessage: "Indented message\n More indented", + model: undefined, }); }); @@ -185,6 +267,7 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: undefined, continueMessage: "Flag message", + model: undefined, }); }); @@ -194,6 +277,7 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: 3000, continueMessage: "Keep going", + model: undefined, }); }); @@ -203,6 +287,7 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: undefined, continueMessage: "Continue here", + model: undefined, }); }); @@ -212,6 +297,7 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: undefined, continueMessage: undefined, + model: undefined, }); }); @@ -222,6 +308,7 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: undefined, continueMessage: "-t should be treated as message content", + model: undefined, }); }); @@ -231,6 +318,27 @@ describe("multiline continue messages", () => { type: "compact", maxOutputTokens: 5000, continueMessage: "-c this is not a flag", + model: undefined, + }); + }); + + it("parses -m flag with multiline continue message", () => { + const result = parseCommand("/compact -m haiku\nContinue with the implementation"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: "Continue with the implementation", + model: "anthropic:claude-haiku-4-5", + }); + }); + + it("parses all flags with multiline continue message", () => { + const result = parseCommand('/compact -t 5000 -m sonnet\nFinish the refactoring'); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: 5000, + continueMessage: "Finish the refactoring", + model: "anthropic:claude-sonnet-4-5", }); }); }); diff --git a/src/utils/slashCommands/registry.ts b/src/utils/slashCommands/registry.ts index b4657ab12a..0715ef24dd 100644 --- a/src/utils/slashCommands/registry.ts +++ b/src/utils/slashCommands/registry.ts @@ -172,7 +172,7 @@ const truncateCommandDefinition: SlashCommandDefinition = { const compactCommandDefinition: SlashCommandDefinition = { key: "compact", description: - "Compact conversation history using AI summarization. Use -t to set max output tokens. Add continue message on lines after the command.", + "Compact conversation history using AI summarization. Use -t to set max output tokens, -m to set compaction model. Add continue message on lines after the command.", handler: ({ rawInput }): ParsedCommand => { // Split rawInput into first line (for flags) and remaining lines (for multiline continue) // rawInput format: "-t 5000\nContinue here" or "\nContinue here" (starts with newline if no flags) @@ -189,7 +189,7 @@ const compactCommandDefinition: SlashCommandDefinition = { // Parse flags from first line using minimist const parsed = minimist(firstLineTokens, { - string: ["t", "c"], + string: ["t", "c", "m"], unknown: (arg: string) => { // Unknown flags starting with - are errors if (arg.startsWith("-")) { @@ -201,7 +201,7 @@ const compactCommandDefinition: SlashCommandDefinition = { // Check for unknown flags (only from first line) const unknownFlags = firstLineTokens.filter( - (token) => token.startsWith("-") && token !== "-t" && token !== "-c" + (token) => token.startsWith("-") && token !== "-t" && token !== "-c" && token !== "-m" ); if (unknownFlags.length > 0) { return { @@ -225,6 +225,14 @@ const compactCommandDefinition: SlashCommandDefinition = { maxOutputTokens = tokens; } + // Handle -m (model) flag: resolve abbreviation if present, otherwise use as-is + let model: string | undefined; + if (parsed.m !== undefined && typeof parsed.m === "string" && parsed.m.trim().length > 0) { + const modelInput = parsed.m.trim(); + // Check if it's an abbreviation + model = MODEL_ABBREVIATIONS[modelInput] ?? modelInput; + } + // Reject extra positional arguments UNLESS they're from multiline content // (multiline content gets parsed as positional args by minimist since newlines become spaces) if (parsed._.length > 0 && !hasMultilineContent) { @@ -248,7 +256,7 @@ const compactCommandDefinition: SlashCommandDefinition = { continueMessage = remainingLines; } - return { type: "compact", maxOutputTokens, continueMessage }; + return { type: "compact", maxOutputTokens, continueMessage, model }; }, }; diff --git a/src/utils/slashCommands/types.ts b/src/utils/slashCommands/types.ts index 1f0b586701..1d0b7a9c25 100644 --- a/src/utils/slashCommands/types.ts +++ b/src/utils/slashCommands/types.ts @@ -11,7 +11,7 @@ export type ParsedCommand = | { type: "model-help" } | { type: "clear" } | { type: "truncate"; percentage: number } - | { type: "compact"; maxOutputTokens?: number; continueMessage?: string } + | { type: "compact"; maxOutputTokens?: number; continueMessage?: string; model?: string } | { type: "unknown-command"; command: string; subcommand?: string } | null; From de976184ab10e2a86d6121c48ee3e6ff101a2d67 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 22:01:49 -0500 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=A4=96=20docs:=20add=20-m=20flag=20to?= =?UTF-8?q?=20/compact=20command=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with `cmux` --- docs/context-management.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/context-management.md b/docs/context-management.md index dfa08bef41..e035b8ec98 100644 --- a/docs/context-management.md +++ b/docs/context-management.md @@ -47,13 +47,14 @@ Compress conversation history using AI summarization. Replaces the conversation ### Syntax ``` -/compact [-t ] +/compact [-t ] [-m ] [continue message on subsequent lines] ``` ### Options - `-t ` - Maximum output tokens for the summary (default: ~2000 words) +- `-m ` - Model to use for compaction (default: workspace model). Supports abbreviations like `sonnet`, `haiku`, `opus`, or full model strings ### Examples @@ -69,6 +70,14 @@ Compress conversation history using AI summarization. Replaces the conversation /compact -t 5000 ``` +**Choose compaction model:** + +``` +/compact -m haiku +``` + +Uses Haiku for faster, lower-cost compaction. + **Auto-continue with custom message:** ``` @@ -88,16 +97,18 @@ Make sure to add tests for the error cases. Continue messages can span multiple lines for more detailed instructions. -**Combine token limit and auto-continue:** +**Combine all options:** ``` -/compact -t 3000 +/compact -m opus -t 8000 Keep working on the feature ``` +Use Opus with custom token limit and auto-continue message. + ### Notes -- Uses the selected LLM to summarize conversation history +- Uses the specified model (or workspace model by default) to summarize conversation history - Preserves actionable context and specific details - **Irreversible** - original messages are replaced - Continue message is sent once after compaction completes (not persisted) From 3ef833bfaa71812fcd3d096023e010cc7c0fd950 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 22:04:44 -0500 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20enforce=20thinking=20?= =?UTF-8?q?policy=20in=20backend=20for=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move thinking policy enforcement from frontend to backend (agentSession.streamWithHistory) to ensure it's applied consistently regardless of where the request originates (compact command, manual override, etc.). This prevents bugs where custom model overrides might bypass policy requirements (e.g., gpt-5-pro requiring 'high' thinking). Single source of truth pattern - no need to remember to call enforceThinkingPolicy in multiple places. Generated with `cmux` --- src/components/ChatInput.tsx | 3 +++ src/services/agentSession.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 03b2efb5c1..5360732733 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -315,6 +315,9 @@ function prepareCompactionMessage( // Use custom model if specified, otherwise use default from sendMessageOptions const compactionModel = parsed.model ?? sendMessageOptions.model; + + // Note: thinking policy enforcement happens in the backend (agentSession.streamWithHistory) + // to ensure it's applied consistently regardless of where the request originates const isAnthropic = compactionModel.startsWith("anthropic:"); const options: Partial = { model: compactionModel, diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index 670eaf01b9..0abb6017d5 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -297,11 +297,18 @@ export class AgentSession { return Err(createUnknownSendMessageError(historyResult.error)); } + // Enforce thinking policy for the specified model (single source of truth) + // This ensures model-specific requirements are met regardless of where the request originates + const { enforceThinkingPolicy } = await import("@/utils/thinking/policy"); + const effectiveThinkingLevel = options?.thinkingLevel + ? enforceThinkingPolicy(modelString, options.thinkingLevel) + : undefined; + const streamResult = await this.aiService.streamMessage( historyResult.data, this.workspaceId, modelString, - options?.thinkingLevel, + effectiveThinkingLevel, options?.toolPolicy, undefined, options?.additionalSystemInstructions, From 23bc5042606e76ddbaa2fe137ef28d2a1446334c Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 22:05:03 -0500 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=A4=96=20docs:=20focus=20on=20haiku?= =?UTF-8?q?=20for=20compaction=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Haiku is the fastest good coding model, making it ideal for compaction. Generated with `cmux` --- docs/context-management.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/context-management.md b/docs/context-management.md index e035b8ec98..f8a72afde3 100644 --- a/docs/context-management.md +++ b/docs/context-management.md @@ -54,7 +54,7 @@ Compress conversation history using AI summarization. Replaces the conversation ### Options - `-t ` - Maximum output tokens for the summary (default: ~2000 words) -- `-m ` - Model to use for compaction (default: workspace model). Supports abbreviations like `sonnet`, `haiku`, `opus`, or full model strings +- `-m ` - Model to use for compaction (default: workspace model). Supports abbreviations like `haiku`, `sonnet`, or full model strings ### Examples @@ -76,7 +76,7 @@ Compress conversation history using AI summarization. Replaces the conversation /compact -m haiku ``` -Uses Haiku for faster, lower-cost compaction. +Use Haiku for faster, lower-cost compaction. **Auto-continue with custom message:** @@ -100,11 +100,11 @@ Continue messages can span multiple lines for more detailed instructions. **Combine all options:** ``` -/compact -m opus -t 8000 +/compact -m haiku -t 8000 Keep working on the feature ``` -Use Opus with custom token limit and auto-continue message. +Combine custom model, token limit, and auto-continue message. ### Notes From d48fb4b84ef83340fcc5113e47f0013274378e56 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 22:07:40 -0500 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20use=20static=20import?= =?UTF-8?q?=20for=20enforceThinkingPolicy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace dynamic import with static import to satisfy linter rules. Generated with `cmux` --- src/services/agentSession.ts | 2 +- src/utils/slashCommands/compact.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index 0abb6017d5..77fd0ea107 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -12,6 +12,7 @@ import type { SendMessageError } from "@/types/errors"; import { createUnknownSendMessageError } from "@/services/utils/sendMessageError"; import type { Result } from "@/types/result"; import { Ok, Err } from "@/types/result"; +import { enforceThinkingPolicy } from "@/utils/thinking/policy"; interface ImagePart { image: string; @@ -299,7 +300,6 @@ export class AgentSession { // Enforce thinking policy for the specified model (single source of truth) // This ensures model-specific requirements are met regardless of where the request originates - const { enforceThinkingPolicy } = await import("@/utils/thinking/policy"); const effectiveThinkingLevel = options?.thinkingLevel ? enforceThinkingPolicy(modelString, options.thinkingLevel) : undefined; diff --git a/src/utils/slashCommands/compact.test.ts b/src/utils/slashCommands/compact.test.ts index 97fa06140c..e83e236eff 100644 --- a/src/utils/slashCommands/compact.test.ts +++ b/src/utils/slashCommands/compact.test.ts @@ -333,7 +333,7 @@ describe("multiline continue messages", () => { }); it("parses all flags with multiline continue message", () => { - const result = parseCommand('/compact -t 5000 -m sonnet\nFinish the refactoring'); + const result = parseCommand("/compact -t 5000 -m sonnet\nFinish the refactoring"); expect(result).toEqual({ type: "compact", maxOutputTokens: 5000, From c4154ca5077a17c88e8b730eabff65935d082ab8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 22:20:52 -0500 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20use=20shared=20f?= =?UTF-8?q?unction=20for=20compaction=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract compaction transformation logic into shared utility to ensure consistency between initial send (ChatInput) and resume (useResumeManager). This eliminates the architectural fragility where custom model/token overrides could be lost during stream resumption. Now both code paths call the same applyCompactionOverrides() function. Key changes: - Add CompactionRequestData shared type (includes model field) - Create applyCompactionOverrides(baseOptions, compactData) utility - Update prepareCompactionMessage to use shared function - Update useResumeManager to detect compaction and apply overrides - Add comprehensive tests for transformation logic Benefits: - Single source of truth for option transformation - Impossible to have divergent behavior between paths - Custom model preserved across interruption/resume - Works retroactively (user message metadata already persisted) Generated with `cmux` --- src/components/ChatInput.tsx | 31 ++++---- src/hooks/useResumeManager.ts | 16 +++- src/types/message.ts | 12 ++- src/utils/messages/compactionOptions.test.ts | 81 ++++++++++++++++++++ src/utils/messages/compactionOptions.ts | 41 ++++++++++ 5 files changed, 158 insertions(+), 23 deletions(-) create mode 100644 src/utils/messages/compactionOptions.test.ts create mode 100644 src/utils/messages/compactionOptions.ts diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 5360732733..020f151c90 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -26,8 +26,9 @@ import { VimTextArea } from "./VimTextArea"; import { ImageAttachments, type ImageAttachment } from "./ImageAttachments"; import type { ThinkingLevel } from "@/types/thinking"; -import type { CmuxFrontendMetadata } from "@/types/message"; +import type { CmuxFrontendMetadata, CompactionRequestData } from "@/types/message"; import type { SendMessageOptions } from "@/types/ipc"; +import { applyCompactionOverrides } from "@/utils/messages/compactionOptions"; const InputSection = styled.div` position: relative; @@ -304,28 +305,22 @@ function prepareCompactionMessage( const messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`; + // Create compaction metadata (will be stored in user message) + const compactData: CompactionRequestData = { + model: parsed.model, + maxOutputTokens: parsed.maxOutputTokens, + continueMessage: parsed.continueMessage, + }; + const metadata: CmuxFrontendMetadata = { type: "compaction-request", rawCommand: command, - parsed: { - maxOutputTokens: parsed.maxOutputTokens, - continueMessage: parsed.continueMessage, - }, + parsed: compactData, }; - // Use custom model if specified, otherwise use default from sendMessageOptions - const compactionModel = parsed.model ?? sendMessageOptions.model; - - // Note: thinking policy enforcement happens in the backend (agentSession.streamWithHistory) - // to ensure it's applied consistently regardless of where the request originates - const isAnthropic = compactionModel.startsWith("anthropic:"); - const options: Partial = { - model: compactionModel, - thinkingLevel: isAnthropic ? "off" : sendMessageOptions.thinkingLevel, - toolPolicy: [{ regex_match: "compact_summary", action: "require" }], - maxOutputTokens: parsed.maxOutputTokens, - mode: "compact" as const, - }; + // Apply compaction overrides using shared transformation function + // This same function is used by useResumeManager to ensure consistency + const options = applyCompactionOverrides(sendMessageOptions, compactData); return { messageText, metadata, options }; } diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index 16d932acfe..dbf4f38d00 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -5,6 +5,7 @@ import { getAutoRetryKey, getRetryStateKey } from "@/constants/storage"; import { getSendOptionsFromStorage } from "@/utils/messages/sendOptions"; import { readPersistedState } from "./usePersistedState"; import { hasInterruptedStream } from "@/utils/messages/retryEligibility"; +import { applyCompactionOverrides } from "@/utils/messages/compactionOptions"; interface RetryState { attempt: number; @@ -139,7 +140,20 @@ export function useResumeManager() { const { attempt } = retryState; try { - const options = getSendOptionsFromStorage(workspaceId); + // Start with workspace defaults + let options = getSendOptionsFromStorage(workspaceId); + + // Check if last user message was a compaction request + const state = workspaceStatesRef.current.get(workspaceId); + if (state) { + const lastUserMsg = [...state.messages].reverse().find((msg) => msg.type === "user"); + if (lastUserMsg?.compactionRequest) { + // Apply compaction overrides using shared function (same as ChatInput) + // This ensures custom model/tokens are preserved across resume + options = applyCompactionOverrides(options, lastUserMsg.compactionRequest.parsed); + } + } + const result = await window.api.workspace.resumeStream(workspaceId, options); if (!result.success) { diff --git a/src/types/message.ts b/src/types/message.ts index 24cff7a1f8..04de8ee517 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -3,16 +3,20 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import type { StreamErrorType } from "./errors"; import type { ToolPolicy } from "@/utils/tools/toolPolicy"; +// Parsed compaction request data (shared type for consistency) +export interface CompactionRequestData { + model?: string; // Custom model override for compaction + maxOutputTokens?: number; + continueMessage?: string; +} + // Frontend-specific metadata stored in cmuxMetadata field // Backend stores this as-is without interpretation (black-box) export type CmuxFrontendMetadata = | { type: "compaction-request"; rawCommand: string; // The original /compact command as typed by user (for display) - parsed: { - maxOutputTokens?: number; - continueMessage?: string; - }; + parsed: CompactionRequestData; } | { type: "compaction-result"; diff --git a/src/utils/messages/compactionOptions.test.ts b/src/utils/messages/compactionOptions.test.ts new file mode 100644 index 0000000000..3b78b9f410 --- /dev/null +++ b/src/utils/messages/compactionOptions.test.ts @@ -0,0 +1,81 @@ +/** + * Tests for compaction options transformation + */ + +import { applyCompactionOverrides } from "./compactionOptions"; +import type { SendMessageOptions } from "@/types/ipc"; +import type { CompactionRequestData } from "@/types/message"; + +describe("applyCompactionOverrides", () => { + const baseOptions: SendMessageOptions = { + model: "anthropic:claude-sonnet-4-5", + thinkingLevel: "medium", + toolPolicy: [], + mode: "exec", + }; + + it("uses workspace model when no override specified", () => { + const compactData: CompactionRequestData = {}; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.model).toBe("anthropic:claude-sonnet-4-5"); + expect(result.mode).toBe("compact"); + }); + + it("applies custom model override", () => { + const compactData: CompactionRequestData = { + model: "anthropic:claude-haiku-4-5", + }; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.model).toBe("anthropic:claude-haiku-4-5"); + }); + + it("sets thinking to off for Anthropic models", () => { + const compactData: CompactionRequestData = { + model: "anthropic:claude-haiku-4-5", + }; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.thinkingLevel).toBe("off"); + }); + + it("preserves workspace thinking level for non-Anthropic models", () => { + const compactData: CompactionRequestData = { + model: "openai:gpt-5-pro", + }; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.thinkingLevel).toBe("medium"); + }); + + it("applies maxOutputTokens override", () => { + const compactData: CompactionRequestData = { + maxOutputTokens: 8000, + }; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.maxOutputTokens).toBe(8000); + }); + + it("sets compact mode and tool policy", () => { + const compactData: CompactionRequestData = {}; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.mode).toBe("compact"); + expect(result.toolPolicy).toEqual([{ regex_match: "compact_summary", action: "require" }]); + }); + + it("applies all overrides together", () => { + const compactData: CompactionRequestData = { + model: "openai:gpt-5", + maxOutputTokens: 5000, + }; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.model).toBe("openai:gpt-5"); + expect(result.maxOutputTokens).toBe(5000); + expect(result.mode).toBe("compact"); + expect(result.thinkingLevel).toBe("medium"); // Non-Anthropic preserves original + }); +}); diff --git a/src/utils/messages/compactionOptions.ts b/src/utils/messages/compactionOptions.ts new file mode 100644 index 0000000000..97809b20ad --- /dev/null +++ b/src/utils/messages/compactionOptions.ts @@ -0,0 +1,41 @@ +/** + * Compaction options transformation + * + * Single source of truth for converting compaction metadata into SendMessageOptions. + * Used by both ChatInput (initial send) and useResumeManager (resume after interruption). + */ + +import type { SendMessageOptions } from "@/types/ipc"; +import type { CompactionRequestData } from "@/types/message"; + +/** + * Apply compaction-specific option overrides to base options. + * + * This function is the single source of truth for how compaction metadata + * transforms workspace defaults. Both initial sends and stream resumption + * use this function to ensure consistent behavior. + * + * @param baseOptions - Workspace default options (from localStorage or useSendMessageOptions) + * @param compactData - Compaction request metadata from /compact command + * @returns Final SendMessageOptions with compaction overrides applied + */ +export function applyCompactionOverrides( + baseOptions: SendMessageOptions, + compactData: CompactionRequestData +): SendMessageOptions { + // Use custom model if specified, otherwise use workspace default + const compactionModel = compactData.model ?? baseOptions.model; + + // Anthropic models don't support thinking, always use "off" + // Non-Anthropic models keep workspace default (backend will enforce policy) + const isAnthropic = compactionModel.startsWith("anthropic:"); + + return { + ...baseOptions, + model: compactionModel, + thinkingLevel: isAnthropic ? "off" : baseOptions.thinkingLevel, + toolPolicy: [{ regex_match: "compact_summary", action: "require" }], + maxOutputTokens: compactData.maxOutputTokens, + mode: "compact" as const, + }; +} From 75c525b23d9c48c9b473ff11dc50ef3bad7357ff Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 22:35:11 -0500 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20handle=20broken=20shf?= =?UTF-8?q?mt=20cache=20in=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check if cached shfmt binary works before using it. If the cache contains a broken binary, reinstall it. Fixes CI failure where cache hit but binary was unusable. Generated with `cmux` --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7da9968411..1366eb6f84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,8 @@ jobs: - name: Install and setup shfmt run: | - if [[ ! -f "$HOME/.local/bin/shfmt" ]]; then + # Install shfmt if not cached or if cached binary is broken + if [[ ! -f "$HOME/.local/bin/shfmt" ]] || ! "$HOME/.local/bin/shfmt" --version >/dev/null 2>&1; then curl -sS https://webinstall.dev/shfmt | bash fi echo "$HOME/.local/bin" >> $GITHUB_PATH