fix(ui): tolerate invalid message parts

This commit is contained in:
Bohdan Triapitsyn
2026-05-01 15:08:44 +03:00
parent a9ee499ae4
commit 4ad5d2f4b7
3 changed files with 36 additions and 13 deletions
@@ -22,7 +22,7 @@ import MessageBody from './message/MessageBody';
import type { AgentMentionInfo } from './message/types';
import type { StreamPhase, ToolPopupContent } from './message/types';
import { deriveMessageRole } from './message/messageRole';
import { filterVisibleParts } from './message/partUtils';
import { filterVisibleParts, normalizeParts } from './message/partUtils';
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
@@ -216,11 +216,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
);
const normalizedParts = React.useMemo(() => {
const safeParts = normalizeParts(message.parts);
if (!isUser) {
return message.parts;
return safeParts;
}
return normalizeUserDisplayParts(message.parts, { planModeEnabled });
return normalizeUserDisplayParts(safeParts, { planModeEnabled });
}, [isUser, message.parts, planModeEnabled]);
const previousUserMetadata = React.useMemo(() => {
@@ -509,7 +510,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
if (!isUser) {
return undefined;
}
const mentionPart = message.parts.find((part) => part.type === 'agent');
const mentionPart = normalizedParts.find((part) => part.type === 'agent');
if (!mentionPart) {
return undefined;
}
@@ -522,7 +523,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
? partWithName.source.value
: `@${name}`;
return { name, token: rawValue } satisfies AgentMentionInfo;
}, [isUser, message.parts]);
}, [isUser, normalizedParts]);
const shouldHideUserMessage = isUser && displayParts.length === 0;
@@ -15,6 +15,7 @@ import { FadeInDisabledProvider } from './message/FadeInOnReveal';
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types';
import { normalizeParts } from './message/partUtils';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = Number.POSITIVE_INFINITY;
const MESSAGE_LIST_OVERSCAN = 6;
@@ -51,7 +52,7 @@ const resolveMessageRole = (message: ChatMessageEntry): string | null => {
const hasCompactionPart = (message: ChatMessageEntry): boolean => {
return message.parts.some((part) => {
const type = (part as { type?: unknown }).type;
const type = (part as { type?: unknown } | null | undefined)?.type;
return type === 'compaction';
});
};
@@ -75,7 +76,7 @@ const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessa
let changedParts = false;
const nextParts = message.parts.map((part) => {
const type = (part as { type?: unknown }).type;
const type = (part as { type?: unknown } | null | undefined)?.type;
if (type !== 'compaction') {
return part;
}
@@ -202,7 +203,7 @@ const getShellBridgeAssistantDetails = (message: ChatMessageEntry, expectedParen
};
};
if (part.type !== 'tool') {
if (part?.type !== 'tool') {
return { hide: false, details: null };
}
@@ -270,9 +271,9 @@ const isSyntheticSubtaskBridgeAssistant = (message: ChatMessageEntry): { hide: b
const onlyPart = message.parts[0] as unknown as {
type?: unknown;
tool?: unknown;
};
} | null | undefined;
if (onlyPart.type !== 'tool') {
if (onlyPart?.type !== 'tool') {
return { hide: false, taskSessionId: null };
}
@@ -352,6 +353,17 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD
};
};
const normalizeMessageParts = (message: ChatMessageEntry): ChatMessageEntry => {
const parts = normalizeParts(message.parts);
if (parts.length === message.parts.length) {
return message;
}
return {
...message,
parts,
};
};
const normalizedMessageBySource = new WeakMap<ChatMessageEntry, ChatMessageEntry>();
const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
@@ -360,7 +372,8 @@ const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageE
return cached;
}
const normalizedCompactionMessage = normalizeCompactionCommandMessage(message);
const normalizedPartMessage = normalizeMessageParts(message);
const normalizedCompactionMessage = normalizeCompactionCommandMessage(normalizedPartMessage);
const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts);
const normalized = filteredParts === normalizedCompactionMessage.parts
? normalizedCompactionMessage
@@ -2,6 +2,14 @@ import type { Part } from '@opencode-ai/sdk/v2';
type PartWithText = Part & { text?: string; content?: string; value?: string };
export const isValidPart = (part: unknown): part is Part => {
return Boolean(part && typeof part === 'object' && typeof (part as { type?: unknown }).type === 'string');
};
export const normalizeParts = (parts: Part[]): Part[] => {
return parts.filter(isValidPart);
};
export const extractTextContent = (part: Part): string => {
const partWithText = part as PartWithText;
const rawText = partWithText.text;
@@ -27,14 +35,15 @@ interface VisibleFilterOptions {
export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions = {}): Part[] => {
const { includeReasoning = true } = options;
const validParts = normalizeParts(parts);
// Check if there are any non-synthetic parts
const hasNonSynthetic = parts.some((part) => {
const hasNonSynthetic = validParts.some((part) => {
const partWithSynthetic = part as PartWithSynthetic;
return !partWithSynthetic.synthetic;
});
return parts.filter((part) => {
return validParts.filter((part) => {
const partWithSynthetic = part as PartWithSynthetic;
const isSynthetic = Boolean(partWithSynthetic.synthetic);