From 097adef6e5bd585be924d2a9765237c5d3879a0a Mon Sep 17 00:00:00 2001 From: Chad Palmer Date: Fri, 18 Sep 2026 10:46:49 -0400 Subject: [PATCH 1/3] oigchat-for-release-v0.261.002.01 - bug fixes from new release. --- application/single_app/app.py | 8 +- application/single_app/config.py | 2 +- .../single_app/functions_authentication.py | 20 +++ application/single_app/requirements.txt | 2 + .../single_app/route_backend_conversations.py | 42 +++++- .../route_backend_retention_policy.py | 12 +- .../route_frontend_authentication.py | 13 +- application/single_app/static/css/styles.css | 38 +++++ .../static/js/chat/chat-input-actions.js | 32 ++++- .../single_app/static/js/chat/chat-retry.js | 12 +- application/single_app/templates/index.html | 23 ++- ...AT_RETRY_EDIT_MODEL_ENDPOINT_REPLAY_FIX.md | 37 +++++ .../CHAT_UPLOAD_FILE_ACCESS_ERROR_FIX.md | 39 +++++ ...est_chat_clipboard_paste_upload_support.py | 37 ++++- ...t_chat_retry_edit_model_endpoint_replay.py | 134 ++++++++++++++++++ ...st_chat_clipboard_paste_upload_workflow.py | 78 +++++++++- 16 files changed, 497 insertions(+), 32 deletions(-) create mode 100644 docs/explanation/fixes/CHAT_RETRY_EDIT_MODEL_ENDPOINT_REPLAY_FIX.md create mode 100644 docs/explanation/fixes/CHAT_UPLOAD_FILE_ACCESS_ERROR_FIX.md create mode 100644 functional_tests/test_chat_retry_edit_model_endpoint_replay.py diff --git a/application/single_app/app.py b/application/single_app/app.py index 8865a9382..266cb90ed 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -1149,8 +1149,14 @@ def index(): # Convert Markdown to HTML safely landing_html = markdown_filter(landing_text) + signed_in_account = get_signed_in_account_display(session.get("user")) - return render_template('index.html', app_settings=public_settings, landing_html=landing_html) + return render_template( + 'index.html', + app_settings=public_settings, + landing_html=landing_html, + signed_in_account=signed_in_account, + ) @public_app_bp.route('/robots933456.txt') @swagger_route(security=get_auth_security()) diff --git a/application/single_app/config.py b/application/single_app/config.py index 2d5952068..34aa87673 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.025" +VERSION = "0.261.002.01" 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/functions_authentication.py b/application/single_app/functions_authentication.py index 1062a86b1..2d68194f5 100644 --- a/application/single_app/functions_authentication.py +++ b/application/single_app/functions_authentication.py @@ -1095,6 +1095,26 @@ def get_current_user_info(): } +def get_signed_in_account_display(user): + """Return safe identity labels for the access-denied account display.""" + if not isinstance(user, dict): + return {"name": "", "account": ""} + + name = user.get("name") if isinstance(user.get("name"), str) else "" + account = "" + for claim_name in ("email", "mail", "preferred_username"): + claim_value = user.get(claim_name) + if not isinstance(claim_value, str): + continue + + candidate = claim_value.strip() + if candidate and "#ext#" not in candidate.lower(): + account = candidate + break + + return {"name": name.strip(), "account": account} + + def _normalize_authority(authority_base, tenant_id): """Normalize an authority URL and append tenant when appropriate.""" base = (authority_base or "").strip().rstrip("/") diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index ceff7d77a..4ed0f99dd 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -64,6 +64,8 @@ psycopg2-binary==2.9.10 cython==3.2.4 pyyaml==6.0.2 aiohttp==3.14.3 +# AnyIO 4.11+ requires typing_extensions.sentinel, which older App Service agents can shadow. +anyio==4.10.0 html2text==2025.4.15 matplotlib==3.10.7 azure-cognitiveservices-speech==1.48.2 diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index d00b78534..7f69b8c4a 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -234,6 +234,41 @@ def _build_replayed_document_context(original_metadata): 'active_public_workspace_ids': workspace_search.get('active_public_workspace_ids') or [], } +def _build_replayed_model_context(original_metadata, request_data=None): + """Rebuild model endpoint routing from stored metadata for retry and edit.""" + metadata = original_metadata if isinstance(original_metadata, dict) else {} + model_selection = metadata.get('model_selection') + model_selection = model_selection if isinstance(model_selection, dict) else {} + request_data = request_data if isinstance(request_data, dict) else {} + + stored_request_model = str( + model_selection.get('frontend_requested_model') + or model_selection.get('selected_model') + or '' + ).strip() + stored_selected_model = str(model_selection.get('selected_model') or '').strip() + requested_model = str(request_data.get('model') or '').strip() + replayed_model = requested_model or stored_request_model + reuses_stored_selection = not requested_model or requested_model in { + stored_request_model, + stored_selected_model, + } + + def requested_or_stored(request_key, metadata_key): + requested_value = request_data.get(request_key) + if requested_value not in (None, ''): + return requested_value + if reuses_stored_selection: + return model_selection.get(metadata_key) + return None + + return { + 'model_deployment': replayed_model or None, + 'model_id': requested_or_stored('model_id', 'model_id'), + 'model_endpoint_id': requested_or_stored('model_endpoint_id', 'model_endpoint_id'), + 'model_provider': requested_or_stored('model_provider', 'model_provider'), + 'model_icon': requested_or_stored('model_icon', 'model_icon'), + } def _get_requested_workspace_document_delete_ids_for_conversation(payload, conversation_id): if not isinstance(payload, dict): @@ -2716,7 +2751,6 @@ def retry_message(message_id): try: data = request.get_json() or {} - selected_model = data.get('model') reasoning_effort = data.get('reasoning_effort') agent_info = data.get('agent_info') # Get agent info if provided @@ -2873,10 +2907,11 @@ def retry_message(message_id): ) # Build chat request parameters from original message metadata replayed_document_context = _build_replayed_document_context(original_metadata) + replayed_model_context = _build_replayed_model_context(original_metadata, data) chat_request = { 'message': user_content, 'conversation_id': conversation_id, - 'model_deployment': selected_model or original_metadata.get('model_selection', {}).get('selected_model'), + **replayed_model_context, 'reasoning_effort': reasoning_effort or original_metadata.get('reasoning_effort'), **replayed_document_context, 'image_generation': original_metadata.get('image_generation', {}).get('enabled', False), @@ -3097,10 +3132,11 @@ def edit_message(message_id): # Build chat request parameters from original message metadata # Keep all original settings (model, reasoning, doc search, etc.) replayed_document_context = _build_replayed_document_context(original_metadata) + replayed_model_context = _build_replayed_model_context(original_metadata) chat_request = { 'message': edited_content, # Use edited content 'conversation_id': conversation_id, - 'model_deployment': original_metadata.get('model_selection', {}).get('selected_model'), + **replayed_model_context, 'reasoning_effort': original_metadata.get('reasoning_effort'), **replayed_document_context, 'image_generation': original_metadata.get('image_generation', {}).get('enabled', False), diff --git a/application/single_app/route_backend_retention_policy.py b/application/single_app/route_backend_retention_policy.py index ecc43d72d..6cc46825f 100644 --- a/application/single_app/route_backend_retention_policy.py +++ b/application/single_app/route_backend_retention_policy.py @@ -406,7 +406,7 @@ def update_user_retention_settings(): # Validate and parse conversation retention if 'conversation_retention_days' in data: conv_retention = data['conversation_retention_days'] - if conv_retention == 'none' or conv_retention is None: + if conv_retention == 'none' or conv_retention == 'default' or conv_retention is None: retention_settings['conversation_retention_days'] = 'none' else: try: @@ -431,7 +431,7 @@ def update_user_retention_settings(): # Validate and parse document retention if 'document_retention_days' in data: doc_retention = data['document_retention_days'] - if doc_retention == 'none' or doc_retention is None: + if doc_retention == 'none' or doc_retention == 'default' or doc_retention is None: retention_settings['document_retention_days'] = 'none' else: try: @@ -515,7 +515,7 @@ def update_group_retention_settings(group_id): # Validate and parse conversation retention if 'conversation_retention_days' in data: conv_retention = data['conversation_retention_days'] - if conv_retention == 'none' or conv_retention is None: + if conv_retention == 'none' or conv_retention == 'default' or conv_retention is None: retention_settings['conversation_retention_days'] = 'none' else: try: @@ -540,7 +540,7 @@ def update_group_retention_settings(group_id): # Validate and parse document retention if 'document_retention_days' in data: doc_retention = data['document_retention_days'] - if doc_retention == 'none' or doc_retention is None: + if doc_retention == 'none' or doc_retention == 'default' or doc_retention is None: retention_settings['document_retention_days'] = 'none' else: try: @@ -625,7 +625,7 @@ def update_public_workspace_retention_settings(public_workspace_id): # Validate and parse conversation retention if 'conversation_retention_days' in data: conv_retention = data['conversation_retention_days'] - if conv_retention == 'none' or conv_retention is None: + if conv_retention == 'none' or conv_retention == 'default' or conv_retention is None: retention_settings['conversation_retention_days'] = 'none' else: try: @@ -650,7 +650,7 @@ def update_public_workspace_retention_settings(public_workspace_id): # Validate and parse document retention if 'document_retention_days' in data: doc_retention = data['document_retention_days'] - if doc_retention == 'none' or doc_retention is None: + if doc_retention == 'none' or doc_retention == 'default' or doc_retention is None: retention_settings['document_retention_days'] = 'none' else: try: diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py index db795d249..857e02b2b 100644 --- a/application/single_app/route_frontend_authentication.py +++ b/application/single_app/route_frontend_authentication.py @@ -172,6 +172,7 @@ def login(): session.pop("last_activity_epoch", None) clear_requested_oauth_scopes() + select_account = request.args.get('select_account') == '1' is_teams_login = request.args.get('teams', 'false').lower() == 'true' if is_teams_login and ENABLE_TEAMS_SSO: settings = get_settings() or {} @@ -213,10 +214,14 @@ def login(): debug_print(f"Front Door enabled: {settings.get('enable_front_door', False)}") debug_print(f"Using redirect_uri for Azure AD: {redirect_uri}") - auth_url = msal_app.get_authorization_request_url( - scopes=SCOPE, # Use SCOPE from config (includes offline_access) - redirect_uri=redirect_uri - ) + authorization_request = { + "scopes": SCOPE, + "redirect_uri": redirect_uri, + } + if select_account: + authorization_request["prompt"] = "select_account" + + auth_url = msal_app.get_authorization_request_url(**authorization_request) print("Redirecting to Azure AD for authentication.") #auth_url= auth_url.replace('https://', 'http://') # Ensure HTTPS for security return redirect(auth_url) diff --git a/application/single_app/static/css/styles.css b/application/single_app/static/css/styles.css index 5e35e2b01..611092e26 100644 --- a/application/single_app/static/css/styles.css +++ b/application/single_app/static/css/styles.css @@ -46,6 +46,44 @@ main { --bs-body-color: #e9ecef; } +.account-switch-button, +.account-switch-button:visited { + color: #495057; + border-color: #495057; +} + +.account-switch-button:hover, +.account-switch-button:focus-visible { + color: #ffffff; + background-color: #495057; + border-color: #495057; +} + +.account-switch-button:active { + color: #ffffff; + background-color: #343a40; + border-color: #343a40; +} + +[data-bs-theme="dark"] .account-switch-button, +[data-bs-theme="dark"] .account-switch-button:visited { + color: #f8f9fa; + border-color: #f8f9fa; +} + +[data-bs-theme="dark"] .account-switch-button:hover, +[data-bs-theme="dark"] .account-switch-button:focus-visible { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +[data-bs-theme="dark"] .account-switch-button:active { + color: #212529; + background-color: #e9ecef; + border-color: #e9ecef; +} + /* Theme-based logo visibility */ [data-bs-theme="light"] .d-dark-mode-only { display: none !important; diff --git a/application/single_app/static/js/chat/chat-input-actions.js b/application/single_app/static/js/chat/chat-input-actions.js index bc8c9d830..bd8836537 100644 --- a/application/single_app/static/js/chat/chat-input-actions.js +++ b/application/single_app/static/js/chat/chat-input-actions.js @@ -303,6 +303,18 @@ function buildUploadFileList(filesLike, fallbackPrefix = "clipboard_upload") { .filter(Boolean); } +async function ensureUploadFileIsReadable(file) { + const fileName = String(file?.name || "selected file").trim() || "selected file"; + + try { + await file.slice(0, Math.min(file.size, 1)).arrayBuffer(); + } catch (error) { + throw new Error( + `The browser could not read "${fileName}". Close the file in other apps then try again.` + ); + } +} + function hasNamedFile(files) { return Array.from(files || []).some((file) => String(file?.name || "").trim()); } @@ -684,6 +696,7 @@ export async function uploadFileToConversation(file) { let uploadingIndicatorEl = null; try { + await ensureUploadFileIsReadable(file); const groupUploadContext = await resolveGroupUploadContext(); uploadingIndicatorEl = showFileUploadingMessage(); @@ -706,10 +719,21 @@ export async function uploadFileToConversation(file) { }); } - const response = await fetch("/upload", { - method: "POST", - body: formData, - }); + let response; + try { + response = await fetch("/upload", { + method: "POST", + body: formData, + }); + } catch (error) { + if (error instanceof TypeError) { + const fileName = String(file?.name || "selected file").trim() || "selected file"; + throw new Error( + `The browser could not upload "${fileName}". The file may be open in another app, unavailable from cloud storage, or the network connection may have been interrupted. Ensure the file is closed and then try again.` + ); + } + throw error; + } hideFileUploadingMessage(uploadingIndicatorEl); uploadingIndicatorEl = null; diff --git a/application/single_app/static/js/chat/chat-retry.js b/application/single_app/static/js/chat/chat-retry.js index 99d3cfdf3..90d9a87f9 100644 --- a/application/single_app/static/js/chat/chat-retry.js +++ b/application/single_app/static/js/chat/chat-retry.js @@ -264,8 +264,18 @@ window.executeMessageRetry = function() { // Model mode - get model and reasoning effort const retryModelSelect = document.getElementById('retry-model-select'); const selectedOption = retryModelSelect ? retryModelSelect.options[retryModelSelect.selectedIndex] : null; - const selectedModel = selectedOption?.dataset?.deploymentName || (retryModelSelect ? retryModelSelect.value : null); + const selectedModel = selectedOption?.dataset?.requestModel + || selectedOption?.dataset?.deploymentName + || (retryModelSelect ? retryModelSelect.value : null); requestBody.model = selectedModel; + requestBody.model_id = selectedOption?.dataset?.modelId || null; + requestBody.model_endpoint_id = selectedOption?.dataset?.endpointId || null; + requestBody.model_provider = selectedOption?.dataset?.provider || null; + try { + requestBody.model_icon = JSON.parse(selectedOption?.dataset?.modelIcon || '{}'); + } catch (error) { + requestBody.model_icon = {}; + } let reasoningEffort = null; const retryReasoningContainer = document.getElementById('retry-reasoning-container'); diff --git a/application/single_app/templates/index.html b/application/single_app/templates/index.html index 2e32b5ff0..b624c30f9 100644 --- a/application/single_app/templates/index.html +++ b/application/single_app/templates/index.html @@ -65,11 +65,26 @@

