From a3d371b86c4e34236fdbada6bf0cc99adac2ecc4 Mon Sep 17 00:00:00 2001 From: spencrmartin Date: Fri, 21 Nov 2025 14:42:28 -0500 Subject: [PATCH] Fix Matrix message streaming - only send complete messages once - Added sentToMatrixRef to track sent messages and prevent duplicates - Only send messages when chatState is Idle (streaming complete) - Format messages as goose-session-message: for proper parsing by receivers - Prevents 429 Too Many Requests errors from sending during streaming --- ui/desktop/src/components/BaseChat2.tsx | 2 + ui/desktop/src/components/ChatInput.tsx | 71 ++++++++++++++++++++++--- ui/desktop/src/contexts/TabContext.tsx | 2 +- ui/desktop/src/hooks/useChatStream.ts | 22 +++++--- 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 64749074cb..34291ff5f9 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -107,6 +107,7 @@ function BaseChatContent({ sessionLoadError, setRecipeUserParams, tokenState, + gooseEnabled, } = useChatStream({ sessionId, onStreamFinish, @@ -544,6 +545,7 @@ function BaseChatContent({ toolCount={toolCount || 0} autoSubmit={false} append={append} + gooseEnabled={gooseEnabled} {...customChatInputProps} /> diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 48cd5711cd..5f41ff53ca 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -103,6 +103,7 @@ interface ChatInputProps { autoSubmit: boolean; append?: (message: Message) => void; isExtensionsLoading?: boolean; + gooseEnabled?: boolean; } export default function ChatInput({ @@ -130,6 +131,7 @@ export default function ChatInput({ autoSubmit = false, append, isExtensionsLoading = false, + gooseEnabled = true, }: ChatInputProps) { // Track the available width for responsive layout const [availableWidth, setAvailableWidth] = useState(window.innerWidth); @@ -478,14 +480,17 @@ export default function ChatInput({ }, }); - // Listen for AI responses to sync to Matrix + // Track which messages have been sent to Matrix to prevent duplicates + const sentToMatrixRef = useRef>(new Set()); + + // Listen for AI responses to sync to Matrix or collaborative sessions // FIXED: Robust null checking to prevent "Cannot read properties of undefined (reading 'length')" error // Updated: Fixed all commandHistory.length accesses with safeCommandHistory // Final fix: All .length accesses now properly null-checked + // CRITICAL FIX: Only send to Matrix when streaming is complete (chatState is Idle) + // CRITICAL FIX 2: Track sent messages to prevent duplicate sends useEffect(() => { - // CRITICAL: Only sync AI responses for non-Matrix rooms - // Matrix rooms handle AI responses directly through Matrix, not through sync - if (!sessionSharing.isSessionActive || isMatrixRoom || !messages || !Array.isArray(messages) || messages.length === 0) return; + if (!messages || !Array.isArray(messages) || messages.length === 0) return; const lastMessage = messages[messages.length - 1]; @@ -498,7 +503,19 @@ export default function ChatInput({ !lastMessage.sender && // Messages from Matrix have sender info, local AI responses don't !lastMessage.metadata?.isFromMatrix && // Additional check for Matrix-originated messages !lastMessage.metadata?.isFromCollaborator) { // Additional check for collaborator messages - console.log('🤖 Syncing AI response to collaborative session (non-Matrix):', lastMessage); + + // CRITICAL: Only send to Matrix when streaming is complete (chatState is Idle) + // This prevents sending partial messages during streaming + if (chatState !== ChatState.Idle) { + console.log('🚫 Skipping Matrix sync - streaming in progress (chatState:', chatState, ')'); + return; + } + + // CRITICAL: Check if we've already sent this message to Matrix + if (sentToMatrixRef.current.has(lastMessage.id)) { + console.log('🚫 Skipping Matrix sync - message already sent:', lastMessage.id); + return; + } // Extract text content from the message - with robust null checking let textContent = ''; @@ -511,7 +528,35 @@ export default function ChatInput({ textContent = lastMessage.content; } - if (textContent.trim()) { + if (!textContent.trim()) return; + + // Handle Matrix rooms: send AI response directly to Matrix with goose-session-message format + if (isMatrixRoom && actualMatrixRoomId && sendMessage) { + console.log('🤖 Sending COMPLETE AI response to Matrix room:', actualMatrixRoomId, '(chatState:', chatState, ', messageId:', lastMessage.id, ')'); + + // Mark as sent BEFORE sending to prevent race conditions + sentToMatrixRef.current.add(lastMessage.id); + + // Format as goose-session-message so it can be properly parsed by other clients + const sessionMessage = { + sessionId: sessionId || actualMatrixRoomId, + role: 'assistant', + content: textContent, + timestamp: Date.now(), + }; + const formattedMessage = `goose-session-message:${JSON.stringify(sessionMessage)}`; + + sendMessage(actualMatrixRoomId, formattedMessage).then(() => { + console.log('✅ Successfully sent COMPLETE AI response to Matrix room (messageId:', lastMessage.id, ')'); + }).catch((error) => { + console.error('❌ Failed to send AI response to Matrix room:', error); + // Remove from sent set if it failed so we can retry + sentToMatrixRef.current.delete(lastMessage.id); + }); + } + // Handle non-Matrix collaborative sessions: sync through sessionSharing + else if (sessionSharing.isSessionActive && !isMatrixRoom) { + console.log('🤖 Syncing COMPLETE AI response to collaborative session (non-Matrix):', lastMessage); sessionSharing.syncMessage({ id: lastMessage.id || `ai-${Date.now()}`, role: 'assistant', @@ -520,7 +565,7 @@ export default function ChatInput({ }); } } - }, [messages, sessionSharing, isMatrixRoom]); + }, [messages, sessionSharing, isMatrixRoom, actualMatrixRoomId, sendMessage, chatState]); @@ -1871,6 +1916,18 @@ export default function ChatInput({ {/* This space is reserved for mention popovers to render above the chat input */} + {/* Goose Off Indicator */} + {!gooseEnabled && ( +
+
+
+ + Goose is OFF - Type @goose to reactivate + +
+
+ )} + {/* Chat input container with max width - floating card */}
diff --git a/ui/desktop/src/contexts/TabContext.tsx b/ui/desktop/src/contexts/TabContext.tsx index 12da37b3bf..da98b5bff2 100644 --- a/ui/desktop/src/contexts/TabContext.tsx +++ b/ui/desktop/src/contexts/TabContext.tsx @@ -915,7 +915,7 @@ export const TabProvider: React.FC = ({ children }) => { messages: [], messageHistoryIndex: 0, recipeConfig: null, - aiEnabled: false, + aiEnabled: true, // Enable AI for Matrix chats so Goose can respond }, loadingChat: true // Show loading state }; diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index e26a55a972..85f99f3a9c 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -31,13 +31,7 @@ function checkForGooseCommands(message: string, currentGooseEnabled: boolean): { } { const trimmedMessage = message.trim().toLowerCase(); - // Check for goose ON command - if (trimmedMessage === '@goose' || trimmedMessage === '@goose on') { - console.log('🦆 Goose turned ON'); - return { skipAI: true, newGooseEnabled: true, isGooseCommand: true }; - } - - // Check for goose OFF commands + // Check for goose OFF commands (must be exact matches) const gooseOffCommands = ['@goose off', '@goose stop', '@goose quiet', '@goose pause']; for (const command of gooseOffCommands) { if (trimmedMessage === command) { @@ -46,7 +40,17 @@ function checkForGooseCommands(message: string, currentGooseEnabled: boolean): { } } - // If goose is disabled, skip all AI responses except for goose commands + // Check for @goose mention anywhere in the message (case-insensitive) + const gooseMentionPattern = /@goose\b/i; + const containsGooseMention = gooseMentionPattern.test(message); + + // If goose is disabled and message contains @goose, turn it back on + if (!currentGooseEnabled && containsGooseMention) { + console.log('🦆 Goose turned ON - detected @goose mention in message'); + return { skipAI: false, newGooseEnabled: true, isGooseCommand: false }; + } + + // If goose is disabled and no @goose mention, skip AI response if (!currentGooseEnabled) { console.log('🦆 Goose is OFF - skipping AI response'); return { skipAI: true, newGooseEnabled: currentGooseEnabled, isGooseCommand: false }; @@ -118,6 +122,7 @@ interface UseChatStreamReturn { stopStreaming: () => void; sessionLoadError?: string; tokenState: TokenState; + gooseEnabled: boolean; } function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] { @@ -777,5 +782,6 @@ export function useChatStream({ stopStreaming, setRecipeUserParams, tokenState, + gooseEnabled, }; }