diff --git a/application/single_app/config.py b/application/single_app/config.py index 6d8303386..b15f9fd7e 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.353" +VERSION = "0.261.354" 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..e1ffe90a0 --- /dev/null +++ b/docs/explanation/fixes/DOCUMENT_STATUS_TEXT_CLASSIFICATION_FIX.md @@ -0,0 +1,119 @@ +# Document Status Text Classification Fix + +Version: **0.261.354** + +Fixed/Implemented in version: **0.261.354** + +Related configuration update: `application/single_app/config.py`, `VERSION = "0.261.354"`. + +The base branch advanced to **0.261.353** during review. The combined application +uses **0.261.354**, preserving the incoming saved-memory and SQL diagnostics fixes +alongside this status classification fix. + +## 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. + +After merging the **0.261.350** base, combined status, SDK-write, explorer and +incoming saved-memory regressions passed **194 tests** and **5 subtests**. +The focused upload/classic browser suites passed **27 tests**, and documentation +checks and the combined Cosmos inventory remained clean. + +After the subsequent **0.261.353** base integration, combined status, saved-memory, +SQL diagnostics and plugin-route SDK regressions passed **308 tests** and **5 +subtests**. The same **27 browser tests**, documentation checks and combined Cosmos +inventory passed. + +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 0dd7d5cde..70ae65a94 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", @@ -6820,7 +6819,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.", @@ -6842,7 +6841,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.", @@ -6864,7 +6863,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.", @@ -6886,7 +6885,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.", @@ -6898,7 +6897,7 @@ }, "application/single_app/functions_documents.py::update_document": { "boundary": { - "sha256": "044b9f8633e1c5f242d861baad67d86126f2ae8592d96b3c49465ae3a46718fd", + "sha256": "04e6e05e2932a55f3c08f69e30d0d1b6636724785e8fdcc4f232b87a81211484", "points": [], "queries": [ { @@ -6908,14 +6907,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": { @@ -6930,7 +6932,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.", @@ -6952,7 +6954,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.", @@ -6974,7 +6976,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.", @@ -6996,7 +6998,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.", @@ -7018,7 +7020,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.", @@ -7040,7 +7042,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.", @@ -7059,7 +7061,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.", @@ -7078,7 +7080,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.", @@ -7100,7 +7102,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.", @@ -7122,7 +7124,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.", @@ -7144,7 +7146,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.", @@ -7166,7 +7168,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.", @@ -7188,7 +7190,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.", @@ -7235,7 +7237,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.", @@ -7262,7 +7264,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.", @@ -7289,7 +7291,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.", @@ -7311,7 +7313,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.", @@ -7333,7 +7335,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.", @@ -7365,7 +7367,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.", @@ -7387,7 +7389,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.", @@ -7409,7 +7411,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.", @@ -7431,7 +7433,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.", @@ -7453,7 +7455,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.", @@ -7480,7 +7482,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.", @@ -7502,7 +7504,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.", @@ -7521,7 +7523,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.", @@ -7545,7 +7547,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.", @@ -7567,7 +7569,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.", @@ -7589,7 +7591,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.", @@ -7611,7 +7613,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.", @@ -7633,7 +7635,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.", @@ -7655,7 +7657,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.", @@ -7677,7 +7679,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.", @@ -7699,7 +7701,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.", @@ -7721,7 +7723,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.", @@ -7743,7 +7745,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.", @@ -7765,7 +7767,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.", @@ -7787,7 +7789,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.", @@ -7809,7 +7811,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.", @@ -7831,7 +7833,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.", @@ -19237,7 +19239,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.", @@ -19258,7 +19260,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.", @@ -19280,7 +19282,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.", @@ -19302,7 +19304,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.", @@ -19324,7 +19326,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.", @@ -19345,7 +19347,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.", @@ -19372,7 +19374,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.", @@ -19393,7 +19395,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.", @@ -19420,7 +19422,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.", @@ -19441,7 +19443,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.", @@ -19473,7 +19475,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.", @@ -19494,7 +19496,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.", @@ -19516,7 +19518,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.", @@ -19538,7 +19540,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.", @@ -19560,7 +19562,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.", @@ -19587,7 +19589,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.", @@ -19640,7 +19642,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.", @@ -19682,7 +19684,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.", @@ -19709,7 +19711,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.", @@ -19731,7 +19733,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.", @@ -19753,7 +19755,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.", @@ -19846,7 +19848,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.", @@ -19920,7 +19922,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.", @@ -19943,7 +19945,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.", @@ -19975,7 +19977,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.", @@ -19997,7 +19999,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.", @@ -20019,7 +20021,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.", @@ -20041,7 +20043,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.", @@ -20063,7 +20065,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.", @@ -20085,7 +20087,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.", @@ -28086,7 +28088,7 @@ }, "functional_tests/test_document_search_metadata_sync.py::FakeDocumentsContainer": { "boundary": { - "sha256": "328730eded73951ad31aafd5c24bebaad155ee9e6d8b6e59b6146b3b53f9b6d5", + "sha256": "cd7e418381a1d0e30689d610106821eba9427898434de404917301b11dd2534b", "methods": [ "create_item", "read_item", @@ -28095,10 +28097,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" ] @@ -32731,7 +32733,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..bb35d96b0 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.354 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..308b71974 --- /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.354 +Implemented in: 0.261.354 + +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.354") diff --git a/functional_tests/test_document_search_metadata_sync.py b/functional_tests/test_document_search_metadata_sync.py index e2c63dfa0..95db21786 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.354 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..0dc442879 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.354 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..7b918cbed 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.354 // 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..a62c936a3 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.354 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..76d7ce5d2 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.354 // 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..47b59dc7c 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.354 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..177fdd446 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.354 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..eb9131cde --- /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.354 +Implemented in: 0.261.354 + +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..8f998839b 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.354 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..a1f32b194 --- /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.354 +Implemented in: 0.261.354 + +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