Skip to content

Commit 91d77a6

Browse files
committed
🤖 fix: emit chat-error for pre-stream API failures
When a chat message fails before streaming starts (e.g., invalid model, missing API key), emit a 'chat-error' event so the error is visible in the chat UI, not just as an easily-missed toast. - Add ChatErrorMessage schema, type, and type guard - Add emitChatError method in agentSession for pre-stream failures - Handle chat-error in WorkspaceStore and StreamingMessageAggregator - Add ChatErrorMessage UI component (displays 'Error' vs 'Stream Error') - chat-error is never auto-retryable (requires user action to fix) - Update retry eligibility to show retry UI for chat-error Distinguishes from stream-error which occurs during AI SDK streaming and may be transient/auto-retryable. _Generated with `mux`_
1 parent b2e8690 commit 91d77a6

File tree

13 files changed

+200
-10
lines changed

13 files changed

+200
-10
lines changed

src/browser/components/AIView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
133133
}
134134

135135
for (const message of workspaceState.messages) {
136-
if (message.type !== "stream-error") {
136+
if (message.type !== "stream-error" && message.type !== "chat-error") {
137137
continue;
138138
}
139139
if (message.errorType !== "model_not_found") {
@@ -143,7 +143,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
143143
continue;
144144
}
145145
handledModelErrorsRef.current.add(message.id);
146-
if (message.model) {
146+
if (message.type === "stream-error" && message.model) {
147147
evictModelFromLRU(message.model);
148148
}
149149
}

src/browser/components/Messages/MessageRenderer.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { UserMessage } from "./UserMessage";
55
import { AssistantMessage } from "./AssistantMessage";
66
import { ToolMessage } from "./ToolMessage";
77
import { ReasoningMessage } from "./ReasoningMessage";
8-
import { StreamErrorMessage } from "./StreamErrorMessage";
8+
import { StreamErrorMessage, ChatErrorMessage } from "./StreamErrorMessage";
99
import { HistoryHiddenMessage } from "./HistoryHiddenMessage";
1010
import { InitMessage } from "./InitMessage";
1111

@@ -56,6 +56,8 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
5656
return <ReasoningMessage message={message} className={className} />;
5757
case "stream-error":
5858
return <StreamErrorMessage message={message} className={className} />;
59+
case "chat-error":
60+
return <ChatErrorMessage message={message} className={className} />;
5961
case "history-hidden":
6062
return <HistoryHiddenMessage message={message} className={className} />;
6163
case "workspace-init":

src/browser/components/Messages/StreamErrorMessage.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,29 @@ export const StreamErrorMessage: React.FC<StreamErrorMessageProps> = ({ message,
3131
</div>
3232
);
3333
};
34+
35+
/**
36+
* ChatErrorMessage - displays pre-stream errors (before AI SDK streaming starts).
37+
* These are errors like invalid model, missing API key, unsupported provider, etc.
38+
*/
39+
interface ChatErrorMessageProps {
40+
message: DisplayedMessage & { type: "chat-error" };
41+
className?: string;
42+
}
43+
44+
export const ChatErrorMessage: React.FC<ChatErrorMessageProps> = ({ message, className }) => {
45+
return (
46+
<div className={cn("bg-error-bg border border-error rounded px-5 py-4 my-3", className)}>
47+
<div className="font-primary text-error mb-3 flex items-center gap-2.5 text-[13px] font-semibold tracking-wide">
48+
<span className="text-base leading-none"></span>
49+
<span>Error</span>
50+
<span className="text-secondary rounded-sm bg-black/40 px-2 py-0.5 font-mono text-[10px] tracking-wider uppercase">
51+
{message.errorType}
52+
</span>
53+
</div>
54+
<div className="text-foreground font-mono text-[13px] leading-relaxed break-words">
55+
{message.error}
56+
</div>
57+
</div>
58+
);
59+
};

src/browser/stores/WorkspaceStore.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useSyncExternalStore } from "react";
1313
import {
1414
isCaughtUpMessage,
1515
isStreamError,
16+
isChatError,
1617
isDeleteMessage,
1718
isMuxMessage,
1819
isQueuedMessageChanged,
@@ -998,6 +999,16 @@ export class WorkspaceStore {
998999
return;
9991000
}
10001001

1002+
// chat-error: pre-stream failures (invalid model, missing API key, etc.)
1003+
// These are NOT auto-retryable - they require user action
1004+
// Don't increment retry counter, but still show in UI and allow manual retry
1005+
if (isChatError(data)) {
1006+
aggregator.handleChatError(data);
1007+
this.states.bump(workspaceId);
1008+
this.dispatchResumeCheck(workspaceId);
1009+
return;
1010+
}
1011+
10011012
if (isDeleteMessage(data)) {
10021013
aggregator.handleDeleteMessage(data);
10031014
this.states.bump(workspaceId);

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ import type {
2121
import type { LanguageModelV2Usage } from "@ai-sdk/provider";
2222
import type { TodoItem, StatusSetToolResult } from "@/common/types/tools";
2323

24-
import type { WorkspaceChatMessage, StreamErrorMessage, DeleteMessage } from "@/common/orpc/types";
24+
import type {
25+
WorkspaceChatMessage,
26+
StreamErrorMessage,
27+
ChatErrorMessage,
28+
DeleteMessage,
29+
} from "@/common/orpc/types";
2530
import { isInitStart, isInitOutput, isInitEnd, isMuxMessage } from "@/common/orpc/types";
2631
import type {
2732
DynamicToolPart,
@@ -589,6 +594,40 @@ export class StreamingMessageAggregator {
589594
}
590595
}
591596

597+
/**
598+
* Handle pre-stream chat errors (before stream-start).
599+
* These are distinct from stream-error (AI SDK errors during streaming).
600+
*
601+
* chat-error occurs when:
602+
* - Model validation fails (invalid format, non-existent model)
603+
* - API key is missing
604+
* - Provider is not supported
605+
* - etc.
606+
*
607+
* Creates a synthetic error message since there's no active stream.
608+
*/
609+
handleChatError(data: ChatErrorMessage): void {
610+
// Get the highest historySequence from existing messages so this appears at the end
611+
const maxSequence = Math.max(
612+
0,
613+
...Array.from(this.messages.values()).map((m) => m.metadata?.historySequence ?? 0)
614+
);
615+
const errorMessage: MuxMessage = {
616+
id: data.messageId,
617+
role: "assistant",
618+
parts: [],
619+
metadata: {
620+
partial: true,
621+
error: data.error,
622+
errorType: data.errorType,
623+
timestamp: Date.now(),
624+
historySequence: maxSequence + 1,
625+
},
626+
};
627+
this.messages.set(data.messageId, errorMessage);
628+
this.invalidateCache();
629+
}
630+
592631
handleToolCallStart(data: ToolCallStartEvent): void {
593632
const message = this.messages.get(data.messageId);
594633
if (!message) return;

src/browser/utils/messages/messageUtils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function shouldShowInterruptedBarrier(msg: DisplayedMessage): boolean {
1111
if (
1212
msg.type === "user" ||
1313
msg.type === "stream-error" ||
14+
msg.type === "chat-error" ||
1415
msg.type === "history-hidden" ||
1516
msg.type === "workspace-init"
1617
)

src/browser/utils/messages/retryEligibility.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export function hasInterruptedStream(
9292

9393
return (
9494
lastMessage.type === "stream-error" || // Stream errored out (show UI for ALL error types)
95+
lastMessage.type === "chat-error" || // Pre-stream error (show UI so user can retry after fixing)
9596
lastMessage.type === "user" || // No response received yet (app restart during slow model)
9697
(lastMessage.type === "assistant" && lastMessage.isPartial === true) ||
9798
(lastMessage.type === "tool" && lastMessage.isPartial === true) ||
@@ -122,6 +123,13 @@ export function isEligibleForAutoRetry(
122123
// If the last message is a non-retryable error, don't auto-retry
123124
// (but manual retry is still available via hasInterruptedStream)
124125
const lastMessage = messages[messages.length - 1];
126+
127+
// chat-error is NEVER auto-retryable - always requires user action
128+
// (fixing model selection, adding API key, etc.)
129+
if (lastMessage.type === "chat-error") {
130+
return false;
131+
}
132+
125133
if (lastMessage.type === "stream-error") {
126134
// Debug flag: force all errors to be retryable
127135
if (isForceAllRetryableEnabled()) {

src/common/orpc/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export {
8282
ReasoningEndEventSchema,
8383
RestoreToInputEventSchema,
8484
SendMessageOptionsSchema,
85+
ChatErrorMessageSchema,
8586
StreamAbortEventSchema,
8687
StreamDeltaEventSchema,
8788
StreamEndEventSchema,

src/common/orpc/schemas/stream.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ export const StreamErrorMessageSchema = z.object({
2222
errorType: StreamErrorTypeSchema,
2323
});
2424

25+
/**
26+
* Chat error message - for errors that occur BEFORE streaming starts.
27+
* Distinct from StreamErrorMessage (AI SDK stream errors that happen during streaming).
28+
*
29+
* These errors are NOT auto-retryable - they require user action:
30+
* - Invalid model format
31+
* - Missing API key
32+
* - Unsupported provider
33+
* - etc.
34+
*/
35+
export const ChatErrorMessageSchema = z.object({
36+
type: z.literal("chat-error"),
37+
messageId: z.string(),
38+
error: z.string(),
39+
errorType: StreamErrorTypeSchema,
40+
});
41+
2542
export const DeleteMessageSchema = z.object({
2643
type: z.literal("delete"),
2744
historySequences: z.array(z.number()),
@@ -260,6 +277,7 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [
260277
// Stream lifecycle events
261278
CaughtUpMessageSchema,
262279
StreamErrorMessageSchema,
280+
ChatErrorMessageSchema,
263281
DeleteMessageSchema,
264282
StreamStartEventSchema,
265283
StreamDeltaEventSchema,

src/common/orpc/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type ImagePart = z.infer<typeof schemas.ImagePartSchema>;
2525
export type WorkspaceChatMessage = z.infer<typeof schemas.WorkspaceChatMessageSchema>;
2626
export type CaughtUpMessage = z.infer<typeof schemas.CaughtUpMessageSchema>;
2727
export type StreamErrorMessage = z.infer<typeof schemas.StreamErrorMessageSchema>;
28+
export type ChatErrorMessage = z.infer<typeof schemas.ChatErrorMessageSchema>;
2829
export type DeleteMessage = z.infer<typeof schemas.DeleteMessageSchema>;
2930
export type WorkspaceInitEvent = z.infer<typeof schemas.WorkspaceInitEventSchema>;
3031
export type UpdateStatus = z.infer<typeof schemas.UpdateStatusSchema>;
@@ -43,6 +44,10 @@ export function isStreamError(msg: WorkspaceChatMessage): msg is StreamErrorMess
4344
return (msg as { type?: string }).type === "stream-error";
4445
}
4546

47+
export function isChatError(msg: WorkspaceChatMessage): msg is ChatErrorMessage {
48+
return (msg as { type?: string }).type === "chat-error";
49+
}
50+
4651
export function isDeleteMessage(msg: WorkspaceChatMessage): msg is DeleteMessage {
4752
return (msg as { type?: string }).type === "delete";
4853
}

0 commit comments

Comments
 (0)