Skip to content

Commit 59286b9

Browse files
authored
🤖 fix: validate /compact model format before persisting message (#1109)
Fixes janky `/compact` slash-command behavior where commands with invalid model formats would show up in chat history even though the request failed. ## Changes - **Backend validation (agentSession.ts)**: Validate model format before persisting user message to history. Invalid model strings (missing provider prefix) are now rejected before the message is saved, preventing orphaned compaction request messages. - **Frontend validation (chatCommands.ts)**: Fail fast in `/compact` handler when model format is invalid, showing a clear toast error with example format. - **Helper function**: Add `isValidModelFormat()` to check for `provider:model-id` format (supports colons in model ID for ollama-style names). - **Preference poisoning fix**: Stop eagerly saving compaction model to localStorage, preventing invalid model values from affecting future `/compact` commands. ## Testing - Added unit tests for `isValidModelFormat()` - Updated integration test for invalid model format error handling _Generated with `mux`_
1 parent 7a8e667 commit 59286b9

File tree

7 files changed

+103
-15
lines changed

7 files changed

+103
-15
lines changed

src/browser/components/ChatInputToasts.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,32 @@
11
import React from "react";
22
import type { Toast } from "./ChatInputToast";
33
import { SolutionLabel } from "./ChatInputToast";
4+
import { DocsLink } from "./DocsLink";
45
import type { ParsedCommand } from "@/browser/utils/slashCommands/types";
56
import type { SendMessageError as SendMessageErrorType } from "@/common/types/errors";
67
import { formatSendMessageError } from "@/common/utils/errors/formatSendError";
78

9+
export function createInvalidCompactModelToast(model: string): Toast {
10+
return {
11+
id: Date.now().toString(),
12+
type: "error",
13+
title: "Invalid Model",
14+
message: `Invalid model format: "${model}". Use an alias or provider:model-id.`,
15+
solution: (
16+
<>
17+
<SolutionLabel>Try an alias:</SolutionLabel>
18+
/compact -m sonnet
19+
<br />
20+
/compact -m gpt
21+
<br />
22+
<br />
23+
<SolutionLabel>Supported models:</SolutionLabel>
24+
<DocsLink path="/models">mux.coder.com/models</DocsLink>
25+
</>
26+
),
27+
};
28+
}
29+
830
/**
931
* Creates a toast message for command-related errors and help messages
1032
*/

src/browser/utils/chatCommands.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ import { WORKSPACE_ONLY_COMMANDS } from "@/constants/slashCommands";
2222
import type { Toast } from "@/browser/components/ChatInputToast";
2323
import type { ParsedCommand } from "@/browser/utils/slashCommands/types";
2424
import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions";
25-
import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference";
25+
import {
26+
resolveCompactionModel,
27+
isValidModelFormat,
28+
} from "@/browser/utils/messages/compactionModelPreference";
2629
import type { ImageAttachment } from "../components/ImageAttachments";
2730
import { dispatchWorkspaceSwitch } from "./workspaceEvents";
2831
import { getRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage";
@@ -37,7 +40,10 @@ import { openInEditor } from "@/browser/utils/openInEditor";
3740
// Workspace Creation
3841
// ============================================================================
3942

40-
import { createCommandToast } from "@/browser/components/ChatInputToasts";
43+
import {
44+
createCommandToast,
45+
createInvalidCompactModelToast,
46+
} from "@/browser/components/ChatInputToasts";
4147
import { trackCommandUsed, trackProviderConfigured } from "@/common/telemetry";
4248
import { addEphemeralMessage } from "@/browser/stores/WorkspaceStore";
4349

@@ -851,6 +857,12 @@ export async function handleCompactCommand(
851857
onCancelEdit,
852858
} = context;
853859

860+
// Validate model format early - fail fast before sending to backend
861+
if (parsed.model && !isValidModelFormat(parsed.model)) {
862+
setToast(createInvalidCompactModelToast(parsed.model));
863+
return { clearInput: false, toastShown: true };
864+
}
865+
854866
setInput("");
855867
setImageAttachments([]);
856868
setIsSending(true);

src/browser/utils/messages/compactionModelPreference.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@
66

77
import { PREFERRED_COMPACTION_MODEL_KEY } from "@/common/constants/storage";
88

9+
// Re-export for convenience - validation used in /compact handler
10+
export { isValidModelFormat } from "@/common/utils/ai/models";
11+
912
/**
10-
* Resolve the effective compaction model, saving preference if a model is specified.
13+
* Resolve the effective compaction model to use for compaction.
1114
*
1215
* @param requestedModel - Model specified in /compact -m flag (if any)
1316
* @returns The model to use for compaction, or undefined to use workspace default
1417
*/
1518
export function resolveCompactionModel(requestedModel: string | undefined): string | undefined {
1619
if (requestedModel) {
17-
// User specified a model with -m flag, save it as the new preference
18-
localStorage.setItem(PREFERRED_COMPACTION_MODEL_KEY, requestedModel);
1920
return requestedModel;
2021
}
2122

src/common/utils/ai/models.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, it, expect } from "bun:test";
2-
import { normalizeGatewayModel, getModelName, supports1MContext } from "./models";
2+
import {
3+
normalizeGatewayModel,
4+
getModelName,
5+
supports1MContext,
6+
isValidModelFormat,
7+
} from "./models";
38

49
describe("normalizeGatewayModel", () => {
510
it("should convert mux-gateway:provider/model to provider:model", () => {
@@ -63,3 +68,28 @@ describe("supports1MContext", () => {
6368
expect(supports1MContext("mux-gateway:anthropic/claude-opus-4-5")).toBe(false);
6469
});
6570
});
71+
72+
describe("isValidModelFormat", () => {
73+
it("returns true for valid model formats", () => {
74+
expect(isValidModelFormat("anthropic:claude-sonnet-4-5")).toBe(true);
75+
expect(isValidModelFormat("openai:gpt-5.2")).toBe(true);
76+
expect(isValidModelFormat("google:gemini-3-pro-preview")).toBe(true);
77+
expect(isValidModelFormat("mux-gateway:anthropic/claude-opus-4-5")).toBe(true);
78+
// Ollama-style model names with colons in the model ID
79+
expect(isValidModelFormat("ollama:gpt-oss:20b")).toBe(true);
80+
});
81+
82+
it("returns false for invalid model formats", () => {
83+
// Missing colon
84+
expect(isValidModelFormat("gpt")).toBe(false);
85+
expect(isValidModelFormat("sonnet")).toBe(false);
86+
expect(isValidModelFormat("badmodel")).toBe(false);
87+
88+
// Colon at start or end
89+
expect(isValidModelFormat(":model")).toBe(false);
90+
expect(isValidModelFormat("provider:")).toBe(false);
91+
92+
// Empty string
93+
expect(isValidModelFormat("")).toBe(false);
94+
});
95+
});

src/common/utils/ai/models.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ import { DEFAULT_MODEL } from "@/common/constants/knownModels";
66

77
export const defaultModel = DEFAULT_MODEL;
88

9+
/**
10+
* Validate model string format (must be "provider:model-id").
11+
* Supports colons in the model ID (e.g., "ollama:gpt-oss:20b").
12+
*/
13+
export function isValidModelFormat(model: string): boolean {
14+
const colonIndex = model.indexOf(":");
15+
return colonIndex > 0 && colonIndex < model.length - 1;
16+
}
17+
918
const MUX_GATEWAY_PREFIX = "mux-gateway:";
1019

1120
/**

src/node/services/agentSession.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { AttachmentService } from "./attachmentService";
3535
import type { PostCompactionAttachment, PostCompactionExclusions } from "@/common/types/attachment";
3636
import { TURNS_BETWEEN_ATTACHMENTS } from "@/common/constants/attachments";
3737
import { extractEditedFileDiffs } from "@/common/utils/messages/extractEditedFiles";
38+
import { isValidModelFormat } from "@/common/utils/ai/models";
3839

3940
/**
4041
* Tracked file state for detecting external edits.
@@ -410,6 +411,21 @@ export class AgentSession {
410411
// muxMetadata is z.any() in schema - cast to proper type
411412
const typedMuxMetadata = options?.muxMetadata as MuxFrontendMetadata | undefined;
412413

414+
// Validate model BEFORE persisting message to prevent orphaned messages on invalid model
415+
if (!options?.model || options.model.trim().length === 0) {
416+
return Err(
417+
createUnknownSendMessageError("No model specified. Please select a model using /model.")
418+
);
419+
}
420+
421+
// Validate model string format (must be "provider:model-id")
422+
if (!isValidModelFormat(options.model)) {
423+
return Err({
424+
type: "invalid_model_string",
425+
message: `Invalid model string format: "${options.model}". Expected "provider:model-id"`,
426+
});
427+
}
428+
413429
const userMessage = createMuxMessage(
414430
messageId,
415431
"user",
@@ -475,12 +491,6 @@ export class AgentSession {
475491
this.emitQueuedMessageChanged();
476492
}
477493

478-
if (!options?.model || options.model.trim().length === 0) {
479-
return Err(
480-
createUnknownSendMessageError("No model specified. Please select a model using /model.")
481-
);
482-
}
483-
484494
return this.streamWithHistory(options.model, options);
485495
}
486496

tests/ipc/sendMessage.errors.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,17 +79,21 @@ describeIntegration("sendMessage error handling tests", () => {
7979

8080
describe("model errors", () => {
8181
test.concurrent(
82-
"should fail with invalid model format",
82+
"should fail with invalid model format (model validation happens before message is persisted)",
8383
async () => {
8484
await withSharedWorkspace("openai", async ({ env, workspaceId }) => {
8585
const result = await sendMessage(env, workspaceId, "Hello", {
8686
model: "invalid-model-without-provider",
8787
});
8888

89+
// Should fail synchronously with invalid_model_string error
90+
// This happens BEFORE the message is persisted to history
8991
expect(result.success).toBe(false);
9092
if (!result.success) {
91-
// Should indicate invalid model
92-
expect(result.error.type).toBeDefined();
93+
expect(result.error.type).toBe("invalid_model_string");
94+
if (result.error.type === "invalid_model_string") {
95+
expect(result.error.message).toContain("provider:model-id");
96+
}
9397
}
9498
});
9599
},

0 commit comments

Comments
 (0)