From 2641781db809376764e1a48753734e6bba7291ac Mon Sep 17 00:00:00 2001 From: Joshua Wilshere Date: Thu, 24 Sep 2026 01:04:14 +0000 Subject: [PATCH 1/6] fix: harden shared collaboration event delivery Route shared conversation events through the Cosmos fallback when Redis is unavailable, with conflict-safe event appends across application workers. Keep EventSource reconnect events visible, isolate optional M365 projection failures from the collaboration stream, and avoid unrelated pending-action refresh warnings during normal shared prompts. Improve collaboration conversation/sidebar synchronization, restore initial M365 action loading, and add regression coverage for cross-worker delivery and concurrent Cosmos appends. --- application/single_app/app_settings_cache.py | 168 ++++++++++++++---- application/single_app/config.py | 2 +- .../single_app/route_backend_collaboration.py | 13 +- .../static/js/chat/chat-collaboration.js | 62 ++----- .../static/js/chat/chat-conversations.js | 89 ++++++++-- .../js/chat/chat-sidebar-conversations.js | 53 ++++++ .../static/js/chat/chat-streaming.js | 10 +- .../static/js/m365-pending-actions.js | 37 +++- .../COLLABORATION_SHARED_AI_WORKFLOW_FIX.md | 27 +++ docs/explanation/release_notes.md | 99 +++++++++++ .../test_collaboration_shared_ai_workflow.py | 34 +++- .../test_cosmos_wave1_cache_fallback.py | 67 ++++++- .../test_m365_pending_action_cards.js | 4 +- 13 files changed, 554 insertions(+), 111 deletions(-) diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py index 864161d4d..8ccd89c45 100644 --- a/application/single_app/app_settings_cache.py +++ b/application/single_app/app_settings_cache.py @@ -14,6 +14,7 @@ from datetime import datetime, timedelta from typing import Callable +from azure.core import MatchConditions from azure.core.exceptions import AzureError from azure.cosmos import ContainerProxy from redis import Redis @@ -49,6 +50,7 @@ class AppCacheDependencies: CACHE_VERSION_READ_TTL_SECONDS = 15 COSMOS_CACHE_ENTRY_DOC_TYPE = 'app_cache_entry' COSMOS_CACHE_ENTRY_PREFIX = 'app_cache_entry:' +STREAM_SESSION_APPEND_MAX_ATTEMPTS = 5 update_settings_cache = None get_settings_cache = None get_app_settings_cache_version = None @@ -375,13 +377,18 @@ def _get_stream_session_events_key(cache_key): def _initialize_stream_session_cache_fallback(cache_key, metadata, ttl_seconds=None, log_event_func=None): expiration_timestamp = _get_expiration_timestamp(ttl_seconds) + existing_events = _get_cosmos_cache_entry( + _get_stream_session_events_key(cache_key), + log_event_func=log_event_func, + ) + events_payload = list(existing_events) if isinstance(existing_events, list) else [] with _app_cache_lock: APP_STREAM_SESSION_METADATA[cache_key] = { 'value': copy.deepcopy(metadata or {}), 'expires_at': expiration_timestamp, } APP_STREAM_SESSION_EVENTS[cache_key] = { - 'value': [], + 'value': events_payload, 'expires_at': expiration_timestamp, } _set_cosmos_cache_entry( @@ -390,12 +397,30 @@ def _initialize_stream_session_cache_fallback(cache_key, metadata, ttl_seconds=N ttl_seconds=ttl_seconds, log_event_func=log_event_func, ) - _set_cosmos_cache_entry( - _get_stream_session_events_key(cache_key), - [], - ttl_seconds=ttl_seconds, - log_event_func=log_event_func, - ) + if existing_events is None: + now = datetime.utcnow() + expires_at = None + if ttl_seconds is not None: + expires_at = now + timedelta(seconds=max(int(ttl_seconds), 0)) + try: + _get_cosmos_cache_container().create_item({ + 'id': _build_cosmos_cache_doc_id(_get_stream_session_events_key(cache_key)), + 'type': COSMOS_CACHE_ENTRY_DOC_TYPE, + 'cache_key': _get_stream_session_events_key(cache_key), + 'payload': [], + 'created_at': now.isoformat(), + 'updated_at': now.isoformat(), + 'expires_at': _serialize_datetime(expires_at), + }) + except AzureError as ex: + if getattr(ex, 'status_code', None) != 409: + _logger.warning("[ASC] Cosmos stream event initialization failed for %s: %s", cache_key, ex) + if callable(log_event_func): + log_event_func( + "[ASC] Cosmos stream event initialization failed.", + extra={'cache_key': cache_key, 'error': str(ex)}, + level=logging.WARNING, + ) def _set_stream_session_meta_fallback(cache_key, metadata, ttl_seconds=None, log_event_func=None): @@ -430,37 +455,94 @@ def _get_stream_session_meta_fallback(cache_key, log_event_func=None): def _append_stream_session_event_fallback(cache_key, event_text, ttl_seconds=None, log_event_func=None): expiration_timestamp = _get_expiration_timestamp(ttl_seconds) + cache_entry_key = _get_stream_session_events_key(cache_key) + doc_id = _build_cosmos_cache_doc_id(cache_entry_key) + container = _get_cosmos_cache_container() + events_payload = None + + for _ in range(STREAM_SESSION_APPEND_MAX_ATTEMPTS): + now = datetime.utcnow() + expires_at = None + if ttl_seconds is not None: + expires_at = now + timedelta(seconds=max(int(ttl_seconds), 0)) + try: + document = container.read_item(item=doc_id, partition_key=doc_id) + events_payload = list(document.get('payload') or []) + events_payload.append(event_text) + document['payload'] = events_payload + document['updated_at'] = now.isoformat() + document['expires_at'] = _serialize_datetime(expires_at) + container.replace_item( + item=doc_id, + body=document, + etag=document.get('_etag'), + match_condition=MatchConditions.IfNotModified, + ) + except AzureError as ex: + status_code = getattr(ex, 'status_code', None) + if status_code in {404, 409, 412}: + if status_code == 404: + try: + container.create_item({ + 'id': doc_id, + 'type': COSMOS_CACHE_ENTRY_DOC_TYPE, + 'cache_key': cache_entry_key, + 'payload': [event_text], + 'created_at': now.isoformat(), + 'updated_at': now.isoformat(), + 'expires_at': _serialize_datetime(expires_at), + }) + except AzureError as create_error: + if getattr(create_error, 'status_code', None) in {409, 412}: + continue + ex = create_error + else: + events_payload = [event_text] + break + else: + continue + _logger.warning("[ASC] Cosmos stream event append failed for %s: %s", cache_key, ex) + if callable(log_event_func): + log_event_func( + "[ASC] Cosmos stream event append failed.", + extra={'cache_key': cache_key, 'error': str(ex), 'status_code': status_code}, + level=logging.WARNING, + ) + break + else: + break + else: + events_payload = None + + if not isinstance(events_payload, list): + with _app_cache_lock: + entry = APP_STREAM_SESSION_EVENTS.get(cache_key) + events_payload = list(entry.get('value') or []) if entry and not _is_expired(entry) else [] + events_payload.append(event_text) + with _app_cache_lock: - entry = APP_STREAM_SESSION_EVENTS.get(cache_key) - if _is_expired(entry): - entry = {'value': [], 'expires_at': expiration_timestamp} - APP_STREAM_SESSION_EVENTS[cache_key] = entry - entry['value'].append(event_text) - if expiration_timestamp is not None: - entry['expires_at'] = expiration_timestamp - events_payload = list(entry.get('value') or []) - _set_cosmos_cache_entry( - _get_stream_session_events_key(cache_key), - events_payload, - ttl_seconds=ttl_seconds, - log_event_func=log_event_func, - ) + APP_STREAM_SESSION_EVENTS[cache_key] = { + 'value': events_payload, + 'expires_at': expiration_timestamp, + } def _get_stream_session_events_fallback(cache_key, start_index=0, log_event_func=None): start = int(start_index or 0) + cached = _get_cosmos_cache_entry( + _get_stream_session_events_key(cache_key), + log_event_func=log_event_func, + ) + if cached is not None: + return list(cached[start:]) + with _app_cache_lock: entry = APP_STREAM_SESSION_EVENTS.get(cache_key) if entry and not _is_expired(entry): return list((entry.get('value') or [])[start:]) if entry: APP_STREAM_SESSION_EVENTS.pop(cache_key, None) - - cached = _get_cosmos_cache_entry( - _get_stream_session_events_key(cache_key), - log_event_func=log_event_func, - ) - return list((cached or [])[start:]) + return [] def _delete_stream_session_cache_fallback(cache_key, log_event_func=None): @@ -949,12 +1031,32 @@ def bump_governance_cache_version_mem(): _set_ttl_cached_version(APP_GOVERNANCE_SHARED_VERSION_CACHE, fallback_version) return fallback_version - initialize_stream_session_cache = initialize_stream_session_cache_mem - set_stream_session_meta = set_stream_session_meta_mem - get_stream_session_meta = get_stream_session_meta_mem - append_stream_session_event = append_stream_session_event_mem - get_stream_session_events = get_stream_session_events_mem - delete_stream_session_cache = delete_stream_session_cache_mem + # Stream sessions must be visible across application workers. The Cosmos + # fallback preserves that contract when Redis is intentionally disabled. + initialize_stream_session_cache = lambda cache_key, metadata, ttl_seconds=None: ( + _initialize_stream_session_cache_fallback( + cache_key, metadata, ttl_seconds=ttl_seconds, log_event_func=log_event, + ) + ) + set_stream_session_meta = lambda cache_key, metadata, ttl_seconds=None: ( + _set_stream_session_meta_fallback( + cache_key, metadata, ttl_seconds=ttl_seconds, log_event_func=log_event, + ) + ) + get_stream_session_meta = lambda cache_key: _get_stream_session_meta_fallback( + cache_key, log_event_func=log_event, + ) + append_stream_session_event = lambda cache_key, event_text, ttl_seconds=None: ( + _append_stream_session_event_fallback( + cache_key, event_text, ttl_seconds=ttl_seconds, log_event_func=log_event, + ) + ) + get_stream_session_events = lambda cache_key, start_index=0: _get_stream_session_events_fallback( + cache_key, start_index=start_index, log_event_func=log_event, + ) + delete_stream_session_cache = lambda cache_key: _delete_stream_session_cache_fallback( + cache_key, log_event_func=log_event, + ) get_user_ui_settings_cache = get_user_ui_settings_cache_mem set_user_ui_settings_cache = set_user_ui_settings_cache_mem delete_user_ui_settings_cache = delete_user_ui_settings_cache_mem diff --git a/application/single_app/config.py b/application/single_app/config.py index ddf9ef5fb..d5f127d2a 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -98,7 +98,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.040" +VERSION = "0.261.052" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py index 40afb72b6..e41878f99 100644 --- a/application/single_app/route_backend_collaboration.py +++ b/application/single_app/route_backend_collaboration.py @@ -309,18 +309,17 @@ def _collaboration_events_for_viewer(events, viewer_user_id, conversation_id): messages = hydrate_m365_pending_action_cards( [payload['message']], viewer_user_id, conversation_id, ) - except (M365PolicyError, PermissionError, AzureError, ValueError) as error: + except Exception as error: log_event( '[COLLABORATION] Shared Microsoft 365 action cards could not be projected.', extra={'conversation_id': conversation_id, 'exception_type': type(error).__name__}, level=logging.WARNING, ) - failure = _build_collaboration_event( - conversation_id, 'collaboration.m365.pending_action', - {'error': 'm365_pending_actions_unavailable'}, - ) - yield f'data: {json.dumps(failure)}\n\n' - return + # M365 card projection is optional. Preserve the collaboration + # message event so one viewer's Graph failure cannot terminate + # their live conversation stream. + yield event_text + continue event = {**event, 'payload': {**payload, 'message': messages[0]}} yield f'data: {json.dumps(make_json_serializable(event))}\n\n' else: diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index 788651e3d..16abde851 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -43,7 +43,6 @@ const sendBtn = document.getElementById('send-btn'); let cachedUserSettingsPromise = null; let activeCollaborativeConversationId = null; let activeCollaborationEventSource = null; -let activeSubscriptionStartedAt = 0; let activeReplyContext = null; let typingUsers = new Map(); let lastTypingState = false; @@ -176,6 +175,8 @@ function setConversationDataset(conversationId, metadata = {}) { element.dataset.currentUserRole = metadata.current_user_role || ''; } }); + + applyConversationMetadataUpdate(conversationId, metadata); } function normalizeCollaborator(rawUser) { @@ -579,37 +580,6 @@ function buildEventKey(eventEnvelope = {}) { ].join('|'); } -function parseCollaborationEventTimestamp(timestamp) { - const normalizedTimestamp = String(timestamp || '').trim(); - if (!normalizedTimestamp) { - return Number.NaN; - } - - if (/(?:Z|[+-]\d{2}:\d{2})$/i.test(normalizedTimestamp)) { - return Date.parse(normalizedTimestamp); - } - - const utcTimestamp = Date.parse(`${normalizedTimestamp}Z`); - if (!Number.isNaN(utcTimestamp)) { - return utcTimestamp; - } - - return Date.parse(normalizedTimestamp); -} - -function isReplayEvent(eventEnvelope = {}) { - if (!activeSubscriptionStartedAt) { - return false; - } - - const occurredAt = parseCollaborationEventTimestamp(eventEnvelope.occurred_at || ''); - if (Number.isNaN(occurredAt)) { - return false; - } - - return occurredAt < (activeSubscriptionStartedAt - 1000); -} - async function getCachedUserSettings() { if (!cachedUserSettingsPromise) { cachedUserSettingsPromise = loadUserSettings().then(settings => settings || {}); @@ -1027,7 +997,6 @@ async function loadConversationMessages(conversationId) { renderCollaborationMessage(decoratedMessage); cacheCollaborationMessage(message); }); - void window.SimpleChatM365PendingActions?.refreshConversation(conversationId); reapplyPendingSearchHighlight(); return messages; } @@ -1060,8 +1029,6 @@ function disconnectConversationEvents() { activeCollaborationEventSource = null; } - activeSubscriptionStartedAt = 0; - if (typingStopHandle) { window.clearTimeout(typingStopHandle); typingStopHandle = null; @@ -1082,6 +1049,17 @@ function handleConversationEvent(eventEnvelope = {}) { return; } + const payload = eventEnvelope.payload || {}; + const eventConversationId = String( + eventEnvelope.conversation_id || payload.conversation_id || payload.message?.conversation_id || '' + ).trim(); + if (eventConversationId && ( + (activeCollaborativeConversationId && eventConversationId !== activeCollaborativeConversationId) + || (window.currentConversationId && eventConversationId !== window.currentConversationId) + )) { + return; + } + const eventKey = buildEventKey(eventEnvelope); if (eventKey && seenCollaborationEventKeys.has(eventKey)) { return; @@ -1090,18 +1068,7 @@ function handleConversationEvent(eventEnvelope = {}) { seenCollaborationEventKeys.add(eventKey); } - if (isReplayEvent(eventEnvelope)) { - return; - } - - const payload = eventEnvelope.payload || {}; if (eventEnvelope.event_type === 'collaboration.m365.pending_action') { - const conversationId = String(eventEnvelope.conversation_id || payload.conversation_id || '').trim(); - if (conversationId && conversationId === activeCollaborativeConversationId - && conversationId === window.currentConversationId - && (!payload.conversation_id || payload.conversation_id === conversationId)) { - void window.SimpleChatM365PendingActions?.refreshConversation(conversationId); - } return; } if (payload.conversation) { @@ -1247,7 +1214,6 @@ function subscribeToConversationEvents(conversationId) { disconnectConversationEvents(); activeCollaborativeConversationId = conversationId; - activeSubscriptionStartedAt = Date.now(); activeCollaborationEventSource = new EventSource(`/api/collaboration/conversations/${encodeURIComponent(conversationId)}/events`); activeCollaborationEventSource.onmessage = event => { if (!event?.data) { @@ -1903,7 +1869,7 @@ async function respondToInvite(conversationId, action) { } if (action === 'accept' && payload.conversation?.id && window.chatConversations?.selectConversation) { - await window.chatConversations.selectConversation(payload.conversation.id); + await window.chatConversations.selectConversation(payload.conversation.id, payload.conversation); } window.hideConversationDetails?.(); diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js index 361adbee1..56f3d471c 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -260,6 +260,56 @@ function updateConversationCache(conversationId, updates = {}) { }); } +function syncConversationAddParticipantsAction(convoItem) { + const dropdownMenu = convoItem?.querySelector('.dropdown-menu'); + if (!dropdownMenu) { + return; + } + + const chatType = convoItem.dataset.chatType || ''; + const isCollaborativeConversation = convoItem.dataset.conversationKind === 'collaborative'; + const canManageMembers = isCollaborativeConversation + ? convoItem.dataset.canManageMembers === 'true' + : ['personal_single_user', 'personal_multi_user', 'group-single-user', 'group_multi_user'].includes(chatType); + const canShowAddParticipants = [ + 'personal_single_user', + 'personal_multi_user', + 'group-single-user', + 'group_multi_user', + ].includes(chatType) && canManageMembers; + const existingAction = dropdownMenu.querySelector('.add-participants-btn'); + + if (!canShowAddParticipants) { + existingAction?.closest('li')?.remove(); + return; + } + + if (existingAction) { + return; + } + + const actionItem = document.createElement('li'); + const actionLink = document.createElement('a'); + actionLink.classList.add('dropdown-item', 'add-participants-btn'); + actionLink.href = '#'; + const actionIcon = document.createElement('i'); + actionIcon.classList.add('bi', 'bi-person-plus', 'me-2'); + actionLink.append(actionIcon, document.createTextNode('Add participants')); + actionLink.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + const dropdownButton = convoItem.querySelector('[data-bs-toggle="dropdown"]'); + closeDropdownMenu(dropdownButton); + window.chatCollaboration?.openParticipantPicker?.({ + conversationId: convoItem.dataset.conversationId, + }); + }); + actionItem.appendChild(actionLink); + + const pinItem = dropdownMenu.querySelector('.pin-btn')?.closest('li'); + dropdownMenu.insertBefore(actionItem, pinItem || null); +} + export function applyConversationMetadataUpdate(conversationId, updates = {}) { if (!conversationId) { return; @@ -328,6 +378,8 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) { convoItem.removeAttribute('data-chat-state'); } + syncConversationAddParticipantsAction(convoItem); + updateConversationCache(conversationId, { title: updates.title, classification: updates.classification, @@ -1514,7 +1566,7 @@ export function addConversationToList(conversationId, title = null, classificati } // Select a conversation, load messages, update UI -export async function selectConversation(conversationId) { +export async function selectConversation(conversationId, metadataOverride = null) { currentConversationId = conversationId; window.currentConversationId = conversationId; notifyConversationContextChanged("select", conversationId); @@ -1533,21 +1585,26 @@ export async function selectConversation(conversationId) { } const conversationTitle = convoItem.getAttribute("data-conversation-title") || "Conversation"; // Use stored title - const isCollaborativeConversation = convoItem.dataset.conversationKind === 'collaborative'; - let metadata = null; + let isCollaborativeConversation = convoItem.dataset.conversationKind === 'collaborative' + || convoItem.dataset.canAcceptInvite === 'true'; + let metadata = metadataOverride; // Fetch the latest conversation metadata to get accurate chat_type, pin, and hide status try { - if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { - metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); - } else { - const response = await fetch(`/api/conversations/${conversationId}/metadata`); - if (response.ok) { - metadata = await response.json(); + if (!metadata) { + if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { + metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); + } else { + const response = await fetch(`/api/conversations/${conversationId}/metadata`); + if (response.ok) { + metadata = await response.json(); + } } } if (metadata) { + isCollaborativeConversation = isCollaborativeConversation + || metadata.conversation_kind === 'collaborative'; // Update Header Title with pin icon and hidden status if (currentConversationTitleEl) { @@ -1659,7 +1716,18 @@ export async function selectConversation(conversationId) { const metaLockedContexts = metadata.locked_contexts || []; restoreScopeLockState(metaScopeLocked, metaLockedContexts); - convoItem.dataset.canManageMembers = metadata.can_manage_members ? 'true' : 'false'; + const metadataIsCollaborative = metadata.conversation_kind === 'collaborative' + || convoItem.dataset.conversationKind === 'collaborative'; + const normalizedChatType = convoItem.dataset.chatType || ''; + const canManageMembers = Object.prototype.hasOwnProperty.call(metadata, 'can_manage_members') + ? Boolean(metadata.can_manage_members) + : (!metadataIsCollaborative && [ + 'personal_single_user', + 'personal_multi_user', + 'group-single-user', + 'group_multi_user', + ].includes(normalizedChatType)); + convoItem.dataset.canManageMembers = canManageMembers ? 'true' : 'false'; convoItem.dataset.canAcceptInvite = metadata.can_accept_invite ? 'true' : 'false'; convoItem.dataset.canPostMessages = metadata.can_post_messages === false ? 'false' : 'true'; if (metadata.membership_status) { @@ -2179,6 +2247,7 @@ export async function createNewConversation(callback, options = {}) { } const { preserveSelections = false, initialMessage = "" } = options; + window.chatCollaboration?.deactivateConversation?.(); if (!preserveSelections) { notifyConversationContextChanged("new", null, { preserveSelections }); } diff --git a/application/single_app/static/js/chat/chat-sidebar-conversations.js b/application/single_app/static/js/chat/chat-sidebar-conversations.js index 6b1dda2a8..5e37d7d1e 100644 --- a/application/single_app/static/js/chat/chat-sidebar-conversations.js +++ b/application/single_app/static/js/chat/chat-sidebar-conversations.js @@ -1064,6 +1064,57 @@ export function updateSidebarConversationTitle(conversationId, newTitle) { } } +function syncSidebarAddParticipantsAction(sidebarItem) { + const dropdownMenu = sidebarItem?.querySelector('.dropdown-menu'); + if (!dropdownMenu) { + return; + } + + const chatType = sidebarItem.dataset.chatType || ''; + const isCollaborativeConversation = sidebarItem.dataset.conversationKind === 'collaborative'; + const canManageMembers = isCollaborativeConversation + ? sidebarItem.dataset.canManageMembers === 'true' + : ['personal_single_user', 'personal_multi_user', 'group-single-user', 'group_multi_user'].includes(chatType); + const canShowAddParticipants = [ + 'personal_single_user', + 'personal_multi_user', + 'group-single-user', + 'group_multi_user', + ].includes(chatType) && canManageMembers; + const existingAction = dropdownMenu.querySelector('.add-participants-btn'); + + if (!canShowAddParticipants) { + existingAction?.closest('li')?.remove(); + return; + } + + if (existingAction) { + return; + } + + const actionItem = document.createElement('li'); + const actionLink = document.createElement('a'); + actionLink.classList.add('dropdown-item', 'add-participants-btn'); + actionLink.href = '#'; + const actionIcon = document.createElement('i'); + actionIcon.classList.add('bi', 'bi-person-plus', 'me-2'); + actionLink.append(actionIcon, document.createTextNode('Add participants')); + actionLink.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + const dropdownButton = sidebarItem.querySelector('[data-bs-toggle="dropdown"]'); + const dropdownInstance = getSidebarConversationDropdownInstance(dropdownButton); + closeSidebarConversationDropdown(dropdownButton, dropdownInstance); + window.chatCollaboration?.openParticipantPicker?.({ + conversationId: sidebarItem.dataset.conversationId, + }); + }); + actionItem.appendChild(actionLink); + + const pinItem = dropdownMenu.querySelector('.pin-btn')?.closest('li'); + dropdownMenu.insertBefore(actionItem, pinItem || null); +} + export function applySidebarConversationMetadataUpdate(conversationId, updates = {}) { const sidebarItem = document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); if (!sidebarItem) { @@ -1106,6 +1157,8 @@ export function applySidebarConversationMetadataUpdate(conversationId, updates = applySidebarConversationContextAttributes(sidebarItem, updates.chat_type || '', updates.context || []); renderSidebarConversationScopeBadge(sidebarItem, updates.chat_type || '', updates.context || []); } + + syncSidebarAddParticipantsAction(sidebarItem); } // Enable inline editing for a conversation title in the sidebar diff --git a/application/single_app/static/js/chat/chat-streaming.js b/application/single_app/static/js/chat/chat-streaming.js index 4c2b4e4f5..cfe84aa58 100644 --- a/application/single_app/static/js/chat/chat-streaming.js +++ b/application/single_app/static/js/chat/chat-streaming.js @@ -703,6 +703,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa let persistedUserMessageId = String(initialPersistedUserMessageId || '').trim() || null; let lastChunkAt = null; let eventCount = 0; + let receivedM365PendingAction = false; const pendingActionConversationEpoch = window.SimpleChatM365PendingActions?.getConversationEpoch(); function finalizePendingUserMessageMetadata() { @@ -736,7 +737,11 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa } function capturePendingActions(data) { - if (data?.type === 'm365_pending_action' || Array.isArray(data?.m365_pending_actions)) { + const hasPendingAction = data?.type === 'm365_pending_action' + || Array.isArray(data?.m365_pending_actions) + || Array.isArray(data?.metadata?.m365_pending_action_ids); + if (hasPendingAction) { + receivedM365PendingAction = true; window.SimpleChatM365PendingActions?.handleChatPayload(data, { conversationId: streamContext.conversationId || recoveryConversationId || data.conversation_id, userMessageId: persistedUserMessageId || tempUserMessageId, @@ -746,6 +751,9 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa } function recoverPendingActions() { + if (!receivedM365PendingAction) { + return; + } void window.SimpleChatM365PendingActions?.refreshConversation( streamContext.conversationId || recoveryConversationId ); diff --git a/application/single_app/static/js/m365-pending-actions.js b/application/single_app/static/js/m365-pending-actions.js index 9bbc6c301..9f7bfdf8e 100644 --- a/application/single_app/static/js/m365-pending-actions.js +++ b/application/single_app/static/js/m365-pending-actions.js @@ -3,6 +3,7 @@ 'use strict'; const apiPath = '/api/msgraph/pending-actions'; + const retryDelaysMs = [1000, 3000, 7000, 15000, 30000, 60000]; const actionableStatuses = new Set(['pending', 'scheduled', 'review_required']); const entries = new Map(); let sequence = 0; @@ -675,6 +676,8 @@ const pendingReferences = new Set(); const referenceFailures = new Map(); let loadPromise = null; + let retryHandle = null; + let retryAttempt = 0; let continuationToken = ''; let disposed = false; let hasError = false; @@ -709,6 +712,18 @@ updateVisibility(); } + function scheduleRetry() { + if (disposed || retryHandle !== null || retryAttempt >= retryDelaysMs.length) { + return; + } + const delay = retryDelaysMs[retryAttempt]; + retryAttempt += 1; + retryHandle = window.setTimeout(() => { + retryHandle = null; + void load(); + }, delay); + } + function place(id) { const view = views.get(id); if (!view || !chatView) { @@ -827,6 +842,7 @@ } payload.pending_actions.forEach(action => add(action, {}, { authoritative: true })); continuationToken = typeof payload.continuation_token === 'string' ? payload.continuation_token : ''; + retryAttempt = 0; more.classList.toggle('d-none', !continuationToken); hasError = false; referenceErrorVisible = false; @@ -848,9 +864,20 @@ notify(entry); } }); - showError(error.status === 403 - ? 'You do not have permission to view outgoing actions for this conversation.' - : 'Outgoing actions could not be loaded. Refresh to recover saved actions; this is not an empty inbox.'); + const hasKnownActionState = views.size > 0 || referenceFailures.size > 0; + if (error.status === 403 || !chatView || hasKnownActionState) { + showError(error.status === 403 + ? 'You do not have permission to view outgoing actions for this conversation.' + : 'Outgoing actions could not be loaded. Refresh to recover saved actions; this is not an empty inbox.'); + } else { + hasError = false; + status.className = 'small text-muted d-none'; + status.textContent = ''; + updateVisibility(); + } + if (error.status !== 403) { + scheduleRetry(); + } } } finally { loadPromise = null; @@ -908,6 +935,10 @@ destroy() { disposed = true; lifecycle.abort(); + if (retryHandle !== null) { + window.clearTimeout(retryHandle); + retryHandle = null; + } views.forEach(view => view.destroy()); views.clear(); root.replaceChildren(); diff --git a/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md b/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md index 33d4f6eec..3ecc8d919 100644 --- a/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md +++ b/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md @@ -2,6 +2,17 @@ Original implementation: **0.241.021** Plain-prompt coverage completed in: **0.261.040** +Conversation lifecycle follow-up completed in: **0.261.041** +Participant menu synchronization follow-up completed in: **0.261.043** +Invite approval message-loading follow-up completed in: **0.261.044** +M365 startup recovery follow-up completed in: **0.261.045** +Unrelated chat warning suppression follow-up completed in: **0.261.046** +M365 projection stream-isolation follow-up completed in: **0.261.047** +Viewer-specific projection exception hardening completed in: **0.261.048** +Shared-event and M365 refresh decoupling completed in: **0.261.049** +Stream-level M365 refresh gating completed in: **0.261.050** +Cross-worker collaboration event sharing completed in: **0.261.051** +Cross-worker event append concurrency hardening completed in: **0.261.052** ## Overview @@ -29,6 +40,19 @@ The follow-up defect was in the routing predicate: it only selected the AI strea - Extended the collaboration `@` suggestion menu so it now surfaces available agents and available models alongside participant tags and invite suggestions. - Added explicit `@agent` and `@model` parsing so shared sends can target AI without selecting a toolbar tool, and the raw `@target` text is stripped from the final message body in favor of a structured chip. - Routed every non-empty collaborative prompt through the AI stream by default, while retaining invocation metadata for tool, agent, model, and workspace-targeted requests. +- Disconnects the active collaboration event stream when a new conversation is created and ignores stale events from conversations that are no longer active. +- Refreshes the Add participants action in the main and sidebar conversation menus when collaboration metadata changes, so sharing takes effect without a page reload. +- Routes collaboration metadata updates through the conversation metadata synchronizer so the Add participants action is refreshed after the first prompt/response exchange as well as immediately after sharing. +- Preserves the Add participants action for ordinary personal conversations when metadata responses omit collaboration-only permission fields, while keeping explicit server permissions authoritative for collaborative conversations. +- Passes the authoritative accepted-conversation metadata into the post-approval selection flow, ensuring the collaboration message loader renders the complete conversation immediately. +- Retries transient Microsoft 365 outgoing-action load failures after container startup and cancels pending retries when the conversation is disposed, preventing a temporary backend warm-up error from lingering in the conversation. +- Keeps generic transient M365 load failures silent in chat conversations when no action is present, while preserving visible warnings for the dedicated Approvals page and conversations with known saved actions. +- Keeps the collaboration SSE stream alive when optional per-viewer M365 card hydration fails, allowing participant typing, prompts, and responses to continue updating normally. +- Treats unexpected M365 projection errors the same way, preventing a viewer-specific integration failure from stopping that viewer's shared chat updates. +- Keeps ordinary collaborative message history, typing, and live event handling independent from Microsoft 365 action refreshes. M365 requests now occur only for explicit saved-action references, and EventSource reconnects no longer discard messages that arrived while a client was reconnecting. +- Gates the generic streaming completion and recovery hooks behind explicit M365 action data, so normal shared AI prompts do not request the pending-actions service or surface its availability failures. +- Uses the shared Cosmos stream-session fallback when Redis is disabled or unavailable, so EventSource subscribers attached to different application workers receive the same prompts, responses, and typing events. +- Preserves an existing shared event log during another worker's session initialization and retries optimistic Cosmos writes when two workers publish at the same time, preventing one shared event from overwriting another. - Split shared chip styling so participant mentions, agent targets, and model targets render with different background colors. - Updated the streaming chat bridge so explicit tagged-agent requests stamp `agent_selection` and the actual resolved model onto the hidden source user message metadata, which keeps shared user-message detail panels aligned with the assistant response. - Synced source conversation tags and context metadata back into the collaborative conversation record after streaming completes so shared conversation details reflect the resolved agent and actual model used. @@ -38,6 +62,8 @@ The follow-up defect was in the routing predicate: it only selected the AI strea - `application/single_app/functions_collaboration.py` - `application/single_app/route_backend_collaboration.py` - `application/single_app/static/js/chat/chat-collaboration.js` +- `application/single_app/static/js/chat/chat-conversations.js` +- `application/single_app/static/js/chat/chat-sidebar-conversations.js` - `application/single_app/static/js/chat/chat-messages.js` - `application/single_app/static/js/chat/chat-streaming.js` - `application/single_app/static/css/chats.css` @@ -47,6 +73,7 @@ The follow-up defect was in the routing predicate: it only selected the AI strea ## Validation - Added regression coverage in `functional_tests/test_collaboration_shared_ai_workflow.py`. +- Added regression contracts for collaboration teardown, stale-event filtering, and dynamic participant actions. - Extended `ui_tests/test_chat_collaboration_ui_scaffolding.py` to cover target-specific chip styling and `@` suggestion rendering for agents and models. - Verified diagnostics were clean for the touched backend and frontend files. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index aa80dae2b..6614d3150 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,105 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.052)** + +#### Bug Fixes + +* **Concurrent Shared Events Remain Ordered and Visible** + * Cosmos-backed collaboration event storage now preserves an existing stream during worker attachment and retries conflicting appends, preventing simultaneous prompts or typing updates from overwriting one another. + * (Ref: `app_settings_cache.py`) + +### **(v0.261.051)** + +#### Bug Fixes + +* **Shared Conversation Events Reach Every Application Worker** + * When Redis is disabled or unavailable, collaboration event sessions now use the Cosmos-backed stream cache instead of isolated worker memory. + * Participants connected through different workers now receive the same shared prompts, responses, and typing updates. + * (Ref: `app_settings_cache.py`) + +### **(v0.261.050)** + +#### Bug Fixes + +* **Normal Shared Prompts Skip Pending-Action Recovery** + * The shared AI streaming lifecycle now contacts Microsoft 365 pending actions only after receiving explicit action data, preventing unrelated `500` responses and warning states in ordinary shared conversations. + * (Ref: `chat-streaming.js`) + +### **(v0.261.049)** + +#### Bug Fixes + +* **Shared Chat Updates No Longer Trigger Unrelated M365 Requests** + * Shared prompts, responses, typing indicators, and EventSource reconnects now process independently from Microsoft 365 pending-action refreshes. + * M365 recovery remains available for messages that explicitly reference a saved action, while ordinary shared conversations avoid both unnecessary requests and unrelated warnings. + * (Ref: `chat-collaboration.js`, `m365-pending-actions.js`) + +### **(v0.261.048)** + +#### Bug Fixes + +* **Shared Updates Survive Viewer-Specific M365 Errors** + * Unexpected Microsoft 365 action-card projection errors no longer stop an individual participant's live collaboration stream. + * (Ref: `route_backend_collaboration.py`) + +### **(v0.261.047)** + +#### Bug Fixes + +* **Participant Updates Survive M365 Projection Failures** + * A viewer-specific Microsoft 365 action-card hydration failure no longer terminates the shared conversation event stream, so typing indicators, prompts, and model responses continue to arrive. + * (Ref: `route_backend_collaboration.py`) + +### **(v0.261.046)** + +#### Bug Fixes + +* **M365 Startup Warnings Stay Out of Unrelated Chats** + * Generic transient M365 action-load failures now retry silently in conversations without known actions, so unrelated chats do not display an alarming outgoing-actions warning during container warm-up. + * (Ref: `m365-pending-actions.js`) + +### **(v0.261.045)** + +#### Bug Fixes + +* **Transient M365 Action Errors Recover Automatically** + * Temporary outgoing-action load failures during container startup now retry automatically, clearing the warning once the service is available instead of requiring a manual refresh. + * (Ref: `m365-pending-actions.js`) + +### **(v0.261.044)** + +#### Bug Fixes + +* **Shared Conversation Prompts Persist After Invite Approval** + * Accepting an invitation now loads the conversation with the authoritative collaborative metadata returned by the approval request, preventing the initial creator prompt from disappearing while the response remains visible. + * (Ref: `chat-collaboration.js`, `chat-conversations.js`) + +### **(v0.261.043)** + +#### Bug Fixes + +* **Add Participants Remains Available Before Sharing** + * Ordinary personal conversations no longer lose the Add participants action when a metadata response omits collaboration-only permission fields. + * (Ref: `chat-conversations.js`, `chat-sidebar-conversations.js`) + +### **(v0.261.042)** + +#### Bug Fixes + +* **Add Participants Appears After the First Shared Exchange** + * Collaboration metadata updates now refresh the conversation menus after prompt/response completion, so the Add participants action appears without a page refresh. + * (Ref: `chat-collaboration.js`, `chat-conversations.js`, `chat-sidebar-conversations.js`) + +### **(v0.261.041)** + +#### Bug Fixes + +* **Shared Conversation State No Longer Leaks Into New Conversations** + * Starting a new conversation now disconnects the previous shared conversation's event stream and clears its typing state, preventing participant activity and messages from appearing in the new conversation. + * Conversation metadata updates now refresh the Add participants action immediately, without requiring a page refresh after sharing a conversation. + * (Ref: `chat-conversations.js`, `chat-collaboration.js`, `chat-sidebar-conversations.js`, [Collaboration Shared AI Workflow Fix](fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md)) + ### **(v0.261.040)** #### Bug Fixes diff --git a/functional_tests/test_collaboration_shared_ai_workflow.py b/functional_tests/test_collaboration_shared_ai_workflow.py index 4d32e5cc2..e452ec988 100644 --- a/functional_tests/test_collaboration_shared_ai_workflow.py +++ b/functional_tests/test_collaboration_shared_ai_workflow.py @@ -2,8 +2,8 @@ # test_collaboration_shared_ai_workflow.py """ Functional test for collaboration shared AI workflow parity. -Version: 0.261.040 -Implemented in: 0.261.040 +Version: 0.261.052 +Implemented in: 0.261.052 This test ensures collaborative conversations route shared AI requests through the collaboration stream bridge, persist explicit AI-request metadata, and @@ -94,6 +94,36 @@ def test_streaming_metadata_alignment_for_explicit_agent_targets(): assert "model_deployment=final_model_used if use_agent_streaming else gpt_model" in chat_route_source +def test_collaboration_lifecycle_and_participant_action_wiring(): + conversations_source = read_repo_file('application', 'single_app', 'static', 'js', 'chat', 'chat-conversations.js') + collaboration_source = read_repo_file('application', 'single_app', 'static', 'js', 'chat', 'chat-collaboration.js') + sidebar_source = read_repo_file('application', 'single_app', 'static', 'js', 'chat', 'chat-sidebar-conversations.js') + + assert 'window.chatCollaboration?.deactivateConversation?.();' in conversations_source + assert 'function syncConversationAddParticipantsAction(convoItem)' in conversations_source + assert "!metadataIsCollaborative && [" in conversations_source + assert 'syncConversationAddParticipantsAction(convoItem);' in conversations_source + assert 'function syncSidebarAddParticipantsAction(sidebarItem)' in sidebar_source + assert '!isCollaborativeConversation' in sidebar_source + assert "'personal_single_user', 'personal_multi_user'" in sidebar_source + assert 'syncSidebarAddParticipantsAction(sidebarItem);' in sidebar_source + assert 'applyConversationMetadataUpdate(conversationId, metadata);' in collaboration_source + assert "convoItem.dataset.canAcceptInvite === 'true'" in conversations_source + assert "metadata.conversation_kind === 'collaborative'" in conversations_source + assert 'metadataOverride = null' in conversations_source + assert 'selectConversation(payload.conversation.id, payload.conversation)' in collaboration_source + route_source = read_repo_file('application', 'single_app', 'route_backend_collaboration.py') + assert "yield event_text" in route_source + assert "continue" in route_source + assert 'if (isReplayEvent(eventEnvelope))' not in collaboration_source + assert 'SimpleChatM365PendingActions?.refreshConversation(conversationId)' not in collaboration_source + streaming_source = read_repo_file('application', 'single_app', 'static', 'js', 'chat', 'chat-streaming.js') + assert 'let receivedM365PendingAction = false;' in streaming_source + assert 'if (!receivedM365PendingAction)' in streaming_source + assert 'eventConversationId !== activeCollaborativeConversationId' in collaboration_source + assert 'eventConversationId !== window.currentConversationId' in collaboration_source + + if __name__ == '__main__': test_backend_collaboration_stream_bridge() test_frontend_collaboration_stream_wiring() diff --git a/functional_tests/test_cosmos_wave1_cache_fallback.py b/functional_tests/test_cosmos_wave1_cache_fallback.py index 608b5a97c..383cda085 100644 --- a/functional_tests/test_cosmos_wave1_cache_fallback.py +++ b/functional_tests/test_cosmos_wave1_cache_fallback.py @@ -1,9 +1,9 @@ # test_cosmos_wave1_cache_fallback.py #!/usr/bin/env python3 """ -Functional test for Cosmos Wave 1 cache fallback behavior. -Version: 0.261.027 -Implemented in: 0.250.005 +Functional test for Cosmos cache fallback behavior. +Version: 0.261.052 +Implemented in: 0.261.052 This test ensures Redis failures in the app cache layer fall back to Cosmos-backed cache/source reads instead of failing callers. @@ -13,6 +13,7 @@ import importlib import os import sys +from azure.core.exceptions import AzureError from redis.exceptions import ConnectionError as RedisConnectionError @@ -22,7 +23,7 @@ sys.path.insert(0, SINGLE_APP_DIR) -class FakeCosmosError(Exception): +class FakeCosmosError(AzureError): def __init__(self, status_code, message): super().__init__(message) self.status_code = status_code @@ -69,6 +70,21 @@ def delete_item(self, item, partition_key, **kwargs): del self.items[item] +class StreamAppendConflictContainer(FakeCosmosContainer): + def __init__(self): + super().__init__() + self.conflicts_remaining = 1 + + def replace_item(self, item, body, etag=None, match_condition=None, **kwargs): + if self.conflicts_remaining: + self.conflicts_remaining -= 1 + concurrent_body = copy.deepcopy(self.items[item]) + concurrent_body['payload'].append('data: concurrent-message\n\n') + self.items[item] = self._copy_with_new_etag(concurrent_body) + raise FakeCosmosError(412, "Concurrent stream append") + return super().replace_item(item, body, etag=etag, match_condition=match_condition, **kwargs) + + class FailingRedis: def __init__(self, *args, **kwargs): pass @@ -186,11 +202,54 @@ def test_redis_initialization_failure_assigns_fallback_functions(): assert cache_module.get_settings_cache()["feature_flag"] == "fallback-configured" +def test_stream_event_fallback_is_shared_between_workers(): + """Worker-local stream snapshots must not hide collaboration events.""" + container = FakeCosmosContainer() + worker_one = _load_cache_module() + worker_two = _load_cache_module() + settings = {"enable_redis_cache": False} + + worker_one.configure_app_cache(settings, dependencies=_dependencies(worker_one, container, RaisingRedis)) + worker_two.configure_app_cache(settings, dependencies=_dependencies(worker_two, container, RaisingRedis)) + + cache_key = "collaboration:test-conversation" + metadata = {"conversation_id": "test-conversation", "active": True} + worker_one.initialize_stream_session_cache(cache_key, metadata, ttl_seconds=60) + worker_one.append_stream_session_event(cache_key, "data: owner-message\n\n", ttl_seconds=60) + assert worker_two.get_stream_session_meta(cache_key) == metadata + worker_two.set_stream_session_meta(cache_key, metadata, ttl_seconds=60) + worker_two.append_stream_session_event(cache_key, "data: invitee-message\n\n", ttl_seconds=60) + + expected_events = ["data: owner-message\n\n", "data: invitee-message\n\n"] + assert worker_one.get_stream_session_events(cache_key) == expected_events + assert worker_two.get_stream_session_events(cache_key) == expected_events + + +def test_stream_event_fallback_retries_concurrent_append(): + """Concurrent stream publishes must retain both events after an ETag conflict.""" + container = StreamAppendConflictContainer() + cache_module = _load_cache_module() + cache_module.configure_app_cache({"enable_redis_cache": False}, dependencies=_dependencies( + cache_module, container, RaisingRedis, + )) + + cache_key = "collaboration:conflict-test" + cache_module.initialize_stream_session_cache(cache_key, {"active": True}, ttl_seconds=60) + cache_module.append_stream_session_event(cache_key, "data: owner-message\n\n", ttl_seconds=60) + + assert cache_module.get_stream_session_events(cache_key) == [ + "data: concurrent-message\n\n", + "data: owner-message\n\n", + ] + + if __name__ == "__main__": tests = [ test_redis_runtime_failure_falls_back_to_cosmos_settings, test_redis_write_failure_persists_user_ui_cache_to_cosmos, test_redis_initialization_failure_assigns_fallback_functions, + test_stream_event_fallback_is_shared_between_workers, + test_stream_event_fallback_retries_concurrent_append, ] results = [] for test in tests: diff --git a/functional_tests/test_m365_pending_action_cards.js b/functional_tests/test_m365_pending_action_cards.js index c3d65900f..c73ce9b43 100644 --- a/functional_tests/test_m365_pending_action_cards.js +++ b/functional_tests/test_m365_pending_action_cards.js @@ -1,6 +1,6 @@ // test_m365_pending_action_cards.js -// Version: 0.261.038 -// Implemented in: 0.261.038 +// Version: 0.261.051 +// Implemented in: 0.261.051 // Offline behavioral checks of the real shared renderer and M365 CSRF helper. // Run: node --test functional_tests\test_m365_pending_action_cards.js From 11bcf1bc161530270ae1c29733f5b5393bfd4781 Mon Sep 17 00:00:00 2001 From: Joshua Wilshere Date: Thu, 24 Sep 2026 03:00:56 +0000 Subject: [PATCH 2/6] fix(collaboration): preserve shared history across invite acceptance Keep Redis-backed stream events when workers attach, synchronize history with the SSE cursor, and avoid reselecting an already active conversation after an invite is accepted. Silence unrelated background M365 recovery warnings while retaining explicit action recovery. Add cache and collaboration regressions; update docs for v0.261.055. --- application/single_app/app_settings_cache.py | 2 - application/single_app/config.py | 2 +- .../single_app/route_backend_collaboration.py | 8 +- .../static/js/chat/chat-collaboration.js | 34 ++++-- .../static/js/m365-pending-actions.js | 12 +- .../COLLABORATION_SHARED_AI_WORKFLOW_FIX.md | 7 ++ docs/explanation/release_notes.md | 26 +++++ ...st_collaboration_group_agent_stream_fix.py | 11 +- .../test_collaboration_shared_ai_workflow.py | 13 ++- .../test_cosmos_wave1_cache_fallback.py | 103 +++++++++++++++++- .../test_m365_collaboration_action_cards.py | 8 +- .../test_m365_pending_action_cards.js | 12 +- 12 files changed, 206 insertions(+), 32 deletions(-) diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py index 8ccd89c45..2fcf08655 100644 --- a/application/single_app/app_settings_cache.py +++ b/application/single_app/app_settings_cache.py @@ -733,9 +733,7 @@ def get_stream_session_events_key(cache_key): def initialize_stream_session_cache_redis(cache_key, metadata, ttl_seconds=None): try: metadata_key = get_stream_session_metadata_key(cache_key) - events_key = get_stream_session_events_key(cache_key) pipeline = redis_client.pipeline() - pipeline.delete(events_key) pipeline.set(metadata_key, json.dumps(metadata)) if ttl_seconds is not None: pipeline.expire(metadata_key, int(ttl_seconds)) diff --git a/application/single_app/config.py b/application/single_app/config.py index d5f127d2a..83e5c7ed1 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -98,7 +98,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.052" +VERSION = "0.261.055" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py index e41878f99..47794813f 100644 --- a/application/single_app/route_backend_collaboration.py +++ b/application/single_app/route_backend_collaboration.py @@ -168,6 +168,11 @@ def publish(self, event_payload): with self._condition: self._condition.notify_all() + def get_event_cursor(self): + """Return the next event index before the caller reads persisted history.""" + self.initialize() + return len(app_settings_cache.get_stream_session_events(self.cache_key, start_index=0) or []) + def iter_events(self, start_index=0): self.initialize() next_index = max(int(start_index or 0), 0) @@ -1347,10 +1352,11 @@ def get_collaboration_messages_api(conversation_id): conversation_doc, allow_pending=True, ) + event_cursor = COLLABORATION_EVENT_REGISTRY.get_session(conversation_id).get_event_cursor() messages = [serialize_collaboration_message(doc) for doc in list_collaboration_messages(conversation_id)] attach_generated_file_approval_state(messages, current_user['user_id']) messages = hydrate_m365_pending_action_cards(messages, current_user['user_id'], conversation_id) - return jsonify({'messages': messages}), 200 + return jsonify({'messages': messages, 'event_cursor': event_cursor}), 200 except CosmosResourceNotFoundError: return jsonify({'error': 'Collaborative conversation not found'}), 404 except PermissionError as exc: diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index 16abde851..ea8c44e07 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -965,7 +965,7 @@ async function loadConversationMessages(conversationId) { const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/messages`); const chatbox = document.getElementById('chatbox'); if (!chatbox) { - return []; + return { messages: [], eventCursor: 0 }; } window.SimpleChatM365PendingActions?.prepareHistory(conversationId); @@ -998,7 +998,10 @@ async function loadConversationMessages(conversationId) { cacheCollaborationMessage(message); }); reapplyPendingSearchHighlight(); - return messages; + return { + messages, + eventCursor: Math.max(0, Number(payload.event_cursor) || 0), + }; } function handleTypingEvent(payload = {}) { @@ -1193,7 +1196,9 @@ function handleConversationEvent(eventEnvelope = {}) { return; } - if (eventEnvelope.event_type === 'collaboration.invite.accepted' && payload.participant?.display_name) { + if (eventEnvelope.event_type === 'collaboration.invite.accepted' + && payload.participant?.display_name + && String(payload.participant.user_id || '').trim() !== getCurrentUserId()) { showToast(`${payload.participant.display_name} accepted the invite.`, 'success'); return; } @@ -1207,14 +1212,17 @@ function handleConversationEvent(eventEnvelope = {}) { } } -function subscribeToConversationEvents(conversationId) { +function subscribeToConversationEvents(conversationId, eventCursor = 0) { if (!isCollaborationEnabled() || !conversationId || typeof EventSource === 'undefined') { return; } disconnectConversationEvents(); activeCollaborativeConversationId = conversationId; - activeCollaborationEventSource = new EventSource(`/api/collaboration/conversations/${encodeURIComponent(conversationId)}/events`); + const query = new URLSearchParams({ start_index: String(Math.max(0, Number(eventCursor) || 0)) }); + activeCollaborationEventSource = new EventSource( + `/api/collaboration/conversations/${encodeURIComponent(conversationId)}/events?${query}` + ); activeCollaborationEventSource.onmessage = event => { if (!event?.data) { return; @@ -1301,11 +1309,11 @@ async function activateConversation(conversationId, metadata = null) { : await fetchConversationMetadata(conversationId); updateComposerAvailability(conversationMetadata); clearReplyTarget({ focusComposer: false }); - await loadConversationMessages(conversationId); + const { eventCursor } = await loadConversationMessages(conversationId); markCollaborationConversationRead(conversationId, { suppressErrorToast: true }).catch(error => { console.warn('Failed to clear shared conversation notifications:', error); }); - subscribeToConversationEvents(conversationId); + subscribeToConversationEvents(conversationId, eventCursor); if (conversationMetadata.can_accept_invite && !promptedPendingInviteConversationIds.has(conversationId)) { promptedPendingInviteConversationIds.add(conversationId); @@ -1868,8 +1876,16 @@ async function respondToInvite(conversationId, action) { promptedPendingInviteConversationIds.delete(conversationId); } - if (action === 'accept' && payload.conversation?.id && window.chatConversations?.selectConversation) { - await window.chatConversations.selectConversation(payload.conversation.id, payload.conversation); + if (action === 'accept' && payload.conversation?.id) { + const acceptedConversation = cacheCollaborationConversation(payload.conversation); + setConversationDataset(acceptedConversation.id, acceptedConversation); + applyConversationMetadataUpdate(acceptedConversation.id, acceptedConversation); + updateComposerAvailability(acceptedConversation); + + if (window.chatConversations?.getCurrentConversationId?.() !== acceptedConversation.id + && window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(acceptedConversation.id, acceptedConversation); + } } window.hideConversationDetails?.(); diff --git a/application/single_app/static/js/m365-pending-actions.js b/application/single_app/static/js/m365-pending-actions.js index 9f7bfdf8e..42e026722 100644 --- a/application/single_app/static/js/m365-pending-actions.js +++ b/application/single_app/static/js/m365-pending-actions.js @@ -720,7 +720,7 @@ retryAttempt += 1; retryHandle = window.setTimeout(() => { retryHandle = null; - void load(); + void load('', { background: true }); }, delay); } @@ -811,7 +811,7 @@ .finally(() => pendingReferences.delete(id)); } - async function load(token = '') { + async function load(token = '', { background = false } = {}) { if (disposed || loadPromise) { return loadPromise; } @@ -865,7 +865,7 @@ } }); const hasKnownActionState = views.size > 0 || referenceFailures.size > 0; - if (error.status === 403 || !chatView || hasKnownActionState) { + if (!background && (error.status === 403 || !chatView || hasKnownActionState)) { showError(error.status === 403 ? 'You do not have permission to view outgoing actions for this conversation.' : 'Outgoing actions could not be loaded. Refresh to recover saved actions; this is not an empty inbox.'); @@ -917,8 +917,8 @@ more.addEventListener('click', () => { void load(continuationToken); }); refresh.addEventListener('click', () => { void load(); }); - window.addEventListener('focus', () => { void load(); }, { signal: lifecycle.signal }); - window.addEventListener('online', () => { void load(); }, { signal: lifecycle.signal }); + window.addEventListener('focus', () => { void load('', { background: true }); }, { signal: lifecycle.signal }); + window.addEventListener('online', () => { void load('', { background: true }); }, { signal: lifecycle.signal }); return { add, load, trackMessage, async refresh() { @@ -974,7 +974,7 @@ chatbox.before(root); } chat = { id, collection: createCollection(root, { conversation_id: id }, { chatView: true }) }; - void chat.collection.load(); + void chat.collection.load('', { background: true }); return chat; } diff --git a/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md b/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md index 3ecc8d919..75508c333 100644 --- a/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md +++ b/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md @@ -13,6 +13,9 @@ Shared-event and M365 refresh decoupling completed in: **0.261.049** Stream-level M365 refresh gating completed in: **0.261.050** Cross-worker collaboration event sharing completed in: **0.261.051** Cross-worker event append concurrency hardening completed in: **0.261.052** +Redis stream-session attachment hardening completed in: **0.261.053** +History and event replay cursor synchronization completed in: **0.261.054** +Active invite acceptance history preservation completed in: **0.261.055** ## Overview @@ -53,6 +56,10 @@ The follow-up defect was in the routing predicate: it only selected the AI strea - Gates the generic streaming completion and recovery hooks behind explicit M365 action data, so normal shared AI prompts do not request the pending-actions service or surface its availability failures. - Uses the shared Cosmos stream-session fallback when Redis is disabled or unavailable, so EventSource subscribers attached to different application workers receive the same prompts, responses, and typing events. - Preserves an existing shared event log during another worker's session initialization and retries optimistic Cosmos writes when two workers publish at the same time, preventing one shared event from overwriting another. +- Preserves Redis-backed shared event logs when another application worker attaches to a conversation, so accepting an invite cannot erase prompts published before the invitee's EventSource subscription begins. +- Treats the initial Microsoft 365 saved-action lookup as background recovery, keeping transient lookup failures out of ordinary shared conversation views while retaining manual recovery controls and saved-action rendering. +- Captures an SSE cursor with each history response and subscribes after that cursor, so persisted prompts remain visible through invite acceptance and retained invite events do not replay as refresh-time notifications. +- Updates accepted conversation metadata and composer availability in place when the invitee is already viewing that conversation, avoiding a second conversation selection that clears the loaded message history. - Split shared chip styling so participant mentions, agent targets, and model targets render with different background colors. - Updated the streaming chat bridge so explicit tagged-agent requests stamp `agent_selection` and the actual resolved model onto the hidden source user message metadata, which keeps shared user-message detail panels aligned with the assistant response. - Synced source conversation tags and context metadata back into the collaborative conversation record after streaming completes so shared conversation details reflect the resolved agent and actual model used. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 6614d3150..62d519a87 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,32 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.055)** + +#### Bug Fixes + +* **Invite Acceptance Preserves Visible Shared History** + * When an invitee accepts while already viewing the shared conversation, the client now updates membership and composer state in place instead of selecting the same conversation again and clearing its loaded history. + * (Ref: `chat-collaboration.js`) + +### **(v0.261.054)** + +#### Bug Fixes + +* **Shared History and Live Events Stay Aligned** + * Collaborative history now supplies an event cursor to EventSource, so invitees retain the owner's initial prompt after accepting and browser refreshes do not replay the accepted-invite toast. + * Invitees no longer receive an acceptance toast for their own action; other participants still receive the live notification. + * (Ref: `route_backend_collaboration.py`, `chat-collaboration.js`) + +### **(v0.261.053)** + +#### Bug Fixes + +* **Invitees Retain Shared Prompts With Redis Enabled** + * Attaching an EventSource session no longer clears the Redis-backed shared event log, so an invitee receives messages published before accepting the invite without needing to refresh. + * Automatic Microsoft 365 action recovery no longer surfaces a warning in normal shared conversations when its lookup is temporarily unavailable; saved actions still render when available and can be refreshed manually. + * (Ref: `app_settings_cache.py`, `m365-pending-actions.js`) + ### **(v0.261.052)** #### Bug Fixes diff --git a/functional_tests/test_collaboration_group_agent_stream_fix.py b/functional_tests/test_collaboration_group_agent_stream_fix.py index 6033d725d..1802cdb3c 100644 --- a/functional_tests/test_collaboration_group_agent_stream_fix.py +++ b/functional_tests/test_collaboration_group_agent_stream_fix.py @@ -2,8 +2,8 @@ # test_collaboration_group_agent_stream_fix.py """ Functional test for group collaboration agent stream completion. -Version: 0.241.068 -Implemented in: 0.241.068 +Version: 0.261.055 +Implemented in: 0.261.055 This test ensures group collaborative agent responses complete through the shared stream bridge even when mirrored agent citation payloads contain nested @@ -67,6 +67,13 @@ def __init__(self): def publish(self, conversation_id, payload): self.events.append((conversation_id, copy.deepcopy(payload))) + def get_session(self, conversation_id): + del conversation_id + return self + + def get_event_cursor(self): + return len(self.events) + def build_group_agent_stream_test_app(): """Register the collaboration stream route with isolated fake dependencies.""" diff --git a/functional_tests/test_collaboration_shared_ai_workflow.py b/functional_tests/test_collaboration_shared_ai_workflow.py index e452ec988..ed9e14c0b 100644 --- a/functional_tests/test_collaboration_shared_ai_workflow.py +++ b/functional_tests/test_collaboration_shared_ai_workflow.py @@ -2,8 +2,8 @@ # test_collaboration_shared_ai_workflow.py """ Functional test for collaboration shared AI workflow parity. -Version: 0.261.052 -Implemented in: 0.261.052 +Version: 0.261.055 +Implemented in: 0.261.055 This test ensures collaborative conversations route shared AI requests through the collaboration stream bridge, persist explicit AI-request metadata, and @@ -111,7 +111,7 @@ def test_collaboration_lifecycle_and_participant_action_wiring(): assert "convoItem.dataset.canAcceptInvite === 'true'" in conversations_source assert "metadata.conversation_kind === 'collaborative'" in conversations_source assert 'metadataOverride = null' in conversations_source - assert 'selectConversation(payload.conversation.id, payload.conversation)' in collaboration_source + assert 'await window.chatConversations.selectConversation(acceptedConversation.id, acceptedConversation);' in collaboration_source route_source = read_repo_file('application', 'single_app', 'route_backend_collaboration.py') assert "yield event_text" in route_source assert "continue" in route_source @@ -122,6 +122,13 @@ def test_collaboration_lifecycle_and_participant_action_wiring(): assert 'if (!receivedM365PendingAction)' in streaming_source assert 'eventConversationId !== activeCollaborativeConversationId' in collaboration_source assert 'eventConversationId !== window.currentConversationId' in collaboration_source + assert 'def get_event_cursor(self):' in route_source + assert "'event_cursor': event_cursor" in route_source + assert 'subscribeToConversationEvents(conversationId, eventCursor);' in collaboration_source + assert 'start_index: String(Math.max(0, Number(eventCursor) || 0))' in collaboration_source + assert "String(payload.participant.user_id || '').trim() !== getCurrentUserId()" in collaboration_source + assert 'window.chatConversations?.getCurrentConversationId?.() !== acceptedConversation.id' in collaboration_source + assert 'updateComposerAvailability(acceptedConversation);' in collaboration_source if __name__ == '__main__': diff --git a/functional_tests/test_cosmos_wave1_cache_fallback.py b/functional_tests/test_cosmos_wave1_cache_fallback.py index 383cda085..5adedc98a 100644 --- a/functional_tests/test_cosmos_wave1_cache_fallback.py +++ b/functional_tests/test_cosmos_wave1_cache_fallback.py @@ -2,8 +2,8 @@ #!/usr/bin/env python3 """ Functional test for Cosmos cache fallback behavior. -Version: 0.261.052 -Implemented in: 0.261.052 +Version: 0.261.055 +Implemented in: 0.261.055 This test ensures Redis failures in the app cache layer fall back to Cosmos-backed cache/source reads instead of failing callers. @@ -116,6 +116,80 @@ def __init__(self, *args, **kwargs): raise RedisConnectionError("redis initialization failed") +class InMemoryRedisPipeline: + def __init__(self, redis_client): + self.redis_client = redis_client + self.operations = [] + + def delete(self, *keys): + self.operations.append(('delete', keys)) + return self + + def set(self, key, value): + self.operations.append(('set', (key, value))) + return self + + def expire(self, key, seconds): + self.operations.append(('expire', (key, seconds))) + return self + + def rpush(self, key, value): + self.operations.append(('rpush', (key, value))) + return self + + def execute(self): + for operation, arguments in self.operations: + getattr(self.redis_client, operation)(*arguments) + return [] + + +class InMemoryRedis: + def __init__(self, *args, **kwargs): + self.values = {} + + def get(self, key): + return self.values.get(key) + + def set(self, key, value): + self.values[key] = value + return True + + def setex(self, key, seconds, value): + return self.set(key, value) + + def setnx(self, key, value): + if key in self.values: + return False + self.values[key] = value + return True + + def incr(self, key): + value = int(self.values.get(key, 0)) + 1 + self.values[key] = value + return value + + def delete(self, *keys): + for key in keys: + self.values.pop(key, None) + + def exists(self, key): + return key in self.values + + def expire(self, key, seconds): + return self.exists(key) + + def rpush(self, key, value): + self.values.setdefault(key, []).append(value) + return len(self.values[key]) + + def lrange(self, key, start, end): + values = self.values.get(key, []) + return values[start:] if end == -1 else values[start:end + 1] + + def pipeline(self): + return InMemoryRedisPipeline(self) + + def _load_cache_module(): spec = importlib.util.spec_from_file_location( "cache_fallback_under_test", @@ -243,6 +317,30 @@ def test_stream_event_fallback_retries_concurrent_append(): ] +def test_redis_stream_session_initialization_preserves_existing_events(): + """A second app service must not erase events published before it attaches.""" + cache_module = _load_cache_module() + cache_module.configure_app_cache({ + "enable_redis_cache": True, + "redis_url": "simplechat.redis.cache.windows.net", + "redis_key": "test-key", + "redis_auth_type": "key", + }, dependencies=_dependencies(cache_module, FakeCosmosContainer(), InMemoryRedis)) + + cache_key = "collaboration:redis-test" + metadata = {"conversation_id": "redis-test", "active": True} + cache_module.initialize_stream_session_cache(cache_key, metadata, ttl_seconds=60) + cache_module.append_stream_session_event(cache_key, "data: owner-message\n\n", ttl_seconds=60) + + cache_module.initialize_stream_session_cache(cache_key, metadata, ttl_seconds=60) + cache_module.append_stream_session_event(cache_key, "data: invitee-message\n\n", ttl_seconds=60) + + assert cache_module.get_stream_session_events(cache_key) == [ + "data: owner-message\n\n", + "data: invitee-message\n\n", + ] + + if __name__ == "__main__": tests = [ test_redis_runtime_failure_falls_back_to_cosmos_settings, @@ -250,6 +348,7 @@ def test_stream_event_fallback_retries_concurrent_append(): test_redis_initialization_failure_assigns_fallback_functions, test_stream_event_fallback_is_shared_between_workers, test_stream_event_fallback_retries_concurrent_append, + test_redis_stream_session_initialization_preserves_existing_events, ] results = [] for test in tests: diff --git a/functional_tests/test_m365_collaboration_action_cards.py b/functional_tests/test_m365_collaboration_action_cards.py index 92939ca5a..433509527 100644 --- a/functional_tests/test_m365_collaboration_action_cards.py +++ b/functional_tests/test_m365_collaboration_action_cards.py @@ -1,8 +1,8 @@ # test_m365_collaboration_action_cards.py """ Shared-conversation action-card projection and live stream regression tests. -Version: 0.261.038 -Implemented in: 0.261.038 +Version: 0.261.055 +Implemented in: 0.261.055 The fresh-process probe imports the real routes with network access blocked and reuses scoped storage/Graph fixtures. It verifies owner-only details never enter @@ -144,12 +144,12 @@ def can_view(user_id, conversation, **kwargs): viewers.remove("viewer") before = len(harness.container.queries) revoked = list(routes._collaboration_events_for_viewer([cached], "viewer", visible_id)) - check("m365_pending_actions_unavailable" in revoked[0], "Revoked access did not produce a visible recovery signal.") + check(revoked == [cached], "Projection failure did not preserve the collaboration event.") check("private@example.test" not in revoked[0] and len(harness.container.queries) == before, "Revoked subscriber read pending data.") viewers.add("viewer") harness.container.query_error = CosmosHttpResponseError(status_code=503, message="private storage details") unavailable = list(routes._collaboration_events_for_viewer([cached], "viewer", visible_id)) - check("m365_pending_actions_unavailable" in unavailable[0], "Storage failure looked like an empty action list.") + check(unavailable == [cached], "Storage failure did not preserve the collaboration event.") check("private storage details" not in unavailable[0], "Provider diagnostics reached the event stream.") check(not harness.writes, "Rendering or replaying a shared card sent an action.") finally: diff --git a/functional_tests/test_m365_pending_action_cards.js b/functional_tests/test_m365_pending_action_cards.js index c73ce9b43..8e6147041 100644 --- a/functional_tests/test_m365_pending_action_cards.js +++ b/functional_tests/test_m365_pending_action_cards.js @@ -1,6 +1,6 @@ // test_m365_pending_action_cards.js -// Version: 0.261.051 -// Implemented in: 0.261.051 +// Version: 0.261.055 +// Implemented in: 0.261.055 // Offline behavioral checks of the real shared renderer and M365 CSRF helper. // Run: node --test functional_tests\test_m365_pending_action_cards.js @@ -459,6 +459,14 @@ test('absent or nonboolean permission flags do not create controls', () => { assert.match(view.card.textContent, /read-only/); }); +test('conversation startup action recovery stays in the background', () => { + const source = fs.readFileSync(path.join(scriptRoot, 'm365-pending-actions.js'), 'utf8'); + assert.match(source, /async function load\(token = '', \{ background = false \} = \{\}\)/); + assert.match(source, /void chat\.collection\.load\('', \{ background: true \}\);/); + assert.match(source, /window\.addEventListener\('focus', \(\) => \{ void load\('', \{ background: true \}\); \}, \{ signal: lifecycle\.signal \}\)/); + assert.match(source, /if \(!background && \(error\.status === 403 \|\| !chatView \|\| hasKnownActionState\)\)/); +}); + test('legacy recreation never offers Send but preserves authorized cancellation', () => { const fixture = harness(action({ requires_recreation: true, requires_review: true })); const view = fixture.mount(); From 797c0d18a516d5dcf791a935dd78e625aa497c5a Mon Sep 17 00:00:00 2001 From: Joshua Wilshere Date: Thu, 24 Sep 2026 19:46:17 +0000 Subject: [PATCH 3/6] Flattened intermediate version increments into single version increment --- application/single_app/config.py | 2 +- .../COLLABORATION_SHARED_AI_WORKFLOW_FIX.md | 17 +--- docs/explanation/release_notes.md | 97 ++----------------- ...st_collaboration_group_agent_stream_fix.py | 4 +- .../test_collaboration_shared_ai_workflow.py | 4 +- .../test_cosmos_wave1_cache_fallback.py | 4 +- .../test_m365_collaboration_action_cards.py | 4 +- .../test_m365_pending_action_cards.js | 4 +- 8 files changed, 23 insertions(+), 113 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 83e5c7ed1..82616f5e3 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -98,7 +98,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.055" +VERSION = "0.261.045" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md b/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md index 75508c333..84bce5404 100644 --- a/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md +++ b/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md @@ -1,21 +1,8 @@ # Collaboration Shared AI Workflow Fix Original implementation: **0.241.021** -Plain-prompt coverage completed in: **0.261.040** -Conversation lifecycle follow-up completed in: **0.261.041** -Participant menu synchronization follow-up completed in: **0.261.043** -Invite approval message-loading follow-up completed in: **0.261.044** -M365 startup recovery follow-up completed in: **0.261.045** -Unrelated chat warning suppression follow-up completed in: **0.261.046** -M365 projection stream-isolation follow-up completed in: **0.261.047** -Viewer-specific projection exception hardening completed in: **0.261.048** -Shared-event and M365 refresh decoupling completed in: **0.261.049** -Stream-level M365 refresh gating completed in: **0.261.050** -Cross-worker collaboration event sharing completed in: **0.261.051** -Cross-worker event append concurrency hardening completed in: **0.261.052** -Redis stream-session attachment hardening completed in: **0.261.053** -History and event replay cursor synchronization completed in: **0.261.054** -Active invite acceptance history preservation completed in: **0.261.055** +Plain-prompt coverage completed in: **0.261.040** ([PR #1515](https://github.com/microsoft/simplechat/pull/1515)) +Cross-worker delivery and invite-acceptance follow-up completed in: **0.261.045** ## Overview diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 62d519a87..df9c6e309 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,97 +2,20 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). -### **(v0.261.055)** - -#### Bug Fixes - -* **Invite Acceptance Preserves Visible Shared History** - * When an invitee accepts while already viewing the shared conversation, the client now updates membership and composer state in place instead of selecting the same conversation again and clearing its loaded history. - * (Ref: `chat-collaboration.js`) - -### **(v0.261.054)** - -#### Bug Fixes - -* **Shared History and Live Events Stay Aligned** - * Collaborative history now supplies an event cursor to EventSource, so invitees retain the owner's initial prompt after accepting and browser refreshes do not replay the accepted-invite toast. - * Invitees no longer receive an acceptance toast for their own action; other participants still receive the live notification. - * (Ref: `route_backend_collaboration.py`, `chat-collaboration.js`) - -### **(v0.261.053)** - -#### Bug Fixes - -* **Invitees Retain Shared Prompts With Redis Enabled** - * Attaching an EventSource session no longer clears the Redis-backed shared event log, so an invitee receives messages published before accepting the invite without needing to refresh. - * Automatic Microsoft 365 action recovery no longer surfaces a warning in normal shared conversations when its lookup is temporarily unavailable; saved actions still render when available and can be refreshed manually. - * (Ref: `app_settings_cache.py`, `m365-pending-actions.js`) - -### **(v0.261.052)** - -#### Bug Fixes - -* **Concurrent Shared Events Remain Ordered and Visible** - * Cosmos-backed collaboration event storage now preserves an existing stream during worker attachment and retries conflicting appends, preventing simultaneous prompts or typing updates from overwriting one another. - * (Ref: `app_settings_cache.py`) - -### **(v0.261.051)** - -#### Bug Fixes - -* **Shared Conversation Events Reach Every Application Worker** - * When Redis is disabled or unavailable, collaboration event sessions now use the Cosmos-backed stream cache instead of isolated worker memory. - * Participants connected through different workers now receive the same shared prompts, responses, and typing updates. - * (Ref: `app_settings_cache.py`) - -### **(v0.261.050)** - -#### Bug Fixes - -* **Normal Shared Prompts Skip Pending-Action Recovery** - * The shared AI streaming lifecycle now contacts Microsoft 365 pending actions only after receiving explicit action data, preventing unrelated `500` responses and warning states in ordinary shared conversations. - * (Ref: `chat-streaming.js`) - -### **(v0.261.049)** - -#### Bug Fixes - -* **Shared Chat Updates No Longer Trigger Unrelated M365 Requests** - * Shared prompts, responses, typing indicators, and EventSource reconnects now process independently from Microsoft 365 pending-action refreshes. - * M365 recovery remains available for messages that explicitly reference a saved action, while ordinary shared conversations avoid both unnecessary requests and unrelated warnings. - * (Ref: `chat-collaboration.js`, `m365-pending-actions.js`) - -### **(v0.261.048)** - -#### Bug Fixes - -* **Shared Updates Survive Viewer-Specific M365 Errors** - * Unexpected Microsoft 365 action-card projection errors no longer stop an individual participant's live collaboration stream. - * (Ref: `route_backend_collaboration.py`) - -### **(v0.261.047)** - -#### Bug Fixes - -* **Participant Updates Survive M365 Projection Failures** - * A viewer-specific Microsoft 365 action-card hydration failure no longer terminates the shared conversation event stream, so typing indicators, prompts, and model responses continue to arrive. - * (Ref: `route_backend_collaboration.py`) - -### **(v0.261.046)** - -#### Bug Fixes - -* **M365 Startup Warnings Stay Out of Unrelated Chats** - * Generic transient M365 action-load failures now retry silently in conversations without known actions, so unrelated chats do not display an alarming outgoing-actions warning during container warm-up. - * (Ref: `m365-pending-actions.js`) - ### **(v0.261.045)** #### Bug Fixes -* **Transient M365 Action Errors Recover Automatically** - * Temporary outgoing-action load failures during container startup now retry automatically, clearing the warning once the service is available instead of requiring a manual refresh. - * (Ref: `m365-pending-actions.js`) +* **Shared Conversation Delivery Remains Reliable Across Workers** + * Shared event sessions now use a cross-worker Cosmos fallback when Redis is unavailable, preserve Redis event logs during worker attachment, and retry concurrent Cosmos event writes. + * History supplies an EventSource cursor, so retained events do not replay after history loads and invitees retain the owner's existing prompts. + * Accepting an invite while already viewing the conversation now updates membership and composer state in place instead of clearing the rendered history. + * (Ref: `app_settings_cache.py`, `route_backend_collaboration.py`, `chat-collaboration.js`) + +* **Microsoft 365 Enhancements Do Not Interrupt Shared Chat** + * Optional Microsoft 365 action-card projection failures no longer terminate collaboration events. + * Background action recovery remains silent for ordinary shared conversations, while saved actions retain explicit recovery controls. + * (Ref: `route_backend_collaboration.py`, `chat-streaming.js`, `m365-pending-actions.js`) ### **(v0.261.044)** diff --git a/functional_tests/test_collaboration_group_agent_stream_fix.py b/functional_tests/test_collaboration_group_agent_stream_fix.py index 1802cdb3c..cf39a2b55 100644 --- a/functional_tests/test_collaboration_group_agent_stream_fix.py +++ b/functional_tests/test_collaboration_group_agent_stream_fix.py @@ -2,8 +2,8 @@ # test_collaboration_group_agent_stream_fix.py """ Functional test for group collaboration agent stream completion. -Version: 0.261.055 -Implemented in: 0.261.055 +Version: 0.261.045 +Implemented in: 0.261.045 This test ensures group collaborative agent responses complete through the shared stream bridge even when mirrored agent citation payloads contain nested diff --git a/functional_tests/test_collaboration_shared_ai_workflow.py b/functional_tests/test_collaboration_shared_ai_workflow.py index ed9e14c0b..745ed48b6 100644 --- a/functional_tests/test_collaboration_shared_ai_workflow.py +++ b/functional_tests/test_collaboration_shared_ai_workflow.py @@ -2,8 +2,8 @@ # test_collaboration_shared_ai_workflow.py """ Functional test for collaboration shared AI workflow parity. -Version: 0.261.055 -Implemented in: 0.261.055 +Version: 0.261.045 +Implemented in: 0.261.045 This test ensures collaborative conversations route shared AI requests through the collaboration stream bridge, persist explicit AI-request metadata, and diff --git a/functional_tests/test_cosmos_wave1_cache_fallback.py b/functional_tests/test_cosmos_wave1_cache_fallback.py index 5adedc98a..912954034 100644 --- a/functional_tests/test_cosmos_wave1_cache_fallback.py +++ b/functional_tests/test_cosmos_wave1_cache_fallback.py @@ -2,8 +2,8 @@ #!/usr/bin/env python3 """ Functional test for Cosmos cache fallback behavior. -Version: 0.261.055 -Implemented in: 0.261.055 +Version: 0.261.045 +Implemented in: 0.261.045 This test ensures Redis failures in the app cache layer fall back to Cosmos-backed cache/source reads instead of failing callers. diff --git a/functional_tests/test_m365_collaboration_action_cards.py b/functional_tests/test_m365_collaboration_action_cards.py index 433509527..7d1d71824 100644 --- a/functional_tests/test_m365_collaboration_action_cards.py +++ b/functional_tests/test_m365_collaboration_action_cards.py @@ -1,8 +1,8 @@ # test_m365_collaboration_action_cards.py """ Shared-conversation action-card projection and live stream regression tests. -Version: 0.261.055 -Implemented in: 0.261.055 +Version: 0.261.045 +Implemented in: 0.261.045 The fresh-process probe imports the real routes with network access blocked and reuses scoped storage/Graph fixtures. It verifies owner-only details never enter diff --git a/functional_tests/test_m365_pending_action_cards.js b/functional_tests/test_m365_pending_action_cards.js index 8e6147041..0d6aff52a 100644 --- a/functional_tests/test_m365_pending_action_cards.js +++ b/functional_tests/test_m365_pending_action_cards.js @@ -1,6 +1,6 @@ // test_m365_pending_action_cards.js -// Version: 0.261.055 -// Implemented in: 0.261.055 +// Version: 0.261.045 +// Implemented in: 0.261.045 // Offline behavioral checks of the real shared renderer and M365 CSRF helper. // Run: node --test functional_tests\test_m365_pending_action_cards.js From d29da17107e97b7dc00ab54a0634d5ef0328aa9b Mon Sep 17 00:00:00 2001 From: Chad Palmer Date: Mon, 28 Sep 2026 17:16:32 +0000 Subject: [PATCH 4/6] fix-new-chat-collab-invite - Fixed issue where new chats could not add participants. --- application/single_app/config.py | 2 +- .../single_app/route_backend_conversations.py | 2 +- .../static/js/chat/chat-collaboration.js | 10 +++- .../js/chat/chat-conversation-details.js | 37 +++++++++++-- .../static/js/chat/chat-conversations.js | 4 +- .../static/js/chat/chat-messages.js | 50 ++++++++++++----- .../js/chat/chat-sidebar-conversations.js | 2 +- ...AT_MESSAGE_MARKDOWN_SANITIZER_GUARD_FIX.md | 47 ++++++++++++++++ ...ON_NEW_CHAT_PARTICIPANT_ELIGIBILITY_FIX.md | 53 +++++++++++++++++++ ...t_chat_message_markdown_sanitizer_guard.py | 50 +++++++++++++++++ .../test_chat_type_normalization.py | 37 +++++++++++-- .../test_chat_collaboration_ui_scaffolding.py | 31 +++++++++-- 12 files changed, 298 insertions(+), 27 deletions(-) create mode 100644 docs/explanation/fixes/CHAT_MESSAGE_MARKDOWN_SANITIZER_GUARD_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_NEW_CHAT_PARTICIPANT_ELIGIBILITY_FIX.md create mode 100644 functional_tests/test_chat_message_markdown_sanitizer_guard.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 79c93e1a6..f4185657d 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -98,7 +98,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.046" +VERSION = "0.261.047" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 440077665..42e089dd4 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -73,7 +73,7 @@ def normalize_chat_type(conversation_item): chat_type = conversation_item.get('chat_type') if chat_type: - if chat_type == 'personal': + if chat_type in ('personal', 'new'): conversation_item['chat_type'] = 'personal_single_user' return conversation_item['chat_type'], True return chat_type, False diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index 788651e3d..338bfd0fc 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -1782,8 +1782,16 @@ function openParticipantConfirmation(userSummary, context = {}) { bootstrap.Modal.getOrCreateInstance(confirmModalEl).show(); } +function normalizeParticipantFlowChatType(chatType) { + const normalizedChatType = String(chatType || '').trim(); + if (normalizedChatType === 'personal' || normalizedChatType === 'new') { + return 'personal_single_user'; + } + return normalizedChatType; +} + function canUseParticipantFlow(conversationId) { - const chatType = getConversationChatType(conversationId); + const chatType = normalizeParticipantFlowChatType(getConversationChatType(conversationId)); if (!chatType || !['personal_single_user', 'personal_multi_user', 'group-single-user', 'group_multi_user'].includes(chatType)) { return false; } diff --git a/application/single_app/static/js/chat/chat-conversation-details.js b/application/single_app/static/js/chat/chat-conversation-details.js index 48f1e0142..47e84013e 100644 --- a/application/single_app/static/js/chat/chat-conversation-details.js +++ b/application/single_app/static/js/chat/chat-conversation-details.js @@ -180,6 +180,36 @@ function renderConversationDetailsActions(metadata, conversationId) { actionContainer.innerHTML = actionButtons.join(''); } +function normalizeParticipantChatType(chatType, context = []) { + const normalizedChatType = String(chatType || '').trim(); + if (normalizedChatType === 'personal' || normalizedChatType === 'new') { + return 'personal_single_user'; + } + if (normalizedChatType) { + return normalizedChatType; + } + + const primaryContext = Array.isArray(context) + ? context.find(item => item?.type === 'primary') + : null; + if (primaryContext?.scope === 'group') { + return 'group-single-user'; + } + if (primaryContext?.scope === 'public') { + return 'public'; + } + return 'personal_single_user'; +} + +function canManageLegacyConversationParticipants(metadata = {}) { + if (metadata.conversation_kind === 'collaborative') { + return false; + } + + const chatType = normalizeParticipantChatType(metadata.chat_type, metadata.context || []); + return ['personal_single_user', 'group-single-user'].includes(chatType); +} + /** * Show conversation details in a modal * @param {string} conversationId - The conversation ID to show details for @@ -277,6 +307,7 @@ function renderConversationMetadata(metadata, conversationId) { } = metadata; const safeConversationId = escapeHtml(conversationId); const resolvedLastUpdated = last_updated || updated_at; + const canInviteParticipants = can_manage_members || canManageLegacyConversationParticipants(metadata); // Organize tags by category const tagsByCategory = { @@ -374,17 +405,17 @@ function renderConversationMetadata(metadata, conversationId) { } // Participants Section - if (participantRecords.length > 0 || can_manage_members || can_accept_invite) { + if (participantRecords.length > 0 || canInviteParticipants || can_accept_invite) { html += `
Participants
- ${renderCollaborationActionButtons(conversationId, metadata)} + ${renderCollaborationActionButtons(conversationId, { ...metadata, can_manage_members: canInviteParticipants })}
${renderParticipantsSection(participantRecords, { - canManageMembers: can_manage_members, + canManageMembers: canInviteParticipants, canManageRoles: can_manage_roles, conversationKind: conversation_kind, })} diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js index bb8be2edf..8ff94bc8d 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -962,7 +962,7 @@ export function createConversationItem(convo) { } convoItem.dataset.hasUnreadAssistantResponse = convo.has_unread_assistant_response ? "true" : "false"; const isCollaborativeConversation = convo.conversation_kind === 'collaborative'; - const conversationChatType = convo.chat_type === 'personal' ? 'personal_single_user' : convo.chat_type; + const conversationChatType = ['personal', 'new'].includes(convo.chat_type) ? 'personal_single_user' : convo.chat_type; const canManageMembers = isCollaborativeConversation ? Boolean(convo.can_manage_members) : ['personal_single_user', 'group-single-user'].includes(conversationChatType || ''); @@ -995,7 +995,7 @@ export function createConversationItem(convo) { // Use the actual chat_type from conversation metadata if available console.log(`createConversationItem: Processing conversation ${convo.id}, chat_type="${convo.chat_type}"`); - const normalizedChatType = convo.chat_type === 'personal' ? 'personal_single_user' : convo.chat_type; + const normalizedChatType = ['personal', 'new'].includes(convo.chat_type) ? 'personal_single_user' : convo.chat_type; if (normalizedChatType) { convoItem.setAttribute("data-chat-type", normalizedChatType); console.log(`createConversationItem: Set data-chat-type to "${normalizedChatType}"`); diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index f4a3c2661..d1636b206 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -106,6 +106,38 @@ const DEFAULT_DOCUMENT_ACTION_CAPABILITIES = { }, }; +function getMarkdownRenderer() { + const purifier = globalThis.DOMPurify; + const markdown = globalThis.marked; + if (!purifier || typeof purifier.sanitize !== 'function') { + return null; + } + if (!markdown || typeof markdown.parse !== 'function') { + return null; + } + return { purifier, markdown }; +} + +function renderMarkdownSafely(markdownText, options = {}) { + const text = String(markdownText || ''); + const renderer = getMarkdownRenderer(); + if (!renderer) { + return escapeHtml(text); + } + + const markdownInput = options.escapeInput ? escapeHtml(text) : text; + return renderer.purifier.sanitize(renderer.markdown.parse(markdownInput)); +} + +function sanitizeHtmlSafely(htmlText) { + const html = String(htmlText || ''); + const purifier = globalThis.DOMPurify; + if (!purifier || typeof purifier.sanitize !== 'function') { + return escapeHtml(html); + } + return purifier.sanitize(html); +} + function getChatWorkspaceProgressValue(value) { const numericValue = Number(value); if (!Number.isFinite(numericValue)) { @@ -2795,7 +2827,7 @@ function renderReplyQuoteHtml(fullMessageObject = null) { const withMarkdownTables = convertUnicodeTableToMarkdown(withUnwrappedTables); const withPSVTables = convertPSVCodeBlockToMarkdown(withMarkdownTables); const withASCIITables = convertASCIIDashTableToMarkdown(withPSVTables); - const sanitizedHtml = DOMPurify.sanitize(marked.parse(withASCIITables)); + const sanitizedHtml = renderMarkdownSafely(withASCIITables); const htmlWithCharts = injectInlineChartHtml(sanitizedHtml, chartExtraction.blocks); const htmlWithImageProposals = injectInlineImageProposalHtml(htmlWithCharts, imageProposalExtraction.blocks); const copyMarkdown = restoreInlineChartTokens( @@ -4198,9 +4230,9 @@ function renderReplyQuoteHtml(fullMessageObject = null) { return previewBlock; } - const sanitizedHtml = DOMPurify.sanitize(marked.parse(normalizedPreviewText)); + const sanitizedHtml = renderMarkdownSafely(normalizedPreviewText); const linkedHtml = addTargetBlankToExternalLinks(sanitizedHtml); - previewBlock.innerHTML = DOMPurify.sanitize(linkedHtml); + previewBlock.innerHTML = sanitizeHtmlSafely(linkedHtml); return previewBlock; } @@ -6284,9 +6316,7 @@ export function appendMessage( } const renderedMessageContent = stripMentionTextFromMessageContent(messageContent, fullMessageObject); - const sanitizedUserHtml = DOMPurify.sanitize( - marked.parse(escapeHtml(renderedMessageContent)) - ); + const sanitizedUserHtml = renderMarkdownSafely(renderedMessageContent, { escapeInput: true }); messageContentHtml = addTargetBlankToExternalLinks(sanitizedUserHtml); } else if (sender === "Collaborator") { messageClass = "collaborator-message"; @@ -6296,9 +6326,7 @@ export function appendMessage( avatarAltText = `${senderLabel} Avatar`; avatarHtml = createCollaboratorAvatarHtml(fullMessageObject, senderLabel); const renderedMessageContent = stripMentionTextFromMessageContent(messageContent, fullMessageObject); - const sanitizedCollaboratorHtml = DOMPurify.sanitize( - marked.parse(escapeHtml(renderedMessageContent)) - ); + const sanitizedCollaboratorHtml = renderMarkdownSafely(renderedMessageContent, { escapeInput: true }); messageContentHtml = addTargetBlankToExternalLinks(sanitizedCollaboratorHtml); } else if (sender === "File") { messageClass = "file-message"; @@ -6367,9 +6395,7 @@ export function appendMessage( avatarAltText = "Content Safety Avatar"; avatarImg = "/static/images/alert.png"; const linkToViolations = `
View My Safety Violations`; - const sanitizedSafetyHtml = DOMPurify.sanitize( - marked.parse(messageContent + linkToViolations) - ); + const sanitizedSafetyHtml = renderMarkdownSafely(messageContent + linkToViolations); messageContentHtml = addTargetBlankToExternalLinks(sanitizedSafetyHtml); } else if (sender === "Error") { messageClass = "error-message"; diff --git a/application/single_app/static/js/chat/chat-sidebar-conversations.js b/application/single_app/static/js/chat/chat-sidebar-conversations.js index 6b1dda2a8..e857975f9 100644 --- a/application/single_app/static/js/chat/chat-sidebar-conversations.js +++ b/application/single_app/static/js/chat/chat-sidebar-conversations.js @@ -38,7 +38,7 @@ function getShortGroupLabel(name) { } function normalizeSidebarChatType(chatType, context = []) { - if (chatType === 'personal') { + if (chatType === 'personal' || chatType === 'new') { return 'personal_single_user'; } diff --git a/docs/explanation/fixes/CHAT_MESSAGE_MARKDOWN_SANITIZER_GUARD_FIX.md b/docs/explanation/fixes/CHAT_MESSAGE_MARKDOWN_SANITIZER_GUARD_FIX.md new file mode 100644 index 000000000..0a3b38715 --- /dev/null +++ b/docs/explanation/fixes/CHAT_MESSAGE_MARKDOWN_SANITIZER_GUARD_FIX.md @@ -0,0 +1,47 @@ +# Chat Message Markdown Sanitizer Guard Fix (v0.261.047) + +Fixed and implemented in version: **0.261.047** + +## Issue Description + +Some chat message rendering paths assumed the global `DOMPurify` and `marked` browser objects were always available. If either local asset had not loaded when a user sent a message, `appendMessage()` could throw `ReferenceError: DOMPurify is not defined` and interrupt the send flow. + +## Root Cause Analysis + +The chat page loads local Markdown and sanitizer assets from `base.html`, but `chat-messages.js` used those globals directly in multiple message rendering paths. Other chat modules already guarded this dependency. The user and collaborator message branches did not, so a missing global became a runtime crash instead of a safe escaped-text fallback. + +## Technical Details + +### Files Modified + +- `application/single_app/static/js/chat/chat-messages.js` +- `functional_tests/test_chat_message_markdown_sanitizer_guard.py` +- `application/single_app/config.py` + +### Code Changes Summary + +- Added guarded Markdown rendering helpers in `chat-messages.js`. +- Preserved sanitized Markdown rendering when `DOMPurify` and `marked` are available. +- Added escaped-text fallbacks when either global is unavailable. +- Replaced direct `DOMPurify.sanitize(...)` and `marked.parse(...)` calls in chat message rendering paths. + +### Testing Approach + +- Added a functional static regression that checks the guarded helpers exist and direct sanitizer/parser calls are not reintroduced in `chat-messages.js`. +- Ran JavaScript syntax checks for the changed chat modules. + +## Validation + +### Test Results + +- `node --check application/single_app/static/js/chat/chat-messages.js` + +### Before And After + +- Before: sending a message could fail if `DOMPurify` was unavailable at render time. +- After: chat rendering uses sanitized Markdown when the local libraries are present and safely falls back to escaped text when they are not. + +### User Experience Improvements + +- Sending a chat message no longer fails because of a missing sanitizer global. +- The fallback keeps message text visible without widening the browser rendering trust boundary. \ No newline at end of file diff --git a/docs/explanation/fixes/COLLABORATION_NEW_CHAT_PARTICIPANT_ELIGIBILITY_FIX.md b/docs/explanation/fixes/COLLABORATION_NEW_CHAT_PARTICIPANT_ELIGIBILITY_FIX.md new file mode 100644 index 000000000..9f2b628b5 --- /dev/null +++ b/docs/explanation/fixes/COLLABORATION_NEW_CHAT_PARTICIPANT_ELIGIBILITY_FIX.md @@ -0,0 +1,53 @@ +# Collaboration New Chat Participant Eligibility Fix (v0.261.047) + +Fixed and implemented in version: **0.261.047** + +## Issue Description + +Starting a chat from a personal workspace file creates a fresh personal conversation and preselects the file for grounded chat. Before the first prompt was sent or the conversation was reloaded from server metadata, the browser stored that conversation with the transient `new` chat type. The collaboration UI treated `new` as ineligible for participant management, so **Add participants** did not appear even when collaborative conversations were enabled. + +## Root Cause Analysis + +The backend creates new personal conversations with `chat_type: "new"` until metadata collection classifies the conversation. The collaboration participant picker and conversation action menu only allowed already-normalized personal and group chat types. That left a short-lived but user-visible gap for document-launched personal chats. + +## Technical Details + +### Files Modified + +- `application/single_app/static/js/chat/chat-collaboration.js` +- `application/single_app/static/js/chat/chat-conversations.js` +- `application/single_app/static/js/chat/chat-conversation-details.js` +- `application/single_app/static/js/chat/chat-sidebar-conversations.js` +- `application/single_app/route_backend_conversations.py` +- `functional_tests/test_chat_type_normalization.py` +- `ui_tests/test_chat_collaboration_ui_scaffolding.py` +- `application/single_app/config.py` + +### Code Changes Summary + +- Normalized transient `new` chat types to `personal_single_user` for participant-flow eligibility in the chat UI. +- Updated conversation list action rendering so freshly created personal chats can show **Add participants** immediately. +- Updated sidebar and conversation details rendering so the same action is available without leaving or reloading Chat. +- Updated backend conversation metadata normalization so details payloads do not keep stale `new` chat types after normalization. + +### Testing Approach + +- Extended chat type normalization coverage to assert the `new` to `personal_single_user` eligibility contract. +- Extended collaboration UI scaffolding coverage to exercise `openParticipantPicker()` against a mocked fresh `new` conversation item. +- Ran JavaScript syntax checks for the changed chat modules. + +## Validation + +### Test Results + +- `node --check application/single_app/static/js/chat/chat-collaboration.js` +- `node --check application/single_app/static/js/chat/chat-conversations.js` + +### Before And After + +- Before: a personal workspace file-launched chat could remain marked as `new` in the browser, hiding **Add participants** until Chat was fully reloaded. +- After: the transient `new` placeholder is treated as a personal single-user chat for participant eligibility across the main list, sidebar, picker, and details modal, while backend ownership and collaboration checks remain authoritative. + +### User Experience Improvements + +- Users can invite participants into a newly created personal file chat without needing to send a message, reload, or reselect the conversation first. \ No newline at end of file diff --git a/functional_tests/test_chat_message_markdown_sanitizer_guard.py b/functional_tests/test_chat_message_markdown_sanitizer_guard.py new file mode 100644 index 000000000..59b54b3d9 --- /dev/null +++ b/functional_tests/test_chat_message_markdown_sanitizer_guard.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +Functional test for chat message markdown sanitizer availability guard. +Version: 0.261.047 +Implemented in: 0.261.047 + +This test ensures chat-messages.js routes Markdown rendering through guarded +helpers so a missing DOMPurify or marked global cannot break message sending. +""" + +from pathlib import Path + +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHAT_MESSAGES_JS = REPO_ROOT / "application" / "single_app" / "static" / "js" / "chat" / "chat-messages.js" + + +def assert_contains(source, expected, label): + if expected not in source: + raise AssertionError(f"Expected {label}: {expected}") + + +def assert_not_contains(source, unexpected, label): + if unexpected in source: + raise AssertionError(f"Unexpected unguarded {label}: {unexpected}") + + +def test_chat_message_markdown_sanitizer_guard(): + """Validate guarded Markdown rendering helpers are used in chat messages.""" + assert_app_version_at_least("0.261.047", REPO_ROOT) + + source = CHAT_MESSAGES_JS.read_text(encoding="utf-8") + + assert_contains(source, "function getMarkdownRenderer()", "markdown renderer availability helper") + assert_contains(source, "function renderMarkdownSafely(markdownText, options = {})", "safe markdown render helper") + assert_contains(source, "function sanitizeHtmlSafely(htmlText)", "safe html sanitizer helper") + assert_contains(source, "return escapeHtml(text);", "escaped markdown fallback") + assert_contains(source, "return escapeHtml(html);", "escaped html fallback") + assert_not_contains(source, "DOMPurify.sanitize(", "DOMPurify call") + assert_not_contains(source, "marked.parse(", "marked parse call") + + print("✅ Chat message markdown sanitizer guard verified") + return True + + +if __name__ == "__main__": + success = test_chat_message_markdown_sanitizer_guard() + raise SystemExit(0 if success else 1) \ No newline at end of file diff --git a/functional_tests/test_chat_type_normalization.py b/functional_tests/test_chat_type_normalization.py index dec83b6ee..7b60bc6ff 100644 --- a/functional_tests/test_chat_type_normalization.py +++ b/functional_tests/test_chat_type_normalization.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """ Functional test for chat type normalization. -Version: 0.236.065 -Implemented in: 0.236.065 +Version: 0.261.047 +Implemented in: 0.236.065; expanded in 0.261.047 This test ensures new conversations are marked with chat_type "new" and personal conversations normalize to "personal_single_user" across the UI -and backend metadata paths. +and backend metadata paths, including collaboration participant eligibility +before a freshly created personal chat is reloaded. """ import os @@ -14,6 +15,8 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__))) +from test_support.versioning import assert_app_version_at_least + def _read_file(path): with open(path, "r", encoding="utf-8") as handle: @@ -38,6 +41,24 @@ def test_chat_type_normalization(): "chat", "chat-conversations.js", ) + js_sidebar_conversations_path = os.path.join( + repo_root, + "application", + "single_app", + "static", + "js", + "chat", + "chat-sidebar-conversations.js", + ) + js_collaboration_path = os.path.join( + repo_root, + "application", + "single_app", + "static", + "js", + "chat", + "chat-collaboration.js", + ) js_details_path = os.path.join( repo_root, "application", @@ -67,14 +88,24 @@ def test_chat_type_normalization(): ) js_conversations = _read_file(js_conversations_path) + js_sidebar_conversations = _read_file(js_sidebar_conversations_path) + js_collaboration = _read_file(js_collaboration_path) js_details = _read_file(js_details_path) backend_conversations = _read_file(backend_conversations_path) backend_chats = _read_file(backend_chats_path) metadata = _read_file(metadata_path) + assert_app_version_at_least("0.261.047", repo_root) + _assert_contains(js_conversations, 'chat_type: "new"', "chat-conversations.js") + _assert_contains(js_conversations, "['personal', 'new'].includes(convo.chat_type)", "chat-conversations.js") + _assert_contains(js_sidebar_conversations, "chatType === 'personal' || chatType === 'new'", "chat-sidebar-conversations.js") + _assert_contains(js_collaboration, "normalizedChatType === 'personal' || normalizedChatType === 'new'", "chat-collaboration.js") _assert_contains(js_conversations, 'personal_single_user', "chat-conversations.js") + _assert_contains(js_details, "function canManageLegacyConversationParticipants", "chat-conversation-details.js") + _assert_contains(js_details, "canInviteParticipants", "chat-conversation-details.js") _assert_contains(js_details, 'personal_single_user', "chat-conversation-details.js") + _assert_contains(backend_conversations, "chat_type in ('personal', 'new')", "route_backend_conversations.py") _assert_contains(backend_conversations, "personal_single_user", "route_backend_conversations.py") _assert_contains(backend_chats, "personal_single_user", "route_backend_chats.py") _assert_contains(metadata, "personal_single_user", "functions_conversation_metadata.py") diff --git a/ui_tests/test_chat_collaboration_ui_scaffolding.py b/ui_tests/test_chat_collaboration_ui_scaffolding.py index d79d7e4ec..3dbc8943c 100644 --- a/ui_tests/test_chat_collaboration_ui_scaffolding.py +++ b/ui_tests/test_chat_collaboration_ui_scaffolding.py @@ -1,8 +1,8 @@ # test_chat_collaboration_ui_scaffolding.py """ UI test for chat collaboration scaffolding. -Version: 0.241.020 -Implemented in: 0.241.020 +Version: 0.261.047 +Implemented in: 0.241.020; expanded in 0.261.047 This test ensures the authenticated chats page loads the collaboration UI containers needed for participant management and @-mention suggestions without @@ -10,7 +10,8 @@ deactivation path used when switching away from a shared conversation and the add-participants picker entry point used from existing chat actions. It also validates the shared conversation details footer actions and the new delete or -leave modal flow for both legacy and collaborative chats. +leave modal flow for both legacy and collaborative chats, including freshly +created personal chats that still carry the transient `new` chat type. """ import os @@ -117,6 +118,30 @@ def track_console(message): } """) + page.evaluate(""" + () => { + const mockConversationId = 'mock-new-personal-conversation'; + const mockConversationItem = document.createElement('div'); + mockConversationItem.className = 'conversation-item'; + mockConversationItem.dataset.conversationId = mockConversationId; + mockConversationItem.dataset.chatType = 'new'; + document.body.appendChild(mockConversationItem); + + const participantModal = document.getElementById('collaboration-participant-modal'); + const modalInstance = bootstrap.Modal.getOrCreateInstance(participantModal); + + try { + window.chatCollaboration.openParticipantPicker({ conversationId: mockConversationId }); + if (!participantModal.classList.contains('show')) { + throw new Error('Participant picker did not open for a freshly created personal conversation.'); + } + } finally { + modalInstance.hide(); + mockConversationItem.remove(); + } + } + """) + page.evaluate(""" () => { const replyPreview = document.getElementById('collaboration-reply-preview'); From 48f6a9e9135c7e4e8b2b999377e1ce9c67756ed7 Mon Sep 17 00:00:00 2001 From: Chad Palmer Date: Wed, 30 Sep 2026 18:06:25 +0000 Subject: [PATCH 5/6] fix-agent-menu - Fix agent creation menu so that it follows governance policy. --- application/single_app/config.py | 2 +- .../single_app/route_backend_agents.py | 20 ++- .../single_app/static/js/agents_common.js | 2 +- .../AGENT_MODEL_GOVERNANCE_FILTERING_FIX.md | 30 ++++ docs/explanation/release_notes.md | 9 ++ ...st_agent_modal_model_endpoint_filtering.py | 10 +- .../test_agent_model_governance_filtering.py | 133 ++++++++++++++++++ 7 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 docs/explanation/fixes/AGENT_MODEL_GOVERNANCE_FILTERING_FIX.md create mode 100644 functional_tests/test_agent_model_governance_filtering.py diff --git a/application/single_app/config.py b/application/single_app/config.py index f4185657d..ff2502c99 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -98,7 +98,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.047" +VERSION = "0.261.048" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 5cfe219fc..9684836aa 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -57,7 +57,7 @@ log_agent_deletion, log_general_admin_action, ) -from functions_governance import ensure_governance_access, upsert_item_policy +from functions_governance import ensure_governance_access, filter_governed_model_endpoints, upsert_item_policy from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers bpa = Blueprint('admin_agents', __name__) @@ -2152,6 +2152,12 @@ def orchestration_settings(): def build_combined_model_endpoints(settings, user_id=None, group_id=None): endpoints = [] global_endpoints = settings.get("model_endpoints", []) or [] + if user_id: + global_endpoints = filter_governed_model_endpoints( + user_id, + global_endpoints, + "governance_global_endpoints", + ) for endpoint in global_endpoints: enriched = dict(endpoint) enriched["scope"] = "global" @@ -2164,7 +2170,11 @@ def build_combined_model_endpoints(settings, user_id=None, group_id=None): if allow_group_custom_endpoints: try: ensure_governance_access("governance_group_endpoints", user_id) - group_endpoints = get_group_model_endpoints(group_id) + group_endpoints = filter_governed_model_endpoints( + user_id, + get_group_model_endpoints(group_id), + "governance_group_endpoints", + ) for endpoint in group_endpoints: enriched = dict(endpoint) enriched["scope"] = "group" @@ -2177,7 +2187,11 @@ def build_combined_model_endpoints(settings, user_id=None, group_id=None): try: ensure_governance_access("governance_user_endpoints", user_id) user_settings = get_user_settings(user_id) - personal = user_settings.get("settings", {}).get("personal_model_endpoints", []) + personal = filter_governed_model_endpoints( + user_id, + user_settings.get("settings", {}).get("personal_model_endpoints", []), + "governance_user_endpoints", + ) for endpoint in personal: enriched = dict(endpoint) enriched["scope"] = "user" diff --git a/application/single_app/static/js/agents_common.js b/application/single_app/static/js/agents_common.js index 01b71de73..cf16f6bb6 100644 --- a/application/single_app/static/js/agents_common.js +++ b/application/single_app/static/js/agents_common.js @@ -689,7 +689,7 @@ export function getAvailableModels({ apimEnabled, settings, agent }) { let selectedModel = null; const endpoints = Array.isArray(settings?.model_endpoints) ? settings.model_endpoints : []; const multiEndpointEnabled = (settings && settings.enable_multi_model_endpoints) || endpoints.length > 0; - if (multiEndpointEnabled && endpoints.length) { + if (multiEndpointEnabled) { const agentType = (agent && agent.agent_type) ? agent.agent_type : 'local'; endpoints.forEach(endpoint => { if (!endpoint || endpoint.enabled === false) return; diff --git a/docs/explanation/fixes/AGENT_MODEL_GOVERNANCE_FILTERING_FIX.md b/docs/explanation/fixes/AGENT_MODEL_GOVERNANCE_FILTERING_FIX.md new file mode 100644 index 000000000..d4e29f121 --- /dev/null +++ b/docs/explanation/fixes/AGENT_MODEL_GOVERNANCE_FILTERING_FIX.md @@ -0,0 +1,30 @@ +# Agent Model Governance Filtering Fix + +Fixed/Implemented in version: **0.261.048** + +## Issue Description + +The chat window model selector respected endpoint governance policies, but the persona workspace agent setup modal could show globally configured models that the signed-in user was not allowed to use. A user could therefore see a restricted model, such as a GPT 5.1 deployment intended only for another user, while creating or editing a personal agent. + +## Root Cause Analysis + +The chat bootstrap path filtered model endpoints through the governance helper before serializing dropdown options. The agent settings endpoint built its own combined endpoint list and appended global endpoints directly, so item-level global endpoint governance was skipped for user-facing agent modal payloads. + +## Technical Details + +Files modified: + +- `application/single_app/route_backend_agents.py` +- `application/single_app/static/js/agents_common.js` +- `application/single_app/config.py` +- `functional_tests/test_agent_model_governance_filtering.py` + +The agent settings endpoint now uses the shared model endpoint governance filter when building user-facing global, personal, and group endpoint lists. Admin/internal calls without a user context still receive the full endpoint catalog needed for management and migration workflows. + +The shared agent modal loader also treats multi-endpoint mode as authoritative. If governance filters every endpoint out of the user-facing response, the dropdown shows no available models instead of falling back to the legacy global `gpt_model.selected` list. + +## Validation + +Added `functional_tests/test_agent_model_governance_filtering.py` to verify that governed agent settings payloads exclude denied global, personal, and group endpoints while preserving the unfiltered admin/internal endpoint list. + +Reference version update: `application/single_app/config.py` now reports **0.261.048**. \ No newline at end of file diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index fa1093ec9..427f4b1d5 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](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.048)** + +#### Bug Fixes + +* **Agent Model Picker Respects Governance Policies** + * Fixed the persona workspace agent setup modal so its model dropdown now follows the same endpoint governance policies as the chat window model selector. + * Users no longer see restricted global, personal, or group model endpoints when creating or editing agents, and governed multi-endpoint responses no longer fall back to legacy global model lists. + * (Ref: agent model picker, endpoint governance, `route_backend_agents.py`, `agents_common.js`, [Agent Model Governance Filtering Fix](fixes/AGENT_MODEL_GOVERNANCE_FILTERING_FIX.md)) + ### **(v0.261.046)** #### Bug Fixes diff --git a/functional_tests/test_agent_modal_model_endpoint_filtering.py b/functional_tests/test_agent_modal_model_endpoint_filtering.py index 1083e77ed..005224e49 100644 --- a/functional_tests/test_agent_modal_model_endpoint_filtering.py +++ b/functional_tests/test_agent_modal_model_endpoint_filtering.py @@ -1,11 +1,13 @@ # test_agent_modal_model_endpoint_filtering.py """ Functional test for agent modal model endpoint filtering. -Version: 0.236.056 -Implemented in: 0.236.056 +Version: 0.261.048 +Implemented in: 0.236.056; updated in 0.261.048 This test ensures the agent modal dropdown includes non-AOAI providers for local -agents and normalizes model IDs/display names when building the model list. +agents, normalizes model IDs/display names when building the model list, and +does not fall back to legacy global model settings when governed multi-endpoint +settings return no available endpoints. """ import os @@ -40,6 +42,8 @@ def test_agent_modal_model_endpoint_filtering(): "const modelId = model.id", "const displayName = model.displayName", "display_name: displayName", + "if (multiEndpointEnabled) {", + "return { models, selectedModel };\n\t}\n\tif (apimEnabled)", ] missing = [snippet for snippet in required_snippets if snippet not in content] diff --git a/functional_tests/test_agent_model_governance_filtering.py b/functional_tests/test_agent_model_governance_filtering.py new file mode 100644 index 000000000..b62a9e9e6 --- /dev/null +++ b/functional_tests/test_agent_model_governance_filtering.py @@ -0,0 +1,133 @@ +# test_agent_model_governance_filtering.py +#!/usr/bin/env python3 +""" +Functional test for agent model governance filtering. +Version: 0.261.048 +Implemented in: 0.261.048 + +This test ensures the personal and group agent model dropdown payloads apply +governance policies before returning model endpoints to the browser. +""" + +import ast +import os +import sys +from pathlib import Path + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from test_support.versioning import assert_app_version_at_least # noqa: E402 + + +REPO_ROOT = Path(__file__).resolve().parents[1] +AGENTS_ROUTE = REPO_ROOT / "application" / "single_app" / "route_backend_agents.py" + + +def _load_build_combined_model_endpoints(): + source = AGENTS_ROUTE.read_text(encoding="utf-8") + tree = ast.parse(source) + function_node = next( + ( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name == "build_combined_model_endpoints" + ), + None, + ) + if function_node is None: + raise AssertionError("build_combined_model_endpoints was not found.") + + module = ast.Module(body=[function_node], type_ignores=[]) + ast.fix_missing_locations(module) + namespace = {} + exec(compile(module, str(AGENTS_ROUTE), "exec"), namespace) + return namespace["build_combined_model_endpoints"] + + +def test_agent_settings_model_endpoints_respect_governance(): + print("Testing agent settings model endpoint governance filtering...") + assert_app_version_at_least("0.261.048") + + calls = [] + build_combined_model_endpoints = _load_build_combined_model_endpoints() + + def filter_governed_model_endpoints(user_id, endpoints, feature_key): + calls.append((user_id, feature_key, [endpoint.get("id") for endpoint in endpoints])) + return [endpoint for endpoint in endpoints if "denied" not in endpoint.get("id", "")] + + def sanitize_model_endpoints_for_frontend(endpoints): + return endpoints + + def ensure_governance_access(feature_key, user_id): + calls.append((user_id, feature_key, "feature-check")) + + def get_user_settings(user_id): + return { + "settings": { + "personal_model_endpoints": [ + {"id": "personal-allowed", "models": [{"id": "personal-model"}]}, + {"id": "personal-denied", "models": [{"id": "restricted-personal-model"}]}, + ] + } + } + + def get_group_model_endpoints(group_id): + return [ + {"id": "group-allowed", "models": [{"id": "group-model"}]}, + {"id": "group-denied", "models": [{"id": "restricted-group-model"}]}, + ] + + build_combined_model_endpoints.__globals__.update({ + "filter_governed_model_endpoints": filter_governed_model_endpoints, + "sanitize_model_endpoints_for_frontend": sanitize_model_endpoints_for_frontend, + "ensure_governance_access": ensure_governance_access, + "get_user_settings": get_user_settings, + "get_group_model_endpoints": get_group_model_endpoints, + }) + + settings = { + "model_endpoints": [ + {"id": "global-allowed", "models": [{"id": "allowed-model"}]}, + {"id": "global-denied", "models": [{"id": "restricted-model"}]}, + ], + "allow_user_custom_endpoints": True, + "allow_group_custom_endpoints": True, + } + + personal_ids = [ + endpoint["id"] + for endpoint in build_combined_model_endpoints(settings, user_id="user-b") + ] + assert personal_ids == ["global-allowed", "personal-allowed"], personal_ids + + group_ids = [ + endpoint["id"] + for endpoint in build_combined_model_endpoints( + settings, + user_id="user-b", + group_id="group-1", + ) + ] + assert group_ids == ["global-allowed", "group-allowed"], group_ids + + admin_ids = [endpoint["id"] for endpoint in build_combined_model_endpoints(settings)] + assert admin_ids == ["global-allowed", "global-denied"], admin_ids + + asserted_filters = {(call[0], call[1]) for call in calls if len(call) == 3} + assert ("user-b", "governance_global_endpoints") in asserted_filters + assert ("user-b", "governance_user_endpoints") in asserted_filters + assert ("user-b", "governance_group_endpoints") in asserted_filters + print("PASS: agent settings model endpoint governance filtering verified") + + +if __name__ == "__main__": + try: + test_agent_settings_model_endpoints_respect_governance() + sys.exit(0) + except Exception as exc: + print(f"FAIL: {exc}") + import traceback + + traceback.print_exc() + sys.exit(1) \ No newline at end of file From 7d4118d2d49fb2c31470e23febc8a955d96004ec Mon Sep 17 00:00:00 2001 From: Chad Palmer Date: Thu, 1 Oct 2026 20:17:26 +0000 Subject: [PATCH 6/6] fix-shared-convo-and-agent-menu - Fix collaboration stream test offline bootstrap. --- .../test_collaboration_group_agent_stream_fix.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/functional_tests/test_collaboration_group_agent_stream_fix.py b/functional_tests/test_collaboration_group_agent_stream_fix.py index cf39a2b55..61ad429ba 100644 --- a/functional_tests/test_collaboration_group_agent_stream_fix.py +++ b/functional_tests/test_collaboration_group_agent_stream_fix.py @@ -2,8 +2,8 @@ # test_collaboration_group_agent_stream_fix.py """ Functional test for group collaboration agent stream completion. -Version: 0.261.045 -Implemented in: 0.261.045 +Version: 0.261.048 +Implemented in: 0.261.045; updated in 0.261.048 This test ensures group collaborative agent responses complete through the shared stream bridge even when mirrored agent citation payloads contain nested @@ -16,6 +16,7 @@ import sys from flask import Flask, Response +from test_support.offline_bootstrap import offline_app_imports REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -77,7 +78,12 @@ def get_event_cursor(self): def build_group_agent_stream_test_app(): """Register the collaboration stream route with isolated fake dependencies.""" - import route_backend_collaboration + if 'route_backend_collaboration' in sys.modules: + route_backend_collaboration = sys.modules['route_backend_collaboration'] + else: + with offline_app_imports(): + import route_backend_collaboration + from collaboration_models import MESSAGE_KIND_AI_REQUEST, MESSAGE_KIND_ASSISTANT conversation_id = 'shared-agent-conversation-001'