diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py index 864161d4d..2fcf08655 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): @@ -651,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)) @@ -949,12 +1029,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 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/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py index 40afb72b6..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) @@ -309,18 +314,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: @@ -1348,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/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/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/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index 788651e3d..8b4f343d7 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 || {}); @@ -995,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); @@ -1027,9 +997,11 @@ async function loadConversationMessages(conversationId) { renderCollaborationMessage(decoratedMessage); cacheCollaborationMessage(message); }); - void window.SimpleChatM365PendingActions?.refreshConversation(conversationId); reapplyPendingSearchHighlight(); - return messages; + return { + messages, + eventCursor: Math.max(0, Number(payload.event_cursor) || 0), + }; } function handleTypingEvent(payload = {}) { @@ -1060,8 +1032,6 @@ function disconnectConversationEvents() { activeCollaborationEventSource = null; } - activeSubscriptionStartedAt = 0; - if (typingStopHandle) { window.clearTimeout(typingStopHandle); typingStopHandle = null; @@ -1082,6 +1052,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 +1071,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) { @@ -1226,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; } @@ -1240,15 +1212,17 @@ function handleConversationEvent(eventEnvelope = {}) { } } -function subscribeToConversationEvents(conversationId) { +function subscribeToConversationEvents(conversationId, eventCursor = 0) { if (!isCollaborationEnabled() || !conversationId || typeof EventSource === 'undefined') { return; } disconnectConversationEvents(); activeCollaborativeConversationId = conversationId; - activeSubscriptionStartedAt = Date.now(); - 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; @@ -1335,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); @@ -1782,8 +1756,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; } @@ -1902,8 +1884,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); + 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/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..7126d9cd5 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -265,6 +265,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; @@ -333,6 +383,8 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) { convoItem.removeAttribute('data-chat-state'); } + syncConversationAddParticipantsAction(convoItem); + updateConversationCache(conversationId, { title: updates.title, classification: updates.classification, @@ -962,7 +1014,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 +1047,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}"`); @@ -1519,7 +1571,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; window.adminNewConversationDefaultsActive = false; @@ -1539,21 +1591,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) { @@ -1665,7 +1722,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) { @@ -2185,6 +2253,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-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..37266f861 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'; } @@ -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..42e026722 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('', { background: true }); + }, delay); + } + function place(id) { const view = views.get(id); if (!view || !chatView) { @@ -796,7 +811,7 @@ .finally(() => pendingReferences.delete(id)); } - async function load(token = '') { + async function load(token = '', { background = false } = {}) { if (disposed || loadPromise) { return loadPromise; } @@ -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 (!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.'); + } else { + hasError = false; + status.className = 'small text-muted d-none'; + status.textContent = ''; + updateVisibility(); + } + if (error.status !== 403) { + scheduleRetry(); + } } } finally { loadPromise = null; @@ -890,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() { @@ -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(); @@ -943,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/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/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/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md b/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md index 33d4f6eec..84bce5404 100644 --- a/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md +++ b/docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md @@ -1,7 +1,8 @@ # Collaboration Shared AI Workflow Fix Original implementation: **0.241.021** -Plain-prompt coverage completed in: **0.261.040** +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 @@ -29,6 +30,23 @@ 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. +- 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. @@ -38,6 +56,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 +67,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 0d44425c3..f092bc0aa 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.047)** #### Bug Fixes @@ -67,6 +76,46 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver * Programmatic model selector refreshes no longer save the admin default as the user's preferred model or prematurely disable the admin default reasoning state. * (Ref: `chat-conversations.js`, `chat-model-selector.js`, `chat-messages.js`, `chat-reasoning.js`) +* **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`) + +* **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.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_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 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/functional_tests/test_collaboration_group_agent_stream_fix.py b/functional_tests/test_collaboration_group_agent_stream_fix.py index 6033d725d..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.241.068 -Implemented in: 0.241.068 +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__))) @@ -67,10 +68,22 @@ 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.""" - 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' diff --git a/functional_tests/test_collaboration_shared_ai_workflow.py b/functional_tests/test_collaboration_shared_ai_workflow.py index 4d32e5cc2..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.040 -Implemented in: 0.261.040 +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 @@ -94,6 +94,43 @@ 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 '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 + 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 + 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__': 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..912954034 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.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. @@ -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 @@ -100,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", @@ -186,11 +276,79 @@ 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", + ] + + +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, 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, + 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..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.038 -Implemented in: 0.261.038 +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 @@ -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 c3d65900f..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.038 -// Implemented in: 0.261.038 +// 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 @@ -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(); 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');