{{ app_settings.access_denied_message | nl2br }}

- {% if app_settings.access_request_button_enabled and app_settings.access_request_page_url %} - - {{ app_settings.access_request_button_text or 'Request Access' }} +
+

Signed in as

+

+ {{ signed_in_account.name or 'Microsoft account' }} +

+ {% if signed_in_account.account %} +

{{ signed_in_account.account }}

+ {% endif %} +
+
+ {% if app_settings.access_request_button_enabled and app_settings.access_request_page_url %} + + {{ app_settings.access_request_button_text or 'Request Access' }} + + {% endif %} + - {% endif %} +
{% else %}
{{ landing_html | safe }} diff --git a/docs/explanation/fixes/CHAT_RETRY_EDIT_MODEL_ENDPOINT_REPLAY_FIX.md b/docs/explanation/fixes/CHAT_RETRY_EDIT_MODEL_ENDPOINT_REPLAY_FIX.md new file mode 100644 index 000000000..3843adf50 --- /dev/null +++ b/docs/explanation/fixes/CHAT_RETRY_EDIT_MODEL_ENDPOINT_REPLAY_FIX.md @@ -0,0 +1,37 @@ +# Chat Retry and Edit Model Endpoint Replay Fix + +Retrying a response or editing a prompt could fail before receiving content even though the original request succeeded. The replay request restored only the saved model name, so multi-endpoint models could be sent to the default Azure OpenAI resource instead of the endpoint that handled the original request. + +Fixed/Implemented in version: **0.261.033** + +Related config.py update: `VERSION = "0.261.033"` + +## Root Cause + +Initial chat sends include the request model, model ID, endpoint ID, and provider. Retry and edit preparation rebuilt `model_deployment` from `metadata.model_selection.selected_model` but discarded the other routing fields. Without `model_endpoint_id`, streaming model resolution could fall through to the default Azure OpenAI client and use a model identifier as an Azure deployment name, resulting in `DeploymentNotFound`. + +## Technical Details + +Files modified: + +- `application/single_app/route_backend_conversations.py` +- `application/single_app/static/js/chat/chat-retry.js` +- `functional_tests/test_chat_retry_edit_model_endpoint_replay.py` +- `application/single_app/config.py` + +The backend now rebuilds the complete model routing context from the original message metadata for retry and edit. The retry modal also sends the selected option's request model, model ID, endpoint ID, provider, and icon, matching the initial chat request contract. A retry that explicitly selects another model does not inherit stale endpoint metadata. + +## Validation + +Regression coverage verifies that: + +- Edit restores the original request model and endpoint identity. +- Retrying the same model preserves the saved endpoint identity. +- Selecting a different model does not retain stale endpoint fields. +- The retry frontend sends the complete selected model identity. + +The Python route compiles successfully and the retry JavaScript passes `node --check`. + +## Impact + +Retry and edit requests for multi-endpoint models are routed to the same configured endpoint as the original response, avoiding false Azure OpenAI `DeploymentNotFound` failures before streaming begins. \ No newline at end of file diff --git a/docs/explanation/fixes/CHAT_UPLOAD_FILE_ACCESS_ERROR_FIX.md b/docs/explanation/fixes/CHAT_UPLOAD_FILE_ACCESS_ERROR_FIX.md new file mode 100644 index 000000000..58e53cc73 --- /dev/null +++ b/docs/explanation/fixes/CHAT_UPLOAD_FILE_ACCESS_ERROR_FIX.md @@ -0,0 +1,39 @@ +# Chat Upload File Access Error Fix + +Fixed in version: **0.261.032** + +## Issue + +Dragging a file into chat could report only `Failed to fetch` when the browser +could not read the file. This commonly affected cloud-synced documents that +were open in another application or were not fully available on the device. +Because the browser failed while preparing the request body, the upload never +reached SimpleChat and no corresponding server log entry was created. + +## Root Cause + +The chat uploader passed the selected `File` directly to `fetch()`. Browser +file-access failures surfaced as a generic network `TypeError`, which did not +identify the document or explain how the user could resolve the problem. + +## Technical Details + +The shared chat upload path now performs a minimal file-read check before +starting the request. If that check fails, the toast identifies the filename +and asks the user to close it in other applications or make it available +offline. A later browser transport failure also explains the possible file +lock, cloud availability, and network causes instead of displaying only the +opaque browser error. + +The change applies consistently to files selected with the paperclip, pasted +from the clipboard, or dropped into the chat input. + +## Validation + +The existing chat upload Playwright workflow now simulates an unreadable +dropped file and verifies that the actionable toast appears without an upload +request. Functional coverage also checks that the shared uploader retains the +file-read guard and recovery guidance. + +The application version in `application/single_app/config.py` was updated to +`0.261.032` for this fix. \ No newline at end of file diff --git a/functional_tests/test_chat_clipboard_paste_upload_support.py b/functional_tests/test_chat_clipboard_paste_upload_support.py index c3eac9c8b..8e3bc2179 100644 --- a/functional_tests/test_chat_clipboard_paste_upload_support.py +++ b/functional_tests/test_chat_clipboard_paste_upload_support.py @@ -2,12 +2,13 @@ #!/usr/bin/env python3 """ Functional test for chat clipboard paste upload support. -Version: 0.241.056 -Implemented in: 0.241.056 +Version: 0.261.032 +Implemented in: 0.241.056; expanded in: 0.261.032 This test ensures chat paste uploads route clipboard files through the shared chat upload helper, normalize missing clipboard filenames, preserve normal text -paste behavior after image uploads, and support dropped files in the chat input. +paste behavior after image uploads, support dropped files in the chat input, +and provide actionable errors when the browser cannot read an upload. """ import os @@ -115,12 +116,37 @@ def test_chat_drag_and_drop_upload_binding(): print("✅ Chat drag-and-drop upload binding passed") +def test_chat_upload_reports_unreadable_files_with_recovery_guidance(): + """Verify local file access failures identify the document and recovery steps.""" + print("🔍 Testing unreadable chat upload guidance...") + + content = read_file(CHAT_INPUT_ACTIONS_FILE) + + required_snippets = [ + "async function ensureUploadFileIsReadable(file) {", + "await file.slice(0, Math.min(file.size, 1)).arrayBuffer();", + "await ensureUploadFileIsReadable(file);", + 'The browser could not read "${fileName}".', + "Close the file in other apps then try again.", + 'The browser could not upload "${fileName}".', + "The file may be open in another app, unavailable from cloud storage, or the network connection may have been interrupted.", + "} catch (error) {\n const fileName = String(file?.name || \"selected file\").trim() || \"selected file\";", + ] + + missing = [snippet for snippet in required_snippets if snippet not in content] + assert not missing, f"Missing unreadable upload guidance snippets: {missing}" + assert "error instanceof TypeError" not in content, ( + "Rejected upload requests must not depend on a browser-realm-specific error type." + ) + + print("✅ Unreadable chat upload guidance passed") + + def test_config_version_is_bumped_for_chat_clipboard_upload_support(): """Verify config version was bumped for the clipboard paste upload feature.""" print("🔍 Testing config version bump...") - config_content = read_file(CONFIG_FILE) - assert_app_version_at_least("0.241.056") + assert_app_version_at_least("0.261.032") print("✅ Config version bump passed") @@ -130,6 +156,7 @@ def test_config_version_is_bumped_for_chat_clipboard_upload_support(): test_chat_clipboard_paste_reuses_shared_upload_helper, test_chat_clipboard_paste_handler_normalizes_missing_filenames, test_chat_drag_and_drop_upload_binding, + test_chat_upload_reports_unreadable_files_with_recovery_guidance, test_config_version_is_bumped_for_chat_clipboard_upload_support, ] diff --git a/functional_tests/test_chat_retry_edit_model_endpoint_replay.py b/functional_tests/test_chat_retry_edit_model_endpoint_replay.py new file mode 100644 index 000000000..5b3a66a9a --- /dev/null +++ b/functional_tests/test_chat_retry_edit_model_endpoint_replay.py @@ -0,0 +1,134 @@ +# test_chat_retry_edit_model_endpoint_replay.py +#!/usr/bin/env python3 +""" +Functional test for retry and edit model endpoint replay. +Version: 0.261.033 +Implemented in: 0.261.033 + +This test ensures retry and edit requests preserve the model endpoint identity +used by the original streamed response instead of falling back to the default +Azure OpenAI resource with only a model or display name. +""" + +import ast +import os + + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CONVERSATION_ROUTE_FILE = os.path.join( + ROOT_DIR, + 'application', + 'single_app', + 'route_backend_conversations.py', +) +RETRY_JS_FILE = os.path.join( + ROOT_DIR, + 'application', + 'single_app', + 'static', + 'js', + 'chat', + 'chat-retry.js', +) + + +def read_file_text(file_path): + with open(file_path, 'r', encoding='utf-8') as file_handle: + return file_handle.read() + + +def load_replayed_model_context_builder(): + route_source = read_file_text(CONVERSATION_ROUTE_FILE) + route_tree = ast.parse(route_source, filename=CONVERSATION_ROUTE_FILE) + helper_node = next( + node + for node in route_tree.body + if isinstance(node, ast.FunctionDef) + and node.name == '_build_replayed_model_context' + ) + helper_module = ast.Module(body=[helper_node], type_ignores=[]) + helper_namespace = {} + exec(compile(helper_module, CONVERSATION_ROUTE_FILE, 'exec'), helper_namespace) + return helper_namespace['_build_replayed_model_context'] + + +def build_original_metadata(): + return { + 'model_selection': { + 'frontend_requested_model': 'gpt-5.6-luna', + 'selected_model': 'resolved-azure-deployment', + 'model_id': 'model-luna', + 'model_endpoint_id': 'endpoint-luna', + 'model_provider': 'aoai', + 'model_icon': {'kind': 'bootstrap', 'value': 'bi-moon'}, + }, + } + + +def test_edit_replays_original_model_endpoint_identity(): + builder = load_replayed_model_context_builder() + + replayed_context = builder(build_original_metadata()) + + assert replayed_context == { + 'model_deployment': 'gpt-5.6-luna', + 'model_id': 'model-luna', + 'model_endpoint_id': 'endpoint-luna', + 'model_provider': 'aoai', + 'model_icon': {'kind': 'bootstrap', 'value': 'bi-moon'}, + } + + +def test_retry_selection_preserves_or_replaces_endpoint_identity(): + builder = load_replayed_model_context_builder() + original_metadata = build_original_metadata() + + same_model_context = builder(original_metadata, {'model': 'gpt-5.6-luna'}) + assert same_model_context['model_endpoint_id'] == 'endpoint-luna' + assert same_model_context['model_id'] == 'model-luna' + + changed_model_context = builder(original_metadata, {'model': 'legacy-deployment'}) + assert changed_model_context['model_deployment'] == 'legacy-deployment' + assert changed_model_context['model_endpoint_id'] is None + assert changed_model_context['model_id'] is None + assert changed_model_context['model_provider'] is None + + explicit_context = builder(original_metadata, { + 'model': 'new-request-model', + 'model_id': 'new-model', + 'model_endpoint_id': 'new-endpoint', + 'model_provider': 'openai', + }) + assert explicit_context['model_deployment'] == 'new-request-model' + assert explicit_context['model_id'] == 'new-model' + assert explicit_context['model_endpoint_id'] == 'new-endpoint' + assert explicit_context['model_provider'] == 'openai' + + +def test_retry_frontend_sends_complete_model_identity(): + retry_source = read_file_text(RETRY_JS_FILE) + + assert 'selectedOption?.dataset?.requestModel' in retry_source + assert 'requestBody.model_id = selectedOption?.dataset?.modelId || null;' in retry_source + assert 'requestBody.model_endpoint_id = selectedOption?.dataset?.endpointId || null;' in retry_source + assert 'requestBody.model_provider = selectedOption?.dataset?.provider || null;' in retry_source + assert "requestBody.model_icon = JSON.parse(selectedOption?.dataset?.modelIcon || '{}');" in retry_source + + +if __name__ == '__main__': + tests = [ + test_edit_replays_original_model_endpoint_identity, + test_retry_selection_preserves_or_replaces_endpoint_identity, + test_retry_frontend_sends_complete_model_identity, + ] + results = [] + for test in tests: + try: + test() + print(f'{test.__name__}: passed') + results.append(True) + except Exception as exc: + print(f'{test.__name__}: failed: {exc}') + results.append(False) + + raise SystemExit(0 if all(results) else 1) \ No newline at end of file diff --git a/ui_tests/test_chat_clipboard_paste_upload_workflow.py b/ui_tests/test_chat_clipboard_paste_upload_workflow.py index 758fcacbf..d1e15eb08 100644 --- a/ui_tests/test_chat_clipboard_paste_upload_workflow.py +++ b/ui_tests/test_chat_clipboard_paste_upload_workflow.py @@ -1,13 +1,14 @@ # test_chat_clipboard_paste_upload_workflow.py """ UI test for chat clipboard paste upload support. -Version: 0.241.056 -Implemented in: 0.241.056 +Version: 0.261.032 +Implemented in: 0.241.056; expanded in: 0.261.032 This test ensures that pasting a clipboard image into the main chat input routes the file through the existing upload flow, auto-creates a conversation, normalizes an empty clipboard filename, preserves later text paste events, and -uploads dropped files through the same chat flow. +uploads dropped files through the same chat flow. It also verifies that an +unreadable dropped file produces actionable guidance without an upload request. """ import json @@ -97,6 +98,7 @@ def handle_get_conversations(route): (() => { const originalFetch = window.fetch.bind(window); window.__uploadCapture = []; + window.__rejectNextUpload = false; window.fetch = async (input, init = {}) => { const requestUrl = typeof input === 'string' ? input : (input && input.url) || ''; @@ -108,6 +110,10 @@ def handle_get_conversations(route): fileName: file ? String(file.name || '') : '', fileType: file ? String(file.type || '') : '', }); + if (window.__rejectNextUpload) { + window.__rejectNextUpload = false; + throw new DOMException('Failed to fetch', 'NetworkError'); + } } return originalFetch(input, init); @@ -255,6 +261,72 @@ def handle_get_conversations(route): assert upload_capture[1]["fileName"] == "drop-notes.txt" assert upload_capture[1]["fileType"] == "text/plain" + page.locator("#user-input").evaluate( + """ + (element) => { + const unreadableFile = new File( + ['locked document'], + 'open-in-onedrive.docx', + { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } + ); + Object.defineProperty(unreadableFile, 'slice', { + value: () => ({ + arrayBuffer: () => Promise.reject(new DOMException('The requested file could not be read')), + }), + }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(unreadableFile); + const dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + + element.dispatchEvent(dropEvent); + } + """ + ) + + unreadable_file_toast = page.locator("#toast-container .toast").last + expect(unreadable_file_toast).to_contain_text( + 'The browser could not read "open-in-onedrive.docx".' + ) + expect(unreadable_file_toast).to_contain_text( + "Close the file in other apps then try again." + ) + assert page.evaluate("() => window.__uploadCapture.length") == 2 + + page.evaluate("() => { window.__rejectNextUpload = true; }") + page.locator("#user-input").evaluate( + """ + (element) => { + const droppedFile = new File( + ['cloud document'], + 'open-cloud-document.docx', + { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } + ); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(droppedFile); + const dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + + element.dispatchEvent(dropEvent); + } + """ + ) + + rejected_upload_toast = page.locator("#toast-container .toast").last + expect(rejected_upload_toast).to_contain_text( + 'The browser could not upload "open-cloud-document.docx".' + ) + expect(rejected_upload_toast).to_contain_text( + "The file may be open in another app, unavailable from cloud storage, or the network connection may have been interrupted." + ) + expect(rejected_upload_toast).not_to_contain_text("Failed to fetch") + expect(page.locator("#current-conversation-title")).to_have_text("Clipboard Upload Conversation") finally: context.close() From abfa048fdca0b3aea167a05c4d1fbede3b345b73 Mon Sep 17 00:00:00 2001 From: Chad Palmer Date: Fri, 18 Sep 2026 18:22:53 +0000 Subject: [PATCH 2/3] v0.261.xxx-fixes - fixed public workspace search menu colors/styles. --- application/single_app/config.py | 2 +- application/single_app/static/css/styles.css | 11 ++++-- .../templates/public_workspaces.html | 2 +- docs/explanation/release_notes.md | 9 +++++ .../test_workspace_active_hero_shortcuts.py | 38 ++++++++++++++++++- 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 34aa87673..1e1ab1bfd 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.002.01" +VERSION = "0.261.003.01" 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/static/css/styles.css b/application/single_app/static/css/styles.css index 611092e26..ef2643874 100644 --- a/application/single_app/static/css/styles.css +++ b/application/single_app/static/css/styles.css @@ -124,18 +124,21 @@ main { color: #e9ecef !important; } -/* Fix for group dropdown in dark mode */ -[data-bs-theme="dark"] #group-dropdown .group-search-container { +/* Fix for workspace selector dropdowns in dark mode */ +[data-bs-theme="dark"] #group-dropdown .group-search-container, +[data-bs-theme="dark"] #public-dropdown .public-search-container { background-color: #343a40 !important; border-bottom: 1px solid #495057 !important; } -[data-bs-theme="dark"] #group-dropdown .dropdown-item:hover { +[data-bs-theme="dark"] #group-dropdown .dropdown-item:hover, +[data-bs-theme="dark"] #public-dropdown .dropdown-item:hover { background-color: #495057 !important; color: #e9ecef !important; } -[data-bs-theme="dark"] #group-dropdown .dropdown-menu { +[data-bs-theme="dark"] #group-dropdown .dropdown-menu, +[data-bs-theme="dark"] #public-dropdown .dropdown-menu { background-color: #343a40 !important; color: #e9ecef !important; border-color: #495057 !important; diff --git a/application/single_app/templates/public_workspaces.html b/application/single_app/templates/public_workspaces.html index ac3864f6b..979ec94f3 100644 --- a/application/single_app/templates/public_workspaces.html +++ b/application/single_app/templates/public_workspaces.html @@ -213,7 +213,7 @@ {% include "_semantic_search_health_warning.html" %} -
+