From 6f3f68e6f069212929bf487bbc0d7598e80b1dce Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 19 Oct 2026 19:59:47 -0400 Subject: [PATCH] Fix filename-independent document processing status Align terminal status predicates across Python, React and classic workspace consumers so filenames and retry details cannot stop polling or force completion. Preserve failure, screening and approval behavior, add cross-runtime and SDK-backed regressions, and bump the application to 0.261.349. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_document_queries.py | 5 +- .../single_app/functions_document_status.py | 32 ++++ application/single_app/functions_documents.py | 15 +- application/single_app/route_backend_chats.py | 5 +- .../static/js/chat/chat-documents.js | 4 +- .../static/js/chat/chat-messages.js | 19 +- .../single_app/static/js/document-status.js | 16 ++ .../static/js/public/public_workspace.js | 17 +- .../js/workspace/workspace-documents.js | 13 +- .../static/js/workspace/workspace-tags.js | 6 +- application/single_app/templates/base.html | 1 + .../templates/group_workspaces.html | 25 +-- application/v2_ui/src/lib/chatUploads.ts | 4 +- application/v2_ui/src/lib/documentExplorer.ts | 5 +- .../v2_ui/src/lib/documentProcessingStatus.ts | 13 ++ ...DOCUMENT_STATUS_TEXT_CLASSIFICATION_FIX.md | 105 +++++++++++ .../cosmos_response_boundaries.json | 170 +++++++++--------- .../document_processing_status_cases.json | 52 ++++++ .../test_content_screening_read_boundaries.py | 9 +- .../test_document_processing_status.py | 161 +++++++++++++++++ .../test_document_search_metadata_sync.py | 113 +++++++++++- .../test_group_document_management.py | 8 +- .../test_v2_chat_context_request.ts | 52 +++++- .../test_v2_documents_explorer.py | 5 +- .../test_v2_documents_explorer_logic.ts | 18 +- .../test_workspace_metadata_outcomes.py | 4 +- ..._chat_workspace_upload_progress_polling.py | 24 +-- .../test_document_status_classification.py | 89 +++++++++ ...t_group_document_filename_xss_rendering.py | 3 +- ui_tests/test_v2_chat_upload_status.py | 124 +++++++++++++ 31 files changed, 936 insertions(+), 183 deletions(-) create mode 100644 application/single_app/functions_document_status.py create mode 100644 application/single_app/static/js/document-status.js create mode 100644 application/v2_ui/src/lib/documentProcessingStatus.ts create mode 100644 docs/explanation/fixes/DOCUMENT_STATUS_TEXT_CLASSIFICATION_FIX.md create mode 100644 functional_tests/document_processing_status_cases.json create mode 100644 functional_tests/test_document_processing_status.py create mode 100644 ui_tests/test_document_status_classification.py create mode 100644 ui_tests/test_v2_chat_upload_status.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 4d0b020d2..6aad28e13 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.348" +VERSION = "0.261.349" 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_document_queries.py b/application/single_app/functions_document_queries.py index c276cb51c..9f6992a08 100644 --- a/application/single_app/functions_document_queries.py +++ b/application/single_app/functions_document_queries.py @@ -3,6 +3,8 @@ from datetime import datetime, timedelta, timezone +from functions_document_status import is_document_processing_failure + DOCUMENT_PLACE_FILTERS = frozenset({ "all", "recent", "shared", "processing", "errors", "untagged", @@ -11,8 +13,7 @@ def _document_processing_state(document_item): - status_text = str(document_item.get("status") or "").lower() - if "error" in status_text or "failed" in status_text: + if is_document_processing_failure(document_item.get("status")): return "error" percentage = document_item.get("percentage_complete") diff --git a/application/single_app/functions_document_status.py b/application/single_app/functions_document_status.py new file mode 100644 index 000000000..d25201d2e --- /dev/null +++ b/application/single_app/functions_document_status.py @@ -0,0 +1,32 @@ +# functions_document_status.py +"""Document terminal-state predicates, independent of application bootstrap. + +Status details can contain filenames, sheet names, or retry diagnostics. Only +lifecycle phrases at the start of a status can declare a terminal outcome. +Keep the browser predicates aligned through document_processing_status_cases.json. +""" + +import re + + +_FAILURE = re.compile( + r"^(?:(?:error|failed)(?:$|[\s:!]|\.(?:$|\s))" + r"|(?:processing|document processing|approval|content screening) failed(?:$|[\s:;!]|\.(?:$|\s))" + r"|video:\s*(?:blob upload|auth|upload|indexing) failed(?:$|[\s:!]|\.(?:$|\s)))", + re.IGNORECASE, +) +_COMPLETE = re.compile( + r"^(?:(?:ready|complete|completed|processed|finished)[.!]?$" + r"|processing complete(?:$|[.!]$|\s+[-(]))", + re.IGNORECASE, +) + + +def is_document_processing_failure(status): + """Recognize terminal failures, not error words in processing details.""" + return isinstance(status, str) and _FAILURE.match(status.strip()) is not None + + +def is_document_processing_complete(status): + """Recognize final completion, not completion of an intermediate stage.""" + return isinstance(status, str) and _COMPLETE.match(status.strip()) is not None diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index eb7536322..0fb841465 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -49,6 +49,7 @@ ) from config import * from functions_appinsights import log_event +from functions_document_status import is_document_processing_complete, is_document_processing_failure from functions_document_provenance import ( ORIGIN_FIELD_NAMES, ORIGIN_KIND_FIELD, @@ -3508,10 +3509,10 @@ def calculate_processing_percentage(doc_metadata): total_chunks_saved = doc_metadata.get('current_file_chunk', 0) # --- Final States --- - if "processing complete" in status or current_pct == 100: + if is_document_processing_complete(status) or current_pct == 100: # Ensure it stays 100 if it ever reached it return 100 - if "error" in status or "failed" in status: + if is_document_processing_failure(status): # Keep the last known percentage on error/failure return current_pct @@ -3694,7 +3695,7 @@ def update_document(**kwargs): marker = existing_document.get(SCREENING_FIELD) if marker is not None and not document_is_available(existing_document): requested_status = str(kwargs.get("status") or "").lower() - if "complete" in requested_status: + if is_document_processing_complete(requested_status): kwargs["status"] = "Content review required" if marker.get("review_required") else "Content screening pending" if kwargs.get("percentage_complete") == 100: kwargs["percentage_complete"] = 99 @@ -3763,14 +3764,14 @@ def update_document(**kwargs): elif isinstance(status_lower, dict): status_lower = json.dumps(status_lower).lower() - if "processing complete" in status_lower: + if is_document_processing_complete(status_lower): new_percentage = 100 - elif "error" in status_lower or "failed" in status_lower: + elif is_document_processing_failure(status_lower): pass # Percentage already calculated by helper based on 'failed' status # Ensure percentage doesn't decrease (unless reset on failure or hitting 100) # Compare against original_percentage fetched *before* any updates in this call - if new_percentage < original_percentage and new_percentage != 0 and "failed" not in status_lower and "error" not in status_lower: + if new_percentage < original_percentage and new_percentage != 0 and not is_document_processing_failure(status_lower): existing_document['percentage_complete'] = original_percentage else: existing_document['percentage_complete'] = new_percentage @@ -6881,7 +6882,7 @@ def detect_doc_type(document_id, user_id=None): def _update_document_for_job(*, operation_guard=None, safe_errors=False, **arguments): status = arguments.get("status") - if safe_errors and isinstance(status, str) and any(word in status.lower() for word in ("error", "failed")): + if safe_errors and is_document_processing_failure(status): arguments["status"] = "Document processing failed. Contact an administrator if the problem persists." if operation_guard is None: return update_document(**arguments) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index c1cee8b08..3d75f5c81 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -13,6 +13,7 @@ guard_model_callable, ) from content_screening.contracts import DocumentHeldError, ScreeningConflictError, ScreeningError, document_is_available +from functions_document_status import is_document_processing_complete, is_document_processing_failure from agent_execution_context import AgentExecutionCancelled, DelegationBudget from action_credential_contracts import ActionCredentialsRequired from agent_delegation_runtime import AgentExecution, delegation_citations, delegation_usage, prepare_agent_execution @@ -1533,7 +1534,7 @@ def _is_search_ready_chat_upload_workspace_document(document_item): return False status = str(document_item.get('status') or '').strip().lower() - if 'error' in status or 'failed' in status: + if is_document_processing_failure(status): return False try: @@ -1548,7 +1549,7 @@ def _is_search_ready_chat_upload_workspace_document(document_item): except (TypeError, ValueError): percentage_complete = 0 - return percentage_complete >= 100 or 'processing complete' in status + return percentage_complete >= 100 or is_document_processing_complete(status) def _get_chat_upload_workspace_document_scope(document_item): diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js index 5331b1518..7a662920d 100644 --- a/application/single_app/static/js/chat/chat-documents.js +++ b/application/single_app/static/js/chat/chat-documents.js @@ -182,10 +182,10 @@ function isConversationTaskDocumentReady(taskDocument = {}) { if (window.ContentScreening?.isHeld(taskDocument)) return false; const statusText = String(taskDocument.status || '').trim().toLowerCase(); const percentageComplete = Number(taskDocument.percentage_complete || taskDocument.percentageComplete || 0); - if (statusText.includes('error') || statusText.includes('failed')) { + if (window.DocumentStatus.isFailure(statusText)) { return false; } - return percentageComplete >= 100 || statusText.includes('processing complete') || statusText.includes('complete'); + return percentageComplete >= 100 || window.DocumentStatus.isComplete(statusText); } function normalizeConversationTaskDocument(documentInfo = {}, fallbackConversationId = null) { diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 4382630a6..b100356e3 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -132,15 +132,13 @@ function getChatWorkspaceProgressValue(value) { function isChatWorkspaceDocumentComplete(doc) { const statusText = String(doc?.status || '').toLowerCase(); return getChatWorkspaceProgressValue(doc?.percentage_complete) >= 100 - || statusText.includes('processing complete') - || statusText.includes('complete') - || statusText.includes('error') - || statusText.includes('failed'); + || window.DocumentStatus.isComplete(statusText) + || window.DocumentStatus.isFailure(statusText); } function isChatWorkspaceDocumentFailure(doc) { const statusText = String(doc?.status || '').toLowerCase(); - return statusText.includes('error') || statusText.includes('failed'); + return window.DocumentStatus.isFailure(statusText); } function isChatWorkspaceDocumentSuccessComplete(doc) { @@ -148,8 +146,7 @@ function isChatWorkspaceDocumentSuccessComplete(doc) { return !isChatWorkspaceDocumentFailure(doc) && ( getChatWorkspaceProgressValue(doc?.percentage_complete) >= 100 - || statusText.includes('processing complete') - || statusText.includes('complete') + || window.DocumentStatus.isComplete(statusText) ); } @@ -231,9 +228,9 @@ function buildChatWorkspaceAttachmentHtml(attachment) { const statusText = String(attachment?.status || 'Queued for workspace processing').trim() || 'Queued for workspace processing'; const detailsId = getChatWorkspaceProgressDetailsId(workspaceDocumentId); const statusLower = statusText.toLowerCase(); - const progressClass = statusLower.includes('error') || statusLower.includes('failed') + const progressClass = window.DocumentStatus.isFailure(statusLower) ? 'bg-danger' - : (pct >= 100 || statusLower.includes('complete') ? 'bg-success' : 'progress-bar-striped progress-bar-animated bg-info'); + : (pct >= 100 || window.DocumentStatus.isComplete(statusLower) ? 'bg-success' : 'progress-bar-striped progress-bar-animated bg-info'); return `
= 100 || statusLower.includes('complete')); + const hasFailure = window.DocumentStatus.isFailure(statusLower); + const hasCompleted = !hasFailure && (pct >= 100 || window.DocumentStatus.isComplete(statusLower)); const progressBar = container.querySelector('.progress-bar'); const statusElement = container.querySelector('.chat-workspace-upload-status-text'); const progressElement = container.querySelector('.progress'); diff --git a/application/single_app/static/js/document-status.js b/application/single_app/static/js/document-status.js new file mode 100644 index 000000000..a1655ee28 --- /dev/null +++ b/application/single_app/static/js/document-status.js @@ -0,0 +1,16 @@ +// document-status.js +// Kept in parity with Python and React through document_processing_status_cases.json. + +window.DocumentStatus = (() => { + const failure = /^(?:(?:error|failed)(?:$|[\s:!]|\.(?:$|\s))|(?:processing|document processing|approval|content screening) failed(?:$|[\s:;!]|\.(?:$|\s))|video:\s*(?:blob upload|auth|upload|indexing) failed(?:$|[\s:!]|\.(?:$|\s)))/i; + const complete = /^(?:(?:ready|complete|completed|processed|finished)[.!]?$|processing complete(?:$|[.!]$|\s+[-(]))/i; + + return Object.freeze({ + isFailure(status) { + return typeof status === "string" && failure.test(status.trim()); + }, + isComplete(status) { + return typeof status === "string" && complete.test(status.trim()); + }, + }); +})(); diff --git a/application/single_app/static/js/public/public_workspace.js b/application/single_app/static/js/public/public_workspace.js index 9c1c2d6c3..4d39a66b0 100644 --- a/application/single_app/static/js/public/public_workspace.js +++ b/application/single_app/static/js/public/public_workspace.js @@ -1071,9 +1071,8 @@ function getPublicDocumentProcessingState(doc) { const pctString = String((doc.percentage_complete ?? doc.percentage) || '0'); const pct = /^\d+(\.\d+)?$/.test(pctString) ? parseFloat(pctString) : 0; const docStatus = doc.status || ''; - const normalizedStatus = docStatus.toLowerCase(); - const hasError = normalizedStatus.includes('error') || normalizedStatus.includes('failed'); - const isComplete = pct >= 100 || normalizedStatus.includes('complete') || hasError; + const hasError = window.DocumentStatus.isFailure(docStatus); + const isComplete = pct >= 100 || window.DocumentStatus.isComplete(docStatus) || hasError; return { pct, docStatus, hasError, isComplete }; } @@ -1667,8 +1666,8 @@ function renderPublicDocumentRow(doc) { const pctString = String((doc.percentage_complete ?? doc.percentage) || "0"); const pct = /^\d+(\.\d+)?$/.test(pctString) ? parseFloat(pctString) : 0; const docStatus = doc.status || ""; - const isComplete = pct >= 100 || docStatus.toLowerCase().includes("complete") || docStatus.toLowerCase().includes("error"); - const hasError = docStatus.toLowerCase().includes("error") || docStatus.toLowerCase().includes("failed"); + const hasError = window.DocumentStatus.isFailure(docStatus); + const isComplete = pct >= 100 || window.DocumentStatus.isComplete(docStatus) || hasError; const isPendingGeneratedArtifact = isPendingGeneratedArtifactDocument(doc); let firstTdHtml = ""; @@ -1896,8 +1895,8 @@ function pollPublicDocumentStatus(documentId) { const pctString = String((doc.percentage_complete ?? doc.percentage) || "0"); const pct = /^\d+(\.\d+)?$/.test(pctString) ? parseFloat(pctString) : 0; const docStatus = doc.status || ""; - const isComplete = pct >= 100 || docStatus.toLowerCase().includes("complete") || docStatus.toLowerCase().includes("error"); - const hasError = docStatus.toLowerCase().includes("error") || docStatus.toLowerCase().includes("failed"); + const hasError = window.DocumentStatus.isFailure(docStatus); + const isComplete = pct >= 100 || window.DocumentStatus.isComplete(docStatus) || hasError; if (!isComplete && statusRow) { // Update progress bar and status text if still processing @@ -3374,8 +3373,8 @@ function buildPublicFolderDocumentsTable(docs) { const pctString = String((doc.percentage_complete ?? doc.percentage) || '0'); const pct = /^\d+(\.\d+)?$/.test(pctString) ? parseFloat(pctString) : 0; const docStatus = doc.status || ''; - const isComplete = pct >= 100 || docStatus.toLowerCase().includes('complete') || docStatus.toLowerCase().includes('error'); - const hasError = docStatus.toLowerCase().includes('error') || docStatus.toLowerCase().includes('failed'); + const hasError = window.DocumentStatus.isFailure(docStatus); + const isComplete = pct >= 100 || window.DocumentStatus.isComplete(docStatus) || hasError; const isSelected = publicSelectedDocuments.has(doc.id); let firstColHtml = ``; let actionsHtml = ''; diff --git a/application/single_app/static/js/workspace/workspace-documents.js b/application/single_app/static/js/workspace/workspace-documents.js index 5abb199e9..54d8ff7c8 100644 --- a/application/single_app/static/js/workspace/workspace-documents.js +++ b/application/single_app/static/js/workspace/workspace-documents.js @@ -318,9 +318,8 @@ function getDocumentProcessingState(doc) { const pctString = String(doc?.percentage_complete ?? ""); const pct = /^\d+(\.\d+)?$/.test(pctString) ? parseFloat(pctString) : 0; const docStatus = doc?.status || ""; - const normalizedStatus = docStatus.toLowerCase(); - const hasError = normalizedStatus.includes("error"); - const isComplete = pct >= 100 || normalizedStatus.includes("complete") || hasError; + const hasError = window.DocumentStatus.isFailure(docStatus); + const isComplete = pct >= 100 || window.DocumentStatus.isComplete(docStatus) || hasError; return { pct, docStatus, hasError, isComplete }; } @@ -1789,8 +1788,8 @@ function renderDocumentRow(doc) { const pctString = String(doc.percentage_complete); const pct = /^\d+(\.\d+)?$/.test(pctString) ? parseFloat(pctString) : 0; const docStatus = doc.status || ""; - const isComplete = pct >= 100 || docStatus.toLowerCase().includes("complete") || docStatus.toLowerCase().includes("error"); - const hasError = docStatus.toLowerCase().includes("error"); + const hasError = window.DocumentStatus.isFailure(docStatus); + const isComplete = pct >= 100 || window.DocumentStatus.isComplete(docStatus) || hasError; const docRow = document.createElement("tr"); docRow.id = `doc-row-${docId}`; @@ -2239,8 +2238,8 @@ function pollDocumentStatus(documentId) { const pctString = String(doc.percentage_complete); const pct = /^\d+(\.\d+)?$/.test(pctString) ? parseFloat(pctString) : 0; const docStatus = doc.status || ""; - const isComplete = pct >= 100 || docStatus.toLowerCase().includes("complete") || docStatus.toLowerCase().includes("error"); - const hasError = docStatus.toLowerCase().includes("error"); + const hasError = window.DocumentStatus.isFailure(docStatus); + const isComplete = pct >= 100 || window.DocumentStatus.isComplete(docStatus) || hasError; if (!isComplete && statusRow) { // Update progress bar and status text if still processing diff --git a/application/single_app/static/js/workspace/workspace-tags.js b/application/single_app/static/js/workspace/workspace-tags.js index c23d28631..2fa0f8bd2 100644 --- a/application/single_app/static/js/workspace/workspace-tags.js +++ b/application/single_app/static/js/workspace/workspace-tags.js @@ -648,10 +648,8 @@ function buildFolderDocumentsTable(docs) { const pctStr = String(doc.percentage_complete); const pct = /^\d+(\.\d+)?$/.test(pctStr) ? parseFloat(pctStr) : 0; const docStatus = doc.status || ''; - const isComplete = pct >= 100 - || docStatus.toLowerCase().includes('complete') - || docStatus.toLowerCase().includes('error'); - const hasError = docStatus.toLowerCase().includes('error'); + const hasError = window.DocumentStatus.isFailure(docStatus); + const isComplete = pct >= 100 || window.DocumentStatus.isComplete(docStatus) || hasError; const currentUserId = window.current_user_id; const isOwner = doc.user_id === currentUserId; diff --git a/application/single_app/templates/base.html b/application/single_app/templates/base.html index ce0f8f9f4..d098042be 100644 --- a/application/single_app/templates/base.html +++ b/application/single_app/templates/base.html @@ -709,6 +709,7 @@ const pctString = String(doc?.percentage_complete ?? ""); const pct = /^\d+(\.\d+)?$/.test(pctString) ? parseFloat(pctString) : 0; const docStatus = doc?.status || ""; - const normalizedStatus = docStatus.toLowerCase(); - const hasError = normalizedStatus.includes("error"); - const isComplete = pct >= 100 || normalizedStatus.includes("complete") || hasError; + const hasError = window.DocumentStatus.isFailure(docStatus); + const isComplete = pct >= 100 || window.DocumentStatus.isComplete(docStatus) || hasError; return { pct, docStatus, hasError, isComplete }; } @@ -6132,11 +6131,8 @@
const pctStr = String(doc.percentage_complete); const pct = /^\d+(\.\d+)?$/.test(pctStr) ? parseFloat(pctStr) : 0; const docStatus = doc.status || ''; - const isComplete = pct >= 100 - || docStatus.toLowerCase().includes('complete') - || docStatus.toLowerCase().includes('error'); - const hasError = docStatus.toLowerCase().includes('error'); + const hasError = window.DocumentStatus.isFailure(docStatus); + const isComplete = pct >= 100 || window.DocumentStatus.isComplete(docStatus) || hasError; const isSelected = groupSelectedDocuments.has(docId); const access = getGroupDocumentAccess(doc); const canModify = access.isOwnerGroup && groupCanModify; diff --git a/application/v2_ui/src/lib/chatUploads.ts b/application/v2_ui/src/lib/chatUploads.ts index 6751119c2..872ebacdb 100644 --- a/application/v2_ui/src/lib/chatUploads.ts +++ b/application/v2_ui/src/lib/chatUploads.ts @@ -8,6 +8,7 @@ import { type ChatUploadTarget, } from './endpoints'; import { documentStatus } from './documentExplorer'; +import { isDocumentProcessingComplete } from './documentProcessingStatus'; import type { ComposerDraft, ComposerReference, ComposerUpload } from './composerDraft'; import type { ChatMessage, WorkspaceDocument } from './types'; import type { Dispatch, SetStateAction } from 'react'; @@ -203,7 +204,6 @@ export function chatUploadDocumentStatus(document: WorkspaceDocument): UploadSta if (status.state === 'pending_approval') { return { state: 'failed', error: 'This file is waiting for access approval.' }; } - const text = String(document.status ?? '').trim().toLowerCase(); const rawProgress = document.percentage_complete; const progress = rawProgress === undefined || rawProgress === null ? NaN @@ -211,7 +211,7 @@ export function chatUploadDocumentStatus(document: WorkspaceDocument): UploadSta // The explorer treats old records with no progress as ready. A new upload cannot make // that assumption: a queued response without a percentage is still not usable. if ((Number.isFinite(progress) && progress >= 100) - || /^(ready|completed?|processed|finished|processing complete[.!]?)$/.test(text)) { + || isDocumentProcessingComplete(document.status)) { return { state: 'ready', progress: 100 }; } return { diff --git a/application/v2_ui/src/lib/documentExplorer.ts b/application/v2_ui/src/lib/documentExplorer.ts index c1a864815..7d6f9e078 100644 --- a/application/v2_ui/src/lib/documentExplorer.ts +++ b/application/v2_ui/src/lib/documentExplorer.ts @@ -18,6 +18,7 @@ import type { WorkspaceDocument, } from './types'; import { isScreeningAvailable } from './contentScreening'; +import { isDocumentProcessingFailure } from './documentProcessingStatus'; /* -------------------------------------------------------------------------- */ /* Query state */ @@ -364,13 +365,11 @@ export function generatedArtifactRestriction(document: WorkspaceDocument): strin * it as permanently stuck at 0% would be worse than saying nothing. */ export function documentStatus(document: WorkspaceDocument): DocumentStatus { - const statusText = String(document.status ?? '').toLowerCase(); - if (document.generated_artifact_promotion_status === 'approval_failed') { return { state: 'error', percent: 0, label: 'Publication handoff failed' }; } - if (statusText.includes('error') || statusText.includes('failed')) { + if (isDocumentProcessingFailure(document.status)) { return { state: 'error', percent: 0, label: 'Error' }; } diff --git a/application/v2_ui/src/lib/documentProcessingStatus.ts b/application/v2_ui/src/lib/documentProcessingStatus.ts new file mode 100644 index 000000000..75c4b7300 --- /dev/null +++ b/application/v2_ui/src/lib/documentProcessingStatus.ts @@ -0,0 +1,13 @@ +// documentProcessingStatus.ts +// Kept in parity with Python and classic through document_processing_status_cases.json. + +const FAILURE = /^(?:(?:error|failed)(?:$|[\s:!]|\.(?:$|\s))|(?:processing|document processing|approval|content screening) failed(?:$|[\s:;!]|\.(?:$|\s))|video:\s*(?:blob upload|auth|upload|indexing) failed(?:$|[\s:!]|\.(?:$|\s)))/i; +const COMPLETE = /^(?:(?:ready|complete|completed|processed|finished)[.!]?$|processing complete(?:$|[.!]$|\s+[-(]))/i; + +export function isDocumentProcessingFailure(status: unknown): boolean { + return typeof status === 'string' && FAILURE.test(status.trim()); +} + +export function isDocumentProcessingComplete(status: unknown): boolean { + return typeof status === 'string' && COMPLETE.test(status.trim()); +} diff --git a/docs/explanation/fixes/DOCUMENT_STATUS_TEXT_CLASSIFICATION_FIX.md b/docs/explanation/fixes/DOCUMENT_STATUS_TEXT_CLASSIFICATION_FIX.md new file mode 100644 index 000000000..200bd5a80 --- /dev/null +++ b/docs/explanation/fixes/DOCUMENT_STATUS_TEXT_CLASSIFICATION_FIX.md @@ -0,0 +1,105 @@ +# Document Status Text Classification Fix + +Version: **0.261.349** + +Fixed/Implemented in version: **0.261.349** + +Related configuration update: `application/single_app/config.py`, `VERSION = "0.261.349"`. + +## Issue and root cause + +A chat attachment could show **Failed** even though the document processor +successfully saved and indexed it. Document status messages include filenames, +sheet names, and processing details. Consumers searched the entire message for +`error`, `failed`, or `complete`, interpreting those words as lifecycle state. + +For example, `Processing file error-budget.md, type: .md` was treated as a +terminal error. React stopped checking the document immediately, so it never +observed the successful completion. Classic views could make the opposite +mistake: a filename such as `incomplete-policy.pdf` enabled completed-document +actions before processing finished. A filename containing `processing complete` +could also force backend progress to 100%. + +The incident's Azure console logs confirmed successful indexing of one chunk +after the UI had stopped polling. It failed before plan review or execution; +the incident did not demonstrate an orchestration or model-provider failure. + +## Technical changes + +Small, bootstrap-independent predicates now recognize explicit lifecycle +phrases rather than arbitrary substrings. Python uses +`functions_document_status.py`; React uses `documentProcessingStatus.ts`; classic +pages load the local `static/js/document-status.js` asset from `base.html`. +A shared test-case matrix keeps the three implementations aligned. + +Consumers updated: + +- Workspace processing/error filters and facets in `functions_document_queries.py`. +- Percentage calculation, guarded safe-error updates, and screening-related + completion wording in `functions_documents.py`. +- Chat-upload search-readiness checks in `route_backend_chats.py`. +- React document status and attachment polling in `documentExplorer.ts` and + `chatUploads.ts`. +- Classic chat attachment cards and task-document readiness, personal workspace + lists/cards/folders, group document views, and public workspace views. + +An explicit failure such as `Error: ...` still takes precedence over 100% +progress. Metadata warnings and known transient retry messages do not become +terminal failures. Completion of an intermediate stage, such as vision analysis, +is not completion of document ingestion. + +This changes interpretation, not ingestion or authorization. Publication +approval, screening holds, sharing restrictions, archived/unlinked documents, +and indexed-chunk requirements retain their separate checks. Legacy explorer +records without progress keep their existing behavior; new chat uploads still +require readiness evidence. + +No database migration, filename restriction, timeout increase, automatic retry, +live upload, deployment, or external harness change is part of this fix. + +## Usage and impact + +Upload documents normally. Filenames can contain state-like words without +changing their processing outcome. Processing attachments remain unavailable +for submission until they are ready, and genuine failures remain visible. + +For an already processed attachment stuck in the old UI's Failed state, the +existing **Retry** status-check action can retrieve the document's authoritative +state after the fix is deployed. It does not need another file upload. +Historical stored percentages are not automatically rewritten. + +## Validation + +Regression coverage includes: + +- `functional_tests/test_document_processing_status.py`: shared Python/React/ + classic predicate cases, backend progress, safe failures, facets and readiness. +- `functional_tests/test_v2_chat_context_request.ts` and + `test_v2_documents_explorer_logic.ts`: actual frontend state and polling. +- `functional_tests/test_document_search_metadata_sync.py`: real status updates + with SDK `CosmosDict` point responses, plain query rows, and ETag protection. +- `ui_tests/test_v2_chat_upload_status.py`: personal/group uploads with misleading + filenames, retry diagnostics, real failures, status-only Retry and removal. +- `ui_tests/test_document_status_classification.py`: group list/card/folder + behavior and personal/public/classic status decisions. +- `ui_tests/test_chat_workspace_upload_progress_polling.py`: classic progress + rendering, status polling and task-document registration. + +These tests use local application assets and intercepted external boundaries; +they do not upload to the deployed application or exercise a live plan. + +Local results: the affected browser suites passed **101 tests**, the SDK-backed +metadata/status suite passed **42 tests**, and the targeted status/explorer/ +screening-readiness suite passed **96 tests**. Frontend typechecking, the +production build, JavaScript syntax checks, route-policy checks and documentation +checks passed. With the application's pinned Cosmos SDK **4.9.0**, the full +network-blocked Cosmos compatibility gate passed **1,163 tests** and **244 +subtests**, with no inventory review gaps. + +A broader backend run encountered a pre-existing extracted-function fixture +failure in `test_content_screening_read_boundaries.py`: its `hybrid_search` +namespace omits `current_public_chat_scope`. The same failure reproduces using +the original test at HEAD. That unrelated test-infrastructure repair is not +included here; the directly affected chat-readiness check passes. + +Related: [Chat upload conversation task context](CHAT_UPLOAD_CONVERSATION_TASK_CONTEXT_FIX.md). diff --git a/functional_tests/cosmos_response_boundaries.json b/functional_tests/cosmos_response_boundaries.json index 2235c1fd9..054735754 100644 --- a/functional_tests/cosmos_response_boundaries.json +++ b/functional_tests/cosmos_response_boundaries.json @@ -110,7 +110,6 @@ "functional_tests/test_document_provenance_origin.py::ConversationStore": "630bfc8422d888ce8a1734e838558088c1ce97442c414b68d92ea8f2ea324664", "functional_tests/test_document_provenance_publication.py::test_an_unreadable_conversation_never_blocks_publication.UnavailableConversations": "2f5064c80b76b576c350ae31e227f600afe4b68141b950fc3b073e3be39bcd91", "functional_tests/test_document_provenance_versions_and_guards.py::ConversationReads": "6b6e988a4e5a58c97875c61b731a11c92fcd62567f635898c815e6f9bbed1ba1", - "functional_tests/test_document_search_metadata_sync.py::FakeDocumentsContainer": "81341dcf6916ebe93a2ad55f6a2e90c747d7d8f19d6b7b019399f440d98ba717", "functional_tests/test_fact_memory_profile_and_mini_sk.py::FakeFactContainer": "38a457b1b2a3236d946664ca1db4353daef1e21c6f36abadfd47178a28ad1393", "functional_tests/test_feedback_submission_authorization.py::FakeConversationContainer": "314eaff4d7220b4632b4a838633a4c3928350063fd568f04da566aef8c3d3df0", "functional_tests/test_feedback_submission_authorization.py::FakeFeedbackContainer": "8280ace4523971c41e58ee4481d244324bc42c7c264451e0fe953a838b0d2dcd", @@ -6823,7 +6822,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "_upsert_document_and_sync_access_index: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -6845,7 +6844,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "_get_document_family_items_from_document: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -6867,7 +6866,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "create_document: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -6889,7 +6888,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_document_metadata: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -6901,7 +6900,7 @@ }, "application/single_app/functions_documents.py::update_document": { "boundary": { - "sha256": "044b9f8633e1c5f242d861baad67d86126f2ae8592d96b3c49465ae3a46718fd", + "sha256": "04e6e05e2932a55f3c08f69e30d0d1b6636724785e8fdcc4f232b87a81211484", "points": [], "queries": [ { @@ -6911,14 +6910,17 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", - "contract": "update_document: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", + "contract": "Workspace-scoped query rows are plain dictionaries. Status predicates only interpret strings; filenames cannot force success/failure or rewrite screening copy. Strict update returns the SDK saved mapping to mapping-compatible callers; read/write ETags, operation guards, access projection and screening holds are preserved. Runtime evidence exercises personal/group/public status writes with installed-SDK point responses and plain query rows.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_workspace_metadata_outcomes.py::test_upload_dispatcher_preserves_ready_content_after_metadata_failure", - "functional_tests/test_content_screening_upload_only_intake.py::test_model_generated_metadata_applies_without_a_hold" + "functional_tests/test_content_screening_upload_only_intake.py::test_model_generated_metadata_applies_without_a_hold", + "functional_tests/test_document_search_metadata_sync.py::test_status_updates_preserve_sdk_points_etags_and_query_rows", + "functional_tests/test_document_search_metadata_sync.py::test_held_status_updates_distinguish_processing_details_from_final_completion", + "functional_tests/test_document_search_metadata_sync.py::test_status_update_cannot_write_after_its_operation_guard_expires" ] }, "application/single_app/functions_documents.py::get_document_metadata_for_citations": { @@ -6933,7 +6935,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_document_metadata_for_citations: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -6955,7 +6957,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "_rebase_document_after_fenced_acl_projection: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -6977,7 +6979,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "_read_document_search_sync_record: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -6999,7 +7001,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "_replace_document_search_sync_record: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7021,7 +7023,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "request_document_search_metadata_sync: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7043,7 +7045,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "response-unused", "contract": "_complete_document_search_sync_pass: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -7062,7 +7064,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "response-unused", "contract": "_delete_document_search_sync_record: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -7081,7 +7083,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "_read_document_for_search_sync: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7103,7 +7105,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "process_due_document_search_metadata_syncs: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7125,7 +7127,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_document_record: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7147,7 +7149,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_latest_version: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7169,7 +7171,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_document_version: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7191,7 +7193,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "delete_from_blob_storage: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7238,7 +7240,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "delete_document: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7265,7 +7267,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "delete_document_revision: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7292,7 +7294,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_chat_upload_workspace_documents_for_conversation: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7314,7 +7316,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_chat_upload_workspace_documents_for_collaboration: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7336,7 +7338,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_document_versions: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7368,7 +7370,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "detect_doc_type: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7390,7 +7392,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "extract_document_metadata: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7412,7 +7414,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "upload_to_blob: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7434,7 +7436,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "_query_current_xsd_documents: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7456,7 +7458,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "_workspace_file_name_exists: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7483,7 +7485,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "sync_chat_upload_workspace_attachment_status: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7505,7 +7507,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "response-unused", "contract": "queue_personal_workspace_upload_from_temp_file: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -7524,7 +7526,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "response-unused", "contract": "queue_group_workspace_upload_from_temp_file: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -7548,7 +7550,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "_process_document_upload_background_impl: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7570,7 +7572,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "upgrade_legacy_documents: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7592,7 +7594,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "share_document_with_user: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7614,7 +7616,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "unshare_document_from_user: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7636,7 +7638,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_shared_users_for_document: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7658,7 +7660,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "is_document_shared_with_user: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7680,7 +7682,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_documents_shared_with_user: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7702,7 +7704,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "share_document_with_group: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7724,7 +7726,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "unshare_document_from_group: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7746,7 +7748,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_shared_groups_for_document: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7768,7 +7770,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "is_document_shared_with_group: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7790,7 +7792,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_documents_shared_with_group: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7812,7 +7814,7 @@ } ], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "get_workspace_tags: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -7834,7 +7836,7 @@ ], "queries": [], "batches": [], - "module_sha256": "f3d00ddb30614f061b946ba0854058923903ac6c3e783434bd8ab9a45b9d881a" + "module_sha256": "28375fdc378755ea3fa75cbc6b94d3544c3bfdc07b6b5cafc7ab87aac0b7cc46" }, "classification": "sdk-compatible", "contract": "propagate_tags_to_blob_metadata: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Workspace outcome review: existing mapping/query consumers and conditional write contracts are retained; representative module runtime behavior is covered with installed-SDK point responses, including ETags and scope propagation.", @@ -19240,7 +19242,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "response-unused", "contract": "Plain query rows provide chunk IDs; all chunk/artifact delete responses are discarded and rollback failure accounting remains explicit.", @@ -19261,7 +19263,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The user-message point mapping supplies nested plain metadata/thread fields for paired image attribution; only user/thread values escape the boundary.", @@ -19283,7 +19285,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Partitioned assistant query rows remain plain dictionaries through soft-delete filtering and saved-analysis history sanitization.", @@ -19305,7 +19307,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Artifact and chunk discovery returns plain query rows to build_message_artifact_payload_map; it is not a point-response normalization boundary.", @@ -19327,7 +19329,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "response-unused", "contract": "The local Cosmos delete response is discarded on source-screening rollback; its IfNotModified ETag comes from the persistence owner result. Assistant point normalization remains with persist_chat_reply rather than weakening screening.", @@ -19348,7 +19350,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Workspace lookup consumes plain query rows, rechecks document availability and projects only id/name/title/workspace fields into a plain selection DTO.", @@ -19375,7 +19377,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "response-unused", "contract": "Both conversation upsert responses are discarded; the constructed plain conversation is logged and cache-invalidated, with existing persistence failures unchanged.", @@ -19396,7 +19398,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The conversation point mapping feeds shared-backing and participation authorization using Mapping fields; no raw document reaches an exact-dictionary domain validator or bypasses access checks.", @@ -19423,7 +19425,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "response-unused", "contract": "Artifact upsert and cancellation-rollback delete responses are discarded; artifact identities are taken from constructed plain documents and cancellation remains visible.", @@ -19444,7 +19446,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The user-message point mapping is read through get() into a plain response-context dictionary; nested thread/user metadata stays JSON and read failures retain existing fallback fields.", @@ -19476,7 +19478,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "response-unused", "contract": "Safety/user-message upsert and conversation replace responses are discarded; the browser payload is constructed from plain screened documents and explicit public fields.", @@ -19497,7 +19499,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Workspace document discovery consumes plain query rows and builds bounded related-document metadata; no Cosmos point wrapper enters its document catalog.", @@ -19519,7 +19521,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "A new-message upsert may return the SDK mapping to Mapping-compatible chat persistence callers; retry metadata is delegated to patch_chat_message_metadata, which owns conditional update/normalization. No strict top-level DTO is synthesized from arbitrary iterables.", @@ -19541,7 +19543,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Chat attachment resolution consumes plain partitioned query rows and emits display-name/context projections; workspace ownership remains with the document resolver.", @@ -19563,7 +19565,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The TOP 1 thread projection is a plain query row; only thread_id is returned, with the existing read-failure fallback unchanged.", @@ -19590,7 +19592,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Existing point mappings are checked for owner/deletion state; newly created SDK conversation mappings feed field-based activity logging and cache invalidation, not exact-dictionary result validation.", @@ -19643,7 +19645,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Retry input point mappings supply authorized role/content/thread fields; history queries are plain, and user/system writes and rollback deletes discard responses. Saved-analysis strict result admission remains with its storage owner.", @@ -19685,7 +19687,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The original Analyze point mapping is checked for role/conversation/content and nested thread metadata; local message writes and cleanup deletes discard responses, while retained analysis contracts remain strict.", @@ -19712,7 +19714,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The source assistant SDK mapping is authorized by conversation/role and used by Mapping-compatible planned-image lookup; conversation replacement discards its response and public image data is projected explicitly.", @@ -19734,7 +19736,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The partitioned point mapping is returned to find_planned_proposal_image, which inspects message fields; a missing message returns None, without an empty-document or strict-type fallback.", @@ -19756,7 +19758,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Proposal status iterates plain query rows, validates nested proposal dictionaries and emits explicit public image status fields.", @@ -19849,7 +19851,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Retry/source point mappings supply role/content and nested model/thread metadata through Mapping access; history queries stay plain. Metadata persistence remains with its existing owner and local write responses are discarded. Action recovery now pauses before user-message persistence and model initialization; the original mapping-compatible Cosmos consumers and conditional persistence stay unchanged.", @@ -19923,7 +19925,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Retry point mappings are checked for role/conversation and nested metadata through Mapping access; history queries remain plain, and local create/replace/delete return values are discarded. SSE telemetry does not alter storage contracts. Action recovery now pauses before user-message persistence and model initialization; the original mapping-compatible Cosmos consumers and conditional persistence stay unchanged.", @@ -19946,7 +19948,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "response-unused", "contract": "Cancellation refreshes the conversation with a discarded replace response; screened message persistence remains with its existing owner and the stream payload is assembled separately.", @@ -19978,7 +19980,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "Point message/conversation mappings and plain fallback query rows use field-based ownership and soft-delete checks; nested mask metadata goes to existing persistence, and public JSON projects mask fields only.", @@ -20000,7 +20002,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The point message mapping is checked for conversation identity and passed to Mapping-compatible visual-style/persistence owners; public JSON projects nested visual_styles rather than the SDK document.", @@ -20022,7 +20024,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The point message mapping is checked against the authorized conversation and passed to Mapping-compatible revision readers/persistence; missing messages retain their 404 contract.", @@ -20044,7 +20046,7 @@ } ], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The partitioned prior-user query returns plain rows; only the first row content string is used as the originating request.", @@ -20066,7 +20068,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "A reread SDK message is checked for conversation membership and inspected by Mapping-compatible block revision helpers; conflict/replay JSON consists of explicit revision fields, not the SDK wrapper.", @@ -20088,7 +20090,7 @@ ], "queries": [], "batches": [], - "module_sha256": "67e689829bc9df650c597af9e5277e27542fb6e270fa56f6ed8b81b763f51196" + "module_sha256": "a578b7745aa421eca63941beab2683c84f7385aba442f6947c77cb11ade96889" }, "classification": "sdk-compatible", "contract": "The reload callback returns a partitioned SDK message mapping to the Mapping-compatible image revision owner, which rechecks concurrent versions; the route emits only explicit revision/error fields.", @@ -28088,7 +28090,7 @@ }, "functional_tests/test_document_search_metadata_sync.py::FakeDocumentsContainer": { "boundary": { - "sha256": "328730eded73951ad31aafd5c24bebaad155ee9e6d8b6e59b6146b3b53f9b6d5", + "sha256": "cd7e418381a1d0e30689d610106821eba9427898434de404917301b11dd2534b", "methods": [ "create_item", "read_item", @@ -28097,10 +28099,10 @@ ], "kind": "class", "inherited_bases": [], - "imports_sha256": "31a2f26eebdbbea8a4271f2b1c1b534d60da811f4cab6f4e65e3063976ccf57b" + "imports_sha256": "ab3456cf1a8dd8d10b8162c85e19d9f98f65e72e6942605ef6d6eebccfeaace8" }, - "classification": "legacy-inline", - "contract": "Unchanged inline Cosmos-shaped test container outside the migrated shared defaults. Its test-specific response behavior is an explicit staged-migration baseline, not blanket runtime SDK proof.", + "classification": "sdk-default", + "contract": "read_item and create/replace/upsert responses use the installed SDK CosmosDict by default; query rows and nested JSON stay plain dictionaries. The shared store keeps copied document fields, new ETags, scoped query filtering and 409/404/412 failures. Status runtime tests cover strict SDK-returning writes, stale ETags, screening holds and expired operation guards.", "tests": [ "functional_tests/test_document_search_metadata_sync.py" ] @@ -32733,7 +32735,7 @@ "inherited_bases": [ "FakeCosmosContainer" ], - "imports_sha256": "563b8f7d2ce8d5724ae824ab7c1742bce285b8992a29b947dcfd8b691435a6ae" + "imports_sha256": "2208c7db731d04d274c9202f2f79fb8273c80f73bb5a3c2cf36c01ee104a1b0c" }, "classification": "sdk-default", "contract": "Projection test container inherits partition routing, ETag checks, query filtering and injected failures from the existing model; read/upsert/replace responses are installed-SDK CosmosDict and query rows remain plain dictionaries.", diff --git a/functional_tests/document_processing_status_cases.json b/functional_tests/document_processing_status_cases.json new file mode 100644 index 000000000..d68bd7496 --- /dev/null +++ b/functional_tests/document_processing_status_cases.json @@ -0,0 +1,52 @@ +[ + {"status": null, "failure": false, "complete": false}, + {"status": {}, "failure": false, "complete": false}, + {"status": "", "failure": false, "complete": false}, + {"status": "Queued for processing", "failure": false, "complete": false}, + {"status": "Processing file FED-failed-harness-followup-20261019-a-FED-17-policy-v1.md, type: .md", "failure": false, "complete": false}, + {"status": "Processing file error-budget.txt, type: .txt", "failure": false, "complete": false}, + {"status": "Processing file processing complete.md, type: .md", "failure": false, "complete": false}, + {"status": "Processing incomplete-policy.pdf in 1 file chunk(s)", "failure": false, "complete": false}, + {"status": "Sending completed-checklist.pdf to Document Intelligence...", "failure": false, "complete": false}, + {"status": "Saving page/chunk 1/3 of failed-complete.pdf...", "failure": false, "complete": false}, + {"status": "Processing sheet 'Error: Processing complete'...", "failure": false, "complete": false}, + {"status": "Uploading failed.md to Blob Storage...", "failure": false, "complete": false}, + {"status": "Enhanced citations: video at group/error-complete.mp4", "failure": false, "complete": false}, + {"status": "Retrying Markdown processing after a transient parser concurrency error (1/3)...", "failure": false, "complete": false}, + {"status": "Retrying compact schema summary for failed-complete.csv...", "failure": false, "complete": false}, + {"status": "Vision analysis completed (no results)", "failure": false, "complete": false}, + {"status": "AI vision analysis completed", "failure": false, "complete": false}, + {"status": "Final metadata extracted", "failure": false, "complete": false}, + {"status": "Content screening incomplete; retry is required", "failure": false, "complete": false}, + {"status": "Content review required", "failure": false, "complete": false}, + {"status": "Pending approval", "failure": false, "complete": false}, + {"status": "error-budget.md", "failure": false, "complete": false}, + {"status": "failed.md", "failure": false, "complete": false}, + {"status": "incomplete", "failure": false, "complete": false}, + {"status": "Processing completely different content", "failure": false, "complete": false}, + {"status": "Error", "failure": true, "complete": false}, + {"status": " ERROR: Unable to read file. ", "failure": true, "complete": false}, + {"status": "Error processing", "failure": true, "complete": false}, + {"status": "Error changing extraction: provider unavailable", "failure": true, "complete": false}, + {"status": "Failed", "failure": true, "complete": false}, + {"status": "Failed to upload document", "failure": true, "complete": false}, + {"status": "Processing failed: unavailable", "failure": true, "complete": false}, + {"status": "Document processing failed. Contact an administrator if the problem persists.", "failure": true, "complete": false}, + {"status": "Approval failed: unavailable", "failure": true, "complete": false}, + {"status": "Content screening failed; retry is required", "failure": true, "complete": false}, + {"status": "VIDEO: blob upload failed : unavailable", "failure": true, "complete": false}, + {"status": "VIDEO: auth failed : unavailable", "failure": true, "complete": false}, + {"status": "VIDEO: upload failed : unavailable", "failure": true, "complete": false}, + {"status": "VIDEO: indexing failed", "failure": true, "complete": false}, + {"status": "Processing complete", "failure": false, "complete": true}, + {"status": " processing COMPLETE! ", "failure": false, "complete": true}, + {"status": "Processing complete.", "failure": false, "complete": true}, + {"status": "Processing complete - final metadata extracted", "failure": false, "complete": true}, + {"status": "Processing complete (metadata extraction warning)", "failure": false, "complete": true}, + {"status": "Processing complete - no content indexed", "failure": false, "complete": true}, + {"status": "Ready", "failure": false, "complete": true}, + {"status": "Complete", "failure": false, "complete": true}, + {"status": "completed", "failure": false, "complete": true}, + {"status": "Processed", "failure": false, "complete": true}, + {"status": "Finished.", "failure": false, "complete": true} +] diff --git a/functional_tests/test_content_screening_read_boundaries.py b/functional_tests/test_content_screening_read_boundaries.py index c8fd847b7..ccff1d385 100644 --- a/functional_tests/test_content_screening_read_boundaries.py +++ b/functional_tests/test_content_screening_read_boundaries.py @@ -1,7 +1,7 @@ # test_content_screening_read_boundaries.py """ Behavioral tests for search, native caches, and resumed source consumption. -Version: 0.261.232 +Version: 0.261.349 Implemented in: 0.261.106 Upload-only export checks covered in: 0.261.232 @@ -28,6 +28,7 @@ from test_content_screening_access import APP_ROOT, FakeContainer, ScreeningAccessFixture from content_screening import access from content_screening.contracts import DocumentHeldError, ScreeningConflictError, ScreeningError, document_is_available, hash_payload +from functions_document_status import is_document_processing_complete, is_document_processing_failure def load_functions(file_name, names, namespace, *, constants=False): @@ -180,7 +181,11 @@ def test_explicit_full_document_fetch_checks_before_loading_any_chunks(self): def test_chat_readiness_cannot_infer_clearance_from_progress(self): namespace = load_functions( "route_backend_chats.py", {"_is_search_ready_chat_upload_workspace_document"}, - {"document_is_available": document_is_available}, + { + "document_is_available": document_is_available, + "is_document_processing_complete": is_document_processing_complete, + "is_document_processing_failure": is_document_processing_failure, + }, ) self.document.update({"percentage_complete": 100, "status": "Processing complete", "num_chunks": 5}) self.hold() diff --git a/functional_tests/test_document_processing_status.py b/functional_tests/test_document_processing_status.py new file mode 100644 index 000000000..c49e27aa9 --- /dev/null +++ b/functional_tests/test_document_processing_status.py @@ -0,0 +1,161 @@ +# test_document_processing_status.py +""" +Functional regressions for document terminal-state interpretation. +Version: 0.261.349 +Implemented in: 0.261.349 + +Execute the real Python, React and classic predicates against shared cases, +plus backend progress, safe job updates, facets and chat-readiness decisions. +No settings bootstrap, live storage or provider calls are needed. +""" + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +from test_support.app_source import run_definitions +from test_support.versioning import assert_app_version_at_least + + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "application" / "single_app" +UI = ROOT / "application" / "v2_ui" +sys.path.insert(0, str(APP)) + +from content_screening.contracts import document_is_available # noqa: E402 +from functions_document_queries import ( # noqa: E402 + _document_processing_state, build_document_facets, filter_documents_by_place, +) +from functions_document_status import ( # noqa: E402 + is_document_processing_complete, is_document_processing_failure, +) + + +CASES = json.loads((ROOT / "functional_tests" / "document_processing_status_cases.json").read_text(encoding="utf-8")) +PREDICATES = { + "is_document_processing_complete": is_document_processing_complete, + "is_document_processing_failure": is_document_processing_failure, +} + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: str(case["status"])) +def test_python_status_contract(case): + failure = is_document_processing_failure(case["status"]) + complete = is_document_processing_complete(case["status"]) + assert failure is case["failure"] + assert complete is case["complete"] + + +def test_browser_predicates_match_python_contract(tmp_path): + bundle = tmp_path / "document-status.cjs" + subprocess.run([ + "node", str(UI / "node_modules" / "esbuild" / "bin" / "esbuild"), + str(UI / "src" / "lib" / "documentProcessingStatus.ts"), + "--bundle", "--platform=node", "--format=cjs", f"--outfile={bundle}", "--log-level=error", + ], check=True, capture_output=True, text=True) + probe = r""" + const fs = require('node:fs'); + const vm = require('node:vm'); + const assert = require('node:assert/strict'); + const react = require(process.argv[1]); + const context = vm.createContext({window: {}}); + vm.runInContext(fs.readFileSync(process.argv[2], 'utf8'), context); + const classic = context.window.DocumentStatus; + const cases = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); + for (const value of cases) { + assert.equal(react.isDocumentProcessingFailure(value.status), value.failure, JSON.stringify(value)); + assert.equal(react.isDocumentProcessingComplete(value.status), value.complete, JSON.stringify(value)); + assert.equal(classic.isFailure(value.status), value.failure, JSON.stringify(value)); + assert.equal(classic.isComplete(value.status), value.complete, JSON.stringify(value)); + } + """ + subprocess.run([ + "node", "-e", probe, str(bundle), str(APP / "static" / "js" / "document-status.js"), + str(ROOT / "functional_tests" / "document_processing_status_cases.json"), + ], check=True, capture_output=True, text=True) + + +@pytest.mark.parametrize("status", [ + "Processing file failed-complete.md, type: .md", + "Processing file processing complete.md, type: .md", + "Processing sheet 'error-completed'...", + "Retrying Markdown processing after a transient parser concurrency error (1/3)...", +]) +def test_backend_progress_and_safe_updates_do_not_infer_terminal_state(status): + writes = [] + namespace = { + **PREDICATES, "json": json, + "update_document": lambda **values: writes.append(values), + } + run_definitions("functions_documents.py", { + "calculate_processing_percentage", "_update_document_for_job", + }, namespace) + progress = namespace["calculate_processing_percentage"]({ + "status": status, "percentage_complete": 0, + }) + namespace["_update_document_for_job"](document_id="doc", status=status, safe_errors=True) + assert progress == 0 + assert writes == [{"document_id": "doc", "status": status}] + + +def test_explicit_failure_is_sanitized_and_guarded(): + writes = [] + namespace = { + **PREDICATES, "update_document": lambda **values: writes.append(values), + } + run_definitions("functions_documents.py", {"_update_document_for_job"}, namespace) + guard = lambda: {"_etag": "revision-1"} + namespace["_update_document_for_job"]( + document_id="doc", status="Error: PRIVATE PROVIDER DETAIL", + operation_guard=guard, safe_errors=True, + ) + [written] = writes + assert written["status"] == "Document processing failed. Contact an administrator if the problem persists." + assert written["expected_etag"] == "revision-1" + assert written["strict"] is True + assert written["operation_guard"] is guard + + +def test_facets_and_filters_do_not_confuse_details_with_failures(): + documents = [ + {"id": "filename", "status": "Processing file failed.md, type: .md", "percentage_complete": 0}, + {"id": "retry", "status": "Retrying Markdown after an error", "percentage_complete": 20}, + {"id": "failure", "status": "Error: unavailable", "percentage_complete": 100}, + {"id": "ready", "status": "Processing complete", "percentage_complete": 100}, + {"id": "legacy"}, + ] + facets = build_document_facets(documents, "owner") + processing = filter_documents_by_place(documents, "processing", "owner") + failed = filter_documents_by_place(documents, "errors", "owner") + legacy_state = _document_processing_state(documents[-1]) + assert facets["processing"] == 2 + assert facets["errors"] == 1 + assert [item["id"] for item in processing] == ["filename", "retry"] + assert [item["id"] for item in failed] == ["failure"] + assert legacy_state == "ready" + + +@pytest.mark.parametrize(("changes", "expected"), [ + ({}, True), + ({"status": "Processing file processing complete.md, type: .md", "percentage_complete": 0}, False), + ({"status": "Error: unable to read"}, False), + ({"number_of_pages": 0}, False), + ({"chat_upload_link_state": "unlinked"}, False), + ({"search_visibility_state": "archived"}, False), +]) +def test_search_readiness_preserves_independent_requirements(changes, expected): + namespace = {**PREDICATES, "document_is_available": document_is_available} + run_definitions("route_backend_chats.py", {"_is_search_ready_chat_upload_workspace_document"}, namespace) + document = { + "status": "Processing complete", "percentage_complete": 100, "number_of_pages": 1, + **changes, + } + ready = namespace["_is_search_ready_chat_upload_workspace_document"](document) + assert ready is expected + + +def test_implementation_version(): + assert_app_version_at_least("0.261.349") diff --git a/functional_tests/test_document_search_metadata_sync.py b/functional_tests/test_document_search_metadata_sync.py index e2c63dfa0..609b53408 100644 --- a/functional_tests/test_document_search_metadata_sync.py +++ b/functional_tests/test_document_search_metadata_sync.py @@ -2,7 +2,7 @@ # test_document_search_metadata_sync.py """ Functional test for durable, batched document search metadata sync. -Version: 0.261.268 +Version: 0.261.349 Implemented in: 0.261.268 This test ensures that document metadata edits no longer do per-chunk Search work inside the @@ -29,6 +29,7 @@ import pytest from azure.core import MatchConditions from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceNotFoundError +from azure.cosmos._cosmos_responses import CosmosDict REPO_ROOT = Path(__file__).resolve().parents[1] APP_ROOT = REPO_ROOT / "application" / "single_app" @@ -43,6 +44,7 @@ document_is_available, ) import functions_group_document_projection_fence as fence # noqa: E402 +from functions_document_status import is_document_processing_complete, is_document_processing_failure # noqa: E402 from test_support.document_search_sync import ( # noqa: E402 FakeSearchClient, FakeSettingsContainer, @@ -75,7 +77,7 @@ class FakeOriginError(Exception): class FakeDocumentsContainer: - """Cosmos documents container with etag preconditions, scoped queries and Cosmos error types.""" + """SDK-shaped points, plain query rows, ETags, scoped queries and Cosmos errors.""" def __init__(self, scope_field): self.scope_field = scope_field @@ -87,7 +89,7 @@ def _store(self, body): item["_etag"] = uuid.uuid4().hex self.documents[item["id"]] = item self.writes.append(item["id"]) - return copy.deepcopy(item) + return CosmosDict(copy.deepcopy(item), response_headers={}) def query_items(self, query, parameters, enable_cross_partition_query=True): values = {parameter["name"]: parameter["value"] for parameter in parameters} @@ -100,7 +102,7 @@ def read_item(self, item, partition_key): assert item == partition_key if item not in self.documents: raise CosmosResourceNotFoundError(status_code=404, message="Not found") - return copy.deepcopy(self.documents[item]) + return CosmosDict(copy.deepcopy(self.documents[item]), response_headers={}) def replace_item(self, item, body, etag=None, match_condition=None): if item not in self.documents: @@ -164,6 +166,8 @@ def sync_index(document_item, operation=None, **_kwargs): return {"success": harness.index_success} namespace = { + "is_document_processing_complete": is_document_processing_complete, + "is_document_processing_failure": is_document_processing_failure, "json": json, "logging": logging, "re": re, @@ -203,7 +207,6 @@ def sync_index(document_item, operation=None, **_kwargs): else harness.user_search ), "add_file_task_to_file_processing_log": lambda **_kwargs: None, - "calculate_processing_percentage": lambda document_item: document_item.get("percentage_complete", 0), "propagate_tags_to_blob_metadata": lambda *args: harness.blob_tag_calls.append(args), "invalidate_personal_search_cache": lambda user_id: harness.cache_invalidations.append(("personal", user_id)), "invalidate_group_search_cache": lambda group_id, **_kwargs: harness.cache_invalidations.append(("group", group_id)), @@ -212,6 +215,10 @@ def sync_index(document_item, operation=None, **_kwargs): ), "log_event": lambda message, extra=None, **kwargs: harness.logs.append((message, extra, kwargs)), } + nodes.extend( + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "calculate_processing_percentage" + ) module = ast.Module(body=nodes, type_ignores=[]) ast.fix_missing_locations(module) exec(compile(module, str(DOCUMENTS_PATH), "exec"), namespace) @@ -297,6 +304,102 @@ def released_marker(): # --- Engine ------------------------------------------------------------------------------------- +@pytest.mark.parametrize("scope", ["personal", "group", "public"]) +def test_status_updates_preserve_sdk_points_etags_and_query_rows(scope): + harness = Harness() + if scope == "group": + document = harness.add_group_document() + container = harness.group_documents + identity = {"group_id": "group-1", "user_id": "editor-1"} + elif scope == "public": + document = { + "id": "public-doc-1", "public_workspace_id": "public-1", "user_id": "editor-1", + "file_name": "policy.md", "status": "Queued for processing", "percentage_complete": 0, + "_etag": "public-revision-1", + } + container = harness.public_documents + container.documents[document["id"]] = document + identity = {"public_workspace_id": "public-1", "user_id": "editor-1"} + else: + document = harness.add_personal_document() + container = harness.user_documents + identity = {"user_id": "owner-1"} + point = container.read_item(document["id"], document["id"]) + assert type(point) is CosmosDict + rows = container.query_items("", [ + {"name": "@document_id", "value": document["id"]}, + {"name": f"@{container.scope_field}", "value": document[container.scope_field]}, + ]) + assert type(rows[0]) is dict + # Start from a new upload, not the fixture's completed document. + container.documents[document["id"]].update(status="Queued for processing", percentage_complete=0) + saved = harness["update_document"]( + document_id=document["id"], **identity, strict=True, expected_etag=point["_etag"], + status="Processing file processing complete-error-failed.md, type: .md", + ) + assert type(saved) is CosmosDict + assert saved["_etag"] != point["_etag"] + assert saved["percentage_complete"] == 0 + with pytest.raises(ScreeningConflictError): + harness["update_document"]( + document_id=document["id"], **identity, strict=True, expected_etag=point["_etag"], + status="Processing complete", + ) + completed = harness["update_document"]( + document_id=document["id"], **identity, strict=True, expected_etag=saved["_etag"], + status="Processing complete", + ) + assert type(completed) is CosmosDict + assert completed["percentage_complete"] == 100 + + +@pytest.mark.parametrize("review_required", [False, True]) +def test_held_status_updates_distinguish_processing_details_from_final_completion(review_required): + harness = Harness() + document = harness.add_personal_document( + percentage_complete=42, + content_screening={"state": "scanning", "review_required": review_required}, + ) + status = "Processing file processing complete-error-failed.md, type: .md" + saved = harness["update_document"]( + document_id=document["id"], user_id="owner-1", strict=True, + expected_etag=document["_etag"], status=status, + ) + assert type(saved) is CosmosDict + assert saved["status"] == status + assert saved["percentage_complete"] == 42 + pending = harness["update_document"]( + document_id=document["id"], user_id="owner-1", strict=True, + expected_etag=saved["_etag"], status="Processing complete", percentage_complete=100, + ) + assert pending["status"] == ("Content review required" if review_required else "Content screening pending") + assert pending["percentage_complete"] == 99 + available = document_is_available(pending) + assert available is False + + +@pytest.mark.parametrize("blocked_call", [1, 2]) +def test_status_update_cannot_write_after_its_operation_guard_expires(blocked_call): + harness = Harness() + document = harness.add_personal_document(percentage_complete=0) + calls = [] + + def guard(): + calls.append("check") + if len(calls) == blocked_call: + raise ScreeningConflictError() + + with pytest.raises(ScreeningConflictError): + harness["update_document"]( + document_id=document["id"], user_id="owner-1", strict=True, + expected_etag=document["_etag"], operation_guard=guard, + status="Processing file failed-complete.md, type: .md", + ) + assert len(calls) == blocked_call + assert harness.user_documents.documents[document["id"]] == document + assert harness.user_documents.writes == [] + + def test_chunk_key_paging_is_key_ordered_scoped_and_id_only(): """Chunk listing pages by key within the document's active scope and never selects content.""" harness = Harness() diff --git a/functional_tests/test_group_document_management.py b/functional_tests/test_group_document_management.py index ff100753a..4155f7b4f 100644 --- a/functional_tests/test_group_document_management.py +++ b/functional_tests/test_group_document_management.py @@ -1,7 +1,7 @@ # test_group_document_management.py """ Functional tests for immutable-target group document management. -Version: 0.261.347 +Version: 0.261.349 Implemented in: 0.261.129 A tag vocabulary conflict answers one coded sentence, from the pre-check or a lost patch: 0.261.167 New tags are defined before any document carries them, so a conflict writes no document: 0.261.168 @@ -263,7 +263,10 @@ def prepare_delete(document_item, actor_id): ) namespace = env.document_helpers + status_helpers = importlib.import_module("functions_document_status") namespace.update({ + "is_document_processing_complete": status_helpers.is_document_processing_complete, + "is_document_processing_failure": status_helpers.is_document_processing_failure, "os": os, "json": json, "datetime": datetime, "timezone": timezone, "tempfile": tempfile, "uuid": uuid, "traceback": traceback, "logging": logging, "partial": partial, "mimetypes": mimetypes, "BytesIO": BytesIO, "zipfile": zipfile, @@ -282,7 +285,6 @@ def prepare_delete(document_item, actor_id): "read_available_document_bytes": env.access.read_available_document_bytes, "current_extraction": lambda _document_id: None, "is_publication": lambda *_args: False, "add_file_task_to_file_processing_log": Mock(), - "calculate_processing_percentage": lambda item: item.get("percentage_complete", 0), "get_all_chunks": lambda document_id, user_id, group_id=None, public_workspace_id=None: [{"id": f"{document_id}-chunk"}], "sync_document_access_index_for_document_fail_open": env.index_updates, "delete_document_access_index_for_document_fail_open": env.index_deletes, @@ -313,7 +315,7 @@ def prepare_delete(document_item, actor_id): "get_document_blob_delete_targets", "delete_from_blob_storage", "_sanitize_download_file_name", "_get_download_content_type", "_get_document_download_entry", "build_document_download_response", "build_documents_zip_download_response", - "allowed_file", "ensure_list", "_update_document_for_job", + "allowed_file", "ensure_list", "_update_document_for_job", "calculate_processing_percentage", }, namespace) # Metadata edits record a durable search sync request; the sync worker merges the latest values # into an in-memory index with one chunk per document. Tests run queued syncs explicitly. diff --git a/functional_tests/test_v2_chat_context_request.ts b/functional_tests/test_v2_chat_context_request.ts index 1a75f50e8..ae0798464 100644 --- a/functional_tests/test_v2_chat_context_request.ts +++ b/functional_tests/test_v2_chat_context_request.ts @@ -1,7 +1,7 @@ // test_v2_chat_context_request.ts // // Runtime test for how the V2 composer's context chips become a chat request. -// Version: 0.261.096 +// Version: 0.261.349 // Implemented in: 0.261.089 // Independent context selection implemented in: 0.261.094 // Shared editor implemented in: 0.261.096 @@ -409,6 +409,28 @@ check('legacy uploads require actual conversation and file-message identities', }, 'notes.txt'), /group workspace/); }); +check('upload states ignore filenames and stage details without relaxing real failures or approvals', () => { + for (const status of [ + 'Processing file failed-policy.md, type: .md', + 'Processing file error-budget.txt, type: .txt', + 'Processing file processing complete.md, type: .md', + 'Retrying Markdown processing after a transient parser concurrency error (1/3)...', + 'Vision analysis completed (no results)', + ]) { + assert.equal(chatUploadDocumentStatus({ status, percentage_complete: 0 }).state, 'processing'); + assert.equal(chatUploadDocumentStatus({ status }).state, 'processing'); + } + assert.equal(chatUploadDocumentStatus({ status: 'Processing complete - no content indexed' }).state, 'ready'); + assert.equal(chatUploadDocumentStatus({ status: 'Error: failed', percentage_complete: 100 }).state, 'failed'); + assert.equal(chatUploadDocumentStatus({ status: 'Complete', shared_approval_status: 'not_approved' }).state, 'failed'); + assert.equal(chatUploadDocumentStatus({ + status: 'Complete', percentage_complete: 100, generated_artifact_promotion_status: 'approval_failed', + }).state, 'failed'); + assert.equal(chatUploadDocumentStatus({ + status: 'Complete', percentage_complete: 100, generated_artifact_promotion_status: 'pending_approval', + }).state, 'failed'); +}); + check('upload validation preserves chat format, account and size gates', () => { const text = new File(['source'], 'notes.txt'); assert.equal(chatUploadValidationError(text, true, 10), null); @@ -489,6 +511,34 @@ check('processing polls the authorized document endpoint until ready and honors } }); +check('filename-bearing processing and transient errors do not stop personal or group polling', async () => { + const originalFetch = globalThis.fetch; + try { + for (const kind of ['personal', 'group'] as const) { + let calls = 0; + const statuses = [ + 'Processing file FED-failed-harness-followup-20261019-a-FED-17-policy-v1.md, type: .md', + 'Processing file processing complete.md, type: .md', + 'Retrying Markdown processing after a transient parser concurrency error (1/3)...', + 'Processing complete - final metadata extracted', + ]; + globalThis.fetch = async (url) => { + assert.equal(String(url), `/api/${kind === 'group' ? 'group_' : ''}documents/workspace-id`); + const status = statuses[calls++]; + return new Response(JSON.stringify({ status, percentage_complete: calls === 4 ? 100 : 20 }), + { headers: { 'Content-Type': 'application/json' } }); + }; + const states: string[] = []; + await pollChatUpload({ kind: 'document', id: 'workspace-id', scope: { kind, id: 'scope-id' } }, + new AbortController().signal, (status) => states.push(status.state)); + assert.deepEqual(states, ['processing', 'processing', 'processing', 'ready']); + assert.equal(calls, 4); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + /* -------------------------------------------------------------------------- */ let failed = 0; diff --git a/functional_tests/test_v2_documents_explorer.py b/functional_tests/test_v2_documents_explorer.py index 7c981d65e..f549fa8c6 100644 --- a/functional_tests/test_v2_documents_explorer.py +++ b/functional_tests/test_v2_documents_explorer.py @@ -3,7 +3,7 @@ """ Functional test for the V2 workspace documents explorer. -Version: 0.261.323 +Version: 0.261.349 Implemented in: 0.261.048 Shared workspace shell integration: 0.261.127 @@ -60,6 +60,8 @@ SECTIONS_PY = APP_DIR / "functions_workspace_sections.py" DOCUMENTS_FUNCTIONS = APP_DIR / "functions_documents.py" DOCUMENT_QUERY_FUNCTIONS = APP_DIR / "functions_document_queries.py" +sys.path.insert(0, str(APP_DIR)) +from functions_document_status import is_document_processing_failure # noqa: E402 ENDPOINTS_TS = V2_SRC / "lib" / "endpoints.ts" EXPLORER_TS = V2_SRC / "lib" / "documentExplorer.ts" @@ -119,6 +121,7 @@ def is_constant_assignment(node): "datetime": datetime, "timedelta": timedelta, "timezone": timezone, + "is_document_processing_failure": is_document_processing_failure, } # Constants are executed one at a time and failures are skipped: these modules also diff --git a/functional_tests/test_v2_documents_explorer_logic.ts b/functional_tests/test_v2_documents_explorer_logic.ts index f8c0750db..cb099edc6 100644 --- a/functional_tests/test_v2_documents_explorer_logic.ts +++ b/functional_tests/test_v2_documents_explorer_logic.ts @@ -1,7 +1,7 @@ // test_v2_documents_explorer_logic.ts // // Runtime test for the V2 documents explorer rules. -// Version: 0.261.048 +// Version: 0.261.349 // Implemented in: 0.261.048 // // The companion test, test_v2_documents_explorer.py, asserts that the pieces are wired @@ -76,6 +76,22 @@ function check(name: string, fn: () => void) { const query = (overrides: Record = {}): any => ({ ...DEFAULT_DOCUMENT_QUERY, ...overrides }); const params = (input: string): Record => Object.fromEntries(new URLSearchParams(input)); +check('document state ignores filename failure words and retains progress and approval contracts', () => { + for (const status of [ + 'Processing file failed-policy.md, type: .md', + 'Processing file error-budget.txt, type: .txt', + 'Processing file completed-checklist.pdf, type: .pdf', + 'Retrying Markdown after a transient error', + ]) { + assert.equal(documentStatus({ status, percentage_complete: 25 }).state, 'processing'); + } + assert.equal(documentStatus({ status: 'Error: failed', percentage_complete: 100 }).state, 'error'); + assert.equal(documentStatus({ percentage_complete: 'invalid' }).state, 'ready'); + assert.equal(documentStatus({ status: 'Processing' }).state, 'ready'); + assert.equal(documentStatus({ percentage_complete: -10 }).percent, 0); + assert.equal(documentStatus({ percentage_complete: 100, shared_approval_status: 'not_approved' }).state, 'pending_approval'); +}); + /* ------------------------------ query parameters ------------------------------ */ check('the default query asks for page one at the default size', () => { diff --git a/functional_tests/test_workspace_metadata_outcomes.py b/functional_tests/test_workspace_metadata_outcomes.py index 9d84516ab..989d32118 100644 --- a/functional_tests/test_workspace_metadata_outcomes.py +++ b/functional_tests/test_workspace_metadata_outcomes.py @@ -1,7 +1,7 @@ # test_workspace_metadata_outcomes.py """ Runtime regressions for shared automatic and manual workspace metadata analysis. -Version: 0.261.347 +Version: 0.261.349 Implemented in: 0.261.347 Execute the real extraction, merge, worker, and final-upload functions with only @@ -25,6 +25,7 @@ from test_cosmos_wave5a_document_access_read_switch import ( FakeCosmosContainer, _load_document_access_index_module, _succeeded_backfill_state, ) +from functions_document_status import is_document_processing_failure class SdkProjectionContainer(FakeCosmosContainer): @@ -85,6 +86,7 @@ def complete(**arguments): client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=complete))) namespace = { + "is_document_processing_failure": is_document_processing_failure, "json": json, "logging": logging, "re": re, "partial": partial, "get_settings": lambda: settings, "get_document_metadata": lambda *args, **kwargs: copy.deepcopy(document), "update_document": update_document, "hybrid_search": search, diff --git a/ui_tests/test_chat_workspace_upload_progress_polling.py b/ui_tests/test_chat_workspace_upload_progress_polling.py index 3f202515d..a4413d7dd 100644 --- a/ui_tests/test_chat_workspace_upload_progress_polling.py +++ b/ui_tests/test_chat_workspace_upload_progress_polling.py @@ -1,7 +1,7 @@ # test_chat_workspace_upload_progress_polling.py """ UI test for chat workspace upload progress polling. -Version: 0.241.203 +Version: 0.261.349 Implemented in: 0.241.203 This test ensures workspace-backed chat upload cards keep the progress bar @@ -13,6 +13,9 @@ from pathlib import Path import pytest +from playwright.sync_api import expect + +from test_v2_chat_context_selection import connect_options # noqa: F401 REPO_ROOT = Path(__file__).resolve().parents[1] @@ -20,23 +23,9 @@ @pytest.mark.ui -def test_chat_workspace_upload_progress_polling_recovers_from_transient_error(): +def test_chat_workspace_upload_progress_polling_recovers_from_transient_error(page): """Validate workspace upload progress polling updates the visible chat card.""" - playwright_sync_api = pytest.importorskip("playwright.sync_api") - expect = playwright_sync_api.expect - sync_playwright = playwright_sync_api.sync_playwright - - with sync_playwright() as playwright: - try: - browser = playwright.chromium.launch() - except Exception as exc: - pytest.skip(f"Playwright browser unavailable: {exc}") - - page = browser.new_page() - try: - run_chat_workspace_upload_progress_polling_scenario(page, expect) - finally: - browser.close() + run_chat_workspace_upload_progress_polling_scenario(page, expect) def run_chat_workspace_upload_progress_polling_scenario(page, expect): @@ -56,6 +45,7 @@ def run_chat_workspace_upload_progress_polling_scenario(page, expect): """ ) + page.add_script_tag(path=str(REPO_ROOT / "application" / "single_app" / "static" / "js" / "document-status.js")) page.add_script_tag( content=f""" const chatWorkspaceUploadPolls = new Map(); diff --git a/ui_tests/test_document_status_classification.py b/ui_tests/test_document_status_classification.py new file mode 100644 index 000000000..6eda111e5 --- /dev/null +++ b/ui_tests/test_document_status_classification.py @@ -0,0 +1,89 @@ +# test_document_status_classification.py +""" +Browser coverage for shared classic document status interpretation. +Version: 0.261.349 +Implemented in: 0.261.349 + +Exercise real group views and personal/public/card status predicates with local +assets and intercepted APIs, including in-progress, failed and completed states. +""" + +import re + +import pytest +from playwright.sync_api import expect + +from test_group_document_filename_xss_rendering import ( # noqa: F401 + APP_ROOT, VIEWS, _document, _render, group_page, group_render_sources, +) +from test_v2_chat_context_selection import connect_options # noqa: F401 + + +pytestmark = pytest.mark.ui + + +@pytest.mark.parametrize("view", VIEWS) +@pytest.mark.parametrize("filename", ["error-budget.md", "failed-policy.md", "incomplete-policy.md"]) +def test_group_views_do_not_enable_ready_actions_from_filename(group_page, view, filename): + page, state = group_page + record = _document(filename, status=f"Processing file {filename}, type: .md", percentage_complete=0) + rows = _render(group_page, view, [record]) + expect(rows.first.locator("a.dropdown-item").filter(has_text=re.compile(r"^\s*Share"))).to_have_count(0) + if view == "list": + assert page.evaluate("window.__pollCallbacks.size") == 1 + state["documents"] = [{**record, "status": "Processing complete", "percentage_complete": 100}] + page.evaluate("() => window.__pollCallbacks.forEach(callback => callback())") + expect(rows.first.locator("a.dropdown-item").filter(has_text=re.compile(r"^\s*Share"))).to_have_count(1) + + +@pytest.mark.parametrize(("relative", "function"), [ + ("static/js/workspace/workspace-documents.js", "getDocumentProcessingState"), + ("static/js/public/public_workspace.js", "getPublicDocumentProcessingState"), +]) +def test_personal_and_public_processing_flags(page, relative, function): + source = APP_ROOT.joinpath(*relative.split("/")).read_text(encoding="utf-8") + start = source.index(f"function {function}(") + end = source.index("\n}", start) + len("\n}") + page.set_content('
') + page.add_script_tag(path=str(APP_ROOT / "static" / "js" / "document-status.js")) + page.add_script_tag(content=source[start:end]) + for status in [ + "Processing file error-budget.md, type: .md", + "Processing file failed-checklist.md, type: .md", + "Processing file processing complete.md, type: .md", + "Retrying Markdown processing after a transient parser concurrency error (1/3)...", + ]: + result = page.evaluate( + "({name, status}) => window[name]({status, percentage_complete: 0})", + {"name": function, "status": status}, + ) + assert result["hasError"] is False + assert result["isComplete"] is False + failure = page.evaluate( + "(name) => window[name]({status: 'Error: unavailable', percentage_complete: 100})", function, + ) + assert failure["hasError"] is True + assert failure["isComplete"] is True + + +def test_classic_chat_card_polls_instead_of_completing_from_details(page): + source = (APP_ROOT / "static" / "js" / "chat" / "chat-messages.js").read_text(encoding="utf-8") + start = source.index("function getChatWorkspaceProgressValue(") + end = source.index("function normalizeChatWorkspaceDocumentResponse(") + page.set_content('
') + page.add_script_tag(path=str(APP_ROOT / "static" / "js" / "document-status.js")) + page.add_script_tag(content=source[start:end]) + for status in [ + "Processing file error-budget.md, type: .md", + "Processing file incomplete-policy.md, type: .md", + "Retrying Markdown processing after a transient parser concurrency error (1/3)...", + "Vision analysis completed (no results)", + ]: + result = page.evaluate( + """(status) => { + const doc = {status, percentage_complete: 0}; + return [isChatWorkspaceDocumentComplete(doc), isChatWorkspaceDocumentFailure(doc), + isChatWorkspaceDocumentSuccessComplete(doc)]; + }""", status, + ) + assert result == [False, False, False] diff --git a/ui_tests/test_group_document_filename_xss_rendering.py b/ui_tests/test_group_document_filename_xss_rendering.py index 8c9e16e90..4d08cda37 100644 --- a/ui_tests/test_group_document_filename_xss_rendering.py +++ b/ui_tests/test_group_document_filename_xss_rendering.py @@ -1,7 +1,7 @@ # test_group_document_filename_xss_rendering.py """ Browser regressions for group document filename rendering. -Version: 0.261.029 +Version: 0.261.349 Implemented in: 0.261.029 Run the real template renderers, access/processing checks, fetch/refresh/polling @@ -201,6 +201,7 @@ def handle_request(route): page.goto("https://simplechat.test/") page.add_style_tag(path=str(APP_ROOT / "static" / "css" / "bootstrap.min.css")) page.add_script_tag(path=str(APP_ROOT / "static" / "js" / "bootstrap" / "bootstrap.bundle.min.js")) + page.add_script_tag(path=str(APP_ROOT / "static" / "js" / "document-status.js")) page.add_script_tag(path=str(APP_ROOT / "static" / "js" / "workspace" / "group-documents-sharing.js")) page.add_script_tag(content=script) page.evaluate("initializeGroupSharing()") diff --git a/ui_tests/test_v2_chat_upload_status.py b/ui_tests/test_v2_chat_upload_status.py new file mode 100644 index 000000000..f806105a9 --- /dev/null +++ b/ui_tests/test_v2_chat_upload_status.py @@ -0,0 +1,124 @@ +# test_v2_chat_upload_status.py +""" +Browser regressions for filename-independent chat upload status. +Version: 0.261.349 +Implemented in: 0.261.349 + +Run the real Composer and polling with intercepted upload/document APIs, using +the existing local/Azure Playwright connection. No live uploads are performed. +""" + +import pytest +from playwright.sync_api import expect + +from test_v2_chat_context_selection import ( # noqa: F401 + CONVERSATION_ID, connect_options, context_page, mount_workflow, +) + + +pytestmark = pytest.mark.ui + + +def prepare_upload(page, scope, filename): + mount_workflow(page) + page.evaluate( + """() => { + const store = window.OrchHarness.stores.bootstrap.useBootstrapStore; + store.setState(state => ({data: {...state.data, + features: {...state.data.features, enable_chat_file_uploads: true} + }})); + }""" + ) + page.get_by_role("textbox", name="Message", exact=True).fill("Review the uploaded policy.") + counts = {"uploads": 0, "polls": 0} + state = {"status": f"Processing file {filename}, type: .md", "progress": 0} + + def upload(route): + counts["uploads"] += 1 + route.fulfill(json={ + "conversation_id": CONVERSATION_ID, + "file_message_id": "upload-message", + "workspace_document_id": "upload-document", + "workspace_scope": scope, + "workspace_document": { + "document_id": "upload-document", "scope": scope, + "group_id": "group-1" if scope == "group" else None, + "file_name": filename, "status": "Queued for processing", "percentage_complete": 0, + }, + }) + + def status(route): + counts["polls"] += 1 + route.fulfill(json={ + "id": "upload-document", "file_name": filename, "status": state["status"], + "percentage_complete": state["progress"], + }) + + page.route("**/upload", upload) + page.route("**/api/get_messages?*", lambda route: route.fulfill(json={"messages": []})) + endpoint = "group_documents" if scope == "group" else "documents" + page.route(f"**/api/{endpoint}/upload-document", status) + page.locator('[data-composer-editor="composer-input"] input[type="file"]').set_input_files({ + "name": filename, "mimeType": "text/markdown", "buffer": b"# Policy\nLimit: 125000.", + }) + item = page.get_by_role("list", name="Attached files", exact=True).get_by_role("listitem") + expect(item.get_by_text("Processing 0%", exact=True)).to_be_visible() + return item, counts, state + + +@pytest.mark.parametrize("scope", ["personal", "group"]) +@pytest.mark.parametrize("filename", [ + "FED-failed-harness-followup-20261019-a-FED-17-policy-v1.md", + "error-budget.md", + "processing complete.md", + "incomplete-policy.md", +]) +def test_processing_keeps_polling_until_real_completion(context_page, scope, filename): + page, _ = context_page + item, counts, state = prepare_upload(page, scope, filename) + endpoint = "group_documents" if scope == "group" else "documents" + with page.expect_request(f"**/api/{endpoint}/upload-document"): + expect(page.get_by_role("button", name="Send message", exact=True)).to_be_disabled() + expect(item.get_by_role("alert")).to_have_count(0) + expect(item.get_by_text("Ready", exact=True)).to_have_count(0) + state.update(status="Retrying Markdown processing after a transient parser concurrency error (1/3)...", progress=20) + expect(item.get_by_text("Processing 20%", exact=True)).to_be_visible() + expect(page.get_by_role("button", name="Send message", exact=True)).to_be_disabled() + state.update(status="Processing complete - final metadata extracted", progress=100) + expect(item.get_by_text("Ready", exact=True)).to_be_visible(timeout=15000) + expect(page.get_by_role("button", name="Send message", exact=True)).to_be_enabled() + assert counts["uploads"] == 1 + assert counts["polls"] >= 4 + + +@pytest.mark.parametrize("scope", ["personal", "group"]) +def test_real_failure_stays_visible_and_retry_only_rechecks_status(context_page, scope): + page, _ = context_page + filename = "error-budget.md" + item, counts, state = prepare_upload(page, scope, filename) + state.update(status="Error: Unable to process document.", progress=100) + expect(item.get_by_text("Failed", exact=True)).to_be_visible(timeout=10000) + expect(item.get_by_role("alert")).to_contain_text("File processing failed.") + expect(page.get_by_role("button", name="Send message", exact=True)).to_be_disabled() + before = counts["polls"] + state.update(status="Processing complete", progress=100) + page.get_by_role("button", name=f"Retry {filename}", exact=True).click() + expect(item.get_by_text("Ready", exact=True)).to_be_visible() + assert counts["uploads"] == 1 + assert counts["polls"] > before + + +def test_removing_processing_upload_cancels_tracking(context_page): + page, _ = context_page + page.clock.install() + filename = "failed-checklist.md" + item, counts, state = prepare_upload(page, "personal", filename) + page.get_by_role("button", name=f"Remove {filename}", exact=True).click() + expect(item).to_have_count(0) + before = counts["polls"] + state.update(status="Processing complete", progress=100) + # Rendering another draft change must not resurrect a removed upload. + page.get_by_role("textbox", name="Message", exact=True).fill("No attachment.") + page.clock.run_for(6000) + expect(page.get_by_role("list", name="Attached files", exact=True)).to_have_count(0) + assert counts["polls"] == before