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}