diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index fa11d431..78d5ddde 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -16086,7 +16086,7 @@ def chat_stream_api(): is_retry = bool(retry_user_message_id) is_edit = bool(data.get('edited_user_message_id')) - compatibility_mode = bool(data.get('image_generation')) or is_retry + compatibility_mode = bool(data.get('image_generation')) requested_conversation_id = str(data.get('conversation_id') or '').strip() or None if requested_conversation_id: @@ -16810,223 +16810,281 @@ def build_streaming_capability_usage(): if conversation_group_id: g.conversation_group_id = conversation_group_id - # Save user message - user_message_id = f"{conversation_id}_user_{int(time.time())}_{random.randint(1000,9999)}" + effective_retry_thread_attempt = retry_thread_attempt - user_metadata = {} - current_user = get_current_user_info() - if current_user: - user_metadata['user_info'] = { - 'user_id': current_user.get('userId'), - 'username': current_user.get('userPrincipalName'), - 'display_name': current_user.get('displayName'), - 'email': current_user.get('email'), - 'timestamp': datetime.utcnow().isoformat() - } + if is_retry: + user_message_id = retry_user_message_id + try: + user_message_doc = cosmos_messages_container.read_item( + item=user_message_id, + partition_key=conversation_id, + ) + except CosmosResourceNotFoundError: + yield f"data: {json.dumps({'error': 'Retry user message not found'})}\n\n" + return + except Exception as exc: + debug_print(f"[Streaming] Error reading retry/edit user message {user_message_id}: {exc}") + yield f"data: {json.dumps({'error': 'Failed to load retry user message'})}\n\n" + return - user_metadata['button_states'] = { - 'image_generation': False, - 'document_search': hybrid_search_enabled, - 'web_search': bool(web_search_enabled), - 'url_access': bool(url_access_enabled), - 'deep_research': bool(deep_research_enabled) - } - user_metadata['capability_usage'] = _build_capability_usage_metadata( - workspace_search_enabled=hybrid_search_enabled, - document_action_type=DOCUMENT_ACTION_TYPE_NONE, - document_scope=effective_document_scope, - selected_document_ids=effective_selected_document_ids, - active_group_ids=effective_active_group_ids, - active_public_workspace_ids=effective_active_public_workspace_ids, - web_search_enabled=web_search_enabled, - url_access_enabled=url_access_enabled, - source_review_enabled=source_review_enabled, - deep_research_enabled=deep_research_enabled, - ) + if user_message_doc.get('role') != 'user': + yield f"data: {json.dumps({'error': 'Retry message must be a user message'})}\n\n" + return - # Document search scope and selections - if hybrid_search_enabled: - user_metadata['workspace_search'] = { - 'search_enabled': True, - 'document_scope': effective_document_scope, - 'selected_document_id': effective_selected_document_id, - 'selected_document_ids': effective_selected_document_ids, - 'active_group_ids': effective_active_group_ids, - 'active_public_workspace_ids': effective_active_public_workspace_ids, - 'classification': classifications_to_send + user_message = user_message_doc.get('content', user_message) + data['message'] = user_message + user_metadata = user_message_doc.get('metadata') if isinstance(user_message_doc.get('metadata'), dict) else {} + thread_info = user_metadata.get('thread_info') if isinstance(user_metadata.get('thread_info'), dict) else {} + requested_thread_id = str(retry_thread_id or '').strip() + stored_thread_id = str(thread_info.get('thread_id') or '').strip() + if requested_thread_id and stored_thread_id and requested_thread_id != stored_thread_id: + yield f"data: {json.dumps({'error': 'Retry thread metadata mismatch'})}\n\n" + return + + current_user_thread_id = requested_thread_id or stored_thread_id + if not current_user_thread_id: + yield f"data: {json.dumps({'error': 'Retry message has no thread_id'})}\n\n" + return + + previous_thread_id = thread_info.get('previous_thread_id') + effective_retry_thread_attempt = ( + retry_thread_attempt + if retry_thread_attempt is not None + else thread_info.get('thread_attempt') + ) + latest_thread_id = current_user_thread_id + chat_context_metadata = user_metadata.get('chat_context') + if not isinstance(chat_context_metadata, dict): + chat_context_metadata = {} + chat_context_metadata['conversation_id'] = conversation_id + user_metadata['chat_context'] = chat_context_metadata + user_message_doc['metadata'] = user_metadata + + debug_print( + "[Streaming] Reusing retry/edit user message | " + f"user_message_id={user_message_id} | " + f"thread_id={current_user_thread_id} | " + f"previous_thread_id={previous_thread_id} | " + f"attempt={effective_retry_thread_attempt}" + ) + else: + # Save user message + user_message_id = f"{conversation_id}_user_{int(time.time())}_{random.randint(1000,9999)}" + + user_metadata = {} + current_user = get_current_user_info() + if current_user: + user_metadata['user_info'] = { + 'user_id': current_user.get('userId'), + 'username': current_user.get('userPrincipalName'), + 'display_name': current_user.get('displayName'), + 'email': current_user.get('email'), + 'timestamp': datetime.utcnow().isoformat() + } + + user_metadata['button_states'] = { + 'image_generation': False, + 'document_search': hybrid_search_enabled, + 'web_search': bool(web_search_enabled), + 'url_access': bool(url_access_enabled), + 'deep_research': bool(deep_research_enabled) } - if assigned_knowledge_filters: - assigned_knowledge = assigned_knowledge_filters.get('assigned_knowledge') or {} - user_metadata['workspace_search']['assigned_knowledge'] = { - 'enabled': True, - 'document_count': len(assigned_knowledge.get('document_ids') or []), - 'tag_count': len(assigned_knowledge.get('tags') or []), - 'effective_scope': effective_document_scope, + user_metadata['capability_usage'] = _build_capability_usage_metadata( + workspace_search_enabled=hybrid_search_enabled, + document_action_type=DOCUMENT_ACTION_TYPE_NONE, + document_scope=effective_document_scope, + selected_document_ids=effective_selected_document_ids, + active_group_ids=effective_active_group_ids, + active_public_workspace_ids=effective_active_public_workspace_ids, + web_search_enabled=web_search_enabled, + url_access_enabled=url_access_enabled, + source_review_enabled=source_review_enabled, + deep_research_enabled=deep_research_enabled, + ) + + # Document search scope and selections + if hybrid_search_enabled: + user_metadata['workspace_search'] = { + 'search_enabled': True, + 'document_scope': effective_document_scope, + 'selected_document_id': effective_selected_document_id, + 'selected_document_ids': effective_selected_document_ids, 'active_group_ids': effective_active_group_ids, 'active_public_workspace_ids': effective_active_public_workspace_ids, + 'classification': classifications_to_send } - if auto_linked_chat_upload_document_ids: - user_metadata['workspace_search']['auto_linked_chat_upload_document_ids'] = auto_linked_chat_upload_document_ids - user_metadata['workspace_search']['auto_linked_chat_upload_document_count'] = len(auto_linked_chat_upload_document_ids) + if assigned_knowledge_filters: + assigned_knowledge = assigned_knowledge_filters.get('assigned_knowledge') or {} + user_metadata['workspace_search']['assigned_knowledge'] = { + 'enabled': True, + 'document_count': len(assigned_knowledge.get('document_ids') or []), + 'tag_count': len(assigned_knowledge.get('tags') or []), + 'effective_scope': effective_document_scope, + 'active_group_ids': effective_active_group_ids, + 'active_public_workspace_ids': effective_active_public_workspace_ids, + } + if auto_linked_chat_upload_document_ids: + user_metadata['workspace_search']['auto_linked_chat_upload_document_ids'] = auto_linked_chat_upload_document_ids + user_metadata['workspace_search']['auto_linked_chat_upload_document_count'] = len(auto_linked_chat_upload_document_ids) - # Get document details if specific document selected - if effective_selected_document_id and effective_selected_document_id != "all": - try: - doc_info = _resolve_chat_selected_document_metadata( - effective_selected_document_id, - user_id=user_id, - document_scope=effective_document_scope, - active_group_id=effective_active_group_id, - active_group_ids=effective_active_group_ids, - active_public_workspace_id=effective_active_public_workspace_id, - active_public_workspace_ids=effective_active_public_workspace_ids, - ) - if doc_info: - user_metadata['workspace_search']['document_name'] = doc_info.get('title') or doc_info.get('file_name') - user_metadata['workspace_search']['document_filename'] = doc_info.get('file_name') - except Exception as e: - debug_print(f"Error retrieving document details: {e}") + # Get document details if specific document selected + if effective_selected_document_id and effective_selected_document_id != "all": + try: + doc_info = _resolve_chat_selected_document_metadata( + effective_selected_document_id, + user_id=user_id, + document_scope=effective_document_scope, + active_group_id=effective_active_group_id, + active_group_ids=effective_active_group_ids, + active_public_workspace_id=effective_active_public_workspace_id, + active_public_workspace_ids=effective_active_public_workspace_ids, + ) + if doc_info: + user_metadata['workspace_search']['document_name'] = doc_info.get('title') or doc_info.get('file_name') + user_metadata['workspace_search']['document_filename'] = doc_info.get('file_name') + except Exception as e: + debug_print(f"Error retrieving document details: {e}") + + # Add scope-specific details + if effective_document_scope == 'group' and effective_active_group_id: + try: + from functions_debug import debug_print + debug_print(f"Workspace search - looking up group for id: {effective_active_group_id}") + group_doc = find_group_by_id(effective_active_group_id) + debug_print(f"Workspace search group lookup result: {group_doc}") - # Add scope-specific details - if effective_document_scope == 'group' and effective_active_group_id: - try: - from functions_debug import debug_print - debug_print(f"Workspace search - looking up group for id: {effective_active_group_id}") - group_doc = find_group_by_id(effective_active_group_id) - debug_print(f"Workspace search group lookup result: {group_doc}") + if group_doc and group_doc.get('name'): + group_name = group_doc.get('name') + user_metadata['workspace_search']['group_name'] = group_name + debug_print(f"Workspace search - set group_name to: {group_name}") + else: + debug_print(f"Workspace search - no group found or no name for id: {effective_active_group_id}") + user_metadata['workspace_search']['group_name'] = None - if group_doc and group_doc.get('name'): - group_name = group_doc.get('name') - user_metadata['workspace_search']['group_name'] = group_name - debug_print(f"Workspace search - set group_name to: {group_name}") - else: - debug_print(f"Workspace search - no group found or no name for id: {effective_active_group_id}") + except Exception as e: + debug_print(f"Error retrieving group details: {e}") user_metadata['workspace_search']['group_name'] = None + import traceback + traceback.print_exc() - except Exception as e: - debug_print(f"Error retrieving group details: {e}") - user_metadata['workspace_search']['group_name'] = None - import traceback - traceback.print_exc() + if effective_document_scope == 'public' and effective_active_public_workspace_id: + # Check if public workspace status allows chat operations + try: + from functions_public_workspaces import find_public_workspace_by_id, check_public_workspace_status_allows_operation + workspace_doc = find_public_workspace_by_id(effective_active_public_workspace_id) + if workspace_doc: + allowed, reason = check_public_workspace_status_allows_operation(workspace_doc, 'chat') + if not allowed: + yield f"data: {json.dumps({'error': reason})}\n\n" + return + except Exception as e: + debug_print(f"Error checking public workspace status: {e}") - if effective_document_scope == 'public' and effective_active_public_workspace_id: - # Check if public workspace status allows chat operations - try: - from functions_public_workspaces import find_public_workspace_by_id, check_public_workspace_status_allows_operation - workspace_doc = find_public_workspace_by_id(effective_active_public_workspace_id) - if workspace_doc: - allowed, reason = check_public_workspace_status_allows_operation(workspace_doc, 'chat') - if not allowed: - yield f"data: {json.dumps({'error': reason})}\n\n" - return - except Exception as e: - debug_print(f"Error checking public workspace status: {e}") + user_metadata['workspace_search']['active_public_workspace_id'] = effective_active_public_workspace_id + else: + user_metadata['workspace_search'] = { + 'search_enabled': False + } - user_metadata['workspace_search']['active_public_workspace_id'] = effective_active_public_workspace_id - else: - user_metadata['workspace_search'] = { - 'search_enabled': False + user_metadata['model_selection'] = { + 'selected_model': gpt_model, + 'frontend_requested_model': frontend_gpt_model, + 'model_endpoint_id': gpt_endpoint_id or data.get('model_endpoint_id'), + 'model_id': gpt_model_id or data.get('model_id'), + 'model_provider': gpt_provider or data.get('model_provider'), + 'model_icon': gpt_model_icon, + 'reasoning_effort': reasoning_effort if reasoning_effort and reasoning_effort != 'none' else None, + 'streaming': 'Enabled' } - user_metadata['model_selection'] = { - 'selected_model': gpt_model, - 'frontend_requested_model': frontend_gpt_model, - 'model_endpoint_id': gpt_endpoint_id or data.get('model_endpoint_id'), - 'model_id': gpt_model_id or data.get('model_id'), - 'model_provider': gpt_provider or data.get('model_provider'), - 'model_icon': gpt_model_icon, - 'reasoning_effort': reasoning_effort if reasoning_effort and reasoning_effort != 'none' else None, - 'streaming': 'Enabled' - } - - agent_selection_metadata = _build_agent_selection_metadata( - request_agent_info, - assigned_knowledge_filters, - ) - if agent_selection_metadata: - user_metadata['agent_selection'] = agent_selection_metadata - - user_metadata['chat_context'] = { - 'conversation_id': conversation_id - } + agent_selection_metadata = _build_agent_selection_metadata( + request_agent_info, + assigned_knowledge_filters, + ) + if agent_selection_metadata: + user_metadata['agent_selection'] = agent_selection_metadata - # --- Threading Logic for Streaming --- - previous_thread_id = None - try: - last_msg_query = f""" - SELECT TOP 1 c.metadata.thread_info.thread_id as thread_id - FROM c - WHERE c.conversation_id = '{conversation_id}' - ORDER BY c.timestamp DESC - """ - last_msgs = list(cosmos_messages_container.query_items( - query=last_msg_query, - partition_key=conversation_id - )) - if last_msgs: - previous_thread_id = last_msgs[0].get('thread_id') - except Exception as e: - debug_print(f"Error fetching last message for threading: {e}") + user_metadata['chat_context'] = { + 'conversation_id': conversation_id + } - current_user_thread_id = str(uuid.uuid4()) - latest_thread_id = current_user_thread_id + # --- Threading Logic for Streaming --- + previous_thread_id = None + try: + last_msg_query = f""" + SELECT TOP 1 c.metadata.thread_info.thread_id as thread_id + FROM c + WHERE c.conversation_id = '{conversation_id}' + ORDER BY c.timestamp DESC + """ + last_msgs = list(cosmos_messages_container.query_items( + query=last_msg_query, + partition_key=conversation_id + )) + if last_msgs: + previous_thread_id = last_msgs[0].get('thread_id') + except Exception as e: + debug_print(f"Error fetching last message for threading: {e}") - # Add thread information to user metadata - user_metadata['thread_info'] = { - 'thread_id': current_user_thread_id, - 'previous_thread_id': previous_thread_id, - 'active_thread': True, - 'thread_attempt': 1 - } + current_user_thread_id = str(uuid.uuid4()) + latest_thread_id = current_user_thread_id - user_message_doc = { - 'id': user_message_id, - 'conversation_id': conversation_id, - 'role': 'user', - 'content': user_message, - 'timestamp': datetime.utcnow().isoformat(), - 'model_deployment_name': None, - 'metadata': user_metadata - } + # Add thread information to user metadata + user_metadata['thread_info'] = { + 'thread_id': current_user_thread_id, + 'previous_thread_id': previous_thread_id, + 'active_thread': True, + 'thread_attempt': 1 + } - cosmos_messages_container.upsert_item(user_message_doc) - debug_print( - f"[Streaming] Saved user message {user_message_id} | thread_id={current_user_thread_id} | previous_thread_id={previous_thread_id}" - ) + user_message_doc = { + 'id': user_message_id, + 'conversation_id': conversation_id, + 'role': 'user', + 'content': user_message, + 'timestamp': datetime.utcnow().isoformat(), + 'model_deployment_name': None, + 'metadata': user_metadata + } - # Log activity - try: - log_chat_activity( - user_id=user_id, - conversation_id=conversation_id, - message_type='user_message', - message_length=len(user_message) if user_message else 0, - has_document_search=hybrid_search_enabled, - has_image_generation=False, - document_scope=effective_document_scope, - chat_context=actual_chat_type, - workspace_type='group' if actual_chat_type == 'group' else 'public' if actual_chat_type == 'public' else 'personal', - group_id=effective_active_group_id if actual_chat_type == 'group' else None, - public_workspace_id=effective_active_public_workspace_id if actual_chat_type == 'public' else None, + cosmos_messages_container.upsert_item(user_message_doc) + debug_print( + f"[Streaming] Saved user message {user_message_id} | thread_id={current_user_thread_id} | previous_thread_id={previous_thread_id}" ) - except Exception as e: - debug_print(f"Activity logging error: {e}") - # Update conversation title - title_updated = _set_initial_conversation_title(conversation_item, user_message) + # Log activity + try: + log_chat_activity( + user_id=user_id, + conversation_id=conversation_id, + message_type='user_message', + message_length=len(user_message) if user_message else 0, + has_document_search=hybrid_search_enabled, + has_image_generation=False, + document_scope=effective_document_scope, + chat_context=actual_chat_type, + workspace_type='group' if actual_chat_type == 'group' else 'public' if actual_chat_type == 'public' else 'personal', + group_id=effective_active_group_id if actual_chat_type == 'group' else None, + public_workspace_id=effective_active_public_workspace_id if actual_chat_type == 'public' else None, + ) + except Exception as e: + debug_print(f"Activity logging error: {e}") + + # Update conversation title + title_updated = _set_initial_conversation_title(conversation_item, user_message) - conversation_item['last_updated'] = datetime.utcnow().isoformat() - cosmos_conversations_container.upsert_item(conversation_item) - invalidate_conversation_cache_for_item(conversation_item, reason="conversation_title_initialized") - if title_updated: - yield _build_conversation_metadata_stream_event(conversation_item) + conversation_item['last_updated'] = datetime.utcnow().isoformat() + cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item(conversation_item, reason="conversation_title_initialized") + if title_updated: + yield _build_conversation_metadata_stream_event(conversation_item) assistant_message_id, thought_tracker, assistant_thread_attempt, response_message_context = _initialize_assistant_response_tracking( conversation_id=conversation_id, user_message_id=user_message_id, current_user_thread_id=current_user_thread_id, previous_thread_id=previous_thread_id, - retry_thread_attempt=retry_thread_attempt, + retry_thread_attempt=effective_retry_thread_attempt, is_retry=is_retry, user_id=user_id, ) diff --git a/docs/explanation/fixes/CHAT_RETRY_EDIT_STREAMING_PARITY_FIX.md b/docs/explanation/fixes/CHAT_RETRY_EDIT_STREAMING_PARITY_FIX.md new file mode 100644 index 00000000..01fbc9b8 --- /dev/null +++ b/docs/explanation/fixes/CHAT_RETRY_EDIT_STREAMING_PARITY_FIX.md @@ -0,0 +1,54 @@ +# Retry and Edit Streaming Parity Fix + +Issue description: Retry and edit flows were reported as potentially not streaming with the same visible progress and responsiveness as first-send chat. + +Root cause: The retry and edit frontend modules already called `sendMessageWithStreaming()`, but `/api/chat/stream` treated every retry/edit request as compatibility mode. That routed retry/edit through the legacy JSON `chat_api()` compatibility bridge, which emits only terminal SSE metadata instead of live token chunks, live thoughts, stop controls, and normal stream recovery behavior. + +Fixed/Implemented in version: **0.250.106** + +Related config.py update: `VERSION = "0.250.106"` + +## Technical Details + +Files modified: + +- `application/single_app/route_backend_chats.py` +- `application/single_app/config.py` +- `functional_tests/test_chat_retry_edit_streaming_parity.py` + +Code changes: + +- Kept image generation on the stream compatibility bridge. +- Removed retry/edit from compatibility-mode routing so they use the full `/api/chat/stream` generator. +- Added stream-generator retry/edit handling that reuses the already-created retry/edit user message, its thread id, previous thread id, metadata, and attempt number. +- Preserved existing retry/edit frontend behavior because `chat-retry.js` and `chat-edit.js` already call the shared streaming client. + +Testing approach: + +- Added a functional regression test that verifies retry/edit are not routed through compatibility mode. +- Verified the stream generator reads and reuses the prepared retry/edit user message instead of creating a duplicate user message. +- Verified retry and edit frontend modules still invoke `sendMessageWithStreaming()`. + +## Validation + +Test coverage: + +- `functional_tests/test_chat_retry_edit_streaming_parity.py` +- `python -m py_compile application/single_app/route_backend_chats.py` + +Before: + +- Retry/edit requests entered `/api/chat/stream` but were immediately diverted to the legacy compatibility bridge because `compatibility_mode` included `is_retry`. +- The browser received a terminal event after processing rather than the live stream path used for first-send chat. + +After: + +- Retry/edit requests enter the same stream generator as first-send chat. +- The stream generator reuses the retry/edit user message created by the preparation endpoint, preserving carousel attempt/thread semantics while restoring live streaming parity. + +Impact: + +- Retry and edit flows now get normal streamed content, live thought updates, stop controls, and recovery behavior across model and agent paths. +- Image generation remains safely handled by the existing compatibility bridge because image generation is not supported by the token stream path. + +Reference: microsoft/simplechat#963 diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 2805dea3..902477b8 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -11,6 +11,13 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver * Restore supports configured target Cosmos DB, AI Search, and Enhanced Citation blob targets while preserving secret-safe review and job responses. * (Ref: Closes #1091, `functions_data_management.py`, `functions_data_management_restore_state.py`, `route_backend_data_management.py`, `admin_settings.html`, `admin_data_management.js`, `DATA_MANAGEMENT_RESTORE.md`) +#### Bug Fixes + +* **Retry and Edit Streaming Parity** + * Retry and edit chat flows now use the same full SSE streaming path as first-send chat, restoring live token updates, streamed thoughts, stop controls, and recovery behavior. + * The stream path reuses the retry/edit user message and thread metadata created by the preparation endpoints, preserving carousel attempt history without duplicating user messages. + * (Ref: Fixes #963, `route_backend_chats.py`, `chat-retry.js`, `chat-edit.js`, `test_chat_retry_edit_streaming_parity.py`) + #### User Interface Enhancements * **Custom Pages Admin Open Action** diff --git a/functional_tests/test_chat_retry_edit_streaming_parity.py b/functional_tests/test_chat_retry_edit_streaming_parity.py new file mode 100644 index 00000000..c9ec39e3 --- /dev/null +++ b/functional_tests/test_chat_retry_edit_streaming_parity.py @@ -0,0 +1,161 @@ +# test_chat_retry_edit_streaming_parity.py +#!/usr/bin/env python3 +""" +Functional test for retry and edit streaming parity. +Version: 0.250.106 +Implemented in: 0.250.106 + +This test ensures retry and edit chat requests use the same full SSE stream +generator as first-send chat instead of the legacy terminal compatibility +bridge, while still reusing the prepared retry/edit user message. +""" + +import os + + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ROUTE_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'route_backend_chats.py') +RETRY_JS_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'static', 'js', 'chat', 'chat-retry.js') +EDIT_JS_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'static', 'js', 'chat', 'chat-edit.js') +CONFIG_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'config.py') +FIX_DOC = os.path.join( + ROOT_DIR, + 'docs', + 'explanation', + 'fixes', + 'CHAT_RETRY_EDIT_STREAMING_PARITY_FIX.md', +) + + +def read_file_text(file_path): + with open(file_path, 'r', encoding='utf-8') as file_handle: + return file_handle.read() + + +def read_config_version(): + for line in read_file_text(CONFIG_FILE).splitlines(): + if line.startswith('VERSION = '): + return line.split('=', 1)[1].strip().strip('"') + raise AssertionError('VERSION assignment not found in config.py') + + +def parse_version(version_text): + return tuple(int(part) for part in str(version_text).split('.')) + + +def get_chat_stream_route_source(): + route_source = read_file_text(ROUTE_FILE) + stream_route_marker = "@bp.route('/api/chat/stream', methods=['POST'])" + cancel_route_marker = "@bp.route('/api/chat/stream/cancel/', methods=['POST'])" + stream_route_index = route_source.find(stream_route_marker) + cancel_route_index = route_source.find(cancel_route_marker, stream_route_index) + + assert stream_route_index != -1, 'Expected to find the /api/chat/stream route definition.' + assert cancel_route_index != -1, 'Expected to find the stream cancel route after /api/chat/stream.' + return route_source[stream_route_index:cancel_route_index] + + +def test_retry_and_edit_are_not_stream_compatibility_mode(): + """Verify retry/edit requests are not diverted away from the full stream generator.""" + print('Testing retry/edit streaming route selection...') + + stream_source = get_chat_stream_route_source() + retry_flag_index = stream_source.find('is_retry = bool(retry_user_message_id)') + compatibility_mode_index = stream_source.find("compatibility_mode = bool(data.get('image_generation'))") + compatibility_bridge_index = stream_source.find('if compatibility_mode:') + generator_index = stream_source.find('def generate(publish_background_event=None):') + retry_reuse_index = stream_source.find('if is_retry:', generator_index) + + assert retry_flag_index != -1, 'Expected stream route to detect retry/edit requests.' + assert compatibility_mode_index != -1, 'Expected compatibility mode to be calculated.' + assert compatibility_bridge_index != -1, 'Expected image compatibility bridge branch to remain.' + assert generator_index != -1, 'Expected full stream generator to remain.' + assert retry_reuse_index != -1, 'Expected retry/edit reuse logic inside the full stream generator.' + assert "compatibility_mode = bool(data.get('image_generation')) or is_retry" not in stream_source + assert retry_flag_index < compatibility_mode_index < compatibility_bridge_index < generator_index < retry_reuse_index + + print('Retry/edit route selection passed') + + +def test_stream_generator_reuses_prepared_retry_edit_user_message(): + """Verify the stream generator reuses the retry/edit message created by preparation routes.""" + print('Testing retry/edit stream message reuse...') + + stream_source = get_chat_stream_route_source() + generator_index = stream_source.find('def generate(publish_background_event=None):') + retry_reuse_index = stream_source.find('if is_retry:', generator_index) + assistant_tracking_index = stream_source.find( + 'assistant_message_id, thought_tracker, assistant_thread_attempt, response_message_context = _initialize_assistant_response_tracking', + retry_reuse_index, + ) + retry_reuse_source = stream_source[retry_reuse_index:assistant_tracking_index] + + assert "user_message_id = retry_user_message_id" in retry_reuse_source + assert 'cosmos_messages_container.read_item(' in retry_reuse_source + assert 'item=user_message_id' in retry_reuse_source + assert "data['message'] = user_message" in retry_reuse_source + assert 'Retry thread metadata mismatch' in retry_reuse_source + assert 'current_user_thread_id = requested_thread_id or stored_thread_id' in retry_reuse_source + assert 'effective_retry_thread_attempt = (' in retry_reuse_source + assert 'retry_thread_attempt=effective_retry_thread_attempt' in stream_source + assert 'Reusing retry/edit user message' in retry_reuse_source + + print('Retry/edit stream message reuse passed') + + +def test_retry_and_edit_frontend_use_shared_streaming_client(): + """Verify retry and edit UI paths continue to call the shared streaming client.""" + print('Testing retry/edit frontend streaming calls...') + + retry_source = read_file_text(RETRY_JS_FILE) + edit_source = read_file_text(EDIT_JS_FILE) + + assert "import { sendMessageWithStreaming } from './chat-streaming.js';" in retry_source + assert "import { sendMessageWithStreaming } from './chat-streaming.js';" in edit_source + assert "fetch(`/api/message/${messageId}/retry`" in retry_source + assert "fetch(`/api/message/${messageId}/edit`" in edit_source + assert 'sendMessageWithStreaming(' in retry_source + assert 'sendMessageWithStreaming(' in edit_source + + print('Retry/edit frontend streaming calls passed') + + +def test_version_and_fix_documentation_alignment(): + """Verify version bump and fix documentation stay aligned.""" + print('Testing version and fix documentation alignment...') + + fix_doc_content = read_file_text(FIX_DOC) + + assert parse_version(read_config_version()) >= (0, 250, 106) + assert 'Fixed/Implemented in version: **0.250.106**' in fix_doc_content + assert 'Related config.py update: `VERSION = "0.250.106"`' in fix_doc_content + assert 'microsoft/simplechat#963' in fix_doc_content + assert '/api/chat/stream' in fix_doc_content + assert 'compatibility bridge' in fix_doc_content + + print('Version and fix documentation alignment passed') + + +if __name__ == '__main__': + tests = [ + test_retry_and_edit_are_not_stream_compatibility_mode, + test_stream_generator_reuses_prepared_retry_edit_user_message, + test_retry_and_edit_frontend_use_shared_streaming_client, + test_version_and_fix_documentation_alignment, + ] + + results = [] + for test in tests: + print(f'\nRunning {test.__name__}...') + try: + test() + results.append(True) + except Exception as exc: + print(f'{test.__name__} failed: {exc}') + import traceback + traceback.print_exc() + results.append(False) + + success = all(results) + print(f'\nResults: {sum(results)}/{len(results)} tests passed') + raise SystemExit(0 if success else 1) diff --git a/functional_tests/test_chat_stream_retry_multiendpoint_resolution_fix.py b/functional_tests/test_chat_stream_retry_multiendpoint_resolution_fix.py index 22429164..655dc2b7 100644 --- a/functional_tests/test_chat_stream_retry_multiendpoint_resolution_fix.py +++ b/functional_tests/test_chat_stream_retry_multiendpoint_resolution_fix.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for chat stream retry multi-endpoint resolution. -Version: 0.241.004 +Version: 0.250.106 Implemented in: 0.241.003 This test ensures the compatibility retry path reuses the in-app multi-endpoint @@ -42,16 +42,30 @@ def parse_version(version_text): return tuple(int(part) for part in str(version_text).split('.')) +def find_first_route_marker(source, markers): + for marker in markers: + marker_index = source.find(marker) + if marker_index != -1: + return marker_index + return -1 + + def test_chat_api_uses_shared_multi_endpoint_resolution_for_retry_compatibility(): """Verify compatibility chat requests reuse the in-app multi-endpoint resolver.""" print('🔍 Testing compatibility retry multi-endpoint resolution wiring...') route_source = read_file_text(ROUTE_FILE) - chat_route_marker = "@app.route('/api/chat', methods=['POST'])" - chat_stream_marker = "@app.route('/api/chat/stream', methods=['POST'])" + chat_route_markers = [ + "@bp.route('/api/chat', methods=['POST'])", + "@app.route('/api/chat', methods=['POST'])", + ] + chat_stream_markers = [ + "@bp.route('/api/chat/stream', methods=['POST'])", + "@app.route('/api/chat/stream', methods=['POST'])", + ] - chat_route_index = route_source.find(chat_route_marker) - chat_stream_index = route_source.find(chat_stream_marker) + chat_route_index = find_first_route_marker(route_source, chat_route_markers) + chat_stream_index = find_first_route_marker(route_source, chat_stream_markers) assert chat_route_index != -1, 'Expected to find the /api/chat route definition.' assert chat_stream_index != -1, 'Expected to find the /api/chat/stream route definition.'