From 3f4186f31e8674d64964f38748416f7a22e47d18 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 6 Nov 2026 14:05:36 -0500 Subject: [PATCH] Fix orchestration progress, native workbook citations, and cleanup Keep confirmed task progress distinct from transport uncertainty, resolve native annotation citations against retained authorized evidence, and correct the thought cleanup call contract. Add regression coverage and related documentation for version 0.261.417. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_citation_tracking.py | 6 +- .../functions_orchestration_adapters.py | 11 +- .../functions_orchestration_composition.py | 2 + .../functions_orchestration_result_runtime.py | 76 ++++++- .../single_app/functions_saved_analysis.py | 2 +- .../single_app/route_backend_documents.py | 39 +++- .../static/js/chat/chat-citations.js | 75 ++++++- .../src/components/chat/CitationChip.tsx | 45 ++++- .../components/chat/OrchestrationPlanCard.tsx | 12 +- .../chat/OrchestrationRecoveryNotice.tsx | 2 +- application/v2_ui/src/lib/citations.ts | 40 +++- application/v2_ui/src/lib/endpoints.ts | 2 + .../v2_ui/src/lib/orchestrationController.ts | 15 +- application/v2_ui/src/lib/types.ts | 17 ++ .../v2_ui/src/stores/orchestrationStore.ts | 5 + .../features/CHAT_ORCHESTRATION.md | 3 +- ...N_PROGRESS_NATIVE_CITATIONS_CLEANUP_FIX.md | 187 ++++++++++++++++++ docs/guides/inspect-excel-workbooks.md | 24 ++- .../review-and-edit-orchestration-plans.md | 10 +- docs/reference/chat-controls.md | 17 +- docs/reference/logging-tags.md | 1 + .../cosmos_response_boundaries.json | 36 ++-- .../test_native_workbook_citation_parser.mjs | 64 ++++++ ...rchestration_attachment_native_evidence.py | 110 ++++++++++- ..._orchestration_native_citation_delivery.py | 106 ++++++++++ .../test_saved_analysis_cleanup.py | 12 +- .../test_saved_analysis_cleanup_http.py | 144 ++++++++++++++ .../test_v2_orchestration_quiet_activity.py | 7 +- ui_tests/test_v2_orchestration_recovery.py | 92 +++++++-- ui_tests/test_v2_stream_citation_delivery.py | 84 +++++++- 31 files changed, 1171 insertions(+), 77 deletions(-) create mode 100644 docs/explanation/fixes/ORCHESTRATION_PROGRESS_NATIVE_CITATIONS_CLEANUP_FIX.md create mode 100644 functional_tests/test_native_workbook_citation_parser.mjs create mode 100644 functional_tests/test_orchestration_native_citation_delivery.py create mode 100644 functional_tests/test_saved_analysis_cleanup_http.py diff --git a/application/single_app/config.py b/application/single_app/config.py index ae502e798..c38507506 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -103,7 +103,7 @@ EXECUTOR_MAX_WORKERS = 30 CHAT_STREAM_EXECUTOR_MAX_WORKERS = max(8, int(os.getenv('SIMPLECHAT_CHAT_STREAM_WORKERS', '32') or '32')) SESSION_TYPE = 'filesystem' -VERSION = "0.261.416" +VERSION = "0.261.417" 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_citation_tracking.py b/application/single_app/functions_citation_tracking.py index 3e5c3e478..130004263 100644 --- a/application/single_app/functions_citation_tracking.py +++ b/application/single_app/functions_citation_tracking.py @@ -50,7 +50,7 @@ ) _INLINE_DOCUMENT_SOURCE_PATTERN = re.compile( r"\(Source:((?:(?!\(Source:).){1,256}?)," - r"\s{0,8}(Page(?:s)?|Sheet(?:s)?|Location):" + r"\s{0,8}(Page(?:s)?|Sheet(?:s)?|Location|Worksheet cell):" r"((?:(?!\(Source:).){0,256}?)\)" r"(?=\s{0,8}(?:\[#|$|[.;,!? \n]))", re.IGNORECASE, @@ -356,6 +356,10 @@ def _citation_matches_explicit_ids(citation: Mapping[str, Any]) -> bool: _source_reference_matches_citation(source_reference, citation) for source_reference in unmatched_source_references ) + if citation.get("native_evidence") or ":native-comments:" in citation_id: + # A worksheet/cell can contain several roots and replies. Only an exact + # retained identity proves which reviewer text the answer cited. + matched_by_source = False if not matched_by_id and not matched_by_source: continue if matched_by_id: diff --git a/application/single_app/functions_orchestration_adapters.py b/application/single_app/functions_orchestration_adapters.py index 4a586648d..321010986 100644 --- a/application/single_app/functions_orchestration_adapters.py +++ b/application/single_app/functions_orchestration_adapters.py @@ -876,7 +876,8 @@ def _run_excel_comments(step, context, *, settings, user_id, emit, cancel_reques citations.append({ 'source_type': 'document', 'document_id': source['document_id'], 'file_name': source.get('file_name'), 'citation_id': results[-1]['id'], - 'location_label': 'Native annotation coverage', + 'evidence_kind': 'native_annotation_coverage', + 'location_label': 'Location', 'location_value': 'Native annotation inventory', }) for index, record in enumerate(records): results.append({ @@ -889,9 +890,13 @@ def _run_excel_comments(step, context, *, settings, user_id, emit, cancel_reques 'source_type': 'document', 'document_id': source['document_id'], 'file_name': source.get('file_name'), 'sheet': record.get('sheet'), 'cell': record.get('cell'), 'annotation_id': record.get('id'), + 'evidence_kind': 'native_excel_annotation', 'citation_id': results[-1]['id'], 'sheet_name': record.get('sheet'), - 'location_label': 'Worksheet cell', - 'location_value': f"{record.get('sheet')}!{record.get('cell')}", + 'location_label': 'Location', + 'location_value': ( + f"{record['sheet']}!{record['cell']}" + if record.get('sheet') and record.get('cell') else 'Workbook annotation' + ), }) current_manifest = resolve_context_source_manifest( context, identifiers, settings=settings, user_id=user_id, cancel_requested=cancel_requested, diff --git a/application/single_app/functions_orchestration_composition.py b/application/single_app/functions_orchestration_composition.py index 275d4f0a5..8f01428eb 100644 --- a/application/single_app/functions_orchestration_composition.py +++ b/application/single_app/functions_orchestration_composition.py @@ -432,6 +432,8 @@ def _input_document_citations(context, reference): } if not citation_id or location in (None, ''): continue + # The browser's retained-result capability is transport metadata, not model input. + citation = {key: value for key, value in citation.items() if key != 'native_evidence'} citations.append({ **citation, 'citation': build_inline_citation_marker( diff --git a/application/single_app/functions_orchestration_result_runtime.py b/application/single_app/functions_orchestration_result_runtime.py index f026fdf88..cac8c7c21 100644 --- a/application/single_app/functions_orchestration_result_runtime.py +++ b/application/single_app/functions_orchestration_result_runtime.py @@ -6,6 +6,7 @@ from copy import deepcopy from dataclasses import replace +import json from content_screening.contracts import DocumentHeldError, ScreeningError from functions_analysis_access import analysis_source_snapshot @@ -266,6 +267,7 @@ def _read_result_document_citations(service, reference, *, evidence_status_out=N 'citation_id', 'chunk_id', 'file_name', 'title', 'page_number', 'chunk_sequence', 'sheet_name', 'location_label', 'location_value', 'classification', 'document_classification', 'score', 'evidence_id', + 'annotation_id', 'evidence_kind', 'cell', 'sheet', ) def source_key(source): @@ -306,6 +308,7 @@ def visit(current): for parent in lineage['upstream']: visit(ResultRef.from_dict(parent)) retained_citations = [] + native_reference = None if ( producer.capability_id == 'document_search' and (current.output_name, current.kind) in ( @@ -334,6 +337,8 @@ def visit(current): _search_evidence_citations(reader, prepared) if current.output_name == 'evidence' else prepared['citations'] ) + if prepared['content_scope'] == 'native_excel_annotations': + native_reference = prepared_reader.reference.to_dict() for source in lineage['sources']: search_counts.setdefault(source_key(source), set()) elif producer.capability_id in ('document_analyze', 'document_compare'): @@ -367,10 +372,19 @@ def visit(current): if len(matches) != 1: raise ResultContractError('result_citation_source_invalid') projected = project(matches[0], citation) + if native_reference is not None: + projected.update({ + 'native_evidence': deepcopy(native_reference), + 'source_version': matches[0]['source_version'], + 'source_revision': matches[0].get('source_revision'), + }) identity_key = source_key(matches[0]) if isinstance(citation.get('file_name'), str) and citation['file_name'].strip(): source_names[identity_key] = citation['file_name'].strip() - if producer.capability_id == 'document_search' and identity_key in search_counts: + if ( + producer.capability_id == 'document_search' and identity_key in search_counts + and citation.get('evidence_kind') != 'native_annotation_coverage' + ): search_counts[identity_key].add(( producer, current.manifest_sha256, canonical_digest({name: value for name, value in projected.items() if name != 'evidence_id'}), @@ -404,6 +418,66 @@ def visit(current): return projected +def read_native_citation_evidence(service, reference, *, citation_id, document_id): + """Resolve an exact committed annotation, with current source access and version. + + Unlike ordinary retained-result inspection, a source citation also authorizes the + workbook it offers to preview. Never search a current inventory by cell or decode + a document identity from the synthetic citation ID. + """ + if ( + type(reference) is not ResultRef or reference.producer.capability_id != 'document_search' + or reference.output_name != 'prepared' or reference.kind != 'structured-v1' + ): + raise ResultContractError('native_citation_reference_invalid') + reader = service.open_result(reference, allow_partial=True, require_current_sources=True) + prepared = read_complete_input(reader) + if ( + type(prepared) is not dict or prepared.get('content_scope') != 'native_excel_annotations' + or type(prepared.get('citations')) is not list or type(prepared.get('search_results')) is not list + ): + raise ResultContractError('native_citation_reference_invalid') + citations = [ + item for item in prepared['citations'] + if type(item) is dict and item.get('citation_id') == citation_id and item.get('document_id') == document_id + ] + hits = [ + item for item in prepared['search_results'] + if type(item) is dict and item.get('id') == citation_id and item.get('document_id') == document_id + ] + manifest = service._load_manifest(reference) + sources = [item for item in manifest['lineage']['sources'] if item['document_id'] == document_id] + if len(citations) != 1 or len(hits) != 1 or len(sources) != 1: + raise ResultUnavailableError('native_citation_unavailable') + service.access.authorize_input_sources(sources, require_snapshot=True) + try: + value = json.loads(hits[0]['chunk_text']) + except (KeyError, TypeError, ValueError): + raise ResultContractError('native_citation_evidence_invalid') from None + if type(value) is not dict: + raise ResultContractError('native_citation_evidence_invalid') + annotation = value.get('excel_annotation') + coverage = value.get('native_coverage') + if type(annotation) is dict: + citation = citations[0] + if any(citation.get(field) != annotation.get(record_field) for field, record_field in ( + ('annotation_id', 'id'), ('sheet', 'sheet'), ('cell', 'cell'), + )): + raise ResultContractError('native_citation_evidence_invalid') + response = {'native_annotation': deepcopy(annotation), 'cited_text': annotation.get('text') or ''} + elif type(coverage) is dict: + response = {'native_coverage': deepcopy(coverage), 'cited_text': ''} + else: + raise ResultContractError('native_citation_evidence_invalid') + reader.recheck() + service.access.authorize_input_sources(sources, require_snapshot=True) + return { + **response, 'citation_id': citation_id, 'document_id': document_id, + 'file_name': hits[0].get('file_name') or '', 'source_version': sources[0]['source_version'], + 'evidence_kind': 'native_excel_annotation' if annotation is not None else 'native_annotation_coverage', + } + + def _authorize_task_result(step, context, task, *, reused=False): if type(task) is not TaskResult: raise ResultContractError('result_contract_invalid') diff --git a/application/single_app/functions_saved_analysis.py b/application/single_app/functions_saved_analysis.py index 07324b235..80a3b1a1a 100644 --- a/application/single_app/functions_saved_analysis.py +++ b/application/single_app/functions_saved_analysis.py @@ -2072,7 +2072,7 @@ def cleanup_chat_analysis_messages( ) if value } for user_id in thought_owners: - delete_thoughts(conversation_id, message["id"], user_id) + delete_thoughts(message["id"], user_id) descriptors = list(metadata.get("saved_analyses") or []) if metadata.get("saved_analysis"): descriptors.append(metadata["saved_analysis"]) diff --git a/application/single_app/route_backend_documents.py b/application/single_app/route_backend_documents.py index 2bc96335a..ffbd90dfb 100644 --- a/application/single_app/route_backend_documents.py +++ b/application/single_app/route_backend_documents.py @@ -24,6 +24,10 @@ build_synced_document_delete_guard, ) from functions_notifications import create_notification, delete_notifications_by_metadata +from functions_orchestration_result_contracts import ResultContractError, ResultRef +from functions_orchestration_result_runtime import read_native_citation_evidence +from functions_orchestration_results import ResultUnavailableError +from functions_workflow_result_store import WorkflowResultIntegrityError from functions_document_access_index import ( DOCUMENT_ACCESS_SCOPE_PERSONAL, is_document_access_shadow_validation_enabled, @@ -1765,6 +1769,38 @@ def get_citation(): if not citation_id: return jsonify({"error": "Missing citation_id"}), 400 + if data.get("native_evidence") is not None or ( + isinstance(citation_id, str) and ":native-comments:" in citation_id + ): + if not data.get("native_evidence") or not document_id or not data.get("conversation_id"): + return jsonify({"error": "Retained native citation evidence is unavailable."}), 404 + try: + reference = ResultRef.from_dict(data["native_evidence"]) + if ( + reference.producer.user_id != user_id + or reference.producer.conversation_id != data["conversation_id"] + ): + return jsonify({"error": "Unauthorized access to citation"}), 403 + # Bootstrap depends on initialized config; load only for native citation reads. + from functions_orchestration_bootstrap import build_orchestration_services + + services = build_orchestration_services(user_id, data["conversation_id"]) + response = read_native_citation_evidence( + services.results, reference, citation_id=citation_id, document_id=document_id, + ) + return jsonify(response), 200 + except ResultContractError: + return jsonify({"error": "Invalid native citation reference."}), 400 + except (WorkflowResultIntegrityError, ResourceNotFoundError): + return jsonify({"error": "Retained native citation evidence is unavailable."}), 404 + except ScreeningError as error: + return jsonify({"error": error.public_message, "error_code": error.code}), error.status_code + except (ResultUnavailableError, PermissionError): + return jsonify({"error": "The cited workbook or retained evidence is no longer available."}), 403 + except Exception as error: + log_event("[CITATION] Native citation lookup failed.", extra={"error_type": type(error).__name__}, level=logging.WARNING) + return jsonify({"error": "Unable to load retained native citation evidence."}), 500 + def build_citation_response(chunk): return jsonify({ "cited_text": chunk.get("chunk_text", ""), @@ -1812,7 +1848,8 @@ def get_citation_for_scope(search_client, scope_name): except ResourceNotFoundError: continue except Exception as e: - return jsonify({"error": f"Unexpected error: {str(e)}"}), 500 + log_event("[CITATION] Citation lookup failed.", extra={"error_type": type(e).__name__}, level=logging.WARNING) + return jsonify({"error": "Unable to load the cited passage."}), 500 if citation_response: return citation_response diff --git a/application/single_app/static/js/chat/chat-citations.js b/application/single_app/static/js/chat/chat-citations.js index bc790576b..9cabf5e35 100644 --- a/application/single_app/static/js/chat/chat-citations.js +++ b/application/single_app/static/js/chat/chat-citations.js @@ -155,7 +155,7 @@ function buildRawDocumentSourceLookup(records) { export function parseCitations(message, options = {}) { // ... (keep existing implementation) - const citationRegex = /\(Source:\s*((?:(?!\(Source:).)+?),\s*(Page(?:s)?|Sheet(?:s)?|Location):\s*((?:(?!\(Source:).)+?)\)\s*((?:\[#.*?\]\s*)+)/gi; + const citationRegex = /\(Source:\s*((?:(?!\(Source:).)+?),\s*(Page(?:s)?|Sheet(?:s)?|Location|Worksheet cell):\s*((?:(?!\(Source:).)+?)\)\s*((?:\[#.*?\]\s*)+)/gi; const m365CitationLookup = buildM365CitationLookup(options.m365Citations); const documentFileNames = buildDocumentFileNameLookup(options.documentCitations); const rawDocumentSources = buildRawDocumentSourceLookup(options.documentCitations); @@ -195,6 +195,27 @@ export function parseCitations(message, options = {}) { }); }); + if (orderedRefs.some(ref => ref.includes(':native-comments:')) || locationLabel.toLowerCase() === 'worksheet cell') { + const links = orderedRefs.map(ref => { + const records = (options.documentCitations || []).filter(record => record?.citation_id === ref); + if (!records.length || records.some(record => JSON.stringify(record) !== JSON.stringify(records[0])) + || !records[0].native_evidence || !records[0].document_id) { + return '[Source unavailable]'; + } + const record = records[0]; + const link = document.createElement('a'); + link.href = '#'; + link.className = 'citation-link'; + link.dataset.citationId = ref; + link.dataset.documentId = record.document_id; + link.dataset.nativeEvidence = JSON.stringify(record.native_evidence); + link.dataset.conversationId = record.native_evidence.producer?.conversation_id || ''; + link.textContent = record.location_value || locations.trim(); + return serializeSafeElement(link); + }); + return `(Source: ${filenameHtml}, Location: ${links.join(', ')})${trailingWhitespace}`; + } + // A Microsoft 365 email, event or file has no workspace passage to fetch, so its // citation links to the item itself, or is plain text when no link was recorded. if (orderedRefs.length > 0 && orderedRefs.every((ref) => isM365CitationId(ref))) { @@ -371,6 +392,10 @@ export function fetchCitedText(citationId, citationContext = {}) { if (chunkId) { requestPayload.chunk_id = chunkId; } + if (citationContext.nativeEvidence) { + requestPayload.native_evidence = citationContext.nativeEvidence; + requestPayload.conversation_id = citationContext.conversationId; + } fetch("/api/get_citation", { method: "POST", @@ -394,7 +419,29 @@ export function fetchCitedText(citationId, citationContext = {}) { hideLoadingIndicator(); // Check for expected data fields explicitly - if (data.cited_text !== undefined && data.file_name && data.page_number !== undefined) { + if (data.native_annotation || data.native_coverage) { + const record = data.native_annotation; + const text = record ? [ + record.kind === 'note' ? 'Workbook note' : record.parent_id ? 'Threaded comment reply' : 'Threaded comment', + `Author: ${record.author || 'Unavailable'}`, + `Worksheet / cell: ${record.sheet || 'Unavailable'} / ${record.cell || 'Unavailable'}`, + `Date: ${record.date || 'Unavailable'}`, + `Reply to: ${record.parent_id || 'Not a reply'}`, + `Saved cell value: ${record.cell_value_status === 'blank' ? 'Blank' : record.cell_value == null ? 'Unavailable (not recalculated)' : JSON.stringify(record.cell_value)}`, + '', 'Reviewer text', data.cited_text || 'No stored reviewer text.', + ].join('\n') : [ + 'Native annotation inventory', + `${data.native_coverage.returned_records} of ${data.native_coverage.total_records} records retained.`, + data.native_coverage.partial ? 'Partial inventory.' : 'Complete saved inventory.', + 'Inventory coverage is not a worksheet cell or reviewer comment.', + ].join('\n'); + showCitedTextPopup(text, data.file_name, '', { + title: `Native workbook evidence: ${data.file_name}`, + workbookPreview: () => showEnhancedCitationModal( + data.document_id, record?.sheet || '', null, record?.sheet || null, + ), + }); + } else if (data.cited_text !== undefined && data.file_name && data.page_number !== undefined) { showCitedTextPopup(data.cited_text, data.file_name, data.page_number); } else if (data.error) { // Handle explicit errors from server even on 200 OK showToast(`Could not retrieve citation: ${data.error}`, "warning"); @@ -482,6 +529,7 @@ export function showCitedTextPopup(citedText, fileName, pageNumber, options = {} } const citedTextContent = ensureCitedTextContentElement(modalContainer, renderMarkdown); + modalContainer.querySelector('.native-workbook-preview')?.remove(); if (citedTextContent) { citedTextContent.className = renderMarkdown ? "generated-analysis-preview-block generated-analysis-markdown-preview citation-markdown-content p-3" @@ -493,6 +541,14 @@ export function showCitedTextPopup(citedText, fileName, pageNumber, options = {} } else { citedTextContent.textContent = citedText; } + if (options.workbookPreview) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'btn btn-outline-primary mt-3 native-workbook-preview'; + button.textContent = 'Open workbook preview (separate from annotation evidence)'; + button.addEventListener('click', options.workbookPreview); + modalContainer.querySelector('.modal-body').appendChild(button); + } } const modal = new bootstrap.Modal(modalContainer); @@ -1113,6 +1169,21 @@ if (chatboxEl) { if (isM365CitationId(citationId)) { return; } + if (citationId.includes(':native-comments:')) { + let nativeEvidence; + try { + nativeEvidence = JSON.parse(target.dataset.nativeEvidence || 'null'); + } catch { + showToast('Retained native citation evidence is unavailable.', 'warning'); + return; + } + fetchCitedText(citationId, { + documentId: target.dataset.documentId, + nativeEvidence, + conversationId: target.dataset.conversationId, + }); + return; + } // Check if this is a metadata citation const isMetadata = target.getAttribute("data-is-metadata") === "true"; diff --git a/application/v2_ui/src/components/chat/CitationChip.tsx b/application/v2_ui/src/components/chat/CitationChip.tsx index 97e837676..8e4203d5b 100644 --- a/application/v2_ui/src/components/chat/CitationChip.tsx +++ b/application/v2_ui/src/components/chat/CitationChip.tsx @@ -40,6 +40,7 @@ function CitationDetail({ const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [preview, setPreview] = useState(false); useEffect(() => { let cancelled = false; @@ -50,6 +51,8 @@ function CitationDetail({ citation_id: citation.citationId, document_id: citation.documentId || undefined, page_number: citation.locationValue || citation.pageNumber || undefined, + native_evidence: citation.nativeEvidence, + conversation_id: citation.conversationId, }); if (!cancelled) { setData(result); @@ -77,12 +80,19 @@ function CitationDetail({ useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - onClose(); + if (preview) setPreview(false); + else onClose(); } }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); - }, [onClose]); + }, [onClose, preview]); + + if (preview) return setPreview(false)} onFallback={() => setPreview(false)} + />; return (
@@ -119,6 +130,29 @@ function CitationDetail({
+ {data?.native_annotation &&
+

+ {data.native_annotation.kind === 'note' ? 'Workbook note' + : data.native_annotation.parent_id ? 'Threaded comment reply' : 'Threaded comment'} +

+

Author: {data.native_annotation.author || 'Unavailable'}

+

Worksheet / cell: {data.native_annotation.sheet || 'Unavailable'} / {data.native_annotation.cell || 'Unavailable'}

+

Date: {data.native_annotation.date || 'Unavailable'}

+ {data.native_annotation.parent_id &&

Reply to: {data.native_annotation.parent_id}

} +

Resolved: {typeof data.native_annotation.resolved === 'boolean' + ? data.native_annotation.resolved ? 'Yes' : 'No' : 'Unavailable'}

+

Saved cell value: {data.native_annotation.cell_value_status === 'blank' ? 'Blank' + : data.native_annotation.cell_value == null ? 'Unavailable (not recalculated)' + : JSON.stringify(data.native_annotation.cell_value)}

+

Reviewer text

+
} + {data?.native_coverage &&
+

Native annotation inventory

+

{data.native_coverage.returned_records ?? 'Unknown'} of {data.native_coverage.total_records ?? 'unknown'} records retained.

+

{data.native_coverage.partial || !data.native_coverage.inventory_complete + ? 'Partial inventory; remaining annotations were not inspected.' : 'The saved annotation inventory was inspected completely.'}

+

This is inventory coverage, not a cited worksheet cell or reviewer comment.

+
} {notice && (

@@ -154,6 +188,9 @@ function CitationDetail({ This citation has no stored passage to show.

)} + {!loading && !error && citation.nativeEvidence && }
@@ -266,7 +303,7 @@ function CitationTarget({ const [fallbackReason, setFallbackReason] = useState(null); useEffect(() => { - if (!enhancedEnabled || !citation.documentId) { + if (!enhancedEnabled || !citation.documentId || citation.nativeEvidence || citation.nativeUnavailable) { setResolving(false); return; } @@ -304,7 +341,7 @@ function CitationTarget({ return () => { cancelled = true; }; - }, [enhancedEnabled, citation.documentId, group.fileName]); + }, [enhancedEnabled, citation.documentId, citation.nativeEvidence, citation.nativeUnavailable, group.fileName]); const onFallback = useCallback((reason: string) => { // Surfaced rather than silent: the user asked to see the source, and should be diff --git a/application/v2_ui/src/components/chat/OrchestrationPlanCard.tsx b/application/v2_ui/src/components/chat/OrchestrationPlanCard.tsx index c4fe0a39c..7595b5a17 100644 --- a/application/v2_ui/src/components/chat/OrchestrationPlanCard.tsx +++ b/application/v2_ui/src/components/chat/OrchestrationPlanCard.tsx @@ -220,9 +220,8 @@ export function OrchestrationPlanCard({ // what the run is doing in words. if (runInFlight || waiting) { const uncertain = recovery?.transportUnknown === true; - const runningStep = editedPlan?.steps.find((step) => step.enabled && stepRuntime[step.step_id]?.status === 'running'); - const checkActivity = runningStep ? completenessActivity(stepRuntime[runningStep.step_id]?.task_completeness) : null; - const progress = checkActivity ?? describeRunProgress(editedPlan?.steps ?? [], stepRuntime, waiting); + const progress = recovery?.status && ['completed', 'failed', 'cancelled'].includes(recovery.status) + ? 'Saving final response' : describeRunProgress(editedPlan?.steps ?? [], stepRuntime, waiting); return (
@@ -231,7 +230,8 @@ export function OrchestrationPlanCard({ {waiting || uncertain ?
diff --git a/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx b/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx index eff86e74e..184a47943 100644 --- a/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx +++ b/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx @@ -210,7 +210,7 @@ export function OrchestrationRecoveryNotice({ >

{legacy && saved?.error ? saved.error - : saved?.transportUnknown ? 'Execution status is not confirmed. Your progress is saved.' + : saved?.transportUnknown ? 'Reconnecting to progress… Your last known progress is saved.' : saved?.error ? saved.error : waiting ? 'Waiting for required results' : revisionNeeded ? 'The answer still needs revision. Your progress is saved.' diff --git a/application/v2_ui/src/lib/citations.ts b/application/v2_ui/src/lib/citations.ts index db6d763b4..a924034a2 100644 --- a/application/v2_ui/src/lib/citations.ts +++ b/application/v2_ui/src/lib/citations.ts @@ -23,6 +23,9 @@ export interface ParsedCitation { locationValue: string; documentId: string; pageNumber: string; + nativeEvidence?: Record; + conversationId?: string; + nativeUnavailable?: boolean; } export interface CitationGroup { @@ -43,7 +46,7 @@ export interface CitationGroup { * citation when two appear back to back. */ const CITATION_MARKER = - /\(Source:\s*((?:(?!\(Source:).)+?),\s*(Page(?:s)?|Sheet(?:s)?|Location):\s*((?:(?!\(Source:).)+?)\)\s*((?:\[#.*?\]\s*)+)/gi; + /\(Source:\s*((?:(?!\(Source:).)+?),\s*(Page(?:s)?|Sheet(?:s)?|Location|Worksheet cell):\s*((?:(?!\(Source:).)+?)\)\s*((?:\[#.*?\]\s*)+)/gi; const RAW_DOCUMENT_MARKER = /\u3010\s*#?([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}_[a-z0-9_.-]{1,128})\s*\u3011/gi; @@ -94,6 +97,8 @@ function rawDocumentSources(records: readonly HybridCitation[]): Map, + conversationId: (record.native_evidence as { producer?: { conversation_id?: string } }).producer?.conversation_id, + } : {}), + ...(native && (!record.native_evidence || !explicitDocumentId) ? { nativeUnavailable: true } : {}), }], }; // A later matching duplicate must not undo an earlier conflicting source. @@ -207,7 +218,16 @@ export function parseCitations( for (const part of inner.split(/[;,]/)) { const trimmed = part.trim(); if (trimmed) { - citations.push(splitCitationId(trimmed)); + const id = trimmed.replace(/^#/, ''); + const retained = rawSources.get(id); + if (/:native-comments:/.test(id) || locationLabel.toLowerCase() === 'worksheet cell') { + if (!retained?.citations[0].nativeEvidence) { + return `${fileName.trim()} — ${locations.trim()} [Source unavailable]${trailingWhitespace}`; + } + citations.push({ ...retained.citations[0] }); + } else { + citations.push(retained ? { ...retained.citations[0] } : splitCitationId(trimmed)); + } } } } @@ -225,7 +245,7 @@ export function parseCitations( : [locations.trim()]; citations.forEach((citation, index) => { - citation.locationValue = tokens[index] ?? citation.pageNumber ?? ''; + if (!citation.nativeEvidence) citation.locationValue = tokens[index] ?? citation.pageNumber ?? ''; }); const kind = classify(fileName, citations); @@ -316,7 +336,17 @@ export function parseCitations( // Any bracket run that survives had no Source prefix to give it meaning, so it is // removed rather than shown to the user as raw ids. - text = text.replace(ORPHAN_BRACKET_RUN, ''); + text = text.replace(ORPHAN_BRACKET_RUN, (run) => { + if (!run.includes(':native-comments:')) return ''; + const ids = [...run.matchAll(/\[#([^\]]+)\]/g)].map((match) => match[1].trim()); + return ids.map((id) => { + const source = rawSources.get(id); + if (!source?.citations[0].nativeEvidence) return '[Source unavailable]'; + const index = groups.length; + groups.push({ ...source, id: `cite-${index}` }); + return CITATION_PLACEHOLDER(index); + }).join(' '); + }); // Collapse runs of blank lines, matching the classic UI's `\n{3,}` -> `\n\n`. With // remark-breaks enabled every newline becomes a line break, so an unclipped run of diff --git a/application/v2_ui/src/lib/endpoints.ts b/application/v2_ui/src/lib/endpoints.ts index df55327cb..8dd7310e5 100644 --- a/application/v2_ui/src/lib/endpoints.ts +++ b/application/v2_ui/src/lib/endpoints.ts @@ -1171,6 +1171,8 @@ export interface CitationRequest { document_id?: string; page_number?: string; chunk_id?: string; + native_evidence?: Record; + conversation_id?: string; } export const fetchCitation = (payload: CitationRequest) => diff --git a/application/v2_ui/src/lib/orchestrationController.ts b/application/v2_ui/src/lib/orchestrationController.ts index ba9202145..8095d3e78 100644 --- a/application/v2_ui/src/lib/orchestrationController.ts +++ b/application/v2_ui/src/lib/orchestrationController.ts @@ -1335,6 +1335,7 @@ async function executeSavedPlan( onM365PendingActions: ingestOutgoingActions, onStep: (event) => { const current = useOrchestrationStore.getState(); + if (selectPlan(current, conversationId, turnId)?.run_id !== runId) return; current.applyStepEvent(conversationId, turnId, event); applyOrchestrationOutputEvent(runId, event); current.mergeReasoningAdjustments(conversationId, turnId, event.reasoning_adjustments); @@ -1587,6 +1588,7 @@ export async function loadOrchestrationRecovery(conversationId: string, runId: s status: record.status, ...(plan ? { plan } : {}), detailLoaded: true, + transportUnknown: false, }, readRevision); return admitted ? record : null; } @@ -1605,7 +1607,7 @@ export async function reconcileOrchestrationRun(conversationId: string, runId: s const store = useOrchestrationStore.getState(); if (store.runRecovery[runId]?.checking) return; store.updateRunRecovery(runId, { - checking: true, transportUnknown: !isOrchestrationRunWaiting(store.runRecovery[runId] ?? {}), + checking: true, }); try { const record = await loadOrchestrationRecovery(conversationId, runId); @@ -1617,9 +1619,11 @@ export async function reconcileOrchestrationRun(conversationId: string, runId: s return; } const attemptRevision = useOrchestrationStore.getState().runRecovery[runId]?.attemptRevision; + const progressRevision = useOrchestrationStore.getState().recoveryRevision; const steps = await fetchRunSteps(runId, { conversationId }).catch(() => []); const current = useOrchestrationStore.getState(); - if (current.runRecovery[runId]?.attemptRevision !== attemptRevision) { + if (current.runRecovery[runId]?.attemptRevision !== attemptRevision + || current.recoveryRevision !== progressRevision) { current.updateRunRecovery(runId, { checking: false }); if (isOrchestrationRunPending(current.runRecovery[runId] ?? {})) scheduleReconciliation(conversationId, runId); else if (current.inFlight[runId]) await reconcileOrchestrationRun(conversationId, runId); @@ -1669,12 +1673,9 @@ export async function reconcileOrchestrationRun(conversationId: string, runId: s } return; } - const waiting = isOrchestrationRunWaiting(record); current.updateRunRecovery(runId, { - checking: false, transportUnknown: !waiting, - error: waiting ? null : terminal - ? 'The server is saving the final response. Checking saved status; no retry will start.' - : 'The server has not confirmed a final result. Execution may still be running. Checking saved status; no retry will start.', + checking: false, transportUnknown: false, + error: terminal ? 'The server is saving the final response. Checking saved status; no retry will start.' : null, }); } catch (error) { const legacyMessage = legacyPlanErrorMessage(error); diff --git a/application/v2_ui/src/lib/types.ts b/application/v2_ui/src/lib/types.ts index 7eda81616..28d80713e 100644 --- a/application/v2_ui/src/lib/types.ts +++ b/application/v2_ui/src/lib/types.ts @@ -1447,6 +1447,23 @@ export interface Citation { cited_text?: string; file_name?: string; page_number?: number | string; + native_annotation?: { + kind?: string; + author?: string; + date?: string; + sheet?: string; + cell?: string; + parent_id?: string; + resolved?: boolean; + cell_value?: unknown; + cell_value_status?: string; + }; + native_coverage?: { + returned_records?: number; + total_records?: number; + partial?: boolean; + inventory_complete?: boolean; + }; [key: string]: unknown; } diff --git a/application/v2_ui/src/stores/orchestrationStore.ts b/application/v2_ui/src/stores/orchestrationStore.ts index a5a2f86ae..498f1a7c2 100644 --- a/application/v2_ui/src/stores/orchestrationStore.ts +++ b/application/v2_ui/src/stores/orchestrationStore.ts @@ -933,6 +933,7 @@ export const useOrchestrationStore = create((set, get) => ({ return {}; } return { + recoveryRevision: state.recoveryRevision + 1, stepRuntime: { ...state.stepRuntime, [key]: { ...current, [stepId]: merged }, @@ -961,7 +962,11 @@ export const useOrchestrationStore = create((set, get) => ({ if (patch.status === undefined && patch.summary === undefined) { return; } + const plan = get().plans[scopeKey(conversationId, turnId)]; + if (!plan || !plan.steps.some((step) => step.step_id === stepId) + || (event.run_id && event.run_id !== plan.run_id)) return; get().updateStepStatus(conversationId, turnId, stepId, patch); + if (plan.run_id) get().updateRunRecovery(plan.run_id, { transportUnknown: false, error: null }); }, beginRun: (record) => { diff --git a/docs/explanation/features/CHAT_ORCHESTRATION.md b/docs/explanation/features/CHAT_ORCHESTRATION.md index 50216edfb..ba397cb7c 100644 --- a/docs/explanation/features/CHAT_ORCHESTRATION.md +++ b/docs/explanation/features/CHAT_ORCHESTRATION.md @@ -1,6 +1,6 @@ # Chat Orchestration -**Version: 0.261.415** (tracked in `application/single_app/config.py`) +**Version: 0.261.417** (tracked in `application/single_app/config.py`) **Implemented in version: 0.261.086** **Knowledge phase added in version: 0.261.089** @@ -31,6 +31,7 @@ **Search evidence citation harness expectations aligned in version: 0.261.399** **Selected-document planning recovery and debug diagnostics fixed/implemented in version: 0.261.413** **Initial-turn persistence and browser-independent Auto planning implemented in version: 0.261.415** +**Confirmed progress, exact native workbook citations, and saved-analysis cleanup fixed in version: 0.261.417** ## Overview diff --git a/docs/explanation/fixes/ORCHESTRATION_PROGRESS_NATIVE_CITATIONS_CLEANUP_FIX.md b/docs/explanation/fixes/ORCHESTRATION_PROGRESS_NATIVE_CITATIONS_CLEANUP_FIX.md new file mode 100644 index 000000000..b1dc7b961 --- /dev/null +++ b/docs/explanation/fixes/ORCHESTRATION_PROGRESS_NATIVE_CITATIONS_CLEANUP_FIX.md @@ -0,0 +1,187 @@ +# Orchestration progress, native workbook citations, and cleanup fix + +Fixed/Implemented in version: **0.261.417** +Current application version: **0.261.417**, tracked in +`application/single_app/config.py`. + +## Issues and root causes + +Successful running-status reconciliation incorrectly restored `transportUnknown`. +The inline progress row prioritized this flag and generic answer-check activity +over the actual confirmed task. A running record was therefore displayed as an +uncertain execution even after a successful status read. + +Native Excel annotation references used `Worksheet cell`, outside the canonical +source-marker grammar, and synthetic colon-delimited IDs that were not Search +chunks. Merely accepting the label would still misidentify the document or open +an unrelated worksheet preview instead of the cited reviewer evidence. + +Saved-analysis cleanup passed conversation ID, message ID, and user ID into +`delete_thoughts_for_message(message_id, user_id)`. Permissive test callbacks +accepted the extra argument and concealed the production TypeError. + +## Technical changes + +### Confirmed progress and recovery + +The V2 controller clears connection uncertainty after an admitted successful +status read. Live scoped step events clear disconnection warnings and advance +the recovery revision, fencing older status/step reads. Existing attempt and +output revisions remain in place. Reconciliation performs read-only polling, +not automatic retries or duplicate execution. + +`OrchestrationPlanCard.tsx` keeps the task/phase headline while check-stage detail +remains in the expanded activity. Genuine disconnection shows reconnecting and +last-known progress. Saving the final response is distinct from confirmed +publication, waiting, failure, or cancellation. + +### Exact retained native evidence + +The native adapter emits canonical `Location` references, preserves structured +worksheet/cell and annotation metadata, and keeps inventory coverage separate. +`functions_orchestration_result_runtime.py` projects the exact committed +prepared-result reference alongside explicit document identity and source version. +The reference survives lineage traversal, saved publication, streamed completion, +and delivery reconciliation. Composition excludes transport descriptors from +model input while supplying the literal source marker. + +The existing authenticated `/api/get_citation` route has a native-evidence branch. +It validates owner/conversation, producer, committed digest, output kind, exact +citation/record pairing, and source lineage. Current workbook access, screening, +version, and revision are checked before returning evidence. It never searches +by cell or splits a synthetic annotation ID to guess a document. + +V2 and classic citation handling recognize historical `Worksheet cell` markers +only with exact retained metadata. Missing/conflicting evidence remains visibly +unavailable. Source tracking requires the exact native citation ID instead of +selecting multiple same-cell annotations from a filename/location hint. + +The evidence view shows reviewer text, author, worksheet/cell, available +timestamps/reply status, and saved cell values separately. Workbook preview is +an explicit second action, not a substitute for cited comment evidence. + +### Scoped message cleanup + +`functions_saved_analysis.py` now passes only message ID and user ID to the +actual thought helper. Signature-accurate tests cover legitimate owner partitions, +other messages, foreign owners, mirrored/workflow references, archive/permanent +deletion, and required-result cleanup failures. Authorization and upstream-result +retention policies are unchanged. + +## Validation + +### Changed implementation files + +- `application/single_app/config.py` +- `application/single_app/functions_saved_analysis.py` +- `application/single_app/functions_orchestration_adapters.py` +- `application/single_app/functions_orchestration_result_runtime.py` +- `application/single_app/functions_orchestration_composition.py` +- `application/single_app/functions_citation_tracking.py` +- `application/single_app/route_backend_documents.py` +- `application/single_app/static/js/chat/chat-citations.js` +- `application/v2_ui/src/lib/orchestrationController.ts` +- `application/v2_ui/src/stores/orchestrationStore.ts` +- `application/v2_ui/src/components/chat/OrchestrationPlanCard.tsx` +- `application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx` +- `application/v2_ui/src/lib/citations.ts` +- `application/v2_ui/src/lib/endpoints.ts` +- `application/v2_ui/src/lib/types.ts` +- `application/v2_ui/src/components/chat/CitationChip.tsx` + +Related documentation updates are in `docs/guides/inspect-excel-workbooks.md`, +`docs/guides/review-and-edit-orchestration-plans.md`, +`docs/reference/chat-controls.md`, `docs/reference/logging-tags.md`, and +`docs/explanation/features/CHAT_ORCHESTRATION.md`. +The regenerated application-surface inventory has no semantic change. +`functional_tests/cosmos_response_boundaries.json` refreshes the affected module +fingerprints; unchanged storage-owner bodies retain their reviewed contracts and +real SDK runtime evidence. + +### Local validation results + +Run Python commands with: + +```powershell +$env:PYTHONPATH="$PWD\application\single_app;$PWD\functional_tests" +``` + +The final functional bundle passed **128 tests**: + +```powershell +python -m pytest functional_tests\test_saved_analysis_cleanup.py functional_tests\test_saved_analysis_cleanup_http.py functional_tests\test_orchestration_attachment_native_evidence.py functional_tests\test_orchestration_native_citation_delivery.py functional_tests\test_orchestration_search_citation_delivery.py functional_tests\test_orchestration_analysis_citation_delivery.py functional_tests\test_orchestration_citation_persistence.py functional_tests\test_orchestration_dependency_citations.py functional_tests\test_orchestration_followup_citation_provenance.py functional_tests\test_v2_citations.py functional_tests\test_v2_enhanced_citations.py functional_tests\test_saved_analysis_cosmos_owner_contract.py functional_tests\test_v2_public_source_owner_sdk.py -q --disable-warnings --basetemp=.test-artifacts\functional-final --tb=short --maxfail=2 +``` + +The final browser bundle passed **93 tests**: + +```powershell +python -m pytest ui_tests\test_v2_orchestration_recovery.py ui_tests\test_v2_orchestration_quiet_activity.py ui_tests\test_v2_stream_citation_delivery.py ui_tests\test_v2_excel_citation_inspection.py -q --disable-warnings --basetemp=.test-artifacts\ui-final --tb=short --maxfail=2 +``` + +Node regression checks passed **42 tests**: + +```powershell +node --test functional_tests\test_native_workbook_citation_parser.mjs functional_tests\test_v2_orchestration_progress_lane.mjs functional_tests\test_v2_orchestration_run_progress_status.mjs functional_tests\test_v2_stream_citation_delivery.mjs functional_tests\test_v2_web_iq_citations.mjs +``` + +Additional completed checks: + +- `npm --prefix .\application\v2_ui run build`: passed; existing bundle-size warning. +- `npm --prefix .\application\v2_ui run build -- --outDir ..\..\.test-artifacts\v2-final`: + final-source production build passed with the same existing warning; isolated + output avoids modifying the application preview while other checks run. +- `python functional_tests\route_tests\test_route_blueprint_policy_inventory.py`: **15/15 passed**. +- `python functional_tests\route_tests\test_route_unauthenticated_policy_contract.py`: **10/10 passed**. +- `python functional_tests\route_tests\test_route_policy_test_coverage.py`: **3/3 passed**. +- `python scripts\build_docs_inventory.py`: regenerated successfully. +- `python functional_tests\test_docs_app_surface_coverage.py`: **7/7 passed**. +- `python functional_tests\test_docs_site_quality.py`: **6/6 passed**. +- `python scripts\check_broken_access_control.py application\single_app\route_backend_documents.py application\single_app\functions_orchestration_result_runtime.py application\single_app\functions_saved_analysis.py`: passed. +- `python scripts\check_xss_sinks.py application\single_app\static\js\chat\chat-citations.js application\v2_ui\src\components\chat\CitationChip.tsx application\v2_ui\src\lib\citations.ts`: passed. +- `node --check application\single_app\static\js\chat\chat-citations.js`: passed. +- The Impeccable mechanical detector returned no findings for the three changed + citation/progress components. + +The full Cosmos response-boundary gate passed **2,005 tests and 431 subtests**, +with **0 review gaps** across 1,094 owners and 425 doubles. The command used a +workspace-local argument-file directory and pytest scratch location: + +```powershell +$env:PYTEST_ADDOPTS='--basetemp=.test-artifacts\cosmos-pytest' +python -u -c "import pathlib,runpy,tempfile; tempfile.tempdir=str(pathlib.Path('.test-artifacts').resolve()); runpy.run_path('scripts/check_cosmos_response_boundaries.py',run_name='__main__')" --run-contracts +``` + +This executes the repository's unmodified `--run-contracts` checks with its +network guard, including the reviewed real-owner SDK evidence. + +Two unrelated checks exposed existing baseline failures: +`test_citation_revision_lookup_fix.py` still expects obsolete public-lookup +wiring, and `test_logging_tag_standardization.py` finds seven existing +mixed-case logging tags. The affected assertions/tags were reproduced against +unchanged `HEAD`; this change does not alter those behaviors or expand scope +to repair them. + +Targeted coverage is in: + +- `functional_tests/test_orchestration_attachment_native_evidence.py`: + real paged inventories, notes, threaded roots/replies, repeated cells on + different sheets, partial/empty inventories, stale versions/revisions, + denied/held sources, uncommitted references, and authenticated native lookup. +- `functional_tests/test_orchestration_native_citation_delivery.py`: + real producer-to-saved-message and replayed-delivery identity. +- `functional_tests/test_native_workbook_citation_parser.mjs`: + canonical/historical markers, unavailable/ambiguous metadata, exact locators, + and ordinary document/web/Microsoft 365 regression coverage. +- `functional_tests/test_saved_analysis_cleanup.py` and + `functional_tests/test_saved_analysis_cleanup_http.py`: + actual two-argument helper and deletion routes, including optimized Python. +- `ui_tests/test_v2_orchestration_recovery.py`, + `ui_tests/test_v2_orchestration_quiet_activity.py`, and + `ui_tests/test_v2_stream_citation_delivery.py`: + real controller/components, status-read failure/recovery, stale-read fences, + task/check-stage separation, desktop/mobile native evidence, keyboard opening, + saved reload, separate preview, and explicit denied evidence. + +These are local/offline regression tests, not production verification. No +deployment, production data mutation, run replay, or historical-answer +regeneration is part of this fix. diff --git a/docs/guides/inspect-excel-workbooks.md b/docs/guides/inspect-excel-workbooks.md index 0853468f9..6d01c957c 100644 --- a/docs/guides/inspect-excel-workbooks.md +++ b/docs/guides/inspect-excel-workbooks.md @@ -4,7 +4,7 @@ title: "Inspect Excel data, comments, and structure" description: "Read workbook feedback and metadata without confusing them with saved values or table calculations." section: "Guides" audience: user -version: "0.261.408" +version: "0.261.417" --- Implemented in version: **0.261.408**, tracked in @@ -39,6 +39,28 @@ totals, and grouped results over all intended records. ## Inspect a citation +Fixed/Implemented in version: **0.261.417**, recorded in +`application/single_app/config.py`. + +A native note or threaded-comment citation opens the exact retained reviewer +text first. Its evidence view identifies the worksheet/cell, author, available +timestamp, reply relationship, resolution status, and saved cell value. +Reviewer suggestions are not worksheet values; unavailable fields remain +unavailable and formulas are not recalculated. + +Use **Open workbook preview (separate from annotation evidence)** to inspect +the workbook independently. A table preview is not proof of the cited comment. +Replies and separate comments at the same cell keep distinct retained identities. +Inventory-coverage references describe inspected records, including zero comments; +they do not invent a worksheet cell or reviewer quotation. + +Opening native evidence requires access to both the original conversation/run +and the exact workbook version/revision that was inspected. A changed, held, +deleted, or inaccessible source can make the citation unavailable. Older +references work only when retained metadata identifies their exact evidence; +SimpleChat does not choose between same-cell comments or rerun an inventory +to guess what an old answer meant. + Open the workbook citation. The viewer identifies the selected worksheet, table/range, and header row. Choose another **Table** when several regions are present. Use **Previous records**, **Next records**, or **Go to record** for diff --git a/docs/guides/review-and-edit-orchestration-plans.md b/docs/guides/review-and-edit-orchestration-plans.md index 2ea197f61..4d01749d7 100644 --- a/docs/guides/review-and-edit-orchestration-plans.md +++ b/docs/guides/review-and-edit-orchestration-plans.md @@ -4,7 +4,7 @@ title: "Review and edit orchestration plans" description: "Refine proposed work with the planner before running it." section: "Guides" audience: user -version: "0.261.416" +version: "0.261.417" --- ## Decide what should run @@ -180,6 +180,14 @@ Fixed/Implemented in version: **0.261.416** (`application/single_app/config.py`). While work runs, Quiet activity keeps the conversation focused on your request. +Since **0.261.417**, the activity headline names the confirmed task and phase, +for example **Gathering: Read workbook comments** or **Reasoning: Prepare audit**. +A successful running-status check restores this progress; it is not an unknown +execution outcome. **Reconnecting to progress…** appears only when updates +cannot be confirmed and qualifies displayed tasks as last known. Reconnection +reads saved state without replaying tasks. **Saving final response** remains +distinct from a successfully published answer. + Expand the activity or open plan details to distinguish preparing an answer from checking it. **Stop** remains in the composer. Technical failure details stay in run inspection rather than taking over the answer. diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md index 0f98feca0..8b12f9192 100644 --- a/docs/reference/chat-controls.md +++ b/docs/reference/chat-controls.md @@ -4,13 +4,28 @@ title: "Chat interface controls" description: "Reference for every documented control in the SimpleChat chat interface." section: "Reference" audience: user -version: "0.261.416" +version: "0.261.417" --- ## How to use this reference Use this page when you can see a control in Chat but are not sure what it does or when to use it. The generated inventory lists 47 real controls plus 3 child sub-elements. The child elements `document-comparison-edit-btn-label`, `fork-conversation-button-label`, and `fork-conversation-button-spinner` are mentioned with their parent controls instead of receiving separate rows. +### Native workbook evidence + +Fixed/Implemented in version: **0.261.417** +(`application/single_app/config.py`). + +| Control | Purpose | Availability | +| --- | --- | --- | +| Native note/comment citation | Opens exact retained reviewer text and its worksheet/cell, author, reply metadata, and saved cell value. | A citation with an unambiguous retained native-evidence reference | +| Open workbook preview (separate from annotation evidence) | Opens worksheet data independently; the preview is not presented as the cited reviewer text. | Successfully authorized native-evidence view | + +Source access and the inspected workbook version are rechecked when evidence +opens. An unavailable historical reference stays unavailable rather than choosing +a newer comment at the same cell. See +[Inspect Excel data, comments, and structure]({{ '/guides/inspect-excel-workbooks/' | relative_url }}). + ## Conversation list {% include media.html src="reference/chat-controls-conversation-list.png" alt="Conversation list showing selection actions and the new-chat button." title="Conversation list" capture="Capture the conversation list with selection actions and the new-chat button visible. Redact conversation titles and user names." %} diff --git a/docs/reference/logging-tags.md b/docs/reference/logging-tags.md index 4f2d8abe9..2905bac41 100644 --- a/docs/reference/logging-tags.md +++ b/docs/reference/logging-tags.md @@ -267,6 +267,7 @@ for availability states and live-UI validation limits. - `[CHAT_UPLOAD_COLLABORATION_SHARING]` - `[CHAT_UPLOAD_WORKSPACE_CONTEXT]` - `[CITATION_EXTRACTION]` +- `[CITATION]` - `[CI_AUTH]` - `[CLAIMS]` - `[COLLABORATION]` diff --git a/functional_tests/cosmos_response_boundaries.json b/functional_tests/cosmos_response_boundaries.json index 4716d3818..7c81e36af 100644 --- a/functional_tests/cosmos_response_boundaries.json +++ b/functional_tests/cosmos_response_boundaries.json @@ -23244,7 +23244,7 @@ } ], "batches": [], - "module_sha256": "877eb258682a1dfce83b5cb076b30d8f90fda087af9b2979381e88d9032527b3" + "module_sha256": "021344807772180334c29350dff78e14b25ede7b525e1bac8864db458874d63b" }, "classification": "sdk-compatible", "contract": "The conversation-partitioned parameterized query returns plain JSON rows; artifact/native selectors remain query parameters and no point-response conversion is inferred.", @@ -23265,7 +23265,7 @@ ], "queries": [], "batches": [], - "module_sha256": "877eb258682a1dfce83b5cb076b30d8f90fda087af9b2979381e88d9032527b3" + "module_sha256": "021344807772180334c29350dff78e14b25ede7b525e1bac8864db458874d63b" }, "classification": "sdk-compatible", "contract": "SDK point mappings retain owner/id/ETag fields and pass through the participation authorization boundary; no strict-dict assumption or identity default is added.", @@ -23291,7 +23291,7 @@ ], "queries": [], "batches": [], - "module_sha256": "877eb258682a1dfce83b5cb076b30d8f90fda087af9b2979381e88d9032527b3" + "module_sha256": "021344807772180334c29350dff78e14b25ede7b525e1bac8864db458874d63b" }, "classification": "sdk-compatible", "contract": "SDK point mapping is checked against the exact artifact conversation and producer; context metadata is patched using IfNotModified and its retained ETag without copying a wrapper into a strict JSON consumer.", @@ -23312,7 +23312,7 @@ ], "queries": [], "batches": [], - "module_sha256": "877eb258682a1dfce83b5cb076b30d8f90fda087af9b2979381e88d9032527b3" + "module_sha256": "021344807772180334c29350dff78e14b25ede7b525e1bac8864db458874d63b" }, "classification": "sdk-compatible", "contract": "SDK message mappings are read only after conversation participation is authorized, checked for exact message/conversation identity, and remain mapping-compatible for saved readers.", @@ -23333,7 +23333,7 @@ ], "queries": [], "batches": [], - "module_sha256": "877eb258682a1dfce83b5cb076b30d8f90fda087af9b2979381e88d9032527b3" + "module_sha256": "021344807772180334c29350dff78e14b25ede7b525e1bac8864db458874d63b" }, "classification": "sdk-compatible", "contract": "The point response is returned as an SDK-compatible Mapping for private-conversation lineage checks, preserving identity and ETag.", @@ -23359,7 +23359,7 @@ ], "queries": [], "batches": [], - "module_sha256": "877eb258682a1dfce83b5cb076b30d8f90fda087af9b2979381e88d9032527b3" + "module_sha256": "021344807772180334c29350dff78e14b25ede7b525e1bac8864db458874d63b" }, "classification": "sdk-compatible", "contract": "SDK point metadata and actor ownership are checked before a dictionary replacement; CAS preserves a concurrent assistant pointer and the function returns only the previous pointer.", @@ -23387,7 +23387,7 @@ ], "queries": [], "batches": [], - "module_sha256": "877eb258682a1dfce83b5cb076b30d8f90fda087af9b2979381e88d9032527b3" + "module_sha256": "021344807772180334c29350dff78e14b25ede7b525e1bac8864db458874d63b" }, "classification": "sdk-compatible", "contract": "SDK point metadata is checked for file/conversation/native-run/producer binding before nested metadata set operations with IfNotModified and the stored ETag; returned write documents are unused.", @@ -23409,7 +23409,7 @@ ], "queries": [], "batches": [], - "module_sha256": "877eb258682a1dfce83b5cb076b30d8f90fda087af9b2979381e88d9032527b3" + "module_sha256": "021344807772180334c29350dff78e14b25ede7b525e1bac8864db458874d63b" }, "classification": "sdk-compatible", "contract": "Current authorized SDK mapping is unpacked into a replacement body; IfNotModified uses the current ETag and 404/412 cannot overwrite a concurrent deletion fence.", @@ -30792,7 +30792,7 @@ } ], "batches": [], - "module_sha256": "70ab9dc1019d5791b7f359a10cda466e6a3c76dc3b67d5625af50976a7e86eb1" + "module_sha256": "3d3073c94f5a6e23582a99f7a9e54ea79500deb428ec64a167e4c806c7616764" }, "classification": "sdk-compatible", "contract": "Fallback source discovery materializes plain query rows before current-revision selection; the access-index success path retains its owner-provided document list.", @@ -30818,7 +30818,7 @@ ], "queries": [], "batches": [], - "module_sha256": "70ab9dc1019d5791b7f359a10cda466e6a3c76dc3b67d5625af50976a7e86eb1" + "module_sha256": "3d3073c94f5a6e23582a99f7a9e54ea79500deb428ec64a167e4c806c7616764" }, "classification": "sdk-compatible", "contract": "The SDK document is field-read and mutated for sharing approval; upsert returns CosmosDict, accepted by isinstance(dict), and that persisted mapping with fresh ETag is passed to index synchronization. Browser JSON is projected separately.", @@ -30839,7 +30839,7 @@ } ], "batches": [], - "module_sha256": "70ab9dc1019d5791b7f359a10cda466e6a3c76dc3b67d5625af50976a7e86eb1" + "module_sha256": "3d3073c94f5a6e23582a99f7a9e54ea79500deb428ec64a167e4c806c7616764" }, "classification": "sdk-compatible", "contract": "Bulk-tag source lookups consume plain query rows, check document ownership and pass selected ids to the document mutation owner; local discovery introduces no SDK point-to-strict conversion.", @@ -30860,7 +30860,7 @@ } ], "batches": [], - "module_sha256": "70ab9dc1019d5791b7f359a10cda466e6a3c76dc3b67d5625af50976a7e86eb1" + "module_sha256": "3d3073c94f5a6e23582a99f7a9e54ea79500deb428ec64a167e4c806c7616764" }, "classification": "sdk-compatible", "contract": "Tag deletion iterates plain query rows and their tag lists before delegating mutations; query rows are not SDK point mappings.", @@ -30881,7 +30881,7 @@ } ], "batches": [], - "module_sha256": "70ab9dc1019d5791b7f359a10cda466e6a3c76dc3b67d5625af50976a7e86eb1" + "module_sha256": "3d3073c94f5a6e23582a99f7a9e54ea79500deb428ec64a167e4c806c7616764" }, "classification": "sdk-compatible", "contract": "The legacy count fallback consumes a scalar query result, while document/index listing projections remain with their existing owners; no point wrapper is treated as an exact plain document.", @@ -30902,7 +30902,7 @@ ], "queries": [], "batches": [], - "module_sha256": "70ab9dc1019d5791b7f359a10cda466e6a3c76dc3b67d5625af50976a7e86eb1" + "module_sha256": "3d3073c94f5a6e23582a99f7a9e54ea79500deb428ec64a167e4c806c7616764" }, "classification": "sdk-compatible", "contract": "The SDK document supplies owner and sharing-list fields for removal checks before delegated mutation; no exact-dict domain admission occurs on the point value.", @@ -30928,7 +30928,7 @@ ], "queries": [], "batches": [], - "module_sha256": "70ab9dc1019d5791b7f359a10cda466e6a3c76dc3b67d5625af50976a7e86eb1" + "module_sha256": "3d3073c94f5a6e23582a99f7a9e54ea79500deb428ec64a167e4c806c7616764" }, "classification": "sdk-compatible", "contract": "The document CosmosDict supplies user_id and shared-user fields for owner authorization; the refreshed SDK mapping is passed to field-based notification projection, while sharing persistence remains with its document owner.", @@ -30949,7 +30949,7 @@ ], "queries": [], "batches": [], - "module_sha256": "70ab9dc1019d5791b7f359a10cda466e6a3c76dc3b67d5625af50976a7e86eb1" + "module_sha256": "3d3073c94f5a6e23582a99f7a9e54ea79500deb428ec64a167e4c806c7616764" }, "classification": "sdk-compatible", "contract": "The point document mapping is checked through user_id and shared-user fields before the document owner removes sharing; JSON responses contain explicit result fields, not the SDK wrapper.", @@ -30970,7 +30970,7 @@ } ], "batches": [], - "module_sha256": "70ab9dc1019d5791b7f359a10cda466e6a3c76dc3b67d5625af50976a7e86eb1" + "module_sha256": "3d3073c94f5a6e23582a99f7a9e54ea79500deb428ec64a167e4c806c7616764" }, "classification": "sdk-compatible", "contract": "Tag rename discovery consumes plain query dictionaries and field-based tag/owner values before existing document mutation calls.", @@ -30997,7 +30997,7 @@ } ], "batches": [], - "module_sha256": "70ab9dc1019d5791b7f359a10cda466e6a3c76dc3b67d5625af50976a7e86eb1" + "module_sha256": "3d3073c94f5a6e23582a99f7a9e54ea79500deb428ec64a167e4c806c7616764" }, "classification": "sdk-compatible", "contract": "The partitioned SDK conversation is checked through user_id before message discovery. File query rows stay plain, supply chunk/content fields and produce an explicit plain JSON content response.", diff --git a/functional_tests/test_native_workbook_citation_parser.mjs b/functional_tests/test_native_workbook_citation_parser.mjs new file mode 100644 index 000000000..af3604144 --- /dev/null +++ b/functional_tests/test_native_workbook_citation_parser.mjs @@ -0,0 +1,64 @@ +// test_native_workbook_citation_parser.mjs +// Version: 0.261.417 +// Implemented in: 0.261.417 +// Execute the real parser against exact retained and ambiguous native evidence. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import './test_support/tsResolve.mjs'; + +const { parseCitations } = await import('../application/v2_ui/src/lib/citations.ts'); +const id = 'workbook:native-comments:1'; +const record = { + citation_id: id, document_id: 'workbook', file_name: 'review.xlsx', + location_label: 'Location', location_value: 'Budget!B4', + native_evidence: { producer: { user_id: 'owner', conversation_id: 'conversation', run_id: 'run' }, manifest_sha256: 'a'.repeat(64) }, +}; + +for (const label of ['Location', 'Worksheet cell']) { + test(`${label} marker uses explicit retained document identity`, () => { + const parsed = parseCitations(`Check (Source: review.xlsx, ${label}: Budget!B4) [#${id}]`, [record]); + assert.equal(parsed.groups.length, 1); + assert.equal(parsed.groups[0].citations[0].documentId, 'workbook'); + assert.equal(parsed.groups[0].citations[0].conversationId, 'conversation'); + assert.deepEqual(parsed.groups[0].citations[0].nativeEvidence, record.native_evidence); + assert.equal(parsed.groups[0].citations[0].locationValue, 'Budget!B4'); + }); +} + +test('retained location cannot be rewritten by model prose', () => { + const parsed = parseCitations(`(Source: review.xlsx, Location: Other!A99) [#${id}]`, [record]); + assert.equal(parsed.groups[0].citations[0].locationValue, 'Budget!B4'); +}); + +test('missing or conflicting retained metadata is visible as unavailable, never decoded as a document', () => { + for (const records of [[], [{ ...record, native_evidence: undefined }], + [{ ...record, document_id: undefined }], [{ ...record, document_id: ' ' }], [ + record, { ...record, native_evidence: { ...record.native_evidence, manifest_sha256: 'b'.repeat(64) } }, + ]]) { + const parsed = parseCitations(`(Source: review.xlsx, Worksheet cell: Budget!B4) [#${id}]`, records); + assert.equal(parsed.groups.length, 0); + assert.match(parsed.text, /Source unavailable/); + assert.doesNotMatch(parsed.text, /native-comments/); + } +}); + +test('orphan native ids resolve only with authentic retained metadata', () => { + const known = parseCitations(`Review [#${id}]`, [record]); + assert.equal(known.groups.length, 1); + const unknown = parseCitations(`Review [#${id}]`); + assert.match(unknown.text, /Source unavailable/); +}); + +test('ordinary page sheet web and Microsoft 365 identities remain unchanged', () => { + for (const [marker, kind, documentId] of [ + ['(Source: report.pdf, Page: 2) [#document_2]', 'document', 'document'], + ['(Source: report.xlsx, Sheet: Budget) [#document_3]', 'document', 'document'], + ['(Source: https://example.com/a, Location: Article) [#web_1]', 'web', 'web'], + ['(Source: Mail, Location: Inbox) [#m365-0123456789abcdef]', 'm365', 'm365-0123456789abcdef'], + ]) { + const parsed = parseCitations(marker); + assert.equal(parsed.groups[0].kind, kind); + assert.equal(parsed.groups[0].citations[0].documentId, documentId); + } +}); diff --git a/functional_tests/test_orchestration_attachment_native_evidence.py b/functional_tests/test_orchestration_attachment_native_evidence.py index 2f420cefa..f6842b02c 100644 --- a/functional_tests/test_orchestration_attachment_native_evidence.py +++ b/functional_tests/test_orchestration_attachment_native_evidence.py @@ -1,7 +1,7 @@ # test_orchestration_attachment_native_evidence.py """Real planning, inventory paging, retained evidence and composition regressions. -Version: 0.261.416 +Version: 0.261.417 Implemented in: 0.261.416 Only external storage/model I/O is replaced; no provider calls are made. """ @@ -17,17 +17,26 @@ from azure.core import MatchConditions from openpyxl import Workbook from openpyxl.comments import Comment +from flask import Blueprint +from types import SimpleNamespace from test_orchestration_harness_routes import modules, real_http_harness from test_orchestration_dependency_runtime import runtime, execute, compose, binding, source_input from test_excel_inventory_persistence import inventory_store, review_workbook from test_support.orchestration_harness_execution import compose_step, input_binding from test_support.orchestration_results import source +from functions_workflow_result_store import WorkflowResultIntegrityError __all__ = ["modules", "real_http_harness", "runtime", "inventory_store", "review_workbook"] +@pytest.fixture(scope="module", autouse=True) +def offline_native_modules(modules): + """Native tests must also work alone, without an earlier image test importing config.""" + yield modules + + def frames(response): return [ json.loads(part.partition("data:")[2]) @@ -288,6 +297,105 @@ def test_native_workbook_comments_reach_retained_evidence_and_composition( assert "Set cap to 19500." in json.dumps(payload) assert "Keep cap at 15000." in json.dumps(payload) assert task.output("prepared").completeness.status == "complete" + citations = runtime.composition.read_result_document_citations(case.context, task.output("prepared")) + annotated = [citation for citation in citations if citation.get("evidence_kind") == "native_excel_annotation"] + assert len(annotated) == 5 + assert all(citation["location_label"] == "Location" for citation in annotated) + assert all(citation["document_id"] == "document-1" and citation["native_evidence"] for citation in annotated) + reader = importlib.import_module("functions_orchestration_result_runtime") + contracts = importlib.import_module("functions_orchestration_result_contracts") + for citation in citations: + evidence = reader.read_native_citation_evidence( + case.fixture.restart(), contracts.ResultRef.from_dict(citation["native_evidence"]), + citation_id=citation["citation_id"], document_id=citation["document_id"], + ) + if citation.get("evidence_kind") == "native_excel_annotation": + assert evidence["native_annotation"].get("id") == citation.get("annotation_id") + assert evidence["native_annotation"]["sheet"] == citation["sheet_name"] + else: + assert evidence["native_coverage"]["returned_records"] == 5 + assert "native_annotation" not in evidence + assert "native_evidence" not in json.dumps(payload) + + +@pytest.mark.parametrize("unavailable", ["changed", "revision", "denied", "held", "foreign_document", "foreign_owner", "digest"]) +def test_native_citation_lookup_rejects_stale_or_untrusted_evidence( + runtime, monkeypatch, inventory_store, review_workbook, unavailable, +): + step, _, _ = configure_inventory(runtime, monkeypatch, inventory_store, review_workbook) + case = runtime.make([step]) + case.fixture.sources["document-1"] = {**source(), "file_name": "review.xlsx"} + result = execute(runtime, case) + assert result["status"] == "completed" + task = case.context.task_results["comments"] + reader = importlib.import_module("functions_orchestration_result_runtime") + reference = task.output("prepared") + citation = reader.read_result_document_citations(case.context, reference)[1] + document_id = "document-1" + if unavailable == "changed": + case.fixture.sources[document_id]["source_version"] = 2 + elif unavailable == "revision": + case.fixture.sources[document_id]["source_revision"] = "new-revision" + elif unavailable == "denied": + case.fixture.denied.add(document_id) + elif unavailable == "held": + case.fixture.held.add(document_id) + elif unavailable == "foreign_document": + document_id = "another-document" + elif unavailable == "foreign_owner": + case.fixture.conversation["user_id"] = "another-owner" + else: + from dataclasses import replace + reference = replace(reference, manifest_sha256="a" * 64) + with pytest.raises((PermissionError, reader.ResultContractError, reader.ScreeningError, WorkflowResultIntegrityError)): + reader.read_native_citation_evidence( + case.fixture.restart(), reference, citation_id=citation["citation_id"], document_id=document_id, + ) + + +def test_native_citation_http_lookup_uses_retained_result_not_search( + runtime, real_http_harness, monkeypatch, inventory_store, review_workbook, +): + step, _, _ = configure_inventory(runtime, monkeypatch, inventory_store, review_workbook) + case = runtime.make([step]) + case.fixture.sources["document-1"] = {**source(), "file_name": "review.xlsx"} + result = execute(runtime, case) + assert result["status"] == "completed" + reader = importlib.import_module("functions_orchestration_result_runtime") + citation = reader.read_result_document_citations(case.context, case.context.task_results["comments"].output("prepared"))[1] + activity = importlib.import_module("functions_activity_logging") + for name in ("log_document_upload", "log_document_metadata_update_transaction"): + monkeypatch.setattr(activity, name, lambda *args, **kwargs: None, raising=False) + route = importlib.import_module("route_backend_documents") + bootstrap = importlib.import_module("functions_orchestration_bootstrap") + screening = importlib.import_module("content_screening.access") + monkeypatch.setattr(screening, "_read_authorized_document", lambda document_id, user_id, **kwargs: case.fixture.metadata( + document_id, user_id, kwargs.get("group_id"), kwargs.get("public_workspace_id"), + )) + monkeypatch.setattr(bootstrap, "build_orchestration_services", lambda user_id, conversation_id: SimpleNamespace(results=case.fixture.restart())) + monkeypatch.setattr(route, "_resolve_citation_chunk", lambda *args, **kwargs: pytest.fail("Native evidence must not enter Search.")) + blueprint = Blueprint("native_citation_documents", __name__) + auth = importlib.import_module("functions_authentication") + blueprint.before_request(auth.user_required_blueprint()) + route.register_route_backend_documents(blueprint) + real_http_harness.app.register_blueprint(blueprint) + body = { + "citation_id": citation["citation_id"], "document_id": "document-1", + "conversation_id": "conversation-1", "native_evidence": citation["native_evidence"], + } + response = real_http_harness.client.post("/api/get_citation", json=body) + assert response.status_code == 200, response.get_data(as_text=True) + assert response.json["native_annotation"]["id"] == citation["annotation_id"] + missing = {key: value for key, value in body.items() if key != "native_evidence"} + response = real_http_harness.client.post("/api/get_citation", json=missing) + assert response.status_code == 404 + foreign = deepcopy(body) + foreign["native_evidence"]["producer"]["user_id"] = "foreign-owner" + response = real_http_harness.client.post("/api/get_citation", json=foreign) + assert response.status_code == 403 + case.fixture.denied.add("document-1") + response = real_http_harness.client.post("/api/get_citation", json=body) + assert response.status_code in (403, 404) and "native_annotation" not in response.json @pytest.mark.parametrize("annotated", [False, True]) diff --git a/functional_tests/test_orchestration_native_citation_delivery.py b/functional_tests/test_orchestration_native_citation_delivery.py new file mode 100644 index 000000000..0d1d97bc8 --- /dev/null +++ b/functional_tests/test_orchestration_native_citation_delivery.py @@ -0,0 +1,106 @@ +# test_orchestration_native_citation_delivery.py +"""Native workbook inventory to composition, saved message and replayed delivery. + +Version: 0.261.417 +Implemented in: 0.261.417 +Real producers/readers/publication use the existing offline external-I/O harness. +""" + +from copy import deepcopy +import importlib +import json + +import pytest + +from functions_citation_tracking import build_cited_source_subsets +from functions_orchestration_execution import refresh_harness_delivery +from functions_orchestration_result_contracts import ResultRef +from functions_orchestration_result_runtime import read_native_citation_evidence +from test_excel_inventory_persistence import inventory_store, review_workbook +from test_orchestration_attachment_native_evidence import configure_inventory +from test_orchestration_harness_execution import harness, initialized_application +from test_support.orchestration_harness_execution import compose_step, decoded_frames, input_binding + + +__all__ = ["harness", "initialized_application", "inventory_store", "review_workbook"] + + +@pytest.mark.parametrize("delivery", ["execute", "refresh"]) +def test_native_evidence_identity_survives_composition_saved_publication_and_reconciliation( + harness, monkeypatch, inventory_store, review_workbook, delivery, +): + step, _, document = configure_inventory(None, monkeypatch, inventory_store, review_workbook) + document["_etag"] = "source-revision-1" + screening = importlib.import_module("content_screening.access") + mixed = importlib.import_module("functions_mixed_source_orchestration") + + def read_document(document_id, user_id, **kwargs): + if document_id != "document-1" or user_id != "owner": + raise PermissionError("The selected workbook is unavailable.") + return deepcopy(document) + + monkeypatch.setattr(screening, "_read_authorized_document", read_document) + monkeypatch.setattr(mixed, "_default_document_context_batch_resolver", lambda **kwargs: [ + {"document": read_document(identifier, kwargs["user_id"]), "scope": "personal", + "group_id": None, "public_workspace_id": None} + for identifier in kwargs["document_ids"] + ]) + answer = [] + + def reply(): + payload = json.loads(harness.model_calls[-1]["messages"][-1]["content"]) + citations = payload["inputs"]["comments"]["document_citations"] + root = next(citation for citation in citations if citation.get("annotation_id") == "{root-budget}") + assert root["location_label"] == "Location" + assert "native_evidence" not in root + answer.append("The reviewer requests 19500. " + root["citation"]) + return answer[-1] + + harness.create([ + step, compose_step(inputs={"comments": {"binding": input_binding("comments", "prepared"), "allow_partial": False}}), + ], replies=[reply], final_response=input_binding("prepare"), user_message="Read the workbook comments.", + resolved_message="Read the workbook comments.", seeds={"document_ids": ["document-1"], "doc_scope": "personal"}, + original_seeds={"document_ids": ["document-1"], "doc_scope": "personal"}) + execution = harness.prepare() + try: + if delivery == "execute": + frames = execution.execute() + else: + result = harness.run_engine(execution) + assert result["status"] == "completed", result + message_id = f'assistant_orchestration_{harness.execution.fingerprint("run-1")[:40]}' + execution.lease.publish_message({ + "id": message_id, "conversation_id": "conversation-1", "role": "assistant", + "content": "Awaiting retained delivery.", "timestamp": "2026-11-06T12:00:00+00:00", + "metadata": {"orchestration": {"run_id": "run-1", "status": "completed"}}, + **execution.answer_model.metadata(), + }) + record = execution.lease.update({ + "citations": [], "assistant_message_id": message_id, + "assistant_message_created_at": "2026-11-06T12:00:00+00:00", + "message_saved": True, "finalization_status": "saved", + }) + services = harness.services() + with harness.publication_only(services): + frames = refresh_harness_delivery(record, services=services, settings=harness.settings, lease=execution.lease) + done = decoded_frames(frames)[-1] + assert done["status"] == "completed", done + message = harness.assistant_messages()[0] + citations = message["hybrid_citations"] + assert citations == done["hybrid_citations"] == harness.read()["citations"] + assert message["content"] == done["full_content"] == answer[-1] + root = next(citation for citation in citations if citation.get("annotation_id") == "{root-budget}") + assert root["document_id"] == "document-1" and root["source_version"] == 1 + evidence = read_native_citation_evidence( + harness.services().results, ResultRef.from_dict(root["native_evidence"]), + citation_id=root["citation_id"], document_id=root["document_id"], + ) + assert evidence["native_annotation"]["text"] == "Set cap to 19500." + assert evidence["native_annotation"]["cell_value"] == 15000 + tracked = build_cited_source_subsets(message["content"], citations)["cited_hybrid_citations"] + assert len(tracked) == 1 and tracked[0]["native_evidence"] == root["native_evidence"] + ambiguous = build_cited_source_subsets("(Source: review.xlsx, Location: Budget!B4)", citations) + assert ambiguous["cited_hybrid_citations"] == [] + assert len(harness.model_calls) == 1 and harness.blobs.file_uploads == 0 + finally: + execution.close() diff --git a/functional_tests/test_saved_analysis_cleanup.py b/functional_tests/test_saved_analysis_cleanup.py index 767e1ec25..4f711f840 100644 --- a/functional_tests/test_saved_analysis_cleanup.py +++ b/functional_tests/test_saved_analysis_cleanup.py @@ -1,7 +1,7 @@ # test_saved_analysis_cleanup.py """ Functional tests for scoped saved Analyze cleanup. -Version: 0.261.109 +Version: 0.261.417 Implemented in: 0.261.109 Deleting a reply must not delete a referenced workflow or mirrored origin. @@ -21,10 +21,10 @@ def test_deleting_origin_cleans_only_its_real_chat_result(saved_chat): saved.cleanup_chat_analysis_messages( [saved_chat["message"]], conversation_id="conversation-1", owner_user_id="owner", delete_result=lambda *args: removed.append(args), - delete_thoughts=lambda *args: thoughts.append(args), + delete_thoughts=lambda message_id, user_id: thoughts.append((message_id, user_id)), ) assert removed == [("owner", "conversation-1", "assistant-1")] - assert thoughts == [("conversation-1", "assistant-1", "owner")] + assert thoughts == [("assistant-1", "owner")] def test_deleting_mirror_does_not_delete_the_original_analysis(saved_chat): @@ -32,7 +32,7 @@ def test_deleting_mirror_does_not_delete_the_original_analysis(saved_chat): saved.cleanup_chat_analysis_messages( [message], conversation_id="another-chat", owner_user_id="reader", delete_result=lambda *args: pytest.fail("The original result belongs to another message."), - delete_thoughts=lambda *args: None, + delete_thoughts=lambda message_id, user_id: None, ) @@ -44,7 +44,7 @@ def test_workflow_results_are_not_deleted_with_a_chat_reference(saved_chat): saved.cleanup_chat_analysis_messages( [message], conversation_id="conversation-1", owner_user_id="owner", delete_result=lambda *args: pytest.fail("Workflow result retention is owned by the workflow."), - delete_thoughts=lambda *args: None, + delete_thoughts=lambda message_id, user_id: None, ) @@ -74,7 +74,7 @@ def failed(*args): with pytest.raises(ConnectionError): saved.cleanup_chat_analysis_messages( [saved_chat["message"]], conversation_id="conversation-1", owner_user_id="owner", - delete_result=failed, delete_thoughts=lambda *args: None, + delete_result=failed, delete_thoughts=lambda message_id, user_id: None, ) diff --git a/functional_tests/test_saved_analysis_cleanup_http.py b/functional_tests/test_saved_analysis_cleanup_http.py new file mode 100644 index 000000000..7f5e451f5 --- /dev/null +++ b/functional_tests/test_saved_analysis_cleanup_http.py @@ -0,0 +1,144 @@ +# test_saved_analysis_cleanup_http.py +"""Real thought helper and deletion-route regressions with scoped SDK-shaped storage. + +Version: 0.261.417 +Implemented in: 0.261.417 +Fresh normal/optimized processes block network and restore every injected callback. +""" + +import os +from pathlib import Path +import subprocess +import sys + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +PROBE = r''' +from contextlib import ExitStack +from copy import deepcopy +from unittest.mock import patch +from flask import Blueprint, Flask +from test_support.offline_bootstrap import offline_app_imports +from test_support.orchestration_results import ResultContainer + +def check(condition, message): + if not condition: + raise AssertionError(message) + +def container(partition): + value = ResultContainer() + value.partition_field = partition + return value + +with offline_app_imports(), ExitStack() as stack: + import functions_authentication as auth + import functions_thoughts as thoughts + import functions_workflow_result_store as result_store + import route_backend_conversations as route + + messages = container("conversation_id") + conversations = container("id") + archived = container("conversation_id") + notes = container("user_id") + conversations.create_item({"id": "conversation", "user_id": "owner"}) + settings = {"enable_conversation_archiving": False} + removed = [] + result_failed = [False] + + original_messages_query = messages.query_items + def message_query(*args, **kwargs): + rows = original_messages_query(*args, **kwargs) + parameters = {p["name"]: p["value"] for p in kwargs.get("parameters", [])} + return [row for row in rows if "@message_id" not in parameters or row["id"] == parameters["@message_id"]] + + original_notes_query = notes.query_items + def notes_query(*args, **kwargs): + rows = original_notes_query(*args, **kwargs) + parameters = {p["name"]: p["value"] for p in kwargs.get("parameters", [])} + return [row for row in rows if row.get("message_id") == parameters["@msg_id"]] + + def delete_result(user_id, conversation_id, message_id): + if result_failed[0]: + raise ConnectionError("Required result cleanup failed.") + removed.append((user_id, conversation_id, message_id)) + + for target, name, value in [ + (messages, "query_items", message_query), (notes, "query_items", notes_query), + (thoughts, "cosmos_thoughts_container", notes), + (result_store, "delete_chat_analysis_results", delete_result), + (route, "get_settings", lambda: settings), + (route, "cosmos_messages_container", messages), + (route, "cosmos_conversations_container", conversations), + (route, "cosmos_archived_messages_container", archived), + (route, "_collect_child_message_documents", lambda *args: []), + (route, "_rebuild_authorized_personal_conversation_used_documents", lambda *args: None), + (route, "_invalidate_conversation_cache_after_message_mutation", lambda *args: None), + ]: + stack.enter_context(patch.object(target, name, value)) + app = Flask("analysis-cleanup-http") + app.config.update(TESTING=True, SECRET_KEY="cleanup-http-test-secret") + blueprint = Blueprint("cleanup_conversations", __name__) + blueprint.before_request(auth.user_required_blueprint()) + route.register_route_backend_conversations(blueprint) + app.register_blueprint(blueprint) + client = app.test_client() + with client.session_transaction() as session: + session["user"] = {"oid": "owner", "roles": ["Admin"]} + + for archiving in (False, True): + settings["enable_conversation_archiving"] = archiving + for scenario in ("owned", "mirror", "workflow", "ordinary", "failed"): + messages.items.clear() + notes.items.clear() + archived.items.clear() + removed.clear() + result_failed[0] = scenario == "failed" + binding = {"kind": "chat", "user_id": "owner", "conversation_id": "conversation", "message_id": "assistant"} + if scenario == "mirror": + binding["message_id"] = "upstream-assistant" + elif scenario == "workflow": + binding = {"kind": "workflow", "workflow_id": "workflow", "run_id": "run", "task_id": "task"} + metadata = {"user_info": {"user_id": "owner"}} + if scenario != "ordinary": + metadata.update(saved_analysis={"binding": binding}, source_thought_user_id="participant") + messages.create_item({"id": "assistant", "conversation_id": "conversation", "role": "assistant", "metadata": metadata}) + for owner, message_id, identifier in [ + ("owner", "assistant", "owner-note"), ("participant", "assistant", "participant-note"), + ("foreign", "assistant", "foreign-note"), ("owner", "another-message", "unrelated-note"), + ]: + notes.create_item({"id": identifier, "user_id": owner, "message_id": message_id}) + response = client.delete("/api/message/assistant", json={}) + check(response.status_code == (500 if scenario == "failed" else 200), f"{archiving}/{scenario}: {response.get_data(as_text=True)}") + if scenario == "failed": + check(("conversation", "assistant") in messages.items and not archived.items, "Failed cleanup must not delete/archive the message.") + continue + check(response.json["deleted_message_ids"] == ["assistant"], "The route must report the exact deleted message.") + if scenario != "ordinary": + check(("owner", "owner-note") not in notes.items and ("participant", "participant-note") not in notes.items, "The real two-argument helper must clean legitimate owner partitions.") + else: + check(("owner", "owner-note") in notes.items, "Ordinary messages must not invoke analysis cleanup.") + check(("foreign", "foreign-note") in notes.items and ("owner", "unrelated-note") in notes.items, "Thought deletion must preserve foreign owners and other messages.") + check(removed == ([("owner", "conversation", "assistant")] if scenario == "owned" else []), "Mirrored/workflow references must not delete upstream results.") + if archiving: + check(messages.read_item("assistant", "conversation")["metadata"]["is_deleted"], "Archived messages stay soft-deleted.") + check(("conversation", "assistant") in archived.items, "Archiving must retain the masked message.") + else: + check(("conversation", "assistant") not in messages.items, "Permanent deletion must remove the message.") + print("SAVED_ANALYSIS_CLEANUP_HTTP_OK") +''' + + +@pytest.mark.parametrize("optimized", [False, True]) +def test_real_helper_and_delete_route_respect_partitions_and_cleanup_failure(optimized): + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join([ + str(ROOT / "application" / "single_app"), str(ROOT / "functional_tests"), + ]) + result = subprocess.run( + [sys.executable, *(["-O"] if optimized else []), "-c", PROBE], + cwd=ROOT, env=env, capture_output=True, text=True, timeout=120, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "SAVED_ANALYSIS_CLEANUP_HTTP_OK" in result.stdout diff --git a/ui_tests/test_v2_orchestration_quiet_activity.py b/ui_tests/test_v2_orchestration_quiet_activity.py index 7a67923b9..9c516514a 100644 --- a/ui_tests/test_v2_orchestration_quiet_activity.py +++ b/ui_tests/test_v2_orchestration_quiet_activity.py @@ -1,7 +1,7 @@ # test_v2_orchestration_quiet_activity.py """ Real shipped component/controller tests for quiet activity, checking and answer acceptance. -Version: 0.261.416 +Version: 0.261.417 Implemented in: 0.261.416 Uses production CSS and the existing Python/Azure Playwright connection fixtures. @@ -104,7 +104,7 @@ def test_quiet_activity_has_light_steps_and_honest_checking(editor_ui, width, da {"plan": plan, "report": report()}, ) activity = page.get_by_test_id("quiet-activity") - expect(activity.get_by_role("status")).to_have_text("Checking answer") + expect(activity.get_by_role("status")).to_have_text("Reasoning: Write the comparison") expect(activity.get_by_text("private_criterion")).to_have_count(0) expect(activity.get_by_text("private_checker_diagnostic")).to_have_count(0) expect(activity.get_by_role("button", name="Stop execution")).to_have_count(0) @@ -123,7 +123,8 @@ def test_quiet_activity_has_light_steps_and_honest_checking(editor_ui, width, da { status: 'running', task_completeness: report })""", {"plan": plan, "report": report(status, corrections=corrections)}, ) - expect(activity.get_by_role("status")).to_have_text(text) + expect(activity.get_by_role("status")).to_have_text("Reasoning: Write the comparison") + expect(activity.get_by_text(text, exact=True)).to_be_visible() expect(activity.get_by_text("Answer prepared — Completed")).to_be_visible() geometry = activity.evaluate( """(element) => ({border: getComputedStyle(element).borderTopWidth, diff --git a/ui_tests/test_v2_orchestration_recovery.py b/ui_tests/test_v2_orchestration_recovery.py index 7da5f1bfc..379851a73 100644 --- a/ui_tests/test_v2_orchestration_recovery.py +++ b/ui_tests/test_v2_orchestration_recovery.py @@ -1,7 +1,7 @@ # test_v2_orchestration_recovery.py """ Real-component browser coverage for orchestration failure and checkpoint recovery. -Version: 0.261.416 +Version: 0.261.417 Implemented in: 0.261.105 Earlier-version run refusals covered in: 0.261.139 Settled runs whose stored plan still reads running covered in: 0.261.141 @@ -66,6 +66,7 @@ def __init__(self, assets): self.preparations = {} self.prepare_mode = "success" self.stream_mode = "failure" + self.status_reads_fail = False self.waiting = [] # The failed step's failure; Microsoft 365 recovery tests replace it. self.failure = {"code": "step_timeout", "message": FAILURE} @@ -276,6 +277,9 @@ def handle(self, route): ]}) return if path.startswith(editor_tests.RUNS + "/"): + if self.status_reads_fail: + route.fulfill(status=503, json={"error": "Progress temporarily unavailable."}) + return run_id = path.split("/")[5] if path.endswith("/steps"): route.fulfill(json={"steps": self.steps.get(run_id, [])}) @@ -513,7 +517,7 @@ def test_transport_loss_is_unknown_until_explicit_stop_calls_backend(recovery_ui api.stream_mode = "transport" mount_recovery(page, api) page.get_by_role("button", name="Approve and run the plan").click() - expect(page.get_by_text("Execution status", exact=False).first).to_be_visible() + expect(page.get_by_test_id("quiet-activity").get_by_role("status")).to_be_visible() expect(page.get_by_role("button", name="Retry from failed step")).to_have_count(0) assert not api.calls("/retry") assert not any("/cancel/" in request["path"] for request in api.requests) @@ -524,6 +528,66 @@ def test_transport_loss_is_unknown_until_explicit_stop_calls_backend(recovery_ui assert main_state(page)["history"][CONVERSATION][0]["status"] == "cancelled" +@pytest.mark.parametrize("disconnected", [False, True]) +def test_successful_running_poll_restores_confirmed_task_without_replay(recovery_ui, disconnected): + page, api = recovery_ui + api.stream_mode = "transport" + api.status_reads_fail = disconnected + run_id = api.plan["run_id"] + api.steps[run_id] = [{"step_id": "read", "status": "running", "summary": "Reading workbook comments"}] + page.clock.install() + mount_recovery(page, api) + page.get_by_role("button", name="Approve and run the plan").click() + activity = page.get_by_test_id("quiet-activity") + if disconnected: + expect(activity.get_by_role("status")).to_contain_text("Reconnecting to progress") + api.status_reads_fail = False + page.evaluate("""({conversation, run}) => window.OrchHarness.controller + .reconcileOrchestrationRun(conversation, run)""", {"conversation": CONVERSATION, "run": run_id}) + expect(activity.get_by_role("status")).to_have_text("Gathering: Read quarterly reports") + assert page.evaluate("""(id) => window.OrchHarness.stores.orchestration.useOrchestrationStore + .getState().runRecovery[id].transportUnknown""", run_id) is False + expect(page.get_by_text("Execution status not confirmed")).to_have_count(0) + expect(page.get_by_role("button", name="Retry from failed step")).to_have_count(0) + assert len(api.calls("/run")) == 1 and not api.calls("/retry") + assert not any("/cancel/" in request["path"] for request in api.requests) + + +def test_fresh_scoped_step_clears_disconnect_and_fences_delayed_running_poll(recovery_ui): + page, api = recovery_ui + api.stream_mode = "transport" + page.clock.install() + mount_recovery(page, api) + page.get_by_role("button", name="Approve and run the plan").click() + run_id = api.plan["run_id"] + held = [] + + def delay_steps(route): + if urlsplit(route.request.url).path.endswith("/steps"): + held.append(route) + else: + route.fallback() + + page.route("**/api/v2/orchestration/runs/**", delay_steps) + with page.expect_request(lambda request: urlsplit(request.url).path.endswith("/steps")): + page.evaluate("""({conversation, run}) => { + window.pollFinished = false; + void window.OrchHarness.controller.reconcileOrchestrationRun(conversation, run) + .then(() => window.pollFinished = true); + }""", {"conversation": CONVERSATION, "run": run_id}) + page.evaluate("""({conversation, turn, run}) => { + const S = window.OrchHarness.stores.orchestration.useOrchestrationStore.getState(); + S.updateRunRecovery(run, { transportUnknown: true }); + S.applyStepEvent(conversation, turn, {run_id: run, step_id: 'answer', status: 'running'}); + }""", {"conversation": CONVERSATION, "turn": TURN, "run": run_id}) + expect(page.get_by_test_id("quiet-activity").get_by_role("status")).to_have_text("Reasoning: Write the comparison") + for route in held: + route.fulfill(json={"steps": [{"step_id": "read", "status": "running"}]}) + page.wait_for_function("() => window.pollFinished") + expect(page.get_by_test_id("quiet-activity").get_by_role("status")).to_have_text("Reasoning: Write the comparison") + assert len(api.calls("/run")) == 1 + + def test_legacy_history_explains_cannot_resume_without_inventing_reason(recovery_ui): page, api = recovery_ui api.finish(api.plan["run_id"], "cancelled") @@ -557,11 +621,17 @@ def test_server_confirmation_required_is_a_gate_not_an_automatic_retry(recovery_ def test_transport_terminal_reconciliation_keeps_saved_failure_and_no_stop(recovery_ui): page, api = recovery_ui api.stream_mode = "transport" + page.clock.install() mount_recovery(page, api) page.get_by_role("button", name="Approve and run the plan").click() - expect(page.get_by_text("Execution status", exact=False).first).to_be_visible() + expect(page.get_by_test_id("quiet-activity").get_by_role("status")).to_be_visible() + page.wait_for_function("""() => { + const recovery = window.OrchHarness.stores.orchestration.useOrchestrationStore + .getState().runRecovery['recovery-chat-run-0']; + return recovery?.detailLoaded && !recovery.checking; + }""") api.finish(api.plan["run_id"]) - check_saved_status(page) + page.clock.run_for(5000) expect(page.get_by_role("button", name="Retry from failed step").first).to_be_enabled() assert main_state(page)["history"][CONVERSATION][0]["status"] == "failed" assert main_state(page)["messages"][-1]["id"] == f"assistant-{api.plan['run_id']}" @@ -706,7 +776,7 @@ def test_terminal_status_waits_for_final_publication_after_transport_loss(recove page.clock.install() mount_recovery(page, api) page.get_by_role("button", name="Approve and run the plan").click() - expect(page.get_by_text("Execution status", exact=False).first).to_be_visible() + expect(page.get_by_test_id("quiet-activity").get_by_role("status")).to_be_visible() run_id = api.plan["run_id"] api.begin_finalization(run_id, status) @@ -714,7 +784,7 @@ def test_terminal_status_waits_for_final_publication_after_transport_loss(recove api.records[run_id].pop("finalization_status") check_saved_status(page) try: - expect(page.get_by_text("Execution status", exact=False).first).to_be_visible() + expect(page.get_by_test_id("quiet-activity").get_by_role("status")).to_have_text("Saving final response") except AssertionError as error: raise AssertionError({ "state": main_state(page), @@ -756,7 +826,7 @@ def test_reload_keeps_polling_until_terminal_publication_is_saved(recovery_ui, s api.begin_finalization(run_id, status) page.clock.install() mount_recovery(page, api, saved=True) - expect(page.get_by_text("Execution status", exact=False).first).to_be_visible() + expect(page.get_by_test_id("quiet-activity").get_by_role("status")).to_be_visible() assert run_id in main_state(page)["inFlight"] expect(page.get_by_role("button", name="Retry from failed step")).to_have_count(0) @@ -774,7 +844,7 @@ def test_expired_publication_reports_uncertainty_instead_of_an_unsaved_claim(rec api.stream_mode = "transport" mount_recovery(page, api) page.get_by_role("button", name="Approve and run the plan").click() - expect(page.get_by_text("Execution status", exact=False).first).to_be_visible() + expect(page.get_by_test_id("quiet-activity").get_by_role("status")).to_be_visible() run_id = api.plan["run_id"] api.begin_finalization(run_id, "failed") api.records[run_id]["finalization_status"] = "interrupted" @@ -882,11 +952,11 @@ def test_transport_loss_then_legacy_refusal_ends_the_tracked_run(recovery_ui): page.clock.install() mount_recovery(page, api) page.get_by_role("button", name="Approve and run the plan").click() - expect(page.get_by_text("Execution status", exact=False).first).to_be_visible() + expect(page.get_by_test_id("quiet-activity").get_by_role("status")).to_be_visible() run_id = api.plan["run_id"] assert run_id in main_state(page)["inFlight"] api.legacy.add(run_id) - check_saved_status(page) + page.clock.run_for(5000) expect(page.get_by_text(LEGACY_PLAN_MESSAGE, exact=True).first).to_be_visible() state = main_state(page) assert run_id not in state["inFlight"] @@ -907,7 +977,7 @@ def test_stop_on_a_legacy_run_releases_it_with_the_server_message(recovery_ui): page.clock.install() mount_recovery(page, api) page.get_by_role("button", name="Approve and run the plan").click() - expect(page.get_by_text("Execution status", exact=False).first).to_be_visible() + expect(page.get_by_test_id("quiet-activity").get_by_role("status")).to_be_visible() run_id = api.plan["run_id"] api.legacy.add(run_id) stop_from_composer(page) diff --git a/ui_tests/test_v2_stream_citation_delivery.py b/ui_tests/test_v2_stream_citation_delivery.py index 0f57a6c63..4fa37b466 100644 --- a/ui_tests/test_v2_stream_citation_delivery.py +++ b/ui_tests/test_v2_stream_citation_delivery.py @@ -1,7 +1,7 @@ # test_v2_stream_citation_delivery.py """ UI regressions for live tool/web evidence and recorded tool outcomes. -Version: 0.261.367 +Version: 0.261.417 Implemented in: 0.261.365 Drives the real send/SSE/store/message inspector locally with synthetic HTTP boundaries. @@ -138,6 +138,88 @@ def test_live_tool_and_web_evidence_match_reopened_message(citation_page, viewpo expect(message.get_by_role("link", name="Record reference")).to_be_visible() +@pytest.mark.parametrize("viewport", [{"width": 1440, "height": 900}, {"width": 390, "height": 844}]) +@pytest.mark.parametrize("kind,parent", [("note", None), ("threaded_comment", None), ("threaded_comment", "root-budget")]) +def test_native_annotation_first_preserves_streamed_and_saved_identity(citation_page, viewport, kind, parent): + page = citation_page + page.set_viewport_size(viewport) + reference = { + "producer": {"user_id": "owner", "conversation_id": "evidence-chat", "run_id": "native-run", + "step_id": "comments", "attempt_index": 1, "capability_id": "document_search"}, + "output_name": "prepared", "kind": "structured-v1", "manifest_sha256": "a" * 64, + } + citation = { + "citation_id": "workbook:native-comments:2", "document_id": "workbook", "file_name": "review.xlsx", + "location_label": "Location", "location_value": "Budget!B4", "sheet_name": "Budget", + "native_evidence": reference, "source_version": 1, "annotation_id": "annotation-2", + } + annotation = { + "id": "annotation-2", "kind": kind, "parent_id": parent, "sheet": "Budget", "cell": "B4", + "author": "Reviewer A", "date": "2026-01-01T10:00:00Z", "cell_value": 15000, "resolved": False, + } + content = "Review (Source: review.xlsx, Worksheet cell: Budget!B4) [#workbook:native-comments:2]" + message = _send(page, {"full_content": content, "hybrid_citations": [citation], "augmented": True}) + page.evaluate("""({annotation}) => { + window.nativeRequests = []; + window.nativeDenied = false; + const originalFetch = window.fetch; + window.fetch = async (url, init = {}) => { + const path = new URL(String(url), location.origin).pathname; + if (path === '/api/get_citation') { + window.nativeRequests.push(JSON.parse(init.body)); + return new Response(JSON.stringify(window.nativeDenied + ? {error: 'The cited workbook or retained evidence is no longer available.'} + : {file_name: 'review.xlsx', document_id: 'workbook', native_annotation: annotation, + cited_text: 'Keep cap at 15000. '}), + {status: window.nativeDenied ? 403 : 200, headers: {'Content-Type': 'application/json'}}); + } + if (path === '/api/enhanced_citations/tabular_preview') { + return new Response(JSON.stringify({ + filename: 'review.xlsx', sheet_names: ['Budget'], selected_sheet: 'Budget', + columns: ['Amount'], rows: [['15000']], row_numbers: [4], total_rows: 1, + tables: [], start_row: 0, has_more: false, + }), {headers: {'Content-Type': 'application/json'}}); + } + return originalFetch(url, init); + }; + const B = window.OrchHarness.stores.bootstrap.useBootstrapStore; + B.setState({data: {...B.getState().data, features: {enable_enhanced_citations: true}}}); + }""", {"annotation": annotation}) + chip = message.get_by_role("button", name="review.xlsx Budget!B4", exact=True) + chip.focus() + chip.press("Enter") + dialog = page.get_by_role("dialog", name="Citation", exact=True) + expect(dialog.get_by_text("Author: Reviewer A", exact=True)).to_be_visible() + expect(dialog.get_by_text("Saved cell value: 15000", exact=True)).to_be_visible() + expect(dialog.get_by_text("Keep cap at 15000. ", exact=True)).to_be_visible() + assert dialog.locator("script").count() == 0 + expect(page.get_by_role("dialog", name="Cited source", exact=True)).to_have_count(0) + preview = dialog.get_by_role("button", name="Open workbook preview (separate from annotation evidence)", exact=True) + expect(preview).to_be_visible() + assert page.evaluate("() => window.nativeRequests[0]") == { + "citation_id": citation["citation_id"], "document_id": "workbook", "page_number": "Budget!B4", + "native_evidence": reference, "conversation_id": "evidence-chat", + } + assert dialog.evaluate("element => element.scrollWidth <= element.clientWidth") + preview.click() + source = page.get_by_role("dialog", name="Cited source", exact=True) + expect(source).to_be_visible() + source.get_by_role("button", name="Close cited source").click() + expect(dialog.get_by_text("Author: Reviewer A", exact=True)).to_be_visible() + dialog.get_by_role("button", name="Close citation", exact=True).click() + page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().reloadMessages()") + chip.click() + expect(dialog.get_by_text("Author: Reviewer A", exact=True)).to_be_visible() + requests = page.evaluate("() => window.nativeRequests") + assert all(request == requests[0] for request in requests) + dialog.get_by_role("button", name="Close citation", exact=True).click() + page.evaluate("() => window.nativeDenied = true") + chip.click() + expect(dialog.get_by_text("The cited workbook or retained evidence is no longer available.", exact=True)).to_be_visible() + expect(dialog.get_by_text("Author: Reviewer A", exact=True)).to_have_count(0) + expect(dialog.get_by_role("button", name="Open workbook preview (separate from annotation evidence)", exact=True)).to_have_count(0) + + @pytest.mark.parametrize("result", [{"error": "HTTP 502", "status_code": 502}, None]) def test_live_failed_tool_retains_error_arguments_and_available_result(citation_page, result): failure = {**TOOL, "success": False, "error_message": "Synthetic upstream unavailable.", "function_result": result}