diff --git a/application/single_app/config.py b/application/single_app/config.py index e4527b682..32e453797 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.201" +VERSION = "0.250.202" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_chat_stream_events.py b/application/single_app/functions_chat_stream_events.py new file mode 100644 index 000000000..859e3a80c --- /dev/null +++ b/application/single_app/functions_chat_stream_events.py @@ -0,0 +1,37 @@ +# functions_chat_stream_events.py + +import json +from typing import Any, Dict + + +USER_MESSAGE_PERSISTED_EVENT_TYPE = "user_message_persisted" + + +def build_user_message_persisted_stream_payload( + conversation_id: str, + user_message_id: str, +) -> Dict[str, Any]: + """Build the SSE payload that acknowledges durable user-message storage.""" + normalized_conversation_id = str(conversation_id or "").strip() + normalized_user_message_id = str(user_message_id or "").strip() + if not normalized_conversation_id or not normalized_user_message_id: + raise ValueError("conversation_id and user_message_id are required") + + return { + "type": USER_MESSAGE_PERSISTED_EVENT_TYPE, + "conversation_id": normalized_conversation_id, + "user_message_id": normalized_user_message_id, + "message_persisted": True, + } + + +def build_user_message_persisted_stream_event( + conversation_id: str, + user_message_id: str, +) -> str: + """Serialize a user-message persistence acknowledgement as an SSE event.""" + payload = build_user_message_persisted_stream_payload( + conversation_id, + user_message_id, + ) + return f"data: {json.dumps(payload)}\n\n" diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index f89d50ec1..8db8e6b2b 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -116,6 +116,7 @@ from functions_global_agents import get_global_agents from functions_group_agents import get_group_agents from functions_personal_agents import get_personal_agents +from functions_chat_stream_events import build_user_message_persisted_stream_event from functions_source_review import ( build_deep_research_ledger, build_deep_research_ledger_markdown, @@ -15020,6 +15021,13 @@ def execute_document_action_chat_request( 'metadata': user_metadata, }) cosmos_messages_container.upsert_item(user_message_doc) + if callable(publish_background_event): + publish_background_event( + build_user_message_persisted_stream_event( + conversation_id, + user_message_id, + ) + ) try: document_action_activity_context = { @@ -15880,6 +15888,11 @@ def generate_image_from_proposal(): @login_required @user_required def chat_api(): + publish_background_event = getattr( + g, + 'chat_publish_background_event', + None, + ) try: request_start_time = time.time() settings = get_settings() @@ -16932,6 +16945,13 @@ def result_requires_message_reload(result: Any) -> bool: # Note: Message-level chat_type will be updated after document search cosmos_messages_container.upsert_item(user_message_doc) + if callable(publish_background_event): + publish_background_event( + build_user_message_persisted_stream_event( + conversation_id, + user_message_id, + ) + ) # Log chat activity for real-time tracking try: @@ -20099,7 +20119,7 @@ def normalize_legacy_chat_payload(payload): 'blocked': payload.get('blocked', False), }) - def generate_compatibility_response(): + def generate_compatibility_response(publish_background_event=None): """Bridge legacy JSON chat handling into a terminal SSE event for parity cases.""" try: g.conversation_id = finalized_conversation_id @@ -20122,6 +20142,7 @@ def generate_compatibility_response(): } yield f"data: {json.dumps(image_request_event)}\n\n" + g.chat_publish_background_event = publish_background_event legacy_result = chat_api() legacy_response = legacy_result status_code = 200 @@ -21205,6 +21226,10 @@ def build_streaming_capability_usage(): } cosmos_messages_container.upsert_item(user_message_doc) + yield build_user_message_persisted_stream_event( + conversation_id, + user_message_id, + ) debug_print( f"[STREAMING] Saved user message {user_message_id} | thread_id={current_user_thread_id} | previous_thread_id={previous_thread_id}" ) diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py index 1c52e27d0..bf1f20af3 100644 --- a/application/single_app/route_backend_collaboration.py +++ b/application/single_app/route_backend_collaboration.py @@ -45,6 +45,10 @@ update_personal_collaboration_title, ) from functions_conversation_cache import bump_conversation_cache_version +from functions_chat_stream_events import ( + USER_MESSAGE_PERSISTED_EVENT_TYPE, + build_user_message_persisted_stream_event, +) from functions_group import assert_group_role, check_group_status_allows_operation, find_group_by_id, require_active_group from functions_image_messages import decode_image_content, get_complete_image_content, is_blob_backed_image_message, is_external_image_url from functions_message_masking import ( @@ -1449,6 +1453,10 @@ def stream_collaboration_message_api(conversation_id): def generate_stream(): try: + yield build_user_message_persisted_stream_event( + conversation_id, + serialized_user_message.get('id'), + ) internal_stream_view = current_app.view_functions.get('chat_stream_api') if not callable(internal_stream_view): yield _serialize_stream_error( @@ -1508,6 +1516,9 @@ def transform_event_block(event_block): conversation_id=conversation_id, ) + if stream_payload.get('type') == USER_MESSAGE_PERSISTED_EVENT_TYPE: + return None + if not stream_payload.get('done'): return normalized_event_block + '\n\n' diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index 32666f30c..0a9f5a8c6 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -5,6 +5,7 @@ import { getCollaborativeTagSuggestions, getGeneratedImageProposalSourceMessageId, groupGeneratedImageProposalMessages, + setUserMessageStreamingActionsDisabled, updateSendButtonVisibility, updateUserMessageId, userInput, @@ -1074,6 +1075,12 @@ function handleConversationEvent(eventEnvelope = {}) { const decoratedMessage = decorateReplyMessage(payload.message); cacheCollaborationMessage(payload.message); if (reconcilePendingCollaborativeUserMessage(payload.message)) { + const messageKind = String( + payload.message.message_kind || payload.message.metadata?.message_kind || '' + ).trim(); + if (senderUserId === getCurrentUserId() && messageKind !== 'ai_request') { + setUserMessageStreamingActionsDisabled(payload.message.id, false); + } if (shouldClearNotifications) { void markCollaborationConversationRead(eventEnvelope.conversation_id || payload.message.conversation_id, { suppressErrorToast: true, @@ -1308,6 +1315,7 @@ async function sendCollaborativeMessage(messageText, tempMessageId = null) { if (!reconcilePendingCollaborativeUserMessage(payload.message, tempMessageId)) { renderCollaborationMessage(decorateReplyMessage(payload.message), { isNewMessage: true }); } + setUserMessageStreamingActionsDisabled(payload.message.id, false); } setTypingState(false, { force: true }); diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index c35488d27..e80fa8fc0 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -6489,6 +6489,9 @@ export function appendMessage( // Add event listeners for user message buttons if (sender === "You") { attachUserMessageEventListeners(messageDiv, messageId, messageContent); + if (String(messageId || '').startsWith('temp_user_')) { + setUserMessageStreamingActionsDisabled(messageId, true); + } // Apply masked state if message has masking if (fullMessageObject?.metadata) { @@ -7515,8 +7518,58 @@ if (promptSelect) { updateDocumentActionControls(); +let userMetadataRequestSequence = 0; + +function renderUserMetadataStatus(container, message, className = 'text-muted') { + const status = document.createElement('div'); + status.className = className; + status.textContent = message; + container.replaceChildren(status); +} + +export function setUserMessageStreamingActionsDisabled(messageId, disabled) { + const normalizedMessageId = String(messageId || '').trim(); + if (!normalizedMessageId) { + return false; + } + + const messageDiv = document.querySelector(`[data-message-id="${normalizedMessageId}"]`); + if (!messageDiv) { + return false; + } + + messageDiv.dataset.streamingActionsDisabled = disabled ? 'true' : 'false'; + const mutatingActions = getUserMessageMutatingActions(normalizedMessageId); + mutatingActions.forEach(action => { + action.dataset.streamingDisabled = disabled ? 'true' : 'false'; + action.classList.toggle('disabled', disabled); + action.setAttribute('aria-disabled', disabled ? 'true' : 'false'); + if (disabled) { + action.setAttribute('tabindex', '-1'); + } else { + action.removeAttribute('tabindex'); + } + if (action instanceof HTMLButtonElement) { + action.disabled = disabled; + } + }); + return mutatingActions.length > 0; +} + +function getUserMessageMutatingActions(messageId) { + const normalizedMessageId = String(messageId || '').trim(); + return Array.from(document.querySelectorAll( + '.dropdown-edit-btn, .dropdown-delete-btn, .dropdown-retry-btn, .mask-add-btn, .mask-remove-btn' + )).filter(action => action.getAttribute('data-message-id') === normalizedMessageId); +} + +function isUserMessageStreamingActionDisabled(action) { + return action?.dataset.streamingDisabled === 'true'; +} + // Helper function to update user message ID after backend response -export function updateUserMessageId(tempId, realId) { +export function updateUserMessageId(tempId, realId, options = {}) { + const { refreshExpandedMetadata = false } = options; console.log(`๐Ÿ”„ Updating message ID: ${tempId} -> ${realId}`); // Find the message with the temporary ID @@ -7527,12 +7580,13 @@ export function updateUserMessageId(tempId, realId) { console.log(`โœ… Updated messageDiv data-message-id to: ${realId}`); // Update ALL elements with the temporary ID to ensure consistency - const elementsToUpdate = [ + const elementsToUpdate = new Set([ messageDiv.querySelector('.copy-user-btn'), messageDiv.querySelector('.metadata-toggle-btn'), ...messageDiv.querySelectorAll(`[data-message-id="${tempId}"]`), - ...messageDiv.querySelectorAll(`[aria-controls*="${tempId}"]`) - ]; + ...messageDiv.querySelectorAll(`[aria-controls*="${tempId}"]`), + ...getUserMessageMutatingActions(tempId) + ]); let updateCount = 0; elementsToUpdate.forEach(element => { @@ -7565,6 +7619,13 @@ export function updateUserMessageId(tempId, realId) { updateCount++; } + if (['pending', 'unconfirmed'].includes(metadataContainer?.dataset.metadataState)) { + metadataContainer.dataset.metadataState = 'ready'; + if (metadataContainer.style.display !== 'none') { + loadUserMessageMetadata(realId, metadataContainer); + } + } + console.log(`โœ… Updated ${updateCount} elements with new message ID`); // Verify the update was successful @@ -7578,12 +7639,74 @@ export function updateUserMessageId(tempId, realId) { const existingRealMessageDiv = document.querySelector(`[data-message-id="${realId}"]`); if (existingRealMessageDiv) { console.info(`โ„น๏ธ Message div for temp ID ${tempId} was already reconciled to ${realId}`); + if (refreshExpandedMetadata) { + refreshUserMessageMetadata(realId); + } } else { console.warn(`โš ๏ธ Message div with temp ID ${tempId} not found for update`); } } } +export function refreshUserMessageMetadata(messageId) { + const normalizedMessageId = String(messageId || '').trim(); + const messageDiv = normalizedMessageId + ? document.querySelector(`[data-message-id="${normalizedMessageId}"]`) + : null; + const metadataContainer = messageDiv?.querySelector('.metadata-container'); + if (!metadataContainer) { + return false; + } + + if (metadataContainer.style.display !== 'none') { + loadUserMessageMetadata(normalizedMessageId, metadataContainer); + } else { + metadataContainer.dataset.metadataState = 'stale'; + metadataContainer.dataset.metadataRequestToken = ''; + } + return true; +} + +function markUserMessageMetadataState(messageId, metadataState, statusMessage) { + const normalizedMessageId = String(messageId || '').trim(); + if (!normalizedMessageId) { + return false; + } + + const messageDiv = document.querySelector(`[data-message-id="${normalizedMessageId}"]`); + const metadataContainer = messageDiv?.querySelector('.metadata-container'); + if (!metadataContainer) { + return false; + } + + metadataContainer.dataset.metadataState = metadataState; + metadataContainer.dataset.metadataRequestToken = ''; + if (metadataContainer.style.display !== 'none') { + renderUserMetadataStatus( + metadataContainer, + statusMessage, + 'text-warning' + ); + } + return true; +} + +export function markUserMessageMetadataUnconfirmed(messageId) { + return markUserMessageMetadataState( + messageId, + 'unconfirmed', + 'Message metadata persistence could not be confirmed. Refresh the conversation to check.' + ); +} + +export function markUserMessageMetadataFinalizationUnconfirmed(messageId) { + return markUserMessageMetadataState( + messageId, + 'finalization-unconfirmed', + 'Message metadata may still be updating after the stream disconnected. Refresh the conversation to check.' + ); +} + // Helper function to attach event listeners to user message buttons function attachUserMessageEventListeners(messageDiv, messageId, messageContent) { const copyBtn = messageDiv.querySelector(".copy-user-btn"); @@ -7609,7 +7732,8 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent) if (metadataToggleBtn) { metadataToggleBtn.addEventListener("click", () => { - toggleUserMessageMetadata(messageDiv, messageId); + const currentMessageId = messageDiv.getAttribute('data-message-id') || messageId; + toggleUserMessageMetadata(messageDiv, currentMessageId); }); } @@ -7619,6 +7743,9 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent) if (dropdownDeleteBtn) { dropdownDeleteBtn.addEventListener("click", (e) => { e.preventDefault(); + if (isUserMessageStreamingActionDisabled(e.currentTarget)) { + return; + } // Always read the message ID from the DOM attribute dynamically // This ensures we use the updated ID after updateUserMessageId is called const currentMessageId = messageDiv.getAttribute('data-message-id'); @@ -7631,6 +7758,9 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent) if (dropdownRetryBtn) { dropdownRetryBtn.addEventListener("click", (e) => { e.preventDefault(); + if (isUserMessageStreamingActionDisabled(e.currentTarget)) { + return; + } // Always read the message ID from the DOM attribute dynamically const currentMessageId = messageDiv.getAttribute('data-message-id'); console.log(`๐Ÿ”„ Retry button clicked - using message ID from DOM: ${currentMessageId}`); @@ -7642,6 +7772,9 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent) if (dropdownEditBtn) { dropdownEditBtn.addEventListener("click", (e) => { e.preventDefault(); + if (isUserMessageStreamingActionDisabled(e.currentTarget)) { + return; + } // Always read the message ID from the DOM attribute dynamically const currentMessageId = messageDiv.getAttribute('data-message-id'); console.log(`โœ๏ธ Edit button clicked - using message ID from DOM: ${currentMessageId}`); @@ -7754,23 +7887,9 @@ function attachCollaboratorMessageEventListeners(messageDiv, fullMessageObject, // Function to toggle user message metadata drawer function toggleUserMessageMetadata(messageDiv, messageId) { + messageId = messageDiv.getAttribute('data-message-id') || messageId; console.log(`๐Ÿ”€ Toggling metadata for message: ${messageId}`); - // Validate that we're not using a temporary ID - if (messageId && messageId.startsWith('temp_user_')) { - console.error(`โŒ Metadata toggle called with temporary ID: ${messageId}`); - console.log(`๐Ÿ” Checking if real ID is available in DOM...`); - - // Try to find the real ID from the message div - const actualMessageId = messageDiv.getAttribute('data-message-id'); - if (actualMessageId && actualMessageId !== messageId && !actualMessageId.startsWith('temp_user_')) { - console.log(`โœ… Found real ID in DOM: ${actualMessageId}, using that instead`); - messageId = actualMessageId; - } else { - console.error(`โŒ No valid real ID found, metadata toggle may fail`); - } - } - const toggleBtn = messageDiv.querySelector('.metadata-toggle-btn'); const targetId = toggleBtn.getAttribute('aria-controls'); const metadataContainer = messageDiv.querySelector(`#${targetId}`); @@ -7800,7 +7919,7 @@ function toggleUserMessageMetadata(messageDiv, messageId) { toggleBtn.innerHTML = ''; // Load metadata if not already loaded - if (metadataContainer.innerHTML.includes('Loading metadata...')) { + if (metadataContainer.dataset.metadataState !== 'loaded') { console.log(`๐Ÿ”„ Loading metadata content for ${messageId}`); loadUserMessageMetadata(messageId, metadataContainer); } @@ -7821,36 +7940,61 @@ function toggleUserMessageMetadata(messageDiv, messageId) { // Function to load user message metadata into the drawer function loadUserMessageMetadata(messageId, container, retryCount = 0) { + const currentMessageId = container.closest('[data-message-id]')?.getAttribute('data-message-id'); + if ( + messageId?.startsWith('temp_user_') + && currentMessageId + && !currentMessageId.startsWith('temp_user_') + ) { + messageId = currentMessageId; + } + console.log(`๐Ÿ” Loading metadata for message ID: ${messageId} (attempt ${retryCount + 1})`); + if (container.dataset.metadataState === 'unconfirmed') { + renderUserMetadataStatus( + container, + 'Message metadata persistence could not be confirmed. Refresh the conversation to check.', + 'text-warning' + ); + return; + } + + if (container.dataset.metadataState === 'finalization-unconfirmed') { + renderUserMetadataStatus( + container, + 'Message metadata may still be updating after the stream disconnected. Refresh the conversation to check.', + 'text-warning' + ); + return; + } + // Validate message ID to catch temporary IDs early if (!messageId || messageId === "null" || messageId === "undefined") { console.error(`โŒ Invalid message ID: ${messageId}`); - container.innerHTML = '
Message metadata not available.
'; + container.dataset.metadataState = 'error'; + renderUserMetadataStatus(container, 'Message metadata not available.'); return; } - // Check for temporary IDs which indicate a bug + // Wait for the persistence event instead of polling with a stale temporary ID. if (messageId.startsWith('temp_user_')) { - console.error(`โŒ Attempting to load metadata with temporary ID: ${messageId}`); - console.error(`This indicates the updateUserMessageId function didn't work properly`); - - if (retryCount < 2) { - // Short retry for temp IDs in case the real ID update is still in progress - console.log(`๐Ÿ”„ Retrying metadata load for temp ID in 100ms (attempt ${retryCount + 1}/3)`); - setTimeout(() => { - loadUserMessageMetadata(messageId, container, retryCount + 1); - }, 100); - return; - } else { - container.innerHTML = '
Message metadata unavailable (temporary ID not updated).
'; - return; - } + container.dataset.metadataState = 'pending'; + renderUserMetadataStatus(container, 'Saving message metadata...'); + return; } + container.dataset.metadataState = 'loading'; + const requestToken = String(++userMetadataRequestSequence); + container.dataset.metadataRequestToken = requestToken; + renderUserMetadataStatus(container, 'Loading metadata...'); + // Fetch message metadata from the backend fetch(`/api/message/${messageId}/metadata`) .then(response => { + if (container.dataset.metadataRequestToken !== requestToken) { + return; + } console.log(`๐Ÿ“ก Metadata API response for ${messageId}: ${response.status}`); if (!response.ok) { @@ -7859,7 +8003,9 @@ function loadUserMessageMetadata(messageId, container, retryCount = 0) { const delay = Math.min((retryCount + 1) * 500, 2000); // Cap at 2 seconds console.log(`โณ Message ${messageId} not found, retrying in ${delay}ms (attempt ${retryCount + 1}/3)`); setTimeout(() => { - loadUserMessageMetadata(messageId, container, retryCount + 1); + if (container.dataset.metadataRequestToken === requestToken) { + loadUserMessageMetadata(messageId, container, retryCount + 1); + } }, delay); return; } @@ -7868,8 +8014,12 @@ function loadUserMessageMetadata(messageId, container, retryCount = 0) { return response.json(); }) .then(data => { + if (container.dataset.metadataRequestToken !== requestToken) { + return; + } if (data) { console.log(`โœ… Successfully loaded metadata for ${messageId}`); + container.dataset.metadataState = 'loaded'; container.innerHTML = formatMetadataForDrawer(data); // Attach event listeners to View Text buttons @@ -7896,8 +8046,12 @@ function loadUserMessageMetadata(messageId, container, retryCount = 0) { } }) .catch(error => { + if (container.dataset.metadataRequestToken !== requestToken) { + return; + } console.error(`โŒ Error fetching message metadata for ${messageId}:`, error); + container.dataset.metadataState = 'error'; if (retryCount >= 3) { container.innerHTML = '
Failed to load message metadata after multiple attempts.
'; } else { @@ -9227,6 +9381,9 @@ function attachMaskButtonEventListeners(messageDiv) { updateMaskControls(messageDiv, messageDiv._maskingMetadata || {}); }); addButton.addEventListener('click', () => { + if (isUserMessageStreamingActionDisabled(addButton)) { + return; + } handleMaskAddButtonClick(messageDiv); }); } @@ -9237,6 +9394,9 @@ function attachMaskButtonEventListeners(messageDiv) { updateMaskControls(messageDiv, messageDiv._maskingMetadata || {}); }); removeButton.addEventListener('click', () => { + if (isUserMessageStreamingActionDisabled(removeButton)) { + return; + } handleMaskRemoveButtonClick(messageDiv); }); } diff --git a/application/single_app/static/js/chat/chat-streaming.js b/application/single_app/static/js/chat/chat-streaming.js index 27c4b58e7..15ab2aeff 100644 --- a/application/single_app/static/js/chat/chat-streaming.js +++ b/application/single_app/static/js/chat/chat-streaming.js @@ -1,5 +1,13 @@ // chat-streaming.js -import { appendMessage, renderAiMessageContent, updateUserMessageId } from './chat-messages.js'; +import { + appendMessage, + markUserMessageMetadataFinalizationUnconfirmed, + markUserMessageMetadataUnconfirmed, + refreshUserMessageMetadata, + renderAiMessageContent, + setUserMessageStreamingActionsDisabled, + updateUserMessageId, +} from './chat-messages.js'; import { applyConversationMetadataUpdate, markConversationRead } from './chat-conversations.js'; import { hideLoadingIndicatorInChatbox, showLoadingIndicatorInChatbox } from './chat-loading-indicator.js'; import { showToast } from './chat-toast.js'; @@ -13,6 +21,7 @@ import { requestDesktopNotificationPermissionIfNeeded, showDesktopConversationNo let currentStreamController = null; let currentStreamContext = null; const MAX_STREAM_CLIENT_ERROR_LENGTH = 500; +const USER_MESSAGE_PERSISTED_EVENT_TYPE = 'user_message_persisted'; function normalizeLegacyEscapedSseDelimiters(chunk) { return String(chunk || '').replace(/(\})\\n\\n(?=(?:data:|event:|id:|retry:|:|$))/g, '$1\n\n'); @@ -468,6 +477,24 @@ export function applyStreamingConversationMetadata(data = {}) { applyConversationMetadataUpdate(conversationId, metadataUpdates); } +export function applyStreamingUserMessagePersistence(data = {}, tempUserMessageId = null) { + if (data.type !== USER_MESSAGE_PERSISTED_EVENT_TYPE || data.message_persisted !== true) { + return false; + } + + const persistedUserMessageId = String(data.user_message_id || '').trim(); + if (!persistedUserMessageId) { + return null; + } + + const pendingUserMessageId = String(tempUserMessageId || '').trim(); + if (pendingUserMessageId) { + updateUserMessageId(pendingUserMessageId, persistedUserMessageId); + } + setUserMessageStreamingActionsDisabled(persistedUserMessageId, true); + return persistedUserMessageId; +} + async function getStreamingStatus(conversationId) { if (!conversationId) { return null; @@ -491,6 +518,7 @@ async function attemptStreamingRecovery(conversationId, failedMessageId, tempUse onError = null, onFinally = null, reconnectStatusLabel = 'Reconnecting...', + persistedUserMessageId = null, } = options; if (!conversationId) { @@ -541,6 +569,7 @@ async function attemptStreamingRecovery(conversationId, failedMessageId, tempUse allowRecovery: false, recoveryConversationId: conversationId, reconnectStatusLabel, + initialPersistedUserMessageId: persistedUserMessageId, }, ); } catch (error) { @@ -559,6 +588,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa cancelEndpoint = null, reconnectStatusLabel = 'Reconnecting...', fallbackAgentInfo = null, + initialPersistedUserMessageId = null, } = options; if (currentStreamController) { @@ -584,9 +614,40 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa let hasStreamedContent = false; let streamError = false; let streamCompleted = false; + let persistedUserMessageId = String(initialPersistedUserMessageId || '').trim() || null; let lastChunkAt = null; let eventCount = 0; + function finalizePendingUserMessageMetadata() { + if (persistedUserMessageId) { + if (tempUserMessageId) { + updateUserMessageId( + tempUserMessageId, + persistedUserMessageId, + { refreshExpandedMetadata: true } + ); + } else { + refreshUserMessageMetadata(persistedUserMessageId); + } + } else if (tempUserMessageId) { + markUserMessageMetadataUnconfirmed(tempUserMessageId); + } + } + + function markInterruptedUserMessageMetadata() { + if (persistedUserMessageId) { + markUserMessageMetadataFinalizationUnconfirmed(persistedUserMessageId); + } else if (tempUserMessageId) { + markUserMessageMetadataUnconfirmed(tempUserMessageId); + } + } + + function enablePersistedUserMessageActions() { + if (persistedUserMessageId) { + setUserMessageStreamingActionsDisabled(persistedUserMessageId, false); + } + } + requestFactory(abortController.signal).then(response => { if (!response.ok) { if (response.status === 404) { @@ -618,6 +679,11 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa lastChunkAt = Date.now(); if (data.error) { + if (data.user_message_id && data.message_persisted === true) { + persistedUserMessageId = String(data.user_message_id); + } + finalizePendingUserMessageMetadata(); + enablePersistedUserMessageActions(); stopThoughtPolling(); streamError = true; clearStreamingThoughtSession(tempAiMessageId); @@ -654,6 +720,18 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa return false; } + if (data.type === USER_MESSAGE_PERSISTED_EVENT_TYPE) { + const acknowledgedUserMessageId = applyStreamingUserMessagePersistence( + data, + tempUserMessageId + ); + if (acknowledgedUserMessageId) { + persistedUserMessageId = acknowledgedUserMessageId; + } + updateStreamContextConversation(streamContext, data.conversation_id || data.conversationId); + return false; + } + if (data.conversation_id || data.conversationId) { updateStreamContextConversation(streamContext, data.conversation_id || data.conversationId); } @@ -668,6 +746,11 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa stopThoughtPolling(); streamCompleted = true; clearStreamingThoughtSession(tempAiMessageId); + if (data.user_message_id) { + persistedUserMessageId = String(data.user_message_id); + } + finalizePendingUserMessageMetadata(); + enablePersistedUserMessageActions(); if (data.cancelled || data.canceled || data.type === 'cancelled' || data.type === 'canceled') { finalizeCancelledStreamingMessage( @@ -783,6 +866,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa onError, onFinally, reconnectStatusLabel, + persistedUserMessageId, }, ); if (recovered) { @@ -790,6 +874,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa } } + markInterruptedUserMessageMetadata(); clearStreamingThoughtSession(tempAiMessageId); handleStreamError( tempAiMessageId, @@ -820,6 +905,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa readStream(); // Continue reading }).catch(async err => { if (abortController.signal.aborted) { + markInterruptedUserMessageMetadata(); void reportClientStreamEvent('stream_aborted', { conversation_id: recoveryConversationId, elapsed_ms: Date.now() - streamStartedAt, @@ -858,6 +944,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa onError, onFinally, reconnectStatusLabel, + persistedUserMessageId, }, ); if (recovered) { @@ -865,6 +952,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa } } + markInterruptedUserMessageMetadata(); clearStreamingThoughtSession(tempAiMessageId); handleStreamError(tempAiMessageId, accumulatedContent, err.message, err); if (typeof onError === 'function') { @@ -880,6 +968,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa }).catch(async error => { if (abortController.signal.aborted) { + markInterruptedUserMessageMetadata(); void reportClientStreamEvent('stream_aborted', { conversation_id: recoveryConversationId, elapsed_ms: Date.now() - streamStartedAt, @@ -918,6 +1007,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa onError, onFinally, reconnectStatusLabel, + persistedUserMessageId, }, ); if (recovered) { @@ -925,6 +1015,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa } } + markInterruptedUserMessageMetadata(); clearStreamingThoughtSession(tempAiMessageId); handleStreamError(tempAiMessageId, accumulatedContent, error.message, error); @@ -1145,10 +1236,6 @@ function finalizeCancelledStreamingMessage(messageId, userMessageId, finalData, const messageElement = getStreamingMessageElement(messageId); const partialContent = finalData.full_content || finalData.partial_content || fallbackContent || ''; - if (finalData.user_message_id && userMessageId) { - updateUserMessageId(userMessageId, finalData.user_message_id); - } - removeStreamingStopButton(messageId); if (finalData.message_id && finalData.message_persisted) { @@ -1277,11 +1364,6 @@ function finalizeStreamingMessage(messageId, userMessageId, finalData, fallbackA removeStreamingStopButton(messageId); - // Update user message ID first - if (finalData.user_message_id && userMessageId) { - updateUserMessageId(userMessageId, finalData.user_message_id); - } - // Remove the temporary streaming message messageElement.remove(); diff --git a/docs/explanation/fixes/USER_MESSAGE_METADATA_STREAMING_FIX.md b/docs/explanation/fixes/USER_MESSAGE_METADATA_STREAMING_FIX.md new file mode 100644 index 000000000..597418453 --- /dev/null +++ b/docs/explanation/fixes/USER_MESSAGE_METADATA_STREAMING_FIX.md @@ -0,0 +1,97 @@ +# User Message Metadata Streaming Fix + +**Issue:** Newly submitted user-message metadata remained unavailable until the assistant response completed. + +**Root cause:** The backend persisted the user message before model work but returned its real ID only in the terminal SSE event. The browser therefore retained a `temp_user_*` ID throughout streaming, and a metadata drawer opened during that interval retried the stale ID. + +**Fixed/Implemented in version: `0.250.202`** + +**Related config.py update:** `VERSION = "0.250.202"` + +**Tracking:** `microsoft/simplechat#1244` + +## Technical details + +### Files modified + +- `application/single_app/functions_chat_stream_events.py` + - Defines the shared `user_message_persisted` SSE payload and serializer. +- `application/single_app/route_backend_chats.py` + - Emits the persistence event after successful user-message storage in standard, document-action, analyze, and image-generation compatibility streams. +- `application/single_app/route_backend_collaboration.py` + - Emits the collaboration-local user message ID and suppresses the corresponding source-conversation event. +- `application/single_app/static/js/chat/chat-streaming.js` + - Reconciles the temporary DOM message ID, carries acknowledged IDs through recovery, refreshes expanded metadata after terminal enrichment, and distinguishes unconfirmed persistence from unconfirmed finalization after disconnect. +- `application/single_app/static/js/chat/chat-messages.js` + - Uses the current DOM ID for metadata actions, transitions an open drawer from saving to loading, cancels stale retries, and keeps Mask/Edit/Delete/Retry disabled until terminal completion. +- `application/single_app/static/js/chat/chat-collaboration.js` + - Re-enables ordinary non-AI shared-message actions after their REST persistence response while AI turns stay gated by stream completion. +- `functional_tests/test_message_metadata_loading_fix.py` + - Validates the SSE contract, route ordering, collaboration translation, client handling, and terminal fallback. +- `ui_tests/test_chat_user_message_metadata_during_stream.py` + - Verifies metadata loads under the real ID while the assistant placeholder remains active. + +### Event lifecycle + +1. The browser renders the submitted user message with a temporary ID. +2. The backend authorizes the conversation and persists the user message. +3. The stream sends: + + ```json + { + "type": "user_message_persisted", + "conversation_id": "", + "user_message_id": "", + "message_persisted": true + } + ``` + +4. The browser updates the message element, metadata button, `aria-controls`, and metadata container ID. +5. If the metadata drawer is already open, it immediately requests the existing authorized metadata endpoint with the persisted ID. +6. Assistant generation continues. The terminal event retains `user_message_id` and refreshes an expanded drawer so later capability/model enrichment is visible. +7. If the stream fails before an acknowledgement arrives, the drawer reports that persistence could not be confirmed instead of incorrectly claiming the message was not saved. +8. If a confirmed stream disconnects before terminal enrichment, the drawer reports that metadata may still be updating and directs the user to refresh instead of freezing a pre-final snapshot. +9. Mask, Edit, Delete, and Retry remain disabled from temporary rendering through stream termination so early ID reconciliation cannot mutate an in-flight turn. + +### Security and compatibility + +- The event contains identifiers and persistence state only; it does not expose raw settings or message metadata. +- Metadata remains protected by the existing `/api/message//metadata` authorization boundary. +- Collaboration streams expose the collaboration-local message ID rather than the internal source-conversation ID. +- Assistant metadata behavior is unchanged and remains deferred until the assistant message is persisted. + +## Validation + +### Test coverage + +- Shared SSE payload and serialization. +- Persistence-event ordering after Cosmos DB storage and before assistant work. +- Standard, document-action, analyze, image-generation, and collaboration stream wiring. +- Collaboration source-event suppression and local-ID reconciliation. +- Browser handling before terminal completion. +- Open-drawer recovery without a refresh. +- Terminal refresh after server-side metadata enrichment. +- Pre-acknowledgement stream failure handling without false storage claims. +- Post-acknowledgement disconnect handling without freezing pre-final metadata. +- Stale metadata request suppression. +- Recovery handoff with the acknowledged user message ID. +- Mask/Edit/Delete/Retry gating during generation. +- Terminal `user_message_id` fallback preservation. + +### Before and after + +| Scenario | Before | After | +|---|---|---| +| Open user metadata while AI is running | Temporary-ID error | Metadata loads after the persistence event | +| Refresh during AI processing | Metadata appears only after reload | Reload is unnecessary | +| Open drawer before ID acknowledgement | Retry retains stale ID | Drawer shows a saving state, then loads automatically | +| Metadata enriched later in the stream | Early snapshot remains stale | Expanded drawer refreshes on terminal completion | +| Stream fails before persistence is acknowledged | Saving state can remain indefinitely | Drawer reports an unconfirmed state and directs the user to refresh | +| Stream disconnects after persistence acknowledgement | Pre-final metadata can appear complete | Drawer reports that finalization is unconfirmed and directs the user to refresh | +| Mask/Edit/Delete/Retry during generation | Early real ID can mutate or overwrite an in-flight turn | Mutating actions remain disabled until terminal completion | +| Collaboration stream | Source/local ID timing depended on terminal event | Local collaboration ID is acknowledged immediately | +| Assistant metadata while running | Unavailable | Unchanged; available after assistant persistence | + +### User experience improvement + +Users can inspect the submitted message's metadata during long-running model, tabular, document-analysis, image-generation, and shared-chat operations without interrupting the stream or refreshing the page. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index bc5115933..fac6a5a63 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,15 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.202)** + +#### Bug Fixes + +* **Live User Message Metadata During Streaming** + * Made submitted user-message metadata available as soon as storage is acknowledged, without waiting for the assistant response to finish or requiring a page refresh. + * Preserved finalized metadata across success, server errors, cancellation, disconnect, recovery, image generation, document actions, and shared-chat streams while keeping in-flight message mutations gated until terminal completion. + * (Ref: #1244, `functions_chat_stream_events.py`, `chat-streaming.js`, `chat-messages.js`, `USER_MESSAGE_METADATA_STREAMING_FIX.md`) + ### **(v0.250.201)** #### Bug Fixes diff --git a/functional_tests/test_message_metadata_loading_fix.py b/functional_tests/test_message_metadata_loading_fix.py index 89dd0f526..d741016f7 100644 --- a/functional_tests/test_message_metadata_loading_fix.py +++ b/functional_tests/test_message_metadata_loading_fix.py @@ -1,93 +1,194 @@ +# test_message_metadata_loading_fix.py #!/usr/bin/env python3 """ -Test script for verifying the message metadata loading fix. +Functional test for live user-message metadata reconciliation. +Version: 0.250.202 +Implemented in: 0.250.202 -This test validates that message metadata loads correctly for subsequent -messages in a conversation, not just the first one. +This test ensures streaming routes acknowledge persisted user messages before +assistant completion and the browser replaces temporary IDs without a refresh. """ +import json import sys -import os -sys.path.append(os.path.dirname(os.path.abspath(__file__))) - -def test_message_metadata_loading_fix(): - """Test that message metadata loads correctly for all messages in a conversation.""" - - print("๐Ÿงช Testing Message Metadata Loading Fix") - print("=" * 60) - - print("๐Ÿ“‹ Issue Description:") - print(" - First message metadata loads fine") - print(" - Subsequent messages fail with 404 for temp_user_* IDs") - print(" - Works after page reload or conversation switch") - print(" - Affects both direct model and agent conversations") - - print("\n๐Ÿ” Root Cause Analysis:") - print(" - User messages created with temporary IDs like 'temp_user_1756915703120'") - print(" - updateUserMessageId() should replace temp ID with real ID") - print(" - Race condition: metadata toggle might still use temp ID") - print(" - loadMessages() works because it uses real IDs from database") - - print("\n๐Ÿ› ๏ธ Fix Strategy:") - print(" 1. Improve updateUserMessageId() robustness") - print(" 2. Add better error handling for metadata loading") - print(" 3. Validate all DOM elements are updated consistently") - print(" 4. Add retry logic with exponential backoff") - - print("\nโœ… Expected Behavior After Fix:") - print(" - All user messages should have real IDs in DOM") - print(" - Metadata should load for any message in conversation") - print(" - No 404 errors for temp_user_* IDs") - print(" - Consistent behavior across page sessions") - - print("\n๐Ÿงช Test Cases to Validate:") - print(" โœ“ First message metadata loads") - print(" โœ“ Second message metadata loads") - print(" โœ“ Third message metadata loads") - print(" โœ“ No temporary IDs in final DOM") - print(" โœ“ All metadata toggle buttons work") - print(" โœ“ Switching conversations and back works") - - print("\n๐Ÿ“ Files Modified:") - print(" - chat-messages.js: updateUserMessageId() improvements") - print(" - chat-messages.js: loadUserMessageMetadata() error handling") - print(" - Added comprehensive validation and retry logic") - - print("\n๐ŸŽฏ This fix addresses the intermittent message metadata loading issue") - print(" where subsequent messages fail to load metadata due to temporary ID") - print(" references not being properly updated in the DOM.") - - try: - # Simulate the fix validation - print("\n๐Ÿ”ง Simulating Fix Implementation...") - - # Test scenario 1: Multiple messages in conversation - print(" โœ“ Scenario 1: Multiple messages - FIXED") - print(" - All messages now use real IDs for metadata requests") - - # Test scenario 2: Agent vs direct model consistency - print(" โœ“ Scenario 2: Agent/Direct model consistency - FIXED") - print(" - Both modes now handle metadata loading consistently") - - # Test scenario 3: Page navigation scenarios - print(" โœ“ Scenario 3: Navigation scenarios - FIXED") - print(" - Metadata works regardless of how conversation is accessed") - - print("\n๐ŸŽ‰ All test scenarios validated!") - print("\n๐Ÿ“‹ Fix Summary:") - print(" โœ… Temporary ID to real ID mapping improved") - print(" โœ… DOM consistency validation added") - print(" โœ… Error handling and retry logic enhanced") - print(" โœ… Race condition eliminated") - - return True - - except Exception as e: - print(f"\nโŒ Test failed: {e}") - import traceback - traceback.print_exc() - return False +from pathlib import Path + +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "application" / "single_app" +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) + +from functions_chat_stream_events import ( # noqa: E402 + USER_MESSAGE_PERSISTED_EVENT_TYPE, + build_user_message_persisted_stream_event, + build_user_message_persisted_stream_payload, +) + + +CHAT_ROUTE = APP_ROOT / "route_backend_chats.py" +COLLABORATION_ROUTE = APP_ROOT / "route_backend_collaboration.py" +CHAT_STREAMING_JS = APP_ROOT / "static" / "js" / "chat" / "chat-streaming.js" +CHAT_MESSAGES_JS = APP_ROOT / "static" / "js" / "chat" / "chat-messages.js" +CHAT_COLLABORATION_JS = APP_ROOT / "static" / "js" / "chat" / "chat-collaboration.js" + + +def read_text(path: Path) -> str: + """Read a UTF-8 repository file.""" + return path.read_text(encoding="utf-8") + + +def source_between(source: str, start_marker: str, end_marker: str) -> str: + """Return source bounded by two required markers.""" + start_index = source.find(start_marker) + end_index = source.find(end_marker, start_index + len(start_marker)) + assert start_index != -1, f"Missing start marker: {start_marker}" + assert end_index != -1, f"Missing end marker: {end_marker}" + return source[start_index:end_index] + + +def assert_ordered(source: str, *snippets: str) -> None: + """Assert snippets occur in the supplied order.""" + cursor = -1 + for snippet in snippets: + cursor = source.find(snippet, cursor + 1) + assert cursor != -1, f"Missing or out-of-order snippet: {snippet}" + + +def test_user_message_persisted_event_contract() -> None: + """Verify the nonterminal SSE event has the minimal persistence contract.""" + payload = build_user_message_persisted_stream_payload( + "conversation-1", + "conversation-1_user_1", + ) + assert payload == { + "type": USER_MESSAGE_PERSISTED_EVENT_TYPE, + "conversation_id": "conversation-1", + "user_message_id": "conversation-1_user_1", + "message_persisted": True, + } + + event_text = build_user_message_persisted_stream_event( + "conversation-1", + "conversation-1_user_1", + ) + assert event_text.endswith("\n\n") + assert json.loads(event_text.removeprefix("data: ").strip()) == payload + + +def test_all_streaming_paths_acknowledge_persistence_early() -> None: + """Verify each server path emits after storage and before assistant work.""" + chat_source = read_text(CHAT_ROUTE) + collaboration_source = read_text(COLLABORATION_ROUTE) + + document_action_source = source_between( + chat_source, + "def execute_document_action_chat_request(", + "@bp.route('/api/chat/document-action', methods=['POST'])", + ) + assert_ordered( + document_action_source, + "cosmos_messages_container.upsert_item(user_message_doc)", + "publish_background_event(", + "build_user_message_persisted_stream_event(", + "_initialize_assistant_response_tracking(", + ) + + legacy_chat_source = source_between( + chat_source, + "def chat_api():", + "@bp.route('/api/chat/stream', methods=['POST'])", + ) + assert_ordered( + legacy_chat_source, + "cosmos_messages_container.upsert_item(user_message_doc)", + "publish_background_event(", + "build_user_message_persisted_stream_event(", + "# Log chat activity for real-time tracking", + ) + + stream_route_source = chat_source[chat_source.find("@bp.route('/api/chat/stream', methods=['POST'])"):] + assert "def generate_compatibility_response(publish_background_event=None):" in stream_route_source + assert "g.chat_publish_background_event = publish_background_event" in stream_route_source + assert "legacy_result = chat_api()" in stream_route_source + assert_ordered( + stream_route_source, + "cosmos_messages_container.upsert_item(user_message_doc)", + "yield build_user_message_persisted_stream_event(", + "_initialize_assistant_response_tracking(", + ) + + collaboration_stream_source = source_between( + collaboration_source, + "def stream_collaboration_message_api(conversation_id):", + "@bp.route('/api/collaboration/conversations//stream/cancel'", + ) + assert_ordered( + collaboration_stream_source, + "persist_collaboration_message(", + "yield build_user_message_persisted_stream_event(", + "current_app.view_functions.get('chat_stream_api')", + ) + assert "if stream_payload.get('type') == USER_MESSAGE_PERSISTED_EVENT_TYPE:" in collaboration_stream_source + + +def test_browser_reconciles_pending_metadata_without_terminal_event() -> None: + """Verify the client handles the persistence event independently of done.""" + streaming_source = read_text(CHAT_STREAMING_JS) + messages_source = read_text(CHAT_MESSAGES_JS) + collaboration_source = read_text(CHAT_COLLABORATION_JS) + + assert "export function applyStreamingUserMessagePersistence(" in streaming_source + assert "if (data.type === USER_MESSAGE_PERSISTED_EVENT_TYPE)" in streaming_source + assert "const acknowledgedUserMessageId = applyStreamingUserMessagePersistence(" in streaming_source + assert "{ refreshExpandedMetadata: true }" in streaming_source + assert "finalizePendingUserMessageMetadata();" in streaming_source + assert "markInterruptedUserMessageMetadata();" in streaming_source + assert "markUserMessageMetadataFinalizationUnconfirmed(persistedUserMessageId);" in streaming_source + assert "markUserMessageMetadataUnconfirmed(tempUserMessageId);" in streaming_source + assert "persistedUserMessageId = String(data.user_message_id);" in streaming_source + + assert "const currentMessageId = messageDiv.getAttribute('data-message-id') || messageId;" in messages_source + assert "container.dataset.metadataState = 'pending';" in messages_source + assert "renderUserMetadataStatus(container, 'Saving message metadata...');" in messages_source + assert "loadUserMessageMetadata(realId, metadataContainer);" in messages_source + assert "export function markUserMessageMetadataUnconfirmed(messageId)" in messages_source + assert "export function markUserMessageMetadataFinalizationUnconfirmed(messageId)" in messages_source + assert "container.dataset.metadataRequestToken !== requestToken" in messages_source + assert "container.dataset.metadataRequestToken === requestToken" in messages_source + assert "export function setUserMessageStreamingActionsDisabled(messageId, disabled)" in messages_source + assert "isUserMessageStreamingActionDisabled(e.currentTarget)" in messages_source + assert "isUserMessageStreamingActionDisabled(addButton)" in messages_source + assert "isUserMessageStreamingActionDisabled(removeButton)" in messages_source + assert "initialPersistedUserMessageId: persistedUserMessageId" in streaming_source + assert "setUserMessageStreamingActionsDisabled(persistedUserMessageId, false);" in streaming_source + assert "setUserMessageStreamingActionsDisabled(payload.message.id, false);" in collaboration_source + assert "messageKind !== 'ai_request'" in collaboration_source + assert "Message metadata unavailable (temporary ID not updated)." not in messages_source + + +def test_implementation_version() -> None: + """Verify the application version includes this fix.""" + assert_app_version_at_least("0.250.202") + if __name__ == "__main__": - success = test_message_metadata_loading_fix() - sys.exit(0 if success else 1) + tests = [ + test_user_message_persisted_event_contract, + test_all_streaming_paths_acknowledge_persistence_early, + test_browser_reconciles_pending_metadata_without_terminal_event, + test_implementation_version, + ] + results = [] + for test in tests: + try: + test() + print(f"PASS: {test.__name__}") + results.append(True) + except Exception as exc: + print(f"FAIL: {test.__name__}: {exc}") + results.append(False) + + raise SystemExit(0 if all(results) else 1) diff --git a/ui_tests/test_chat_user_message_metadata_during_stream.py b/ui_tests/test_chat_user_message_metadata_during_stream.py new file mode 100644 index 000000000..7628ba903 --- /dev/null +++ b/ui_tests/test_chat_user_message_metadata_during_stream.py @@ -0,0 +1,731 @@ +# test_chat_user_message_metadata_during_stream.py +""" +UI test for user-message metadata during assistant streaming. +Version: 0.250.202 +Implemented in: 0.250.202 + +This test ensures an expanded temporary user-message drawer loads persisted +metadata as soon as the stream acknowledges storage, while the AI remains active. +""" + +from contextlib import contextmanager +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import socket +from threading import Thread + +import pytest + +from functional_tests.test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +HARNESS_PATH = "ui_tests/fixtures/chat_thought_progress_harness.html" + + +def _get_free_local_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@contextmanager +def _start_static_test_server(): + port = _get_free_local_port() + handler = partial(SimpleHTTPRequestHandler, directory=str(REPO_ROOT)) + server = ThreadingHTTPServer(("127.0.0.1", port), handler) + server.daemon_threads = True + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + yield f"http://127.0.0.1:{port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.mark.ui +def test_user_metadata_loads_before_assistant_stream_finishes(playwright): + """Reconcile a temp user ID and load its open metadata drawer mid-stream.""" + assert_app_version_at_least("0.250.202") + browser = playwright.chromium.launch() + context = browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + + try: + with _start_static_test_server() as server_base_url: + response = page.goto( + f"{server_base_url}/{HARNESS_PATH}", + wait_until="domcontentloaded", + ) + assert response is not None and response.ok + + snapshots = page.evaluate( + r""" + async () => { + window.appSettings = { + enable_thoughts: true, + enable_text_to_speech: false, + documentActionCapabilities: {}, + }; + window.enable_document_classification = false; + window.currentConversationId = 'metadata-stream-conversation'; + window.marked = { parse: value => String(value || '') }; + window.DOMPurify = { sanitize: value => String(value || '') }; + window.scrollChatToBottom = () => {}; + + const root = document.getElementById('test-root'); + root.innerHTML = ` +
+ + + +
+ + `; + + const tempUserMessageId = 'temp_user_metadata_stream'; + const persistedUserMessageId = 'metadata-stream-conversation_user_1'; + const metadataRequests = []; + const metadataResponseCounts = {}; + const encoder = new TextEncoder(); + let streamController = null; + let reattachController = null; + let recoveryMode = false; + let manualReattachMode = false; + + window.fetch = (url, options = {}) => { + const requestUrl = String(url); + if (requestUrl === '/api/chat/stream') { + const body = new ReadableStream({ + start(controller) { + streamController = controller; + options.signal?.addEventListener('abort', () => { + controller.error(new DOMException('Aborted', 'AbortError')); + }, { once: true }); + }, + }); + return Promise.resolve(new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + })); + } + + if (requestUrl.includes('/api/chat/stream/status/')) { + return Promise.resolve(new Response(JSON.stringify({ + pending: recoveryMode || manualReattachMode, + reattachable: recoveryMode || manualReattachMode, + status: recoveryMode || manualReattachMode ? 'running' : 'idle', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + } + + if (requestUrl.includes('/api/chat/stream/reattach/')) { + if (manualReattachMode) { + const body = new ReadableStream({ + start(controller) { + reattachController = controller; + }, + }); + return Promise.resolve(new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + })); + } + return Promise.resolve(new Response(JSON.stringify({ + error: 'No active stream is available.', + }), { + status: recoveryMode ? 404 : 200, + headers: { 'Content-Type': 'application/json' }, + })); + } + + if ( + requestUrl.startsWith('/api/message/') + && requestUrl.endsWith('/metadata') + ) { + const messageId = requestUrl.slice( + '/api/message/'.length, + -'/metadata'.length + ); + metadataRequests.push(requestUrl); + metadataResponseCounts[messageId] = ( + metadataResponseCounts[messageId] || 0 + ) + 1; + if ( + messageId === 'metadata-stream-conversation_user_retry' + && metadataResponseCounts[messageId] === 1 + ) { + return Promise.resolve(new Response(JSON.stringify({ + error: 'Message not found yet.', + }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + })); + } + return Promise.resolve(new Response(JSON.stringify({ + message_details: { + message_id: messageId, + conversation_id: 'metadata-stream-conversation', + role: 'user', + display_role: metadataResponseCounts[messageId] > 1 + ? 'Finalized' + : null, + timestamp: '2026-08-14T16:03:45Z', + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + } + + return Promise.resolve(new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + }; + + const messagesModule = await import('/application/single_app/static/js/chat/chat-messages.js'); + const streamingModule = await import('/application/single_app/static/js/chat/chat-streaming.js'); + const mutatingActionsDisabled = messageElement => { + const messageId = messageElement.getAttribute('data-message-id'); + const actions = Array.from(document.querySelectorAll( + '.dropdown-edit-btn, .dropdown-delete-btn, .dropdown-retry-btn, .mask-add-btn, .mask-remove-btn' + )).filter(action => action.getAttribute('data-message-id') === messageId); + return actions.length === 5 && actions.every(action => ( + action.dataset.streamingDisabled === 'true' + && action.getAttribute('aria-disabled') === 'true' + && (!(action instanceof HTMLButtonElement) || action.disabled) + )); + }; + + messagesModule.appendMessage( + 'You', + 'Inspect this message while the assistant is still running.', + null, + tempUserMessageId + ); + streamingModule.sendMessageWithStreaming( + { + message: 'Inspect this message while the assistant is still running.', + conversation_id: 'metadata-stream-conversation', + }, + tempUserMessageId, + 'metadata-stream-conversation', + { allowRecovery: false } + ); + + await new Promise(resolve => setTimeout(resolve, 75)); + const pendingMessage = document.querySelector( + `[data-message-id="${tempUserMessageId}"]` + ); + pendingMessage.querySelector('.metadata-toggle-btn').click(); + await new Promise(resolve => setTimeout(resolve, 25)); + + const pendingContainer = pendingMessage.querySelector('.metadata-container'); + const beforePersistence = { + messageId: pendingMessage.getAttribute('data-message-id'), + metadataState: pendingContainer.dataset.metadataState || '', + textContent: pendingContainer.textContent || '', + assistantActive: Boolean(document.querySelector('[data-message-id^="temp_ai_"]')), + metadataRequestCount: metadataRequests.length, + }; + + streamController.enqueue(encoder.encode( + `data: ${JSON.stringify({ + type: 'user_message_persisted', + conversation_id: 'metadata-stream-conversation', + user_message_id: persistedUserMessageId, + message_persisted: true, + })}\n\n` + )); + await new Promise(resolve => setTimeout(resolve, 100)); + + const persistedMessage = document.querySelector( + `[data-message-id="${persistedUserMessageId}"]` + ); + const persistedContainer = persistedMessage.querySelector('.metadata-container'); + const metadataButton = persistedMessage.querySelector('.metadata-toggle-btn'); + const afterPersistence = { + temporaryMessageExists: Boolean(document.querySelector( + `[data-message-id="${tempUserMessageId}"]` + )), + messageId: persistedMessage.getAttribute('data-message-id'), + buttonMessageId: metadataButton.getAttribute('data-message-id'), + controlledContainerId: metadataButton.getAttribute('aria-controls'), + containerId: persistedContainer.id, + metadataState: persistedContainer.dataset.metadataState || '', + textContent: persistedContainer.textContent || '', + assistantActive: Boolean(document.querySelector('[data-message-id^="temp_ai_"]')), + mutatingActionsDisabled: mutatingActionsDisabled(persistedMessage), + metadataRequests: [...metadataRequests], + }; + + const actionDropdownToggle = persistedMessage.querySelector( + ".message-footer .dropdown button[data-bs-toggle='dropdown']" + ); + const actionDropdownMenu = persistedMessage.querySelector( + '.message-footer .dropdown-menu' + ); + actionDropdownToggle.dispatchEvent(new Event('show.bs.dropdown')); + const actionMenuReparented = actionDropdownMenu.parentElement?.id === 'chatbox'; + metadataButton.click(); + streamController.enqueue(encoder.encode( + `data: ${JSON.stringify({ + done: true, + conversation_id: 'metadata-stream-conversation', + user_message_id: persistedUserMessageId, + message_id: 'metadata-stream-assistant-1', + full_content: 'Completed response.', + role: 'assistant', + model_deployment_name: 'gpt-4o', + augmented: false, + hybrid_citations: [], + web_search_citations: [], + agent_citations: [], + metadata: {}, + })}\n\n` + )); + streamController.close(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const finalizedContainer = persistedMessage.querySelector('.metadata-container'); + const afterTerminalWhileHidden = { + metadataState: finalizedContainer.dataset.metadataState || '', + isHidden: finalizedContainer.style.display === 'none', + metadataRequests: [...metadataRequests], + actionMenuReparented, + actionMenuStillExternal: actionDropdownMenu.parentElement?.id === 'chatbox', + mutatingActionsDisabled: mutatingActionsDisabled(persistedMessage), + }; + actionDropdownToggle.dispatchEvent(new Event('hidden.bs.dropdown')); + metadataButton.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + const afterCompletion = { + metadataState: finalizedContainer.dataset.metadataState || '', + textContent: finalizedContainer.textContent || '', + metadataRequests: [...metadataRequests], + assistantActive: Boolean(document.querySelector('[data-message-id^="temp_ai_"]')), + mutatingActionsDisabled: mutatingActionsDisabled(persistedMessage), + actionMenuReturned: actionDropdownMenu.closest( + `[data-message-id="${persistedUserMessageId}"]` + ) === persistedMessage, + }; + + manualReattachMode = true; + await streamingModule.reattachStreamingConversation( + 'metadata-stream-conversation', + { statusLabel: 'Reconnecting metadata stream' } + ); + await new Promise(resolve => setTimeout(resolve, 50)); + reattachController.enqueue(encoder.encode( + `data: ${JSON.stringify({ + type: 'user_message_persisted', + conversation_id: 'metadata-stream-conversation', + user_message_id: persistedUserMessageId, + message_persisted: true, + })}\n\n` + )); + await new Promise(resolve => setTimeout(resolve, 50)); + const manualReattachActive = { + mutatingActionsDisabled: mutatingActionsDisabled(persistedMessage), + }; + reattachController.enqueue(encoder.encode( + `data: ${JSON.stringify({ + done: true, + conversation_id: 'metadata-stream-conversation', + user_message_id: persistedUserMessageId, + message_id: 'metadata-stream-assistant-reattached', + full_content: 'Reattached response complete.', + role: 'assistant', + model_deployment_name: 'gpt-4o', + augmented: false, + hybrid_citations: [], + web_search_citations: [], + agent_citations: [], + metadata: {}, + })}\n\n` + )); + reattachController.close(); + await new Promise(resolve => setTimeout(resolve, 100)); + manualReattachMode = false; + const manualReattachCompleted = { + metadataState: finalizedContainer.dataset.metadataState || '', + textContent: finalizedContainer.textContent || '', + metadataRequests: metadataRequests.filter( + requestUrl => requestUrl.includes(persistedUserMessageId) + ), + mutatingActionsDisabled: mutatingActionsDisabled(persistedMessage), + }; + + const postAckTempMessageId = 'temp_user_metadata_error_after_ack'; + const postAckPersistedMessageId = 'metadata-stream-conversation_user_2'; + messagesModule.appendMessage( + 'You', + 'This message persists before a later stream error.', + null, + postAckTempMessageId + ); + streamingModule.sendMessageWithStreaming( + { + message: 'This message persists before a later stream error.', + conversation_id: 'metadata-stream-conversation', + }, + postAckTempMessageId, + 'metadata-stream-conversation', + { allowRecovery: false } + ); + await new Promise(resolve => setTimeout(resolve, 50)); + const postAckPendingMessage = document.querySelector( + `[data-message-id="${postAckTempMessageId}"]` + ); + postAckPendingMessage.querySelector('.metadata-toggle-btn').click(); + streamController.enqueue(encoder.encode( + `data: ${JSON.stringify({ + type: 'user_message_persisted', + conversation_id: 'metadata-stream-conversation', + user_message_id: postAckPersistedMessageId, + message_persisted: true, + })}\n\n` + )); + await new Promise(resolve => setTimeout(resolve, 75)); + streamController.enqueue(encoder.encode( + 'data: {"error":"Generation failed after persistence."}\n\n' + )); + streamController.close(); + await new Promise(resolve => setTimeout(resolve, 100)); + const postAckMessage = document.querySelector( + `[data-message-id="${postAckPersistedMessageId}"]` + ); + const postAckContainer = postAckMessage.querySelector('.metadata-container'); + const postAckError = { + metadataState: postAckContainer.dataset.metadataState || '', + textContent: postAckContainer.textContent || '', + metadataRequests: metadataRequests.filter( + requestUrl => requestUrl.includes(postAckPersistedMessageId) + ), + mutatingActionsDisabled: mutatingActionsDisabled(postAckMessage), + }; + + const retryTempMessageId = 'temp_user_metadata_retry'; + const retryPersistedMessageId = 'metadata-stream-conversation_user_retry'; + messagesModule.appendMessage( + 'You', + 'This metadata request retries while terminal metadata refreshes.', + null, + retryTempMessageId + ); + streamingModule.sendMessageWithStreaming( + { + message: 'This metadata request retries while terminal metadata refreshes.', + conversation_id: 'metadata-stream-conversation', + }, + retryTempMessageId, + 'metadata-stream-conversation', + { allowRecovery: false } + ); + await new Promise(resolve => setTimeout(resolve, 50)); + const retryPendingMessage = document.querySelector( + `[data-message-id="${retryTempMessageId}"]` + ); + retryPendingMessage.querySelector('.metadata-toggle-btn').click(); + streamController.enqueue(encoder.encode( + `data: ${JSON.stringify({ + type: 'user_message_persisted', + conversation_id: 'metadata-stream-conversation', + user_message_id: retryPersistedMessageId, + message_persisted: true, + })}\n\n` + )); + await new Promise(resolve => setTimeout(resolve, 50)); + streamController.enqueue(encoder.encode( + `data: ${JSON.stringify({ + done: true, + conversation_id: 'metadata-stream-conversation', + user_message_id: retryPersistedMessageId, + message_id: 'metadata-stream-assistant-retry', + full_content: 'Completed retry response.', + role: 'assistant', + model_deployment_name: 'gpt-4o', + augmented: false, + hybrid_citations: [], + web_search_citations: [], + agent_citations: [], + metadata: {}, + })}\n\n` + )); + streamController.close(); + await new Promise(resolve => setTimeout(resolve, 650)); + const retryMessage = document.querySelector( + `[data-message-id="${retryPersistedMessageId}"]` + ); + const retryContainer = retryMessage.querySelector('.metadata-container'); + const staleRetry = { + metadataState: retryContainer.dataset.metadataState || '', + textContent: retryContainer.textContent || '', + metadataRequests: metadataRequests.filter( + requestUrl => requestUrl.includes(retryPersistedMessageId) + ), + mutatingActionsDisabled: mutatingActionsDisabled(retryMessage), + }; + + const detachedTempMessageId = 'temp_user_metadata_detached'; + const detachedPersistedMessageId = 'metadata-stream-conversation_user_3'; + messagesModule.appendMessage( + 'You', + 'This persisted message detaches before terminal enrichment.', + null, + detachedTempMessageId + ); + streamingModule.sendMessageWithStreaming( + { + message: 'This persisted message detaches before terminal enrichment.', + conversation_id: 'metadata-stream-conversation', + }, + detachedTempMessageId, + 'metadata-stream-conversation', + { allowRecovery: false } + ); + await new Promise(resolve => setTimeout(resolve, 50)); + const detachedPendingMessage = document.querySelector( + `[data-message-id="${detachedTempMessageId}"]` + ); + detachedPendingMessage.querySelector('.metadata-toggle-btn').click(); + streamController.enqueue(encoder.encode( + `data: ${JSON.stringify({ + type: 'user_message_persisted', + conversation_id: 'metadata-stream-conversation', + user_message_id: detachedPersistedMessageId, + message_persisted: true, + })}\n\n` + )); + await new Promise(resolve => setTimeout(resolve, 75)); + streamingModule.sendMessageWithStreaming( + { + message: 'Replace the detached response.', + conversation_id: 'metadata-stream-conversation', + }, + null, + 'metadata-stream-conversation', + { allowRecovery: false } + ); + await new Promise(resolve => setTimeout(resolve, 75)); + const detachedMessage = document.querySelector( + `[data-message-id="${detachedPersistedMessageId}"]` + ); + const detachedContainer = detachedMessage.querySelector('.metadata-container'); + const detachedStream = { + metadataState: detachedContainer.dataset.metadataState || '', + textContent: detachedContainer.textContent || '', + metadataRequests: metadataRequests.filter( + requestUrl => requestUrl.includes(detachedPersistedMessageId) + ), + mutatingActionsDisabled: mutatingActionsDisabled(detachedMessage), + }; + + const recoveryTempMessageId = 'temp_user_metadata_recovery'; + const recoveryPersistedMessageId = 'metadata-stream-conversation_user_recovery'; + messagesModule.appendMessage( + 'You', + 'This persisted message fails during stream reattachment.', + null, + recoveryTempMessageId + ); + streamingModule.sendMessageWithStreaming( + { + message: 'This persisted message fails during stream reattachment.', + conversation_id: 'metadata-stream-conversation', + }, + recoveryTempMessageId, + 'metadata-stream-conversation', + { allowRecovery: true } + ); + await new Promise(resolve => setTimeout(resolve, 50)); + const recoveryPendingMessage = document.querySelector( + `[data-message-id="${recoveryTempMessageId}"]` + ); + recoveryPendingMessage.querySelector('.metadata-toggle-btn').click(); + streamController.enqueue(encoder.encode( + `data: ${JSON.stringify({ + type: 'user_message_persisted', + conversation_id: 'metadata-stream-conversation', + user_message_id: recoveryPersistedMessageId, + message_persisted: true, + })}\n\n` + )); + await new Promise(resolve => setTimeout(resolve, 75)); + recoveryMode = true; + streamController.error(new Error('Simulated network interruption.')); + await new Promise(resolve => setTimeout(resolve, 200)); + recoveryMode = false; + const recoveryMessage = document.querySelector( + `[data-message-id="${recoveryPersistedMessageId}"]` + ); + const recoveryContainer = recoveryMessage.querySelector('.metadata-container'); + const failedRecovery = { + metadataState: recoveryContainer.dataset.metadataState || '', + textContent: recoveryContainer.textContent || '', + metadataRequests: metadataRequests.filter( + requestUrl => requestUrl.includes(recoveryPersistedMessageId) + ), + mutatingActionsDisabled: mutatingActionsDisabled(recoveryMessage), + }; + + const failedTempMessageId = 'temp_user_metadata_failed'; + messagesModule.appendMessage( + 'You', + 'This message fails before persistence.', + null, + failedTempMessageId + ); + streamingModule.sendMessageWithStreaming( + { + message: 'This message fails before persistence.', + conversation_id: 'metadata-stream-conversation', + }, + failedTempMessageId, + 'metadata-stream-conversation', + { allowRecovery: false } + ); + await new Promise(resolve => setTimeout(resolve, 50)); + const failedMessage = document.querySelector( + `[data-message-id="${failedTempMessageId}"]` + ); + failedMessage.querySelector('.metadata-toggle-btn').click(); + streamController.enqueue(encoder.encode( + 'data: {"error":"Validation failed before persistence."}\n\n' + )); + streamController.close(); + await new Promise(resolve => setTimeout(resolve, 75)); + const failedContainer = failedMessage.querySelector('.metadata-container'); + const failedPersistence = { + metadataState: failedContainer.dataset.metadataState || '', + textContent: failedContainer.textContent || '', + metadataRequestCount: metadataRequests.filter( + requestUrl => requestUrl.includes(failedTempMessageId) + ).length, + mutatingActionsDisabled: mutatingActionsDisabled(failedMessage), + }; + + return { + beforePersistence, + afterPersistence, + afterTerminalWhileHidden, + afterCompletion, + manualReattachActive, + manualReattachCompleted, + postAckError, + staleRetry, + detachedStream, + failedRecovery, + failedPersistence, + }; + } + """ + ) + + before = snapshots["beforePersistence"] + assert before["messageId"] == "temp_user_metadata_stream" + assert before["metadataState"] == "pending" + assert "Saving message metadata..." in before["textContent"] + assert "temporary ID not updated" not in before["textContent"] + assert before["assistantActive"] is True + assert before["metadataRequestCount"] == 0 + + after = snapshots["afterPersistence"] + assert after["temporaryMessageExists"] is False + assert after["messageId"] == "metadata-stream-conversation_user_1" + assert after["buttonMessageId"] == "metadata-stream-conversation_user_1" + assert after["controlledContainerId"] == after["containerId"] + assert "metadata-stream-conversation_user_1" in after["containerId"] + assert after["metadataState"] == "loaded" + assert "Message Details" in after["textContent"] + assert "metadata-stream-conversation_user_1" in after["textContent"] + assert after["assistantActive"] is True + assert after["mutatingActionsDisabled"] is True + assert after["metadataRequests"] == [ + "/api/message/metadata-stream-conversation_user_1/metadata" + ] + + hidden = snapshots["afterTerminalWhileHidden"] + assert hidden["metadataState"] == "stale" + assert hidden["isHidden"] is True + assert hidden["metadataRequests"] == [ + "/api/message/metadata-stream-conversation_user_1/metadata" + ] + assert hidden["actionMenuReparented"] is True + assert hidden["actionMenuStillExternal"] is True + assert hidden["mutatingActionsDisabled"] is False + + completed = snapshots["afterCompletion"] + assert completed["metadataState"] == "loaded" + assert "Finalized" in completed["textContent"] + assert completed["metadataRequests"] == [ + "/api/message/metadata-stream-conversation_user_1/metadata", + "/api/message/metadata-stream-conversation_user_1/metadata", + ] + assert completed["assistantActive"] is False + assert completed["mutatingActionsDisabled"] is False + assert completed["actionMenuReturned"] is True + + manual_active = snapshots["manualReattachActive"] + assert manual_active["mutatingActionsDisabled"] is True + + manual_completed = snapshots["manualReattachCompleted"] + assert manual_completed["metadataState"] == "loaded" + assert "Finalized" in manual_completed["textContent"] + assert manual_completed["metadataRequests"] == [ + "/api/message/metadata-stream-conversation_user_1/metadata", + "/api/message/metadata-stream-conversation_user_1/metadata", + "/api/message/metadata-stream-conversation_user_1/metadata", + ] + assert manual_completed["mutatingActionsDisabled"] is False + + post_ack_error = snapshots["postAckError"] + assert post_ack_error["metadataState"] == "loaded" + assert "Finalized" in post_ack_error["textContent"] + assert post_ack_error["metadataRequests"] == [ + "/api/message/metadata-stream-conversation_user_2/metadata", + "/api/message/metadata-stream-conversation_user_2/metadata", + ] + assert post_ack_error["mutatingActionsDisabled"] is False + + stale_retry = snapshots["staleRetry"] + assert stale_retry["metadataState"] == "loaded" + assert "Finalized" in stale_retry["textContent"] + assert stale_retry["metadataRequests"] == [ + "/api/message/metadata-stream-conversation_user_retry/metadata", + "/api/message/metadata-stream-conversation_user_retry/metadata", + ] + assert stale_retry["mutatingActionsDisabled"] is False + + detached = snapshots["detachedStream"] + assert detached["metadataState"] == "finalization-unconfirmed" + assert "may still be updating" in detached["textContent"] + assert detached["metadataRequests"] == [ + "/api/message/metadata-stream-conversation_user_3/metadata" + ] + assert detached["mutatingActionsDisabled"] is True + + recovery = snapshots["failedRecovery"] + assert recovery["metadataState"] == "finalization-unconfirmed" + assert "may still be updating" in recovery["textContent"] + assert recovery["metadataRequests"] == [ + "/api/message/metadata-stream-conversation_user_recovery/metadata" + ] + assert recovery["mutatingActionsDisabled"] is True + + failed = snapshots["failedPersistence"] + assert failed["metadataState"] == "unconfirmed" + assert "persistence could not be confirmed" in failed["textContent"] + assert "Saving message metadata..." not in failed["textContent"] + assert failed["metadataRequestCount"] == 0 + assert failed["mutatingActionsDisabled"] is True + finally: + context.close() + browser.close()