From 92c20eef73541dd8a73cc5708dd149d8fc3fef82 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Wed, 4 Mar 2026 10:20:36 -0800 Subject: [PATCH] fix: filter empty-parts messages to prevent follow-up conversation crash (#402) * fix: filter out messages with empty parts to prevent follow-up crash When an assistant response is interrupted or errors before producing content, a UIMessage with empty parts remains in the chat state. On the next send, the AI SDK validates all messages and rejects the empty-parts message with "Message must contain at least one part". This filters them out when not streaming and adds a safety guard in formatConversationHistory. Co-Authored-By: Claude Opus 4.6 * fix: filter empty-parts messages before persisting to storage Addresses race condition where the save effect could persist messages with empty parts before the cleanup effect's state update applies. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../sidepanel/index/useChatSession.ts | 18 +++++++++++++----- .../conversations/formatConversationHistory.ts | 1 + 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/agent/entrypoints/sidepanel/index/useChatSession.ts b/apps/agent/entrypoints/sidepanel/index/useChatSession.ts index 4c4f1fb0..a31ede48 100644 --- a/apps/agent/entrypoints/sidepanel/index/useChatSession.ts +++ b/apps/agent/entrypoints/sidepanel/index/useChatSession.ts @@ -304,6 +304,15 @@ export const useChatSession = () => { }), }) + // Remove messages with empty parts (e.g. interrupted assistant responses) + // to prevent AI SDK validation errors on subsequent sends + useEffect(() => { + if (status === 'streaming') return + if (messages.some((m) => !m.parts?.length)) { + setMessages(messages.filter((m) => m.parts?.length > 0)) + } + }, [messages, status, setMessages]) + useNotifyActiveTab({ messages, status, @@ -370,15 +379,14 @@ export const useChatSession = () => { // biome-ignore lint/correctness/useExhaustiveDependencies: only need to run when messages change useEffect(() => { messagesRef.current = messages - if (messages.length > 0) { - // Local storage: save on every change (including during streaming) - // Remote: only save when not streaming to avoid partial message saves + const messagesToSave = messages.filter((m) => m.parts?.length > 0) + if (messagesToSave.length > 0) { if (isLoggedIn) { if (status !== 'streaming') { - saveRemoteConversation(conversationIdRef.current, messages) + saveRemoteConversation(conversationIdRef.current, messagesToSave) } } else { - saveLocalConversation(conversationIdRef.current, messages) + saveLocalConversation(conversationIdRef.current, messagesToSave) } } }, [messages, isLoggedIn, status]) diff --git a/apps/agent/lib/conversations/formatConversationHistory.ts b/apps/agent/lib/conversations/formatConversationHistory.ts index 50ffeb58..8988a2af 100644 --- a/apps/agent/lib/conversations/formatConversationHistory.ts +++ b/apps/agent/lib/conversations/formatConversationHistory.ts @@ -17,6 +17,7 @@ export function formatConversationHistory( return recentMessages .map((msg) => { + if (!msg.parts?.length) return null const role: 'user' | 'assistant' = msg.role === 'user' ? 'user' : 'assistant' const textContent = msg.parts