diff --git a/application/single_app/background_tasks.py b/application/single_app/background_tasks.py index c7b795f1d..dcf3ed3b0 100644 --- a/application/single_app/background_tasks.py +++ b/application/single_app/background_tasks.py @@ -61,9 +61,9 @@ workflow_result_is_waiting, workflow_result_runtime_status, ) -from functions_m365_approvals import get_m365_approval_service +from functions_m365_approvals import M365PolicyError, get_m365_approval_service from functions_m365_connections import configure_m365_connection_authorization, get_m365_connection_service -from functions_m365_continuations import resume_pending_workflows +from functions_m365_continuations import M365ContinuationNotStarted, resume_pending_workflows from functions_m365_execution import configure_m365_execution, validate_m365_workflow_context from functions_m365_file_runtime import configure_m365_file_runtime from functions_m365_runtime import ( @@ -572,7 +572,13 @@ def connection_ready(job): return all(scope.rsplit("/", 1)[-1].lower() in granted for scope in job.get("required_scopes") or []) def execute(job): - workflow = load_current_workflow(job["workflow_ref"]) + try: + workflow = load_current_workflow(job["workflow_ref"]) + except M365PolicyError as error: + if error.code != "m365_workflow_missing": + raise + # Only this initial read proves no workflow/Graph effects were entered. + raise M365ContinuationNotStarted() from error settings = get_settings() group_id = workflow.get("group_id") if group_id: diff --git a/application/single_app/config.py b/application/single_app/config.py index 606e97d33..53149a72b 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.341" +VERSION = "0.261.342" 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_appinsights.py b/application/single_app/functions_appinsights.py index 9c87e48bf..9e2a53e6f 100644 --- a/application/single_app/functions_appinsights.py +++ b/application/single_app/functions_appinsights.py @@ -102,6 +102,7 @@ LOGGER_EXTERNAL_EVENT_MESSAGE = "[SIMPLE_CHAT_EXTERNAL_EVENT]" MAX_EXTERNAL_EVENT_STRING_LENGTH = 256 LOGGER_SAFE_TEXT_MAX_LENGTH = 1024 +LOGGER_APPLICATION_VERSION_MAX_LENGTH = 64 # Diagnostic keys whose sanitized text may be retained; everything else stays a length. LOGGER_SAFE_TEXT_KEYS = frozenset({ "attempt", @@ -131,6 +132,7 @@ LOGGER_WORKFLOW_CODE_KEYS = frozenset({ "validationcode", "validationrule", "responsefailure", "executioncode", "outputcode", "durablestatus", "failurecode", "authorityreason", "capabilityid", "outputformat", "producercapabilityid", + "executionpath", "outcome", }) @@ -146,6 +148,46 @@ def workflow_log_context(*, conversation_id=None, turn_id=None, run_id=None, ste } +def _is_application_version(value: Any) -> bool: + """Accept bounded three-part decimal versions without regex backtracking.""" + if not isinstance(value, str) or len(value) > LOGGER_APPLICATION_VERSION_MAX_LENGTH: + return False + parts = value.split(".", 2) + return len(parts) == 3 and all(part.isdecimal() for part in parts) + + +def log_chat_execution_outcome( + *, execution_path, status, outcome, conversation_id, turn_id=None, run_id=None, + failure=None, application_version=None, attempt_index=None, message_saved=None, +): + """Record business outcomes independently of a successful SSE/HTTP transport.""" + if execution_path not in {"direct", "orchestration"}: + raise ValueError("Invalid chat execution path.") + outcomes = {"completed", "partial", "failed", "waiting", "cancelled"} + if outcome not in outcomes or status not in outcomes: + raise ValueError("Invalid chat execution outcome.") + failure = failure if isinstance(failure, dict) else {} + fields = { + **workflow_log_context( + conversation_id=conversation_id, turn_id=turn_id, run_id=run_id, + step_id=failure.get("step_id"), + ), + "execution_path": execution_path, "status": status, "outcome": outcome, + "failure_code": failure.get("code"), "capability_id": failure.get("capability_id"), + } + if _is_application_version(application_version): + fields["application_version"] = application_version + if type(attempt_index) is int and attempt_index > 0: + fields["attempt_index"] = attempt_index + if type(message_saved) is bool: + fields["message_saved"] = message_saved + log_event( + "[CHAT_EXECUTION] Execution outcome recorded.", + extra=fields, + level=logging.WARNING if outcome in {"failed", "partial"} else logging.INFO, + ) + + def _format_message(message: Any, message_args: Optional[Tuple[Any, ...]] = None) -> str: """Support legacy printf-style rendering while preserving plain strings.""" message_text = str(message) @@ -281,6 +323,9 @@ def _build_logger_extra( elif diagnostic_key in LOGGER_WORKFLOW_CODE_KEYS: if re.fullmatch(r"[a-z][a-z0-9_]{0,79}", value): logger_extra[normalized_key] = value + elif diagnostic_key == "applicationversion": + if _is_application_version(value): + logger_extra[normalized_key] = value elif _is_safe_log_text_key(key): logger_extra[normalized_key] = ( sanitize_log_message(value)[:LOGGER_SAFE_TEXT_MAX_LENGTH] diff --git a/application/single_app/functions_m365_continuations.py b/application/single_app/functions_m365_continuations.py index 4e74d4fe8..f0cef7af4 100644 --- a/application/single_app/functions_m365_continuations.py +++ b/application/single_app/functions_m365_continuations.py @@ -2,15 +2,51 @@ """Conditional delivery of persisted Microsoft 365 workflow continuations.""" from datetime import datetime, timedelta, timezone +import hashlib import logging from azure.core import MatchConditions from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceNotFoundError +from functions_m365_context import M365PolicyError + DECIDED_STATES = frozenset({"approved", "denied", "expired", "invalidated", "revoked"}) +class M365ContinuationNotStarted(M365PolicyError): + """The executor verified deletion before entering any workflow execution.""" + + def __init__(self): + super().__init__("m365_workflow_missing", "The workflow no longer exists.") + + +def _log_continuation(log_event, candidate, *, code, stage, level, error=None): + properties = { + "request_id_hash": hashlib.sha256(str(candidate.get("id") or "").encode("utf-8")).hexdigest(), + "failure_code": code, + "stage": stage, + } + if error is not None: + properties["error_type"] = type(error).__name__ + log_event( + "[MS_GRAPH_PLUGIN] Workflow continuation outcome.", + level=level, extra=properties, + ) + + +def _fail_deleted_workflow(jobs, candidate, now): + stopped = { + **candidate, "status": "failed", "failure_code": "workflow_deleted", + "completed_at": now.isoformat(), + } + stopped.pop("resume_lease_expires_at", None) + jobs.replace_item( + candidate["id"], body=stopped, + etag=candidate["_etag"], match_condition=MatchConditions.IfNotModified, + ) + + def resume_pending_workflows( jobs, approvals, *, execute, can_resume, log_event, connection_ready=None, limit=25, clock=None, ): @@ -27,84 +63,136 @@ def resume_pending_workflows( ) outcomes = [] for candidate in candidates: - if candidate.get("status") == "resuming": - lease = candidate.get("resume_lease_expires_at") - if lease and datetime.fromisoformat(lease) > now: - continue - # Uncertain execution is not an instruction to repeat external mutations. - candidate["status"] = "recovery_required" - candidate["recovery_reason"] = "A continuation worker stopped before acknowledging its result." - jobs.replace_item( - candidate["id"], body=candidate, - etag=candidate["_etag"], match_condition=MatchConditions.IfNotModified, - ) - log_event( - "[MS_GRAPH_PLUGIN] Workflow continuation requires recovery.", - level=logging.WARNING, - extra={"request_id": candidate["id"]}, - ) - continue - if candidate.get("status") == "awaiting_sign_in": - if connection_ready is None or not connection_ready(candidate): + stage = "preflight" + try: + if candidate.get("status") == "resuming": + lease = candidate.get("resume_lease_expires_at") + if lease and datetime.fromisoformat(lease) > now: + continue + # An expired claim is uncertain even if its workflow was later deleted. + recovering = { + **candidate, "status": "recovery_required", + "recovery_reason": "A continuation worker stopped before acknowledging its result.", + } + stage = "recover_expired_claim" + jobs.replace_item( + candidate["id"], body=recovering, + etag=candidate["_etag"], match_condition=MatchConditions.IfNotModified, + ) + _log_continuation( + log_event, candidate, code="continuation_recovery_required", + stage=stage, level=logging.WARNING, + ) continue - approval = None - else: + try: - approval = approvals.get_approval( - candidate["approval_id"], candidate["user_id"], - ) - except (CosmosResourceNotFoundError, LookupError): - log_event( - "[MS_GRAPH_PLUGIN] Continuation approval is unavailable.", - level=logging.WARNING, extra={"request_id": candidate["id"]}, + # Check the workflow even when approval/sign-in is still pending. + eligible = can_resume(candidate, None) + except M365PolicyError as error: + if error.code != "m365_workflow_missing": + raise + stage = "terminalize_deleted_workflow" + _fail_deleted_workflow(jobs, candidate, now) + _log_continuation( + log_event, candidate, code="workflow_deleted", + stage=stage, level=logging.INFO, ) continue - if approval.get("status") not in DECIDED_STATES: + if not eligible: continue - if not can_resume(candidate, approval): - continue - claimed = { - **candidate, - "status": "resuming", - "resume_lease_expires_at": (now + timedelta(minutes=15)).isoformat(), - } - try: + if candidate.get("status") == "awaiting_sign_in": + if connection_ready is None or not connection_ready(candidate): + continue + else: + try: + approval = approvals.get_approval( + candidate["approval_id"], candidate["user_id"], + ) + except (CosmosResourceNotFoundError, LookupError) as error: + _log_continuation( + log_event, candidate, code="continuation_approval_unavailable", + stage=stage, level=logging.WARNING, error=error, + ) + continue + if approval.get("status") not in DECIDED_STATES: + continue + try: + eligible = can_resume(candidate, approval) + except M365PolicyError as error: + if error.code != "m365_workflow_missing": + raise + stage = "terminalize_deleted_workflow" + _fail_deleted_workflow(jobs, candidate, now) + _log_continuation( + log_event, candidate, code="workflow_deleted", + stage=stage, level=logging.INFO, + ) + continue + if not eligible: + continue + claimed = { + **candidate, + "status": "resuming", + "resume_lease_expires_at": (now + timedelta(minutes=15)).isoformat(), + } + stage = "claim" claimed = jobs.replace_item( candidate["id"], body=claimed, etag=candidate["_etag"], match_condition=MatchConditions.IfNotModified, ) - except CosmosHttpResponseError as error: - if error.status_code == 412: + stage = "execute" + try: + result = execute(claimed) + except M365ContinuationNotStarted: + stage = "terminalize_deleted_workflow" + _fail_deleted_workflow(jobs, claimed, now) + _log_continuation( + log_event, claimed, code="workflow_deleted", + stage=stage, level=logging.INFO, + ) continue - raise - try: - result = execute(claimed) - except Exception: - log_event( - "[MS_GRAPH_PLUGIN] Workflow continuation failed.", - level=logging.ERROR, - extra={"request_id": claimed["id"]}, - exceptionTraceback=True, - ) - current = jobs.read_item(claimed["id"], partition_key=claimed["user_id"]) - if current.get("_etag") == claimed["_etag"]: - current["status"] = "recovery_required" + except Exception as error: + _log_continuation( + log_event, claimed, code="continuation_execution_failed", + stage=stage, level=logging.ERROR, error=error, + ) + stage = "recover_failed_execution" + recovering = { + **claimed, "status": "recovery_required", + "recovery_reason": "The continuation failed after execution may have started.", + } jobs.replace_item( - current["id"], body=current, - etag=current["_etag"], match_condition=MatchConditions.IfNotModified, + claimed["id"], body=recovering, + etag=claimed["_etag"], match_condition=MatchConditions.IfNotModified, ) - raise - current = jobs.read_item(claimed["id"], partition_key=claimed["user_id"]) - if current.get("_etag") == claimed["_etag"]: + continue + stage = "acknowledge" + current = dict(claimed) if result.get("durable_execution") is True and result.get("pending") is True: current["status"] = "running" current.pop("resume_lease_expires_at", None) else: current["status"] = "completed" if result.get("success") else "failed" current["completed_at"] = now.isoformat() - jobs.replace_item( - current["id"], body=current, - etag=current["_etag"], match_condition=MatchConditions.IfNotModified, + try: + jobs.replace_item( + current["id"], body=current, + etag=claimed["_etag"], match_condition=MatchConditions.IfNotModified, + ) + except CosmosHttpResponseError as error: + if error.status_code != 412: + raise + # The executor may have saved a new approval or its own checkpoint. + _log_continuation( + log_event, candidate, code="continuation_conflict", + stage=stage, level=logging.INFO, error=error, + ) + outcomes.append({"request_id": claimed["id"], "result": result}) + except Exception as error: + conflict = isinstance(error, CosmosHttpResponseError) and error.status_code in {404, 412} + _log_continuation( + log_event, candidate, + code="continuation_conflict" if conflict else "continuation_processing_failed", + stage=stage, level=logging.INFO if conflict else logging.ERROR, error=error, ) - outcomes.append({"request_id": claimed["id"], "result": result}) return outcomes diff --git a/application/single_app/functions_m365_runtime.py b/application/single_app/functions_m365_runtime.py index af3827735..1dba9d577 100644 --- a/application/single_app/functions_m365_runtime.py +++ b/application/single_app/functions_m365_runtime.py @@ -19,6 +19,7 @@ cosmos_m365_execution_runs_container, ) from collaboration_models import is_shared_conversation_backing +from cosmos_document_utils import normalize_cosmos_document from functions_appinsights import log_event from functions_collaboration import ( assert_user_can_participate_in_collaboration_conversation, @@ -53,6 +54,8 @@ m365_revision_component, workflow_execution_fingerprint, ) +from functions_workflow_alert_safety import sanitize_workflow_alert_record +from functions_workflow_definitions import workflow_definition_for_editor from m365_interaction import M365_AUTH_INTERACTION_CODES @@ -347,16 +350,31 @@ def resolve_m365_workflow_binding(context, manifests, policies): def load_current_workflow(workflow): - # Workflow stores depend on action modules, so resolve after runtime bootstrap. - from functions_group_workflows import get_group_workflow - from functions_personal_workflows import get_personal_workflow - if workflow.get("group_id"): - current = get_group_workflow(workflow["group_id"], workflow["id"]) - else: - current = get_personal_workflow(workflow["user_id"], workflow["id"]) - if not current: + """Read authoritatively: only a scoped 404 or deletion tombstone means missing.""" + # Storage handles resolve at execution time, after workflow/runtime bootstrap. + # The UI getters deliberately hide storage errors and cannot establish deletion. + from config import cosmos_group_workflows_container, cosmos_personal_workflows_container + + group_id = workflow.get("group_id") + container = cosmos_group_workflows_container if group_id else cosmos_personal_workflows_container + partition = group_id or workflow["user_id"] + try: + current = normalize_cosmos_document(container.read_item( + item=workflow["id"], partition_key=partition, + )) + except CosmosResourceNotFoundError as error: + raise M365PolicyError("m365_workflow_missing", "The workflow no longer exists.") from error + if ( + current.get("id") != workflow["id"] + or current.get("group_id" if group_id else "user_id") != partition + ): + raise PermissionError("The workflow does not match its stored scope.") + if current.get("deleting") is True or current.get("status") == "deleting": raise M365PolicyError("m365_workflow_missing", "The workflow no longer exists.") - return current + cleaned = sanitize_workflow_alert_record({ + key: value for key, value in current.items() if not str(key).startswith("_") + }) + return workflow_definition_for_editor(cleaned) def workflow_m365_manifests(workflow): diff --git a/application/single_app/functions_orchestration_adapters.py b/application/single_app/functions_orchestration_adapters.py index 8c44f18ee..9950e1ff1 100644 --- a/application/single_app/functions_orchestration_adapters.py +++ b/application/single_app/functions_orchestration_adapters.py @@ -59,7 +59,7 @@ from copy import deepcopy from functions_appinsights import log_event -from functions_orchestration_context import build_elicitation_user_request, conversation_reference_messages +from functions_orchestration_context import build_elicitation_user_request, execution_conversation_messages from functions_orchestration_execution_policy import orchestration_file_policy from functions_orchestration_invocation_capture import ( OrchestrationInvocationCapture, @@ -72,6 +72,7 @@ from functions_orchestration_result_contracts import ( ProducerIdentity, ResultContractError, digest as validate_result_digest, ) +from functions_orchestration_result_runtime import retain_document_search_result, source_selection_provenance from functions_mixed_source_orchestration import ( AUTHORIZATION_STATUS_AUTHORIZED, MixedSourceCancellationError, @@ -386,10 +387,7 @@ def _step_visuals(context, settings, *planned_texts, step=None): def _conversation_reference(context): - return conversation_reference_messages( - _ctx(context, 'conversation_context', {}), - _ctx(context, 'context_message_ids', None), - ) + return execution_conversation_messages(context) def _with_conversation_reference(task, context): @@ -601,12 +599,14 @@ def run_document_search(step, context, *, settings, user_id, emit, cancel_reques workspace_ids = [document_id for document_id in requested_ids if document_id not in chat_ids] results = [] seen_results = set() - for scope in _workspace_search_scopes(context, arguments, requested_ids, workspace_ids): + search_scopes = list(_workspace_search_scopes(context, arguments, requested_ids, workspace_ids)) + top_n = _coerce_int(arguments.get('top_n'), 12) + for scope in search_scopes: workspace_results = hybrid_search( query, user_id, document_ids=scope['document_ids'], - top_n=_coerce_int(arguments.get('top_n'), 12), + top_n=top_n, doc_scope=scope['doc_scope'], active_group_ids=scope['active_group_ids'] or None, active_public_workspace_id=scope['active_public_workspace_id'] or None, @@ -664,6 +664,21 @@ def run_document_search(step, context, *, settings, user_id, emit, cancel_reques if document_id and document_id not in document_ids: document_ids.append(document_id) + citations = _citations_from_search_results(results) + task_result = None + if _ctx(context, 'result_service') is not None: + raise_if_mixed_source_cancelled(cancel_requested, 'orchestration_search_retention') + manifest = resolve_context_source_manifest( + context, document_ids, settings=settings, user_id=user_id, cancel_requested=cancel_requested, + ) + task_result = retain_document_search_result( + step, context, results, source_manifest=manifest, citations=citations, + search_scope={ + 'workspace_scopes': search_scopes, 'chat_attachment_ids': selected_chat_ids, + 'top_n_per_scope': top_n, 'tags_filter': _ctx(context, 'tags', None) or [], + 'document_filter_mode': _ctx(context, 'document_filter_mode', 'intersection'), + }, + ) narrative_sources = [{'document_id': document_id} for document_id in document_ids] envelopes = build_narrative_evidence_envelopes( narrative_sources, results, _selection_mode(context) @@ -677,7 +692,8 @@ def run_document_search(step, context, *, settings, user_id, emit, cancel_reques status=STEP_STATUS_COMPLETED, summary=summary, evidence=envelopes, - citations=_citations_from_search_results(results), + citations=citations, + task_result=task_result, ) @@ -824,6 +840,9 @@ def run_document_analyze(step, context, *, settings, user_id, emit, cancel_reque ) if not prompt: raise ValueError('The Analyze step has no analysis request.') + selection_upstream, selection_limitations = source_selection_provenance(context) + if selection_limitations: + prompt += '\n\nSource-selection coverage:\n' + '\n'.join(selection_limitations) checkpoints = _prepare_step_analysis_checkpoints(step, context) if checkpoints is None: raise ResultContractError('result_analysis_checkpoint_required') @@ -882,6 +901,7 @@ def save_analysis_section(user_id, conversation_id, run_id, step_id, value, *, s task_result = persist_saved_analysis_result( service=service, producer=producer, reader=reader, guard_token=token, input_fingerprint=input_fingerprint, + upstream=selection_upstream, selection_limitations=selection_limitations, ) raise_if_mixed_source_cancelled(cancel_requested, 'orchestration_analysis_finalization') except MixedSourceCancellationError: diff --git a/application/single_app/functions_orchestration_analysis_results.py b/application/single_app/functions_orchestration_analysis_results.py index f924ab1b2..0b8cda48c 100644 --- a/application/single_app/functions_orchestration_analysis_results.py +++ b/application/single_app/functions_orchestration_analysis_results.py @@ -109,7 +109,9 @@ def _finding_units(reader): reader.recheck() -def persist_saved_analysis_result(*, service, producer, reader, guard_token, input_fingerprint=None): +def persist_saved_analysis_result( + *, service, producer, reader, guard_token, input_fingerprint=None, upstream=(), selection_limitations=(), +): """Retain native units, optional exact public rows, and actual report text. ``findings`` always has the ordered record/evidence schema, so heterogeneous @@ -134,6 +136,7 @@ def persist_saved_analysis_result(*, service, producer, reader, guard_token, inp raise ResultContractError("result_native_analysis_invalid") sources = analysis_source_snapshot((manifest.get("analysis_access") or {}).get("sources")) status, coverage, checks, limitations = _native_completeness(reader) + limitations = tuple(dict.fromkeys((*limitations, *selection_limitations))) count = _count(manifest.get("record_count")) columns = _public_columns(reader) projection = SavedAnalysisRecordSource(reader, columns=columns) if status == "complete" and columns else None @@ -163,12 +166,14 @@ def persist_saved_analysis_result(*, service, producer, reader, guard_token, inp "sources": sources, "report_available": report_available, "public_records_available": projection is not None, + **({"source_selection_limitations": list(selection_limitations)} if selection_limitations else {}), }, _completeness(status, 1, coverage, checks, limitations), )) return service.persist_task_result( producer=producer, role="reason", status=status, outputs=outputs, sources=sources, origin="grounded", guard_token=guard_token, input_fingerprint=input_fingerprint, + upstream=upstream, ) diff --git a/application/single_app/functions_orchestration_composition.py b/application/single_app/functions_orchestration_composition.py index f364bc148..f3691bc96 100644 --- a/application/single_app/functions_orchestration_composition.py +++ b/application/single_app/functions_orchestration_composition.py @@ -34,7 +34,7 @@ from functions_diagram_operations import MERMAID_BLOCK_LANGUAGE from functions_m365_citations import M365_ANSWER_STYLE_INSTRUCTIONS from functions_mixed_source_orchestration import MixedSourceCancellationError -from functions_orchestration_context import conversation_reference_messages +from functions_orchestration_context import ConversationContextError, execution_conversation_messages from functions_orchestration_deliverables import ( compose_deliverable_guidance, missing_compose_visuals, place_deck_images, place_image_tokens, ) @@ -444,10 +444,7 @@ def recheck(): JSON_OUTPUT_POLICY, *(_output_shape(output) for output in outputs), )), ) if part) - history = conversation_reference_messages( - getattr(context, 'conversation_context', None) or {}, - getattr(context, 'context_message_ids', None), - ) + history = execution_conversation_messages(context) messages = [{'role': 'system', 'content': policy}] # What the user asked to receive frames the work before the visual guidance. messages.extend( @@ -668,7 +665,7 @@ def prepared_values(response): failure = build_failure('result_input_too_large') elif isinstance(exc, workflow_results_error): failure = build_failure(exc.code) - elif isinstance(exc, OrchestrationMemoryError): + elif isinstance(exc, (OrchestrationMemoryError, ConversationContextError)): failure = build_failure('context_unavailable') elif isinstance(exc, (ResultUnavailableError, PermissionError, ScreeningError)): failure = access_failure(exc) diff --git a/application/single_app/functions_orchestration_context.py b/application/single_app/functions_orchestration_context.py index 371638129..6d9ed2827 100644 --- a/application/single_app/functions_orchestration_context.py +++ b/application/single_app/functions_orchestration_context.py @@ -81,7 +81,7 @@ HISTORY_MAX_MESSAGES = 50 HISTORY_MAX_BYTES = 16384 HISTORY_SCAN_LIMIT = 200 -HISTORY_SCHEMA_VERSION = 1 +HISTORY_SCHEMA_VERSION = 2 CLARIFICATION_MAX_BYTES = 32768 # How much of a selected prompt the planner is shown. @@ -1762,6 +1762,24 @@ class ConversationContextError(ValueError): """Conversation context could not be safely prepared or reused.""" +def authorize_conversation_source_access(conversation, user_id): + """Recheck the shared conversation behind an owned orchestration backing.""" + # Collaboration initializes storage; ordinary personal history must not bootstrap it. + from functions_orchestration_collaboration import ( + authorize_shared_orchestration, is_orchestration_backing, shared_conversation, + ) + + if not is_orchestration_backing(conversation): + return + collaboration = shared_conversation(conversation.get('collaboration_conversation_id')) + if not collaboration: + raise ConversationContextError('That conversation could not be opened.') + try: + authorize_shared_orchestration(collaboration, user_id) + except PermissionError as exc: + raise ConversationContextError('That conversation could not be opened.') from exc + + def history_message_limit(settings=None): """Honor the chat history setting, rounding up to an even, bounded message count.""" try: @@ -1851,6 +1869,21 @@ def conversation_snapshot_size(snapshot): return len(json.dumps(snapshot, ensure_ascii=False, separators=(',', ':')).encode('utf-8')) +def _history_preview(content, length): + """Sample both constraints and the middle without claiming the omitted source is present.""" + if len(content) <= length: + return content + head = (length + 2) // 3 + tail = length // 3 + middle = length - head - tail + start = max(head, (len(content) - middle) // 2) + marker = '\n[Conversation discovery preview; source text omitted.]\n' + return ( + content[:head] + marker + content[start:start + middle] + + marker + (content[-tail:] if tail else '') + ) + + def build_conversation_snapshot( messages, settings=None, *, turn_id=None, truncated=False, logical_order=False, ): @@ -1885,6 +1918,17 @@ def build_conversation_snapshot( eligible.sort(key=_history_order) limit = history_message_limit(settings) retained = eligible[-limit:] if limit else [] + excluded_count = len(eligible) - len(retained) + if retained and any(item['role'] == 'user' for item in eligible[:excluded_count]): + # Do not begin a bounded window with an answer whose source turn was excluded. + while retained and retained[0]['role'] == 'assistant': + retained.pop(0) + for item in retained: + item.update( + content_complete=True, + source={'kind': 'stored_message', 'message_id': item['id'], 'role': item['role']}, + source_characters=len(item['content']), + ) snapshot = { 'schema_version': HISTORY_SCHEMA_VERSION, 'messages': retained, @@ -1892,34 +1936,48 @@ def build_conversation_snapshot( } if logical_order: snapshot['logical_order'] = True - while len(retained) > 1 and conversation_snapshot_size(snapshot) > HISTORY_MAX_BYTES: - retained.pop(0) - snapshot['truncated'] = True if retained and conversation_snapshot_size(snapshot) > HISTORY_MAX_BYTES: - # Preserve both ends of an oversized turn; constraints often occur at the end. - newest = retained[0] - original = newest['content'] - newest['truncated'] = True + # Very long IDs or many turns can exhaust the reference metadata allowance + # itself. Evict oldest whole turns only after reserving discovery text for each. + def minimum_size(): + return conversation_snapshot_size({ + **snapshot, + 'messages': [ + {**item, 'content': _history_preview(item['content'], 32), + 'content_complete': False, 'truncated': True} + for item in retained + ], + }) + + while len(retained) > 2 and minimum_size() > HISTORY_MAX_BYTES: + next_turn = next( + (index for index, item in enumerate(retained[1:], 1) if item['role'] == 'user'), + 1, + ) + del retained[:next_turn] + # Share the discovery allowance across turns instead of evicting the user's + # source to retain only the associated assistant answer. + originals = [item['content'] for item in retained] snapshot['truncated'] = True - lower, upper = 0, len(original) + lower, upper = 0, max(map(len, originals)) + + def project(length): + for item, original in zip(retained, originals): + item['content'] = _history_preview(original, length) + item['content_complete'] = len(original) <= length + if item['content_complete']: + item.pop('truncated', None) + else: + item['truncated'] = True + while lower < upper: length = (lower + upper + 1) // 2 - tail_length = length // 2 - newest['content'] = ( - original[:length - tail_length] - + '\n[Conversation message truncated.]\n' - + (original[-tail_length:] if tail_length else '') - ) + project(length) if conversation_snapshot_size(snapshot) <= HISTORY_MAX_BYTES: lower = length else: upper = length - 1 - tail_length = lower // 2 - newest['content'] = ( - original[:lower - tail_length] - + '\n[Conversation message truncated.]\n' - + (original[-tail_length:] if tail_length else '') - ) + project(lower) if conversation_snapshot_size(snapshot) > HISTORY_MAX_BYTES: raise ConversationContextError('The conversation context exceeds its size limit.') return snapshot @@ -1929,7 +1987,7 @@ def validate_conversation_snapshot(snapshot, messages): """Reject a saved context if a source was edited, hidden, or removed after planning.""" if ( not isinstance(snapshot, dict) - or snapshot.get('schema_version') != HISTORY_SCHEMA_VERSION + or snapshot.get('schema_version') not in (1, HISTORY_SCHEMA_VERSION) or not isinstance(snapshot.get('messages'), list) or len(snapshot['messages']) > HISTORY_MAX_MESSAGES or conversation_snapshot_size(snapshot) > HISTORY_MAX_BYTES @@ -1957,6 +2015,19 @@ def validate_conversation_snapshot(snapshot, messages): 'Conversation context changed. Create a new plan before running this request.' ) seen.add(message_id) + if snapshot['schema_version'] == 1: + # Complete historical snapshots remain usable after current-access checks. + # A clipped legacy snapshot cannot prove which user source it discarded. + legacy = { + 'schema_version': 1, + 'messages': [current[item['id']] for item in snapshot['messages']], + 'truncated': False, + } + if snapshot.get('logical_order'): + legacy['logical_order'] = True + if snapshot != legacy: + raise ConversationContextError('This plan needs to be created again with complete conversation sources.') + return legacy rebuilt = build_conversation_snapshot( [sources[item['id']] for item in snapshot['messages']], {'conversation_history_limit': len(snapshot['messages'])}, @@ -1969,15 +2040,52 @@ def validate_conversation_snapshot(snapshot, messages): def conversation_reference_messages(snapshot, message_ids=None): - """Return only role/content/ID fields, never stored metadata or citation payloads.""" + """Discovery only: preserve completeness and provenance, never private stored metadata.""" allowed = set(message_ids) if message_ids is not None else None return [ - {'id': message['id'], 'role': message['role'], 'content': message['content']} + { + key: deepcopy(message[key]) + for key in ('id', 'role', 'content', 'content_complete', 'source', 'source_characters', 'truncated') + if key in message + } for message in (snapshot or {}).get('messages', []) if allowed is None or message['id'] in allowed ] +def resolve_conversation_source_messages(snapshot, messages, message_ids=None): + """Resolve exact, currently checked stored text; never upgrade discovery previews.""" + selected = conversation_reference_messages(snapshot, message_ids) + if message_ids is not None and {item['id'] for item in selected} != set(message_ids): + raise ConversationContextError('The selected conversation source is unavailable. Create a new plan.') + current = {} + for message in messages: + normalized = normalize_history_message(message) + if normalized: + current[normalized['id']] = normalized + expected = {item['id']: item for item in snapshot.get('messages') or []} + resolved = [] + for entry in selected: + source = current.get(entry['id']) + if not source or source['fingerprint'] != expected[entry['id']].get('fingerprint'): + raise ConversationContextError('Conversation context changed. Create a new plan before running this request.') + resolved.append({'id': source['id'], 'role': source['role'], 'content': source['content']}) + return resolved + + +def execution_conversation_messages(context): + """Read selected full messages through the runtime owner, with no preview fallback.""" + snapshot = getattr(context, 'conversation_context', None) or {} + ids = getattr(context, 'context_message_ids', None) + reader = getattr(context, 'read_conversation_messages', None) + if callable(reader): + return reader() + selected = conversation_reference_messages(snapshot, ids) + if any(item.get('truncated') or item.get('content_complete') is False for item in selected): + raise ConversationContextError('The complete conversation source must be reloaded. Create a new plan.') + return selected + + def validate_clarification_answers(answers): primitive_answers = [ {key: value for key, value in answer.items() if key != 'context'} @@ -2089,7 +2197,11 @@ def build_conversation_signals(messages, user_message, *, truncated=False, messa """Project already bounded history for the planner; the route owns loading it.""" allowed = set(message_ids) if message_ids is not None else None turns = [ - {'id': message.get('id'), 'role': message['role'], 'content': _history_text(message.get('content'))} + { + key: deepcopy(message[key]) + for key in ('id', 'role', 'content', 'content_complete', 'source', 'source_characters', 'truncated') + if key in message + } for message in messages or () if isinstance(message, dict) and message.get('role') in ('user', 'assistant') diff --git a/application/single_app/functions_orchestration_execution.py b/application/single_app/functions_orchestration_execution.py index fefba2731..3ee56aa59 100644 --- a/application/single_app/functions_orchestration_execution.py +++ b/application/single_app/functions_orchestration_execution.py @@ -102,7 +102,7 @@ assert_current_request_sources_available, guard_model_callable, strict_source_authority, ) from content_screening.contracts import DocumentHeldError, ScreeningError -from functions_appinsights import log_event, workflow_log_context +from functions_appinsights import log_chat_execution_outcome, log_event, workflow_log_context from functions_orchestration_adapters import resolve_context_source_manifest from functions_orchestration_checkpoints import CheckpointError, fingerprint, orchestration_answer_message_id from functions_orchestration_context import ( @@ -110,12 +110,15 @@ CatalogResolutionError, ConversationContextError, ElicitationContextError, + authorize_conversation_source_access, build_capability_request_context, build_elicitation_user_request, conversation_user_urls, + history_message_limit, normalize_history_message, resolve_action_catalog, resolve_agent_catalog, + resolve_conversation_source_messages, resolve_elicitation_references, validate_clarification_answers, validate_conversation_snapshot, @@ -403,9 +406,11 @@ def invoke_prompt(prompt_text, stage="window_analysis", metadata=None): return invoke_prompt -def _conversation_context(record, message_container, read_conversation): +def _conversation_context(record, message_container, read_conversation, *, full_message_ids=None): """Rebuild the exact saved snapshot and turn, never the latest browser history.""" - read_conversation() + conversation = read_conversation() + # The owned backing alone does not prove continued membership in a shared chat. + authorize_conversation_source_access(conversation, record["user_id"]) user_id, conversation_id = record["user_id"], record["conversation_id"] snapshot = record.get("conversation_context") entries = snapshot.get("messages") if type(snapshot) is dict else None @@ -443,12 +448,14 @@ def _conversation_context(record, message_container, read_conversation): if context not in contexts: contexts.append(deepcopy(context)) if messages or contexts: - # Saved-analysis readers need initialized storage; early imports must not - # pull their artifact/provider dependency chain into the runtime. + # Checked-history and saved-analysis readers need initialized storage; + # early imports must not pull their provider dependency chain into the runtime. + from functions_chat_content_review import checked_history_messages from functions_saved_analysis import ( analysis_result_contexts, load_saved_analysis, sanitize_saved_analysis_messages, ) + messages = checked_history_messages(messages, for_model=True) messages = sanitize_saved_analysis_messages(messages, user_id) for message in messages: for context in analysis_result_contexts(message): @@ -462,6 +469,10 @@ def _conversation_context(record, message_container, read_conversation): ) if contexts: validated["analysis_result_contexts"] = contexts + if full_message_ids is not None: + # Full text is resolved only after the same ownership, visibility and revision + # checks used for execution/retry. It is never persisted in the discovery snapshot. + return resolve_conversation_source_messages(validated, messages, full_message_ids) return validated @@ -575,6 +586,11 @@ def _validate_memory(self): return conversation def _revalidate_context(self): + if ( + history_message_limit(self._bootstrap.get_settings()) == 0 + and (self.record.get("conversation_context") or {}).get("messages") + ): + raise ConversationContextError("Conversation history is disabled. Create a new request.") if not self._delivery_only: assert_current_request_sources_available(self.record["user_id"]) snapshot = _conversation_context( @@ -815,6 +831,10 @@ def resolve_step_model(step_seeds, current_settings): ), ) self._bind_public_chat_scope(self.context, conversation_id) + self.context.read_conversation_messages = lambda: _conversation_context( + self.record, self.lease.message_container, self._read_conversation, + full_message_ids=self.context.context_message_ids or [], + ) self.context.prompt_token_usage = self.prompt_token_usage if auto_routing: def guarded_planner_client(model): @@ -1746,6 +1766,17 @@ def _finalize(self, result, error, *, refreshes=0): finalized = self.lease.close(release=True) self._released = True self._final_record = finalized + root_failure = next( + (failure for failure in failures if failure.get("code") != "dependency_unavailable"), + failures[0] if failures else None, + ) + log_chat_execution_outcome( + execution_path="orchestration", status=status, outcome=outcome, + conversation_id=self.record["conversation_id"], run_id=self.record["id"], + turn_id=self.record.get("turn_id"), failure=root_failure, + application_version=getattr(getattr(self._bootstrap, "config", None), "VERSION", None), + attempt_index=finalized.get("attempt_index"), message_saved=True, + ) public = public_execution_fields(finalized) frame = build_run_done_event( self.record["conversation_id"], message_id=message_id, run_id=self.record["id"], @@ -1785,6 +1816,14 @@ def _finish(self, result, error): }) except Exception as persistence_error: _log_failure("Publication failure could not be recorded.", self.record, persistence_error) + if self._final_record is not None: + log_chat_execution_outcome( + execution_path="orchestration", status="failed", outcome="failed", + conversation_id=self.record["conversation_id"], run_id=self.record["id"], + turn_id=self.record.get("turn_id"), failure=failure, + application_version=getattr(getattr(self._bootstrap, "config", None), "VERSION", None), + attempt_index=self.record.get("attempt_index"), message_saved=False, + ) self._frames = [ build_error_event(failure["message"], self.record["conversation_id"]), build_run_done_event( diff --git a/application/single_app/functions_orchestration_executor.py b/application/single_app/functions_orchestration_executor.py index 3fae8f906..1453c0bff 100644 --- a/application/single_app/functions_orchestration_executor.py +++ b/application/single_app/functions_orchestration_executor.py @@ -79,6 +79,7 @@ retain_gather_result, validate_task_diagnostics, validate_task_outputs, ) from functions_orchestration_results import ResultUnavailableError +from functions_workflow_collections import CollectionSizeError from functions_workflow_result_store import WorkflowResultIntegrityError, WorkflowResultStorageUnavailableError from functions_orchestration_schema import ( PLAN_STATUS_CANCELLED, @@ -918,6 +919,8 @@ def _run_dependency_step( 'document_analyze', CAPABILITY_TABULAR_INSPECT, CAPABILITY_TABULAR_MERGE, CAPABILITY_DOCUMENT_MERGE, ): named_sources = read_complete_input(readers['sources']) + if not named_sources: + raise ResultUnavailableError('result_source_selection_empty') runtime_step['arguments']['document_ids'] = [ source['document_id'] for source in named_sources ] @@ -943,6 +946,7 @@ def _run_dependency_step( ): raise ResultUnavailableError('result_source_snapshot_changed') scoped = _dependency_adapter_context(context, manifest, step, input_fingerprint) + scoped.source_selection_reader = readers.get('sources') if named_sources else None binding_scope = getattr(context, 'step_model_scope', None) model_scope = ( binding_scope(step, scoped) @@ -1039,6 +1043,8 @@ def _run_dependency_step( failure = access_failure(exc, capability_id=step['capability_id']) elif isinstance(exc, PartialInputNotAcceptedError): failure = build_failure('input_partial_not_accepted') + elif step['capability_id'] == 'document_search' and isinstance(exc, CollectionSizeError): + failure = build_failure('search_result_too_large') elif isinstance(exc, ResultContractError): failure = build_failure('result_invalid') else: diff --git a/application/single_app/functions_orchestration_planner.py b/application/single_app/functions_orchestration_planner.py index b71b48d0a..e7cf79567 100644 --- a/application/single_app/functions_orchestration_planner.py +++ b/application/single_app/functions_orchestration_planner.py @@ -109,7 +109,8 @@ # correction can fix it. WORKFLOW_CONTEXT_UNAVAILABLE_RULE = 'workflow_context_unavailable' REPAIRABLE_PLAN_CODES = frozenset({ - 'deliverables_invalid', 'source_kind_invalid', 'source_binding_required', *WORKFLOW_REPAIR_CODES, + 'deliverables_invalid', 'source_kind_invalid', 'source_binding_required', + 'conversation_retrieval_conflict', *WORKFLOW_REPAIR_CODES, *WORKFLOW_RUN_REPAIR_CODES, *WORKFLOW_RESULTS_REPAIR_CODES, }) DELIVERABLES_FAILURE_MESSAGE = ( @@ -150,6 +151,65 @@ r'(?:hi|hello|hey|thanks|thank you|ok|okay|got it|understood|great|sounds good)[.! ]*', re.IGNORECASE, ) +CONVERSATION_RETRIEVAL_CAPABILITIES = frozenset({ + 'document_search', 'document_analyze', 'tabular_analyze', 'web_search', 'url_fetch', 'deep_research', +}) +TRANSFORMATION_PATTERN = re.compile( + r'\b(?:summari[sz]e|rewrite|reformat|format|shorten|expand|revise|edit|transform|' + r'improve|audit|review|convert|put|turn|make)\b', + re.IGNORECASE, +) +CONVERSATION_TARGET_PATTERN = re.compile( + r'\b(?:this|that|those|these|it|above|previous|prior|pasted|provided|earlier|' + r'your (?:answer|reply|response)|the (?:text|content|answer|reply|response))\b', + re.IGNORECASE, +) +EXPLICIT_RETRIEVAL_PATTERN = re.compile( + r'(?:^|[.!?]\s+|\b(?:and|then|also)\s+)' + r'(?:(?:please|can you|could you|would you)\s+)?' + r'(?:search|retrieve|look up|research|find)\b|' + r'\b(?:fact.check|up.to.date)\b|https?://|' + r'\b(?:verify|current|latest)\b.{0,50}\b(?:facts?|sources?|hours|prices?|news|data|policies)\b|' + r'\b(?:using|against)\s+(?:the\s+)?(?:web|workspace|documents?)\b', + re.IGNORECASE, +) + + +def _explicit_retrieval(user_message, seeds=None): + seeds = seeds or {} + instruction = str(user_message or '').split('```', 1)[0].split('\n\n', 1)[0] + if ':' in instruction and TRANSFORMATION_PATTERN.search(instruction.split(':', 1)[0]): + instruction = instruction.split(':', 1)[0] + return bool( + EXPLICIT_RETRIEVAL_PATTERN.search(instruction) + or any(seeds.get(key) for key in ( + 'document_ids', 'elicitation_references', 'public_workspace_selection', 'tags', + )) + or seeds.get('web_search') is True + ) + + +def conversation_retrieval_allowed(resolution, user_message='', seeds=None): + """A no-retrieval resolution is a routing contract, not a suggestion to the planner.""" + return (resolution or {}).get('requires_retrieval') is not False or _explicit_retrieval(user_message, seeds) + + +def _conversation_transformation(message): + return bool( + TRANSFORMATION_PATTERN.search(message) and CONVERSATION_TARGET_PATTERN.search(message) + # A source supplied in this very request needs no historical target. + and not re.search(r'\b(?:rewrite|summari[sz]e|format|edit|convert)\b[^\n:]{0,100}:\s*\S', message, re.I) + and '\n\n' not in message + ) + + +def _missing_conversation_target(message): + return { + 'relationship': 'clarification', 'resolved_message': message, 'message_ids': [], + 'requires_retrieval': False, + 'clarification': 'Which message or text should I use? Select or paste the content you want transformed.', + 'token_usage': {}, + } class PlannerError(RuntimeError): """Raised when the planner could not be reached or configured.""" @@ -635,6 +695,11 @@ def resolve_planner_client(settings): evidence: reuse a previous answer for transformations or conversational references when its text is actually supplied, and gather again when a requested fact is missing or needs current evidence. Earlier assistant claims do not establish current facts or opening hours. +When source_policy is conversation_only, use the selected historical messages for the +transformation; do not search workspaces or the web, or invent a document audit. +content_complete=false marks a discovery preview, not a complete source: the server +reloads the exact selected stored messages for execution. If the requested target is +missing or ambiguous, ask which content to use instead of substituting external sources. Make every query, analysis instruction, compose instruction, agent task, and action task self-contained: include the subject, place, time, and other relevant constraints rather than @@ -971,6 +1036,13 @@ def _call_planner( conversation and answers genuinely do not resolve the request. Do not ask what kind of business the user means when the preceding conversation already establishes that subject. If the user declined a clarification, do not ask it again or invent the missing information. +History entries labeled content_complete=false are discovery previews, not source text. +Their IDs reference full stored messages that the server can reauthorize and load for +execution. Select the relevant user source/request as well as its associated assistant +answer, preserving their roles. Do not require retrieval merely because a preview is +shortened. A transformation of pasted or prior content uses conversation content first; +if its target is missing or ambiguous, ask which text/message rather than searching +workspaces. Earlier assistant assertions are not evidence of external facts. If history is truncated, do not pretend to know what was omitted.""" @@ -1017,6 +1089,7 @@ def _normalize_request_resolution(parsed, valid_ids, *, has_answers=False): def resolve_conversation_request( user_message, snapshot, settings=None, answered_questions=None, planner_model=None, + *, seeds=None, ): """Resolve context before retrieval, using the captured model binding when supplied.""" message = str(user_message or '').strip() @@ -1030,6 +1103,8 @@ def resolve_conversation_request( 'token_usage': {}, } if not history and not answered_questions: + if _conversation_transformation(message) and not _explicit_retrieval(message, seeds): + return _missing_conversation_target(message) return default if ACKNOWLEDGMENT_PATTERN.fullmatch(message) and not answered_questions: return default @@ -1115,6 +1190,26 @@ def resolve_conversation_request( if resolution['relationship'] == 'new_topic' and not answered_questions: resolution['resolved_message'] = message + if ( + _conversation_transformation(message) + and not _explicit_retrieval(message, seeds) + and resolution['relationship'] != 'clarification' + ): + if not resolution['message_ids'] and not answered_questions: + resolution = _missing_conversation_target(message) + else: + resolution['requires_retrieval'] = False + if resolution['relationship'] == 'follow_up' and resolution['requires_retrieval'] is False: + selected = set(resolution['message_ids']) + # The user supplied the source and constraints; an assistant answer alone + # must not replace that source merely because it is the shorter message. + user_source = None + for entry in history: + if entry['role'] == 'user': + user_source = entry['id'] + elif entry['id'] in selected and user_source: + selected.add(user_source) + resolution['message_ids'] = [entry['id'] for entry in history if entry['id'] in selected] resolution['token_usage'] = token_usage log_event( '[ORCHESTRATION_PLANNER] Resolved the conversational request.', @@ -1168,6 +1263,14 @@ def plan_repair_message(error, *, handoff_present=False): ``handoff_present`` says the rejected plan holds an enabled workflow_handoff step, so an answer the plan was told to write is steered away from the handed-off part. """ + if getattr(error, 'code', None) == 'conversation_retrieval_conflict': + return ( + 'This request must use the selected conversation messages, not workspace or web retrieval. ' + 'Discovery previews are not complete sources; execution reloads the selected full stored messages. ' + 'Remove search, Analyze and research steps and prepare the requested transformation with compose. ' + 'If the target is genuinely unclear, return a focused elicitation instead. ' + 'Return the complete corrected plan as one JSON object.' + ) if getattr(error, 'code', None) in WORKFLOW_REPAIR_CODES: return ( f'The server rejected the workflow proposal in that plan: {error}\n' @@ -1377,6 +1480,10 @@ def plan_request( settings = settings if isinstance(settings, dict) else {} plan_contract_version({'planner_contract_version': contract_version}) + conversation_only = not conversation_retrieval_allowed( + (planner_context or {}).get('request_resolution'), + (planner_context or {}).get('original_message') or user_message, seeds, + ) unavailable = {} capabilities = resolve_available_capabilities( settings, @@ -1386,9 +1493,19 @@ def plan_request( contract_version=contract_version, export_catalog=export_catalog, ) + if conversation_only: + capabilities = [ + capability for capability in capabilities + if capability['id'] not in CONVERSATION_RETRIEVAL_CAPABILITIES + ] + unavailable.update({ + capability_id: 'This request uses selected conversation content, not external retrieval.' + for capability_id in CONVERSATION_RETRIEVAL_CAPABILITIES + }) available_ids = [capability['id'] for capability in capabilities] context = dict(planner_context or {}) + context['source_policy'] = 'conversation_only' if conversation_only else 'retrieval_allowed' model_candidates = [] if (seeds or {}).get('model_routing') == 'auto': model_candidates = authorized_routing_candidates(settings, user_id) @@ -1468,6 +1585,14 @@ def _failure(reason, error=None, *, stage=None, message=None, attempt=None): } def _normalize(raw, capability_ids, availability, selected_image, **options): + if conversation_only and any( + step.get('enabled', True) and step.get('capability_id') in CONVERSATION_RETRIEVAL_CAPABILITIES + for step in raw.get('steps') or [] if isinstance(step, dict) + ): + raise PlanValidationError( + 'The plan searches outside the selected conversation despite its no-retrieval resolution.', + code='conversation_retrieval_conflict', + ) normalized = normalize_plan( raw, conversation_id, @@ -1692,6 +1817,10 @@ def _normalize(raw, capability_ids, availability, selected_image, **options): 'deliverables_invalid': DELIVERABLES_FAILURE_MESSAGE, 'source_kind_invalid': SOURCE_KIND_FAILURE_MESSAGE, 'source_binding_required': SOURCE_BINDING_FAILURE_MESSAGE, + 'conversation_retrieval_conflict': ( + 'This request needs the conversation content, not a workspace search. ' + 'Select or paste the target text and create a new plan.' + ), **{code: WORKFLOW_FAILURE_MESSAGE for code in WORKFLOW_REPAIR_CODES}, **{code: WORKFLOW_RUN_FAILURE_MESSAGE for code in WORKFLOW_RUN_REPAIR_CODES}, **{code: WORKFLOW_RESULTS_FAILURE_MESSAGE for code in WORKFLOW_RESULTS_REPAIR_CODES}, diff --git a/application/single_app/functions_orchestration_recovery.py b/application/single_app/functions_orchestration_recovery.py index 1d86d23c7..9a4feaf57 100644 --- a/application/single_app/functions_orchestration_recovery.py +++ b/application/single_app/functions_orchestration_recovery.py @@ -85,6 +85,18 @@ def _partial_dependency_recovery(record): """Complete-only consumers must recover their partial producer, not replay it unchanged.""" saved_steps = {step['step_id']: step for step in _execution_steps(record)} rerun, blocked = set(), set() + for saved in saved_steps.values(): + if saved.get('capability_id') != 'document_search' or saved.get('status') != 'partial': + continue + task = TaskResult.from_dict(saved['task_result']) if saved.get('task_result') is not None else None + if task is None: + raise CheckpointError('checkpoint_invalid') + if any( + reference.completeness.status == 'partial' + and 'retained_adapter_output' in reference.completeness.checks + for reference in task.outputs + ): + blocked.add(saved['step_id']) for step in record['plan'].get('steps') or []: if not step.get('enabled', True): continue @@ -100,6 +112,9 @@ def _partial_dependency_recovery(record): if completeness.status != 'partial': continue if ( + task.producer.capability_id == 'document_search' + and 'retained_adapter_output' in completeness.checks + ) or ( task.producer.capability_id == 'document_analyze' and 'analysis_response_schema:passed' in completeness.checks and completeness.coverage.expected == completeness.coverage.completed @@ -569,7 +584,12 @@ def recovery_projection(record): and (step.get('failure') or {}).get('code') != 'input_partial_not_accepted' for step in steps ): - reason, message = 'input_partial_not_recoverable', build_failure('input_partial_not_recoverable')['message'] + search_partial = any( + step.get('step_id') in blocked_partial_producers and step.get('capability_id') == 'document_search' + for step in steps + ) + reason = 'search_result_incomplete' if search_partial else 'input_partial_not_recoverable' + message = build_failure(reason)['message'] return { 'eligible': reason is None, 'reason_code': reason, 'message': message, 'expected_version': record.get('recovery_version'), diff --git a/application/single_app/functions_orchestration_result_runtime.py b/application/single_app/functions_orchestration_result_runtime.py index bc8deb0d5..95f5e93e1 100644 --- a/application/single_app/functions_orchestration_result_runtime.py +++ b/application/single_app/functions_orchestration_result_runtime.py @@ -20,7 +20,9 @@ InputBinding, PartialInputNotAcceptedError, ResultContractError, ResultNotReadyError, ResultRef, TaskResult, canonical_bytes, canonical_digest, output_name, ) -from functions_orchestration_results import MAX_LINEAGE_RESULTS, MAX_VALUE_BYTES, NamedOutput, OrchestrationResults +from functions_orchestration_results import ( + MAX_LINEAGE_RESULTS, MAX_VALUE_BYTES, NamedOutput, OrchestrationResults, ResultUnavailableError, +) from functions_orchestration_schema import step_input_specs @@ -303,6 +305,88 @@ def _complete(count, limitations=(), *, partial=False): ) +def retain_document_search_result(step, context, results, *, source_manifest, citations, search_scope): + """Commit returned hits before any presentation envelope can shorten them. + + Completion covers the returned response and its discovered identities, not every + match in an index or the complete contents of the discovered documents. + """ + service = require_result_service(context) + sources = analysis_source_snapshot(source_manifest) + by_id = {source['document_id']: source for source in sources} + if ( + len(by_id) != len(sources) + or any(source.get('authorization_status') != 'authorized' for source in source_manifest) + or set(by_id) != {hit.get('document_id') for hit in results} + ): + raise ResultUnavailableError('result_source_unavailable') + prior = {source['document_id']: source for source in context.execution_manifest} + for source in sources: + original = prior.get(source['document_id']) + if original and any( + original.get(key) != source.get(key) + for key in ('scope', 'scope_id', 'source_version', 'source_revision', 'content_sha256') + ): + raise ResultUnavailableError('result_source_snapshot_changed') + limitations = ( + 'Complete only for the returned search response and its discovered source identities.', + 'Top-N retrieval is not exhaustive corpus discovery or whole-document coverage.', + ) + retained, grouped = [], {} + for index, hit in enumerate(results): + if type(hit) is not dict or type(hit.get('chunk_text')) is not str: + raise ResultContractError('result_evidence_invalid') + source = by_id[hit['document_id']] + retained.append({'evidence_id': f'excerpt-{index}', 'source': source, 'text': hit['chunk_text']}) + grouped.setdefault(hit['document_id'], []).append({ + key: deepcopy(hit.get(key)) for key in ('chunk_text', 'page_number', 'chunk_sequence', 'score') + }) + prepared = { + 'version': 'orchestration-gathered-content-v1', 'capability_id': 'document_search', + 'content_scope': 'returned_excerpts', + 'evidence': [ + {'document_id': document_id, 'status': 'completed', 'evidence': hits} + for document_id, hits in grouped.items() + ], + 'notes': [], 'citations': deepcopy(citations), 'limitations': list(limitations), + 'search_results': deepcopy(results), + 'search_scope': deepcopy(search_scope), + 'coverage': { + 'returned_excerpt_count': len(results), 'discovered_source_count': len(sources), + 'whole_document_coverage': False, 'exhaustive_corpus_coverage': False, + }, + } + return service.persist_task_result( + producer=context.result_producer(step), role='gather', status='complete', + outputs=[ + NamedOutput('evidence', 'evidence-set-v1', retained, Completeness( + 'complete', len(retained), len(retained), Coverage(len(retained), len(retained), 'items'), + 'valid', ('lossless_search_response',), limitations, + )), + NamedOutput('sources', 'source-set-v1', sources, Completeness( + 'complete', len(sources), len(sources), Coverage(len(sources), len(sources), 'sources'), + 'valid', ('authorized_discovered_source_identities',), limitations, + )), + NamedOutput('prepared', 'structured-v1', prepared, _complete(1, limitations)), + ], + sources=sources, origin='grounded' if sources else 'generated', + guard_token=context.result_guard_token_for_step(step['step_id']), + input_fingerprint=context.result_input_fingerprint_for_step(step['step_id']), + ) + + +def source_selection_provenance(context): + """Keep a search-selected Analyze scoped to those sources, not an entire corpus.""" + reader = getattr(context, 'source_selection_reader', None) + if reader is None: + return (), () + if reader.result_kind != 'source-set-v1': + raise ResultContractError('result_input_kind_mismatch') + reader.completeness.require_readable() + reader.recheck() + return (reader.reference,), reader.completeness.limitations + + def retain_gather_result(step, context, result, *, source_manifest): """Retain exact returned excerpts/notes, never relabel them whole-document data.""" if type(result) is not dict or result.get('status') not in ('completed', 'partial'): diff --git a/application/single_app/functions_orchestration_schema.py b/application/single_app/functions_orchestration_schema.py index d761dc736..fac748214 100644 --- a/application/single_app/functions_orchestration_schema.py +++ b/application/single_app/functions_orchestration_schema.py @@ -1875,6 +1875,14 @@ def summarize_plan(plan): 'saved. Ask again to read the current result.' ), 'result_invalid': 'The operation did not produce the complete named results declared by the plan.', + 'search_result_too_large': ( + 'The full search response exceeds the retained-result storage budget. No shortened result was ' + 'accepted. Narrow the search or select fewer sources and create a new plan.' + ), + 'search_result_incomplete': ( + 'This saved search contains only a bounded or incomplete result. Its discarded content cannot ' + 'be recovered from the checkpoint. Review the source selection and create a new plan.' + ), 'result_input_too_large': 'The complete named inputs exceed the selected model budget. No input was truncated. Use a larger model or revise the plan.', 'result_not_ready': 'Required computation is still pending. Its result is not ready to consume.', 'result_commit_unconfirmed': 'The producer stopped before its retained completion checkpoint was confirmed. Its work will not be repeated automatically.', @@ -2102,6 +2110,7 @@ def failure_is_transient(failure): # Failures in which a service declined or rejected the planned request itself. A checkpoint # retry resends exactly the same request, so it would reproduce them. REQUEST_REFUSAL_FAILURE_CODES = frozenset({ + 'search_result_too_large', 'result_input_too_large', 'image_content_refused', 'image_request_invalid', 'operation_recovery_required', }) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index e7619c8d5..129a2b019 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -340,7 +340,7 @@ vision_thought_text, ) from functions_image_references import references_from_metadata -from functions_appinsights import log_event +from functions_appinsights import log_chat_execution_outcome, log_event from functions_debug import debug_print from functions_governance import ensure_governance_access from functions_notifications import create_chat_response_notification @@ -8907,10 +8907,25 @@ def get_cancel_reason(self): def get_terminal_replacement(self): return self._terminal_replacement or self._get_metadata().get("terminal_replacement") + def _log_outcome(self, metadata): + status = metadata.get("status") + outcome = ( + "cancelled" if status == STREAM_STATUS_CANCELED + else "failed" if status == STREAM_STATUS_ERROR else "completed" + ) + code = "user_cancelled" if outcome == "cancelled" else "stream_failed" if outcome == "failed" else None + log_chat_execution_outcome( + execution_path="direct", status=outcome, outcome=outcome, + conversation_id=self.conversation_id, + turn_id=metadata.get("user_message_id") or self._analysis_message_id, + failure={"code": code}, application_version=VERSION, + ) + def retract(self, payload): if not isinstance(payload, dict) or not payload.get("blocked"): raise ValueError("A terminal content replacement is required.") with self._condition: + was_terminal = self._get_metadata().get("status") in TERMINAL_STREAM_STATUSES self._terminal_replacement = strip_private_chat_checks(payload) self._accepting_events = False metadata = self._build_metadata(active=False, existing=self._get_metadata()) @@ -8927,6 +8942,8 @@ def retract(self, payload): ttl_seconds=self.session_ttl_seconds, ) self._condition.notify_all() + if not was_terminal: + self._log_outcome(metadata) return True def publish(self, event_text): @@ -8948,7 +8965,9 @@ def publish(self, event_text): ) is_terminal_event = isinstance(payload, dict) and (payload.get('done') or payload.get('error') or is_cancel_event) - metadata = self._build_metadata(active=not is_terminal_event, existing=self._get_metadata()) + existing = self._get_metadata() + was_terminal = existing.get("status") in TERMINAL_STREAM_STATUSES + metadata = self._build_metadata(active=not is_terminal_event, existing=existing) if isinstance(payload, dict) and payload.get('user_message_id'): metadata['user_message_id'] = payload['user_message_id'] if isinstance(payload, dict) and payload.get("message_id") and payload.get("type") != "user_message_persisted": @@ -9005,8 +9024,14 @@ def publish(self, event_text): ) if is_terminal_event: + if not was_terminal: + self._log_outcome(metadata) + cancelled = metadata.get('status') == STREAM_STATUS_CANCELED log_event( - '[Streaming] Stream session completed' if metadata.get('status') == STREAM_STATUS_COMPLETED else '[Streaming] Stream session failed', + '[STREAMING] Stream session cancelled' if cancelled else ( + '[STREAMING] Stream session completed' if metadata.get('status') == STREAM_STATUS_COMPLETED + else '[STREAMING] Stream session failed' + ), extra={ 'conversation_id': self.conversation_id, 'user_id': self.user_id, @@ -9020,7 +9045,7 @@ def publish(self, event_text): 'reattach_count': metadata.get('reattach_count'), 'last_error': metadata.get('last_error'), }, - level=logging.INFO if metadata.get('status') == STREAM_STATUS_COMPLETED else logging.ERROR, + level=logging.INFO if metadata.get('status') == STREAM_STATUS_COMPLETED or cancelled else logging.ERROR, ) with self._condition: @@ -9040,6 +9065,7 @@ def close(self): if metadata['status'] == STREAM_STATUS_CANCELED: metadata['canceled_at'] = metadata.get('canceled_at') or metadata['completed_at'] self._persist_metadata(metadata) + self._log_outcome(metadata) log_event( '[STREAMING] Stream session closed without explicit terminal event', extra={ diff --git a/application/single_app/route_backend_orchestration.py b/application/single_app/route_backend_orchestration.py index d9f621658..917d38b44 100644 --- a/application/single_app/route_backend_orchestration.py +++ b/application/single_app/route_backend_orchestration.py @@ -86,6 +86,7 @@ HISTORY_SCAN_LIMIT, CatalogResolutionError, ConversationContextError, + authorize_conversation_source_access, build_capability_request_context as _capability_request_context, build_conversation_snapshot, build_conversation_signals, @@ -184,6 +185,7 @@ from functions_orchestration_planner import ( ConversationResolutionError, PlannerError, + conversation_retrieval_allowed, plan_request, resolve_conversation_request, ) @@ -642,6 +644,7 @@ def _authorize_context_conversation(conversation_id, user_id): raise ConversationContextError('That conversation could not be opened.') from None if conversation.get('user_id') != user_id or conversation.get('orchestration_deleted'): raise ConversationContextError('That conversation could not be opened.') + authorize_conversation_source_access(conversation, user_id) return conversation @@ -1186,6 +1189,8 @@ def _validate_saved_conversation_context(snapshot, conversation_id, user_id): def _conversation_context_for_run(record, user_id, settings): conversation_id = record['conversation_id'] _authorize_context_conversation(conversation_id, user_id) + if history_message_limit(settings) == 0 and (record.get('conversation_context') or {}).get('messages'): + raise ConversationContextError('Conversation history is disabled. Create a new request without prior content.') for context in record.get('analysis_result_contexts') or []: load_saved_analysis(user_id, context) if record.get('user_message_id'): @@ -2664,7 +2669,7 @@ def generate(): return resolution = resolve_conversation_request( message, snapshot, settings=settings, answered_questions=answered_record, - planner_model=planner_model, + planner_model=planner_model, seeds=seeds, ) reasoning_adjustments = build_model_reasoning_metadata( planner_model, 'planner', @@ -2677,12 +2682,10 @@ def generate(): turn_context['planning_token_usage'] = planning_usage if resolution['relationship'] == 'clarification': if not allow_elicitation: - resolution.update({ - 'relationship': 'follow_up', - 'resolved_message': message, - 'message_ids': [item['id'] for item in snapshot['messages']], - 'requires_retrieval': False, - }) + raise PlannerError( + 'Select or paste the text you want to use, then create a new request.', + reason='conversation_target_required', + ) if resolution['relationship'] == 'clarification': question = resolution['clarification'] elicitation = normalize_elicitation({ @@ -2720,11 +2723,7 @@ def generate(): turn_context['memory_scope'] = memory_context['scope'] for notice in memory_context['notices']: yield build_planning_thought(notice) - if ( - resolution.get('requires_retrieval') is False - and not seeds.get('document_ids') and not seeds.get('elicitation_references') - and not seeds.get('public_workspace_selection') - ): + if not conversation_retrieval_allowed(resolution, message, seeds): candidates = [] else: candidates, _probed = resolve_candidate_documents( diff --git a/application/v2_ui/src/components/chat/Composer.tsx b/application/v2_ui/src/components/chat/Composer.tsx index 68f91dde0..da3114b1b 100644 --- a/application/v2_ui/src/components/chat/Composer.tsx +++ b/application/v2_ui/src/components/chat/Composer.tsx @@ -2,7 +2,7 @@ // The message input surface: textarea, send/stop control, model / agent / prompt pickers // and the capability toggles that map onto the /api/chat/stream request fields. -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useLocation, useSearchParams } from 'react-router-dom'; import { clsx } from 'clsx'; import { @@ -98,6 +98,11 @@ import { } from '../../lib/mentions'; import { useUiStore } from '../../stores/uiStore'; import { toast } from '../../stores/toastStore'; +import { + adoptConversationModeDraft, + conversationModes, + rememberConversationMode, +} from '../../lib/conversationMode'; import { chatWidthClass } from '../../lib/chatWidth'; import { getModelSupportedLevels, @@ -366,23 +371,26 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st const orchestrationAvailable = Boolean( features.enable_chat_orchestration && orchestrationConfig?.enabled, ); - const [orchestrationOn, setOrchestrationOn] = useState(orchestrationAvailable && !initialAgentSelection); - // Whether the user has expressed an opinion. The bootstrap resolves after the first - // render, so the deployment's answer has to be adopted when it lands -- but adopting it - // unconditionally would switch orchestration back on every time the payload refreshed, - // overriding somebody who had just turned it off. - const orchestrationChosen = useRef(Boolean(initialAgentSelection)); + const modeAccountId = bootstrap?.user?.id ?? ''; + const modeRevision = useSyncExternalStore(conversationModes.subscribe, conversationModes.getRevision); + // Resolve against this render's identity/thread, never an effect's previous conversation. + const savedMode = useMemo( + () => conversationModes.read(modeAccountId, activeConversationId), + [modeAccountId, activeConversationId, modeRevision], + ); useEffect(() => { - if (!orchestrationChosen.current) { - setOrchestrationOn(orchestrationAvailable); + if (savedMode.storageFailed) { + toast.error('Your saved mode choice could not be read in this browser. Choose your chat mode again.'); } - }, [orchestrationAvailable]); + }, [savedMode]); + const orchestrating = (savedMode.choice ?? true) && orchestrationAvailable + && !options.agentSelection && !answeringFromStoredResult; const toggleOrchestration = () => { clearAnalysisResultContext(); - orchestrationChosen.current = true; - setOrchestrationOn((on) => answeringFromStoredResult ? true : !on); + const next = !orchestrating; + if (next) setOptions((current) => ({ ...current, agentSelection: undefined })); + rememberConversationMode(modeAccountId, activeConversationId, next); }; - const orchestrating = orchestrationOn && orchestrationAvailable && !answeringFromStoredResult; // The disclosure that hides the manual controls while orchestrating. Only reachable when the // administrator leaves them reachable; otherwise the planner owns every decision and there is @@ -1130,6 +1138,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st } if (!activeConversationId && uploadConversationRef.current) { adoptedUploadConversationRef.current = uploadConversationRef.current; + adoptConversationModeDraft(conversationModes.captureDraft(modeAccountId), uploadConversationRef.current); useChatStore.setState({ activeConversationId: uploadConversationRef.current, activeConversationKind: 'personal', @@ -1274,6 +1283,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st const conversationId = activeConversationId ?? uploadConversationRef.current; if (!activeConversationId && conversationId) { adoptedUploadConversationRef.current = conversationId; + adoptConversationModeDraft(conversationModes.captureDraft(modeAccountId), conversationId); useChatStore.setState({ activeConversationId: conversationId, activeConversationKind: 'personal' }); } void startOrchestrationPlan({ @@ -1341,8 +1351,8 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st } setDraft((current) => ({ ...current, text: draftHandoff.text })); if (draftHandoff.orchestrate && orchestrationAvailable) { - orchestrationChosen.current = true; - setOrchestrationOn(true); + // The new chat uses the deployment default; a page handoff is not a saved toggle choice. + setOptions((current) => ({ ...current, agentSelection: undefined })); } setSearchParams((current) => current, { replace: true, state: null }); window.requestAnimationFrame(() => { diff --git a/application/v2_ui/src/components/layout/Sidebar.tsx b/application/v2_ui/src/components/layout/Sidebar.tsx index aaa0507a9..f24ab481b 100644 --- a/application/v2_ui/src/components/layout/Sidebar.tsx +++ b/application/v2_ui/src/components/layout/Sidebar.tsx @@ -38,6 +38,7 @@ import { Users, } from 'lucide-react'; import { useUiStore } from '../../stores/uiStore'; +import { clearConversationModes } from '../../lib/conversationMode'; import { useBootstrapStore } from '../../stores/bootstrapStore'; import { useChatStore } from '../../stores/chatStore'; import { classicChatHref } from '../../lib/conversationUrl'; @@ -263,6 +264,7 @@ function UserMenu({ collapsed }: { collapsed: boolean }) { clearConversationModes(user?.id ?? '')} className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-danger hover:bg-danger-soft" > Sign out diff --git a/application/v2_ui/src/lib/conversationMode.ts b/application/v2_ui/src/lib/conversationMode.ts new file mode 100644 index 000000000..e14b7f400 --- /dev/null +++ b/application/v2_ui/src/lib/conversationMode.ts @@ -0,0 +1,170 @@ +// conversationMode.ts +// Only explicit choices are saved; a missing entry continues to use the deployment default. + +import { toast } from '../stores/toastStore'; + +export const CONVERSATION_MODE_PREFIX = 'simplechat:v2:conversation-mode:'; + +interface ModeChoice { + choice: boolean | undefined; + storageFailed: boolean; +} + +export interface DraftModeChoice { + accountId: string; + draftId: number; + session: number; + choice: boolean | undefined; +} + +export class ConversationModeMemory { + private choices = new Map(); + private draftChoices = new Map(); + private draftId = 0; + private session = 0; + private revision = 0; + private listeners = new Set<() => void>(); + private storage: () => Storage; + + constructor(storage: () => Storage) { + this.storage = storage; + } + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getRevision = (): number => this.revision; + + private changed(): void { + this.revision += 1; + this.listeners.forEach((listener) => listener()); + } + + private key(accountId: string, conversationId: string): string { + return `${CONVERSATION_MODE_PREFIX}${encodeURIComponent(accountId)}:${encodeURIComponent(conversationId)}`; + } + + read(accountId: string, conversationId: string | null): ModeChoice { + if (!conversationId) { + return { choice: this.draftChoices.get(accountId), storageFailed: false }; + } + const key = this.key(accountId, conversationId); + let result = this.choices.get(key); + // Choices made before identity resolves can be local, but cannot read browser persistence. + if (!accountId) return result ?? { choice: undefined, storageFailed: false }; + if (!result) { + try { + const value = this.storage().getItem(key); + result = { choice: value === 'on' ? true : value === 'off' ? false : undefined, storageFailed: false }; + } catch { + result = { choice: undefined, storageFailed: true }; + } + this.choices.set(key, result); + } + return result; + } + + choose(accountId: string, conversationId: string | null, choice: boolean): boolean { + let saved = true; + if (!conversationId) { + this.draftChoices.set(accountId, choice); + } else { + const key = this.key(accountId, conversationId); + // Keep the chosen mode usable for this page even if browser storage is unavailable. + this.choices.set(key, { choice, storageFailed: false }); + if (accountId) { + try { + this.storage().setItem(key, choice ? 'on' : 'off'); + } catch { + saved = false; + } + } + } + this.changed(); + return saved; + } + + captureDraft(accountId: string): DraftModeChoice { + return { accountId, draftId: this.draftId, session: this.session, choice: this.draftChoices.get(accountId) }; + } + + adoptDraft(draft: DraftModeChoice, conversationId: string): boolean { + // Logout/account reset invalidates late creation responses, not just the visible draft. + if (draft.session !== this.session) return true; + const saved = draft.choice === undefined || !draft.accountId + ? true : this.choose(draft.accountId, conversationId, draft.choice); + // A response for a draft already left must not consume the next new chat's choice. + if (draft.draftId === this.draftId) this.resetDraft(); + return saved; + } + + resetDraft(): void { + this.draftChoices.clear(); + this.draftId += 1; + this.changed(); + } + + reset(): void { + this.choices.clear(); + this.session += 1; + this.resetDraft(); + } + + forget(accountId: string, conversationId: string): boolean { + if (!accountId) return true; + const key = this.key(accountId, conversationId); + this.choices.set(key, { choice: undefined, storageFailed: false }); + let removed = true; + try { + this.storage().removeItem(key); + } catch { + removed = false; + } + this.changed(); + return removed; + } + + forgetAccount(accountId: string): boolean { + let removed = true; + try { + const storage = this.storage(); + const prefix = `${CONVERSATION_MODE_PREFIX}${encodeURIComponent(accountId)}:`; + const keys = Array.from({ length: storage.length }, (_, index) => storage.key(index)); + for (const key of keys) { + if (key?.startsWith(prefix)) storage.removeItem(key); + } + } catch { + removed = false; + } + this.reset(); + return removed; + } +} + +export const conversationModes = new ConversationModeMemory(() => window.localStorage); + +export function rememberConversationMode(accountId: string, conversationId: string | null, choice: boolean): void { + if (!conversationModes.choose(accountId, conversationId, choice)) { + toast.error('Your mode choice could not be saved in this browser. It applies until this page is reloaded.'); + } +} + +export function adoptConversationModeDraft(draft: DraftModeChoice, conversationId: string): void { + if (!conversationModes.adoptDraft(draft, conversationId)) { + toast.error('Your mode choice could not be saved in this browser. It applies until this page is reloaded.'); + } +} + +export function forgetConversationMode(accountId: string, conversationId: string): void { + if (!conversationModes.forget(accountId, conversationId)) { + toast.error('The conversation was removed, but its mode choice could not be cleared from this browser.'); + } +} + +export function clearConversationModes(accountId: string): void { + if (!conversationModes.forgetAccount(accountId)) { + toast.error('Saved mode choices could not be cleared from this browser. Clear site data on a shared device.'); + } +} diff --git a/application/v2_ui/src/lib/orchestrationController.ts b/application/v2_ui/src/lib/orchestrationController.ts index 78192d4d3..ca2218ecb 100644 --- a/application/v2_ui/src/lib/orchestrationController.ts +++ b/application/v2_ui/src/lib/orchestrationController.ts @@ -16,6 +16,8 @@ import { createConversation, type AttemptOrchestrationRequest } from './endpoints'; import { ApiError } from './apiClient'; +import { adoptConversationModeDraft, conversationModes } from './conversationMode'; +import { useBootstrapStore } from '../stores/bootstrapStore'; import type { CredentialRequest } from './actionCredentialTypes'; import { legacyPlanErrorMessage } from './orchestrationErrors'; import { @@ -212,8 +214,10 @@ async function ensureConversation( // Taken before the round trip: New chat leaves the id null too, so only the epoch says the // reader has moved on to a different new chat. const epoch = currentConversationEpoch(); + const draftMode = conversationModes.captureDraft(useBootstrapStore.getState().data?.user?.id ?? ''); try { const created = await createConversation(message); + adoptConversationModeDraft(draftMode, created.conversation_id); const chat = useChatStore.getState(); if (chat.activeConversationId === null && currentConversationEpoch() === epoch) { useChatStore.setState({ diff --git a/application/v2_ui/src/stores/bootstrapStore.ts b/application/v2_ui/src/stores/bootstrapStore.ts index 59b9e2062..03e130676 100644 --- a/application/v2_ui/src/stores/bootstrapStore.ts +++ b/application/v2_ui/src/stores/bootstrapStore.ts @@ -8,6 +8,7 @@ import { fetchActiveScope, fetchBootstrap } from '../lib/endpoints'; import { ApiError, isAccessRestricted, isTermsOfUseRequired } from '../lib/apiClient'; import type { BootstrapPayload, PromptOption } from '../lib/types'; import { useAppSessionStore } from './appSessionStore'; +import { conversationModes } from '../lib/conversationMode'; /** * Orders concurrent refreshes so a slower earlier one cannot land after a newer one. @@ -280,6 +281,18 @@ export const useBootstrapStore = create((set, get) => ({ }), })); +useBootstrapStore.subscribe((state, previous) => { + if (state.data?.user?.id !== previous.data?.user?.id) { + conversationModes.reset(); + } +}); + +useAppSessionStore.subscribe((state, previous) => { + if (state.status === 'changed' && previous.status !== 'changed') { + conversationModes.reset(); + } +}); + /** Read a feature flag, defaulting to off when bootstrap has not resolved. */ export function useFeature(key: string): boolean { return useBootstrapStore((state) => Boolean(state.data?.features?.[key])); diff --git a/application/v2_ui/src/stores/chatStore.ts b/application/v2_ui/src/stores/chatStore.ts index 256d4146e..5685ae5cc 100644 --- a/application/v2_ui/src/stores/chatStore.ts +++ b/application/v2_ui/src/stores/chatStore.ts @@ -152,6 +152,7 @@ import { getAppNavigator, subscribeRouteChanges, type AppRoute } from '../lib/ap import { readConversationParam } from '../lib/conversationUrl'; import { refreshNotificationCount } from './notificationStore'; import { useBootstrapStore } from './bootstrapStore'; +import { adoptConversationModeDraft, conversationModes, forgetConversationMode } from '../lib/conversationMode'; import { useCollaborationStore, participantName } from './collaborationStore'; import { chatPendingActionsStore } from './m365PendingActionsStore'; import { latestUserMessageId } from '../lib/m365PendingActions'; @@ -2401,6 +2402,7 @@ export const useChatStore = create((set, get) => ({ stopCollaborationEvents(); useCollaborationStore.getState().reset(); conversationEpoch += 1; + conversationModes.resetDraft(); // Known from the rail row when there is one, which is the common case and costs no // request. A conversation opened from a link is not in the list yet, so its kind is @@ -2621,6 +2623,7 @@ export const useChatStore = create((set, get) => ({ stopCollaborationEvents(); useCollaborationStore.getState().reset(); conversationEpoch += 1; + conversationModes.resetDraft(); // The conversation row is created by the server on first send, so a new chat is // purely a local reset until then. This avoids leaving empty conversations behind @@ -2697,6 +2700,7 @@ export const useChatStore = create((set, get) => ({ * member is refused, and posting `leave` as the sole owner would strand the thread. */ removeConversation: async (conversationId, decidedAction) => { + const modeAccountId = useBootstrapStore.getState().data?.user?.id ?? ''; const previous = get().conversations; const listed = previous.find((conversation) => conversation.id === conversationId); const collaborative = isCollaborativeConversation(get(), conversationId); @@ -2733,6 +2737,7 @@ export const useChatStore = create((set, get) => ({ await deleteConversationApi(conversationId); } forgetOrchestrationConversation(conversationId); + forgetConversationMode(modeAccountId, conversationId); } catch (error) { set({ conversations: previous }); toast.error( @@ -2839,6 +2844,7 @@ export const useChatStore = create((set, get) => ({ * posted as a delete would destroy a thread other people are still using. */ bulkRemoveConversations: async () => { + const modeAccountId = useBootstrapStore.getState().data?.user?.id ?? ''; const previous = get().conversations; const targets = selectedConversations(previous, get().selectedConversationIds); if (targets.length === 0) { @@ -2867,7 +2873,11 @@ export const useChatStore = create((set, get) => ({ if (personalIds.length > 0) { const result = await deleteConversationsApi(personalIds); - failed += (result?.failed_ids ?? []).length; + const failedIds = new Set(result?.failed_ids ?? []); + failed += failedIds.size; + for (const id of personalIds) { + if (!failedIds.has(id)) forgetConversationMode(modeAccountId, id); + } } if (removals.length > 0) { @@ -2879,6 +2889,11 @@ export const useChatStore = create((set, get) => ({ ), ); failed += outcomes.filter((outcome) => outcome.status === 'rejected').length; + outcomes.forEach((outcome, index) => { + if (outcome.status === 'fulfilled') { + forgetConversationMode(modeAccountId, removals[index].id); + } + }); } const message = partialFailureMessage(targets.length, failed, 'delete', 'deleted'); @@ -3075,9 +3090,11 @@ export const useChatStore = create((set, get) => ({ // Taken before the round trip: New chat leaves `activeConversationId` null too, so // null alone cannot say the reader is still in the chat this was sent from. const epoch = conversationEpoch; + const draftMode = conversationModes.captureDraft(useBootstrapStore.getState().data?.user?.id ?? ''); try { const created = await createConversation(trimmed); conversationId = created.conversation_id; + adoptConversationModeDraft(draftMode, conversationId); // The reader can open a conversation during that round trip, and if they do // their click wins. The message is still sent and its answer still generated // and saved — it appears in the rail and is there when they come back — but @@ -3422,9 +3439,11 @@ export const useChatStore = create((set, get) => ({ // As in sendMessage: only the epoch tells "still in this new chat" apart from // "started another one" while the conversation is being created. const epoch = conversationEpoch; + const draftMode = conversationModes.captureDraft(useBootstrapStore.getState().data?.user?.id ?? ''); try { const created = await createConversation(trimmed); conversationId = created.conversation_id; + adoptConversationModeDraft(draftMode, conversationId); if (get().activeConversationId === null && conversationEpoch === epoch) { set({ activeConversationId: conversationId, activeConversationKind: 'personal' }); useCollaborationStore.getState().setActiveConversation(conversationId); diff --git a/docs/explanation/features/CHAT_ORCHESTRATION.md b/docs/explanation/features/CHAT_ORCHESTRATION.md index f57841cd8..97b7a2630 100644 --- a/docs/explanation/features/CHAT_ORCHESTRATION.md +++ b/docs/explanation/features/CHAT_ORCHESTRATION.md @@ -1,6 +1,6 @@ # Chat Orchestration -**Version: 0.261.256** (tracked in `application/single_app/config.py`) +**Version: 0.261.341** (tracked in `application/single_app/config.py`) **Implemented in version: 0.261.086** **Knowledge phase added in version: 0.261.089** @@ -25,6 +25,7 @@ **Gather / Reason / Render made the only orchestration contract in version: 0.261.139** **Photorealistic planned images and reference images implemented in version: 0.261.192** **One progress indicator per phase (Planning label, run progress on the plan card only) fixed in version: 0.261.256** +**Conversation-first source retention, conversation-mode persistence, and semantic diagnostics fixed in version: 0.261.341** ## Overview @@ -624,12 +625,32 @@ The normal setting default is 10; a missing setting falls back to six. Zero disa history. Older content is trimmed first, and truncation is identified explicitly rather than silently clipping every message to 300 characters. +Since **0.261.341**, the bounded snapshot is a planning preview, not the +authoritative source text for a requested transformation. Turn-aware references +preserve the identity of eligible user material even when a long answer or +pasted message cannot fit that preview. Execution reopens selected saved +messages, verifies their current access and content revision, and checks the +full input against the selected model's budget. It does not silently substitute +the clipped preview or reopen masked/hidden content. Count and reference-metadata +limits can still exclude older whole turns; this is not an unlimited transcript. +If the intended conversation source is absent or ambiguous, +the resolver asks for clarification instead of assuming a search across all +workspaces. Existing explicit source selections retain their precedence. + Masked text, inactive attempts, hidden generated artifacts, system/tool records, and binary payloads are excluded. Current block revisions are respected. The snapshot and its source fingerprints are saved with the plan, so a reload or a delayed approval uses the same context. Changes to the referenced messages or their visibility require a new plan; newly appended turns do not enter an older run. +The V2 Orchestrate on/off choice is also remembered per account and conversation +in the browser, separately from account-level approval/model settings. +`[CHAT_EXECUTION]` records safe semantic outcomes for direct and orchestrated +requests, not just transport success. See the +[chat reliability fix](../fixes/ORCHESTRATION_CHAT_RELIABILITY_FIX.md) for +source-completeness boundaries, scheduler containment, diagnostic queries, and +production verification limits. + These rules apply to Auto, countdown, and manual approval. They do not introduce rolling summaries or cross-conversation transcript lookup. Existing scoped saved memories are separate from this history window. First turns without history and simple diff --git a/docs/explanation/features/ORCHESTRATION_GATHER_REASON_RENDER.md b/docs/explanation/features/ORCHESTRATION_GATHER_REASON_RENDER.md index 0f5dda369..868a63843 100644 --- a/docs/explanation/features/ORCHESTRATION_GATHER_REASON_RENDER.md +++ b/docs/explanation/features/ORCHESTRATION_GATHER_REASON_RENDER.md @@ -1,6 +1,15 @@ # Gather / Reason / Render orchestration -**Version: 0.261.309** +**Version: 0.261.341** + +Search/result integrity fixed in version: **0.261.341**, recorded in +`application/single_app/config.py`. Search retains authoritative returned +content before creating bounded previews. Preview clipping alone does not +contaminate a complete source-identity selection. Selection completeness, +evidence coverage, and exhaustive corpus coverage remain distinct; an +incomplete upstream result still cannot become complete through a downstream +Analyze or Compose task. See the +[chat reliability fix](../fixes/ORCHESTRATION_CHAT_RELIABILITY_FIX.md). Document-derived handoff reliability implemented in version: **0.261.309**, recorded in `application/single_app/config.py`. Native Analyze requests explicit diff --git a/docs/explanation/fixes/ORCHESTRATION_CHAT_RELIABILITY_FIX.md b/docs/explanation/fixes/ORCHESTRATION_CHAT_RELIABILITY_FIX.md new file mode 100644 index 000000000..d02e2285a --- /dev/null +++ b/docs/explanation/fixes/ORCHESTRATION_CHAT_RELIABILITY_FIX.md @@ -0,0 +1,283 @@ +# Orchestration Chat Reliability Fix + +**Version: 0.261.342** + +Fixed/Implemented in version: **0.261.341**, recorded in +`application/single_app/config.py`. + +Telemetry version validation hardened in version: **0.261.342**, with the +corresponding patch update in `application/single_app/config.py` (PR #1773). + +## Issue and root causes + +Two reported chat failures had different causes. A long direct conversation +completed successfully, but an orchestrated follow-up did not retain its pasted +source material. Its plan searched workspaces instead. Search preview clipping +then marked every output partial, including the source selection, and a +complete-only analysis correctly refused the partial input. Dependent writing +was skipped. The saved attempt did not show a provider context-window error. + +The other request failed before invoking the Control Center action. Its worker +did not forward authenticated roles to action discovery and exact manifest +resolution. That defect was already fixed in **0.261.329**; this change does +not introduce another authorization exception. See the +[Control Center role-propagation fix](CONTROL_CENTER_ORCHESTRATION_ROLE_PROPAGATION_FIX.md). + +Broader investigation identified two additional reliability problems: + +- The V2 Orchestrate choice lived in component state and could reset when the + composer remounted. +- A deleted workflow could raise during a pending Microsoft 365 continuation's + readiness check, abort later candidates, and remain eligible for the next + scheduler tick. Repeated errors were not evidence of unique failed workflows + or the demonstrated cause of either chat failure. + +An HTTP 200 for an established SSE response did not prove that the chat itself +completed. Direct-stream cancellation also used error-level failure logging. + +## Changes and impact + +Search results retain authoritative content separately from bounded UI/model +previews. Complete source identity selection is distinct from excerpt coverage +and from an exhaustive search of a corpus. A genuinely partial upstream result +still cannot be promoted to complete by analysis or composition. + +Full search hits are committed before preview construction. Overflow refuses +the operation instead of committing shortened data. Historical clipped +checkpoints remain incomplete and do not offer an unchanged retry as a repair. +Analysis carries the selected-source lineage and its limitations forward. +`search_result_too_large` identifies retention overflow; +`search_result_incomplete` identifies a retained search that cannot safely +serve as complete input. Neither is repaired by silently shortening the source. + +Conversation-first transformation requests use authorized saved-message +references rather than treating a shortened planning snapshot as the full +source. Missing or ambiguous targets require clarification; full input must +fit the selected model's verified budget. Earlier assistant claims remain +conversation content, not independently verified document evidence. + +Full input means selected eligible text after existing masking, block-revision, +moderation, and saved-analysis checks, not raw hidden content. When transforming +a selected assistant answer, its associated user source is included. Discovery +still has a 16 KiB budget and reference/count limits, so older whole turns can +be excluded. Oversized input fails explicitly; there is no automatic splitting. +Exact, untruncated legacy snapshots can remain compatible after current checks, +but clipped or count-truncated legacy snapshots require replanning. + +The V2 execution-mode choice is associated with the signed-in account and +conversation in the browser. Returning to a conversation or reloading should +not silently switch a deliberately direct conversation into Orchestrate. +Capability availability remains authoritative; remembering a choice never +enables an unavailable capability. + +Microsoft 365 continuation checks isolate individual failures. A typed, +confirmed missing workflow can leave the pending queue through a conditional +terminal update. Transient service failures are not classified as deletion, +and uncertain external effects require recovery rather than automatic replay. + +`[CHAT_EXECUTION]` records direct and orchestration outcomes independently of +transport status. Events contain categorical outcomes, safe failure and +capability codes, hashed identifiers, and the application version when known. +Orchestration records confirmed saved outcomes rather than declaring success +merely because an HTTP request returned 200. Cancellation is informational. +Prompts, replies, source text, raw identities, and credentials are not included. + +CodeQL found unbounded regular-expression validation of application versions. +Both the outcome producer and logger now share a non-regex parser that rejects +the entire value above 64 characters before parsing. Within that limit it +preserves the original three nonempty decimal-component format, including +leading zeroes and Unicode decimal digits. Malformed and oversized versions +are omitted, not truncated into an apparently valid version; the logger may +retain only their length. + +## Implementation boundaries + +| Area | Main files | +| --- | --- | +| Search retention and completeness | `functions_orchestration_adapters.py`, `functions_orchestration_result_runtime.py`, analysis result and recovery owners | +| Failure and dependency semantics | `functions_orchestration_schema.py`, `functions_orchestration_executor.py`, `functions_orchestration_recovery.py` | +| Conversation source resolution | `functions_orchestration_context.py`, `functions_orchestration_planner.py`, composition and execution owners, `route_backend_orchestration.py` | +| Conversation mode | V2 `conversationMode.ts`, `Composer.tsx`, chat/bootstrap stores and conversation lifecycle callers | +| Continuation containment | `functions_m365_continuations.py`, `functions_m365_runtime.py`, `background_tasks.py` | +| Semantic diagnostics | `functions_appinsights.py`, `functions_orchestration_execution.py`, `route_backend_chats.py` | +| Application version | `application/single_app/config.py`: reliability fix `0.261.341`, telemetry validation hardening `0.261.342` | + +No new route, capability setting, deployment setting, source permission, model +context limit, or automatic retry policy is added. + +## Validation + +Regression coverage includes authoritative search bytes and genuine partial +lineage, long conversation references and input budgets, account/conversation +mode isolation, deleted/transient/conflicting continuation cases, and semantic +outcome privacy and cancellation. The execution harness also verifies durable +publication, authorization revocation, and cached SSE replay without another +execution outcome. + +Version-validation regressions exercise both telemetry boundaries with valid, +malformed, non-string and 100,000-character inputs, length-limit cases, and +private suffixes. They also verify that oversized input is rejected before +parsing and that no private content reaches event properties. + +The PR #1773 CodeQL follow-up passed 78 telemetry, logging-recursion and search +integrity tests, including native analysis with its required `internal` pytest +fixture. The 26 application-version cases also passed under optimized Python. +All 11 conversation-mode browser tests and 13 documentation checks passed. +The mandatory network-blocked Cosmos gate passed 971 tests and 244 subtests, +with 1,059 owners, 404 doubles and no review gaps; its inventory was unchanged. +The genuinely unused browser harness import was removed; the imported +`connect_options`, `approval_assets` and `approval_browser` fixtures remain +necessary for pytest's name-based dependency injection. + +Related tests: + +- `functional_tests/test_orchestration_search_result_integrity.py` +- `functional_tests/test_orchestration_conversation_sources.py` +- `functional_tests/test_v2_conversation_mode_logic.mjs` +- `ui_tests/test_v2_conversation_mode_persistence.py` +- `functional_tests/test_m365_continuations.py` +- `functional_tests/test_m365_workflow_continuation_boundary.py` +- `functional_tests/test_orchestration_failure_telemetry.py` +- `functional_tests/test_chat_content_streaming.py` +- `functional_tests/test_control_center_orchestration_roles.py` + +The original Control Center role and dependency baseline passed **112 tests** +in an isolated environment with the application's pinned Flask, Werkzeug, and +Semantic Kernel versions. The initial semantic-outcome and direct-stream run +passed **39 tests and 4 subtests**. + +Additional completed checks: + +| Check | Result | +| --- | --- | +| Search retention, native analysis, dependency runtime, result/recovery safety | 322 passed | +| Conversation source and context regressions | 74 passed, 27 subtests | +| Microsoft 365 continuation, SDK boundary, authorization and operation integration | 204 passed, 32 subtests | +| Optimized continuation/boundary regressions | 44 passed, 13 subtests | +| Real execution/publication harness, Control Center roles and write fences | 684 passed, 2 expected failures | +| Combined semantic diagnostics, direct streaming and dependency runtime | 134 passed, 4 subtests | +| Final combined context, search, continuation, diagnostics, direct streaming and Control Center regressions | 146 passed, 17 subtests | +| V2 TypeScript check and production build | Passed | +| Browser mode persistence, approval preference and existing composer | 11, 23 and 7 passed respectively | +| Browser saved-run recovery | 35 passed | +| Browser conversation-context transport | 5 passed, 3 subtests | +| Account/conversation mode logic | 5 Node tests passed | +| Reviewed Cosmos response-boundary inventory | 1,059 owners, 404 doubles, 0 review gaps | +| Mandatory network-blocked Cosmos contract gate | 945 passed, 244 subtests passed | +| Documentation surface coverage and site quality | 13 passed | + +The final combined 146-test run was repeated with the manifest-pinned +`azure-cosmos==4.9.0`. Missing/mismatched validation dependencies were restored +only in the session's isolated Python environment; application requirements and +shared Python installations were not changed. Tokenizer data is warmed in a +session cache so network-blocked tests do not download assets while running. +The full `scripts/check_cosmos_response_boundaries.py --run-contracts` gate +passed after restoring the declared `yamcs-client==2.1.0` dependency in that +same isolated environment. Missing validation dependencies were not treated as +application defects, and the contract checks were not weakened. + +Browser suites with different Playwright fixture lifecycles run in separate +processes. The recovery fixture now answers the existing pending-credentials +lookup with an empty request list, rather than treating that unrelated +background lookup as an unexpected API call. + +Integration with the timeline-delivery fix preserves its single visual +correction allowance. Its prior-answer fixture now records the selected +message in `request_resolution`, matching the production context contract; +merely storing a conversation snapshot does not select every message for use. + +An extra broad logging run found two pre-existing failures: nonstandard tags +in unchanged modules, and a legacy test expecting a pre-sanitization debug +message. Both were reproduced against `HEAD` without changing the worktree. +This change adds no missing or stale documented logging tag. Those unrelated +checks were not weakened to make the run pass. + +A broader planner/route/harness run passed **803 tests with 2 expected +failures**, but had **10 setup errors** in an older AST-extraction diagnostic +fixture. That fixture omits `normalize_public_workspace_selection` from the +namespace used by `resolve_seeds`. Both the failing production function and +the extraction fixture are unchanged from `HEAD`; attribution is based on +that source identity, not a clean-HEAD rerun of the entire suite. + +### Configured production image evidence + +Read-only registry inspection identified the configured image digest +`sha256:f36fb6a60c7205923639118aefababc6a7f3ab7b455d4bd6a6d72eaa6fbb5118` +as application **0.261.337**. Its +`functions_orchestration_external_sources.py` matches the already-fixed +worktree source after newline normalization. The file's SHA-256 is +`1f792e425bbc54a3cd7fa6fd5dfad7aa5e54c16e3d7611c0a57d4c675116cd96`. +The image has no OCI revision/version labels, so an exact repository commit +was not inferred from its timestamp. + +This verifies the configured immutable image's contents, not every active +worker or the end-to-end metrics/chart outcome. No production deployment, +conversation retry, continuation cleanup, or permissions change was performed. + +## Operational diagnosis + +Application Insights stores the useful fields in `Properties`; `Message` is +usually the literal `[SIMPLE_CHAT_LOG_EVENT]`. Do not count all HTTP 200 +orchestration requests as successful chats. + +### Semantic outcomes by execution path + +```kusto +AppTraces +| where TimeGenerated > ago(24h) +| where Message == "[SIMPLE_CHAT_LOG_EVENT]" +| extend p = todynamic(Properties) +| where tostring(p.sc_message) == "[CHAT_EXECUTION] Execution outcome recorded." +| summarize executions=count() + by path=tostring(p.sc_execution_path), + outcome=tostring(p.sc_outcome), + failure=tostring(p.sc_failure_code), + version=tostring(p.sc_application_version) +| order by executions desc +``` + +These are observed outcome transitions, not a denominator for every submitted +request. Waiting/resumption and separate retry attempts can produce different +transitions. Correlate hashed run and attempt fields when measuring unique runs; +telemetry sampling or a worker failure can also affect event counts. + +### Follow one conversation without logging its contents + +Compute the UTF-8 SHA-256 hex digest of the conversation identifier locally, +then substitute it below. Do not paste prompts, raw IDs, or keys into telemetry. + +```kusto +AppTraces +| where TimeGenerated > ago(7d) +| where Message == "[SIMPLE_CHAT_LOG_EVENT]" +| extend p = todynamic(Properties) +| where tostring(p.sc_conversation_id_hash) == "" +| project TimeGenerated, + event=tostring(p.sc_message), + path=tostring(p.sc_execution_path), + outcome=tostring(p.sc_outcome), + failure=tostring(p.sc_failure_code), + capability=tostring(p.sc_capability_id), + run=tostring(p.sc_run_id_hash), + step=tostring(p.sc_step_id_hash) +| order by TimeGenerated asc +| take 200 +``` + +### Check the historical stale-workflow error storm + +```kusto +AppServiceConsoleLogs +| where TimeGenerated > ago(24h) +| where ResultDescription contains "[WORKFLOW_SCHEDULER]" +| where ResultDescription contains "The workflow no longer exists." +| summarize events=count() by bin(TimeGenerated, 15m) +| order by TimeGenerated asc +``` + +After an explicitly approved rollout, confirm the running immutable build, +exercise fresh authorized direct/context and Control Center requests, and check +that stale errors stop while valid continuations still proceed. Do not treat +quiet logs alone as proof that workflow processing works. Saved-attempt retries +must pass their current recovery and authorization checks; no completed external +effect should be replayed to test the fix. diff --git a/docs/guides/review-and-edit-orchestration-plans.md b/docs/guides/review-and-edit-orchestration-plans.md index c40a1db71..55b6593eb 100644 --- a/docs/guides/review-and-edit-orchestration-plans.md +++ b/docs/guides/review-and-edit-orchestration-plans.md @@ -51,6 +51,23 @@ planner may Analyze or Compare those inputs without adding a Search step merely because they were pinned. Explicitly selected Search still constrains the plan, and the validator still rejects a plan that omits selected documents. +### Work from content already in the conversation + +Fixed/Implemented in version: **0.261.341** +(`application/single_app/config.py`). +When you ask to rewrite, audit, summarize, or otherwise transform material +already pasted into the conversation, that material is the source to resolve +first. A short planning preview is not the complete input. The execution step +reopens the referenced saved message with current access and content checks. +An earlier AI answer can be rewritten as text, but it is not independently +verified evidence for new factual claims. + +If the intended material is missing or unclear, identify it when the planner +asks for clarification. Review a proposed workspace search before running if +you wanted work on the conversation instead. A source that exceeds the +selected model's verified input budget needs a smaller selection or an +appropriate configured model; repeating the unchanged plan does not add space. + ## Review versus Edit Since **0.261.321**, integration steps show whether they **Gather information** or perform @@ -101,6 +118,12 @@ Named results are not download links. A retained result or a planned output does not prove that a file has been created. The interface does not count those results as completed file artifacts. +Since **0.261.341**, shortening a search preview does not by itself make the +retained source selection incomplete. The full retained result and its +coverage still govern downstream work. A complete top-N selection is not proof +that every relevant document or every passage in a document was examined. +Genuinely incomplete evidence still blocks a task that requires complete input. + When the server supplies an explicit `render_file` task, **Planned file** shows its intended name, format, profile, bound source, and options before approval. These are descriptions of requested work, not success links. Changing a file diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md index 7f568f600..5acfc999c 100644 --- a/docs/reference/chat-controls.md +++ b/docs/reference/chat-controls.md @@ -4,7 +4,7 @@ title: "Chat interface controls" description: "Reference for every documented control in the SimpleChat chat interface." section: "Reference" audience: user -version: "0.261.339" +version: "0.261.341" --- ## How to use this reference @@ -583,6 +583,19 @@ for the complete workflow. ## Orchestration approval (V2 interface) +Fixed/Implemented in version: **0.261.341** +(`application/single_app/config.py`). +The **Orchestrate** on/off choice is remembered separately for each conversation +and signed-in account in this browser. Navigating away, reopening the thread, +or reloading does not intentionally replace that choice with another +conversation's mode. This browser preference is not a cross-device account +setting. A remembered mode cannot enable orchestration when the administrator +has made it unavailable. + +This execution-mode choice is separate from the account-level approval and +model preferences below. A newly created conversation does not inherit a +different conversation's saved on/off choice. + Since **0.261.126**, Orchestrate has an **Orchestration model** picker. **Auto - choose per step** asks the server to choose an authorized connected model for each model-backed step; a specific model remains pinned. Planned and completed diff --git a/docs/reference/logging-tags.md b/docs/reference/logging-tags.md index bab828637..156673401 100644 --- a/docs/reference/logging-tags.md +++ b/docs/reference/logging-tags.md @@ -32,6 +32,16 @@ These diagnostics do not emit prompts, replies, document text/names, tags, regex literals, or raw actor identifiers. Their categorical/count fields survive the Application Insights sanitization boundary with debug logging on or off. +## Chat execution outcomes + +Since **0.261.341**, `[CHAT_EXECUTION]` records the semantic result of direct +chat and orchestration separately from the HTTP/SSE transport status. Fields +include `execution_path`, `status`, `outcome`, the application version when +known, hashed conversation/turn/run/step identifiers, and safe root failure +and capability codes. A cancelled stream is informational, not an error. +Orchestration records an outcome only after its saved state is confirmed; +replaying a cached response does not record another execution. + ## Current tag inventory - `[EDITOR_ASSIST]` @@ -88,6 +98,7 @@ Application Insights sanitization boundary with debug logging on or off. - `[CHAT_API_ERROR]` - `[CHAT_BOOTSTRAP_CACHE]` - `[CHAT_CONTENT_CHECKS]` +- `[CHAT_EXECUTION]` - `[CHAT_DOCUMENT_ACTION]` - `[CHAT_DOCUMENT_ANALYSIS]` - `[CHAT_TABULAR_SK]` diff --git a/functional_tests/cosmos_response_boundaries.json b/functional_tests/cosmos_response_boundaries.json index 438c6bfba..25d130308 100644 --- a/functional_tests/cosmos_response_boundaries.json +++ b/functional_tests/cosmos_response_boundaries.json @@ -140,7 +140,6 @@ "functional_tests/test_m365_action_card_api.py::CardContainer": "5b8f2d941400e8f05bec6782db6260bc4c075be15d8d0b9916c2a3c00d2e93af", "functional_tests/test_m365_action_lifecycle.py::MemoryContainer": "a8d9a2799d781e7080af5f2cb9edd7ec079ba2072f694d9eaf49f60ef77aca9a", "functional_tests/test_m365_chat_action_cards.py::run_offline_scenarios.Container": "a920b0d111b76470c5329435987179ee91722e093e8147e3c0dbcb0d884c9a65", - "functional_tests/test_m365_continuations.py::PendingJobs": "e72fab118acd5efc25d2abf21dcaae8fab1da84480fc764a93faa130e46bccb1", "functional_tests/test_m365_conversation_lifecycle.py::PendingContainer": "aa3e0ed29d4fcd5ceffab62147053c57edfa2d52dd75d511cc07782e28d0036c", "functional_tests/test_m365_pending_delivery.py::PendingContainer": "6d7f9224c38485e2ab89910c54da8914ccdb2deb6c74f9686edb4756a4c082f1", "functional_tests/test_m365_runtime_imports.py::embedded-PROBE::Container": "bea3be0a6d645448de027c756f434e8074405d0af51c2e3f6cccea8c10266954", @@ -384,8 +383,6 @@ "application/single_app/app_settings_cache.py::_get_cosmos_cache_entry": "dea14a891613aba100ad1ece813ea5dc7a3975d6d2298235aff0fadcc4d0a353", "application/single_app/app_settings_store.py::AppSettingsStore._read_cosmos": "56597c9f9be5567c646e0dfff7123209205054b9bd512c49db3175dd05060fb5", "application/single_app/app_settings_store.py::AppSettingsStore._write": "d211afdc1a48792598479fe6a2f216f41e6c2929df544d46f796ff8410a52a82", - "application/single_app/background_tasks.py::acquire_distributed_task_lock": "96acb8f99f87eb710ca72f6d0cf05265ff72e8ab5fb63d851074742098a71da9", - "application/single_app/background_tasks.py::release_distributed_task_lock": "a625bff11adba5becc4a58831d6715a0b90812f0e5b2c4b4cbe4e57afe72fa98", "application/single_app/conversation_memory_runtime.py::resolve_memory_context": "bd5d1887e75be3047ff78cb0d7aa431cf38f53bbb16eb94a2ffa791ba2ec2d2f", "application/single_app/conversation_memory_runtime.py::resolve_m365_memory": "c46a93c135fe6f55da1a1e883834708544439a83d6a566a22cf08639df9ee0cb", "application/single_app/foundry_agent_runtime.py::_collect_recent_chat_upload_file_inputs": "0336dc2e7609706fb2958378324a0055120a6b05317540a9679b97b235535ca0", @@ -668,7 +665,6 @@ "application/single_app/functions_keyvault_reminders.py::mark_key_vault_secret_reminder_disabled": "f68553d9bd9e41f600ac17f2ae0bc3f51aa8b69361a02ecdede50ec7b3b136a3", "application/single_app/functions_keyvault_reminders.py::list_key_vault_secret_reminders": "428216f6ea1a157e12e943fe49d7b8bc4bb3e10e72dd3adcf914dd04aa26affd", "application/single_app/functions_m365_agent_continuation.py::AgentContinuationJournal._job": "3e93c9abd66e5eeec6143025acfb896a9f72f402284826cc158b08aae59b6895", - "application/single_app/functions_m365_continuations.py::resume_pending_workflows": "17a0d1a95e49b2006bb2301efad2f1f86f8ecc67c97cfcc9988ee7df8cb562c7", "application/single_app/functions_m365_workflow_checkpoints.py::read_m365_task_checkpoint": "24c29a3e590681ce48e952a636b6a53015f6bf7f8d3307f5d1b83d067b6b0c5f", "application/single_app/functions_m365_workflow_checkpoints.py::save_m365_task_checkpoint": "77a082ecb7a4a20c39698b3a440953e450ecfd402901f6ab6dc681055af05f0c", "application/single_app/functions_mcp_server_enterprise.py::check_inbound_mcp_tool_rate_limit": "8563316a6e6733e95dd8f4bf11585527afc2c3d3a79fd470869944223d4ef2c1", @@ -687,9 +683,6 @@ "application/single_app/functions_notifications.py::_write_notification_change": "82cc5d4f81604a76ebd007dac7cda3e95384389c5a865bf2eb483940b6468bff", "application/single_app/functions_orchestration_collaboration.py::_refresh_mirrored_answer": "7f0eaa8812e33322262da8a5c208324b9f9a781b475f358f61061c947bacd996", "application/single_app/functions_orchestration_collaboration.py::delete_orchestration_backing.read_backing": "0dbd48b611e5c0d277f645ddea3bf692284e6cfc163b1bfe1b1570e9fd91c593", - "application/single_app/functions_orchestration_context.py::_image_reference_message_candidates": "6d6a7b8ba9f78ef9b10f0ba55b8d6f18488206cb004e102e01eb3f3ff4715315", - "application/single_app/functions_orchestration_context.py::_owned_elicitation_conversation": "e938fb6773d3befeff376dcf23ad01f6a1a56373f99eb8686f7de6c06981d1b4", - "application/single_app/functions_orchestration_context.py::_authorize_references": "f297054359cf1296836b842c9ff70ca5787c32b92c246084cfa6b202407122ff", "application/single_app/functions_orchestration_runs.py::get_pending_turn_context": "ea9e8ea9d5c26b68abeaffd369cdeaad0df0a5bc5748d1d4df014de740479b4b", "application/single_app/functions_orchestration_runs.py::_replanned_outcome": "7c8dfe316d74c544b48b4ca595151386be1a3331c36be18c06a62158e39bb5b7", "application/single_app/functions_orchestration_runs.py::_publish_replanned_run": "c79252d36d79df63ffdc38628206de5496eb0cac7c4f0b0f7296281d699ff4c1", @@ -1163,13 +1156,13 @@ ], "queries": [], "batches": [], - "module_sha256": "6e2ccac24a828d77e3f60c57fe00a74e8f541bd943c9e5b54bf9d40fc44f7b83" + "module_sha256": "48da4da09bfa2996fab51aa9a50a5ee6915758a16b50443c29291ee227f910e7" }, "classification": "sdk-compatible", - "contract": "acquire_distributed_task_lock: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "Lock takeover field-reads the SDK mapping and builds a plain replacement with its original ETag; create/replace responses are unused. Real SDK regression exercises expired takeover and refusal of a live lease.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_m365_workflow_continuation_boundary.py::test_scheduler_locks_consume_sdk_mappings_and_keep_conditional_ownership" ] }, "application/single_app/background_tasks.py::release_distributed_task_lock": { @@ -1189,13 +1182,13 @@ ], "queries": [], "batches": [], - "module_sha256": "6e2ccac24a828d77e3f60c57fe00a74e8f541bd943c9e5b54bf9d40fc44f7b83" + "module_sha256": "48da4da09bfa2996fab51aa9a50a5ee6915758a16b50443c29291ee227f910e7" }, "classification": "sdk-compatible", - "contract": "release_distributed_task_lock: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "Lock release compares current holder fields from the SDK point mapping and conditionally deletes with its ETag. Real SDK regression verifies holder isolation, partition routing and If-Match.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_m365_workflow_continuation_boundary.py::test_scheduler_locks_consume_sdk_mappings_and_keep_conditional_ownership" ] }, "application/single_app/conversation_memory_runtime.py::resolve_memory_context": { @@ -10345,7 +10338,7 @@ }, "application/single_app/functions_m365_continuations.py::resume_pending_workflows": { "boundary": { - "sha256": "3accc15e22395eaf9789dd4558b5d24167ca3635b0894f746ee0f80d72a2f164", + "sha256": "63da48177fb86ac078d48d86c36a36d047ec5c6af7aeb3c4f7b5057abc42f6b9", "points": [ { "method": "replace_item", @@ -10357,21 +10350,11 @@ "receiver": "jobs", "response": "consumed" }, - { - "method": "read_item", - "receiver": "jobs", - "response": "consumed" - }, { "method": "replace_item", "receiver": "jobs", "response": "unused" }, - { - "method": "read_item", - "receiver": "jobs", - "response": "consumed" - }, { "method": "replace_item", "receiver": "jobs", @@ -10386,13 +10369,34 @@ } ], "batches": [], - "module_sha256": "db9760942b3f642015113ec6067879f6982a2ea06a0c040c66d1c2c36cedaa0b" + "module_sha256": "75e4481db115f322d8b6ceede395e28ff20dee828de691b3f1e46742f96ce3dc" }, "classification": "sdk-compatible", - "contract": "resume_pending_workflows: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "Candidates are plain query rows; the returned claim is an SDK mapping consumed by field reads and expanded into conditional state projections. ETag conflicts preserve newer approval/cancellation/claims; uncertain effects remain recovery_required without replay. No strict whole-document JSON sink consumes the SDK wrapper.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_m365_continuations.py", + "functional_tests/test_m365_workflow_continuation_boundary.py" + ] + }, + "application/single_app/functions_m365_continuations.py::_fail_deleted_workflow": { + "boundary": { + "sha256": "10d9349b1c74318c6b96945662a92c6f5cbd6b32bde554b8271181dfe00bd1fe", + "points": [ + { + "method": "replace_item", + "receiver": "jobs", + "response": "unused" + } + ], + "queries": [], + "batches": [], + "module_sha256": "75e4481db115f322d8b6ceede395e28ff20dee828de691b3f1e46742f96ce3dc" + }, + "classification": "response-unused", + "contract": "The continuation's own failed/workflow_deleted/completed_at projection is conditionally replaced with its snapshot ETag; the SDK write response is unused. No pending-delivery lifecycle fields are applied.", + "tests": [ + "functional_tests/test_m365_continuations.py" ] }, "application/single_app/functions_m365_file_runtime.py::resolve_m365_budget_run": { @@ -10792,15 +10796,13 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Conversation point mappings are field-read to enforce current owner or participation access.", + "contract": "Conversation point mappings are field-read to enforce current ownership or participation access; no exact-dictionary document contract is entered.", "tests": [ "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_runtime_adapters.py" ], "evidence_scope": "owner-runtime" }, @@ -10816,15 +10818,13 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Pending-request point mappings supply current status/identity fields before owned context restoration.", + "contract": "Pending-request SDK point mappings supply current status and bound identity fields before owned context restoration.", "tests": [ "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_runtime_adapters.py" ], "evidence_scope": "owner-runtime" }, @@ -10840,12 +10840,13 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "response-unused", - "contract": "Context-initialization point write responses are unused; failures are not hidden.", - "tests": [], - "evidence_scope": "owner-runtime" + "contract": "Context-initialization conditional write responses are unused; failures are not hidden.", + "tests": [ + "functional_tests/test_m365_runtime_adapters.py" + ] }, "application/single_app/functions_m365_runtime.py::ensure_m365_execution_record": { "boundary": { @@ -10869,15 +10870,14 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Point execution mappings supply current request context; ETags remain available for conditional state changes.", + "contract": "Point execution mappings supply current request identity fields; SDK responses retain ETags for conditional state changes and are not strict result envelopes.", "tests": [ "functional_tests/test_m365_pending_authorization.py", "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_runtime_adapters.py" ], "evidence_scope": "owner-runtime" }, @@ -10903,15 +10903,13 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "SDK request point mappings supply current actor/scope fields; writes are projected before result contracts.", + "contract": "SDK request point mappings supply current actor/scope fields; conditional writes build plain projections. No point wrapper reaches a strict domain envelope.", "tests": [ - "functional_tests/test_m365_pending_authorization.py", "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_runtime_adapters.py" ], "evidence_scope": "owner-runtime" }, @@ -10932,15 +10930,13 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Conditional step request mappings supply saved status and request state; strict results receive nested plain JSON.", + "contract": "Step request point mappings supply saved state fields and ETags for conditional status projections; nested result values remain plain JSON.", "tests": [ - "functional_tests/test_m365_pending_authorization.py", "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_runtime_adapters.py" ], "evidence_scope": "owner-runtime" }, @@ -10956,14 +10952,11 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Execution request point mapping supplies actor/conversation/run fields; exact owned plan is reloaded and validated before new provenance strings are attached. Tool/browser identifiers are not provenance authority.", + "contract": "Execution request SDK mappings supply actor/conversation/run fields; exact owned plan reauthorization produces plain provenance strings rather than trusting browser or tool identifiers.", "tests": [ - "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", "functional_tests/test_m365_pending_action_provenance.py" ], "evidence_scope": "owner-runtime" @@ -10980,14 +10973,12 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Saved request mapping is reauthorized against current actor, conversation, selected capability and original plan before token/transport work.", + "contract": "The request SDK mapping is field-read and reauthorized against current actor, conversation, selected capability and owned plan before token/transport work.", "tests": [ "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", "functional_tests/test_m365_pending_action_provenance.py" ], "evidence_scope": "owner-runtime" @@ -11004,15 +10995,13 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Approval decisions revalidate saved request point identity/current state and existing grants before permitting the operation.", + "contract": "Approval decisions field-read the saved request SDK mapping and revalidate identity, current workflow state and existing grants before permitting execution.", "tests": [ "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_runtime_adapters.py" ], "evidence_scope": "owner-runtime" }, @@ -11028,15 +11017,13 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Authentication wait point mappings supply current conditional request state, not credential or domain result envelopes.", + "contract": "Authentication wait SDK point mappings supply prior conditional request state, not credential or domain result envelopes; persisted records are plain projections.", "tests": [ - "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_pending_authorization.py" ], "evidence_scope": "owner-runtime" }, @@ -11052,15 +11039,12 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "History authorization reads conversation point mappings through identity fields.", + "contract": "History authorization receives conversation SDK point mappings and consumes identity fields; no exact-dictionary document sink is introduced.", "tests": [ - "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_runtime_adapters.py" ], "evidence_scope": "owner-runtime" }, @@ -11076,15 +11060,12 @@ } ], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "History reader consumes plain query rows and bounded field projections.", + "contract": "History reader returns plain query rows to field-projection consumers; these are not SDK point wrappers.", "tests": [ - "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_runtime_adapters.py" ], "evidence_scope": "owner-runtime" }, @@ -11100,15 +11081,12 @@ } ], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Active-request lookup consumes plain query rows, not point response wrappers.", + "contract": "The active-request existence check consumes plain query rows, not point response wrappers.", "tests": [ - "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_runtime_adapters.py" ], "evidence_scope": "owner-runtime" }, @@ -11124,15 +11102,13 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Pending request point mappings are field-read/projected for durable wait state; nested result envelopes remain plain JSON.", + "contract": "Pending request SDK mappings are field-read and projected for durable wait state; nested result payloads remain plain JSON and conditional writes retain the prior ETag.", "tests": [ - "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_orchestration_operation_runtime.py" ], "evidence_scope": "owner-runtime" }, @@ -11153,12 +11129,13 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "response-unused", - "contract": "Wait-record write responses are unused; write failures remain visible.", - "tests": [], - "evidence_scope": "owner-runtime" + "contract": "Wait-record create/replace responses are unused; conflicts and storage failures remain visible.", + "tests": [ + "functional_tests/test_m365_cosmos_sdk_contract.py" + ] }, "application/single_app/functions_m365_runtime.py::complete_m365_request": { "boundary": { @@ -11177,15 +11154,13 @@ ], "queries": [], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "sdk-compatible", - "contract": "Completion point mappings supply identity/state fields for conditional outcome writes.", + "contract": "Completion point mappings supply current identity/status/ETag fields for conditional writes. SDK mapping mutation is supported and no strict result sink consumes the whole record.", "tests": [ - "functional_tests/test_m365_pending_authorization.py", - "functional_tests/test_orchestration_operation_runtime.py", - "functional_tests/test_m365_runtime_adapters.py", - "functional_tests/test_m365_pending_action_provenance.py" + "functional_tests/test_m365_cosmos_sdk_contract.py", + "functional_tests/test_m365_runtime_adapters.py" ], "evidence_scope": "owner-runtime" }, @@ -11207,11 +11182,33 @@ } ], "batches": [], - "module_sha256": "694c0aba003c5e727e085a99b81999bd48321a6f4c49fedcb6b8d989a90de70e" + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" }, "classification": "response-unused", - "contract": "Cancellation consumes plain query rows and discards point write responses.", - "tests": [], + "contract": "Cancellation field-reads plain query rows, conditionally updates the continuation's own status and discards SDK point write responses.", + "tests": [ + "functional_tests/test_m365_runtime_adapters.py" + ] + }, + "application/single_app/functions_m365_runtime.py::load_current_workflow": { + "boundary": { + "sha256": "03033aa434b0c8b273fc0f86ff205142a605f76ad6a0f32507ed4f57082b3f9a", + "points": [ + { + "method": "read_item", + "receiver": "container", + "response": "consumed" + } + ], + "queries": [], + "batches": [], + "module_sha256": "1929651d37301e0b3c6b470c5aff19102fabbc96dd123be0278874ec2aa2bd2e" + }, + "classification": "normalized-strict", + "contract": "The exact personal/group partition point response is normalized before metadata projection and alert/editor contracts. Only an authoritative scoped 404 or stored deletion tombstone produces m365_workflow_missing; authorization, invalid responses and transient storage failures propagate. Real SDK tests preserve the existing plain editor projection.", + "tests": [ + "functional_tests/test_m365_workflow_continuation_boundary.py" + ], "evidence_scope": "owner-runtime" }, "application/single_app/functions_m365_workflow_checkpoints.py::read_m365_task_checkpoint": { @@ -12276,13 +12273,13 @@ ], "queries": [], "batches": [], - "module_sha256": "17eded55069836e3d1317ef48bbcb232a2b4df281708d67c380aa0efffba6386" + "module_sha256": "e98e73bccb8f68c6b3f3f50668bd12cef877129e93243ed7e841f2d6898d0241" }, "classification": "sdk-compatible", - "contract": "_image_reference_message_candidates: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "Partitioned message point mappings are checked for conversation identity, image/file role and supported filename; only a plain candidate DTO is emitted, never the SDK document.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_orchestration_conversation_sources.py::test_sdk_image_reference_candidates_use_partitioned_message_read" ] }, "application/single_app/functions_orchestration_context.py::_owned_elicitation_conversation": { @@ -12297,13 +12294,13 @@ ], "queries": [], "batches": [], - "module_sha256": "17eded55069836e3d1317ef48bbcb232a2b4df281708d67c380aa0efffba6386" + "module_sha256": "e98e73bccb8f68c6b3f3f50668bd12cef877129e93243ed7e841f2d6898d0241" }, "classification": "sdk-compatible", - "contract": "_owned_elicitation_conversation: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "The conversation point mapping is authorized through user_id and passed to Mapping-compatible scope-lock readers; missing or foreign conversations retain ElicitationContextError rather than an empty fallback.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_orchestration_conversation_sources.py::test_sdk_owned_elicitation_conversation_authorizes_current_owner" ] }, "application/single_app/functions_orchestration_context.py::_authorize_references": { @@ -12318,13 +12315,13 @@ } ], "batches": [], - "module_sha256": "17eded55069836e3d1317ef48bbcb232a2b4df281708d67c380aa0efffba6386" + "module_sha256": "e98e73bccb8f68c6b3f3f50668bd12cef877129e93243ed7e841f2d6898d0241" }, "classification": "sdk-compatible", - "contract": "_authorize_references: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "Authorized chat-attachment readiness uses partitioned query rows, which remain plain dictionaries; workspace manifests and readiness checks consume field projections without changing strict reference validation.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_orchestration_conversation_sources.py::test_sdk_chat_attachment_reference_readiness_query" ] }, "application/single_app/functions_orchestration_continuation.py::_continuation_guards": { @@ -12393,7 +12390,7 @@ }, "application/single_app/functions_orchestration_execution.py::_conversation_context": { "boundary": { - "sha256": "369e78d28cf686eb692ad1720a9b74612faa16ee52156dae2160de0a1259b8ae", + "sha256": "1061ff490d079ee34c435d41190d6faedeae749043bf38cd1ddf02882344d632", "points": [ { "method": "read_item", @@ -12408,14 +12405,15 @@ ], "queries": [], "batches": [], - "module_sha256": "e09d375f266e73b933e0a82bc42f5a1795d566371a0112377b4cb9ef8132b893" + "module_sha256": "13e3d0820f1601ae9a10a16aaa111abdf4571ad8705c8b70502859449a77465d" }, "classification": "sdk-compatible", - "contract": "Saved snapshot point responses are consumed through Mapping fields and normalize_history_message before strict snapshot validation; publication metadata enrichment does not alter this boundary.", + "contract": "Partitioned turn/source point mappings are reauthorized, checked for visibility and converted by normalize_history_message before strict saved-snapshot validation. Full source messages are resolved only after fingerprint checks; history disablement and revoked shared access cannot broaden reads.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_orchestration_continuation.py", - "functional_tests/test_orchestration_cosmos_response_contract.py" + "functional_tests/test_orchestration_cosmos_response_contract.py", + "functional_tests/test_orchestration_conversation_sources.py" ] }, "application/single_app/functions_orchestration_execution.py::HarnessExecution._read_delivery_metadata": { @@ -12430,10 +12428,10 @@ ], "queries": [], "batches": [], - "module_sha256": "e09d375f266e73b933e0a82bc42f5a1795d566371a0112377b4cb9ef8132b893" + "module_sha256": "13e3d0820f1601ae9a10a16aaa111abdf4571ad8705c8b70502859449a77465d" }, "classification": "sdk-compatible", - "contract": "Delivery metadata point responses are read through Mapping fields and projected into plain model/reasoning dictionaries; restored publication retains SDK ETags and conditional guards.", + "contract": "The previous assistant point mapping is checked for conversation/run ownership; model and reasoning fields are projected into plain dictionaries, without passing the SDK wrapper to an exact-dictionary contract.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_orchestration_continuation.py", @@ -12452,10 +12450,10 @@ ], "queries": [], "batches": [], - "module_sha256": "e09d375f266e73b933e0a82bc42f5a1795d566371a0112377b4cb9ef8132b893" + "module_sha256": "13e3d0820f1601ae9a10a16aaa111abdf4571ad8705c8b70502859449a77465d" }, "classification": "sdk-compatible", - "contract": "Regeneration reads the question's Mapping role and nested plain thread metadata; no exact top-level dictionary contract consumes the SDK point response.", + "contract": "The question point mapping supplies role and nested plain thread metadata; only a deepcopy of the validated nested thread dictionary is returned.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_orchestration_continuation.py", @@ -12474,10 +12472,10 @@ ], "queries": [], "batches": [], - "module_sha256": "e09d375f266e73b933e0a82bc42f5a1795d566371a0112377b4cb9ef8132b893" + "module_sha256": "13e3d0820f1601ae9a10a16aaa111abdf4571ad8705c8b70502859449a77465d" }, "classification": "sdk-compatible", - "contract": "The bootstrap supplies a normalized owned conversation; conditional replace responses go to the Mapping-compatible cache invalidator. New readable citation metadata is merged into nested plain document records.", + "contract": "A normalized owned conversation retains its _etag for IfNotModified replacement; the returned SDK mapping is consumed only by the Mapping-compatible cache invalidator. Lost-CAS retries retain current ownership.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_orchestration_continuation.py", @@ -12487,7 +12485,7 @@ }, "application/single_app/functions_orchestration_execution.py::HarnessExecution._finalize": { "boundary": { - "sha256": "b6e2ff51f8dc7a514213a3ddcb52ad02f90d1a55af81ee451e37c039a5e5eb1f", + "sha256": "f228e771c1ca08f0b54a5fec7264e35a6d6e1344d1f74c7943b7673c4a177743", "points": [ { "method": "read_item", @@ -12497,15 +12495,16 @@ ], "queries": [], "batches": [], - "module_sha256": "e09d375f266e73b933e0a82bc42f5a1795d566371a0112377b4cb9ef8132b893" + "module_sha256": "13e3d0820f1601ae9a10a16aaa111abdf4571ad8705c8b70502859449a77465d" }, "classification": "sdk-compatible", - "contract": "Finalization reads the already-published SDK message through Mapping fields and publishes a plain message DTO. Display metadata is copied only from authorized source manifests, outside strict retained-result identity descriptors.", + "contract": "The published message point mapping is projected with items() into a metadata-free fingerprint and consumed by Mapping-compatible mirroring. Durable outcome telemetry reads only finalized scalar fields; strict task/result descriptors and saved-message confirmation remain unchanged.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_orchestration_continuation.py", "functional_tests/test_orchestration_cosmos_response_contract.py", - "functional_tests/test_orchestration_harness_execution.py::test_selected_result_citations_reach_headless_message_cache_and_delivery" + "functional_tests/test_orchestration_harness_execution.py::test_selected_result_citations_reach_headless_message_cache_and_delivery", + "functional_tests/test_orchestration_failure_telemetry.py::test_durable_execution_outcome_is_recorded_once_not_on_sse_replay" ] }, "application/single_app/functions_orchestration_operations.py::OperationJournal._change": { @@ -12740,10 +12739,10 @@ ], "queries": [], "batches": [], - "module_sha256": "85fbda793f397c3ddc760221e6926e6ccb034e0f41f34c739414a868c5805fc6" + "module_sha256": "9c8c8ecccd44a39e6e1b18d1a358a5f9c8b0cad5337faf75405fc4456c247197" }, "classification": "normalized-strict", - "contract": "Lease responses normalize before execution; publication guards use mapping fields and metadata-stripped fingerprints, not exact SDK document types.", + "contract": "The run replacement preserves the input _etag with IfNotModified, rejects non-document responses through CheckpointError and normalizes the returned SDK document before lease/checkpoint strict contracts; domain values are not recursively repaired.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_orchestration_cosmos_response_contract.py" @@ -12771,10 +12770,10 @@ ], "queries": [], "batches": [], - "module_sha256": "85fbda793f397c3ddc760221e6926e6ccb034e0f41f34c739414a868c5805fc6" + "module_sha256": "9c8c8ecccd44a39e6e1b18d1a358a5f9c8b0cad5337faf75405fc4456c247197" }, "classification": "sdk-compatible", - "contract": "Lease responses normalize before execution; publication guards use mapping fields and metadata-stripped fingerprints, not exact SDK document types.", + "contract": "The guard point mapping supplies run/user ownership and _etag; a plain metadata-stripped tombstone is conditionally replaced, with create/replace responses discarded and conflicts retried without borrowing another owner.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_orchestration_cosmos_response_contract.py" @@ -12808,10 +12807,10 @@ "response": "unused" } ], - "module_sha256": "85fbda793f397c3ddc760221e6926e6ccb034e0f41f34c739414a868c5805fc6" + "module_sha256": "9c8c8ecccd44a39e6e1b18d1a358a5f9c8b0cad5337faf75405fc4456c247197" }, "classification": "sdk-compatible", - "contract": "Lease responses normalize before execution; publication guards use mapping fields and metadata-stripped fingerprints, not exact SDK document types.", + "contract": "Guard/current-message point mappings feed ownership and retraction checks; metadata is stripped into plain batch bodies while original ETags fence atomic publication. Batch status/body responses remain separate from point documents.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_orchestration_cosmos_response_contract.py" @@ -12834,10 +12833,10 @@ ], "queries": [], "batches": [], - "module_sha256": "85fbda793f397c3ddc760221e6926e6ccb034e0f41f34c739414a868c5805fc6" + "module_sha256": "9c8c8ecccd44a39e6e1b18d1a358a5f9c8b0cad5337faf75405fc4456c247197" }, "classification": "sdk-compatible", - "contract": "Lease responses normalize before execution; publication guards use mapping fields and metadata-stripped fingerprints, not exact SDK document types.", + "contract": "Guard and published-message point mappings supply owner/token/digest fields; publication equality fingerprints a metadata-stripped plain projection, preserving lease ownership rather than treating normalization as authority.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_orchestration_cosmos_response_contract.py" @@ -12865,10 +12864,10 @@ ], "queries": [], "batches": [], - "module_sha256": "85fbda793f397c3ddc760221e6926e6ccb034e0f41f34c739414a868c5805fc6" + "module_sha256": "9c8c8ecccd44a39e6e1b18d1a358a5f9c8b0cad5337faf75405fc4456c247197" }, "classification": "sdk-compatible", - "contract": "Lease responses normalize before execution; publication guards use mapping fields and metadata-stripped fingerprints, not exact SDK document types.", + "contract": "Guard-create and conditional-replace responses are discarded; a conflicting guard is read through mapping fields and its original _etag is used with IfNotModified before ownership is rechecked.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_orchestration_cosmos_response_contract.py" @@ -12886,10 +12885,10 @@ ], "queries": [], "batches": [], - "module_sha256": "85fbda793f397c3ddc760221e6926e6ccb034e0f41f34c739414a868c5805fc6" + "module_sha256": "9c8c8ecccd44a39e6e1b18d1a358a5f9c8b0cad5337faf75405fc4456c247197" }, "classification": "sdk-compatible", - "contract": "Lease responses normalize before execution; publication guards use mapping fields and metadata-stripped fingerprints, not exact SDK document types.", + "contract": "The publication-guard point mapping is checked for user/conversation/run identity before a plain claim descriptor is retained; run claims keep their normalized storage-owner boundary and conditional ETags.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_orchestration_cosmos_response_contract.py" @@ -12907,12 +12906,15 @@ "response": "unused" } ], - "module_sha256": "85fbda793f397c3ddc760221e6926e6ccb034e0f41f34c739414a868c5805fc6" + "module_sha256": "9c8c8ecccd44a39e6e1b18d1a358a5f9c8b0cad5337faf75405fc4456c247197" }, "classification": "response-unused", - "contract": "prepare_retry: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", - "evidence_scope": "source-review", - "tests": [] + "contract": "Atomic replace/create batch responses are discarded, never normalized as point documents; original parent _etag fences the batch and ambiguous failures reread the authoritative parent/submission through the normalized run owner. Partial-search recovery cannot grant external-effect replay authority.", + "evidence_scope": "owner-runtime", + "tests": [ + "functional_tests/test_orchestration_cosmos_response_contract.py", + "functional_tests/test_orchestration_search_result_integrity.py" + ] }, "application/single_app/functions_orchestration_recovery.py::cleanup_conversation_checkpoints": { "boundary": { @@ -12937,10 +12939,10 @@ } ], "batches": [], - "module_sha256": "85fbda793f397c3ddc760221e6926e6ccb034e0f41f34c739414a868c5805fc6" + "module_sha256": "9c8c8ecccd44a39e6e1b18d1a358a5f9c8b0cad5337faf75405fc4456c247197" }, "classification": "sdk-compatible", - "contract": "Lease responses normalize before execution; publication guards use mapping fields and metadata-stripped fingerprints, not exact SDK document types.", + "contract": "Conversation point mappings provide owner and _etag for a conditional deletion fence; replacements are plain metadata-stripped projections and responses are discarded. Run discovery consumes plain partitioned query rows.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_orchestration_cosmos_response_contract.py" @@ -19255,10 +19257,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "response-unused", - "contract": "_rollback_agent_citation_artifacts: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Plain query rows provide chunk IDs; all chunk/artifact delete responses are discarded and rollback failure accounting remains explicit.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py" @@ -19276,10 +19278,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "_get_user_message_image_context: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The user-message point mapping supplies nested plain metadata/thread fields for paired image attribution; only user/thread values escape the boundary.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19298,10 +19300,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "_read_recent_assistant_messages: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Partitioned assistant query rows remain plain dictionaries through soft-delete filtering and saved-analysis history sanitization.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19320,10 +19322,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "_read_agent_citation_artifact_payloads: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Artifact and chunk discovery returns plain query rows to build_message_artifact_payload_map; it is not a point-response normalization boundary.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19342,10 +19344,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "response-unused", - "contract": "_persist_screened_assistant: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The local Cosmos delete response is discarded on source-screening rollback; its IfNotModified ETag comes from the persistence owner result. Assistant point normalization remains with persist_chat_reply rather than weakening screening.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py" @@ -19363,10 +19365,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "_resolve_chat_selected_document_metadata: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Workspace lookup consumes plain query rows, rechecks document availability and projects only id/name/title/workspace fields into a plain selection DTO.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19390,10 +19392,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "response-unused", - "contract": "_create_personal_conversation: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Both conversation upsert responses are discarded; the constructed plain conversation is logged and cache-invalidated, with existing persistence failures unchanged.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py" @@ -19411,10 +19413,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "_resolve_authorized_conversation_context: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The conversation point mapping feeds shared-backing and participation authorization using Mapping fields; no raw document reaches an exact-dictionary domain validator or bypasses access checks.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19438,10 +19440,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "response-unused", - "contract": "persist_agent_citation_artifacts: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Artifact upsert and cancellation-rollback delete responses are discarded; artifact identities are taken from constructed plain documents and cancellation remains visible.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py" @@ -19459,10 +19461,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "_load_user_message_response_context: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The user-message point mapping is read through get() into a plain response-context dictionary; nested thread/user metadata stays JSON and read failures retain existing fallback fields.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19491,10 +19493,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "response-unused", - "contract": "_reject_chat_submission: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Safety/user-message upsert and conversation replace responses are discarded; the browser payload is constructed from plain screened documents and explicit public fields.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py" @@ -19512,10 +19514,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "_build_tabular_related_document_catalog: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Workspace document discovery consumes plain query rows and builds bounded related-document metadata; no Cosmos point wrapper enters its document catalog.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19534,10 +19536,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "_save_chat_user_metadata: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "A new-message upsert may return the SDK mapping to Mapping-compatible chat persistence callers; retry metadata is delegated to patch_chat_message_metadata, which owns conditional update/normalization. No strict top-level DTO is synthesized from arbitrary iterables.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19556,10 +19558,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats._resolve_document_action_selected_documents: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Chat attachment resolution consumes plain partitioned query rows and emits display-name/context projections; workspace ownership remains with the document resolver.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19578,10 +19580,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats._get_latest_chat_thread_id: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The TOP 1 thread projection is a plain query row; only thread_id is returned, with the existing read-failure fallback unchanged.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19605,10 +19607,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats._load_or_create_analyze_conversation: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Existing point mappings are checked for owner/deletion state; newly created SDK conversation mappings feed field-based activity logging and cache invalidation, not exact-dictionary result validation.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19658,10 +19660,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats.execute_saved_analysis_chat_request: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Retry input point mappings supply authorized role/content/thread fields; history queries are plain, and user/system writes and rollback deletes discard responses. Saved-analysis strict result admission remains with its storage owner.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19700,10 +19702,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats.execute_document_action_chat_request: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The original Analyze point mapping is checked for role/conversation/content and nested thread metadata; local message writes and cleanup deletes discard responses, while retained analysis contracts remain strict.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19727,10 +19729,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats.generate_image_from_proposal: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The source assistant SDK mapping is authorized by conversation/role and used by Mapping-compatible planned-image lookup; conversation replacement discards its response and public image data is projected explicitly.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19749,10 +19751,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats.generate_image_from_proposal.read_conversation_image: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The partitioned point mapping is returned to find_planned_proposal_image, which inspects message fields; a missing message returns None, without an empty-document or strict-type fallback.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19771,10 +19773,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats.image_proposal_status: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Proposal status iterates plain query rows, validates nested proposal dictionaries and emits explicit public image status fields.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19864,10 +19866,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats.chat_api: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Retry/source point mappings supply role/content and nested model/thread metadata through Mapping access; history queries stay plain. Metadata persistence remains with its existing owner and local write responses are discarded.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19937,10 +19939,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats.chat_stream_api.generate: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Retry point mappings are checked for role/conversation and nested metadata through Mapping access; history queries remain plain, and local create/replace/delete return values are discarded. SSE telemetry does not alter storage contracts.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -19959,10 +19961,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "response-unused", - "contract": "register_route_backend_chats.chat_stream_api.generate.finalize_cancelled_stream_response: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Cancellation refreshes the conversation with a discarded replace response; screened message persistence remains with its existing owner and the stream payload is assembled separately.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py" @@ -19991,10 +19993,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats.mask_message_api: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "Point message/conversation mappings and plain fallback query rows use field-based ownership and soft-delete checks; nested mask metadata goes to existing persistence, and public JSON projects mask fields only.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -20013,10 +20015,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats.set_message_visual_style_api: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The point message mapping is checked for conversation identity and passed to Mapping-compatible visual-style/persistence owners; public JSON projects nested visual_styles rather than the SDK document.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -20035,10 +20037,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats._load_block_revision_message: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The point message mapping is checked against the authorized conversation and passed to Mapping-compatible revision readers/persistence; missing messages retain their 404 contract.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -20057,10 +20059,10 @@ } ], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats._find_originating_user_request: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The partitioned prior-user query returns plain rows; only the first row content string is used as the originating request.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -20079,10 +20081,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats._answer_raced_block_submission: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "A reread SDK message is checked for conversation membership and inspected by Mapping-compatible block revision helpers; conflict/replay JSON consists of explicit revision fields, not the SDK wrapper.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -20101,10 +20103,10 @@ ], "queries": [], "batches": [], - "module_sha256": "b93ec6e2741e6ab6ce2fedb133c7d765b548fc4257f74c8649a89e341fb99892" + "module_sha256": "8e7877d8da8036f0a6ce009ee846c3b508a781f3f56bcb7efaff51406768c375" }, "classification": "sdk-compatible", - "contract": "register_route_backend_chats.add_message_image_revision_api: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Credential interaction adds no new raw point consumer: the private owner normalizes its own SDK responses; existing ownership/Mapping reads and discarded write returns remain unchanged.", + "contract": "The reload callback returns a partitioned SDK message mapping to the Mapping-compatible image revision owner, which rechecks concurrent versions; the route emits only explicit revision/error fields.", "evidence_scope": "owner-runtime", "tests": [ "functional_tests/test_action_credentials_bootstrap.py", @@ -23364,10 +23366,10 @@ } ], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Query rows are plain SDK dictionaries, not point wrappers; no point normalization is required.", + "contract": "Ledger source revalidation consumes plain projected query rows to check source visibility; failed reads omit the ledger rather than authorizing point documents or widening scope.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23375,7 +23377,7 @@ }, "application/single_app/route_backend_orchestration.py::_authorize_context_conversation": { "boundary": { - "sha256": "89efc6e7eca2ba3ef0112983c5d9e2788fad3304f0ef503796998be744495e58", + "sha256": "22acc754f250e1f43e216ac38a72d4f6f86940db24340fdb1a534d3848cb3ecc", "points": [ { "method": "read_item", @@ -23385,12 +23387,13 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point mapping fields prove owner/backing-conversation access before dependent reads.", + "contract": "The conversation point mapping proves owner/nondeleted state through field access and now rechecks shared-source membership before dependent reads; missing or unauthorized records retain ConversationContextError.", "tests": [ - "functional_tests/test_orchestration_conversation_context_routes.py" + "functional_tests/test_orchestration_conversation_context_routes.py", + "functional_tests/test_orchestration_conversation_sources.py" ], "evidence_scope": "owner-runtime" }, @@ -23406,10 +23409,10 @@ } ], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Query rows are plain SDK dictionaries used for the authorized historical context cutoff.", + "contract": "Historical context discovery consumes plain partitioned query rows, applies retry-prefix/screening rules and builds a bounded plain conversation snapshot.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23427,10 +23430,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point source message mapping supplies retry metadata, not a strict result envelope.", + "contract": "Point answer mappings supply conversation/visibility and nested thread fields; metadata patching retains its existing owner and stale or missing answers are not silently restored.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23454,10 +23457,10 @@ } ], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point cutoff message mapping and plain query rows feed the bounded authorized context reader.", + "contract": "A cutoff point mapping supplies authorized conversation/role/timestamp; plain bounded query rows pass through screening and saved-analysis sanitization before plain snapshot construction.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23475,10 +23478,10 @@ } ], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Plain query rows are bounded before context validation; no point wrapper enters the strict context.", + "contract": "Source revalidation consumes plain bounded query rows through screening and strict saved-snapshot checks; no point wrapper is repaired into a domain context.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23486,7 +23489,7 @@ }, "application/single_app/route_backend_orchestration.py::_conversation_context_for_run": { "boundary": { - "sha256": "e67be42f4ee19df1dc6c91805dbb858a6f0a01b1690f0995d016a83dea55a4d4", + "sha256": "285797ca8e6a453e9284bfdb04486f76457e63ae0e122b77b486487ca857db49", "points": [ { "method": "read_item", @@ -23496,12 +23499,13 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point message mapping is reauthorized and converted into bounded nested conversation context.", + "contract": "The saved turn point mapping is normalized by normalize_history_message before role/conversation/fingerprint validation; history-disabled snapshots fail before reuse and source authorization is rechecked.", "tests": [ - "functional_tests/test_orchestration_conversation_context_routes.py" + "functional_tests/test_orchestration_conversation_context_routes.py", + "functional_tests/test_orchestration_conversation_sources.py" ], "evidence_scope": "owner-runtime" }, @@ -23527,10 +23531,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point conversation mapping is consumed through field access; create/upsert responses are discarded.", + "contract": "Requested conversation point mappings supply owner/scope/deletion fields; constructed create/upsert bodies are plain and write responses are discarded. Shared ownership is established separately, not inferred from wrapper type.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23548,10 +23552,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "response-unused", - "contract": "Upsert response is unused; existing write errors propagate.", + "contract": "The message upsert response is discarded; screening and source-availability checks occur before publication, and persistence errors retain the existing failure path.", "tests": [], "evidence_scope": "owner-runtime" }, @@ -23572,10 +23576,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point conversation/message mappings feed collaboration field projections; publication failure remains explicit.", + "contract": "Conversation/message point mappings feed shared-backing ownership checks and Mapping-compatible collaboration mirroring; no strict task/result descriptor consumes the wrappers.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23593,10 +23597,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point message mapping is patched through the chat metadata owner; saved identity publishes only scoped string fields.", + "contract": "The saved question point mapping updates nested orchestration metadata through the existing metadata-patch owner; durable returned planning identity is a separate plain scoped DTO.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23619,10 +23623,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point messages use field access for durable ID/fingerprint reuse and preserve ETags.", + "contract": "Saved turn point mappings are converted through normalize_history_message before identity/fingerprint reuse; nested metadata patches retain conditional ownership, and the returned identity/fingerprint are scalar projections.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23645,10 +23649,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point conversation mapping is consumed through field access; replace response is unused.", + "contract": "The conversation point mapping supplies owner/deletion and mutable index fields; replace response is discarded and the Mapping-compatible cache invalidator consumes the authorized input.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23666,10 +23670,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point message mapping supplies JSON display metadata; domain validators do not receive the wrapper.", + "contract": "The assistant point mapping supplies role/deletion/mask metadata to reply_is_retracted; absence is treated as removed, never as an empty valid response.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23687,10 +23691,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point message mappings supply artifact metadata to existing nested JSON contracts.", + "contract": "Artifact point mappings are checked by field access for conversation/user/visibility and nested content metadata; no top-level exact-dictionary result contract consumes them.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23708,10 +23712,10 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Point message mappings supply display/provenance revision fields; artifact validators operate on nested plain JSON.", + "contract": "Artifact point mappings are JSON-encoded by the existing checkpoint fingerprint helper; SDK dictionary subclasses are supported and nested artifact validation is unchanged.", "tests": [ "functional_tests/test_orchestration_conversation_context_routes.py" ], @@ -23719,7 +23723,7 @@ }, "application/single_app/route_backend_orchestration.py::register_route_backend_orchestration.orchestration_plan": { "boundary": { - "sha256": "278c6030a064e1be2396c35f3dde06a488815d95f42d4ab019fe5360bcf30e5b", + "sha256": "8af4094a26711c20c92115037e439856a01a0a917809a999a622df6ec7c46853", "points": [ { "method": "read_item", @@ -23729,12 +23733,13 @@ ], "queries": [], "batches": [], - "module_sha256": "0f6635544ac2696660fbb898a314587195672da3ca906727270dec96c12e45b9" + "module_sha256": "0c0799be3a3016ac21fe42976e25b98be279ff08154313e0b27b146dff3f4403" }, "classification": "sdk-compatible", - "contract": "Authorized backing conversation point mapping supplies the plan admission scope; durable saved identity is separately bound to run/turn/conversation.", + "contract": "Clarification admission reads its backing conversation through owner fields; saved planning context is separately authorized/projected. Conversation-only resolution and retrieval gating do not pass SDK wrappers into planner or strict result contracts.", "tests": [ - "functional_tests/test_orchestration_conversation_context_routes.py" + "functional_tests/test_orchestration_conversation_context_routes.py", + "functional_tests/test_orchestration_conversation_sources.py" ], "evidence_scope": "owner-runtime" }, @@ -28738,16 +28743,18 @@ }, "functional_tests/test_m365_continuations.py::PendingJobs": { "boundary": { - "sha256": "56c3aa51a4bb48204fb8b806ce6f2a60e16666ce15cb6b5a3bc6f9d6ff27a67c", - "methods": [], + "sha256": "374ed222329c5a7b15badf2083f10142d53b9be06bdd766ef70a36381f50a731", + "methods": [ + "replace_item" + ], "kind": "class", "inherited_bases": [ "CosmosContainer" ], - "imports_sha256": "34742e0a941e9fb3d99d02e7086fb96f82eb337d5d5529d0fc3c8a01a0579e70" + "imports_sha256": "170b73fefd9f10a74777d8870f746c46a49f9f0b57f97cc7b2b9b67b751ec0a0" }, - "classification": "legacy-inline", - "contract": "Unchanged inline Cosmos-shaped test container outside the migrated shared defaults. Its test-specific response behavior is an explicit staged-migration baseline, not blanket runtime SDK proof.", + "classification": "sdk-default", + "contract": "Inherits installed SDK CosmosDict point/write responses and conditional ETag/partition behavior. Filtered query rows are plain dictionaries; before_replace injects storage failures or concurrent state changes without changing response shape.", "tests": [ "functional_tests/test_m365_continuations.py" ] @@ -32343,6 +32350,66 @@ "tests": [ "functional_tests/test_workflow_authoring_sdk_boundaries.py" ] + }, + "functional_tests/test_m365_workflow_continuation_boundary.py::test_loader_does_not_translate_authorization_or_storage_failure_into_deletion::point-seam-1": { + "boundary": { + "sha256": "c911c24ca985dad5aa22384c32560fc985a8d70f10ef2ba061677aeddd306984", + "methods": [ + "read_item" + ], + "kind": "seam", + "imports_sha256": "1421386a0144cb4d828af7085068c63a78bea5d8abe3730634a177abe7b60ab3" + }, + "classification": "fault-injection", + "contract": "Scoped point-read failure injection proves permission, throttling and storage-unavailable errors remain distinct from authoritative workflow deletion.", + "tests": [ + "functional_tests/test_m365_workflow_continuation_boundary.py::test_loader_does_not_translate_authorization_or_storage_failure_into_deletion" + ] + }, + "functional_tests/test_m365_workflow_continuation_boundary.py::test_loader_rejects_a_response_for_a_different_scope::point-seam-1": { + "boundary": { + "sha256": "f2767071077fb8456111e2671a83d614820d07358fbd6c58bc8d8d05e4536ccf", + "methods": [ + "read_item" + ], + "kind": "seam", + "imports_sha256": "1421386a0144cb4d828af7085068c63a78bea5d8abe3730634a177abe7b60ab3" + }, + "classification": "fault-injection", + "contract": "Invalid personal/group point response scope injection proves the loader fails closed instead of treating another scope as the requested workflow.", + "tests": [ + "functional_tests/test_m365_workflow_continuation_boundary.py::test_loader_rejects_a_response_for_a_different_scope" + ] + }, + "functional_tests/test_m365_workflow_continuation_boundary.py::test_loader_rejects_invalid_sdk_response_instead_of_classifying_it_as_missing::point-seam-1": { + "boundary": { + "sha256": "3712fdcdb9a897824dd3fb62e450d8a76b2589e3ca58c3dbde69a0ef262b5826", + "methods": [ + "read_item" + ], + "kind": "seam", + "imports_sha256": "1421386a0144cb4d828af7085068c63a78bea5d8abe3730634a177abe7b60ab3" + }, + "classification": "fault-injection", + "contract": "A non-document point response explicitly fails normalization with TypeError; it never becomes a success-shaped empty document or authoritative deletion.", + "tests": [ + "functional_tests/test_m365_workflow_continuation_boundary.py::test_loader_rejects_invalid_sdk_response_instead_of_classifying_it_as_missing" + ] + }, + "functional_tests/test_orchestration_conversation_sources.py::test_shared_membership_is_rechecked_before_exact_message_reads::point-seam-1": { + "boundary": { + "sha256": "5bcc0ff1ff276e52a070e436c365264d75ca35b276a56f656a1a1d33f71f01ff", + "methods": [ + "read_item" + ], + "kind": "seam", + "imports_sha256": "f0599840a873c4b2b7527f041707fee865cbb299475b843098f7e4dad8f30b5a" + }, + "classification": "fault-injection", + "contract": "A wrapping Mock observes the SDK-default message read without changing its return type; revoked shared membership must fail before any message point read. This authorization fault does not add a plain-response compatibility override.", + "tests": [ + "functional_tests/test_orchestration_conversation_sources.py::test_shared_membership_is_rechecked_before_exact_message_reads" + ] } } } diff --git a/functional_tests/test_chat_content_streaming.py b/functional_tests/test_chat_content_streaming.py index 67d07294b..6b966d470 100644 --- a/functional_tests/test_chat_content_streaming.py +++ b/functional_tests/test_chat_content_streaming.py @@ -1,8 +1,9 @@ # test_chat_content_streaming.py """ Functional regressions for content-check streaming and processing-note visibility. -Version: 0.261.127 +Version: 0.261.341 Implemented in: 0.261.127 +Semantic outcome and cancellation diagnostics implemented in: 0.261.341 Execute the shipped stream session/bridge classes against an isolated cache. Cold-import probes use real modules with network access blocked. @@ -77,6 +78,7 @@ def stream_classes(cache, authoritative): "queue": queue, "threading": threading, "time": time, "json": json, "logging": logging, "datetime": datetime, "app_settings_cache": cache, "log_event": Mock(), "strip_private_chat_checks": strip_private_chat_checks, + "log_chat_execution_outcome": Mock(), "VERSION": "0.261.341", "retracted_stream_payload": authoritative, } exec(compile(ast.Module(body=definitions, type_ignores=[]), str(source), "exec"), namespace) @@ -136,6 +138,38 @@ def test_a_late_publisher_cannot_append_after_retraction(self): self.assertFalse(accepted) self.assertNotIn("private-canary", json.dumps(self.cache.events)) + def test_cancellation_is_correlated_once_and_not_logged_as_error(self): + terminal = frame({"done": True, "cancelled": True, "message_id": "answer"}) + self.session.publish(terminal) + self.session.publish(terminal) + self.session.close() + calls = self.module.log_chat_execution_outcome.call_args_list + self.assertEqual(len(calls), 1) + fields = calls[0].kwargs + self.assertEqual(fields["execution_path"], "direct") + self.assertEqual(fields["conversation_id"], "conversation") + self.assertEqual(fields["status"], "cancelled") + self.assertEqual(fields["outcome"], "cancelled") + self.assertEqual(fields["failure"]["code"], "user_cancelled") + terminal_logs = [ + call for call in self.module.log_event.call_args_list + if call.args[0] == "[STREAMING] Stream session cancelled" + ] + self.assertTrue(terminal_logs) + self.assertTrue(all(call.kwargs["level"] == logging.INFO for call in terminal_logs)) + + def test_error_emits_failed_outcome_without_provider_diagnostics(self): + self.session.publish(frame({"error": "PRIVATE_PROVIDER_MESSAGE"})) + fields = self.module.log_chat_execution_outcome.call_args.kwargs + self.assertEqual(fields["outcome"], "failed") + self.assertEqual(fields["failure"], {"code": "stream_failed"}) + self.assertNotIn("PRIVATE_PROVIDER_MESSAGE", json.dumps(fields)) + + def test_implicit_close_records_an_outcome_once(self): + self.session.close() + self.session.close() + self.assertEqual(self.module.log_chat_execution_outcome.call_count, 1) + def test_processing_note_polling_holds_pending_text_and_hides_retracted_text(self): source = APP / "functions_thoughts.py" tree = ast.parse(source.read_text(encoding="utf-8")) diff --git a/functional_tests/test_m365_continuations.py b/functional_tests/test_m365_continuations.py index b51a79073..9a941c21a 100644 --- a/functional_tests/test_m365_continuations.py +++ b/functional_tests/test_m365_continuations.py @@ -1,33 +1,53 @@ # test_m365_continuations.py """ Functional tests for conditional Microsoft 365 workflow continuation delivery. -Version: 0.261.122 +Version: 0.261.341 Implemented in: 0.261.029 +Stale workflow containment implemented in: 0.261.341 Approval delivery is idempotent and uncertain external execution is not replayed. """ -from datetime import datetime, timezone +from copy import deepcopy +from datetime import datetime, timedelta, timezone +import logging from pathlib import Path import sys import unittest +from azure.cosmos._cosmos_responses import CosmosDict +from azure.cosmos.exceptions import CosmosHttpResponseError + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "application" / "single_app")) sys.path.insert(0, str(Path(__file__).resolve().parent)) # Test paths are installed before importing dependency-light application modules. -from functions_m365_continuations import resume_pending_workflows # noqa: E402 +from functions_m365_continuations import M365ContinuationNotStarted, resume_pending_workflows # noqa: E402 +from functions_m365_context import M365PolicyError # noqa: E402 from test_support.m365 import CosmosContainer # noqa: E402 class PendingJobs(CosmosContainer): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.before_replace = None + + def replace_item(self, item, body, *, etag, match_condition): + if self.before_replace is not None: + self.before_replace(item, body) + return super().replace_item(item, body, etag=etag, match_condition=match_condition) + def query_items(self, **kwargs): - return [ - self.read_item(record["id"], record["user_id"]) + rows = [ + deepcopy(dict(record)) for record in list(self.items.values()) - if record.get("status") in {"awaiting_approval", "ready_to_resume", "resuming"} + if record.get("type") == "m365_execution_request" + and isinstance(record.get("workflow_id"), str) and record["workflow_id"] + and record.get("status") in {"awaiting_approval", "awaiting_sign_in", "ready_to_resume", "resuming"} ] + limit = next(parameter["value"] for parameter in kwargs["parameters"] if parameter["name"] == "@limit") + return rows[:limit] class Approvals: @@ -45,15 +65,37 @@ def setUp(self): }) self.effects = [] self.events = [] + self.now = datetime(2026, 9, 17, tzinfo=timezone.utc) - def run_pending(self, execute): + def run_pending(self, execute, **kwargs): + options = { + "can_resume": lambda job, approval: True, + "log_event": lambda *args, **kwargs: self.events.append((args, kwargs)), + "clock": lambda: self.now, + } + options.update(kwargs) return resume_pending_workflows( - self.jobs, Approvals(), execute=execute, - can_resume=lambda job, approval: True, - log_event=lambda *args, **kwargs: self.events.append(args), - clock=lambda: datetime(2026, 9, 17, tzinfo=timezone.utc), + self.jobs, options.pop("approvals", Approvals()), execute=execute, **options, ) + def add_valid_job(self): + self.jobs.create_item(body={ + "id": "later-request", "user_id": "reader", "workflow_id": "valid-workflow", + "type": "m365_execution_request", "approval_id": "valid-approval", + "status": "awaiting_approval", + }) + + def execute_successfully(self, job): + self.effects.append(job["id"]) + self.assertIs(type(job), CosmosDict) + return {"success": True} + + @staticmethod + def workflow_available(job, approval): + if job["workflow_id"] == "workflow": + raise M365PolicyError("m365_workflow_missing", "The workflow no longer exists.") + return True + def test_decided_job_runs_once(self): def execute(job): self.effects.append(job["id"]) @@ -68,10 +110,10 @@ def test_uncertain_operation_requires_recovery(self): def execute(job): self.effects.append(job["id"]) raise RuntimeError("Worker stopped after an external operation") - with self.assertRaises(RuntimeError): - self.run_pending(execute) + first = self.run_pending(execute) second = self.run_pending(execute) current = self.jobs.read_item("request", "reader") + self.assertEqual(first, []) self.assertEqual(second, []) self.assertEqual(current["status"], "recovery_required") self.assertEqual(self.effects, ["request"]) @@ -83,10 +125,11 @@ def execute(job): current["approval_id"] = "second-approval" self.jobs.upsert_item(body=current) return {"success": False, "pending": True} - self.run_pending(execute) + outcomes = self.run_pending(execute) current = self.jobs.read_item("request", "reader") self.assertEqual(current["status"], "awaiting_approval") self.assertEqual(current["approval_id"], "second-approval") + self.assertEqual(outcomes, [{"request_id": "request", "result": {"success": False, "pending": True}}]) def test_durable_handoff_remains_running_without_a_legacy_replay_lease(self): def execute(job): @@ -100,6 +143,280 @@ def execute(job): self.assertEqual(self.run_pending(execute), []) self.assertEqual(self.effects, ["request"]) + def test_deleted_then_valid_candidate_and_repeated_tick_has_no_error_storm(self): + self.add_valid_job() + first = self.run_pending(self.execute_successfully, can_resume=self.workflow_available) + event_count = len(self.events) + for _tick in range(3): + repeated = self.run_pending(self.execute_successfully, can_resume=self.workflow_available) + self.assertEqual(repeated, []) + stale = self.jobs.read_item("request", "reader") + self.assertEqual(stale["status"], "failed") + self.assertEqual(stale["failure_code"], "workflow_deleted") + self.assertEqual(stale["completed_at"], self.now.isoformat()) + self.assertNotIn("continuation_status", stale) + self.assertNotIn("terminal_reason", stale) + self.assertEqual([item["request_id"] for item in first], ["later-request"]) + self.assertEqual(self.effects, ["later-request"]) + self.assertEqual(len(self.events), event_count) + self.assertTrue(all(event[1]["level"] != logging.ERROR for event in self.events)) + + def test_deleted_workflow_is_closed_before_waiting_for_sign_in_or_approval(self): + for status in ("awaiting_sign_in", "awaiting_approval"): + with self.subTest(status=status): + current = self.jobs.read_item("request", "reader") + self.jobs.upsert_item(body={**current, "status": status}) + self.run_pending( + self.execute_successfully, can_resume=self.workflow_available, + approvals=object(), connection_ready=lambda job: self.fail("Sign-in is not needed to close stale work."), + ) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "failed") + self.assertEqual(self.effects, []) + + def test_deletion_after_approval_read_is_still_authoritative(self): + def can_resume(job, approval): + if approval is not None: + raise M365PolicyError("m365_workflow_missing", "The workflow no longer exists.") + return True + self.run_pending(self.execute_successfully, can_resume=can_resume) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["failure_code"], "workflow_deleted") + self.assertEqual(self.effects, []) + + def test_deletion_between_check_and_execution_closes_only_unstarted_work(self): + self.add_valid_job() + def execute(job): + if job["id"] == "request": + raise M365ContinuationNotStarted() + return self.execute_successfully(job) + self.run_pending(execute) + self.run_pending(execute) + stale = self.jobs.read_item("request", "reader") + self.assertEqual(stale["status"], "failed") + self.assertEqual(stale["failure_code"], "workflow_deleted") + self.assertNotIn("resume_lease_expires_at", stale) + self.assertEqual(self.effects, ["later-request"]) + + def test_missing_workflow_after_possible_effects_requires_recovery_and_does_not_block_later_work(self): + self.add_valid_job() + def execute(job): + if job["id"] == "request": + self.effects.append(job["id"]) + raise M365PolicyError("m365_workflow_missing", "The workflow no longer exists.") + return self.execute_successfully(job) + self.run_pending(execute) + self.run_pending(execute) + stale = self.jobs.read_item("request", "reader") + self.assertEqual(stale["status"], "recovery_required") + self.assertNotIn("failure_code", stale) + self.assertEqual(self.effects, ["request", "later-request"]) + + def test_unknown_execution_error_is_contained_logged_and_never_replayed(self): + self.add_valid_job() + def execute(job): + if job["id"] == "request": + self.effects.append(job["id"]) + raise RuntimeError("private-prompt-and-access-token") + return self.execute_successfully(job) + self.run_pending(execute) + self.run_pending(execute) + self.assertEqual(self.effects, ["request", "later-request"]) + failure = next(event[1] for event in self.events if event[1]["level"] == logging.ERROR) + self.assertEqual(failure["extra"]["failure_code"], "continuation_execution_failed") + self.assertEqual(failure["extra"]["error_type"], "RuntimeError") + self.assertNotIn("private-prompt-and-access-token", repr(self.events)) + self.assertNotIn("'request_id':", repr(self.events)) + + def test_transient_storage_authorization_and_same_message_failures_do_not_mean_deleted(self): + failures = [ + PermissionError("The workflow no longer exists."), + RuntimeError("The workflow no longer exists."), + M365PolicyError("m365_workflow_changed", "The workflow no longer exists."), + *(CosmosHttpResponseError(status_code=code, message="private storage details") for code in (403, 429, 503)), + ] + for error in failures: + with self.subTest(error=type(error).__name__, code=getattr(error, "status_code", None)): + self.setUp() + self.add_valid_job() + def can_resume(job, approval): + if job["id"] == "request": + raise error + return True + self.run_pending(self.execute_successfully, can_resume=can_resume) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "awaiting_approval") + self.assertNotIn("completed_at", current) + self.assertEqual(self.effects, ["later-request"]) + self.assertTrue(any(event[1]["level"] == logging.ERROR for event in self.events)) + + def test_approval_storage_failure_does_not_abort_later_jobs(self): + self.add_valid_job() + class UnavailableApproval(Approvals): + def get_approval(self, approval_id, user_id): + if approval_id == "approval": + raise CosmosHttpResponseError(status_code=503, message="temporarily unavailable") + return super().get_approval(approval_id, user_id) + self.run_pending(self.execute_successfully, approvals=UnavailableApproval()) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "awaiting_approval") + self.assertEqual(self.effects, ["later-request"]) + + def test_stale_terminalization_cas_preserves_new_approval_cancellation_or_other_claim(self): + for status in ("awaiting_approval", "cancelled", "resuming"): + with self.subTest(status=status): + self.setUp() + self.add_valid_job() + def change_record(item, body): + if item == "request": + self.jobs.before_replace = None + current = self.jobs.read_item(item, "reader") + self.jobs.upsert_item(body={ + **current, "status": status, "approval_id": "new-approval", + }) + self.jobs.before_replace = change_record + self.run_pending(self.execute_successfully, can_resume=self.workflow_available) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], status) + self.assertEqual(current["approval_id"], "new-approval") + self.assertNotIn("failure_code", current) + self.assertNotIn("completed_at", current) + self.assertEqual(self.effects, ["later-request"]) + + def test_delete_race_terminalization_preserves_a_newer_approval(self): + self.add_valid_job() + def execute(job): + if job["id"] == "request": + self.jobs.upsert_item(body={**job, "status": "awaiting_approval", "approval_id": "new-approval"}) + raise M365ContinuationNotStarted() + return self.execute_successfully(job) + self.run_pending(execute) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "awaiting_approval") + self.assertEqual(current["approval_id"], "new-approval") + self.assertNotIn("failure_code", current) + self.assertEqual(self.effects, ["later-request"]) + + def test_terminalization_storage_failure_preserves_the_unclaimed_job(self): + self.add_valid_job() + def fail_terminalization(item, body): + if item == "request": + raise CosmosHttpResponseError(status_code=503, message="storage unavailable") + self.jobs.before_replace = fail_terminalization + self.run_pending(self.execute_successfully, can_resume=self.workflow_available) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "awaiting_approval") + self.assertNotIn("failure_code", current) + self.assertEqual(self.effects, ["later-request"]) + self.jobs.before_replace = None + self.run_pending(self.execute_successfully, can_resume=self.workflow_available) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["failure_code"], "workflow_deleted") + self.assertEqual(self.effects, ["later-request"]) + + def test_pending_approval_and_unready_connection_remain_unclaimed(self): + class PendingApproval(Approvals): + def get_approval(self, approval_id, user_id): + return {"id": approval_id, "subject_user_id": user_id, "status": "pending"} + self.run_pending(self.execute_successfully, approvals=PendingApproval()) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "awaiting_approval") + self.jobs.upsert_item(body={**current, "status": "awaiting_sign_in"}) + self.run_pending(self.execute_successfully, connection_ready=lambda job: False) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "awaiting_sign_in") + self.assertEqual(self.effects, []) + + def test_claim_conflict_never_enters_execution_and_continues_batch(self): + self.add_valid_job() + def cancel_before_claim(item, body): + if item == "request": + self.jobs.before_replace = None + self.jobs.upsert_item(body={**body, "status": "cancelled"}) + self.jobs.before_replace = cancel_before_claim + self.run_pending(self.execute_successfully) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "cancelled") + self.assertEqual(self.effects, ["later-request"]) + + def test_execution_cannot_overwrite_new_approval_or_cancellation_on_error(self): + for status in ("awaiting_approval", "cancelled"): + with self.subTest(status=status): + self.setUp() + def execute(job): + self.jobs.upsert_item(body={**job, "status": status, "approval_id": "new-approval"}) + raise RuntimeError("Worker stopped after saving a newer state") + self.run_pending(execute) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], status) + self.assertEqual(current["approval_id"], "new-approval") + + def test_expired_uncertain_claim_is_recovered_without_checking_or_replaying_deleted_workflow(self): + current = self.jobs.read_item("request", "reader") + self.jobs.upsert_item(body={ + **current, "status": "resuming", + "resume_lease_expires_at": (self.now - timedelta(minutes=1)).isoformat(), + }) + self.run_pending( + self.execute_successfully, can_resume=lambda *args: self.fail("A claimed effect is uncertain."), + ) + self.run_pending(self.execute_successfully) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "recovery_required") + self.assertNotIn("failure_code", current) + self.assertEqual(self.effects, []) + + def test_active_claim_is_not_changed_or_executed(self): + current = self.jobs.read_item("request", "reader") + leased = self.jobs.upsert_item(body={ + **current, "status": "resuming", + "resume_lease_expires_at": (self.now + timedelta(minutes=1)).isoformat(), + }) + self.run_pending(self.execute_successfully) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current, leased) + self.assertEqual(self.effects, []) + + def test_acknowledgement_failure_leaves_claim_and_next_tick_recovers_without_replay(self): + self.add_valid_job() + def fail_acknowledgement(item, body): + if item == "request" and body["status"] == "completed": + raise CosmosHttpResponseError(status_code=503, message="private connection details") + self.jobs.before_replace = fail_acknowledgement + self.run_pending(self.execute_successfully) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "resuming") + self.run_pending(self.execute_successfully) + self.now += timedelta(minutes=16) + self.run_pending(self.execute_successfully) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "recovery_required") + self.assertEqual(self.effects, ["request", "later-request"]) + + def test_failed_recovery_write_preserves_claim_and_continues_without_replay(self): + self.add_valid_job() + def fail_recovery(item, body): + if item == "request" and body["status"] == "recovery_required": + raise CosmosHttpResponseError(status_code=503, message="storage unavailable") + def execute(job): + if job["id"] == "request": + self.effects.append(job["id"]) + raise RuntimeError("uncertain external write") + return self.execute_successfully(job) + self.jobs.before_replace = fail_recovery + self.run_pending(execute) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "resuming") + self.now += timedelta(minutes=16) + self.run_pending(execute) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "resuming") + self.jobs.before_replace = None + self.run_pending(execute) + current = self.jobs.read_item("request", "reader") + self.assertEqual(current["status"], "recovery_required") + self.assertEqual(self.effects, ["request", "later-request"]) + if __name__ == "__main__": unittest.main() diff --git a/functional_tests/test_m365_cosmos_sdk_contract.py b/functional_tests/test_m365_cosmos_sdk_contract.py index 78dde354d..8bb80b6f8 100644 --- a/functional_tests/test_m365_cosmos_sdk_contract.py +++ b/functional_tests/test_m365_cosmos_sdk_contract.py @@ -1,9 +1,10 @@ # test_m365_cosmos_sdk_contract.py """ Functional regressions for Microsoft 365 Cosmos replacement calls. -Version: 0.261.324 +Version: 0.261.341 Implemented in: 0.261.031 Point/query response representation coverage added in: 0.261.324 +Conditional scheduler lock deletion coverage added in: 0.261.341 Uses the installed Cosmos SDK and RequestsTransport with an autospecced HTTP session. Only remote responses are faked; the real SDK derives partition routing @@ -93,7 +94,7 @@ def request(self, method, url, *, data=None, headers=None, **kwargs): status, payload = 409, {"code": "Conflict", "message": "The scoped item already exists."} else: status, payload = 201, self.seed(request) - elif "/docs/" in path and method in {"GET", "PUT", "PATCH"}: + elif "/docs/" in path and method in {"GET", "PUT", "PATCH", "DELETE"}: partition = json.loads(headers["x-ms-documentdb-partitionkey"])[0] item_id = path.rsplit("/", 1)[-1] existing = self.records.get((partition, item_id)) @@ -103,6 +104,9 @@ def request(self, method, url, *, data=None, headers=None, **kwargs): payload = deepcopy(existing) elif headers.get("If-Match") != existing["_etag"]: status, payload = 412, {"code": "PreconditionFailed", "message": "The item changed."} + elif method == "DELETE": + del self.records[(partition, item_id)] + status, payload = 204, {} else: body = json.loads(data) if method == "PATCH": diff --git a/functional_tests/test_m365_workflow_continuation_boundary.py b/functional_tests/test_m365_workflow_continuation_boundary.py new file mode 100644 index 000000000..6ba45fb53 --- /dev/null +++ b/functional_tests/test_m365_workflow_continuation_boundary.py @@ -0,0 +1,243 @@ +# test_m365_workflow_continuation_boundary.py +""" +Functional regressions for authoritative workflow deletion and scheduler isolation. +Version: 0.261.341 +Implemented in: 0.261.341 + +Real Cosmos SDK point reads exercise the loader; real continuation dispatch exercises +the scheduler callbacks with closed workflow execution and connection boundaries. +""" + +import ast +from contextlib import nullcontext +from datetime import datetime, timedelta, timezone +import logging +from pathlib import Path +import sys +from types import ModuleType, SimpleNamespace +from unittest.mock import Mock, patch +import uuid + +from azure.core import MatchConditions +from azure.cosmos.exceptions import CosmosHttpResponseError +import pytest + + +APP = Path(__file__).resolve().parents[1] / "application" / "single_app" +sys.path.insert(0, str(APP)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +# Standalone tests install application paths before loading the runtime owners. +from functions_m365_context import M365PolicyError +from functions_m365_continuations import M365ContinuationNotStarted, resume_pending_workflows +from functions_m365_workflow_binding import M365_ACTIVE_STATES +from test_m365_continuations import Approvals, PendingJobs +from test_m365_cosmos_sdk_contract import cosmos_factory, load_runtime + + +def workflow_config(container): + config = ModuleType("config") + config.cosmos_group_workflows_container = container + config.cosmos_personal_workflows_container = container + return config + + +@pytest.mark.parametrize("scope", ["personal", "group"]) +def test_authoritative_workflow_loader_uses_scoped_sdk_read_and_preserves_projection(cosmos_factory, scope): + partition_field = "group_id" if scope == "group" else "user_id" + http, container = cosmos_factory(partition_field) + record = {"id": "workflow", "user_id": "owner", "status": "awaiting_approval", "tasks": []} + if scope == "group": + record["group_id"] = "group" + http.seed(record) + runtime = load_runtime("functions_m365_runtime", container) + with patch.dict(sys.modules, {"config": workflow_config(container)}): + current = runtime.load_current_workflow(record) + assert type(current) is dict + assert current["id"] == record["id"] + assert current["status"] == "awaiting_approval" + assert current["definition_revision"] + assert not any(key.startswith("_") for key in current) + method, _path, headers = http.calls[-1] + assert method == "GET" + assert headers["x-ms-documentdb-partitionkey"] == f'["{record[partition_field]}"]' + + +@pytest.mark.parametrize("scope", ["personal", "group"]) +@pytest.mark.parametrize("deleted", ["not_found", "deleting_flag", "deleting_status"]) +def test_only_authoritative_absence_or_tombstone_reports_workflow_missing(cosmos_factory, scope, deleted): + http, container = cosmos_factory("group_id" if scope == "group" else "user_id") + reference = {"id": "workflow", "user_id": "owner"} + if scope == "group": + reference["group_id"] = "group" + if deleted != "not_found": + http.seed({ + **reference, "deleting": deleted == "deleting_flag", + "status": "deleting" if deleted == "deleting_status" else "awaiting_approval", + }) + runtime = load_runtime("functions_m365_runtime", container) + with patch.dict(sys.modules, {"config": workflow_config(container)}): + with pytest.raises(M365PolicyError) as failure: + runtime.load_current_workflow(reference) + assert failure.value.code == "m365_workflow_missing" + + +@pytest.mark.parametrize("scope", ["personal", "group"]) +@pytest.mark.parametrize("error", [ + PermissionError("The workflow no longer exists."), + CosmosHttpResponseError(status_code=403, message="access denied"), + CosmosHttpResponseError(status_code=429, message="throttled"), + CosmosHttpResponseError(status_code=503, message="storage unavailable"), +]) +def test_loader_does_not_translate_authorization_or_storage_failure_into_deletion(cosmos_factory, scope, error): + _http, container = cosmos_factory("group_id" if scope == "group" else "user_id") + runtime = load_runtime("functions_m365_runtime", container) + reference = {"id": "workflow", "user_id": "owner"} + if scope == "group": + reference["group_id"] = "group" + with patch.dict(sys.modules, {"config": workflow_config(container)}): + with patch.object(container, "read_item", side_effect=error): + with pytest.raises(type(error)) as failure: + runtime.load_current_workflow(reference) + assert failure.value is error + + +def test_loader_rejects_invalid_sdk_response_instead_of_classifying_it_as_missing(cosmos_factory): + _http, container = cosmos_factory("user_id") + runtime = load_runtime("functions_m365_runtime", container) + with patch.dict(sys.modules, {"config": workflow_config(container)}): + with patch.object(container, "read_item", return_value=None): + with pytest.raises(TypeError): + runtime.load_current_workflow({"id": "workflow", "user_id": "owner"}) + +@pytest.mark.parametrize("scope", ["personal", "group"]) +def test_loader_rejects_a_response_for_a_different_scope(cosmos_factory, scope): + _http, container = cosmos_factory("group_id" if scope == "group" else "user_id") + runtime = load_runtime("functions_m365_runtime", container) + reference = {"id": "workflow", "user_id": "owner"} + if scope == "group": + reference["group_id"] = "group" + response = {**reference, "group_id" if scope == "group" else "user_id": "another-scope"} + with patch.dict(sys.modules, {"config": workflow_config(container)}): + with patch.object(container, "read_item", return_value=response): + with pytest.raises(PermissionError): + runtime.load_current_workflow(reference) + + +def scheduler_callbacks(jobs, load_workflow, runner, events): + names = ( + "configure_m365_execution", "configure_m365_connection_authorization", + "configure_m365_file_runtime", "configure_m365_pending_delivery_runtime", + "dispatch_due_m365_deliveries", "validate_m365_approval_decision", + "validate_m365_workflow_execution", "resolve_m365_action_config", + "resolve_m365_workflow_binding", "resolve_m365_action_selection", + "validate_m365_workflow_context", + ) + namespace = {name: Mock() for name in names} + namespace.update( + M365PolicyError=M365PolicyError, M365ContinuationNotStarted=M365ContinuationNotStarted, + M365_ACTIVE_STATES=M365_ACTIVE_STATES, + get_m365_approval_service=Approvals, + _get_workflow_runner_app=lambda: SimpleNamespace(test_request_context=nullcontext), + load_current_workflow=load_workflow, + get_settings=lambda: {"allow_user_workflows": True}, + acquire_distributed_task_lock=Mock(return_value={"id": "workflow-lock"}), + release_distributed_task_lock=Mock(), + resume_m365_durable_workflow_run=runner, + run_group_workflow=runner, run_personal_workflow=runner, + update_group_workflow_runtime_fields=Mock(), update_personal_workflow_runtime_fields=Mock(), + workflow_result_runtime_status=lambda result: "idle", + workflow_result_is_waiting=lambda result: True, + resume_pending_workflows=resume_pending_workflows, + cosmos_m365_execution_runs_container=jobs, + log_event=lambda *args, **kwargs: events.append((args, kwargs)), + ) + path = APP / "background_tasks.py" + source = ast.parse(path.read_text(encoding="utf-8")) + entrypoint = next(node for node in source.body if isinstance(node, ast.FunctionDef) + and node.name == "check_m365_workflow_continuations_once") + exec(compile(ast.Module(body=[entrypoint], type_ignores=[]), str(path), "exec"), namespace) + return namespace[entrypoint.name], namespace + + +@pytest.mark.parametrize("after_execution_started", [False, True]) +def test_scheduler_delete_race_distinguishes_no_effects_from_uncertain_execution(after_execution_started): + jobs = PendingJobs(partition_field="user_id") + workflows = {} + for workflow_id in ("deleted-workflow", "valid-workflow"): + workflow = { + "id": workflow_id, "user_id": "owner", "m365_run_as_user_id": "reader", + "active_run_id": f"run-{workflow_id}", "status": "awaiting_approval", + } + workflows[workflow_id] = workflow + jobs.create_item(body={ + "id": f"request-{workflow_id}", "user_id": "reader", "actor_user_id": "owner", + "workflow_id": workflow_id, "run_id": workflow["active_run_id"], + "workflow_ref": {"id": workflow_id, "user_id": "owner"}, + "type": "m365_execution_request", "approval_id": "approval", + "status": "awaiting_approval", + }) + reads, effects, events = {}, [], [] + + def load_workflow(reference): + workflow_id = reference["id"] + reads[workflow_id] = reads.get(workflow_id, 0) + 1 + if workflow_id == "deleted-workflow" and not after_execution_started and reads[workflow_id] == 3: + raise M365PolicyError("m365_workflow_missing", "The workflow no longer exists.") + return workflows[workflow_id] + + def runner(workflow, **kwargs): + effects.append(workflow["id"]) + if workflow["id"] == "deleted-workflow": + raise M365PolicyError("m365_workflow_missing", "The workflow no longer exists.") + return {"success": True} + + tick, namespace = scheduler_callbacks(jobs, load_workflow, runner, events) + first = tick() + repeated = tick() + stopped = jobs.read_item("request-deleted-workflow", "reader") + assert stopped["status"] == ("recovery_required" if after_execution_started else "failed") + assert stopped.get("failure_code") == (None if after_execution_started else "workflow_deleted") + assert effects == (["deleted-workflow", "valid-workflow"] if after_execution_started else ["valid-workflow"]) + assert [outcome["request_id"] for outcome in first] == ["request-valid-workflow"] + assert repeated == [] + assert namespace["release_distributed_task_lock"].call_count == (2 if after_execution_started else 1) + + +def test_scheduler_locks_consume_sdk_mappings_and_keep_conditional_ownership(cosmos_factory): + http, container = cosmos_factory("id") + path = APP / "background_tasks.py" + source = ast.parse(path.read_text(encoding="utf-8")) + names = {"_is_expired_timestamp", "acquire_distributed_task_lock", "release_distributed_task_lock"} + functions = [node for node in source.body if isinstance(node, ast.FunctionDef) and node.name in names] + namespace = { + "datetime": datetime, "timedelta": timedelta, "timezone": timezone, + "uuid": uuid, "logging": logging, "MatchConditions": MatchConditions, + "_get_lock_holder_id": lambda: "this-worker", + "cosmos_settings_container": container, "log_event": Mock(), + } + exec(compile(ast.Module(body=functions, type_ignores=[]), str(path), "exec"), namespace) + lock_id = "background_task_lock_workflow" + http.seed({ + "id": lock_id, "holder_id": "old-worker", + "expires_at": (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat(), + }) + acquire = namespace["acquire_distributed_task_lock"] + release = namespace["release_distributed_task_lock"] + lock = acquire("workflow", 900) + blocked = acquire("workflow", 900) + assert lock["holder_id"] == "this-worker" + assert blocked is None + current_etag = http.records[(lock_id, lock_id)]["_etag"] + release({**lock, "holder_id": "different-worker"}) + assert http.records[(lock_id, lock_id)]["_etag"] == current_etag + release(lock) + assert (lock_id, lock_id) not in http.records + method, _path, headers = http.calls[-1] + assert method == "DELETE" + assert headers["If-Match"] == current_etag + assert headers["x-ms-documentdb-partitionkey"] == f'["{lock_id}"]' + + +if __name__ == "__main__": + sys.exit(pytest.main([str(Path(__file__).resolve()), "-p", "no:cacheprovider"])) diff --git a/functional_tests/test_orchestration_compose_output_retry.py b/functional_tests/test_orchestration_compose_output_retry.py index 767bfe53b..c72856bf2 100644 --- a/functional_tests/test_orchestration_compose_output_retry.py +++ b/functional_tests/test_orchestration_compose_output_retry.py @@ -1,7 +1,7 @@ # test_orchestration_compose_output_retry.py """Records guidance, JSON mode and the single corrective call for content preparation. -Version: 0.261.341 +Version: 0.261.342 Implemented in: 0.261.141 Explicit chart/diagram omission correction implemented in: 0.261.341 @@ -379,7 +379,10 @@ def _store_visual(harness, replies, *, kind="diagram", requested="explicit", mul ], "steps": [step], "final_response": input_binding("schedule"), }) - _store(harness, plan, replies=replies, history=[PRIOR_PLAN]) + _store(harness, plan, replies=replies, history=[PRIOR_PLAN], request_resolution={ + "relationship": "follow_up", "resolved_message": step["arguments"]["instruction"], + "message_ids": [PRIOR_PLAN["id"]], "requires_retrieval": False, + }) return plan diff --git a/functional_tests/test_orchestration_conversation_sources.py b/functional_tests/test_orchestration_conversation_sources.py new file mode 100644 index 000000000..0ce4ad9e6 --- /dev/null +++ b/functional_tests/test_orchestration_conversation_sources.py @@ -0,0 +1,433 @@ +# test_orchestration_conversation_sources.py +""" +Functional regressions for exact conversation-source transformations. +Version: 0.261.341 +Implemented in: 0.261.341 + +Exercises real discovery, resolver, Flask planning and initialized execution with +offline storage/model boundaries. Discovery previews never substitute for full +authorized sources, and no-retrieval resolutions cannot become workspace audits. +""" + +import importlib +import json +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from azure.cosmos._cosmos_responses import CosmosDict + +from test_orchestration_conversation_context import message +from test_orchestration_conversation_context_routes import ( + _compose_only_plan, + _new_topic, + _plan, + _planned, + _replace_history, + _resolution, + _run, + _search_then_answer_plan, + no_candidate_document_probe, # noqa: F401 +) +from test_orchestration_harness_routes import modules, real_http_harness # noqa: F401 +from test_support.orchestration_revisions import AtomicMemoryContainer + + +REQUEST = 'Rewrite the above content as a concise report without dropping its constraints.' + + +@pytest.fixture +def context(modules): + return importlib.import_module('functions_orchestration_context') + + +def long_sources(): + source = ('BEGIN preserve the owner constraint\n' + 'pasted source material ' * 6000)[:116886] + middle = 'MIDDLE preserve the authorization constraint\n' + source = source[:50000] + middle + source[50000 + len(middle):] + end = 'END preserve the explicitly selected scope.' + source = source[:-len(end)] + end + answer = ('Assistant unverified assertions. ' * 1000)[:29192] + return [message('source', 'user', source, 1), message('answer', 'assistant', answer, 2)] + + +def transform_resolution(): + return _resolution( + resolved_message=REQUEST, message_ids=['answer'], requires_retrieval=False, + ) + + +def verified_model(harness): + harness.settings['gpt_model']['selected'][0].update({ + 'contextWindow': 128000, 'outputTokenLimit': 16384, + 'responseLength': 1024, 'outputTokenAccounting': 'total_generation', + }) + + +def test_discovery_retains_long_user_source_and_answer_roles_with_provenance(context): + rows = long_sources() + assert len(rows[0]['content']) == 116886 + assert len(rows[1]['content']) == 29192 + snapshot = context.build_conversation_snapshot(rows) + assert context.conversation_snapshot_size(snapshot) <= context.HISTORY_MAX_BYTES + assert [entry['role'] for entry in snapshot['messages']] == ['user', 'assistant'] + assert [entry['id'] for entry in snapshot['messages']] == ['source', 'answer'] + for entry, original in zip(snapshot['messages'], rows): + assert entry['content_complete'] is False + assert entry['source_characters'] == len(original['content']) + assert entry['source'] == { + 'kind': 'stored_message', 'message_id': entry['id'], 'role': entry['role'], + } + validated = context.validate_conversation_snapshot(snapshot, rows) + exact = context.resolve_conversation_source_messages(validated, rows, ['source', 'answer']) + assert [entry['content'] for entry in exact] == [entry['content'] for entry in rows] + assert 'MIDDLE preserve the authorization constraint' in exact[0]['content'] + with pytest.raises(context.ConversationContextError): + context.execution_conversation_messages(SimpleNamespace( + conversation_context=snapshot, context_message_ids=['source'], + )) + + +def test_long_paste_is_reloaded_exactly_for_composition_not_used_as_a_preview(real_http_harness, monkeypatch): + runtime = real_http_harness + harness = runtime.harness + verified_model(harness) + rows = long_sources() + _replace_history(harness, rows) + harness.replies = [transform_resolution(), _compose_only_plan(), 'Rewritten complete source.'] + plan = _planned(runtime, REQUEST) + record = harness.runs.read_item(plan['run_id'], 'conversation-1') + assert record['request_resolution']['message_ids'] == ['source', 'answer'] + assert record['conversation_context']['messages'][0]['content_complete'] is False + budget = importlib.import_module('functions_workflow_context') + calculate = budget.calculate_workflow_context_budget + audits = [] + + def capture_budget(*args, **kwargs): + audit = calculate(*args, **kwargs) + audits.append(audit) + return audit + + monkeypatch.setattr(budget, 'calculate_workflow_context_budget', capture_budget) + events = _run(runtime, plan) + assert not any(event.get('error') for event in events), events + composition = harness.model_calls[-1]['messages'] + assert audits and audits[0]['decision'] == 'full_input', json.dumps(audits) + assert audits[0]['limit_status'] == 'known' + for row in rows: + assert any( + entry['role'] == row['role'] and entry['content'] == row['content'] + for entry in composition + ) + assert all('Conversation discovery preview' not in entry['content'] for entry in composition) + assert any('they are not verified evidence' in entry['content'] for entry in composition if entry['role'] == 'system') + saved = harness.runs.read_item(plan['run_id'], 'conversation-1') + assert saved['status'] == 'completed' + + +def test_missing_target_clarifies_without_a_model_or_workspace_fallback(real_http_harness): + runtime = real_http_harness + _replace_history(runtime.harness, []) + events = _plan(runtime, 'Please rewrite that answer.') + assert not any(event.get('type') == 'orchestration_plan' for event in events), events + assert any('Which message or text' in json.dumps(event) for event in events), events + assert runtime.harness.model_calls == [] + + +def test_history_disabled_does_not_restore_pasted_content(real_http_harness): + runtime = real_http_harness + _replace_history(runtime.harness, long_sources()) + runtime.harness.settings['conversation_history_limit'] = 0 + events = _plan(runtime, REQUEST) + assert any('Which message or text' in json.dumps(event) for event in events), events + assert runtime.harness.model_calls == [] + + +def test_ambiguous_resolver_new_topic_transformation_cannot_search_all_workspaces(real_http_harness): + runtime = real_http_harness + _replace_history(runtime.harness, long_sources()) + runtime.harness.replies = [_new_topic(REQUEST)] + events = _plan(runtime, REQUEST) + assert any('Which message or text' in json.dumps(event) for event in events), events + assert len(runtime.harness.model_calls) == 1 + + +def test_no_retrieval_resolution_repairs_inconsistent_workspace_audit_plan(real_http_harness): + runtime = real_http_harness + _replace_history(runtime.harness, long_sources()) + runtime.harness.replies = [ + transform_resolution(), _search_then_answer_plan(REQUEST), _compose_only_plan(), + ] + plan = _planned(runtime, REQUEST) + assert [step['capability_id'] for step in plan['steps']] == ['compose'] + planning = runtime.harness.model_calls[1]['messages'] + payload = json.loads(planning[1]['content']) + assert payload['source_policy'] == 'conversation_only' + assert 'document_search' not in payload['capability_availability']['available'] + correction = runtime.harness.model_calls[-1]['messages'][-1]['content'] + assert 'not workspace or web retrieval' in correction + + +def test_repeated_inconsistent_plan_fails_instead_of_broadening_scope(real_http_harness): + runtime = real_http_harness + _replace_history(runtime.harness, long_sources()) + runtime.harness.replies = [ + transform_resolution(), _search_then_answer_plan(REQUEST), _search_then_answer_plan(REQUEST), + ] + events = _plan(runtime, REQUEST) + assert any('not a workspace search' in event.get('error', '') for event in events), events + assert not any(event.get('type') == 'orchestration_plan' for event in events) + + +def test_explicit_workspace_retrieval_overrides_no_retrieval_resolution(real_http_harness): + runtime = real_http_harness + _replace_history(runtime.harness) + request = 'Search workspace documents for current policies.' + runtime.harness.replies = [_new_topic(request), _search_then_answer_plan(request)] + plan = _planned(runtime, request) + assert [step['capability_id'] for step in plan['steps']] == ['document_search', 'compose'] + payload = json.loads(runtime.harness.model_calls[-1]['messages'][1]['content']) + assert payload['source_policy'] == 'retrieval_allowed' + assert payload['request_resolution']['message_ids'] == [] + + +def test_new_topic_has_no_prior_source_in_composition(real_http_harness): + runtime = real_http_harness + _replace_history(runtime.harness, long_sources()) + request = 'Explain Python generators.' + runtime.harness.replies = [_new_topic(request), _compose_only_plan(), 'Generators yield values.'] + plan = _planned(runtime, request) + events = _run(runtime, plan) + assert not any(event.get('error') for event in events), events + encoded = json.dumps(runtime.harness.model_calls[-1]['messages']) + assert 'pasted source material' not in encoded + assert 'Assistant unverified assertions' not in encoded + + +def test_full_source_exceeding_verified_model_budget_is_not_truncated_or_sent(real_http_harness): + runtime = real_http_harness + verified_model(runtime.harness) + _replace_history(runtime.harness, [ + message('source', 'user', 'budget material ' * 150000, 1), + message('answer', 'assistant', 'An earlier short response.', 2), + ]) + runtime.harness.replies = [transform_resolution(), _compose_only_plan()] + plan = _planned(runtime, REQUEST) + _run(runtime, plan) + record = runtime.harness.runs.read_item(plan['run_id'], 'conversation-1') + assert record['status'] == 'failed' + assert record['failure']['code'] == 'result_input_too_large' + assert 'larger model or revise the plan' in record['failure']['message'] + assert len(runtime.harness.model_calls) == 2 + + +@pytest.mark.parametrize('mutation', ['changed', 'deleted', 'masked']) +def test_selected_long_source_revision_is_rechecked_before_execution(real_http_harness, mutation): + runtime = real_http_harness + _replace_history(runtime.harness, long_sources()) + runtime.harness.replies = [transform_resolution(), _compose_only_plan()] + plan = _planned(runtime, REQUEST) + key = ('conversation-1', 'source') + if mutation == 'deleted': + del runtime.harness.messages.items[key] + elif mutation == 'masked': + runtime.harness.messages.items[key]['metadata'] = {'masked': True} + else: + runtime.harness.messages.items[key]['content'] += 'Changed after planning.' + response = runtime.client.post('/api/v2/orchestration/run', json={ + 'conversation_id': 'conversation-1', 'run_id': plan['run_id'], + }, buffered=True) + assert response.status_code == 409 + assert 'context' in response.get_json()['error'].lower() + assert len(runtime.harness.model_calls) == 2 + + +def test_inline_source_does_not_require_history(modules): + result = modules.route.resolve_conversation_request('Rewrite this: A short sentence.', {'messages': []}) + assert result['relationship'] == 'new_topic' + assert result['message_ids'] == [] + + +def test_legacy_clipped_snapshot_requires_a_new_plan(context): + rows = long_sources() + snapshot = context.build_conversation_snapshot(rows) + snapshot['schema_version'] = 1 + with pytest.raises(context.ConversationContextError): + context.validate_conversation_snapshot(snapshot, rows) + + +def test_complete_legacy_snapshot_preserves_existing_read_and_retry_behavior(context): + rows = [message('source', 'user', 'Complete supplied text.', 1)] + legacy = { + 'schema_version': 1, 'messages': [context.normalize_history_message(rows[0])], 'truncated': False, + } + validated = context.validate_conversation_snapshot(legacy, rows) + assert validated == legacy + exact = context.resolve_conversation_source_messages(validated, rows, ['source']) + assert exact == [{'id': 'source', 'role': 'user', 'content': 'Complete supplied text.'}] + + +def test_many_large_turns_evict_whole_turns_only_when_reference_metadata_cannot_fit(context): + rows = [ + message(f'{index:036d}', 'user' if index % 2 == 0 else 'assistant', '大' * 10000, index) + for index in range(50) + ] + snapshot = context.build_conversation_snapshot(rows, {'conversation_history_limit': 50}) + assert context.conversation_snapshot_size(snapshot) <= context.HISTORY_MAX_BYTES + assert snapshot['messages'][0]['role'] == 'user' + assert snapshot['messages'][-2]['id'] == rows[-2]['id'] + assert snapshot['messages'][-1]['id'] == rows[-1]['id'] + context.validate_conversation_snapshot(snapshot, rows) + + +def test_shared_membership_is_rechecked_before_exact_message_reads(real_http_harness, context, monkeypatch): + runtime = real_http_harness + _replace_history(runtime.harness, long_sources()) + runtime.harness.replies = [transform_resolution(), _compose_only_plan()] + plan = _planned(runtime, REQUEST) + record = runtime.harness.runs.read_item(plan['run_id'], 'conversation-1') + collaboration = importlib.import_module('functions_orchestration_collaboration') + backing = { + 'id': 'conversation-1', 'user_id': 'owner', 'conversation_kind': 'collaboration_source', + 'collaboration_conversation_id': 'conversation-1', + } + authorize = Mock(side_effect=PermissionError('Membership was revoked.')) + monkeypatch.setattr(collaboration, 'shared_conversation', lambda conversation_id: {'id': conversation_id}) + monkeypatch.setattr(collaboration, 'authorize_shared_orchestration', authorize) + read = Mock(wraps=runtime.harness.messages.read_item) + monkeypatch.setattr(runtime.harness.messages, 'read_item', read) + execution = importlib.import_module('functions_orchestration_execution') + with pytest.raises(context.ConversationContextError): + execution._conversation_context( + record, runtime.harness.messages, lambda: backing, + full_message_ids=['source', 'answer'], + ) + authorize.assert_called_once_with({'id': 'conversation-1'}, 'owner') + read.assert_not_called() + + +def test_no_history_setting_changed_after_planning_blocks_execution(real_http_harness): + runtime = real_http_harness + _replace_history(runtime.harness, long_sources()) + runtime.harness.replies = [transform_resolution(), _compose_only_plan()] + plan = _planned(runtime, REQUEST) + runtime.harness.settings['conversation_history_limit'] = 0 + response = runtime.client.post('/api/v2/orchestration/run', json={ + 'conversation_id': 'conversation-1', 'run_id': plan['run_id'], + }, buffered=True) + assert response.status_code == 409 + assert len(runtime.harness.model_calls) == 2 + + +@pytest.mark.parametrize('mutation', ['changed', 'deleted']) +def test_retry_rejects_changed_or_deleted_selected_source(real_http_harness, mutation): + runtime = real_http_harness + verified_model(runtime.harness) + _replace_history(runtime.harness, long_sources()) + runtime.harness.replies = [ + transform_resolution(), _compose_only_plan(), RuntimeError('Synthetic provider failure.'), + ] + plan = _planned(runtime, REQUEST) + _run(runtime, plan) + record = runtime.harness.runs.read_item(plan['run_id'], 'conversation-1') + assert record['status'] == 'failed' + key = ('conversation-1', 'source') + if mutation == 'changed': + runtime.harness.messages.items[key]['content'] += 'Changed after failure.' + else: + del runtime.harness.messages.items[key] + response = runtime.client.post(f"/api/v2/orchestration/runs/{plan['run_id']}/retry", json={ + 'conversation_id': 'conversation-1', 'expected_version': record['recovery_version'], + 'submission_id': '226f6df6-f2ab-4a95-b6c9-f13380672f18', 'confirm_external_effects': False, + }, buffered=True) + assert response.status_code == 409, response.get_json() + assert response.get_json()['code'] == 'recovery_unavailable' + assert response.get_json()['error'] == ( + 'The original context or access changed. Review the request and create a new plan.' + ) + assert len(runtime.harness.model_calls) == 3 + + +def test_search_as_a_subject_is_not_an_explicit_workspace_request(modules): + allowed = modules.route.conversation_retrieval_allowed( + {'requires_retrieval': False}, 'Rewrite your answer about search indexing.', + ) + assert allowed is False + + +def test_sdk_owned_elicitation_conversation_authorizes_current_owner(context, modules, monkeypatch): + conversations = AtomicMemoryContainer('id') + conversations.create_item({'id': 'conversation-1', 'user_id': 'owner'}) + monkeypatch.setattr(modules.config, 'cosmos_conversations_container', conversations) + conversation = context._owned_elicitation_conversation('owner', 'conversation-1') + assert isinstance(conversation, CosmosDict) + assert conversation['_etag'] + assert conversation['user_id'] == 'owner' + with pytest.raises(context.ElicitationContextError): + context._owned_elicitation_conversation('foreign', 'conversation-1') + with pytest.raises(context.ElicitationContextError): + context._owned_elicitation_conversation('owner', 'missing') + + +def test_sdk_image_reference_candidates_use_partitioned_message_read(context, modules, monkeypatch): + messages = AtomicMemoryContainer('conversation_id') + messages.create_item({ + 'id': 'conversation-1_image-1', 'conversation_id': 'conversation-1', + 'role': 'image', 'file_name': 'selected.png', + }) + monkeypatch.setattr(modules.config, 'cosmos_messages_container', messages) + point = messages.read_item('conversation-1_image-1', 'conversation-1') + assert isinstance(point, CosmosDict) + selected = context._image_reference_message_candidates({ + 'image_references': [{'type': 'message', 'message_id': 'conversation-1_image-1'}], + }, 'conversation-1') + assert selected == [{ + 'message_id': 'conversation-1_image-1', 'file_name': 'selected.png', + 'label': 'selected.png', 'workspace_document_id': '', + }] + for message_id in ('conversation-2_image-1', 'conversation-1_missing'): + with pytest.raises(context.CatalogResolutionError): + context._image_reference_message_candidates({ + 'image_references': [{'type': 'message', 'message_id': message_id}], + }, 'conversation-1') + + +def test_sdk_chat_attachment_reference_readiness_query(context, modules, monkeypatch): + conversations = AtomicMemoryContainer('id') + messages = AtomicMemoryContainer('conversation_id') + conversations.create_item({'id': 'conversation-1', 'user_id': 'owner'}) + messages.create_item({ + 'id': 'attachment-1', 'conversation_id': 'conversation-1', + 'role': 'file', 'file_name': 'source.txt', 'percentage_complete': 100, + }) + monkeypatch.setattr(modules.config, 'cosmos_conversations_container', conversations) + monkeypatch.setattr(modules.config, 'cosmos_messages_container', messages) + source_access = importlib.import_module('functions_mixed_source_orchestration') + search = importlib.import_module('functions_search_service') + monkeypatch.setattr(search, 'resolve_document_contexts', lambda *args, **kwargs: [{ + 'scope': 'chat', 'document': {'id': 'attachment-1', 'percentage_complete': 100}, + }]) + monkeypatch.setattr(source_access, 'resolve_authorized_source_manifest', lambda *args, **kwargs: [{ + 'document_id': 'attachment-1', 'authorization_status': 'authorized', + 'scope': 'chat', 'scope_id': 'conversation-1', 'display_name': 'Stored attachment', + }]) + reference = { + 'kind': 'chat_attachment', 'id': 'attachment-1', + 'scope': {'kind': 'chat', 'id': 'conversation-1'}, + } + selected = context.resolve_elicitation_references([reference], 'owner', 'conversation-1') + assert selected[0]['label'] == 'Stored attachment' + query, parameters, partition = messages.queries[-1] + assert partition == 'conversation-1' + assert 'c.id = @document_id' in query + assert parameters == [{'name': '@document_id', 'value': 'attachment-1'}] + rows = messages.query_items(query, parameters=parameters, partition_key=partition) + assert type(rows[0]) is dict + messages.items[('conversation-1', 'attachment-1')]['percentage_complete'] = 0 + with pytest.raises(context.ElicitationContextError) as failure: + context.resolve_elicitation_references([reference], 'owner', 'conversation-1') + assert failure.value.reason == 'document_not_ready' + del messages.items[('conversation-1', 'attachment-1')] + with pytest.raises(context.ElicitationContextError) as failure: + context.resolve_elicitation_references([reference], 'owner', 'conversation-1') + assert failure.value.reason == 'document_unavailable' diff --git a/functional_tests/test_orchestration_deliverables.py b/functional_tests/test_orchestration_deliverables.py index e44734e97..c91df841e 100644 --- a/functional_tests/test_orchestration_deliverables.py +++ b/functional_tests/test_orchestration_deliverables.py @@ -1,12 +1,13 @@ # test_orchestration_deliverables.py """The deliverables contract and planned image generation in Gather / Reason / Render plans. -Version: 0.261.341 +Version: 0.261.342 Implemented in: 0.261.138 Single orchestration contract updated in: 0.261.139 Planner kind-specific fields and absent answer bindings updated in: 0.261.140 Row-shaped compose guidance and JSON mode for CSV scenarios updated in: 0.261.141 Prior-answer visual correction fixture support added in: 0.261.341 +Explicit prior-answer source selection fixture support added in: 0.261.342 Uses the initialized headless harness (real bootstrap, planner, schema, executor, result store, chat image persistence, rendering service and Office renderers) with the planning @@ -177,7 +178,7 @@ def _president_plan(*, with_file=True, with_search=False, output_format="docx"): return {"deliverables": deliverables, "steps": steps, "final_response": input_binding("report", "report")} -def _store(harness, plan, *, replies=(), history=()): +def _store(harness, plan, *, replies=(), history=(), request_resolution=None): """Save a validated plan as run-1, exactly as the harness saves its own fixtures.""" run_store = importlib.import_module("functions_orchestration_runs") context = importlib.import_module("functions_orchestration_context") @@ -196,6 +197,8 @@ def _store(harness, plan, *, replies=(), history=()): "memory_audience": memory.validate_memory_audience(harness.conversation, "owner"), "memory_scope": None, } + if request_resolution is not None: + turn_context["request_resolution"] = deepcopy(request_resolution) harness.replies = list(replies) return run_store.create_orchestration_run( plan, "owner", "conversation-1", turn_index=1, turn_context=turn_context, diff --git a/functional_tests/test_orchestration_failure_telemetry.py b/functional_tests/test_orchestration_failure_telemetry.py index 7fac9d883..a0b05de42 100644 --- a/functional_tests/test_orchestration_failure_telemetry.py +++ b/functional_tests/test_orchestration_failure_telemetry.py @@ -1,10 +1,12 @@ # test_orchestration_failure_telemetry.py """Specific orchestration failure rules reach telemetry without private plan text. -Version: 0.261.191 +Version: 0.261.342 Implemented in: 0.261.140 Step, capability, format, and authority-reason codes covered in: 0.261.141 Partial-input producer capability and step hash fields covered in: 0.261.191 +Semantic chat outcomes and persisted-state correlation implemented in: 0.261.341 +Bounded, non-regex application-version validation implemented in: 0.261.342 Uses real planner/validation/logging modules with offline provider replies. The synthetic invalid proposals test diagnostics, not the unknown historical @@ -23,7 +25,7 @@ from test_orchestration_deliverables import _csv_plan from test_orchestration_harness_execution import harness, initialized_application # noqa: F401 -from test_support.orchestration_harness_execution import compose_step, input_binding +from test_support.orchestration_harness_execution import compose_step, decoded_frames, input_binding RULES = ( @@ -207,3 +209,150 @@ def test_preparation_diagnostic_identifies_stage_and_confirmed_failure(harness, assert details["response_type"] == "dict" assert failure.value.durable_status == "failed" assert "run_id" not in details and "user_id" not in details + + +@pytest.mark.parametrize("outcome", ["completed", "partial", "failed", "waiting", "cancelled"]) +@pytest.mark.parametrize("debug", [False, True]) +def test_chat_outcome_dimensions_survive_sanitization_without_private_text( + harness, monkeypatch, caplog, outcome, debug, +): + telemetry = importlib.import_module("functions_appinsights") + logger = logging.getLogger("test.chat.outcome") + monkeypatch.setattr(telemetry, "get_appinsights_logger", lambda: logger) + monkeypatch.setattr(telemetry, "_load_logging_settings", lambda: {"enable_debug_logging": debug}) + status = "failed" if outcome == "partial" else outcome + with caplog.at_level(logging.INFO, logger=logger.name): + telemetry.log_chat_execution_outcome( + execution_path="orchestration", status=status, outcome=outcome, + conversation_id="PRIVATE_CONVERSATION", turn_id="PRIVATE_TURN", run_id="PRIVATE_RUN", + failure={ + "code": "input_partial_not_accepted", "step_id": "PRIVATE_STEP", + "capability_id": "document_analyze", "message": "PRIVATE_DIAGNOSTIC", + }, + application_version="0.261.341", attempt_index=2, message_saved=True, + ) + records = [record for record in caplog.records if getattr(record, "sc_message", "") == "[CHAT_EXECUTION] Execution outcome recorded."] + assert len(records) == 1 + record = records[0] + assert record.sc_execution_path == "orchestration" + assert record.sc_status == status and record.sc_outcome == outcome + assert record.sc_application_version == "0.261.341" + assert record.sc_attempt_index == 2 and record.sc_message_saved is True + assert record.sc_conversation_id_hash == hashlib.sha256(b"PRIVATE_CONVERSATION").hexdigest() + assert record.sc_turn_id_hash == hashlib.sha256(b"PRIVATE_TURN").hexdigest() + assert record.sc_run_id_hash == hashlib.sha256(b"PRIVATE_RUN").hexdigest() + assert record.sc_step_id_hash == hashlib.sha256(b"PRIVATE_STEP").hexdigest() + assert record.sc_failure_code == "input_partial_not_accepted" + assert record.sc_capability_id == "document_analyze" + properties = {key: value for key, value in record.__dict__.items() if key.startswith("sc_")} + assert "PRIVATE_" not in json.dumps(properties) + assert record.levelno < logging.ERROR + invalid = telemetry._build_logger_extra("Outcome.", {"application_version": "PRIVATE_VERSION"}) + assert "sc_application_version" not in invalid + + +@pytest.mark.parametrize( + "version", + ["0.261.342", "1.2.3", "00.001.0002", "١.٢.٣", f"{'0' * 60}.1.2"], + ids=["current", "short", "leading-zeroes", "decimal-unicode", "length-limit"], +) +def test_application_version_is_preserved_in_both_telemetry_boundaries(harness, monkeypatch, version): + telemetry = importlib.import_module("functions_appinsights") + events = [] + monkeypatch.setattr(telemetry, "log_event", lambda message, **kwargs: events.append(kwargs)) + telemetry.log_chat_execution_outcome( + execution_path="direct", status="completed", outcome="completed", + conversation_id="PRIVATE_CONVERSATION", application_version=version, + ) + assert events[0]["extra"]["application_version"] == version + properties = telemetry._build_logger_extra("Outcome.", {"application_version": version}) + assert properties["sc_application_version"] == version + assert properties["sc_application_version_length"] == len(version) + assert "PRIVATE_" not in json.dumps(events) + + +@pytest.mark.parametrize( + "version", + [ + "", "1.2", "1.2.3.4", ".1.2", "1..2", "1.2.", "1.2.3\n", " 1.2.3", + "1.2.3-private", "¹.2.3", "PRIVATE_VERSION", + f"{'0' * 61}.1.2", "0" * 100_000, f"1.2.{'0' * 100_000}", + f"1.2.3{'0' * 100_000}PRIVATE_VERSION", + ], + ids=[ + "empty", "two-parts", "four-parts", "empty-major", "empty-minor", "empty-patch", + "newline", "whitespace", "suffix", "non-decimal-digit", "private", + "over-limit", "long-numeric", "long-last-part", "long-valid-prefix-private-suffix", + ], +) +def test_invalid_application_version_is_omitted_without_truncation(harness, monkeypatch, version): + telemetry = importlib.import_module("functions_appinsights") + events = [] + monkeypatch.setattr(telemetry, "log_event", lambda message, **kwargs: events.append(kwargs)) + telemetry.log_chat_execution_outcome( + execution_path="direct", status="completed", outcome="completed", + conversation_id="PRIVATE_CONVERSATION", application_version=version, + ) + assert "application_version" not in events[0]["extra"] + properties = telemetry._build_logger_extra("Outcome.", {"application_version": version}) + assert "sc_application_version" not in properties + assert properties["sc_application_version_length"] == len(version) + assert "PRIVATE_" not in json.dumps(events) + json.dumps(properties) + + +def test_application_version_length_is_checked_before_parsing(harness): + telemetry = importlib.import_module("functions_appinsights") + + class OversizedVersion(str): + def split(self, *args, **kwargs): + raise AssertionError("Oversized application version must not be parsed.") + + version = OversizedVersion("0" * 100_000) + accepted = telemetry._is_application_version(version) + assert accepted is False + properties = telemetry._build_logger_extra("Outcome.", {"application_version": version}) + assert "sc_application_version" not in properties + + +@pytest.mark.parametrize("version", [None, 123, True, {}, []]) +def test_outcome_omits_non_string_application_versions(harness, monkeypatch, version): + telemetry = importlib.import_module("functions_appinsights") + events = [] + monkeypatch.setattr(telemetry, "log_event", lambda message, **kwargs: events.append(kwargs)) + telemetry.log_chat_execution_outcome( + execution_path="direct", status="completed", outcome="completed", + conversation_id="PRIVATE_CONVERSATION", application_version=version, + ) + assert "application_version" not in events[0]["extra"] + + +@pytest.mark.parametrize("failed", [False, True]) +def test_durable_execution_outcome_is_recorded_once_not_on_sse_replay(harness, monkeypatch, failed): + telemetry = importlib.import_module("functions_appinsights") + execution_module = harness.execution + observations = [] + + def observe(**kwargs): + saved = harness.read() + observations.append((kwargs, saved["status"], saved.get("message_saved"))) + telemetry.log_chat_execution_outcome(**kwargs) + + monkeypatch.setattr(execution_module, "log_chat_execution_outcome", observe) + harness.create(replies=["Exact prepared answer."], final_response=input_binding("prepare")) + execution = harness.prepare() + if failed: + def fail_plan(*args, **kwargs): + raise execution_module.HarnessExecutionError("execution_interrupted") + monkeypatch.setattr(execution_module, "execute_plan", fail_plan) + frames = execution.execute() + replay = execution.execute() + done = decoded_frames(frames)[-1] + record = harness.read() + assert replay == frames and len(observations) == 1 + fields, saved_status, message_saved = observations[0] + assert fields["status"] == fields["outcome"] == saved_status == done["status"] + assert saved_status == ("failed" if failed else "completed") + assert message_saved is True and fields["message_saved"] is True + assert fields["conversation_id"] == record["conversation_id"] + if failed: + assert fields["failure"]["code"] == "execution_interrupted" diff --git a/functional_tests/test_orchestration_search_result_integrity.py b/functional_tests/test_orchestration_search_result_integrity.py new file mode 100644 index 000000000..b37c455f0 --- /dev/null +++ b/functional_tests/test_orchestration_search_result_integrity.py @@ -0,0 +1,432 @@ +# test_orchestration_search_result_integrity.py +"""Lossless search retention, selected-source handoffs and historical partial safety. + +Version: 0.261.341 +Implemented in: 0.261.341 +Search/provider I/O is isolated; envelopes, stores, readers, dependency admission, +native Analyze and checkpoint recovery use their real implementations. +""" + +from copy import deepcopy +from types import ModuleType +import importlib +import json +import sys + +import pytest + +from test_orchestration_dependency_runtime import binding, compose, execute, runtime, source_input +from test_orchestration_internal_analysis import internal +from test_support.app_stubs import stubbed_config +from test_support.orchestration_results import source + + +def search_step(): + return { + 'step_id': 'search', 'capability_id': 'document_search', + 'arguments': {'query': 'approved search', 'top_n': 50}, + } + + +def install_search(monkeypatch, hits): + calls = [] + module = ModuleType('functions_search') + + def hybrid_search(*args, **kwargs): + calls.append((args, kwargs)) + return deepcopy(hits) + + module.hybrid_search = hybrid_search + monkeypatch.setitem(sys.modules, 'functions_search', module) + return calls + + +@pytest.mark.parametrize('text', ['a' * 1024, 'a' * 1025, 'é' * 900, '😀' * 1000], + ids=['ascii-1024', 'ascii-1025', 'multibyte', 'emoji']) +def test_full_hits_are_committed_before_bounded_previews(runtime, monkeypatch, text): + hits = [ + {'document_id': 'document-1', 'id': f'hit-{index}', 'chunk_text': f'{text}-LAST-{index}', + 'chunk_sequence': index, 'provider_detail': {'retained': True}} + for index in range(15) + ] + calls = install_search(monkeypatch, hits) + adapters = importlib.import_module('functions_orchestration_adapters') + original = adapters.build_narrative_evidence_envelopes + case = runtime.make([search_step()]) + previews = [] + committed = [] + persist = case.fixture.service.persist_task_result + + def observe_commit(**kwargs): + task = persist(**kwargs) + committed.append(task) + return task + + def bounded_after_commit(*args): + assert len(committed) == 1 + task = committed[0] + prepared = case.fixture.restart().open_result(task.output('prepared')).read_value() + assert prepared['search_results'] == hits + preview = original(*args) + previews.extend(preview) + return preview + + monkeypatch.setattr(adapters, 'build_narrative_evidence_envelopes', bounded_after_commit) + monkeypatch.setattr(case.fixture.service, 'persist_task_result', observe_commit) + result = execute(runtime, case) + assert result['status'] == result['outcome'] == 'completed', result + assert len(calls) == 1 and case.model.calls == [] + task = case.context.task_results['search'] + restarted = case.fixture.restart() + evidence = list(restarted.open_result(task.output('evidence')).iter_items()) + prepared = restarted.open_result(task.output('prepared')).read_value() + sources = list(restarted.open_result(task.output('sources')).iter_items()) + assert [item['text'] for item in evidence] == [hit['chunk_text'] for hit in hits] + assert len(prepared['evidence'][0]['evidence']) == 15 + assert prepared['search_results'] == hits + assert prepared['search_scope']['top_n_per_scope'] == 50 + assert prepared['coverage']['exhaustive_corpus_coverage'] is False + assert prepared['coverage']['whole_document_coverage'] is False + assert sources == [source()] + assert all(reference.completeness.status == 'complete' for reference in task.outputs) + assert previews[0]['coverage']['evidence_envelope_truncated'] is True + assert len(previews[0]['evidence']) <= 10 + assert task.output('evidence').completeness.coverage.unit == 'items' + assert task.output('sources').completeness.coverage.unit == 'sources' + assert 'Top-N' in ' '.join(task.output('sources').completeness.limitations) + + +@pytest.mark.parametrize('text', ['Short exact excerpt.', 'a' * 1024, 'a' * 1025, 'é' * 513], + ids=['short', 'ascii-1024', 'ascii-1025', 'utf8-1026']) +def test_single_excerpt_byte_boundary_is_presentation_only(runtime, monkeypatch, text): + install_search(monkeypatch, [{'document_id': 'document-1', 'id': 'hit', 'chunk_text': text}]) + case = runtime.make([search_step()]) + result = execute(runtime, case) + assert result['status'] == 'completed' + task = case.context.task_results['search'] + reader = case.fixture.restart().open_result(task.output('evidence')) + evidence = list(reader.iter_items()) + assert evidence[0]['text'] == text + assert task.output('sources').completeness.status == 'complete' + + +def test_fifty_hits_twenty_three_sources_feed_complete_only_analyze(runtime, monkeypatch): + hits = [ + {'document_id': f'document-{index % 23}', 'id': f'hit-{index}', 'chunk_text': 'é' * 2000} + for index in range(50) + ] + install_search(monkeypatch, hits) + case = runtime.make([ + search_step(), + {'step_id': 'analyze', 'capability_id': 'document_analyze', + 'arguments': {'analysis_prompt': 'Analyze the discovered sources.'}, + 'inputs': {'sources': source_input('search', 'sources')}}, + ]) + case.fixture.sources = {f'document-{index}': source(f'document-{index}') for index in range(23)} + observed = [] + + def analyze_selected(step, context, **kwargs): + observed.append(step['arguments']['document_ids']) + upstream, limitations = runtime.result_runtime.source_selection_provenance(context) + task = context.result_service.persist_task_result( + producer=context.result_producer(step), role='reason', status='complete', + outputs=[ + runtime.NamedOutput('findings', 'records-v1', [], runtime.complete(0), + (runtime.contracts.RecordColumn('finding', 'string'),)), + runtime.NamedOutput('coverage', 'structured-v1', { + 'source_selection_limitations': list(limitations), + }, runtime.complete(1)), + ], + sources=list(case.fixture.sources.values()), origin='grounded', + upstream=upstream, guard_token=context.result_guard_token_for_step('analyze'), + ) + return runtime.schema.build_step_result(status='completed', task_result=task) + + result = execute(runtime, case, get_adapter=lambda name: ( + analyze_selected if name == 'document_analyze' else runtime.executor._dependency_adapter(name) + )) + assert result['status'] == result['outcome'] == 'completed', result + assert observed == [[f'document-{index}' for index in range(23)]] + assert case.context.task_results['search'].output('evidence').item_count == 50 + assert case.context.task_results['search'].output('sources').item_count == 23 + assert all(output['status'] == 'complete' for output in result['result_outputs']) + assert result['delivery_facts'] == {'files': []} + assert 'comprehensive' not in result['message'].lower() + + +def test_compose_reads_all_long_excerpts_not_the_preview(runtime, monkeypatch): + hits = [ + {'document_id': 'document-1', 'id': f'hit-{index}', 'chunk_text': 'é' * 800 + f' LAST-{index}'} + for index in range(12) + ] + install_search(monkeypatch, hits) + case = runtime.make([ + search_step(), compose('answer', inputs={'evidence': source_input('search', 'evidence')}), + ], ['An answer about the returned excerpts.'], final_response=binding('answer')) + result = execute(runtime, case) + assert result['status'] == result['outcome'] == 'completed', result + payload = json.loads(case.model.calls[0][0][-1]['content']) + evidence = payload['inputs']['evidence'] + assert [item['text'] for item in evidence['value']] == [hit['chunk_text'] for hit in hits] + assert any('Top-N' in item for item in evidence['completeness']['limitations']) + assert result['message'] == 'An answer about the returned excerpts.' + assert result['outputs'] == [] and result['delivery_facts'] == {'files': []} + + +def test_full_search_input_still_obeys_the_selected_model_budget(runtime, monkeypatch): + install_search(monkeypatch, [ + {'document_id': 'document-1', 'id': 'hit', 'chunk_text': 'token ' * 40000}, + ]) + case = runtime.make([ + search_step(), compose('answer', inputs={'evidence': source_input('search', 'evidence')}), + ], ['An over-budget answer must not be requested.'], final_response=binding('answer')) + result = execute(runtime, case) + assert case.model.calls == [] + assert result['status'] == 'failed' and result['outcome'] == 'partial' + assert result['steps'][1]['failure']['code'] == 'result_input_too_large' + assert runtime.schema.failure_repeats_on_retry(result['steps'][1]['failure']) + assert result['failure']['code'] == 'result_input_too_large' + assert case.context.task_results['search'].status == 'complete' + + +@pytest.mark.parametrize('change', ['revoked', 'changed']) +def test_selected_sources_recheck_access_and_revision_before_analyze(runtime, monkeypatch, change): + install_search(monkeypatch, [ + {'document_id': 'document-1', 'id': 'hit', 'chunk_text': '😀' * 1000}, + ]) + case = runtime.make([ + search_step(), + {'step_id': 'analyze', 'capability_id': 'document_analyze', + 'arguments': {'analysis_prompt': 'Analyze.'}, + 'inputs': {'sources': source_input('search', 'sources')}}, + ]) + called = [] + adapter = runtime.executor._dependency_adapter('document_search') + + def search_then_change(step, context, **kwargs): + result = adapter(step, context, **kwargs) + if change == 'revoked': + case.fixture.denied.add('document-1') + else: + case.fixture.sources['document-1']['source_revision'] = 'revision-2' + return result + + def forbidden(*args, **kwargs): + called.append(True) + raise AssertionError('An inaccessible or changed source must not be analyzed.') + + result = execute(runtime, case, get_adapter=lambda name: ( + search_then_change if name == 'document_search' else forbidden + )) + assert called == [] + assert result['status'] == 'failed' and result['outcome'] == 'partial' + assert result['steps'][1]['failure']['code'] == 'result_unavailable' + assert result['steps'][1]['status'] == 'failed' + + +def test_empty_discovery_never_falls_back_to_ambient_selected_documents(runtime, monkeypatch): + calls = install_search(monkeypatch, []) + case = runtime.make([ + search_step(), + {'step_id': 'analyze', 'capability_id': 'document_analyze', + 'arguments': {'analysis_prompt': 'Analyze.'}, + 'inputs': {'sources': source_input('search', 'sources')}}, + ]) + case.context.selected_document_ids = ['document-1'] + analyzed = [] + + def forbidden(*args, **kwargs): + analyzed.append(True) + raise AssertionError('An empty source set cannot silently broaden to selected documents.') + + result = execute(runtime, case, get_adapter=lambda name: ( + runtime.executor._dependency_adapter(name) if name == 'document_search' else forbidden + )) + assert len(calls) == 1 and analyzed == [] + assert result['steps'][1]['failure']['code'] == 'result_unavailable' + assert result['status'] == 'failed' + + +@pytest.mark.parametrize('manifest_problem', ['unresolved', 'ambiguous_scope']) +def test_unverified_source_identity_never_reaches_a_preview(runtime, monkeypatch, manifest_problem): + install_search(monkeypatch, [{'document_id': 'document-1', 'id': 'hit', 'chunk_text': 'Exact source.'}]) + case = runtime.make([search_step()]) + adapters = importlib.import_module('functions_orchestration_adapters') + preview_calls = [] + original = adapters.build_narrative_evidence_envelopes + + def preview(*args): + preview_calls.append(True) + return original(*args) + + def resolve(ids): + if manifest_problem == 'unresolved': + return [{**source(), 'authorization_status': 'unresolved'}] + return [ + {**source(), 'authorization_status': 'authorized'}, + {**source(), 'scope': 'group', 'scope_id': 'different-group', 'authorization_status': 'authorized'}, + ] + + case.context.resolve_source_manifest = resolve + monkeypatch.setattr(adapters, 'build_narrative_evidence_envelopes', preview) + result = execute(runtime, case) + assert preview_calls == [] + assert result['status'] == result['outcome'] == 'failed' + assert result['failure']['code'] == 'result_unavailable' + assert case.context.task_results == {} + + +def test_search_commit_recovers_after_restart_without_retrieval(runtime, monkeypatch): + calls = install_search(monkeypatch, [ + {'document_id': 'document-1', 'id': 'hit', 'chunk_text': 'Full text ' * 1000}, + ]) + case = runtime.make([search_step()]) + first = execute(runtime, case) + case.context = runtime.executor.RunContext( + run_id='run-1', plan_id='plan-1', conversation_id='conversation-1', user_id='owner', + user_message='Prepare the requested content.', plan_contract_version=2, + result_service=case.fixture.restart(), resolve_source_manifest=case.fixture.resolve, + result_guard_token_for_step=lambda step_id: 'owned-execution-token', invoke_prompt=case.model, + ) + again = execute(runtime, case) + assert first['status'] == again['status'] == 'completed' + assert first['task_results'] == again['task_results'] + assert len(calls) == 1 + + +def test_storage_overflow_never_commits_a_shortened_search(runtime, monkeypatch): + install_search(monkeypatch, [ + {'document_id': 'document-1', 'id': 'hit', 'chunk_text': 'Exact large data ' * 2000}, + ]) + case = runtime.make([search_step()]) + case.fixture.service.max_result_bytes = 4096 + events = [] + result = execute(runtime, case, emit=events.append) + assert result['status'] == result['outcome'] == 'failed' + assert result['failure']['code'] == 'search_result_too_large' + assert runtime.schema.failure_repeats_on_retry(result['failure']) + assert case.context.task_results == {} + step = case.plan['steps'][0] + recovered = case.fixture.restart().recover_task_result( + producer=case.context.result_producer(step), + input_fingerprint=next( + event['input_fingerprint'] for event in events + if event.get('type') == 'step' and event.get('phase') == 'running' + ), + ) + assert recovered is None + + +@pytest.mark.parametrize('genuinely_partial', [False, True]) +def test_historical_clipped_and_partial_searches_stay_partial(runtime, monkeypatch, genuinely_partial): + case = runtime.make([ + search_step(), + {'step_id': 'analyze', 'capability_id': 'document_analyze', + 'arguments': {'analysis_prompt': 'Analyze.'}, + 'inputs': {'sources': source_input('search', 'sources')}}, + ]) + envelopes = importlib.import_module('functions_mixed_source_orchestration') + with stubbed_config( + cosmos_orchestration_runs_container=case.fixture.container, + cosmos_orchestration_run_steps_container=case.fixture.container, + ): + recovery = importlib.import_module('functions_orchestration_recovery') + + def historical(step, context, **kwargs): + if step['step_id'] != 'search': + raise AssertionError('Partial source selections cannot feed complete-only Analyze.') + evidence = envelopes.build_narrative_evidence_envelopes( + [{'document_id': 'document-1'}], + [{'document_id': 'document-1', 'chunk_text': 'bounded ' * 1000}], 'relevance', + ) + return runtime.schema.build_step_result( + status='partial' if genuinely_partial else 'completed', evidence=evidence, + ) + + result = execute(runtime, case, get_adapter=lambda name: historical) + assert result['steps'][1]['failure']['code'] == 'input_partial_not_accepted' + task = case.context.task_results['search'] + before = deepcopy(task.to_dict()) + restarted = case.fixture.restart() + with pytest.raises(runtime.contracts.ResultNotReadyError): + restarted.open_result(task.output('sources')) + partial_reader = restarted.open_result(task.output('sources'), allow_partial=True) + retained_sources = list(partial_reader.iter_items()) + assert retained_sources == [source()] + record = { + 'id': 'run-1', 'plan': case.plan, 'execution_steps': result['steps'], + 'status': 'failed', 'outcome': 'partial', 'started_at': '2026-10-16T00:00:00Z', + 'checkpoint_version': runtime.checkpoints.CHECKPOINT_VERSION, 'execution_binding': {'present': True}, + } + for step in record['execution_steps']: + step['checkpoint_available'] = True + rerun, blocked = recovery._partial_dependency_recovery(record) + projection = recovery.recovery_projection(record) + assert rerun == set() and blocked == {'search'} + assert projection['eligible'] is False + assert projection['reason_code'] == 'search_result_incomplete' + assert task.to_dict() == before + assert 'discarded content cannot' in projection['message'] + search_only = deepcopy(record) + search_only['plan']['steps'] = [search_only['plan']['steps'][0]] + search_only['execution_steps'] = [search_only['execution_steps'][0]] + search_only_projection = recovery.recovery_projection(search_only) + assert search_only_projection['eligible'] is False + assert search_only_projection['reason_code'] == 'search_result_incomplete' + calls = install_search(monkeypatch, []) + case.context = runtime.executor.RunContext( + run_id='run-1', plan_id='plan-1', conversation_id='conversation-1', user_id='owner', + user_message='Prepare the requested content.', plan_contract_version=2, + result_service=restarted, resolve_source_manifest=case.fixture.resolve, + result_guard_token_for_step=lambda step_id: 'owned-execution-token', invoke_prompt=case.model, + ) + resumed = execute(runtime, case) + assert calls == [] + assert resumed['task_results']['search'] == before + assert resumed['steps'][1]['failure']['code'] == 'input_partial_not_accepted' + + +def test_real_analyze_preserves_search_selection_coverage(internal, monkeypatch): + executor = importlib.import_module('functions_orchestration_executor') + schema = importlib.import_module('functions_orchestration_schema') + hits = [ + {'document_id': 'document-1', 'id': f'hit-{index}', 'chunk_text': 'é' * 1800} + for index in range(12) + ] + install_search(monkeypatch, hits) + settings = {'enable_user_workspace': True} + plan = schema.normalize_plan( + {'run_id': 'run-1', 'steps': [ + search_step(), + {'step_id': 'analyze', 'capability_id': 'document_analyze', + 'arguments': {'analysis_prompt': 'Retain all original findings.'}, + 'inputs': {'sources': source_input('search', 'sources')}}, + ]}, + 'conversation-1', 'owner', settings=settings, contract_version=2, + available_capability_ids=['document_search', 'document_analyze'], + ) + internal.fixture.runs['run-1']['plan'] = plan + context = executor.RunContext( + run_id='run-1', plan_id=plan['plan_id'], conversation_id='conversation-1', user_id='owner', + user_message='Analyze the discovered documents.', plan_contract_version=2, + result_service=internal.fixture.service, resolve_source_manifest=internal.fixture.resolve, + invoke_prompt=internal.context.invoke_prompt, + result_guard_token_for_step=lambda step_id: ( + internal.checkpoints[step_id].token if step_id == 'analyze' else 'owned-search-token' + ), + ) + context.analysis_checkpoint_factory = internal.context.analysis_checkpoint_factory + result = executor.execute_plan(plan, context, settings=settings, user_id='owner') + assert result['status'] == result['outcome'] == 'completed', result + restarted = internal.fixture.restart() + task = context.task_results['analyze'] + findings = list(restarted.open_result(task.output('findings')).iter_records()) + coverage = restarted.open_result(task.output('coverage')).read_value() + assert len(findings) == 151 + assert any('Top-N' in item for item in task.output('findings').completeness.limitations) + assert any('Top-N' in item for item in coverage['source_selection_limitations']) + manifest = restarted.store.load_committed_orchestration_result( + 'owner', 'conversation-1', 'run-1', 'analyze', task.output('findings').manifest_sha256, + ) + assert manifest['lineage']['upstream'] == [context.task_results['search'].output('sources').to_dict()] + assert internal.hooks.call_count == 0 diff --git a/functional_tests/test_v2_conversation_mode_logic.mjs b/functional_tests/test_v2_conversation_mode_logic.mjs new file mode 100644 index 000000000..419859e0f --- /dev/null +++ b/functional_tests/test_v2_conversation_mode_logic.mjs @@ -0,0 +1,161 @@ +// test_v2_conversation_mode_logic.mjs +// Version: 0.261.341 +// Implemented in: 0.261.341 +// Exercise the real account/conversation memory, draft adoption and storage failure boundaries. + +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]sx?$/.test(specifier)) { + return nextResolve(`${specifier}.ts`, context); + } + return nextResolve(specifier, context); + }, +}); + +const { ConversationModeMemory, CONVERSATION_MODE_PREFIX } = + await import('../application/v2_ui/src/lib/conversationMode.ts'); + +function fixture() { + const entries = new Map(); + let fail = ''; + const storage = { + getItem: (key) => { + if (fail === 'read') throw new Error('blocked'); + return entries.get(key) ?? null; + }, + setItem: (key, value) => { + if (fail === 'write') throw new Error('quota'); + entries.set(key, value); + }, + removeItem: (key) => { + if (fail === 'remove') throw new Error('blocked'); + entries.delete(key); + }, + get length() { return entries.size; }, + key: (index) => [...entries.keys()][index] ?? null, + }; + return { entries, storage, memory: new ConversationModeMemory(() => storage), fail: (value) => { fail = value; } }; +} + +function testExplicitChoicesOnly() { + const { memory, entries, storage } = fixture(); + const absent = memory.read('alice', 'a'); + assert.equal(absent.choice, undefined); + assert.equal(absent.storageFailed, false); + assert.equal(entries.size, 0, 'reading a default never materializes it'); + const saved = memory.choose('alice', 'a', false); + assert.equal(saved, true); + assert.equal(memory.read('alice', 'a').choice, false); + assert.equal(memory.read('alice', 'b').choice, undefined); + assert.equal(memory.read('bob', 'a').choice, undefined); + const reload = new ConversationModeMemory(() => storage); + assert.equal(reload.read('alice', 'a').choice, false, 'off survives a new runtime'); + reload.choose('alice', 'b', true); + assert.equal(reload.read('alice', 'b').choice, true); + assert.equal(reload.read('alice', 'a').choice, false); + memory.choose('a:b', 'c', false); + memory.choose('a', 'b:c', true); + assert.equal(memory.read('a:b', 'c').choice, false, 'key separators cannot collide'); + assert.equal(memory.read('a', 'b:c').choice, true); +} + +function testDraftMigrationAndNavigationRace() { + const { memory, entries } = fixture(); + memory.choose('alice', null, false); + const first = memory.captureDraft('alice'); + assert.equal(entries.size, 0, 'a draft is not an account-wide default'); + memory.resetDraft(); + memory.choose('alice', null, true); + const second = memory.captureDraft('alice'); + const adopted = memory.adoptDraft(first, 'created-offscreen'); + assert.equal(adopted, true); + assert.equal(memory.read('alice', 'created-offscreen').choice, false); + assert.equal(memory.read('alice', null).choice, true, 'an old response cannot consume a newer draft'); + memory.adoptDraft(second, 'created-current'); + assert.equal(memory.read('alice', 'created-current').choice, true); + assert.equal(memory.read('alice', null).choice, undefined, 'the next new chat returns to the default'); + const noChoice = memory.captureDraft('alice'); + memory.adoptDraft(noChoice, 'default-chat'); + assert.equal(memory.read('alice', 'default-chat').choice, undefined); +} + +function testResetLogoutAndPruning() { + const { memory, storage, entries } = fixture(); + memory.choose('alice', 'a', false); + memory.choose('bob', 'a', true); + memory.choose('alice', null, false); + const late = memory.captureDraft('alice'); + const cleared = memory.forgetAccount('alice'); + assert.equal(cleared, true); + memory.adoptDraft(late, 'late-after-logout'); + assert.equal(memory.read('alice', 'late-after-logout').choice, undefined); + assert.equal(memory.read('alice', null).choice, undefined); + assert.equal(entries.size, 1, 'logout removes only this account'); + const reload = new ConversationModeMemory(() => storage); + assert.equal(reload.read('alice', 'a').choice, undefined); + assert.equal(reload.read('bob', 'a').choice, true); + const removed = reload.forget('bob', 'a'); + assert.equal(removed, true); + assert.equal(entries.size, 0); + memory.choose('alice', null, false); + const beforeReset = memory.captureDraft('alice'); + memory.reset(); + memory.adoptDraft(beforeReset, 'after-reset'); + assert.equal(memory.read('alice', 'after-reset').choice, undefined); +} + +function testStorageFailureAndInvalidData() { + const { memory, storage, entries, fail } = fixture(); + entries.set(`${CONVERSATION_MODE_PREFIX}alice:invalid`, '{"choice":false}'); + assert.equal(memory.read('alice', 'invalid').choice, undefined, 'only known scalar values are accepted'); + fail('read'); + const unavailable = memory.read('alice', 'blocked'); + assert.equal(unavailable.storageFailed, true); + assert.equal(unavailable.choice, undefined); + fail('write'); + const saved = memory.choose('alice', 'blocked', false); + assert.equal(saved, false); + assert.equal(memory.read('alice', 'blocked').choice, false, 'failed persistence keeps the current choice usable'); + const reload = new ConversationModeMemory(() => storage); + assert.equal(reload.read('alice', 'blocked').choice, undefined); + memory.choose('alice', null, false); + const adopted = memory.adoptDraft(memory.captureDraft('alice'), 'new'); + assert.equal(adopted, false, 'draft adoption reports persistence failure too'); + assert.equal(memory.read('alice', 'new').choice, false); + fail('remove'); + const removed = memory.forget('alice', 'new'); + assert.equal(removed, false); + assert.equal(memory.read('alice', 'new').choice, undefined); + const inaccessible = new ConversationModeMemory(() => { throw new Error('SecurityError'); }); + assert.equal(inaccessible.read('alice', 'a').storageFailed, true); + const inaccessibleWrite = inaccessible.choose('alice', 'a', false); + assert.equal(inaccessibleWrite, false); + memory.choose('', 'a', false); + assert.equal(memory.read('', 'a').choice, false, 'unidentified input is memory-only'); + assert.equal(entries.has(`${CONVERSATION_MODE_PREFIX}:a`), false); + assert.equal(memory.read('alice', 'a').choice, undefined); +} + +function testSubscriptions() { + const { memory } = fixture(); + let notified = 0; + const initial = memory.getRevision(); + const unsubscribe = memory.subscribe(() => { notified += 1; }); + memory.choose('alice', 'a', false); + assert.equal(notified, 1); + assert.ok(memory.getRevision() > initial); + unsubscribe(); + memory.reset(); + assert.equal(notified, 1); +} + +for (const test of [ + testExplicitChoicesOnly, testDraftMigrationAndNavigationRace, testResetLogoutAndPruning, + testStorageFailureAndInvalidData, testSubscriptions, +]) { + test(); + console.log(`PASS ${test.name}`); +} diff --git a/ui_tests/fixtures/orchestration/harness_entry.tsx b/ui_tests/fixtures/orchestration/harness_entry.tsx index e30e8e667..41cb0eed7 100644 --- a/ui_tests/fixtures/orchestration/harness_entry.tsx +++ b/ui_tests/fixtures/orchestration/harness_entry.tsx @@ -28,6 +28,7 @@ import * as plan from '../../../application/v2_ui/src/lib/orchestrationPlan'; import * as orchestration from '../../../application/v2_ui/src/lib/orchestration'; import * as resume from '../../../application/v2_ui/src/lib/orchestrationResume'; import * as sharing from '../../../application/v2_ui/src/lib/sharing'; +import { conversationModes } from '../../../application/v2_ui/src/lib/conversationMode'; import { OrchestrationPlanCard } from '../../../application/v2_ui/src/components/chat/OrchestrationPlanCard'; import { ElicitationCard } from '../../../application/v2_ui/src/components/chat/ElicitationCard'; @@ -48,6 +49,7 @@ import { ParticipantsPanel } from '../../../application/v2_ui/src/components/cha import { Toaster } from '../../../application/v2_ui/src/components/ui/Toaster'; import { Dropdown } from '../../../application/v2_ui/src/components/ui/Dropdown'; import { AppShell } from '../../../application/v2_ui/src/components/layout/AppShell'; +import { Sidebar } from '../../../application/v2_ui/src/components/layout/Sidebar'; import { ChatPage } from '../../../application/v2_ui/src/pages/ChatPage'; import { HomePage } from '../../../application/v2_ui/src/pages/HomePage'; import { PageHeader } from '../../../application/v2_ui/src/components/layout/PageHeader'; @@ -225,6 +227,7 @@ type ComponentName = | 'Composer' | 'PromptExperience' | 'SessionChatExperience' + | 'Sidebar' | 'ChatExperience' | 'HomeExperience' | 'HeaderExperience' @@ -256,6 +259,7 @@ const components: Record ReactElement | null> = { Composer, PromptExperience, SessionChatExperience, + Sidebar, ChatExperience, HomeExperience, HeaderExperience, @@ -323,6 +327,7 @@ function unmount(containerId: string): void { /** Unmount everything and clear both stores and persisted runs, for isolation between tests. */ function reset(): void { + conversationModes.reset(); for (const [containerId, root] of roots) { root.unmount(); const container = document.getElementById(containerId); diff --git a/ui_tests/test_v2_conversation_mode_persistence.py b/ui_tests/test_v2_conversation_mode_persistence.py new file mode 100644 index 000000000..f3068e935 --- /dev/null +++ b/ui_tests/test_v2_conversation_mode_persistence.py @@ -0,0 +1,427 @@ +# test_v2_conversation_mode_persistence.py +""" +Real-Composer browser regressions for explicit per-conversation mode choices. +Version: 0.261.342 +Implemented in: 0.261.341 + +Use the existing local/Azure Playwright connection, production CSS, real stores, +router and Composer. Only HTTP is replaced; no models, tools or deliveries run. +Run: python -m pytest -q ui_tests/test_v2_conversation_mode_persistence.py +""" + +import copy +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import expect + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "ui_tests" / "fixtures")) +sys.path.insert(0, str(REPO_ROOT / "ui_tests" / "fixtures" / "orchestration")) + +from composer_helpers import open_composer_tools # noqa: E402 +from playwright_connection import connect_options # noqa: E402, F401 +from test_v2_orchestration_approval_persistence import ( # noqa: E402 + ApprovalApi, + CHAT_PATH, + HARNESS_PATH, + ORIGIN, + approval_assets, + approval_browser, + message_box, + send_button, +) + + +pytestmark = pytest.mark.ui +MODE_PREFIX = "simplechat:v2:conversation-mode:" + + +class ModeApi(ApprovalApi): + def __init__(self, assets): + super().__init__(assets) + self.bootstrap["user"] = {"id": "mode-user", "display_name": "Mode User"} + self.creations = [] + self.pending_creations = [] + self.hold_creation = False + + def finish_creation(self, route, conversation_id): + self.respond(route, {"conversation_id": conversation_id, "title": "Mode test"}) + + def handle(self, route): + path = urlsplit(route.request.url).path + if path == "/api/create_conversation" and route.request.method == "POST": + conversation_id = f"mode-created-{len(self.creations) + 1}" + self.creations.append(copy.deepcopy(route.request.post_data_json)) + if self.hold_creation: + self.pending_creations.append((route, conversation_id)) + else: + self.finish_creation(route, conversation_id) + elif path.startswith("/api/chat/stream/status/"): + self.respond(route, {"active": False, "pending": False, "reattachable": False}) + elif path.startswith("/api/conversations/") and path.endswith("/metadata"): + self.respond(route, {}) + elif path.startswith("/api/conversations/") and route.request.method == "DELETE": + self.respond(route, {"success": True}) + elif path == "/logout": + route.fulfill(status=204) + else: + super().handle(route) + + +@pytest.fixture +def mode_ui(approval_browser, approval_assets): + api = ModeApi(approval_assets) + context = approval_browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.on("console", lambda message: errors.append(message.text) if message.type == "error" else None) + page.route("**/*", api.handle) + try: + yield page, api + finally: + context.close() + assert not api.unexpected, f"Unexpected requests: {api.unexpected}" + assert not errors, f"Unexpected browser errors: {errors}" + + +def mount(page, api, conversation_id="mode-a", fresh=True, bootstrap=True, component="ApprovalPreferenceWorkflow"): + response = page.goto(f"{ORIGIN}{HARNESS_PATH}", wait_until="domcontentloaded") + assert response and response.ok + page.wait_for_function("() => Boolean(window.OrchHarness)") + for path in api.assets: + if path.endswith(".css"): + page.add_style_tag(url=f"{ORIGIN}{path}") + page.evaluate( + """(spec) => { + const H = window.OrchHarness; + if (spec.fresh) H.reset(); + H.stores.bootstrap.useBootstrapStore.setState({ data: spec.bootstrap }); + H.stores.chat.useChatStore.setState({ + activeConversationId: spec.conversationId, + activeConversationKind: 'personal', + conversations: ['mode-a', 'mode-b'].map(id => ({ id, title: id })), + streaming: false, messagesLoading: false, + }); + H.stores.userSettings.useUserSettingsStore.setState({ + settings: {}, loading: false, error: null, + }); + H.mount('mount-a', spec.component, {}, { initialEntries: ['/chat'] }); + }""", + {"fresh": fresh, "bootstrap": api.bootstrap if bootstrap else None, + "conversationId": conversation_id, "component": component}, + ) + expect(message_box(page)).to_be_visible() + open_composer_tools(page) + + +def toggle(page): + open_composer_tools(page) + return page.get_by_role("button", name="Orchestrate", exact=True) + + +def assert_mode(page, enabled): + expect(toggle(page)).to_have_attribute("aria-pressed", str(enabled).lower()) + + +def select_conversation(page, conversation_id): + page.evaluate( + "(id) => window.OrchHarness.stores.chat.useChatStore.getState().selectConversation(id, { kind: 'personal' })", + conversation_id, + ) + expect(message_box(page)).to_be_visible() + open_composer_tools(page) + + +def start_new(page): + page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().startNewConversation()") + open_composer_tools(page) + + +def stored_choices(page): + return page.evaluate( + "(prefix) => Object.fromEntries(Object.entries(localStorage).filter(([key]) => key.startsWith(prefix)))", + MODE_PREFIX, + ) + + +def send_direct(page, text="Keep this question in direct chat."): + message_box(page).fill(text) + with page.expect_response(lambda response: urlsplit(response.url).path == CHAT_PATH): + message_box(page).press("Enter") + page.wait_for_function("() => !window.OrchHarness.stores.chat.useChatStore.getState().streaming") + expect(page.get_by_role("button", name="Stop generating", exact=True)).to_have_count(0) + + +@pytest.mark.parametrize("width", [1440, 390]) +def test_off_survives_navigation_reload_and_keyboard_submission(mode_ui, width): + page, api = mode_ui + page.set_viewport_size({"width": width, "height": 900}) + mount(page, api) + assert_mode(page, True) + assert stored_choices(page) == {}, "the deployment default is not a saved user choice" + control = toggle(page) + control.focus() + control.press("Space") + assert_mode(page, False) + expect(control).to_be_focused() + send_direct(page) + assert len(api.chats) == 1 and not api.plans + + page.get_by_role("link", name="Leave chat", exact=True).click() + expect(message_box(page)).to_have_count(0) + page.get_by_role("link", name="Return to chat", exact=True).click() + assert_mode(page, False) + assert len(api.chats) == 1 and not api.plans, "remounting never replays a submission" + send_direct(page, "Direct after navigating back.") + page.reload(wait_until="domcontentloaded") + mount(page, api, fresh=False) + assert_mode(page, False) + assert len(api.chats) == 2 and not api.plans, "reload never replays a submission" + send_direct(page, "Direct after reloading.") + assert len(api.chats) == 3 and not api.plans + assert all(request["conversation_id"] == "mode-a" for request in api.chats) + + +def test_separate_conversations_and_accounts_never_share_a_choice(mode_ui): + page, api = mode_ui + mount(page, api) + toggle(page).click() + assert_mode(page, False) + select_conversation(page, "mode-b") + assert_mode(page, True) + assert len(stored_choices(page)) == 1 + toggle(page).click() + toggle(page).click() + assert_mode(page, True) + select_conversation(page, "mode-a") + assert_mode(page, False) + select_conversation(page, "mode-b") + assert_mode(page, True) + + bob = copy.deepcopy(api.bootstrap) + bob["user"]["id"] = "other-account" + page.evaluate("(data) => window.OrchHarness.stores.bootstrap.useBootstrapStore.setState({ data })", bob) + assert_mode(page, True) + toggle(page).click() + assert_mode(page, False) + page.evaluate("(data) => window.OrchHarness.stores.bootstrap.useBootstrapStore.setState({ data })", api.bootstrap) + assert_mode(page, True) + select_conversation(page, "mode-a") + assert_mode(page, False) + start_new(page) + assert_mode(page, True) + assert not api.chats and not api.plans + + +def test_late_bootstrap_and_thread_switch_resolve_the_current_identity_only(mode_ui): + page, api = mode_ui + mount(page, api, bootstrap=False) + expect(page.get_by_role("button", name="Orchestrate", exact=True)).to_have_count(0) + page.evaluate( + """(prefix) => { + localStorage.setItem(prefix + 'mode-user:mode-a', 'on'); + localStorage.setItem(prefix + 'mode-user:mode-b', 'off'); + window.OrchHarness.stores.chat.useChatStore.setState({ activeConversationId: 'mode-b' }); + }""", + MODE_PREFIX, + ) + page.evaluate("(data) => window.OrchHarness.stores.bootstrap.useBootstrapStore.setState({ data })", api.bootstrap) + assert_mode(page, False) + send_direct(page) + assert api.chats[0]["conversation_id"] == "mode-b" + select_conversation(page, "mode-a") + assert_mode(page, True) + assert len(api.chats) == 1 and not api.plans + + +def test_explicit_draft_choices_migrate_for_direct_and_orchestration_creation(mode_ui): + page, api = mode_ui + mount(page, api, conversation_id=None) + toggle(page).click() + assert_mode(page, False) + page.get_by_role("link", name="Leave chat", exact=True).click() + page.get_by_role("link", name="Return to chat", exact=True).click() + assert_mode(page, False) + send_direct(page) + assert_mode(page, False) + assert api.chats[0]["conversation_id"] == "mode-created-1" + assert stored_choices(page) == {f"{MODE_PREFIX}mode-user:mode-created-1": "off"} + + start_new(page) + assert_mode(page, True) + toggle(page).click() + toggle(page).click() + message_box(page).fill("Prepare a plan, but do not run it.") + with page.expect_response(lambda response: urlsplit(response.url).path == "/api/v2/orchestration/plan"): + send_button(page).click() + page.wait_for_function("() => !window.OrchHarness.stores.chat.useChatStore.getState().streaming") + assert api.plans[0]["conversation_id"] == "mode-created-2" + assert_mode(page, True) + assert stored_choices(page)[f"{MODE_PREFIX}mode-user:mode-created-2"] == "on" + start_new(page) + assert_mode(page, True) + select_conversation(page, "mode-created-1") + assert_mode(page, False) + assert len(api.chats) == 1 and len(api.plans) == 1 + + +def test_late_creation_cannot_consume_another_new_chats_choice(mode_ui): + page, api = mode_ui + mount(page, api, conversation_id=None) + toggle(page).click() + api.hold_creation = True + message_box(page).fill("Direct from the first draft.") + with page.expect_request(lambda request: urlsplit(request.url).path == "/api/create_conversation"): + send_button(page).click() + expect(message_box(page)).to_have_value("") + assert len(api.pending_creations) == 1 + start_new(page) + assert_mode(page, True) + toggle(page).click() + toggle(page).click() + route, conversation_id = api.pending_creations.pop() + with page.expect_response(lambda response: urlsplit(response.url).path == CHAT_PATH): + api.finish_creation(route, conversation_id) + assert_mode(page, True) + current = page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().activeConversationId") + assert current is None + select_conversation(page, conversation_id) + assert_mode(page, False) + assert len(api.chats) == 1 and not api.plans + + +def test_forced_modes_do_not_overwrite_the_saved_choice(mode_ui): + page, api = mode_ui + api.bootstrap["catalogs"]["agents"] = [{"id": "selected-agent", "name": "Selected agent"}] + mount(page, api) + toggle(page).click() + toggle(page).click() + original = stored_choices(page) + disabled = copy.deepcopy(api.bootstrap) + disabled["features"]["enable_chat_orchestration"] = False + page.evaluate("(data) => window.OrchHarness.stores.bootstrap.useBootstrapStore.setState({ data })", disabled) + expect(page.get_by_role("button", name="Orchestrate", exact=True)).to_have_count(0) + expect(page.get_by_title("Documents", exact=True)).to_be_visible() + send_direct(page, "Capability policy requires direct chat without changing my saved choice.") + page.evaluate("(data) => window.OrchHarness.stores.bootstrap.useBootstrapStore.setState({ data })", api.bootstrap) + assert_mode(page, True) + + page.evaluate( + """() => { + const H = window.OrchHarness; + H.unmount('mount-a'); + H.mount('mount-a', 'Composer', { initialAgentSelection: 'selected-agent' }); + }""" + ) + assert_mode(page, False) + assert stored_choices(page) == original + send_direct(page, "Ask the selected agent directly.") + page.evaluate( + """() => { + const H = window.OrchHarness; + H.unmount('mount-a'); + H.mount('mount-a', 'Composer'); + }""" + ) + assert_mode(page, True) + page.evaluate( + """() => window.OrchHarness.stores.chat.useChatStore.setState({ + analysisResultContext: { + conversation_id: 'mode-a', analysis_id: 'saved-analysis', + title: 'Saved analysis', status: 'completed', + }, + })""" + ) + assert_mode(page, False) + assert stored_choices(page) == original + page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().clearAnalysisResultContext()") + assert_mode(page, True) + page.evaluate( + """() => window.OrchHarness.stores.chat.useChatStore.setState({ + workflowResultContext: { conversation_id: 'mode-a', descriptor: { title: 'Stored workflow result' } }, + })""" + ) + assert_mode(page, False) + assert stored_choices(page) == original + page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().clearWorkflowResultContext()") + assert_mode(page, True) + assert len(api.chats) == 2 and not api.plans + + +def test_storage_failure_is_visible_and_keeps_the_current_choice_usable(mode_ui): + page, api = mode_ui + mount(page, api) + page.evaluate( + """() => { + window.restoreModeStorage = Storage.prototype.setItem; + Storage.prototype.setItem = () => { throw new DOMException('Blocked', 'QuotaExceededError'); }; + window.OrchHarness.mount('mount-b', 'Toaster'); + }""" + ) + message_box(page).fill("Preserve this draft when saving fails.") + toggle(page).click() + assert_mode(page, False) + expect(page.get_by_role("alert").filter(has_text="mode choice could not be saved")).to_be_visible() + expect(message_box(page)).to_have_value("Preserve this draft when saving fails.") + page.get_by_role("link", name="Leave chat", exact=True).click() + page.get_by_role("link", name="Return to chat", exact=True).click() + assert_mode(page, False) + send_direct(page) + page.evaluate("() => { Storage.prototype.setItem = window.restoreModeStorage; }") + assert not stored_choices(page) + assert len(api.chats) == 1 and not api.plans + + +def test_successful_deletion_prunes_only_that_conversations_preference(mode_ui): + page, api = mode_ui + mount(page, api) + toggle(page).click() + select_conversation(page, "mode-b") + toggle(page).click() + page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().removeConversation('mode-a')") + assert stored_choices(page) == {f"{MODE_PREFIX}mode-user:mode-b": "off"} + assert_mode(page, False) + + +def test_storage_read_failure_is_announced_and_does_not_block_an_explicit_choice(mode_ui): + page, api = mode_ui + mount(page, api) + page.evaluate( + """() => { + window.restoreModeRead = Storage.prototype.getItem; + Storage.prototype.getItem = () => { throw new DOMException('Blocked', 'SecurityError'); }; + window.OrchHarness.mount('mount-b', 'Toaster'); + }""" + ) + select_conversation(page, "unread-mode") + assert_mode(page, True) + expect(page.get_by_role("alert").filter(has_text="mode choice could not be read")).to_be_visible() + toggle(page).click() + assert_mode(page, False) + send_direct(page) + page.evaluate("() => { Storage.prototype.getItem = window.restoreModeRead; }") + assert stored_choices(page) == {f"{MODE_PREFIX}mode-user:unread-mode": "off"} + assert len(api.chats) == 1 and not api.plans + + +def test_sign_out_clears_this_accounts_modes_and_invalidates_the_runtime(mode_ui): + page, api = mode_ui + mount(page, api) + toggle(page).click() + page.evaluate( + """(prefix) => { + localStorage.setItem(prefix + 'other-account:mode-a', 'off'); + window.OrchHarness.mount('mount-b', 'Sidebar'); + }""", + MODE_PREFIX, + ) + page.locator('#mount-b [data-tour="user-menu"]').click() + with page.expect_request(lambda request: urlsplit(request.url).path == "/logout"): + page.get_by_role("link", name="Sign out", exact=True).click() + assert stored_choices(page) == {f"{MODE_PREFIX}other-account:mode-a": "off"} + assert_mode(page, True) + assert not api.chats and not api.plans diff --git a/ui_tests/test_v2_orchestration_recovery.py b/ui_tests/test_v2_orchestration_recovery.py index 257cbc6ed..d0b73664f 100644 --- a/ui_tests/test_v2_orchestration_recovery.py +++ b/ui_tests/test_v2_orchestration_recovery.py @@ -1,13 +1,14 @@ # test_v2_orchestration_recovery.py """ Real-component browser coverage for orchestration failure and checkpoint recovery. -Version: 0.261.317 +Version: 0.261.341 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 Configurable step failure, reused by Microsoft 365 recovery coverage, in: 0.261.238 Ended runs show unfinished steps and retry them; files that cannot be retried alone offer Retry from failed step, in: 0.261.303 +Empty pending-credential polling fixture updated in: 0.261.341 The production controller, SSE reader, stores, message list, and Run drawer execute in the existing local/Azure Playwright harness. Only API responses are deterministic. @@ -170,6 +171,9 @@ def handle(self, route): if is_pending_actions_list(request.method, path): route.fulfill(json=pending_actions_payload()) return + if path == "/api/action-credentials/pending" and request.method == "GET": + route.fulfill(json={"requests": []}) + return body = request.post_data_json if request.post_data else None self.requests.append({"path": path, "method": request.method, "body": body}) if request.method == "GET" and path in self.assets: