diff --git a/application/single_app/config.py b/application/single_app/config.py index d02452ba1..5f76ed2fb 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -103,7 +103,7 @@ EXECUTOR_MAX_WORKERS = 30 CHAT_STREAM_EXECUTOR_MAX_WORKERS = max(8, int(os.getenv('SIMPLECHAT_CHAT_STREAM_WORKERS', '32') or '32')) SESSION_TYPE = 'filesystem' -VERSION = "0.261.414" +VERSION = "0.261.415" 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_chat_retry.py b/application/single_app/functions_chat_retry.py index 8ff49c21a..a26cff750 100644 --- a/application/single_app/functions_chat_retry.py +++ b/application/single_app/functions_chat_retry.py @@ -16,6 +16,7 @@ from functions_assist_submissions import normalize_submission_id from functions_chat_message_metadata import patch_message_metadata from functions_message_deletion import is_soft_deleted_message +from functions_orchestration_planning import PLANNING_METADATA MODEL_FIELDS = ('model_deployment', 'model_id', 'model_endpoint_id', 'model_provider') @@ -331,6 +332,7 @@ def retry_source_fingerprint(source): 'button_states', 'prompt_selection', 'image_references', 'image_generation', 'requested_reasoning_effort', 'time_zone', 'masked', 'masked_ranges', 'orchestration', 'orchestration_turn_id', 'orchestration_inputs', 'orchestration_clarification_answers', + PLANNING_METADATA, ) if key in metadata } return hashlib.sha256(json.dumps( diff --git a/application/single_app/functions_orchestration_collaboration.py b/application/single_app/functions_orchestration_collaboration.py index 8edb380e5..d2010ab28 100644 --- a/application/single_app/functions_orchestration_collaboration.py +++ b/application/single_app/functions_orchestration_collaboration.py @@ -164,7 +164,14 @@ def mirror_orchestration_turn(conversation, user_message, sender_user, *, detail 'source_conversation_id': conversation['id'], 'source_role': 'user', 'orchestration_turn_id': metadata.get('orchestration_turn_id'), - 'orchestration': {'turn_id': metadata.get('orchestration_turn_id')}, + 'orchestration': { + 'turn_id': metadata.get('orchestration_turn_id'), + **{ + key: (metadata.get('orchestration') or {})[key] + for key in ('planning_status', 'planning_submission_id') + if key in (metadata.get('orchestration') or {}) + }, + }, } if isinstance(details.get('invocation_target'), dict): extra['ai_invocation_target'] = details['invocation_target'] diff --git a/application/single_app/functions_orchestration_planning.py b/application/single_app/functions_orchestration_planning.py new file mode 100644 index 000000000..88e7daa40 --- /dev/null +++ b/application/single_app/functions_orchestration_planning.py @@ -0,0 +1,370 @@ +# functions_orchestration_planning.py +"""Durable initial planning attempts anchored to their saved user question. + +The route owns authorization and supplies the messages container. This module +stays below application bootstrap and never persists settings or runtime objects. +""" + +from copy import deepcopy +from datetime import datetime, timedelta, timezone +import hashlib +import json +import uuid + +from azure.core import MatchConditions +from azure.cosmos.exceptions import ( + CosmosAccessConditionFailedError, CosmosResourceExistsError, CosmosResourceNotFoundError, +) + +from cosmos_document_utils import normalize_cosmos_document +from functions_message_deletion import is_soft_deleted_message + + +PLANNING_METADATA = "_orchestration_planning" +PLANNING_LEASE_SECONDS = 90 +PLANNING_HEARTBEAT_SECONDS = 20 +PLANNING_STATES = frozenset({ + "planning", "awaiting_clarification", "awaiting_review", "running", + "completed", "failed", "interrupted", "cancelled", +}) +INTERRUPTED_MESSAGE = "Planning was interrupted before completion. Your question was saved. Retry to create a new plan." +_REQUEST_LIMIT = 1024 * 1024 +_TRANSIENT_INPUTS = frozenset({ + "approval_mode", "requires_fresh_review", "revision", "planning_submission_id", +}) + + +class PlanningStateError(ValueError): + """A public-safe refusal to alter or recover an initial planning attempt.""" + + def __init__(self, message, *, code="planning_changed", status_code=409): + super().__init__(message) + self.public_message = message + self.code = code + self.status_code = status_code + + +def _now(): + return datetime.now(timezone.utc) + + +def _json(value): + try: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError) as exc: + raise PlanningStateError("The planning inputs are invalid.", code="invalid_request", status_code=400) from exc + if len(encoded.encode("utf-8")) > _REQUEST_LIMIT: + raise PlanningStateError("The planning selections are too large.", code="invalid_request", status_code=400) + return encoded + + +def _hash(value): + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _input_fingerprint(request_body): + return _hash(_json({key: value for key, value in request_body.items() if key not in _TRANSIENT_INPUTS})) + + +def planning_question_id(conversation_id, turn_id): + return f"user_orchestration_{uuid.uuid5(uuid.NAMESPACE_URL, f'{conversation_id}:{turn_id}').hex}" + + +def new_planning_attempt(request_body, turn_id, submission_id, *, revision=0): + """Prepare metadata that can be created atomically with a new question.""" + if ( + type(request_body) is not dict or not isinstance(request_body.get("message"), str) + or not isinstance(request_body.get("conversation_id"), str) or not request_body["conversation_id"] + ): + raise PlanningStateError("The planning inputs are invalid.", code="invalid_request", status_code=400) + if not isinstance(submission_id, str) or not submission_id or len(submission_id) > 256: + raise PlanningStateError("The planning submission id is invalid.", code="invalid_request", status_code=400) + if not isinstance(turn_id, str) or not turn_id or len(turn_id) > 256: + raise PlanningStateError("The planning turn id is invalid.", code="invalid_request", status_code=400) + stored_request = deepcopy(request_body) + message = stored_request.pop("message") + _json(stored_request) + now = _now() + return { + "version": 1, + "conversation_id": request_body["conversation_id"], + "turn_id": turn_id, + "submission_id": submission_id, + "claim_id": uuid.uuid4().hex, + "revision": revision, + "question_hash": _hash(message), + "input_fingerprint": _input_fingerprint(request_body), + "request": stored_request, + "status": "planning", + "updated_at": now.isoformat(), + "lease_until": (now + timedelta(seconds=PLANNING_LEASE_SECONDS)).isoformat(), + } + + +def _read_question(messages, conversation_id, message_id, turn_id): + try: + question = normalize_cosmos_document(messages.read_item(item=message_id, partition_key=conversation_id)) + except CosmosResourceNotFoundError: + raise PlanningStateError("The saved question is unavailable.", code="planning_unavailable", status_code=404) from None + metadata = question.get("metadata") + if metadata is None: + metadata = {} + if type(metadata) is not dict: + raise PlanningStateError("Saved planning metadata is unavailable.", code="planning_unavailable", status_code=503) + if ( + question.get("conversation_id") != conversation_id or question.get("role") != "user" + or metadata.get("orchestration_turn_id") != turn_id + or is_soft_deleted_message(question) or metadata.get("masked") or metadata.get("masked_ranges") + ): + raise PlanningStateError("The saved question is unavailable.", code="planning_unavailable", status_code=404) + state = metadata.get(PLANNING_METADATA) + if state is not None and ( + type(state) is not dict or state.get("version") != 1 + or state.get("conversation_id") != conversation_id or state.get("turn_id") != turn_id + or state.get("question_hash") != _hash(question.get("content") or "") + or state.get("status") not in PLANNING_STATES + ): + raise PlanningStateError("The saved planning inputs changed. Review a new request.") + return question, state + + +def _replace(messages, question, state): + updated = deepcopy(question) + updated.setdefault("metadata", {})[PLANNING_METADATA] = state + return normalize_cosmos_document(messages.replace_item( + item=question["id"], body=updated, etag=question["_etag"], + match_condition=MatchConditions.IfNotModified, + )) + + +def _expired(state): + try: + return datetime.fromisoformat(state["lease_until"]) <= _now() + except (KeyError, TypeError, ValueError): + raise PlanningStateError("The saved planning lease is invalid.") from None + + +def read_planning_attempt(messages, conversation_id, message_id, turn_id): + """Reconcile a dead planning worker without restarting model or tool work.""" + for _attempt in range(5): + question, state = _read_question(messages, conversation_id, message_id, turn_id) + if state is None: + raise PlanningStateError("This question has no saved planning attempt.", code="planning_unavailable", status_code=404) + if state["status"] != "planning" or not _expired(state): + return question, state + updated = { + **state, "status": "interrupted", "error": INTERRUPTED_MESSAGE, + "error_code": "planning_interrupted", "updated_at": _now().isoformat(), + } + try: + saved = _replace(messages, question, updated) + return saved, saved["metadata"][PLANNING_METADATA] + except CosmosAccessConditionFailedError: + continue + raise PlanningStateError("Planning status changed. Reload to check its saved state.") + + +def claim_planning_attempt( + messages, conversation_id, message_id, prepared, *, + retry=False, continuation=False, retry_failed_submission=False, +): + """Claim once, observe the same submission, or fence an explicit new attempt.""" + for _attempt in range(5): + question, state = _read_question(messages, conversation_id, message_id, prepared["turn_id"]) + if state and state.get("claim_id") == prepared["claim_id"]: + return state, True + if state and state.get("submission_id") == prepared["submission_id"]: + if state["input_fingerprint"] != prepared["input_fingerprint"]: + raise PlanningStateError("This submission id was already used for different planning inputs.") + _question, current = read_planning_attempt( + messages, conversation_id, message_id, prepared["turn_id"], + ) + if not ( + retry_failed_submission and current["status"] in {"failed", "interrupted"} + and not current.get("run_started") + ): + return current, False + state = current + if state: + if state["status"] == "planning": + _question, state = read_planning_attempt( + messages, conversation_id, message_id, prepared["turn_id"], + ) + allowed = ( + continuation + or (retry and state["status"] in {"failed", "interrupted", "cancelled"}) + or (retry_failed_submission and state["status"] in {"failed", "interrupted"}) + or ( + state["status"] == "awaiting_review" + and prepared["revision"] > state.get("revision", 0) + ) + ) + if not allowed or state["status"] in {"planning", "running"} or state.get("run_started"): + raise PlanningStateError("This question already has an active or saved plan. Reload its saved state.") + canonical = {**state["request"], "message": question["content"]} + proposed = {**prepared["request"], "message": question["content"]} + if not continuation and ( + _input_fingerprint(canonical) != _input_fingerprint(proposed) + and state["input_fingerprint"] != prepared["input_fingerprint"] + ): + raise PlanningStateError("The original planning selections changed. Review a new request.") + if not continuation: + prepared = { + **prepared, "request": { + **deepcopy(state["request"]), + **{ + key: prepared["request"][key] + for key in _TRANSIENT_INPUTS if key in prepared["request"] + }, + }, + } + try: + saved = _replace(messages, question, deepcopy(prepared)) + return saved["metadata"][PLANNING_METADATA], True + except CosmosAccessConditionFailedError: + continue + raise PlanningStateError("This planning attempt changed. Reload its saved state.") + + +def update_planning_attempt(messages, conversation_id, message_id, turn_id, updates, *, claim_id=None): + """Apply a conditional state transition; cancellation fences a late worker.""" + for _attempt in range(5): + question, state = _read_question(messages, conversation_id, message_id, turn_id) + if state is None or ( + claim_id is not None and ( + state.get("claim_id") != claim_id + or state["status"] in {"cancelled", "interrupted", "failed", "completed"} + ) + ): + raise PlanningStateError("This planning attempt is no longer active.") + if updates.get("status", state["status"]) not in PLANNING_STATES: + raise ValueError("Invalid planning status.") + updated = {**state, **deepcopy(updates), "updated_at": _now().isoformat()} + try: + saved = _replace(messages, question, updated) + return saved["metadata"][PLANNING_METADATA] + except CosmosAccessConditionFailedError: + continue + raise PlanningStateError("This planning attempt changed. Reload its saved state.") + + +def require_planning_claim(messages, conversation_id, message_id, turn_id, claim_id): + _question, state = read_planning_attempt(messages, conversation_id, message_id, turn_id) + if state.get("claim_id") != claim_id or state["status"] not in {"planning", "running"}: + raise PlanningStateError("This planning attempt is no longer active.") + return state + + +def heartbeat_planning_attempt(messages, conversation_id, message_id, turn_id, claim_id): + require_planning_claim(messages, conversation_id, message_id, turn_id, claim_id) + return update_planning_attempt( + messages, conversation_id, message_id, turn_id, + {"lease_until": (_now() + timedelta(seconds=PLANNING_LEASE_SECONDS)).isoformat()}, + claim_id=claim_id, + ) + + +def cancel_planning_attempt( + messages, conversation_id, message_id, turn_id, *, submission_id, claim_id=None, +): + for _attempt in range(5): + question, state = read_planning_attempt(messages, conversation_id, message_id, turn_id) + if state["submission_id"] != submission_id or ( + claim_id is not None and state["claim_id"] != claim_id + ): + raise PlanningStateError("This planning submission changed. Reload its saved state.") + if state["status"] in {"completed", "failed", "interrupted", "cancelled"}: + return state + updated = {**state, "status": "cancelled", "updated_at": _now().isoformat()} + try: + saved = _replace(messages, question, updated) + return saved["metadata"][PLANNING_METADATA] + except CosmosAccessConditionFailedError: + continue + raise PlanningStateError("Stop could not be confirmed. Retry Stop or check saved status.") + + +def _cancellation_id(conversation_id, user_id, turn_id, submission_id): + return "planning_stop_" + _hash(_json([conversation_id, user_id, turn_id, submission_id])) + + +def planning_was_cancelled(cancellations, conversation_id, user_id, turn_id, submission_id): + try: + marker = normalize_cosmos_document(cancellations.read_item( + item=_cancellation_id(conversation_id, user_id, turn_id, submission_id), + partition_key=conversation_id, + )) + except CosmosResourceNotFoundError: + return False + expected = { + "record_type": "planning_cancellation", "conversation_id": conversation_id, + "user_id": user_id, "turn_id": turn_id, "submission_id": submission_id, + } + if any(marker.get(key) != value for key, value in expected.items()): + raise PlanningStateError("The saved stop request is unavailable.", code="planning_unavailable", status_code=503) + return True + + +def record_planning_cancellation(cancellations, conversation_id, user_id, turn_id, submission_id): + """An immutable typed record cannot be erased by unrelated conversation writes.""" + marker = { + "id": _cancellation_id(conversation_id, user_id, turn_id, submission_id), + "record_type": "planning_cancellation", "conversation_id": conversation_id, + "user_id": user_id, "turn_id": turn_id, "submission_id": submission_id, + "created_at": _now().isoformat(), + } + try: + cancellations.create_item(marker) + except CosmosResourceExistsError: + if not planning_was_cancelled(cancellations, conversation_id, user_id, turn_id, submission_id): + raise PlanningStateError("Stop could not be confirmed. Retry Stop or check saved status.") from None + + +def planning_projection(question, state, *, include_request=False): + result = { + "conversation_id": question["conversation_id"], "user_message_id": question["id"], + "turn_id": state["turn_id"], "submission_id": state["submission_id"], + "status": state["status"], "updated_at": state["updated_at"], + } + for field in ("run_id", "error", "error_code"): + if state.get(field): + result[field] = state[field] + if include_request: + result["request"] = {**deepcopy(state["request"]), "message": question["content"]} + if "run_started" in state: + result["run_started"] = state["run_started"] + return result + + +def public_planning_message(message): + """Private claims and replay inputs never ride along with normal history.""" + result = normalize_cosmos_document(message) + incoming = result.get("metadata") + if incoming is not None and type(incoming) is not dict: + raise PlanningStateError("Saved message metadata is unavailable.", code="planning_unavailable", status_code=503) + metadata = dict(incoming or {}) + state = metadata.pop(PLANNING_METADATA, None) + if state is not None and type(state) is not dict: + raise PlanningStateError("Saved planning metadata is unavailable.", code="planning_unavailable", status_code=503) + if state and ( + state.get("conversation_id") != message.get("conversation_id") + or metadata.get("masked") or metadata.get("masked_ranges") or is_soft_deleted_message(message) + ): + orchestration = dict(metadata.get("orchestration") or {}) + for field in ("planning_status", "planning_submission_id", "planning_error", "planning_run_started"): + orchestration.pop(field, None) + metadata["orchestration"] = orchestration + elif state: + if state.get("status") not in PLANNING_STATES or not state.get("turn_id") or not state.get("submission_id"): + raise PlanningStateError("Saved planning metadata is unavailable.", code="planning_unavailable", status_code=503) + metadata["orchestration"] = { + **(metadata.get("orchestration") or {}), + "turn_id": state["turn_id"], "planning_status": state["status"], + "planning_submission_id": state["submission_id"], + **({"run_id": state["run_id"]} if state.get("run_id") else {}), + **({"planning_error": state["error"]} if state.get("error") else {}), + **({"planning_run_started": state["run_started"]} if "run_started" in state else {}), + } + if metadata or "metadata" in result: + result["metadata"] = metadata + return result diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index d314b077b..bcde1e231 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -96,6 +96,7 @@ filter_assistant_artifact_items, hydrate_agent_citations_from_artifacts, ) +from functions_orchestration_planning import PlanningStateError, public_planning_message from functions_message_deletion import ( exclude_soft_deleted_messages, is_soft_deleted_message, @@ -1648,7 +1649,13 @@ def api_get_messages(): "message": "Microsoft 365 action cards could not be loaded. Reload the conversation to try again.", }), status_code - return jsonify({'messages': messages}) + return jsonify({'messages': [public_planning_message(message) for message in messages]}) + except PlanningStateError as error: + log_event( + '[ORCHESTRATION] Saved planning history could not be projected.', + level=logging.ERROR, extra={'stage': 'planning_history', 'code': error.code}, + ) + return jsonify({'error': error.public_message, 'code': error.code}), error.status_code except PermissionError: return jsonify({'error': 'Forbidden'}), 403 except LookupError: diff --git a/application/single_app/route_backend_orchestration.py b/application/single_app/route_backend_orchestration.py index 501ab705b..6397562c5 100644 --- a/application/single_app/route_backend_orchestration.py +++ b/application/single_app/route_backend_orchestration.py @@ -39,6 +39,7 @@ import threading import uuid from copy import deepcopy +from contextlib import ExitStack from agent_execution_context import capture_execution_identity from datetime import datetime, timezone from functools import wraps @@ -47,17 +48,17 @@ from azure.core.exceptions import AzureError from azure.core import MatchConditions from azure.cosmos.exceptions import CosmosResourceNotFoundError -from flask import Response, g, has_request_context, jsonify, make_response, request, session, stream_with_context +from flask import Response, copy_current_request_context, g, has_request_context, jsonify, make_response, request, session, stream_with_context -from config import cosmos_conversations_container, cosmos_messages_container +from config import cosmos_conversations_container, cosmos_messages_container, cosmos_orchestration_runs_container from content_screening.contracts import DocumentHeldError, ScreeningError from functions_activity_logging import log_workflow_creation from functions_appinsights import log_event, workflow_log_context from functions_interaction_csrf import get_interaction_csrf_token from functions_assist_submissions import SubmissionIdError, normalize_submission_id -from public_chat_scope import prepare_public_chat_scope, public_chat_scope_required +from public_chat_scope import prepare_public_chat_scope, public_chat_execution_scope, public_chat_scope_required from public_chat_scope_state import PublicChatScopeError, current_public_chat_scope -from public_source_policy import current_public_source_policy +from public_source_policy import current_public_source_policy, public_source_policy_context from functions_public_workspaces import resolve_public_chat_workspace_ids from functions_chat_content_checks import ( CHECK_METADATA, check_chat_content, orchestration_input_text, @@ -67,9 +68,11 @@ checked_history_messages, patch_chat_message_metadata, record_blocked_chat_attempt, reply_is_retracted, ) from functions_chat_retry import ( - MODEL_FIELDS, ChatRetryError, available_retry_attempts, claim_retry_attempt, load_owned_retry_message, + MODEL_FIELDS, ChatRetryError, agent_request_from_metadata, available_retry_attempts, + claim_retry_attempt, load_owned_retry_message, prepare_retry_attempt, reconcile_prepared_retry, retry_history_prefix, set_retry_attempt_state, ) +from functions_chat_stream_events import build_user_message_persisted_stream_payload from functions_message_deletion import is_soft_deleted_message from functions_saved_analysis import ( analysis_result_contexts, @@ -149,6 +152,13 @@ load_orchestration_memory, validate_memory_context, ) +from functions_orchestration_planning import ( + PLANNING_HEARTBEAT_SECONDS, PLANNING_METADATA, PlanningStateError, + cancel_planning_attempt, claim_planning_attempt, heartbeat_planning_attempt, + new_planning_attempt, planning_projection, planning_question_id, public_planning_message, + planning_was_cancelled, read_planning_attempt, record_planning_cancellation, + require_planning_claim, update_planning_attempt, +) from functions_orchestration_collaboration import ( SHARED_ORCHESTRATION_STALE_COPY, SharedOrchestrationError, @@ -240,6 +250,7 @@ is_legacy_plan, plan_contract_version, normalize_elicitation, + plan_approval_floor, safe_failure, ) from functions_settings import enabled_required, get_settings, get_user_settings, workflow_user_required @@ -306,6 +317,261 @@ def public_events(): return Response(public_events(), mimetype='text/event-stream', headers=dict(SSE_HEADERS)) +def _require_planning_owner(holder): + if not holder.get('owned'): + return + _authorize_context_conversation(holder['conversation_id'], holder['user_id']) + if planning_was_cancelled( + cosmos_orchestration_runs_container, holder['conversation_id'], holder['user_id'], + holder['turn_id'], holder['submission_id'], + ): + cancel_planning_attempt( + cosmos_messages_container, holder['conversation_id'], holder['message_id'], holder['turn_id'], + submission_id=holder['submission_id'], claim_id=holder['claim_id'], + ) + raise PlanningStateError('Planning was cancelled.', code='planning_cancelled') + require_planning_claim( + cosmos_messages_container, holder['conversation_id'], holder['message_id'], + holder['turn_id'], holder['claim_id'], + ) + + +def _update_planning_owner(holder, **updates): + return update_planning_attempt( + cosmos_messages_container, holder['conversation_id'], holder['message_id'], + holder['turn_id'], updates, claim_id=holder['claim_id'], + ) + + +def _planning_turn_row(conversation_id, user_id, message_id, turn_id, *, include_request=False): + _authorize_context_conversation(conversation_id, user_id) + question, state = read_planning_attempt( + cosmos_messages_container, conversation_id, message_id, turn_id, + ) + row = planning_projection(question, state, include_request=include_request) + if state.get('run_id'): + record = get_orchestration_run(state['run_id'], user_id, conversation_id, strict=True) + if record: + if record.get('superseded_by_run_id'): + record = read_revision_run(record['run_id'], user_id, conversation_id, follow_current=True) + row['run_id'] = record['run_id'] + row['run_started'] = bool(record.get('started_at')) + if record.get('started_at') or record.get('status') in ('failed', 'cancelled', 'completed'): + row['status'] = record['status'] + elif state['status'] not in ('planning', 'cancelled', 'failed', 'interrupted'): + row['status'] = 'awaiting_review' + if row['status'] == 'awaiting_clarification': + pending = get_pending_elicitation(user_id, conversation_id, turn_id) + if not pending or pending.get('status') == 'completed': + raise PlanningStateError('The clarification changed. Reload its saved state.') + _validate_turn_memory_context(pending['turn_context'], user_id, conversation_id) + row['elicitation'] = pending['question'] + return row + + +def _stream_planning(events, holder, context, user_id, settings, run_plan): + """The browser observes a worker; disconnecting never closes its planner.""" + frames = queue.Queue(maxsize=128) + attached = threading.Event() + attached.set() + finished = threading.Event() + stop_heartbeat = threading.Event() + heartbeat = None + started = False + end = object() + admitted_public_scope = current_public_chat_scope(user_id) + if admitted_public_scope is not None: + admitted_public_scope = { + **admitted_public_scope, + 'workspace_ids': list(admitted_public_scope['workspace_ids']), + } + + def emit(frame): + if attached.is_set(): + try: + frames.put_nowait(frame) + except queue.Full: + attached.clear() + + def renew(): + while not stop_heartbeat.wait(PLANNING_HEARTBEAT_SECONDS): + try: + heartbeat_planning_attempt( + cosmos_messages_container, holder['conversation_id'], holder['message_id'], + holder['turn_id'], holder['claim_id'], + ) + except (PlanningStateError, AzureError) as exc: + log_event( + '[ORCHESTRATION] Planning heartbeat could not retain its claim.', + level=logging.WARNING, extra={ + **workflow_log_context(conversation_id=holder['conversation_id'], turn_id=holder['turn_id']), + 'stage': 'planning_heartbeat', 'error_type': type(exc).__name__, + }, + ) + return + + def fail(message, code='planning_failed', **fields): + if holder.get('owned'): + try: + _update_planning_owner(holder, status='failed', error=message, error_code=code, **fields) + except (PlanningStateError, AzureError) as exc: + log_event( + '[ORCHESTRATION] Planning failure could not update its saved question.', + level=logging.WARNING, extra={'stage': 'planning_finalize', 'error_type': type(exc).__name__}, + ) + emit(serialize_sse({'error': message, 'code': code, 'done': True})) + + @copy_current_request_context + def worker(): + nonlocal heartbeat + auto_plan = None + terminal = False + scope = { + 'seeds': context['seeds'], 'conversation_id': context.get('conversation_id'), + 'resolve_public_chat_workspace_ids': resolve_public_chat_workspace_ids, + 'read_conversation_for_public_chat': lambda value: _authorize_context_conversation(value, user_id), + } + try: + with ExitStack() as scopes: + scopes.enter_context(public_source_policy_context( + user_id, context['seeds'].get('public_source_policy'), + )) + if admitted_public_scope is not None: + g.public_chat_scope = admitted_public_scope + for frame in events: + if holder.get('owned') and heartbeat is None: + heartbeat = threading.Thread(target=renew, name='orchestration-planning-heartbeat', daemon=True) + heartbeat.start() + if not frame.startswith('data:'): + emit(frame) + continue + payload = json.loads(frame.partition('data:')[2].strip()) + if holder.get('owned'): + _require_planning_owner(holder) + if payload.get('error'): + fail(payload['error'], payload.get('code') or 'planning_failed') + terminal = True + continue + if payload.get('type') == 'orchestration_elicitation': + _update_planning_owner(holder, status='awaiting_clarification') + terminal = True + elif payload.get('type') == 'orchestration_plan': + plan = payload['plan'] + automatic = ( + (plan.get('approval') or {}).get('mode') == 'auto' + and (plan.get('approval') or {}).get('state') == 'approved' + and not plan.get('requires_fresh_review') + and not plan_approval_floor(plan) + ) + _update_planning_owner( + holder, status='planning' if automatic else 'awaiting_review', + run_id=plan['run_id'], + ) + payload['server_managed'] = True + auto_plan = plan if automatic else None + terminal = True + emit(serialize_sse(payload)) + if holder.get('owned') and payload.get('type') == 'user_message_persisted': + scope.update(seeds=context['seeds'], conversation_id=holder['conversation_id']) + scopes.enter_context(public_chat_execution_scope(scope, user_id, settings)) + if auto_plan is not None: + _require_planning_owner(holder) + response = make_response(run_plan({ + 'run_id': auto_plan['run_id'], 'conversation_id': holder['conversation_id'], + })) + try: + if response.status_code >= 400: + failure = response.get_json(silent=True) or {} + record = get_orchestration_run( + auto_plan['run_id'], user_id, holder['conversation_id'], strict=True, + ) + if record and record.get('started_at'): + _update_planning_owner(holder, status='running', run_started=True) + else: + fail( + failure.get('error') or 'The saved plan could not start. Reload to review its status.', + 'auto_admission_failed', run_started=False, + ) + else: + _update_planning_owner(holder, status='running', run_started=True) + for frame in response.response: + text = frame.decode('utf-8') if isinstance(frame, bytes) else frame + if text.startswith('data:'): + payload = json.loads(text.partition('data:')[2].strip()) + if payload.get('done'): + status = payload.get('status') or ('failed' if payload.get('error') else 'completed') + if status in ('completed', 'failed', 'cancelled'): + _update_planning_owner( + holder, status=status, + **({'error': payload['error']} if payload.get('error') else {}), + ) + finally: + response.close() + elif holder.get('owned') and not terminal: + fail('Planning ended without a saved result. Your question was saved. Retry to create a new plan.') + except PlanningStateError as exc: + log_event( + '[ORCHESTRATION] Planning stopped because its saved attempt changed.', + level=logging.WARNING, extra={'stage': 'planning_fence', 'code': exc.code}, + ) + if auto_plan is not None: + try: + request_cancellation( + auto_plan['run_id'], user_id, holder['conversation_id'], + lambda: _authorize_context_conversation(holder['conversation_id'], user_id), + ) + except (AzureError, RecoveryError, ConversationContextError) as cancellation_error: + log_event( + '[ORCHESTRATION] A fenced plan could not confirm cancellation.', + level=logging.ERROR, extra={'error_type': type(cancellation_error).__name__}, + ) + emit(serialize_sse({'error': exc.public_message, 'code': exc.code, 'done': True})) + except Exception as exc: + log_event( + '[ORCHESTRATION] Background planning could not finish.', + level=logging.ERROR, extra={'stage': 'planning_worker', 'error_type': type(exc).__name__}, + ) + fail('Planning could not finish. Reload to check the saved question and retry.') + finally: + try: + events.close() + except Exception as exc: + log_event( + '[ORCHESTRATION] Planning resources could not be closed.', + level=logging.ERROR, extra={'stage': 'planning_close', 'error_type': type(exc).__name__}, + ) + finally: + stop_heartbeat.set() + if heartbeat is not None: + heartbeat.join(timeout=1) + emit(end) + finished.set() + + def observe(): + nonlocal started + thread = threading.Thread(target=worker, name='orchestration-planning', daemon=True) + try: + thread.start() + started = True + while not finished.is_set() or not frames.empty(): + try: + frame = frames.get(timeout=SSE_KEEPALIVE_SECONDS) + if frame is end: + return + yield frame + except queue.Empty: + if not finished.is_set(): + yield SSE_KEEPALIVE_FRAME + finally: + attached.clear() + if not started: + events.close() + + response = _sse(observe()) + response.call_on_close(attached.clear) + return response + + def _orchestration_enabled(settings): return bool((settings or {}).get('enable_chat_orchestration')) @@ -735,11 +1001,11 @@ def _require_regeneration_source_idle(record): ) -def _regeneration_request(record): +def _regeneration_request(record, *, require_selection=True): seeds = record.get('seeds') or record.get('original_seeds') - if not isinstance(seeds, dict) or not ( + if not isinstance(seeds, dict) or (require_selection and not ( seeds.get('model') or seeds.get('agent') or seeds.get('model_routing') == 'auto' - ): + )): raise _regeneration_unavailable() body = { 'message': record['user_message'], 'conversation_id': record['conversation_id'], @@ -763,7 +1029,7 @@ def _regeneration_request(record): 'conversation_references': deepcopy(seeds.get('conversation_references') or []), } if seeds.get('agent'): - body['agent_info'] = deepcopy(seeds['agent']) + body['agent_info'] = agent_request_from_metadata(seeds['agent']) if seeds.get('model_routing') == 'auto': body['model_routing'] = 'auto' elif seeds.get('model'): @@ -775,6 +1041,34 @@ def _regeneration_request(record): return body +def _planning_request(context, data): + body = _regeneration_request(context, require_selection=False) + body.update({ + 'turn_id': context['turn_id'], 'approval_mode': context.get('approval_mode') or '', + 'revision': context.get('revision', 0), + }) + if context.get('requires_fresh_review'): + body['requires_fresh_review'] = True + body.update(deepcopy(context.get('collaboration') or {})) + if 'elicitation_response' in data: + for key in ( + 'elicitation_response', 'elicitation_context', 'elicitation_id', + 'elicitation_revision', 'elicitation_submission_id', + ): + if key in data: + body[key] = deepcopy(data[key]) + return body + + +def _restore_turn_selections(context, previous): + for key in ( + 'user_message_id', 'user_message_fingerprint', 'seeds', 'original_seeds', + 'answered_questions', 'prompt_selection', 'memory_audience', 'memory_scope', 'time_zone', + ): + if key in previous: + context[key] = deepcopy(previous[key]) + + def _regeneration_input_fingerprint(record): return hashlib.sha256(json.dumps( {'request': _regeneration_request(record), 'answers': record.get('answered_questions') or []}, @@ -1020,6 +1314,7 @@ def wrapped(*args, **kwargs): cosmos_messages_container, cosmos_conversations_container, user_id, question_id, ) body, context = _restore_prepared_regeneration(question, user_id, get_settings()) + planning_submission_id = data.get('planning_submission_id') question = claim_retry_attempt( cosmos_messages_container, cosmos_conversations_container, user_id, question['conversation_id'], question['id'], kind='orchestration', @@ -1027,6 +1322,8 @@ def wrapped(*args, **kwargs): ) turn_id = question['metadata']['orchestration']['turn_id'] body.update({'turn_id': turn_id, 'retry_user_message_id': question['id'], 'approval_mode': 'manual'}) + if planning_submission_id is not None: + body['planning_submission_id'] = planning_submission_id data.clear() data.update(body) g.orchestration_regeneration_context = context @@ -1087,12 +1384,17 @@ def _regeneration_planning_events(events, context): 'requires_fresh_review': True, }) finally: - if question_id and not terminal: - set_retry_attempt_state( - cosmos_messages_container, context['conversation_id'], question_id, 'interrupted', - error='Planning was interrupted. Retry this question to create another plan.', - expected_states={'planning'}, - ) + try: + if question_id and not terminal: + set_retry_attempt_state( + cosmos_messages_container, context['conversation_id'], question_id, 'interrupted', + error='Planning was interrupted. Retry this question to create another plan.', + expected_states={'planning'}, + ) + finally: + close = getattr(events, 'close', None) + if callable(close): + close() def _plan_replay(): @@ -1459,19 +1761,26 @@ def _shared_turn_details(data): return {key: value for key, value in details.items() if value} -def _mirror_planned_turn(conversation_id, user_id, message_id, turn_context): - """Show a shared conversation's orchestrated question to everyone; the plan never waits on it.""" +def _mirror_planned_turn(conversation_id, user_id, message_id, turn_context, *, required=False): + """Admission can require a durable shared copy before acknowledging the question.""" try: conversation = cosmos_conversations_container.read_item( item=conversation_id, partition_key=conversation_id, ) if not is_orchestration_backing(conversation) or conversation.get('user_id') != user_id: return - message = cosmos_messages_container.read_item(item=message_id, partition_key=conversation_id) + message = public_planning_message( + cosmos_messages_container.read_item(item=message_id, partition_key=conversation_id), + ) sender = normalize_collaboration_user(get_current_user_info() or {}) or {'user_id': user_id} mirror_orchestration_turn(conversation, message, sender, details=turn_context.get('collaboration')) except Exception as exc: log_mirror_failure('question', exc) + if required: + raise PlanningStateError( + 'The question could not be posted to the shared conversation. Retry to save it there.', + code='planning_mirror_unavailable', status_code=503, + ) from exc def _elicitation_outcome_events(outcome, turn_context, user_id, conversation_id): @@ -1509,6 +1818,7 @@ def _elicitation_outcome_events(outcome, turn_context, user_id, conversation_id) def _persist_planned_turn( plan, turn_context, user_id, conversation_id, submission=None, expected_previous_run=None, + before_commit=None, ): if turn_context.get('requires_fresh_review'): plan['requires_fresh_review'] = True @@ -1534,6 +1844,8 @@ def _persist_planned_turn( turn_context['user_message_id'] = message_id turn_context['user_message_fingerprint'] = fingerprint _mirror_planned_turn(conversation_id, user_id, message_id, turn_context) + if before_commit is not None: + before_commit() create_orchestration_run( plan, user_id, conversation_id=conversation_id, idempotent=True, turn_context=turn_context, expected_previous_run=expected_previous_run, @@ -1553,20 +1865,15 @@ def _persist_planned_turn( def _save_turn_message( conversation_id, user_id, turn_id, message, previous=None, prompt_selection=None, - content_check=None, image_references=None, + content_check=None, image_references=None, planning=None, ): """A stable ID makes retries and revised plans reuse their original user message.""" _authorize_context_conversation(conversation_id, user_id) message_id = (previous or {}).get('user_message_id') or ( - f"user_orchestration_{uuid.uuid5(uuid.NAMESPACE_URL, f'{conversation_id}:{turn_id}').hex}" + planning_question_id(conversation_id, turn_id) ) - try: - stored = cosmos_messages_container.read_item( - item=message_id, partition_key=conversation_id - ) - except CosmosResourceNotFoundError: - stored = None - if stored is not None: + + def validate(stored): normalized = normalize_history_message(stored) if ( not normalized or normalized['role'] != 'user' @@ -1578,6 +1885,16 @@ def _save_turn_message( ) ): raise ConversationContextError('This turn changed. Submit a new request.') + return normalized + + try: + stored = cosmos_messages_container.read_item( + item=message_id, partition_key=conversation_id + ) + except CosmosResourceNotFoundError: + stored = None + if stored is not None: + normalized = validate(stored) if content_check or image_references: metadata = stored.setdefault("metadata", {}) if content_check: @@ -1604,15 +1921,25 @@ def _save_turn_message( metadata[CHECK_METADATA] = deepcopy(content_check) if image_references: metadata['image_references'] = deepcopy(image_references) + if planning is not None: + metadata[PLANNING_METADATA] = deepcopy(planning) + + def create_question(document): + try: + cosmos_messages_container.create_item(document) + except exceptions.CosmosResourceExistsError: + validate(cosmos_messages_container.read_item(item=message_id, partition_key=conversation_id)) + saved = _save_message( conversation_id, 'user', message, metadata=metadata, message_id=message_id, + persist=create_question, ) if not saved: raise ConversationContextError('The user message could not be saved.') stored = cosmos_messages_container.read_item(item=saved, partition_key=conversation_id) - return saved, normalize_history_message(stored)['fingerprint'] + return saved, validate(stored)['fingerprint'] def _sum_token_usage(*usages): @@ -2293,6 +2620,115 @@ def regenerate_orchestration_message(message_id): payload, status = _regeneration_error(exc) return jsonify(payload), status + @bp.route("/api/v2/orchestration/turns/", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def orchestration_turn_status(turn_id): + """Read an owned question's planning state without starting or approving work.""" + user_id = get_current_user_id() + conversation_id = _text(request.args.get('conversation_id')) + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + if not conversation_id: + return jsonify({'error': 'A conversation id is required.'}), 400 + message_id = _text(request.args.get('message_id')) or planning_question_id(conversation_id, turn_id) + try: + row = _planning_turn_row( + conversation_id, user_id, message_id, turn_id, + include_request=request.args.get('include_request') == '1', + ) + response = jsonify({'turn': row}) + response.headers['Cache-Control'] = 'no-store' + return response + except PlanningStateError as exc: + return jsonify({'error': exc.public_message, 'code': exc.code}), exc.status_code + except ConversationContextError: + return jsonify({'error': 'Conversation not found.'}), 404 + except (PlanRevisionError, PlanValidationError, OrchestrationMemoryError) as exc: + payload, status = _plan_edit_error(exc) + return jsonify(payload), status + except (AzureError, TypeError) as exc: + log_event( + '[ORCHESTRATION] Saved planning status could not be read.', + level=logging.ERROR, extra={'stage': 'planning_status', 'error_type': type(exc).__name__}, + ) + return jsonify({'error': 'Planning status is unavailable. Reload to try again.'}), 503 + + @bp.route("/api/v2/orchestration/turns//cancel", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def orchestration_turn_cancel(turn_id): + """Durably stop a submission, including the interval before its acknowledgement.""" + user_id = get_current_user_id() + data = request.get_json(silent=True) or {} + if not isinstance(data, dict): + return jsonify({'error': 'A stop request must be an object.'}), 400 + conversation_id = _text(data.get('conversation_id')) + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + if not conversation_id: + return jsonify({'error': 'A conversation id is required.'}), 400 + message_id = _text(data.get('message_id')) or planning_question_id(conversation_id, turn_id) + try: + _authorize_context_conversation(conversation_id, user_id) + submission_id = _text(data.get('submission_id')) + try: + _question, current = read_planning_attempt( + cosmos_messages_container, conversation_id, message_id, turn_id, + ) + except PlanningStateError as exc: + if exc.status_code != 404 or not submission_id: + raise + current = None + submission_id = submission_id or current['submission_id'] + if not submission_id or len(submission_id) > 256 or len(turn_id) > 256: + raise PlanningStateError('The stop request is invalid.', code='invalid_request', status_code=400) + record_planning_cancellation( + cosmos_orchestration_runs_container, conversation_id, user_id, turn_id, submission_id, + ) + # Admission or Auto handoff may have advanced since the first read. + try: + _question, current = read_planning_attempt( + cosmos_messages_container, conversation_id, message_id, turn_id, + ) + except PlanningStateError as exc: + if exc.status_code != 404: + raise + current = None + if current is not None and current['submission_id'] == submission_id: + current = cancel_planning_attempt( + cosmos_messages_container, conversation_id, message_id, turn_id, + submission_id=submission_id, + ) + if current.get('run_id'): + target = read_revision_run(current['run_id'], user_id, conversation_id, follow_current=True) + request_cancellation( + target['run_id'], user_id, conversation_id, + lambda: _authorize_context_conversation(conversation_id, user_id), + ) + row = _planning_turn_row( + conversation_id, user_id, message_id, turn_id, include_request=True, + ) if current is not None and current['submission_id'] == submission_id else None + return jsonify({ + 'success': True, 'turn_id': turn_id, 'submission_id': submission_id, + **({'turn': row} if row is not None else {}), + }) + except PlanningStateError as exc: + return jsonify({'error': exc.public_message, 'code': exc.code}), exc.status_code + except ConversationContextError: + return jsonify({'error': 'Conversation not found.'}), 404 + except (PlanRevisionError, PlanValidationError, CheckpointError) as exc: + payload, status = _plan_edit_error(exc) + return jsonify(payload), status + except (AzureError, RecoveryError, TypeError) as exc: + log_event( + '[ORCHESTRATION] Planning stop could not be confirmed.', + level=logging.ERROR, extra={'stage': 'planning_cancel', 'error_type': type(exc).__name__}, + ) + return jsonify({'error': 'Stop could not be confirmed. Retry Stop or check saved status.'}), 503 + @bp.route("/api/v2/orchestration/plan", methods=["POST"]) @swagger_route(security=get_auth_security()) @login_required @@ -2532,6 +2968,7 @@ def orchestration_plan(): # restores the original turn's canonical seeds. identity = _request_identity(user_id) planner_model = None + planning_holder = {} def close_planning_resources(): try: @@ -2542,17 +2979,8 @@ def close_planning_resources(): def generate(): nonlocal turn_context, seeds, answered_record, approval_mode, replan_hint, planner_model + resolved_conversation_id = conversation_id try: - if submission and submission.get('outcome'): - outcome = submission['outcome'] - _validate_turn_memory_context(turn_context, user_id, conversation_id) - if outcome['kind'] == 'plan': - _persist_planned_turn( - outcome['document'], turn_context, user_id, conversation_id, submission, - ) - complete_elicitation_submission(submission) - yield from _elicitation_outcome_events(outcome, turn_context, user_id, conversation_id) - return if submission: _authorize_context_conversation(conversation_id, user_id) resolved_conversation_id, created = conversation_id, False @@ -2573,12 +3001,99 @@ def generate(): return if input_check.metadata: turn_context[CHECK_METADATA] = input_check.metadata + turn_context.update({'conversation_id': resolved_conversation_id, 'revision': revision}) + if revision and not submission and not turn_context.get('regeneration_of_run_id'): + previous_selection = get_latest_turn_run(resolved_conversation_id, user_id, turn_id) + if previous_selection: + if previous_selection.get('user_message') != message: + raise ConversationContextError('This turn changed. Submit a new request.') + _restore_turn_selections(turn_context, previous_selection) + seeds = turn_context['seeds'] + answered_record = turn_context['answered_questions'] + prepared = new_planning_attempt( + _planning_request(turn_context, data), turn_id, + _text(data.get('planning_submission_id')) or _text(data.get('elicitation_submission_id')) + or f'{turn_id}:{revision}', + revision=revision, + ) + message_id, message_fingerprint = _save_turn_message( + resolved_conversation_id, user_id, turn_id, message, + previous=turn_context, prompt_selection=turn_context.get('prompt_selection'), + content_check=input_check.metadata, + image_references=image_reference_provenance_from_seeds(seeds), + planning=prepared, + ) + claim, owned = claim_planning_attempt( + cosmos_messages_container, resolved_conversation_id, message_id, prepared, + retry=turn_context.get('requires_fresh_review') is True, continuation=is_reply, + retry_failed_submission=is_reply or not data.get('planning_submission_id'), + ) + planning_holder.update({ + 'conversation_id': resolved_conversation_id, 'user_id': user_id, + 'turn_id': turn_id, 'message_id': message_id, 'claim_id': claim['claim_id'], + 'owned': owned, + 'submission_id': claim['submission_id'], + }) + _authorize_context_conversation(resolved_conversation_id, user_id) + if planning_was_cancelled( + cosmos_orchestration_runs_container, resolved_conversation_id, user_id, + turn_id, claim['submission_id'], + ): + cancel_planning_attempt( + cosmos_messages_container, resolved_conversation_id, message_id, turn_id, + submission_id=claim['submission_id'], + ) + planning_holder['owned'] = owned = False + turn_context['user_message_id'] = message_id + turn_context['user_message_fingerprint'] = message_fingerprint + if owned: + saved_model = { + key: claim['request'][key] for key in MODEL_FIELDS if claim['request'].get(key) + } + if saved_model and not seeds.get('agent') and seeds.get('model_routing') != 'auto': + seeds = {**seeds, 'model': saved_model} + turn_context['seeds'] = seeds + _touch_conversation(resolved_conversation_id, user_id) + _mirror_planned_turn( + resolved_conversation_id, user_id, message_id, turn_context, required=True, + ) + log_event( + '[ORCHESTRATION] Initial planning question saved.', + extra={ + **workflow_log_context(conversation_id=resolved_conversation_id, turn_id=turn_id), + 'stage': 'planning_admission', 'status': 'saved', + }, + ) if created or not conversation_id: # Announced with the same event the chat stream uses, so the client # adopts a new conversation's id by the path it already knows. yield build_conversation_metadata_event( resolved_conversation_id, _text(message, 80) ) + yield serialize_sse({ + **build_user_message_persisted_stream_payload(resolved_conversation_id, message_id), + 'turn_id': turn_id, 'server_managed': True, + 'planning_submission_id': claim['submission_id'], + }) + if not owned: + yield serialize_sse({ + 'type': 'orchestration_turn', 'done': True, + 'turn': _planning_turn_row( + resolved_conversation_id, user_id, message_id, turn_id, include_request=True, + ), + }) + return + if submission and submission.get('outcome'): + outcome = submission['outcome'] + _validate_turn_memory_context(turn_context, user_id, resolved_conversation_id) + if outcome['kind'] == 'plan': + _persist_planned_turn( + outcome['document'], turn_context, user_id, resolved_conversation_id, submission, + before_commit=lambda: _require_planning_owner(planning_holder), + ) + complete_elicitation_submission(submission) + yield from _elicitation_outcome_events(outcome, turn_context, user_id, resolved_conversation_id) + return observed_pending = None current_revision = revision @@ -2634,18 +3149,13 @@ def generate(): raise ConversationContextError('This turn changed. Submit a new request.') snapshot = _conversation_context_for_run(previous, user_id, settings) current_revision = max(revision, int(previous.get('revision') or 0) + 1) - for key in ( - 'user_message_id', 'user_message_fingerprint', 'seeds', 'original_seeds', - 'answered_questions', 'prompt_selection', 'memory_audience', 'memory_scope', - 'time_zone', - ): - if key in previous: - turn_context[key] = deepcopy(previous[key]) + _restore_turn_selections(turn_context, previous) seeds = turn_context['seeds'] answered_record = turn_context['answered_questions'] else: snapshot = _load_conversation_snapshot( resolved_conversation_id, user_id, settings, turn_id=turn_id, + before_message_id=message_id, ) turn_context['conversation_context'] = snapshot turn_context['revision'] = current_revision @@ -2678,6 +3188,9 @@ def generate(): **(turn_context.get('original_seeds') or seeds), 'model': dict(seeds['model']), } + frozen_request = _planning_request(turn_context, data) + frozen_request.pop('message') + _update_planning_owner(planning_holder, request=frozen_request, revision=current_revision) except (ValueError, PermissionError, PlannerError, AzureError) as exc: log_event( '[ORCHESTRATION] The planner model could not be selected.', @@ -2794,6 +3307,7 @@ def generate(): 'required': ['clarification'], }, }, run_id=None, revision=current_revision) + _require_planning_owner(planning_holder) outcome = _save_pending_elicitation( elicitation, turn_context, user_id, resolved_conversation_id, turn_id, submission=submission, expected_pending=observed_pending, @@ -2977,6 +3491,7 @@ def generate(): turn_context['planning_token_usage'] = planning_usage if kind == 'elicitation': plan['revision'] = current_revision + _require_planning_owner(planning_holder) outcome = _save_pending_elicitation( plan, turn_context, user_id, resolved_conversation_id, turn_id, submission=submission, expected_pending=observed_pending, @@ -2993,6 +3508,7 @@ def generate(): _persist_planned_turn( plan, turn_context, user_id, resolved_conversation_id, submission, expected_previous_run=planning_base, + before_commit=lambda: _require_planning_owner(planning_holder), ) if submission: complete_elicitation_submission(submission) @@ -3000,6 +3516,8 @@ def generate(): outcome, turn_context, user_id, resolved_conversation_id, ) + except PlanningStateError as exc: + yield serialize_sse({'error': exc.public_message, 'code': exc.code, 'done': True}) except PlanRevisionError as exc: payload, _status = _plan_edit_error(exc) yield serialize_sse(payload) @@ -3050,9 +3568,10 @@ def generate(): finally: close_planning_resources() - streamed = _sse(stream_with_context(_regeneration_planning_events(generate(), turn_context))) - streamed.call_on_close(close_planning_resources) - return streamed + return _stream_planning( + _regeneration_planning_events(generate(), turn_context), + planning_holder, turn_context, user_id, settings, _run_saved_plan, + ) @bp.route("/api/v2/orchestration/runs//editor", methods=["GET"]) @swagger_route(security=get_auth_security()) @@ -3236,12 +3755,8 @@ def validate_retry(record): ) return jsonify({'error': 'Saved progress could not be verified. Please retry.', 'code': 'recovery_unavailable'}), 503 - @bp.route("/api/v2/orchestration/run", methods=["POST"]) - @swagger_route(security=get_auth_security()) - @login_required - @user_required - def orchestration_run(): - """Execute an approved plan, streaming step progress and the answer.""" + def _run_saved_plan(data): + """One execution-admission boundary for reviewed and server-owned Auto plans.""" settings = get_settings() if not _orchestration_enabled(settings): return jsonify({'error': 'Chat orchestration is not enabled.'}), 403 @@ -3250,7 +3765,6 @@ def orchestration_run(): if not user_id: return jsonify({'error': 'User not authenticated'}), 401 - data = request.get_json(silent=True) or {} if not isinstance(data, dict) or 'plan' in data: return jsonify({'error': 'Run the saved plan by its ID, not a submitted plan.'}), 400 run_id = _text(data.get('run_id')) @@ -3419,6 +3933,14 @@ def orchestration_run(): record, data, user_id, settings, snapshot, identity, agent_execution_identity, services, ) + @bp.route("/api/v2/orchestration/run", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def orchestration_run(): + """Execute an approved plan, streaming step progress and the answer.""" + return _run_saved_plan(request.get_json(silent=True) or {}) + @bp.route("/api/v2/orchestration/cancel/", methods=["POST"]) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/v2_ui/src/components/chat/Composer.tsx b/application/v2_ui/src/components/chat/Composer.tsx index 69f87834d..040ff37e0 100644 --- a/application/v2_ui/src/components/chat/Composer.tsx +++ b/application/v2_ui/src/components/chat/Composer.tsx @@ -1529,16 +1529,10 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st stopTyping(); }; - /** - * Stop the work in flight, whichever kind it is. - * - * A plan or run has no server-side cancel endpoint the way a chat stream does, so Stop can - * only abort the reader; the controller settles the thread either way and keeps the partial - * answer. Routed by whether the conversation has an orchestration stream open, so Stop does - * the right thing without the button needing to know which mode produced the work. - */ + /** Stop the visible stream's owner, not another orchestration running in the background. */ const handleStop = () => { - if (activeConversationId && hasActiveOrchestration(activeConversationId)) { + if (activeConversationId && useChatStore.getState().orchestrationSurface + && hasActiveOrchestration(activeConversationId)) { cancelOrchestration(activeConversationId); return; } diff --git a/application/v2_ui/src/components/chat/MessageList.tsx b/application/v2_ui/src/components/chat/MessageList.tsx index 2b16d2016..c6bed4f11 100644 --- a/application/v2_ui/src/components/chat/MessageList.tsx +++ b/application/v2_ui/src/components/chat/MessageList.tsx @@ -82,7 +82,7 @@ import { ImageEditor } from './ImageEditor'; import { ImageReferenceThumbnail } from './ImageReferenceThumbnail'; import { ImageProposalScope } from './ImageProposalContext'; import { normalizeFoundryAuthUrl } from '../../lib/foundryAuth'; -import { messageOrchestrationTurn, savedResponseAttempt, selectedAttemptError } from '../../lib/chatRetryAttempts'; +import { initialPlanningState, messageOrchestrationTurn, savedResponseAttempt, selectedAttemptError } from '../../lib/chatRetryAttempts'; import { answerGeneratedImages, extractProposalSpecs, @@ -1797,6 +1797,9 @@ export function MessageList() { {!here && savedResponseAttempt(message).state === 'prepared' && (

This retry is prepared. Select Retry to generate its response.

)} + {!here && initialPlanningState(message).status === 'cancelled' && ( +

Planning stopped. Your question was saved.

+ )} {activeConversationId && planAnchor === message.id && !(localRetry && !localRetry.admitted) && ( )} diff --git a/application/v2_ui/src/lib/chatRetryAttempts.ts b/application/v2_ui/src/lib/chatRetryAttempts.ts index 62b35d147..8da516846 100644 --- a/application/v2_ui/src/lib/chatRetryAttempts.ts +++ b/application/v2_ui/src/lib/chatRetryAttempts.ts @@ -25,6 +25,23 @@ export interface ResponseAttempt { run_id?: string; } +export function initialPlanningState(message: ChatMessage | undefined): { + status?: string; submissionId?: string; sourceMessageId?: string; + runId?: string; runStarted?: boolean; error?: string; +} { + const value = message?.metadata?.orchestration; + if (!isRecord(value)) return {}; + return { + status: typeof value.planning_status === 'string' ? value.planning_status : undefined, + submissionId: typeof value.planning_submission_id === 'string' ? value.planning_submission_id : undefined, + sourceMessageId: typeof message?.metadata?.source_message_id === 'string' + ? message.metadata.source_message_id : undefined, + runId: typeof value.run_id === 'string' ? value.run_id : undefined, + runStarted: typeof value.planning_run_started === 'boolean' ? value.planning_run_started : undefined, + error: typeof value.planning_error === 'string' ? value.planning_error : undefined, + }; +} + function isRecord(value: unknown): value is Json { return value !== null && typeof value === 'object' && !Array.isArray(value); } @@ -136,6 +153,10 @@ export function retryReplyMetadata(metadata: Json, presentation: RetryPresentati } export function selectedAttemptError(question: ChatMessage, messages: ChatMessage[]): string | undefined { + const planning = initialPlanningState(question); + if ((!planning.runId || planning.error) && ['failed', 'interrupted'].includes(planning.status ?? '')) { + return planning.error || 'Planning did not complete. Your question was saved. You can retry it.'; + } const attempt = savedResponseAttempt(question); if (!['failed', 'interrupted'].includes(attempt.state ?? '')) return undefined; const thread = messageThreadId(question); diff --git a/application/v2_ui/src/lib/orchestration.ts b/application/v2_ui/src/lib/orchestration.ts index 45b617581..7afe135d4 100644 --- a/application/v2_ui/src/lib/orchestration.ts +++ b/application/v2_ui/src/lib/orchestration.ts @@ -789,6 +789,7 @@ export interface OrchestrationPlanRequest extends OrchestrationSeeds { message: string; conversation_id?: string | null; turn_id?: string; + planning_submission_id?: string; elicitation_response?: ElicitationResponse; elicitation_id?: string; elicitation_revision?: number; @@ -841,6 +842,34 @@ export interface PlannedTurnIdentity { user_message_id: string; } +export interface SavedPlanningTurn { + conversation_id: string; + turn_id: string; + user_message_id: string; + submission_id: string; + status: PlanStatus | 'planning' | 'awaiting_clarification' | 'awaiting_review' | 'interrupted'; + updated_at: string; + run_id?: string; + run_started?: boolean; + error?: string; + error_code?: string; + request?: OrchestrationPlanRequest; + elicitation?: Elicitation; +} + +function isSavedPlanningTurn(value: unknown): value is SavedPlanningTurn { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const turn = value as Record; + return ['conversation_id', 'turn_id', 'user_message_id', 'submission_id', 'status', 'updated_at'] + .every((field) => typeof turn[field] === 'string' && Boolean(turn[field])) + && [ + 'planning', 'awaiting_clarification', 'awaiting_review', 'interrupted', + 'draft', 'awaiting_approval', 'approved', 'running', 'waiting', + 'completed', 'failed', 'cancelled', 'superseded', + ].includes(String(turn.status)) + && (turn.run_started === undefined || typeof turn.run_started === 'boolean'); +} + export interface PlanStreamEvent { reasoning_adjustments?: ReasoningResolution[]; type?: 'thought' | 'orchestration_plan' | 'orchestration_elicitation' | string; @@ -849,6 +878,8 @@ export interface PlanStreamEvent { editor?: PlanEditorState; export_catalog?: OrchestrationExportFormat[]; turn_identity?: PlannedTurnIdentity; + server_managed?: boolean; + turn?: SavedPlanningTurn; done?: boolean; error?: string; details?: unknown; @@ -888,11 +919,14 @@ export interface PlanStreamHandlers { * new conversation's id by the path it already knows rather than a second mechanism. */ onConversationMetadata?: (event: ChatStreamEvent) => void; + onUserMessagePersisted?: (event: ChatStreamEvent) => void; + onTurn?: (turn: SavedPlanningTurn) => void; /** The planner produced a plan; the stream is over. */ onPlan?: ( plan: OrchestrationPlan, exportCatalog?: OrchestrationExportFormat[], turnIdentity?: PlannedTurnIdentity, + serverManaged?: boolean, ) => void; /** The planner asked a question instead of planning; the stream is over. */ onElicitation?: (elicitation: Elicitation) => void; @@ -965,6 +999,51 @@ export const ORCHESTRATION_PLAN_PATH = '/api/v2/orchestration/plan'; export const ORCHESTRATION_RUN_PATH = '/api/v2/orchestration/run'; export const ORCHESTRATION_RUNS_PATH = '/api/v2/orchestration/runs'; +export async function fetchOrchestrationTurn( + conversationId: string, + turnId: string, + options: { messageId?: string; includeRequest?: boolean; signal?: AbortSignal } = {}, +): Promise { + const params = new URLSearchParams({ conversation_id: conversationId }); + if (options.messageId) params.set('message_id', options.messageId); + if (options.includeRequest) params.set('include_request', '1'); + const payload = await api.get<{ turn: SavedPlanningTurn }>( + `/api/v2/orchestration/turns/${encodeURIComponent(turnId)}?${params}`, options.signal, + ); + if (!isSavedPlanningTurn(payload.turn) + || payload.turn.conversation_id !== conversationId || payload.turn.turn_id !== turnId + || (options.messageId && payload.turn.user_message_id !== options.messageId) + || (options.includeRequest && ( + !payload.turn.request || typeof payload.turn.request.message !== 'string' + || payload.turn.request.conversation_id !== conversationId || payload.turn.request.turn_id !== turnId + ))) { + throw new Error('The saved planning status did not match this question.'); + } + return payload.turn; +} + +export async function cancelOrchestrationTurn( + conversationId: string, turnId: string, submissionId: string, messageId?: string, +): Promise { + const result = await api.post<{ + success: boolean; turn_id: string; submission_id: string; turn?: SavedPlanningTurn; + }>( + `/api/v2/orchestration/turns/${encodeURIComponent(turnId)}/cancel`, + { + conversation_id: conversationId, submission_id: submissionId, + ...(messageId && !messageId.startsWith('pending-user-') ? { message_id: messageId } : {}), + }, + ); + if (!result.success || result.turn_id !== turnId || result.submission_id !== submissionId) { + throw new Error('Stop could not be confirmed for this planning submission.'); + } + if (result.turn && (!isSavedPlanningTurn(result.turn) + || result.turn.conversation_id !== conversationId || result.turn.turn_id !== turnId)) { + throw new Error('Stop returned an invalid saved planning status.'); + } + return result.turn ?? null; +} + /* -------------------------------------------------------------------------- */ /* Stored runs */ /* -------------------------------------------------------------------------- */ @@ -1334,6 +1413,22 @@ async function readPlanStream( return false; } + if (event.type === 'user_message_persisted') { + handlers.onUserMessagePersisted?.(event as ChatStreamEvent); + return false; + } + + if (event.type === 'orchestration_turn') { + if (!isSavedPlanningTurn(event.turn)) { + result.errored = true; + handlers.onError?.('The saved planning status was invalid.'); + } else { + result.completed = true; + handlers.onTurn?.(event.turn); + } + return true; + } + const editor = event.editor ? { ...event.editor, ...(Array.isArray(event.export_catalog) ? { export_catalog: event.export_catalog } : {}), @@ -1355,7 +1450,7 @@ async function readPlanStream( } result.completed = true; if (result.plan) { - handlers.onPlan?.(result.plan, event.export_catalog, event.turn_identity); + handlers.onPlan?.(result.plan, event.export_catalog, event.turn_identity, event.server_managed === true); } return true; } diff --git a/application/v2_ui/src/lib/orchestrationController.ts b/application/v2_ui/src/lib/orchestrationController.ts index f3f9f4c23..ab57f8067 100644 --- a/application/v2_ui/src/lib/orchestrationController.ts +++ b/application/v2_ui/src/lib/orchestrationController.ts @@ -23,6 +23,8 @@ import { legacyPlanErrorMessage } from './orchestrationErrors'; import { beginPlanEdit, cancelOrchestrationRun, + cancelOrchestrationTurn, + fetchOrchestrationTurn, fetchOrchestrationRun, fetchRunSteps, isOrchestrationRunPending, @@ -52,11 +54,15 @@ import { type RunStreamEvent, type PersistedRun, type PlanEdits, + type SavedPlanningTurn, } from './orchestration'; import { applyPlanEdits, doneFrameHasGeneratedImages, isPlanApproved, isPlanAwaitingApproval, isPlanRunnable, normalizePlan, planHasApprovalFloor, + neutralizeTimedApproval, } from './orchestrationPlan'; +import { initialPlanningState, messageOrchestrationTurn } from './chatRetryAttempts'; +import { isOwnMessage } from './sharedMessage'; import type { Json } from './types'; import { normalizeReasoningAdjustments, type ReasoningResolution } from './reasoning'; import { applyOrchestrationOutputEvent } from './orchestrationOutputController'; @@ -158,6 +164,8 @@ interface TurnContext { revision: number; pendingUserMessageId: string; requiresFreshReview?: boolean; + submissionId?: string; + serverManaged?: boolean; } const turnContexts = new Map(); @@ -188,6 +196,211 @@ const activeControllers = new Map(); const activeRunIds = new Map(); /** Editor requests never abort, begin, settle, or append a main-thread turn. */ const editorControllers = new Map(); +interface PlanningWatch { + turnId: string; + messageId: string; + submissionId: string; + timer?: ReturnType; + checking: boolean; + startedHere?: boolean; +} +const planningWatches = new Map(); +const consideredPlanningViews = new Map(); + +function stopPlanningWatch(conversationId: string): void { + const watch = planningWatches.get(conversationId); + if (watch?.timer) clearTimeout(watch.timer); + planningWatches.delete(conversationId); +} + +function planningViewKey(messageId: string, submissionId: string): string { + return `${currentConversationEpoch()}:${messageId}:${submissionId}`; +} + +function mayOwnPlanningSurface(conversationId: string): boolean { + const chat = useChatStore.getState(); + return chat.activeConversationId !== conversationId || !chat.streaming || Boolean(chat.orchestrationSurface); +} + +function updateQuestionPlanning(turn: Pick): void { + useChatStore.setState((state) => { + if (state.activeConversationId !== turn.conversation_id) return state; + let changed = false; + const messages = state.messages.map((message) => { + if (message.role !== 'user' || message.conversation_id !== turn.conversation_id + || messageOrchestrationTurn(message) !== turn.turn_id) return message; + const value = message.metadata?.orchestration; + const previous = value && typeof value === 'object' && !Array.isArray(value) ? value as Json : {}; + if (previous.planning_status === turn.status && previous.planning_submission_id === turn.submission_id + && previous.run_id === turn.run_id && previous.planning_error === turn.error + && previous.planning_run_started === turn.run_started) return message; + const { run_id: _run, planning_error: _error, planning_run_started: _started, ...retained } = previous; + changed = true; + return { + ...message, metadata: { + ...message.metadata, orchestration: { + ...retained, turn_id: turn.turn_id, planning_status: turn.status, + planning_submission_id: turn.submission_id, + ...(turn.run_id ? { run_id: turn.run_id } : {}), + ...(turn.error ? { planning_error: turn.error } : {}), + ...(turn.run_started !== undefined ? { planning_run_started: turn.run_started } : {}), + }, + }, + }; + }); + return changed ? { messages } : state; + }); +} + +function rememberPlanningContext(turn: SavedPlanningTurn): void { + if (!turn.request) return; + const { + message, approval_mode, revision, conversation_id: _conversation, + turn_id: _turn, planning_submission_id: _submission, ...seeds + } = turn.request; + turnContexts.set(scopeKey(turn.conversation_id, turn.turn_id), { + message, seeds: structuredClone(seeds), + approvalMode: approval_mode === 'auto' || approval_mode === 'timed' ? approval_mode : 'manual', + revision: revision ?? 0, pendingUserMessageId: turn.user_message_id, + requiresFreshReview: turn.request.requires_fresh_review === true, + submissionId: turn.submission_id, serverManaged: true, + }); +} + +async function applySavedPlanningTurn(conversationId: string, watch: PlanningWatch, turn: SavedPlanningTurn): Promise { + if (planningWatches.get(conversationId) !== watch) return; + if (turn.submission_id !== watch.submissionId && watch.submissionId) { + watch.submissionId = turn.submission_id; + } + watch.messageId = turn.user_message_id; + rememberPlanningContext(turn); + updateQuestionPlanning(turn); + const store = useOrchestrationStore.getState(); + store.setActiveTurn(conversationId, turn.turn_id); + const chat = useChatStore.getState(); + if (turn.status === 'planning') { + if (mayOwnPlanningSurface(conversationId) + && (chat.activeConversationId !== conversationId || !chat.streaming || chat.orchestrationSurface !== 'planning')) { + chat.beginOrchestrationTurn(conversationId, turn.request?.message ?? '', false, turn.turn_id); + } + return; + } + if (turn.status === 'awaiting_clarification' && turn.elicitation) { + store.setElicitation(conversationId, turn.turn_id, turn.elicitation); + chat.settleOrchestrationTurn(conversationId, { turnId: turn.turn_id, status: 'planned' }); + stopPlanningWatch(conversationId); + return; + } + if (turn.run_id && (turn.run_started + || ['awaiting_review', 'running', 'waiting', 'completed'].includes(turn.status))) { + const record = await loadOrchestrationRecovery(conversationId, turn.run_id); + const steps = await fetchRunSteps(turn.run_id, { conversationId }); + if (!record || planningWatches.get(conversationId) !== watch) return; + const awaitingReview = turn.status === 'awaiting_review'; + const plan = persistedRunPlan(record); + const normalized = normalizePlan(plan); + if (!normalized) throw new Error('The saved plan could not be restored.'); + const current = useOrchestrationStore.getState(); + current.adoptPersistedPlan( + conversationId, turn.turn_id, awaitingReview ? neutralizeTimedApproval(plan) : plan, + steps, { readOnly: !awaitingReview }, + ); + current.setActiveTurn(conversationId, turn.turn_id); + chat.settleOrchestrationTurn(conversationId, { turnId: turn.turn_id, status: 'planned' }); + stopPlanningWatch(conversationId); + if (!awaitingReview) { + const tracked = { + conversationId, turnId: turn.turn_id, runId: turn.run_id, + planId: normalized.plan_id, startedAt: Date.now(), + }; + if (watch.startedHere) current.beginRun(tracked); + else current.restoreRuns([{ ...tracked, resumed: true }]); + if (isOrchestrationRunPending(record) && !isOrchestrationRunWaiting(record) + && mayOwnPlanningSurface(conversationId)) { + useChatStore.getState().beginOrchestrationTurn( + conversationId, turn.request?.message ?? '', false, turn.turn_id, undefined, 'running', + ); + } + void reconcileOrchestrationRun(conversationId, turn.run_id); + } + return; + } + if (turn.status === 'cancelled') { + chat.settleOrchestrationTurn(conversationId, { turnId: turn.turn_id, status: 'cancelled', accumulated: '' }); + } else { + const error = turn.error || 'Planning was interrupted. Your question was saved. Retry to create a new plan.'; + updateQuestionPlanning({ ...turn, error }); + chat.settleOrchestrationTurn(conversationId, { turnId: turn.turn_id, status: 'failed', error }); + if (useChatStore.getState().activeConversationId === conversationId) { + useChatStore.setState({ streamError: null }); + } + } + stopPlanningWatch(conversationId); +} + +async function checkPlanningWatch(conversationId: string, watch: PlanningWatch): Promise { + if (watch.checking || planningWatches.get(conversationId) !== watch) return; + watch.checking = true; + try { + const turn = await fetchOrchestrationTurn(conversationId, watch.turnId, { + messageId: watch.messageId.startsWith('pending-user-') ? undefined : watch.messageId, + includeRequest: true, + }); + await applySavedPlanningTurn(conversationId, watch, turn); + } catch (error) { + if (planningWatches.get(conversationId) !== watch) return; + const chat = useChatStore.getState(); + const unavailable = error instanceof ApiError && error.status === 404; + if (unavailable) { + stopPlanningWatch(conversationId); + chat.settleOrchestrationTurn(conversationId, { + turnId: watch.turnId, status: 'failed', + error: 'This saved planning request is no longer available. Reload the conversation.', + }); + } + if (chat.activeConversationId === conversationId) { + useChatStore.setState({ + streamError: unavailable + ? 'This saved planning request is no longer available. Reload the conversation.' + : 'Saved planning status could not be reached. Work may still be running. Checking again; no retry will start.', + }); + } + } finally { + watch.checking = false; + } + if (planningWatches.get(conversationId) === watch) { + watch.timer = setTimeout(() => { void checkPlanningWatch(conversationId, watch); }, 3000); + } +} + +function followPlanningTurn(conversationId: string, turnId: string, messageId: string, submissionId: string): void { + stopPlanningWatch(conversationId); + const watch: PlanningWatch = { turnId, messageId, submissionId, checking: false, startedHere: true }; + planningWatches.set(conversationId, watch); + void checkPlanningWatch(conversationId, watch); +} + +/** History, not sessionStorage, identifies an accepted initial turn after a fresh load. */ +export async function resumeOrchestrationPlanning(conversationId: string): Promise { + const chat = useChatStore.getState(); + if (!conversationId || chat.activeConversationId !== conversationId || planningWatches.has(conversationId)) return; + const question = [...chat.messages].reverse().find((message) => message.role === 'user'); + const state = initialPlanningState(question); + const turnId = messageOrchestrationTurn(question); + if (!question || !turnId || !state.status || state.status === 'completed') return; + if (chat.activeConversationKind === 'collaborative' + && !isOwnMessage(question, useBootstrapStore.getState().data?.user?.id)) return; + const key = planningViewKey(question.id, state.submissionId ?? ''); + if (consideredPlanningViews.get(conversationId) === key || activeControllers.has(conversationId)) return; + consideredPlanningViews.set(conversationId, key); + const watch: PlanningWatch = { + turnId, messageId: state.sourceMessageId ?? question.id, + submissionId: state.submissionId ?? '', checking: false, + }; + planningWatches.set(conversationId, watch); + await checkPlanningWatch(conversationId, watch); +} export type ElicitationSubmitResult = { ok: true } | { ok: false; error: string }; @@ -293,6 +506,8 @@ async function dispatchPlan( // these only ever move on a genuine disagreement — the helpers below make that move safe. let currentConversationId = conversationId; let currentTurnId = turnId; + stopPlanningWatch(conversationId); + context.submissionId = continuation?.submissionId ?? makeTurnId(); const key = scopeKey(currentConversationId, currentTurnId); turnContexts.set(key, context); @@ -409,6 +624,7 @@ async function dispatchPlan( turn_id: currentTurnId, revision: context.revision, approval_mode: context.approvalMode, + planning_submission_id: context.submissionId, ...(context.requiresFreshReview ? { requires_fresh_review: true } : {}), }; const timeZone = requestTimeZone(); @@ -424,6 +640,7 @@ async function dispatchPlan( body.elicitation_id = continuation.elicitationId; body.elicitation_revision = continuation.revision; body.elicitation_submission_id = continuation.submissionId; + delete body.elicitation_context; if (continuation.response.action === 'accept' && continuation.context) { body.elicitation_context = continuation.context; } @@ -432,6 +649,8 @@ async function dispatchPlan( let produced = false; let errored = false; let failure = ''; + let needsRecovery = false; + const adoption: { turn: SavedPlanningTurn | null } = { turn: null }; let reasoningAdjustments: ReasoningResolution[] = []; const isCurrentRequest = () => !controller.signal.aborted && activeControllers.get(currentConversationId) === controller; @@ -459,7 +678,39 @@ async function dispatchPlan( event.conversation_title ?? event.title, ); }, - onPlan: (plan, exportCatalog, turnIdentity) => { + onUserMessagePersisted: (event) => { + if (!isCurrentRequest() || typeof event.user_message_id !== 'string' + || !event.user_message_id || typeof event.conversation_id !== 'string') return; + adoptServerConversationId(event.conversation_id, undefined); + adoptServerTurnId(typeof event.turn_id === 'string' ? event.turn_id : undefined); + useChatStore.getState().reconcileOrchestrationUserMessage({ + conversationId: currentConversationId, turnId: currentTurnId, + optimisticId: context.pendingUserMessageId || pendingUserMessageId, + savedId: event.user_message_id, + }); + context.pendingUserMessageId = event.user_message_id; + context.serverManaged = event.server_managed === true; + if (typeof event.planning_submission_id === 'string' && event.planning_submission_id) { + context.submissionId = event.planning_submission_id; + } + if (context.serverManaged) { + updateQuestionPlanning({ + conversation_id: currentConversationId, turn_id: currentTurnId, + submission_id: context.submissionId ?? '', status: 'planning', + }); + if (useChatStore.getState().activeConversationId === currentConversationId) { + consideredPlanningViews.set(currentConversationId, + planningViewKey(event.user_message_id, context.submissionId ?? '')); + } + } + }, + onTurn: (turn) => { + if (!isCurrentRequest() || turn.conversation_id !== currentConversationId + || turn.turn_id !== currentTurnId) return; + adoption.turn = turn; + produced = true; + }, + onPlan: (plan, exportCatalog, turnIdentity, serverManaged) => { if (!isCurrentRequest()) { return; } @@ -494,6 +745,14 @@ async function dispatchPlan( context.pendingUserMessageId = turnIdentity.user_message_id; } context.revision = normalized.revision ?? context.revision; + context.serverManaged ||= serverManaged === true; + if (context.serverManaged) { + updateQuestionPlanning({ + conversation_id: currentConversationId, turn_id: currentTurnId, + submission_id: context.submissionId ?? '', run_id: normalized.run_id, + status: normalized.approval.mode === 'auto' ? 'planning' : 'awaiting_review', + }); + } useOrchestrationStore.getState().setPlan(currentConversationId, currentTurnId, { ...normalized, reasoning_adjustments: normalizeReasoningAdjustments([ @@ -514,17 +773,34 @@ async function dispatchPlan( } adoptServerTurnId(elicitation.turn_id); context.revision = elicitation.revision ?? context.revision; + if (context.serverManaged) { + updateQuestionPlanning({ + conversation_id: currentConversationId, turn_id: currentTurnId, + submission_id: context.submissionId ?? '', status: 'awaiting_clarification', + }); + } useOrchestrationStore .getState() .setElicitation(currentConversationId, currentTurnId, elicitation); produced = true; }, - onError: (message) => { + onError: (message, error) => { if (!isCurrentRequest()) { return; } + if (context.serverManaged && !error?.code) { + failure = message; + needsRecovery = true; + return; + } errored = true; failure = message; + if (context.serverManaged) { + updateQuestionPlanning({ + conversation_id: currentConversationId, turn_id: currentTurnId, + submission_id: context.submissionId ?? '', status: 'failed', error: message, + }); + } useChatStore .getState() .settleOrchestrationTurn(currentConversationId, { @@ -532,6 +808,9 @@ async function dispatchPlan( status: 'failed', error: message, }); + if (context.serverManaged && useChatStore.getState().activeConversationId === currentConversationId) { + useChatStore.setState({ streamError: null }); + } }, }, controller.signal, @@ -542,6 +821,27 @@ async function dispatchPlan( } activeControllers.delete(currentConversationId); + if (adoption.turn) { + const turn = adoption.turn; + const watch: PlanningWatch = { + turnId: turn.turn_id, messageId: turn.user_message_id, + submissionId: turn.submission_id, checking: false, + }; + planningWatches.set(currentConversationId, watch); + await applySavedPlanningTurn(currentConversationId, watch, turn); + if (planningWatches.get(currentConversationId) === watch) { + void checkPlanningWatch(currentConversationId, watch); + } + return { ok: true }; + } + if (needsRecovery && !controller.signal.aborted) { + followPlanningTurn( + currentConversationId, currentTurnId, + context.pendingUserMessageId, context.submissionId ?? '', + ); + return { ok: true }; + } + if (produced && !errored) { // Auto mode is pre-approved on arrival, so its run starts here rather than waiting for a // click or a countdown — and it starts INSTEAD OF settling `planned`, so the thinking @@ -563,9 +863,16 @@ async function dispatchPlan( isPlanApproved(settledPlan) && isPlanRunnable(settledPlan); if (autoRun) { - void approveAndRunPlan({ - conversationId: currentConversationId, turnId: currentTurnId, automatic: true, - }); + if (context.serverManaged) { + followPlanningTurn( + currentConversationId, currentTurnId, + context.pendingUserMessageId, context.submissionId ?? '', + ); + } else { + void approveAndRunPlan({ + conversationId: currentConversationId, turnId: currentTurnId, automatic: true, + }); + } } else { useChatStore .getState() @@ -646,17 +953,35 @@ export async function planRegeneratedMessage(prepared: AttemptOrchestrationReque ); } -/** Retry only an unsaved planning turn whose original request is still held in this tab. */ +/** Initial planning retries use canonical saved inputs, not today's composer selections. */ export async function retryOrchestrationPlanning( conversationId: string, turnId: string, messageId: string, ): Promise { const chat = useChatStore.getState(); - const context = turnContexts.get(scopeKey(conversationId, turnId)); + let context = turnContexts.get(scopeKey(conversationId, turnId)); const message = chat.messages.find((candidate) => candidate.id === messageId); - if (chat.activeConversationId !== conversationId || !context - || context.pendingUserMessageId !== messageId + const sourceMessageId = initialPlanningState(message).sourceMessageId ?? messageId; + if (message?.conversation_id === conversationId + && (context?.serverManaged || initialPlanningState(message).status)) { + try { + const turn = await fetchOrchestrationTurn(conversationId, turnId, { + messageId: sourceMessageId, includeRequest: true, + }); + if (!['failed', 'interrupted', 'cancelled'].includes(turn.status) || turn.run_started) { + followPlanningTurn(conversationId, turnId, sourceMessageId, turn.submission_id); + return { ok: false, error: 'This request has saved work. Its current status is being restored; no retry was started.' }; + } + rememberPlanningContext(turn); + context = turnContexts.get(scopeKey(conversationId, turnId)); + useOrchestrationStore.getState().clearPlan(conversationId, turnId); + } catch { + return { ok: false, error: 'The saved planning inputs could not be loaded. Reload to check the request; no retry was started.' }; + } + } + if (useChatStore.getState().activeConversationId !== conversationId || !context + || context.pendingUserMessageId !== sourceMessageId || message?.conversation_id !== conversationId || message.role !== 'user' || message.content !== context.message) { return { @@ -679,6 +1004,8 @@ export async function retryOrchestrationPlanning( /** A deleted conversation must not regain a plan from a late response or retained Retry context. */ export function forgetOrchestrationConversation(conversationId: string): void { + stopPlanningWatch(conversationId); + consideredPlanningViews.delete(conversationId); activeControllers.get(conversationId)?.abort(); activeControllers.delete(conversationId); activeRunIds.delete(conversationId); @@ -1467,12 +1794,44 @@ export async function runPreparedOrchestrationRetry(conversationId: string, runI } export async function cancelOrchestration(conversationId: string, runId?: string): Promise { - if (!runId && activeControllers.has(conversationId) && !activeRunIds.has(conversationId)) { - activeControllers.get(conversationId)?.abort(); - return; - } const runs = Object.values(useOrchestrationStore.getState().inFlight) .filter((candidate) => candidate.conversationId === conversationId); + const turnId = selectActiveTurn(useOrchestrationStore.getState(), conversationId); + const context = turnId ? turnContexts.get(scopeKey(conversationId, turnId)) : undefined; + if (!runId && turnId && context?.submissionId && runs.length === 0 + && (context.serverManaged || activeControllers.has(conversationId) || planningWatches.has(conversationId))) { + const controller = activeControllers.get(conversationId); + const submissionId = context.submissionId; + try { + const stopped = await cancelOrchestrationTurn( + conversationId, turnId, submissionId, context.pendingUserMessageId, + ); + if (turnContexts.get(scopeKey(conversationId, turnId))?.submissionId !== submissionId) return; + stopPlanningWatch(conversationId); + if (activeControllers.get(conversationId) === controller) controller?.abort(); + if (stopped?.run_id && stopped.run_started) { + followPlanningTurn(conversationId, turnId, stopped.user_message_id, stopped.submission_id); + return; + } + if (context.serverManaged || !context.pendingUserMessageId.startsWith('pending-user-')) { + updateQuestionPlanning({ + conversation_id: conversationId, turn_id: turnId, + submission_id: submissionId, status: 'cancelled', + }); + } + useChatStore.getState().settleOrchestrationTurn(conversationId, { + turnId, status: 'cancelled', accumulated: '', + }); + } catch { + if (useChatStore.getState().activeConversationId === conversationId + && turnContexts.get(scopeKey(conversationId, turnId))?.submissionId === submissionId) { + useChatStore.setState({ + streamError: 'Stop could not be confirmed. Work may still be running. Retry Stop or check saved status.', + }); + } + } + return; + } const targetId = runId ?? activeRunIds.get(conversationId); const run = targetId ? runs.find((candidate) => candidate.runId === targetId) : runs.sort((left, right) => right.startedAt - left.startedAt)[0]; @@ -1503,6 +1862,7 @@ export async function cancelOrchestration(conversationId: string, runId?: string /** Whether a plan or run is streaming for a conversation, so Stop can route to the right cancel. */ export function hasActiveOrchestration(conversationId: string): boolean { return activeControllers.has(conversationId) + || planningWatches.has(conversationId) || Object.values(useOrchestrationStore.getState().inFlight).some((run) => run.conversationId === conversationId); } diff --git a/application/v2_ui/src/lib/orchestrationPlan.ts b/application/v2_ui/src/lib/orchestrationPlan.ts index 193ef12c2..77914a791 100644 --- a/application/v2_ui/src/lib/orchestrationPlan.ts +++ b/application/v2_ui/src/lib/orchestrationPlan.ts @@ -55,6 +55,16 @@ import { normalizeReasoningAdjustments } from './reasoning'; export const DOCUMENT_ARRAY_FIELDS = ['document_ids', 'right_document_ids', 'reference_document_ids', 'reference_message_ids'] as const; export const REFERENCE_ARRAY_FIELDS = ['reference_document_ids', 'reference_message_ids'] as const; +/** Reopening a saved proposal is never a new timed or automatic approval. */ +export function neutralizeTimedApproval(plan: Json): Json { + if (!plan || typeof plan !== 'object' || Array.isArray(plan)) return plan; + const approval = plan.approval; + if (!approval || typeof approval !== 'object' || Array.isArray(approval)) return plan; + const record = approval as Record; + if (record.mode !== 'timed' && record.mode !== 'auto') return plan; + return { ...plan, approval: { ...record, mode: 'manual', timeout_seconds: 0 } }; +} + const STEP_STATUSES: readonly StepStatus[] = [ 'pending', 'running', diff --git a/application/v2_ui/src/lib/orchestrationResume.ts b/application/v2_ui/src/lib/orchestrationResume.ts index 832b8f23a..03507a112 100644 --- a/application/v2_ui/src/lib/orchestrationResume.ts +++ b/application/v2_ui/src/lib/orchestrationResume.ts @@ -29,7 +29,11 @@ import { fetchConversationRuns, fetchOrchestrationRun, fetchRunSteps, isOrchestrationRunPending, persistedRunPlan, } from './orchestration'; -import { loadOrchestrationRecovery, reconcileOrchestrationRun, refreshOrchestrationPlanEditor } from './orchestrationController'; +import { + loadOrchestrationRecovery, reconcileOrchestrationRun, refreshOrchestrationPlanEditor, + resumeOrchestrationPlanning, +} from './orchestrationController'; +import { neutralizeTimedApproval } from './orchestrationPlan'; import type { Json } from './orchestration'; import { hasPendingOrchestrationOutputs } from './orchestrationOutputs'; @@ -59,6 +63,7 @@ export function resetOrchestrationResume(): void { export async function resumeOrchestrationForConversation( conversationId: string, ): Promise { + await resumeOrchestrationPlanning(conversationId); if (!conversationId || consideredConversations.has(conversationId)) { return; } @@ -218,31 +223,3 @@ function conversationHasReplyAfter(conversationId: string, userMessageId: string .slice(index + 1) .some((message) => message.role === 'assistant'); } - -/** - * Turn a restored timed approval into a manual one. - * - * A timed plan's contract is "this runs unless you stop it in the next N seconds", and that - * bargain was offered to whoever was watching at the time. Restoring it verbatim would restart the - * clock on a device that was not there, and run real work -- searches, documents, model calls -- - * because someone opened a laptop. The plan is restored as an ordinary approval instead: the same - * steps, the same everything else, but it waits to be asked for. - */ -function neutralizeTimedApproval(plan: Json): Json { - if (!plan || typeof plan !== 'object' || Array.isArray(plan)) { - return plan; - } - const record = plan as Record; - const approval = record.approval; - if (!approval || typeof approval !== 'object' || Array.isArray(approval)) { - return plan; - } - const approvalRecord = approval as Record; - if (approvalRecord.mode !== 'timed' && approvalRecord.mode !== 'auto') { - return plan; - } - return { - ...record, - approval: { ...approvalRecord, mode: 'manual', timeout_seconds: 0 }, - } as Json; -} diff --git a/application/v2_ui/src/stores/chatStore.ts b/application/v2_ui/src/stores/chatStore.ts index 50e699758..49481740d 100644 --- a/application/v2_ui/src/stores/chatStore.ts +++ b/application/v2_ui/src/stores/chatStore.ts @@ -140,7 +140,7 @@ import { } from '../lib/chatContext'; import { currentAttempt, messageThreadId } from '../lib/threads'; import { - adoptRetryQuestion, beginRetryPresentation, insertRetryReply, messageOrchestrationTurn, + adoptRetryQuestion, beginRetryPresentation, initialPlanningState, insertRetryReply, messageOrchestrationTurn, retryQuestion, retryReplyMetadata, savedResponseAttempt, updateRetryState, type RetryPresentation, } from '../lib/chatRetryAttempts'; import { withoutDeletedMessages } from '../lib/deletedMessages'; @@ -152,6 +152,7 @@ import { forgetOrchestrationConversation, planRegeneratedMessage, retryOrchestrationPlanning, + resumeOrchestrationPlanning, } from '../lib/orchestrationController'; import { foundryAuthUrl } from '../lib/foundryAuth'; import { announceCompletedReply, type CompletedReply } from '../lib/replyEvents'; @@ -1746,7 +1747,9 @@ export async function resumeActionRecoveryChat(body: ChatStreamRequest): Promise }] }); } useChatStore.setState({ - streaming: true, streamingContent: '', streamError: null, thoughts: [], + streaming: true, + orchestrationSurface: null, + streamingContent: '', streamError: null, thoughts: [], streamAuthUrl: null, streamingReasoningAdjustments: [], }); const { action_recovery: previousDecision, ...pending } = body; @@ -2583,6 +2586,7 @@ export const useChatStore = create((set, get) => ({ return; } set({ messages, messagesLoading: false, publicWorkspaceSelection: publicChatScopeFromMessages(messages) }); + void resumeOrchestrationPlanning(conversationId); const descriptor = latestSavedAnalysis(messages); if (descriptor && !get().analysisContextChosen) { get().selectAnalysisResult(descriptor, analysisRevision); @@ -4051,7 +4055,8 @@ export const useChatStore = create((set, get) => ({ && message.role === 'user' && message.metadata?.orchestration_turn_id === turnId; const optimistic = state.messages.find((message) => matches(message) && message.id === optimisticId); if (!optimistic) return state; - const saved = state.messages.find((message) => message.id === savedId); + const saved = state.messages.find((message) => + message.id === savedId || mirroredSourceId(message) === savedId); if (saved && !matches(saved)) return state; return { messages: state.messages.flatMap((message) => @@ -4277,8 +4282,11 @@ export const useChatStore = create((set, get) => ({ } const message = get().messages.find((candidate) => candidate.id === messageId); const recoveryConversationId = get().activeConversationId; - if (messageId.startsWith('pending-user-')) { - const turnId = message?.metadata?.orchestration_turn_id; + const planning = initialPlanningState(message); + if (messageId.startsWith('pending-user-') || (planning.status && !savedResponseAttempt(message).kind && ( + !planning.runId || (planning.runStarted === false && ['failed', 'interrupted'].includes(planning.status)) + ))) { + const turnId = messageOrchestrationTurn(message); const result = recoveryConversationId && typeof turnId === 'string' && turnId ? await retryOrchestrationPlanning(recoveryConversationId, turnId, messageId) : { diff --git a/docs/explanation/features/CHAT_ORCHESTRATION.md b/docs/explanation/features/CHAT_ORCHESTRATION.md index 48e634dd8..50216edfb 100644 --- a/docs/explanation/features/CHAT_ORCHESTRATION.md +++ b/docs/explanation/features/CHAT_ORCHESTRATION.md @@ -1,6 +1,6 @@ # Chat Orchestration -**Version: 0.261.413** (tracked in `application/single_app/config.py`) +**Version: 0.261.415** (tracked in `application/single_app/config.py`) **Implemented in version: 0.261.086** **Knowledge phase added in version: 0.261.089** @@ -30,6 +30,7 @@ **Private conversation references (`~`), `conversation_search` / `conversation_read`, and queued `conversation_send` handoffs added in version: 0.261.381** **Search evidence citation harness expectations aligned in version: 0.261.399** **Selected-document planning recovery and debug diagnostics fixed/implemented in version: 0.261.413** +**Initial-turn persistence and browser-independent Auto planning implemented in version: 0.261.415** ## Overview @@ -49,6 +50,26 @@ its required companions. This is a V2 interface feature. The classic interface is unchanged. +### Leave and reopen an accepted request + +Fixed/Implemented in version: **0.261.415** +(`application/single_app/config.py`). + +An accepted question is saved before planning completes. Switching conversations +or refreshing does not discard it: reopening restores its planning, follow-up, +review, or failure state. Auto work authorized by the original submission +continues server-side; manual/review plans still wait for approval. A restored +countdown is not a new authorization to run. + +If planning fails, use the saved question's **Retry** action to reuse its original +selections and review a new plan. This initial-planning retry is different from +resuming completed execution checkpoints. A status outage does not silently start +another attempt, and an interrupted worker leaves a recoverable state rather +than permanent Planning. **Stop**, not navigation, requests cancellation. + +See the [initial-turn persistence fix](../fixes/ORCHESTRATION_INITIAL_TURN_PERSISTENCE_FIX.md) +for the storage, worker, cancellation, and recovery boundaries. + Version **0.261.125** added the internal [Gather / Reason / Render result foundation](ORCHESTRATION_GATHER_REASON_RENDER.md) (Refs #1509). In **0.261.139**, Gather / Reason / Render became the only diff --git a/docs/explanation/features/ORCHESTRATION_CHECKPOINT_RECOVERY.md b/docs/explanation/features/ORCHESTRATION_CHECKPOINT_RECOVERY.md index 562f8d418..d0d71c259 100644 --- a/docs/explanation/features/ORCHESTRATION_CHECKPOINT_RECOVERY.md +++ b/docs/explanation/features/ORCHESTRATION_CHECKPOINT_RECOVERY.md @@ -1,6 +1,6 @@ # Orchestration Checkpoint Recovery -**Version: 0.261.358** +**Version: 0.261.415** Implemented in version: **0.261.105**, recorded in `application/single_app/config.py`. @@ -53,6 +53,26 @@ plan. ## Dependencies and configuration +### A failure before a plan exists + +Fixed/Implemented in version: **0.261.415** +(`application/single_app/config.py`). + +Initial planning now saves its accepted question and lifecycle before producing +a plan. A failed initial planning pass therefore remains visible after +navigation or reload. Its **Retry** action reuses canonical request selections +and requires a new manual review; there are no completed execution checkpoints +to reuse at that stage. + +Planning observation can disconnect while originally authorized Auto work +continues server-side. Reopening follows the saved attempt rather than starting +another run. An expired planning lease is shown as an interruption requiring +deliberate recovery. See the +[initial-turn persistence fix](../fixes/ORCHESTRATION_INITIAL_TURN_PERSISTENCE_FIX.md) +for admission, cancellation, and browser-restoration boundaries. + +### Recovering an execution + The existing `enable_chat_orchestration` setting enables the workflow. Each capability, source, agent, action, and model must still be enabled and accessible to the user. Retry does not grant additional permissions. diff --git a/docs/explanation/fixes/ORCHESTRATION_INITIAL_TURN_PERSISTENCE_FIX.md b/docs/explanation/fixes/ORCHESTRATION_INITIAL_TURN_PERSISTENCE_FIX.md new file mode 100644 index 000000000..0d00343c3 --- /dev/null +++ b/docs/explanation/fixes/ORCHESTRATION_INITIAL_TURN_PERSISTENCE_FIX.md @@ -0,0 +1,161 @@ +# Initial orchestration turn persistence + +**Version: 0.261.415** + +Fixed/Implemented in version: **0.261.415**, tracked in +`application/single_app/config.py`. + +Issue: [microsoft/simplechat#1893](https://github.com/microsoft/simplechat/issues/1893). + +## Issue and root cause + +A new Orchestrate question could disappear after switching conversations or +refreshing during planning. The sidebar could retain its title while the thread +showed no messages. A planning error, or even a legitimate clarification, could +leave the question available only in the browser that submitted it. + +The browser rendered an optimistic user message, but the normal server path +saved that message only after producing a valid plan. Planning failures were +reported through the event stream without saving an initial-turn failure. +Reopening a conversation replaced the optimistic message with server history; +there was no saved question to restore. Existing run recovery could not help +because a failed initial planning pass had not created a run. + +Planning also belonged to the response generator, and Auto execution was +initiated by the browser after receiving the plan. Saving the question alone +would therefore not make authorized Auto work continue after the page closed. + +Read-only Application Insights and App Service console investigation identified +a selected-requirements validation failure in the reported turn on application +version 0.261.410. Its HTTP request was recorded as 200 because the failure was +inside an SSE response. HTTP transport success was not evidence that a plan or +question had been committed. + +## Durable admission + +The accepted user question now anchors a versioned planning attempt in the +existing messages container. Authorization, request validation, and required +content checks precede acceptance. The question and its initial state are +created together before slow planning work; an early `user_message_persisted` +event acknowledges the actual saved question ID. + +The stored attempt retains canonical replay inputs, a submission identity, +conditional ownership, and an expiring planning lease. A repeated submission +observes its existing state rather than taking ownership again. Changed inputs +cannot reuse an accepted submission. Explicit initial-planning retries preserve +the question and its original selections and require fresh manual review. + +Saving early does not insert the current question into its own prior-history +snapshot. The existing stable message identity and content fingerprint remain +the association between question, clarification, plan, and run. + +## Browser-independent planning and execution + +An authenticated background worker owns planning and its resources. The SSE +connection observes the worker; disconnecting the observer does not dispose the +planner. Idle connections receive keepalive frames, and the worker maintains its +conditional lease while planning. + +Auto plans already authorized by the original submission pass through the same +execution-admission and run-claim boundary as reviewed plans. The browser follows +that execution instead of submitting a second run. Manual/timed approvals, +approval floors, plan-edit holds, source checks, and integration delivery policies +remain authoritative. + +Explicit Stop is different from navigation. A submission-scoped cancellation +record can be saved even before the question acknowledgement arrives. These +typed records use the existing orchestration container, are not executable runs, +and are excluded from normal run listings. They cannot be overwritten by an +unrelated conversation update. Cancellation and publication fencing prevent a +late planner from starting Auto work. If execution already started, the existing +run cancellation/reconciliation path remains responsible for its outcome. + +A process restart or expired planning lease produces a recoverable interruption, +not an indefinite claim that planning is still active. The application does not +silently replay uncertain external effects or promise transparent recovery from +every process failure. + +## Reopening and retrying + +Normal conversation history contains the saved question and public lifecycle +hints, not private claim tokens or canonical input records. A small authenticated +turn-status API restores planning, clarification, review, failure, and execution +state. The browser can recover without a session-storage copy of the prompt. + +| API | Purpose | +| --- | --- | +| `GET /api/v2/orchestration/turns/` | Read an owned question's authoritative status; explicit canonical-input reads support deliberate retry. | +| `POST /api/v2/orchestration/turns//cancel` | Record Stop for one submission, including the pre-acknowledgement interval. | +| `POST /api/v2/orchestration/plan` | Admit a new planning submission or continue/retry an authorized saved question. | +| `POST /api/v2/orchestration/run` | Preserve the existing reviewed-run API and shared execution-admission boundary. | + +Conversation and question ownership are checked at the read/write boundaries. +Deleted, masked, changed, or inaccessible questions cannot be used to recover an +older unreviewed request. A status outage is shown as uncertainty while the saved +question remains visible; it is not permission to start another execution. + +Restored countdown/automatic review proposals wait for manual approval. Reopening +a page does not create a new approval. A Stop that races with completion follows +the saved result instead of falsely reporting that a completed answer was stopped. + +## Files and validation + +The backend lifecycle lives in `functions_orchestration_planning.py` and +`route_backend_orchestration.py`. Conversation history strips its private fields, +and retry preparation does not copy an old worker's ownership into a new attempt. +The V2 transport, orchestration controller/resume code, chat store, and existing +message error/status surfaces implement restoration and saved-ID reconciliation. + +Regression coverage includes: + +- `functional_tests/test_orchestration_planning_persistence.py`: real + authenticated routes and SDK-default storage for early persistence, provider + failure, clarification, disconnect, Auto completion, duplicate delivery, + conditional claims, interruption, cancellation, ownership, and explicit retry. +- `ui_tests/test_v2_orchestration_planning_persistence.py`: production components + and CSS for desktop/mobile navigation, fresh browser contexts, persisted errors + and clarifications, canonical retries, Stop, review holds, completion races, + status outages, and absence of duplicate run POSTs. +- Existing clarification, conversation-context, regeneration, run recovery, + hydration, shared-conversation, and route-policy suites. +- Selected-source clarification checks keep exactly one saved question across + added sources and repeated continuation requests. Follow-up citation checks + retain authorized answer provenance without admitting unrelated user text; + their prior-message fixtures precede the newly accepted question. + +The session-only diagnostic first reproduced three missing-question failures +(planning, planner failure, clarification) with a successful-plan control. +The regression tests assert the corrected durability contract rather than +preserving those failure expectations. + +### Validation results + +The complete `scripts/check_cosmos_response_boundaries.py --run-contracts` +bank passed **2,002 tests** and **431 subtests** with network access blocked +and zero inventory review gaps after merging the 0.261.414 base. The combined +selected-document correction, durable planning/Auto, and guided-input backend +selection passed **96 tests**. + +The merged navigation, planning-retry, guided-input, and attachment-isolation +browser selection passed **72 tests** using local Chromium. Existing elicitation +and run-hydration scripts passed **6/6** and **7/7** with their required standalone +setup. The attachment ownership Node contract, TypeScript checking, production +UI build, route policies, and documentation checks passed. + +The base's guided answer-review flow, document-planning corrections, and +conversation attachment isolation are preserved. Planner-only fixtures now +separate side-effect-free planning from the approved Auto execution handoff; +browser fixtures cover the new pending-input reads and durable Stop acknowledgement +without relaxing unexpected-request checks. These are local validation results, +not evidence of deployment to Azure. + +## Compatibility and limitations + +No new deployment resource, administrator toggle, or legacy-plan migration is +required. Existing capability gates and source permissions still apply, and a +planner rejection is not bypassed to make a question appear successful. + +This fixes newly admitted turns. It does not reconstruct questions that an older +version never saved, or fabricate a plan rejected before creation. Server +acceptance also cannot guarantee preservation of a request that never reached +the server or failed its required admission checks. diff --git a/docs/guides/review-and-edit-orchestration-plans.md b/docs/guides/review-and-edit-orchestration-plans.md index 7286342b4..64349f822 100644 --- a/docs/guides/review-and-edit-orchestration-plans.md +++ b/docs/guides/review-and-edit-orchestration-plans.md @@ -4,7 +4,7 @@ title: "Review and edit orchestration plans" description: "Refine proposed work with the planner before running it." section: "Guides" audience: user -version: "0.261.411" +version: "0.261.415" --- ## Decide what should run @@ -51,6 +51,31 @@ another question. There is no one-question limit, and unanswered required fields still prevent submission. When your plan is ready, the selected approval mode still applies; answering a question does not authorize execution by itself. +## Leave or refresh without losing an accepted question + +Fixed/Implemented in version: **0.261.415** +(`application/single_app/config.py`). + +Once the server accepts your question, it saves the question and its planning +state before the plan is ready. You can switch conversations or refresh, then +return to the saved question and its current status. Originally authorized Auto +work continues server-side; a review-required plan still waits for approval. +Reopening a saved countdown does not approve it again. + +A planning failure stays with the saved question. Use **Retry** to reuse its +original selections and review a new plan rather than rebuilding the request +from today's composer controls. Follow-up questions are restored too, so answer +the current question instead of submitting another copy of the original request. + +Use **Stop** when you want cancellation. Leaving the page is not Stop. If Stop +races with completion, the saved answer is restored rather than described as +cancelled. Work already performed by an external integration is not rolled back. +A status outage reports uncertainty and does not start another execution. + +An interrupted server worker can require an explicit retry. The saved question +remains available, but this is not a promise of transparent recovery from every +process restart, or reconstruction of messages an older version never saved. + ## Choose requirements, not permissions Since **0.261.104**, every Orchestrate request reaches the planner, including diff --git a/functional_tests/cosmos_response_boundaries.json b/functional_tests/cosmos_response_boundaries.json index 98db710ed..0ca95a113 100644 --- a/functional_tests/cosmos_response_boundaries.json +++ b/functional_tests/cosmos_response_boundaries.json @@ -4944,12 +4944,12 @@ "inherited_bases": [ "AtomicMemoryContainer" ], - "imports_sha256": "b91d9d156c9c55cc819a7499feb9bd7b78933e3b0c653c91e4ab08450852c641" + "imports_sha256": "5312f35975dbd3c63ea5b15eaf6a9804ba7e62d5b718d234af08972cce6e65b5" }, - "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 SDK-default point reads/writes, partition routing, ETags and conditional replacement from AtomicMemoryContainer. Its source-message query returns plain rows.", "tests": [ - "functional_tests/test_orchestration_shared_conversations.py" + "functional_tests/test_orchestration_shared_conversations.py::test_shared_message_storage_uses_sdk_points_and_plain_queries" ] }, "functional_tests/test_orchestration_source_access.py::AuthorityContainer": { @@ -7294,7 +7294,6 @@ "functional_tests/test_orchestration_image_references.py::test_candidates_use_enriched_metadata_scopes_and_fold_uploads.Messages": "19f833896f6ef02d8b9ba8aebe79e81c21622ddc55d5ba4218dea3f7afbef91b", "functional_tests/test_orchestration_publication_policy.py::embedded-APPROVAL_PROBE::Messages": "2a3b0594aa169126b37006a4fd16a41679d68d8b6a0a733f48a60d1828c7b618", "functional_tests/test_orchestration_run_hydration_routes.py::_load_projections.UncheckedReplyMessages": "35f1185c741c93db15b5ae1fb8c304298a8e892eb5da3932802f01a3fbe50779", - "functional_tests/test_orchestration_shared_conversations.py::CollaborationMessages": "1ef0833a0f30a281222ceb8fcc43011d94512aad2ea9b5ef689754a26d10d417", "functional_tests/test_orchestration_source_access.py::AuthorityContainer": "afca61571d53031a3ddb9b0a6867adc88ef2bdb6556faa4c5a545db5e1282016", "functional_tests/test_orchestration_workflow_handoff_routes.py::Items": "6914cac3c722eef349cf1501a2be418bb3f6b4f43313a2417b7011488b0a70e2", "functional_tests/test_orchestration_workflow_proposal_routes.py::RunStore": "6eb7164a6fe952467933ad96ba82d70bda80397860753babde5731309904467c", @@ -9922,7 +9921,7 @@ ], "queries": [], "batches": [], - "module_sha256": "138d88598971207603aa6a0b20589ea0e7eb6aece3da367d59f8bf5e7d0f96c0" + "module_sha256": "547b942ec520d0b0402d853e103ff8b291409d4d79188734cfa7d98c2b89a28e" }, "classification": "sdk-compatible", "contract": "Conversation point mappings supply nested retry counters and ETags; deepcopy retains the SDK mapping for conditional replacement. Replace responses are discarded and the caller question is returned unchanged.", @@ -9958,7 +9957,7 @@ ], "queries": [], "batches": [], - "module_sha256": "138d88598971207603aa6a0b20589ea0e7eb6aece3da367d59f8bf5e7d0f96c0" + "module_sha256": "547b942ec520d0b0402d853e103ff8b291409d4d79188734cfa7d98c2b89a28e" }, "classification": "sdk-compatible", "contract": "Partitioned question/source/conversation CosmosDict values are checked using fields and nested plain metadata. The original ETag guards replacement and its SDK mapping is returned without changing strict domain contracts.", @@ -9985,7 +9984,7 @@ } ], "batches": [], - "module_sha256": "138d88598971207603aa6a0b20589ea0e7eb6aece3da367d59f8bf5e7d0f96c0" + "module_sha256": "547b942ec520d0b0402d853e103ff8b291409d4d79188734cfa7d98c2b89a28e" }, "classification": "sdk-compatible", "contract": "Query rows remain plain messages; the partitioned conversation CosmosDict is checked through user_id/shared-backing fields and returned unchanged alongside the plain message.", @@ -10036,7 +10035,7 @@ ], "queries": [], "batches": [], - "module_sha256": "138d88598971207603aa6a0b20589ea0e7eb6aece3da367d59f8bf5e7d0f96c0" + "module_sha256": "547b942ec520d0b0402d853e103ff8b291409d4d79188734cfa7d98c2b89a28e" }, "classification": "sdk-compatible", "contract": "SDK message/conversation points supply identity, counters, source fingerprints and ETags. Conditional conversation writes discard responses; created or replayed question CosmosDict values return through mapping-compatible publication.", @@ -10057,7 +10056,7 @@ ], "queries": [], "batches": [], - "module_sha256": "138d88598971207603aa6a0b20589ea0e7eb6aece3da367d59f8bf5e7d0f96c0" + "module_sha256": "547b942ec520d0b0402d853e103ff8b291409d4d79188734cfa7d98c2b89a28e" }, "classification": "sdk-compatible", "contract": "The source question CosmosDict is read through deletion/metadata/content fields for fingerprint comparison; publishing retains the current plain query-row question return shape.", @@ -10078,7 +10077,7 @@ } ], "batches": [], - "module_sha256": "138d88598971207603aa6a0b20589ea0e7eb6aece3da367d59f8bf5e7d0f96c0" + "module_sha256": "547b942ec520d0b0402d853e103ff8b291409d4d79188734cfa7d98c2b89a28e" }, "classification": "sdk-compatible", "contract": "Partitioned question query rows remain plain dictionaries; nested metadata/thread fields select exactly one nondeleted attempt and the chosen plain row is returned.", @@ -10099,7 +10098,7 @@ } ], "batches": [], - "module_sha256": "138d88598971207603aa6a0b20589ea0e7eb6aece3da367d59f8bf5e7d0f96c0" + "module_sha256": "547b942ec520d0b0402d853e103ff8b291409d4d79188734cfa7d98c2b89a28e" }, "classification": "sdk-compatible", "contract": "The partitioned thread query is materialized as a list of plain query dictionaries, separate from SDK point response mappings.", @@ -10125,7 +10124,7 @@ ], "queries": [], "batches": [], - "module_sha256": "138d88598971207603aa6a0b20589ea0e7eb6aece3da367d59f8bf5e7d0f96c0" + "module_sha256": "547b942ec520d0b0402d853e103ff8b291409d4d79188734cfa7d98c2b89a28e" }, "classification": "sdk-compatible", "contract": "The SDK question mapping is copied, its nested response-attempt state changed and its ETag used for conditional replace; the write response is discarded and the function returns None.", @@ -19731,7 +19730,7 @@ ], "queries": [], "batches": [], - "module_sha256": "d91095101df3dc9c602599fcb80b8e8a19f814c7226cde580e6f3333dfd88ec5" + "module_sha256": "d812ec63ab912fca0a83b7a2f597dcebcb5d6cf205d7ffa067a2751f972ebe9c" }, "classification": "sdk-compatible", "contract": "_refresh_mirrored_answer: 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. Representative SDK-default runtime coverage now executes this shared-storage module; this does not claim exhaustive branch coverage for this unchanged owner.", @@ -19773,7 +19772,7 @@ } ], "batches": [], - "module_sha256": "d91095101df3dc9c602599fcb80b8e8a19f814c7226cde580e6f3333dfd88ec5" + "module_sha256": "d812ec63ab912fca0a83b7a2f597dcebcb5d6cf205d7ffa067a2751f972ebe9c" }, "classification": "response-unused", "contract": "delete_orchestration_backing: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -19792,7 +19791,7 @@ ], "queries": [], "batches": [], - "module_sha256": "d91095101df3dc9c602599fcb80b8e8a19f814c7226cde580e6f3333dfd88ec5" + "module_sha256": "d812ec63ab912fca0a83b7a2f597dcebcb5d6cf205d7ffa067a2751f972ebe9c" }, "classification": "sdk-compatible", "contract": "delete_orchestration_backing.read_backing: 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. Representative SDK-default runtime coverage now executes this shared-storage module; this does not claim exhaustive branch coverage for this unchanged owner.", @@ -20273,6 +20272,92 @@ "functional_tests/test_orchestration_cosmos_response_contract.py" ] }, + "application/single_app/functions_orchestration_planning.py::_read_question": { + "boundary": { + "sha256": "e290895645e538dad573548844c48c924d1151372f5507a6c6a23573802738ce", + "points": [ + { + "method": "read_item", + "receiver": "messages", + "response": "consumed" + } + ], + "queries": [], + "batches": [], + "module_sha256": "ae2373ab845b8f0c1837ca03e591a9056fc7c256af3cdfac79c298518f1d56c5" + }, + "classification": "normalized-strict", + "contract": "The SDK point mapping is normalized before domain checks or copying. The private attempt remains strict JSON; question identity, masks, deletion and conditional ETags are preserved.", + "tests": [ + "functional_tests/test_orchestration_planning_persistence.py" + ], + "evidence_scope": "owner-runtime" + }, + "application/single_app/functions_orchestration_planning.py::_replace": { + "boundary": { + "sha256": "2e1b8fc5cd2ebbe45389b7c0c125c4a0b8e838938acb0fd69f4ddd62796eae8f", + "points": [ + { + "method": "replace_item", + "receiver": "messages", + "response": "consumed" + } + ], + "queries": [], + "batches": [], + "module_sha256": "ae2373ab845b8f0c1837ca03e591a9056fc7c256af3cdfac79c298518f1d56c5" + }, + "classification": "normalized-strict", + "contract": "The caller supplies a normalized question with its ETag. Conditional replacement preserves unrelated metadata; its SDK write document is normalized before the owner reads the saved state.", + "tests": [ + "functional_tests/test_orchestration_planning_persistence.py::test_a_racing_stop_cannot_be_overwritten_by_a_heartbeat", + "functional_tests/test_orchestration_planning_persistence.py::test_sdk_claim_fencing_stale_worker_and_public_history" + ], + "evidence_scope": "owner-runtime" + }, + "application/single_app/functions_orchestration_planning.py::planning_was_cancelled": { + "boundary": { + "sha256": "c89776276828ccc568e7fe165827f56b8ca9bc10fbcfc8802e3c80964078e28c", + "points": [ + { + "method": "read_item", + "receiver": "cancellations", + "response": "consumed" + } + ], + "queries": [], + "batches": [], + "module_sha256": "ae2373ab845b8f0c1837ca03e591a9056fc7c256af3cdfac79c298518f1d56c5" + }, + "classification": "normalized-strict", + "contract": "A cancellation point response is normalized before exact scoped-field validation. Only a real 404 means absence; malformed or unavailable storage cannot grant planning ownership.", + "tests": [ + "functional_tests/test_orchestration_planning_persistence.py::test_stop_before_acknowledgement_is_durable", + "functional_tests/test_orchestration_planning_persistence.py::test_early_stop_is_not_erased_by_an_unrelated_conversation_write" + ], + "evidence_scope": "owner-runtime" + }, + "application/single_app/functions_orchestration_planning.py::record_planning_cancellation": { + "boundary": { + "sha256": "de0d7e2639a35e917c33bae3c918ce9ef31dc0c11200ad7452f2f992e4d172b2", + "points": [ + { + "method": "create_item", + "receiver": "cancellations", + "response": "unused" + } + ], + "queries": [], + "batches": [], + "module_sha256": "ae2373ab845b8f0c1837ca03e591a9056fc7c256af3cdfac79c298518f1d56c5" + }, + "classification": "response-unused", + "contract": "The immutable, submission-scoped cancellation create response is discarded. A 409 rereads the exact owned marker through planning_was_cancelled instead of overwriting another record.", + "tests": [ + "functional_tests/test_orchestration_planning_persistence.py::test_stop_before_acknowledgement_is_durable" + ], + "evidence_scope": "owner-runtime" + }, "application/single_app/functions_orchestration_recovery.py::ExecutionLease._published_message_matches": { "boundary": { "sha256": "4609d59057d566547a1c6a28c20ea46a44e45a44667980aeebbff2c949f84062", @@ -29930,7 +30015,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "_authorize_personal_conversation_read: 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.", @@ -29952,7 +30037,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "_collect_child_message_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.", @@ -29974,7 +30059,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "_count_hidden_legacy_conversations: 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.", @@ -29996,7 +30081,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "_list_personal_conversation_generated_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.", @@ -30018,7 +30103,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "_load_scope_lock_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.", @@ -30040,7 +30125,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "_load_unread_collaboration_notification_map: 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.", @@ -30067,7 +30152,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "response-unused", "contract": "_persist_scope_lock_update: 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.", @@ -30088,7 +30173,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "_promote_remaining_thread_attempt: 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.", @@ -30110,7 +30195,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "_query_legacy_conversations_for_feed: 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.", @@ -30132,7 +30217,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "_query_matching_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.", @@ -30165,7 +30250,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "response-unused", "contract": "_rebuild_authorized_personal_conversation_used_documents: 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.", @@ -30176,7 +30261,7 @@ }, "application/single_app/route_backend_conversations.py::register_route_backend_conversations.api_get_messages": { "boundary": { - "sha256": "1f1b9bc53d9c4dfd2cfab4b059192911c97df1c923c45fe2416c44a68d8384cb", + "sha256": "a1315739488b1f1ee106b0da7e33aa3724148ac50c1ca77580b5558fc92f8f21", "points": [], "queries": [ { @@ -30186,14 +30271,13 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", - "contract": "register_route_backend_conversations.api_get_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": "Message queries return plain rows. Existing history hydration remains mapping-compatible; public_planning_message normalizes each message and removes private planning claims/replay records while retaining the accepted question and public lifecycle hints.", "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_action_credentials_bootstrap.py", - "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_planning_persistence.py::test_real_history_endpoint_restores_the_question_without_private_claims" ] }, "application/single_app/route_backend_conversations.py::register_route_backend_conversations.bulk_hide_conversations": { @@ -30213,7 +30297,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.bulk_hide_conversations: 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.", @@ -30240,7 +30324,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.bulk_pin_conversations: 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.", @@ -30283,7 +30367,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "response-unused", "contract": "register_route_backend_conversations.delete_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.", @@ -30330,7 +30414,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.delete_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.", @@ -30373,7 +30457,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "response-unused", "contract": "register_route_backend_conversations.delete_multiple_conversations: 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.", @@ -30394,7 +30478,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.generate_conversation_summary_api: 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.", @@ -30416,7 +30500,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.get_conversation_kind_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.", @@ -30443,7 +30527,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.get_conversation_metadata_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.", @@ -30465,7 +30549,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.get_conversations: 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.", @@ -30487,7 +30571,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.get_user_classifications: 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.", @@ -30514,7 +30598,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.mark_conversation_read_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.", @@ -30536,7 +30620,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.search_conversations: 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.", @@ -30574,7 +30658,7 @@ } ], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.switch_attempt: 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.", @@ -30601,7 +30685,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.toggle_conversation_hide: 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.", @@ -30628,7 +30712,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.toggle_conversation_pin: 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.", @@ -30655,7 +30739,7 @@ ], "queries": [], "batches": [], - "module_sha256": "51433ed93d3471ecfc203f8f4b18567672c272eeeb93a656996a142b2d258d95" + "module_sha256": "99ba8846993e531c4caa4d09c5e61cb38a34e9e533627c86dcf767a6f3749600" }, "classification": "sdk-compatible", "contract": "register_route_backend_conversations.update_conversation_title: 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.", @@ -31273,7 +31357,7 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "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.", @@ -31295,7 +31379,7 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "contract": "Artifact point mappings are JSON-encoded by the existing checkpoint fingerprint helper; SDK dictionary subclasses are supported and nested artifact validation is unchanged.", @@ -31316,7 +31400,7 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "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.", @@ -31348,7 +31432,7 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "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.", @@ -31369,7 +31453,7 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "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.", @@ -31396,7 +31480,7 @@ } ], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "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.", @@ -31417,7 +31501,7 @@ } ], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "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.", @@ -31438,7 +31522,7 @@ } ], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "contract": "Historical context discovery consumes plain partitioned query rows, applies retry-prefix/screening rules and builds a bounded plain conversation snapshot.", @@ -31449,7 +31533,7 @@ }, "application/single_app/route_backend_orchestration.py::_mirror_planned_turn": { "boundary": { - "sha256": "8615966a67ce45b07978a6c1bef586a4929ecaada317f7d63b94829c1fc302b4", + "sha256": "2b7b2cea2b66e84450b97f0d71558f182401a4a971bdae757bc283935589334f", "points": [ { "method": "read_item", @@ -31464,18 +31548,18 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", - "contract": "Conversation/message point mappings feed shared-backing ownership checks and Mapping-compatible collaboration mirroring; no strict task/result descriptor consumes the wrappers.", + "contract": "Conversation SDK mappings feed existing mapping-compatible shared authorization. The saved question is normalized by public_planning_message before projection/mirroring; private claims are not shared, and required admission mirror failures are explicit.", "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_orchestration_conversation_context_routes.py" + "functional_tests/test_orchestration_shared_conversations.py" ] }, "application/single_app/route_backend_orchestration.py::_persist_planned_turn": { "boundary": { - "sha256": "0ee1e0c7657944a1819faffbbb32aeff018ed19408c9a4e443e6aea195af0138", + "sha256": "25bb1dc5e7d8a112e8a60ac4f7d3c6460e40798b43fb201286a15d38147462a5", "points": [ { "method": "read_item", @@ -31485,13 +31569,14 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", - "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.", + "contract": "The review-required question point mapping uses nested metadata mapping updates and the existing conditional metadata writer. Planning ownership is checked before run publication; no raw SDK question reaches an exact-dictionary JSON contract.", "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_orchestration_conversation_context_routes.py" + "functional_tests/test_orchestration_planning_persistence.py::test_explicit_retry_uses_saved_inputs_and_one_question", + "functional_tests/test_orchestration_message_regeneration.py" ] }, "application/single_app/route_backend_orchestration.py::_run_response_removed": { @@ -31506,7 +31591,7 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "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.", @@ -31527,7 +31612,7 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "response-unused", "contract": "The message upsert response is discarded; screening and source-availability checks occur before publication, and persistence errors retain the existing failure path.", @@ -31536,7 +31621,7 @@ }, "application/single_app/route_backend_orchestration.py::_save_turn_message": { "boundary": { - "sha256": "b05e8634d174ceba3cdee9fbdd21ad0ef4b8864a7a572aae0795b6a64578e257", + "sha256": "8cd23b87b2c3989b2aa847ba8aaa35721582ddc84c23d93e1515466a9c2a6f09", "points": [ { "method": "read_item", @@ -31551,15 +31636,41 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", - "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.", + "contract": "Question SDK point mappings are consumed by normalize_history_message and metadata mapping operations. The projected history dictionary, not the SDK wrapper, is fingerprinted. Create-only admission and conditional lifecycle writes preserve the original question.", "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_orchestration_conversation_context_routes.py" + "functional_tests/test_orchestration_planning_persistence.py" ] }, + "application/single_app/route_backend_orchestration.py::_save_turn_message.create_question": { + "boundary": { + "sha256": "01efdae81d08f614c19e366aef28e19081e9d69dbe230d3f4eeab182599b811f", + "points": [ + { + "method": "create_item", + "receiver": "cosmos_messages_container", + "response": "unused" + }, + { + "method": "read_item", + "receiver": "cosmos_messages_container", + "response": "consumed" + } + ], + "queries": [], + "batches": [], + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" + }, + "classification": "sdk-compatible", + "contract": "The question create response is discarded. A racing 409 rereads an SDK point and validates the existing question instead of upserting over it; mismatched content fails without a save acknowledgement.", + "tests": [ + "functional_tests/test_orchestration_planning_persistence.py::test_concurrent_question_creation_never_upserts_over_the_winner" + ], + "evidence_scope": "owner-runtime" + }, "application/single_app/route_backend_orchestration.py::_touch_conversation": { "boundary": { "sha256": "99d4c36c67f84cce3003b1743cd74df2a0c457539fd6f0e4d185db2bd3ce140c", @@ -31577,7 +31688,7 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "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.", @@ -31598,7 +31709,7 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "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.", @@ -31619,7 +31730,7 @@ } ], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", "contract": "Source revalidation consumes plain bounded query rows through screening and strict saved-snapshot checks; no point wrapper is repaired into a domain context. Selected-history projection uses plain SDK query rows, existing visibility/fingerprint checks and admitted typed result references; new full-text projection and assistant-only result selection are exercised through real planning, execution and editor routes with SDK-default containers.", @@ -31631,7 +31742,7 @@ }, "application/single_app/route_backend_orchestration.py::register_route_backend_orchestration.orchestration_plan": { "boundary": { - "sha256": "29a1e31ee65d239f9ded08e3cf8d8621d32b412f8f41fd579c437c689453a33c", + "sha256": "a1e3bd6dfcd5c021b81436c680a7e1ff8e9e49f2f8d006d8c027c7d18ff2531b", "points": [ { "method": "read_item", @@ -31641,17 +31752,15 @@ ], "queries": [], "batches": [], - "module_sha256": "cba894adf41106ce90ad888c4fd342703fa3ea772e2a113351fbb9c240674861" + "module_sha256": "471268db20d1197bc1b1f7a6af6a0d7cef95091d35c5dd85ba398c5588d97894" }, "classification": "sdk-compatible", - "contract": "Clarification admission consumes SDK conversation ownership fields. Selected-source preparation uses current authorized manifest DTOs before resolution; only bounded plain identity/schema projections enter model context. Only source revision hashes persist in seeds. Point wrappers never enter strict planner contracts, and approval precedes computation/export. Selected-history projection uses plain SDK query rows, existing visibility/fingerprint checks and admitted typed result references; new full-text projection and assistant-only result selection are exercised through real planning, execution and editor routes with SDK-default containers.", + "contract": "Clarification admission reads the SDK conversation through mapping access and rechecks ownership before dependent work. Durable planning is delegated to the normalized planning owner; canonical server selections and pre-turn snapshots are preserved.", "evidence_scope": "owner-runtime", "tests": [ + "functional_tests/test_orchestration_elicitation_routes.py", "functional_tests/test_orchestration_conversation_context_routes.py", - "functional_tests/test_orchestration_conversation_sources.py", - "functional_tests/test_orchestration_followup_citation_provenance.py", - "functional_tests/test_orchestration_request_memory_resolution.py", - "functional_tests/test_orchestration_selected_source_planning.py" + "functional_tests/test_orchestration_planning_persistence.py" ] }, "application/single_app/route_backend_plugins.py::edit_plugin": { diff --git a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py index 484381b83..51da19632 100644 --- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py +++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py @@ -574,8 +574,10 @@ def test_public_routes_are_explicitly_listed() -> None: def test_plan_editor_routes_keep_the_orchestration_security_policy() -> None: - """Every editor operation stays on the authenticated orchestration Blueprint.""" + """Editor and initial-turn operations stay on the authenticated orchestration Blueprint.""" expected = { + "/api/v2/orchestration/turns/": "orchestration_turn_status", + "/api/v2/orchestration/turns//cancel": "orchestration_turn_cancel", "/api/v2/orchestration/runs//editor": "orchestration_plan_editor", "/api/v2/orchestration/runs//edit": "orchestration_begin_plan_edit", "/api/v2/orchestration/runs//revisions": "orchestration_revise_plan", diff --git a/functional_tests/test_orchestration_document_planning_recovery.py b/functional_tests/test_orchestration_document_planning_recovery.py index a0ba9dfc6..c23f7ed7c 100644 --- a/functional_tests/test_orchestration_document_planning_recovery.py +++ b/functional_tests/test_orchestration_document_planning_recovery.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional regressions for picked-document planning and debug diagnostics. -Version: 0.261.413 +Version: 0.261.415 Implemented in: 0.261.413 +Server-owned Auto handoff integration: 0.261.415 Real authenticated routes, source metadata, planner, validation and logger serialization run with external model/storage I/O isolated. Diagnostics must @@ -20,6 +21,7 @@ import httpx import pytest +from flask import Response from jsonschema import Draft202012Validator from openai import APITimeoutError from openpyxl import Workbook @@ -272,8 +274,9 @@ def test_advertised_analyze_schema_rejects_incompatible_semantics(planning, opti @pytest.mark.parametrize("extension", ["png", "pdf", "docx", "csv", "xlsx"]) @pytest.mark.parametrize("approval", ["manual", "auto"]) def test_http_picked_document_recovery_preserves_scope_and_does_not_execute( - planning, monkeypatch, caplog, extension, approval, + planning, modules, monkeypatch, caplog, extension, approval, ): + """Planning stays side-effect free; only approved Auto plans hand off afterward.""" harness = planning.harness documents = harness.config.cosmos_user_documents_container record = documents.read_item(LEFT, LEFT) @@ -289,6 +292,8 @@ def test_http_picked_document_recovery_preserves_scope_and_does_not_execute( harness.blobs.records[("PRIVATE_CONTAINER", "PRIVATE_PATH")]["data"] = content.getvalue() execute = Mock(side_effect=AssertionError("Planning must not execute a step.")) monkeypatch.setattr(harness.execution, "prepare_harness_execution", execute) + handoff = Mock(return_value=Response(mimetype="text/event-stream")) + monkeypatch.setattr(modules.route, "_prepare_execution_stream", handoff) adapters = importlib.import_module("functions_orchestration_adapters") monkeypatch.setitem(adapters.ADAPTER_REGISTRY, "document_analyze", execute) request = "Answer the question using only the selected file." @@ -328,6 +333,11 @@ def test_http_picked_document_recovery_preserves_scope_and_does_not_execute( assert plan["approval"]["mode"] == approval assert harness.schema.effective_plan_document_ids(plan, {"document_ids": [LEFT]}) == [LEFT] assert plan["steps"][0]["capability_id"] == "document_search" + assert handoff.call_count == (1 if approval == "auto" else 0) + if approval == "auto": + admitted = handoff.call_args.args[0] + assert admitted["id"] == plan["run_id"] + assert harness.schema.effective_plan_document_ids(admitted["plan"], {"document_ids": [LEFT]}) == [LEFT] assert execute.call_count == 0 assert harness.blobs.file_uploads == 0 and harness.assistant_messages() == [] summary = diagnostic_records(caplog, stage="plan_validated", message="[ORCHESTRATION_PLANNER] Planning diagnostic.")[-1] diff --git a/functional_tests/test_orchestration_followup_citation_provenance.py b/functional_tests/test_orchestration_followup_citation_provenance.py index da819a8b7..4c7878ac7 100644 --- a/functional_tests/test_orchestration_followup_citation_provenance.py +++ b/functional_tests/test_orchestration_followup_citation_provenance.py @@ -1,7 +1,7 @@ # test_orchestration_followup_citation_provenance.py """Selected sourced answers retain provenance through formatting-only follow-ups. -Version: 0.261.370 +Version: 0.261.415 Implemented in: 0.261.370 Real planning routes, retained results, composition and publication run with @@ -9,6 +9,7 @@ """ from copy import deepcopy +from datetime import datetime, timezone import importlib import json @@ -160,7 +161,7 @@ def test_user_text_does_not_admit_unselected_answer_sources(sourced_conversation harness = case['runtime'].harness user = { 'id': 'pasted-text', 'conversation_id': 'conversation-1', 'role': 'user', - 'content': ANSWER, 'timestamp': '2099-01-01T00:00:00+00:00', + 'content': ANSWER, 'timestamp': datetime.now(timezone.utc).isoformat(), } harness.messages.create_item(user) message_id = user['id'] if selection == 'pasted' else case['record']['user_message_id'] diff --git a/functional_tests/test_orchestration_harness_routes.py b/functional_tests/test_orchestration_harness_routes.py index 394f9083f..8702cb91f 100644 --- a/functional_tests/test_orchestration_harness_routes.py +++ b/functional_tests/test_orchestration_harness_routes.py @@ -1,7 +1,7 @@ # test_orchestration_harness_routes.py """ Real authenticated routes and bootstrap for orchestration file admission. -Version: 0.261.324 +Version: 0.261.415 Implemented in: 0.261.127 Initial-claim timing coverage added in: 0.261.129 Auto-routing admission and content-review file visibility added in: 0.261.131 @@ -96,6 +96,7 @@ def runtime(modules, monkeypatch): (modules.config, "cosmos_conversations_container", conversations), (modules.route, "cosmos_conversations_container", conversations), (modules.config, "cosmos_orchestration_runs_container", runs), + (modules.route, "cosmos_orchestration_runs_container", runs), (modules.runs, "cosmos_orchestration_runs_container", runs), (modules.config, "cosmos_personal_workflow_run_items_container", results), (modules.config, "cosmos_messages_container", messages), @@ -664,6 +665,7 @@ def real_http_harness(modules, monkeypatch): for name, value in ( ("cosmos_conversations_container", harness.conversations), ("cosmos_messages_container", harness.messages), + ("cosmos_orchestration_runs_container", harness.runs), ("get_settings", lambda: dict(harness.settings)), ("get_user_settings", lambda user_id: {"settings": {}}), ): diff --git a/functional_tests/test_orchestration_message_regeneration.py b/functional_tests/test_orchestration_message_regeneration.py index 4b292cf84..7d6fa40af 100644 --- a/functional_tests/test_orchestration_message_regeneration.py +++ b/functional_tests/test_orchestration_message_regeneration.py @@ -1,7 +1,7 @@ # test_orchestration_message_regeneration.py """ Functional coverage for fresh, review-required orchestration message generations. -Version: 0.261.320 +Version: 0.261.415 Implemented in: 0.261.317 The real planning, revision, execution, and retry state machines run against the @@ -18,7 +18,7 @@ import pytest from test_orchestration_harness_routes import modules as harness_modules, real_http_harness # noqa: F401 -from test_orchestration_elicitation_routes import reply_payload, resolution, style_question +from test_orchestration_elicitation_routes import reply_payload, style_question from test_orchestration_model_selection import TERRA_SELECTION from test_support.offline_bootstrap import offline_app_imports from test_support.orchestration_harness_execution import compose_step, input_binding @@ -46,8 +46,9 @@ def frames(response): if frame.startswith('data:') and frame.partition('data:')[2].strip() != '[DONE]' ] -def planning_replies(*, answered=False): - return ([json.dumps(resolution(QUESTION))] if answered else []) + [ +def planning_replies(): + # Answered clarifications do not invent history in this source-free fixture. + return [ json.dumps({'kind': 'plan', 'steps': [compose_step()], 'final_response': input_binding('prepare')}), ] @@ -128,9 +129,7 @@ def prepare(context, source, *, submission_id=None, **fields): def plan_again(context, prepared, **overrides): - context.harness.replies = planning_replies(answered=bool( - prepared['user_message']['metadata'].get('orchestration_clarification_answers'), - )) + context.harness.replies = planning_replies() response = context.client.post( '/api/v2/orchestration/plan', json={**prepared['plan_request'], **overrides}, buffered=True, ) @@ -182,6 +181,8 @@ def test_regeneration_forces_review_despite_auto_and_disabled_approval_override( }) assert refused.status_code == 409, refused.get_data(as_text=True) assert refused.get_json()['code'] == 'fresh_review_required' + + context.harness.replies = ['The regenerated complete answer.'] approved = context.client.post('/api/v2/orchestration/run', json={ 'conversation_id': 'conv1', 'run_id': plan['run_id'], 'plan_id': plan['plan_id'], @@ -196,6 +197,16 @@ def test_regeneration_forces_review_despite_auto_and_disabled_approval_override( assert context.messages.read_item(prepared['user_message_id'], 'conv1')['metadata']['response_attempt']['state'] == 'completed' +def test_prepared_regeneration_preserves_the_client_planning_submission_id(context): + original = source_run(context) + _, prepared = prepare(context, original['user_message_id']) + response, events = plan_again(context, prepared, planning_submission_id='client-regeneration-submission') + acknowledged = next(event for event in events if event.get('type') == 'user_message_persisted') + assert response.status_code == 200 + assert acknowledged['planning_submission_id'] == 'client-regeneration-submission' + assert acknowledged['user_message_id'] == prepared['user_message_id'] + + def test_duplicate_preparation_and_invocation_cannot_create_extra_generations(context): original = source_run(context) submission = str(uuid.uuid4()) @@ -392,7 +403,7 @@ def test_clarification_preserves_answers_and_manual_review(context, failed_conti response = context.client.post('/api/v2/orchestration/plan', json=prepared['plan_request'], buffered=True) events = frames(response) question = next(event['elicitation'] for event in events if event.get('elicitation')) - context.harness.replies = [] if failed_continuation else planning_replies(answered=True) + context.harness.replies = [] if failed_continuation else planning_replies() answer = reply_payload(question, turn_id=prepared['turn_id']) answer['conversation_id'] = 'conv1' answer['approval_mode'] = 'auto' diff --git a/functional_tests/test_orchestration_planning_persistence.py b/functional_tests/test_orchestration_planning_persistence.py new file mode 100644 index 000000000..d9144ce49 --- /dev/null +++ b/functional_tests/test_orchestration_planning_persistence.py @@ -0,0 +1,495 @@ +# test_orchestration_planning_persistence.py +""" +Regression coverage for durable initial orchestration turns. +Version: 0.261.415 +Implemented in: 0.261.415 + +Real authenticated routes and SDK-default offline storage exercise admission, +clarification, failure, disconnect, Auto handoff, cancellation, and explicit retry. +Only external model/storage I/O is replaced by the existing harness. +""" + +from contextlib import contextmanager +from copy import deepcopy +from datetime import timedelta +import json +import importlib +import threading + +import pytest +from flask import Blueprint + +import functions_orchestration_planning as planning +from test_orchestration_elicitation_routes import ( + empty_history_runtime, plan_doc, sse_frames, style_question, +) +from test_orchestration_harness_routes import login, modules, real_http_harness +from test_support.orchestration_revisions import AtomicMemoryContainer + + +CONVERSATION = "conversation-1" +TURN = "durable-turn" +MESSAGE = "Write a short source-free explanation." +PLAN_PATH = "/api/v2/orchestration/plan" +STATUS_PATH = f"/api/v2/orchestration/turns/{TURN}" + + +def body(**changes): + return { + "conversation_id": CONVERSATION, "turn_id": TURN, + "message": MESSAGE, "approval_mode": "manual", + "planning_submission_id": "submission-1", **changes, + } + + +def questions(runtime): + return [ + item for item in runtime.harness.messages.items.values() + if item.get("role") == "user" and item.get("content") == MESSAGE + ] + + +def status(runtime, *, include_request=False): + response = runtime.client.get( + STATUS_PATH, query_string={ + "conversation_id": CONVERSATION, + **({"include_request": "1"} if include_request else {}), + }, + ) + assert response.status_code == 200, response.get_data(as_text=True) + return response.get_json()["turn"] + + +@pytest.mark.parametrize("outcome", ["failed", "clarification", "success"]) +def test_initial_question_and_outcome_are_saved(empty_history_runtime, outcome): + runtime = empty_history_runtime + proposal = {"kind": "plan", "steps": []} if outcome == "failed" else ( + style_question() if outcome == "clarification" else plan_doc() + ) + runtime.harness.replies.append(json.dumps(proposal)) + response = runtime.client.post(PLAN_PATH, json=body(), buffered=True) + frames = sse_frames(response) + saved = questions(runtime) + current = status(runtime, include_request=True) + assert response.status_code == 200 + assert len(saved) == 1 + assert saved[0]["metadata"]["orchestration_turn_id"] == TURN + acknowledgement = next(frame for frame in frames if frame.get("type") == "user_message_persisted") + assert acknowledgement["user_message_id"] == saved[0]["id"] + assert acknowledgement["server_managed"] is True + expected = {"failed": "failed", "clarification": "awaiting_clarification", "success": "awaiting_review"} + assert current["status"] == expected[outcome] + assert current["request"]["message"] == MESSAGE + assert current["user_message_id"] == saved[0]["id"] + assert "claim_id" not in json.dumps(current) + if outcome == "clarification": + assert current["elicitation"]["message"] == "Choose a response style." + if outcome == "failed": + assert current["error"] and current["error_code"] == "planning_failed" + if outcome == "success": + record = runtime.harness.runs.read_item(current["run_id"], CONVERSATION) + assert record["conversation_context"]["messages"] == [] + + +def hold_planner(runtime, modules, monkeypatch): + entered, release, settled = threading.Event(), threading.Event(), threading.Event() + original_plan = modules.route.plan_request + original_update = modules.route._update_planning_owner + + def held(*args, **kwargs): + entered.set() + if not release.wait(timeout=10): + raise RuntimeError("The test did not release its planner.") + return original_plan(*args, **kwargs) + + def updated(*args, **kwargs): + result = original_update(*args, **kwargs) + if kwargs.get("status") in {"awaiting_review", "awaiting_clarification", "failed", "completed"}: + settled.set() + return result + + monkeypatch.setattr(modules.route, "plan_request", held) + monkeypatch.setattr(modules.route, "_update_planning_owner", updated) + return entered, release, settled + + +@pytest.mark.parametrize("automatic", [False, True]) +def test_disconnect_does_not_own_planning_or_auto_execution( + empty_history_runtime, modules, monkeypatch, automatic, +): + runtime = empty_history_runtime + runtime.harness.replies.append(json.dumps(plan_doc())) + if automatic: + runtime.harness.replies.append("The answer completed without a browser.") + entered, release, settled = hold_planner(runtime, modules, monkeypatch) + response = runtime.client.post( + PLAN_PATH, json=body(approval_mode="auto" if automatic else "manual"), buffered=False, + ) + try: + planner_entered = entered.wait(timeout=10) + assert planner_entered + current = status(runtime) + assert current["status"] == "planning" + assert len(questions(runtime)) == 1 + response.close() + finally: + release.set() + worker_settled = settled.wait(timeout=10) + assert worker_settled + current = status(runtime) + assert current["status"] == ("completed" if automatic else "awaiting_review") + assert len(questions(runtime)) == 1 + if automatic: + answers = runtime.harness.assistant_messages() + assert len(answers) == 1 + assert answers[0]["content"] == "The answer completed without a browser." + + +def test_duplicate_submission_observes_one_planner(empty_history_runtime, modules, monkeypatch): + runtime = empty_history_runtime + runtime.harness.replies.append(json.dumps(plan_doc())) + entered, release, settled = hold_planner(runtime, modules, monkeypatch) + first = runtime.client.post(PLAN_PATH, json=body(), buffered=False) + try: + planner_entered = entered.wait(timeout=10) + assert planner_entered + second = runtime.client.post(PLAN_PATH, json=body(), buffered=True) + frames = sse_frames(second) + adopted = next(frame["turn"] for frame in frames if frame.get("type") == "orchestration_turn") + assert adopted["status"] == "planning" + assert len(questions(runtime)) == 1 + assert runtime.harness.model_calls == [] + finally: + first.close() + release.set() + worker_settled = settled.wait(timeout=10) + assert worker_settled + + +@pytest.mark.parametrize("same_request", [False, True]) +def test_concurrent_question_creation_never_upserts_over_the_winner( + empty_history_runtime, modules, monkeypatch, same_request, +): + runtime = empty_history_runtime + save = modules.route._save_message + competing_content = MESSAGE if same_request else "A different concurrent question." + + def raced_save(conversation_id, role, content, **kwargs): + state = kwargs["metadata"][planning.PLANNING_METADATA] + competing = planning.new_planning_attempt( + {**state["request"], "message": competing_content}, TURN, state["submission_id"], + ) + runtime.harness.messages.create_item({ + "id": kwargs["message_id"], "conversation_id": conversation_id, "role": role, + "content": competing_content, "timestamp": "2026-01-01T10:00:00Z", + "metadata": {**kwargs["metadata"], planning.PLANNING_METADATA: competing}, + }) + return save(conversation_id, role, content, **kwargs) + + monkeypatch.setattr(modules.route, "_save_message", raced_save) + response = runtime.client.post(PLAN_PATH, json=body(), buffered=True) + frames = sse_frames(response) + stored = runtime.harness.messages.read_item(planning.planning_question_id(CONVERSATION, TURN), CONVERSATION) + assert stored["content"] == competing_content + assert stored["timestamp"] == "2026-01-01T10:00:00Z" + assert runtime.harness.model_calls == [] + assert len(runtime.harness.messages.items) == 1 + if same_request: + assert any(frame.get("type") == "orchestration_turn" for frame in frames) + else: + assert any(frame.get("error") for frame in frames) + assert not any(frame.get("message_persisted") for frame in frames) + + +def test_save_failure_never_starts_the_planner(empty_history_runtime): + runtime = empty_history_runtime + runtime.harness.messages.fail_writes = True + response = runtime.client.post(PLAN_PATH, json=body(), buffered=True) + frames = sse_frames(response) + assert any(frame.get("error") for frame in frames) + assert not any(frame.get("message_persisted") for frame in frames) + assert runtime.harness.model_calls == [] + assert questions(runtime) == [] + + +def test_real_history_endpoint_restores_the_question_without_private_claims( + empty_history_runtime, modules, monkeypatch, +): + runtime = empty_history_runtime + history = importlib.import_module("route_backend_conversations") + for name, value in ( + ("cosmos_conversations_container", runtime.harness.conversations), + ("cosmos_messages_container", runtime.harness.messages), + ("get_settings", lambda: dict(runtime.harness.settings)), + ("get_user_settings", lambda *args, **kwargs: {"settings": {}}), + ): + monkeypatch.setattr(history, name, value) + blueprint = Blueprint("planning_history_probe", __name__) + blueprint.before_request(modules.auth.user_required_blueprint()) + history.register_route_backend_conversations(blueprint) + runtime.app.register_blueprint(blueprint) + runtime.harness.replies.append(json.dumps({"kind": "plan", "steps": []})) + runtime.client.post(PLAN_PATH, json=body(), buffered=True) + response = runtime.client.get("/api/get_messages", query_string={"conversation_id": CONVERSATION}) + assert response.status_code == 200, response.get_data(as_text=True) + messages = response.get_json()["messages"] + assert len(messages) == 1 + assert messages[0]["content"] == MESSAGE + assert messages[0]["metadata"]["orchestration"]["planning_status"] == "failed" + assert planning.PLANNING_METADATA not in messages[0]["metadata"] + assert "claim_id" not in response.get_data(as_text=True) + assert "input_fingerprint" not in response.get_data(as_text=True) + + +def test_source_scope_failure_after_admission_keeps_the_question(empty_history_runtime, modules, monkeypatch): + runtime = empty_history_runtime + + @contextmanager + def unavailable(*args, **kwargs): + raise RuntimeError("PRIVATE_SOURCE_DIAGNOSTIC") + yield + + monkeypatch.setattr(modules.route, "public_chat_execution_scope", unavailable) + response = runtime.client.post(PLAN_PATH, json=body(), buffered=True) + frames = sse_frames(response) + current = status(runtime) + assert any(frame.get("message_persisted") for frame in frames) + assert any(frame.get("error") for frame in frames) + assert current["status"] == "failed" + assert len(questions(runtime)) == 1 + assert runtime.harness.model_calls == [] + assert "PRIVATE_SOURCE_DIAGNOSTIC" not in response.get_data(as_text=True) + + +def test_explicit_retry_uses_saved_inputs_and_one_question(empty_history_runtime): + runtime = empty_history_runtime + runtime.harness.replies.append(json.dumps({"kind": "plan", "steps": []})) + failed = runtime.client.post(PLAN_PATH, json=body(), buffered=True) + failed_frames = sse_frames(failed) + assert any(frame.get("error") for frame in failed_frames) + saved = status(runtime, include_request=True) + request = { + **saved["request"], "planning_submission_id": "submission-retry", + "requires_fresh_review": True, "approval_mode": "manual", + } + runtime.harness.replies.append(json.dumps(plan_doc())) + retried = runtime.client.post(PLAN_PATH, json=request, buffered=True) + frames = sse_frames(retried) + plan = next(frame["plan"] for frame in frames if frame.get("type") == "orchestration_plan") + current = status(runtime) + assert len(questions(runtime)) == 1 + assert plan["requires_fresh_review"] is True + assert plan["approval"]["mode"] == "manual" + assert current["status"] == "awaiting_review" + + +def test_stop_before_acknowledgement_is_durable(empty_history_runtime): + runtime = empty_history_runtime + stopped = runtime.client.post( + f"{STATUS_PATH}/cancel", + json={"conversation_id": CONVERSATION, "submission_id": "submission-1"}, + ) + response = runtime.client.post(PLAN_PATH, json=body(approval_mode="auto"), buffered=True) + frames = sse_frames(response) + current = status(runtime) + assert stopped.status_code == 200 + assert any(frame.get("message_persisted") for frame in frames) + assert current["status"] == "cancelled" + assert len(questions(runtime)) == 1 + assert runtime.harness.model_calls == [] + assert runtime.harness.assistant_messages() == [] + + +def test_early_stop_is_not_erased_by_an_unrelated_conversation_write(empty_history_runtime): + runtime = empty_history_runtime + before_stop = runtime.harness.conversations.read_item(CONVERSATION, CONVERSATION) + stopped = runtime.client.post( + f"{STATUS_PATH}/cancel", + json={"conversation_id": CONVERSATION, "submission_id": "submission-1"}, + ) + runtime.harness.conversations.upsert_item({**before_stop, "title": "Concurrent title update"}) + response = runtime.client.post(PLAN_PATH, json=body(approval_mode="auto"), buffered=True) + frames = sse_frames(response) + current = status(runtime) + assert stopped.status_code == 200 + assert any(frame.get("message_persisted") for frame in frames) + assert current["status"] == "cancelled" + assert runtime.harness.model_calls == [] + + +def test_stop_fences_a_late_auto_plan(empty_history_runtime, modules, monkeypatch): + runtime = empty_history_runtime + runtime.harness.replies.append(json.dumps(plan_doc())) + entered, release, _settled = hold_planner(runtime, modules, monkeypatch) + response = runtime.client.post(PLAN_PATH, json=body(approval_mode="auto"), buffered=False) + try: + planner_entered = entered.wait(timeout=10) + assert planner_entered + stopped = runtime.client.post( + f"{STATUS_PATH}/cancel", + json={"conversation_id": CONVERSATION, "submission_id": "submission-1"}, + ) + assert stopped.status_code == 200 + finally: + release.set() + frames = sse_frames(response) + current = status(runtime) + saved_runs = modules.runs.list_conversation_runs(CONVERSATION, "owner", strict=True) + assert any(frame.get("error") for frame in frames) + assert current["status"] == "cancelled" + assert saved_runs == [] + assert {item["record_type"] for item in runtime.harness.runs.items.values()} == {"planning_cancellation"} + assert runtime.harness.assistant_messages() == [] + + +def test_turn_reads_and_stop_require_conversation_ownership(empty_history_runtime): + runtime = empty_history_runtime + runtime.harness.replies.append(json.dumps({"kind": "plan", "steps": []})) + runtime.client.post(PLAN_PATH, json=body(), buffered=True) + login(runtime, user_id="another-user") + read = runtime.client.get(STATUS_PATH, query_string={"conversation_id": CONVERSATION}) + stop = runtime.client.post( + f"{STATUS_PATH}/cancel", + json={"conversation_id": CONVERSATION, "submission_id": "submission-1"}, + ) + assert read.status_code == stop.status_code == 404 + + +@pytest.mark.parametrize("authenticated", [False, True]) +def test_turn_routes_keep_login_and_user_role_guards(empty_history_runtime, authenticated): + runtime = empty_history_runtime + if authenticated: + login(runtime, roles=()) + else: + runtime.client.delete_cookie(runtime.app.config["SESSION_COOKIE_NAME"]) + read = runtime.client.get(STATUS_PATH, query_string={"conversation_id": CONVERSATION}) + stop = runtime.client.post( + f"{STATUS_PATH}/cancel", + json={"conversation_id": CONVERSATION, "submission_id": "submission-1"}, + ) + assert read.status_code == stop.status_code == (403 if authenticated else 401) + + +def test_auto_admission_failure_keeps_an_explicit_manual_retry(empty_history_runtime, modules, monkeypatch): + runtime = empty_history_runtime + prepare = modules.route._prepare_execution_stream + monkeypatch.setattr( + modules.route, "_prepare_execution_stream", + lambda *args, **kwargs: (modules.route.jsonify({"error": "Execution is temporarily unavailable."}), 503), + ) + runtime.harness.replies.append(json.dumps(plan_doc())) + runtime.client.post(PLAN_PATH, json=body(approval_mode="auto"), buffered=True) + failed = status(runtime, include_request=True) + assert failed["status"] == "failed" + assert failed["error_code"] == "auto_admission_failed" + assert failed["run_started"] is False + assert runtime.harness.assistant_messages() == [] + monkeypatch.setattr(modules.route, "_prepare_execution_stream", prepare) + runtime.harness.replies.append(json.dumps(plan_doc())) + response = runtime.client.post(PLAN_PATH, json={ + **failed["request"], "approval_mode": "manual", "requires_fresh_review": True, + "planning_submission_id": "explicit-manual-retry", + }, buffered=True) + frames = sse_frames(response) + current = status(runtime) + assert any(frame.get("type") == "orchestration_plan" for frame in frames) + assert current["status"] == "awaiting_review" + assert len(questions(runtime)) == 1 + assert runtime.harness.assistant_messages() == [] + + +@pytest.mark.parametrize("new_submission", [False, True]) +def test_changed_inputs_cannot_reuse_the_saved_question(empty_history_runtime, new_submission): + runtime = empty_history_runtime + runtime.harness.replies.append(json.dumps({"kind": "plan", "steps": []})) + runtime.client.post(PLAN_PATH, json=body(), buffered=True) + count = len(runtime.harness.model_calls) + changed = body(selected_document_ids=["different-source"]) + if new_submission: + changed.update(planning_submission_id="changed-selection", requires_fresh_review=True) + response = runtime.client.post(PLAN_PATH, json=changed, buffered=True) + frames = sse_frames(response) + current = status(runtime, include_request=True) + assert any(frame.get("error") for frame in frames) + assert len(runtime.harness.model_calls) == count + assert current["request"]["selected_document_ids"] == [] + assert len(questions(runtime)) == 1 + + +def test_sdk_claim_fencing_stale_worker_and_public_history(monkeypatch): + messages = AtomicMemoryContainer("conversation_id") + prepared = planning.new_planning_attempt(body(), TURN, "submission-1") + question = { + "id": planning.planning_question_id(CONVERSATION, TURN), + "conversation_id": CONVERSATION, "role": "user", "content": MESSAGE, + "timestamp": prepared["updated_at"], + "metadata": {"orchestration_turn_id": TURN, planning.PLANNING_METADATA: prepared}, + } + messages.create_item(question) + claimed, owned = planning.claim_planning_attempt(messages, CONVERSATION, question["id"], prepared) + assert owned and claimed["claim_id"] == prepared["claim_id"] + duplicate = planning.new_planning_attempt(body(), TURN, "submission-1") + _state, duplicate_owned = planning.claim_planning_attempt(messages, CONVERSATION, question["id"], duplicate) + assert not duplicate_owned + public = planning.public_planning_message(question) + assert planning.PLANNING_METADATA not in public["metadata"] + assert public["metadata"]["orchestration"]["planning_status"] == "planning" + now = planning._now() + monkeypatch.setattr(planning, "_now", lambda: now + timedelta(seconds=planning.PLANNING_LEASE_SECONDS + 1)) + _question, stale = planning.read_planning_attempt(messages, CONVERSATION, question["id"], TURN) + assert type(_question) is dict + assert stale["status"] == "interrupted" + with pytest.raises(planning.PlanningStateError): + planning.require_planning_claim(messages, CONVERSATION, question["id"], TURN, claimed["claim_id"]) + changed = deepcopy(body()) + changed["selected_document_ids"] = ["different-source"] + replacement = planning.new_planning_attempt(changed, TURN, "submission-1") + with pytest.raises(planning.PlanningStateError): + planning.claim_planning_attempt(messages, CONVERSATION, question["id"], replacement) + + +def test_a_racing_stop_cannot_be_overwritten_by_a_heartbeat(): + messages = AtomicMemoryContainer("conversation_id") + prepared = planning.new_planning_attempt(body(), TURN, "submission-1") + message_id = planning.planning_question_id(CONVERSATION, TURN) + messages.create_item({ + "id": message_id, "conversation_id": CONVERSATION, "role": "user", "content": MESSAGE, + "metadata": {"orchestration_turn_id": TURN, planning.PLANNING_METADATA: prepared}, + }) + messages.before_replace = lambda: planning.cancel_planning_attempt( + messages, CONVERSATION, message_id, TURN, submission_id=prepared["submission_id"], + ) + with pytest.raises(planning.PlanningStateError): + planning.heartbeat_planning_attempt(messages, CONVERSATION, message_id, TURN, prepared["claim_id"]) + question, current = planning.read_planning_attempt(messages, CONVERSATION, message_id, TURN) + assert current["status"] == "cancelled" + assert question["_etag"] + + +def test_a_late_stop_cannot_cancel_a_newer_planning_submission(): + messages = AtomicMemoryContainer("conversation_id") + prepared = planning.new_planning_attempt(body(), TURN, "submission-1") + message_id = planning.planning_question_id(CONVERSATION, TURN) + messages.create_item({ + "id": message_id, "conversation_id": CONVERSATION, "role": "user", "content": MESSAGE, + "metadata": {"orchestration_turn_id": TURN, planning.PLANNING_METADATA: prepared}, + }) + planning.update_planning_attempt( + messages, CONVERSATION, message_id, TURN, {"status": "awaiting_review"}, + claim_id=prepared["claim_id"], + ) + replacement = planning.new_planning_attempt( + body(revision=1, planning_submission_id="submission-2"), TURN, "submission-2", revision=1, + ) + messages.before_replace = lambda: planning.claim_planning_attempt( + messages, CONVERSATION, message_id, replacement, + ) + with pytest.raises(planning.PlanningStateError): + planning.cancel_planning_attempt( + messages, CONVERSATION, message_id, TURN, + submission_id=prepared["submission_id"], claim_id=prepared["claim_id"], + ) + _question, current = planning.read_planning_attempt(messages, CONVERSATION, message_id, TURN) + assert current["submission_id"] == "submission-2" + assert current["status"] == "planning" + planning.require_planning_claim(messages, CONVERSATION, message_id, TURN, replacement["claim_id"]) diff --git a/functional_tests/test_orchestration_selected_source_planning.py b/functional_tests/test_orchestration_selected_source_planning.py index e2273df9a..e7040fc5b 100644 --- a/functional_tests/test_orchestration_selected_source_planning.py +++ b/functional_tests/test_orchestration_selected_source_planning.py @@ -1,7 +1,7 @@ # test_orchestration_selected_source_planning.py """Selected attachments remain usable during planning and clarification. -Version: 0.261.357 +Version: 0.261.415 Implemented in: 0.261.355 Uses real source projection, parsers, resolution and HTTP planning with external @@ -692,6 +692,9 @@ def test_clarification_adds_an_authorized_source_without_duplicate_turns( "clarification_context": {"reason": "source_selection", "document_ids": ["narrative-doc"]}, })] first = frames(runtime.client.post("/api/v2/orchestration/plan", json=body, buffered=True)) + question_id = next( + frame["user_message_id"] for frame in first if frame.get("type") == "user_message_persisted" + ) question = next(frame["elicitation"] for frame in first if frame.get("elicitation")) harness.replies = [ json.dumps({ @@ -730,5 +733,7 @@ def test_clarification_adds_an_authorized_source_without_duplicate_turns( assert pending["turn_context"]["seeds"]["document_ids"] == ["narrative-doc", "tabular-doc"] assert len(pending["answered_questions"]) == 1 assert pending["user_message"] == request - assert sum(row.get("content") == request for row in harness.messages.items.values()) == 0 + saved_questions = [row for row in harness.messages.items.values() if row.get("content") == request] + assert len(saved_questions) == 1 + assert saved_questions[0]["id"] == question_id assert harness.blobs.file_uploads == 0 and harness.assistant_messages() == [] diff --git a/functional_tests/test_orchestration_shared_conversations.py b/functional_tests/test_orchestration_shared_conversations.py index 4a6ae528d..005a0f2a7 100644 --- a/functional_tests/test_orchestration_shared_conversations.py +++ b/functional_tests/test_orchestration_shared_conversations.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for Orchestrate in shared conversations. -Version: 0.261.270 +Version: 0.261.415 Implemented in: 0.261.270 Before 0.261.270 an orchestrated request in a shared conversation created a private @@ -25,6 +25,9 @@ from pathlib import Path import pytest +from azure.core import MatchConditions +from azure.core.exceptions import AzureError +from azure.cosmos._cosmos_responses import CosmosDict from azure.cosmos.exceptions import CosmosResourceNotFoundError TESTS = Path(__file__).resolve().parent @@ -62,6 +65,32 @@ def query_items(self, query, parameters=None, partition_key=None, **kwargs): return rows[:1] +def test_shared_message_storage_uses_sdk_points_and_plain_queries(): + messages = CollaborationMessages("conversation_id") + saved = messages.create_item({ + "id": "shared-sdk-message", "conversation_id": SHARED_ID, + "content": REQUEST, "metadata": {"source_message_id": "source-sdk-message"}, + }) + point = messages.read_item("shared-sdk-message", SHARED_ID) + replaced = messages.replace_item( + point["id"], {**point, "content": ANSWER}, etag=point["_etag"], + match_condition=MatchConditions.IfNotModified, + ) + rows = messages.query_items( + "SELECT * FROM c WHERE c.conversation_id = @conversation_id AND c.metadata.source_message_id = @source_message_id", + parameters=[ + {"name": "@conversation_id", "value": SHARED_ID}, + {"name": "@source_message_id", "value": "source-sdk-message"}, + ], + partition_key=SHARED_ID, + ) + assert type(saved) is type(point) is type(replaced) is CosmosDict + assert replaced["_etag"] != point["_etag"] + assert type(rows[0]) is dict and rows[0]["content"] == ANSWER + with pytest.raises(CosmosResourceNotFoundError): + messages.read_item("shared-sdk-message", "another-conversation") + + @pytest.fixture def shared(real_http_harness, modules, monkeypatch): collaboration = importlib.import_module("functions_collaboration") @@ -199,6 +228,51 @@ def test_other_participants_are_asked_to_turn_off_orchestrate(shared): assert shared_thread(shared) == [] and shared.events == [] +def test_failed_initial_planning_has_a_saved_shared_question_and_owner_status(shared): + shared.harness.replies = [json.dumps({"kind": "plan", "steps": []})] + response = shared.client.post("/api/v2/orchestration/plan", json={ + "conversation_id": SHARED_ID, "turn_id": "failed-shared-turn", "message": REQUEST, + "approval_mode": "manual", "planning_submission_id": "shared-submission", + }, buffered=True) + events = frames(response) + question, = shared_thread(shared) + query = { + "conversation_id": SHARED_ID, "message_id": question["metadata"]["source_message_id"], + "include_request": "1", + } + saved = shared.client.get("/api/v2/orchestration/turns/failed-shared-turn", query_string=query) + assert any(event.get("message_persisted") for event in events) + assert any(event.get("error") for event in events) + assert question["metadata"]["orchestration"]["planning_status"] == "planning" + assert question["metadata"]["orchestration"]["planning_submission_id"] == "shared-submission" + assert "_orchestration_planning" not in json.dumps(question) + assert saved.status_code == 200 + assert saved.get_json()["turn"]["status"] == "failed" + login(shared, user_id="guest") + denied = shared.client.get("/api/v2/orchestration/turns/failed-shared-turn", query_string=query) + assert denied.status_code == 404 + + +def test_shared_mirror_failure_is_not_acknowledged_or_sent_to_the_model(shared, monkeypatch): + collaboration = importlib.import_module("functions_collaboration") + + def fail(*args, **kwargs): + raise AzureError("PRIVATE_SHARED_WRITE_DIAGNOSTIC") + + monkeypatch.setattr(collaboration, "persist_collaboration_message", fail) + shared.harness.replies = [json.dumps({"kind": "plan", "steps": [compose_step()]})] + response = shared.client.post("/api/v2/orchestration/plan", json={ + "conversation_id": SHARED_ID, "turn_id": "unsaved-shared-turn", "message": REQUEST, + "approval_mode": "manual", "planning_submission_id": "shared-unsaved", + }, buffered=True) + events = frames(response) + assert any(event.get("code") == "planning_mirror_unavailable" for event in events) + assert not any(event.get("message_persisted") for event in events) + assert shared.harness.model_calls == [] + assert shared_thread(shared) == [] + assert "PRIVATE_SHARED_WRITE_DIAGNOSTIC" not in response.get_data(as_text=True) + + def test_anyone_else_cannot_tell_a_shared_conversation_exists(shared): login(shared, user_id="stranger") planning = plan(shared) diff --git a/functional_tests/test_orchestration_turn_recovery_identifiers.py b/functional_tests/test_orchestration_turn_recovery_identifiers.py index cad2da407..84b61b11d 100644 --- a/functional_tests/test_orchestration_turn_recovery_identifiers.py +++ b/functional_tests/test_orchestration_turn_recovery_identifiers.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for the identifiers that let an orchestration turn be found again. -Version: 0.261.270 +Version: 0.261.415 Implemented in: 0.261.099 Chat content-check metadata binding included in: 0.261.131 Shared conversation question posting included in: 0.261.270 @@ -202,7 +202,9 @@ def test_run_route_refuses_a_plan_that_already_ran(): """A second approval, from a second device, is refused rather than doing the work twice.""" print("Testing the run route refuses an already-run plan...") try: - body = _function_source("orchestration_run") + route_body = _function_source("orchestration_run") + assert "return _run_saved_plan(" in route_body + body = _function_source("_run_saved_plan") guard = re.search( r"if\s+(?:record\.get\('started_at'\)\s+or\s+)?record\.get\('status'\)\s+in\s+\(([^)]*)\)", body @@ -233,7 +235,9 @@ def test_run_route_trusts_the_record_not_the_request(): """The plan, seeds and question come from the stored run, not from whoever posted.""" print("Testing the run route reads the plan from the stored record...") try: - body = _function_source("orchestration_run") + route_body = _function_source("orchestration_run") + assert "return _run_saved_plan(" in route_body + body = _function_source("_run_saved_plan") for field in ("plan", "seeds", "user_message"): assert re.search(rf"record\.get\('{field}'", body), ( diff --git a/ui_tests/test_v2_new_chat_reset.py b/ui_tests/test_v2_new_chat_reset.py index 4f49ca2ba..7a473728a 100644 --- a/ui_tests/test_v2_new_chat_reset.py +++ b/ui_tests/test_v2_new_chat_reset.py @@ -1,7 +1,7 @@ # test_v2_new_chat_reset.py """ Browser regressions for starting a new chat in V2 while the open conversation is busy. -Version: 0.261.414 +Version: 0.261.415 Implemented in: 0.261.226 Clicking New chat while an orchestration turn was planning or running kept the old turn's @@ -186,6 +186,13 @@ def handle(self, route): # Answered so a regression shows up as an assertion, not a hung request. route.fulfill(json={"success": True}) return + if request.method == "POST" and path.startswith("/api/v2/orchestration/turns/") and path.endswith("/cancel"): + body = request.post_data_json + route.fulfill(json={ + "success": True, "turn_id": unquote(path.split("/")[-2]), + "submission_id": body["submission_id"], + }) + return if path == "/api/v2/orchestration/runs": route.fulfill(json={"runs": []}) return diff --git a/ui_tests/test_v2_orchestration_planning_persistence.py b/ui_tests/test_v2_orchestration_planning_persistence.py new file mode 100644 index 000000000..e3abd09dd --- /dev/null +++ b/ui_tests/test_v2_orchestration_planning_persistence.py @@ -0,0 +1,525 @@ +# test_v2_orchestration_planning_persistence.py +""" +Browser regressions for durable initial Orchestrate turns. +Version: 0.261.415 +Implemented in: 0.261.415 + +The production controller, stores, SSE reader, message list, composer, and CSS run +unchanged. A deterministic HTTP boundary retains accepted turns independently of +browser contexts. The shared Azure Playwright connection uses DefaultAzureCredential +when configured; no cloud resources or production conversations are modified. +""" + +import copy +import json +from urllib.parse import parse_qs, unquote, urlsplit + +import pytest +from playwright.sync_api import expect + +import test_v2_orchestration_plan_editor as editor_tests +import test_v2_orchestration_planning_retry as planning_tests +from test_v2_orchestration_plan_editor import connect_options, editor_assets, editor_browser # noqa: F401 + + +pytestmark = pytest.mark.ui +CONVERSATION = planning_tests.CONVERSATION +PROMPT = planning_tests.PROMPT +PLAN = planning_tests.PLAN +QUESTION_ID = "saved-planning-question" +ANSWER = "The original request completed while its browser was away." +FAILURE = "The request could not be planned. Your question was saved; retry to create a new plan." +SEEDS = {key: copy.deepcopy(value) for key, value in planning_tests.SEEDS.items() if key != "prompt_info"} + + +class DurablePlanningApi(planning_tests.PlanningApi): + def __init__(self, assets): + super().__init__(assets) + self.outcome = "planning" + self.turn = None + self.request = None + self.plan = None + self.records = {} + self.status_reads = [] + self.status_failure = False + self.conversations = [ + {"id": CONVERSATION, "title": "Original planning request"}, + {"id": "another-chat", "title": "Another conversation"}, + ] + + def save_question(self): + metadata = { + "orchestration_turn_id": self.turn["turn_id"], + "orchestration": { + "turn_id": self.turn["turn_id"], "planning_status": self.turn["status"], + "planning_submission_id": self.turn["submission_id"], + **({"run_id": self.turn["run_id"]} if self.turn.get("run_id") else {}), + **({"planning_error": self.turn["error"]} if self.turn.get("error") else {}), + **({"planning_run_started": self.turn["run_started"]} if "run_started" in self.turn else {}), + }, + } + question = { + "id": QUESTION_ID, "conversation_id": CONVERSATION, "role": "user", "content": PROMPT, + "timestamp": "2026-11-05T20:55:24Z", "metadata": metadata, + } + self.messages = [question, *[message for message in self.messages if message["id"] != QUESTION_ID]] + + def accept(self, body): + if self.request is None: + self.request = copy.deepcopy(body) + self.request.pop("planning_submission_id", None) + if self.request.get("active_group_id"): + self.request["active_group_ids"] = [self.request.pop("active_group_id")] + self.request["approval_mode"] = body.get("approval_mode", "manual") + self.request["revision"] = body.get("revision", 0) + self.turn = { + "conversation_id": CONVERSATION, "turn_id": body["turn_id"], "user_message_id": QUESTION_ID, + "submission_id": body["planning_submission_id"], "status": "planning", + "updated_at": "2026-11-05T20:55:25Z", "request": copy.deepcopy(self.request), + } + events = [{ + "type": "user_message_persisted", "conversation_id": CONVERSATION, + "turn_id": body["turn_id"], "user_message_id": QUESTION_ID, + "planning_submission_id": self.turn["submission_id"], + "message_persisted": True, "server_managed": True, + }] + if self.outcome in ("failed", "interrupted"): + self.turn.update(status=self.outcome, error=FAILURE, error_code="planning_failed") + events.append({"error": FAILURE, "code": "planning_failed", "done": True}) + elif self.outcome == "clarification": + question = { + "elicitation_id": "saved-style-question", "contract_version": 2, "run_id": "", + "conversation_id": CONVERSATION, "turn_id": body["turn_id"], "revision": 0, + "message": "What response style should I use?", + "requested_schema": { + "type": "object", + "properties": {"style": {"type": "string", "title": "Response style"}}, + "required": ["style"], + }, + "ui_hints": {"pages": [["style"]], "order": ["style"]}, + } + self.turn.update(status="awaiting_clarification", elicitation=question) + events.append({"type": "orchestration_elicitation", "elicitation": question, "done": True}) + elif self.outcome == "plan": + self.make_record(body.get("approval_mode", "manual")) + events.append({ + "type": "orchestration_plan", "plan": copy.deepcopy(self.plan), + "server_managed": True, "done": True, + "turn_identity": { + "conversation_id": CONVERSATION, "turn_id": self.turn["turn_id"], + "user_message_id": QUESTION_ID, "run_id": self.plan["run_id"], + }, + }) + else: + # A dropped observer after durable acknowledgement, while the independent + # server-side attempt remains in planning. Only GET status follows it. + events.append({"type": "thought", "content": "Planning the saved request."}) + self.save_question() + return events + + def make_record(self, mode="auto"): + self.plan = editor_tests.make_plan(CONVERSATION, self.turn["turn_id"], mode) + if mode in ("auto", "timed"): + self.plan["approval"]["state"] = "approved" + self.plan["status"] = "approved" + running = mode == "auto" + self.turn.update( + status="running" if running else "awaiting_review", + run_id=self.plan["run_id"], run_started=running, + ) + self.records[self.plan["run_id"]] = { + "run_id": self.plan["run_id"], "conversation_id": CONVERSATION, + "turn_id": self.turn["turn_id"], "plan": copy.deepcopy(self.plan), + "status": "running" if running else self.plan["status"], + "created_at": "2026-11-05T20:55:30Z", + "started_at": "2026-11-05T20:55:31Z" if running else None, + "user_message_id": QUESTION_ID, "user_message": PROMPT, "assistant_message_id": None, + "attempt_index": 1, "turn_index": 0, "outputs": [], + "plan_summary": { + "plan_id": self.plan["plan_id"], "turn_id": self.turn["turn_id"], + "step_count": len(self.plan["steps"]), "status": self.plan["status"], + "intent_summary": self.plan["intent"]["summary"], + }, + } + + def complete(self): + if self.turn["status"] == "cancelled": + return False + if not self.plan: + self.make_record() + record = self.records[self.plan["run_id"]] + record.update( + status="completed", outcome="completed", finalization_status="saved", message_saved=True, + assistant_message_id="saved-answer", completed_at="2026-11-05T20:56:00Z", + ) + record["plan"]["status"] = record["plan_summary"]["status"] = "completed" + self.turn.update(status="completed", run_started=True) + self.save_question() + if not any(message["id"] == "saved-answer" for message in self.messages): + self.messages.append({ + "id": "saved-answer", "conversation_id": CONVERSATION, + "role": "assistant", "content": ANSWER, "timestamp": "2026-11-05T20:56:00Z", + "metadata": {"orchestration": { + "turn_id": self.turn["turn_id"], "run_id": self.plan["run_id"], + "outcome": "completed", "finalization_status": "saved", "message_saved": True, + }}, + }) + return True + + def handle(self, route): + request = route.request + parsed = urlsplit(request.url) + path = parsed.path + body = request.post_data_json if request.post_data else None + if path == PLAN: + self.requests.append({"path": path, "method": request.method, "body": copy.deepcopy(body)}) + events = self.accept(body) + route.fulfill(content_type="text/event-stream", body="".join( + f"data: {json.dumps(event)}\n\n" for event in events + )) + return + if path.startswith("/api/v2/orchestration/turns/"): + self.requests.append({"path": path, "method": request.method, "body": copy.deepcopy(body)}) + if request.method == "GET": + self.status_reads.append(parse_qs(parsed.query)) + if request.method == "POST" and path.endswith("/cancel"): + if self.turn["status"] != "completed": + self.turn.update(status="cancelled") + self.save_question() + route.fulfill(json={ + "success": True, "turn_id": self.turn["turn_id"], + "submission_id": body["submission_id"], "turn": copy.deepcopy(self.turn), + }) + elif self.status_failure: + route.fulfill(status=503, json={"error": "Saved planning status is temporarily unavailable."}) + else: + route.fulfill(json={"turn": copy.deepcopy(self.turn)}) + return + if path == "/api/get_messages": + conversation = parse_qs(parsed.query).get("conversation_id", [""])[0] + route.fulfill(json={"messages": copy.deepcopy(self.messages) if conversation == CONVERSATION else []}) + return + if path.startswith("/api/chat/stream/status/"): + route.fulfill(json={"is_streaming": False, "active": False, "status": "idle"}) + return + if path.startswith("/api/conversations/") and path.endswith("/metadata"): + route.fulfill(json={"conversation_id": unquote(path.split("/")[3]), "title": "Saved conversation"}) + return + if path == editor_tests.RUNS: + route.fulfill(json={"runs": list(self.records.values())}) + return + if path.startswith(editor_tests.RUNS + "/"): + run_id = unquote(path.split("/")[5]) + if path.endswith("/steps"): + record = self.records[run_id] + route.fulfill(json={"steps": [ + {"step_id": step["step_id"], "step_index": index, + "status": "completed" if record["status"] == "completed" else "pending", "summary": ""} + for index, step in enumerate(record["plan"]["steps"]) + ]}) + else: + route.fulfill(json={"run": copy.deepcopy(self.records[run_id])}) + return + super().handle(route) + + +@pytest.fixture +def durable_ui(editor_browser, editor_assets): + context = editor_browser.new_context(viewport={"width": 1280, "height": 900}) + page = context.new_page() + api = DurablePlanningApi(editor_assets) + page.route("**/*", api.handle) + page.on("pageerror", lambda error: api.errors.append(str(error))) + try: + open_saved(page, api) + yield page, api + finally: + context.close() + assert not api.errors, api.errors + + +def open_saved(page, api): + planning_tests.mount(page, api) + page.evaluate( + "(id) => window.OrchHarness.stores.chat.useChatStore.getState().selectConversation(id, {kind: 'personal'})", + CONVERSATION, + ) + + +def submit(page, mode="auto"): + page.evaluate( + "(spec) => { void window.OrchHarness.controller.startOrchestrationPlan(spec); }", + {"conversationId": CONVERSATION, "message": PROMPT, "approvalMode": mode, "seeds": SEEDS}, + ) + expect(page.locator(f'[id="message-{QUESTION_ID}"]')).to_be_visible() + + +@pytest.mark.parametrize("width", [1280, 390]) +def test_navigate_back_recovers_question_and_background_auto_answer(durable_ui, width): + page, api = durable_ui + page.set_viewport_size({"width": width, "height": 900}) + submit(page) + expect(page.get_by_text("No messages in this conversation yet", exact=True)).to_have_count(0) + page.evaluate( + "() => window.OrchHarness.stores.chat.useChatStore.getState().selectConversation('another-chat', {kind: 'personal'})", + ) + expect(page.get_by_text(PROMPT, exact=True)).to_have_count(0) + completed = api.complete() + assert completed + page.evaluate( + "(id) => window.OrchHarness.stores.chat.useChatStore.getState().selectConversation(id, {kind: 'personal'})", + CONVERSATION, + ) + expect(page.get_by_text(PROMPT, exact=True)).to_have_count(1) + expect(page.get_by_text(ANSWER, exact=True)).to_have_count(1) + assert len(api.calls(PLAN)) == 1 + assert api.calls(editor_tests.RUN) == [] + overflow = page.evaluate("document.documentElement.scrollWidth > window.innerWidth") + assert not overflow + + +def test_fresh_browser_context_restores_planning_and_follows_the_original_run(durable_ui, editor_browser): + page, api = durable_ui + submit(page) + page.close() + fresh = editor_browser.new_context(viewport={"width": 1280, "height": 900}) + try: + restored = fresh.new_page() + restored.route("**/*", api.handle) + restored.on("pageerror", lambda error: api.errors.append(str(error))) + open_saved(restored, api) + expect(restored.get_by_text(PROMPT, exact=True)).to_have_count(1) + expect(restored.get_by_role("button", name="Stop generating", exact=True)).to_be_visible() + completed = api.complete() + assert completed + expect(restored.get_by_text(ANSWER, exact=True)).to_have_count(1, timeout=15000) + assert len(api.calls(PLAN)) == 1 + assert api.calls(editor_tests.RUN) == [] + finally: + fresh.close() + + +@pytest.mark.parametrize("failure", ["failed", "interrupted"]) +def test_failure_survives_reload_and_retry_keeps_canonical_selections(durable_ui, failure): + page, api = durable_ui + api.outcome = failure + submit(page, "manual") + expect(page.get_by_role("alert").filter(has_text=FAILURE)).to_have_count(1) + open_saved(page, api) + expect(page.get_by_text(PROMPT, exact=True)).to_have_count(1) + expect(page.get_by_role("alert").filter(has_text=FAILURE)).to_have_count(1) + api.outcome = "plan" + page.get_by_role("button", name="Retry", exact=True).click() + expect(page.get_by_role("button", name="Approve and run the plan").first).to_be_visible() + expect(page.get_by_text(PROMPT, exact=True)).to_have_count(1) + original, retried = [request["body"] for request in api.calls(PLAN)] + for field in ("selected_document_ids", "model_deployment", "model_endpoint_id", "doc_scope"): + assert retried[field] == original[field] + assert retried["active_group_ids"] == [original["active_group_id"]] + assert retried["planning_submission_id"] != original["planning_submission_id"] + assert retried["requires_fresh_review"] is True + assert retried["approval_mode"] == "manual" + assert api.calls(editor_tests.RUN) == [] + + +def test_clarification_is_restored_and_answered_after_reload(durable_ui): + page, api = durable_ui + api.outcome = "clarification" + submit(page, "manual") + expect(page.get_by_role("textbox", name="Response style", exact=True)).to_be_visible() + open_saved(page, api) + expect(page.get_by_text(PROMPT, exact=True)).to_have_count(1) + page.get_by_role("textbox", name="Response style", exact=True).fill("A concise explanation.") + api.outcome = "plan" + page.get_by_role("button", name="Review answers", exact=True).click() + page.get_by_role("button", name="Use these answers", exact=True).click() + expect(page.get_by_role("button", name="Approve and run the plan").first).to_be_visible() + submitted = api.calls(PLAN)[1]["body"] + assert submitted["message"] == PROMPT + assert submitted["elicitation_id"] == "saved-style-question" + assert submitted["elicitation_response"]["content"] == {"style": "A concise explanation."} + expect(page.get_by_text(PROMPT, exact=True)).to_have_count(1) + + +def test_stop_after_reload_is_durable_without_a_live_stream(durable_ui): + page, api = durable_ui + submit(page) + open_saved(page, api) + page.get_by_role("button", name="Stop generating", exact=True).click() + expect(page.get_by_text("Planning stopped. Your question was saved.", exact=True)).to_be_visible() + completed = api.complete() + assert not completed + open_saved(page, api) + expect(page.get_by_text(PROMPT, exact=True)).to_have_count(1) + expect(page.get_by_text("Planning stopped. Your question was saved.", exact=True)).to_be_visible() + assert len(api.calls(PLAN)) == 1 + assert api.calls(editor_tests.RUN) == [] + + +@pytest.mark.parametrize("mode", ["manual", "timed"]) +def test_opening_a_saved_review_never_starts_its_countdown_or_execution(durable_ui, mode): + page, api = durable_ui + api.outcome = "plan" + api.accept({ + **SEEDS, "message": PROMPT, "conversation_id": CONVERSATION, "turn_id": "saved-review", + "planning_submission_id": "saved-review-submission", "approval_mode": mode, + }) + page.clock.install() + open_saved(page, api) + expect(page.get_by_role("button", name="Approve and run the plan").first).to_be_visible() + page.clock.fast_forward(5000) + assert api.calls(editor_tests.RUN) == [] + + +def test_server_managed_auto_plan_does_not_send_a_second_run_post(durable_ui): + page, api = durable_ui + api.outcome = "plan" + submit(page) + page.wait_for_function( + "() => window.OrchHarness.stores.chat.useChatStore.getState().orchestrationSurface === 'running'", + ) + completed = api.complete() + assert completed + expect(page.get_by_text(ANSWER, exact=True)).to_have_count(1, timeout=15000) + assert len(api.calls(PLAN)) == 1 + assert api.calls(editor_tests.RUN) == [] + + +def test_stop_racing_with_completion_restores_the_answer_instead_of_claiming_it_stopped(durable_ui): + page, api = durable_ui + submit(page) + stop = page.get_by_role("button", name="Stop generating", exact=True) + expect(stop).to_be_visible() + completed = api.complete() + assert completed + stop.click() + expect(page.get_by_text(ANSWER, exact=True)).to_have_count(1, timeout=15000) + expect(page.get_by_text("Planning stopped. Your question was saved.", exact=True)).to_have_count(0) + assert len(api.calls(PLAN)) == 1 + assert api.calls(editor_tests.RUN) == [] + + +def test_status_outage_keeps_the_saved_question_without_automatic_retry(durable_ui): + page, api = durable_ui + submit(page) + api.status_failure = True + open_saved(page, api) + expect(page.get_by_text(PROMPT, exact=True)).to_have_count(1) + expect(page.get_by_role("alert").filter(has_text="Saved planning status could not be reached")).to_be_visible() + assert len(api.calls(PLAN)) == 1 + api.status_failure = False + completed = api.complete() + assert completed + expect(page.get_by_text(ANSWER, exact=True)).to_have_count(1, timeout=15000) + assert len(api.calls(PLAN)) == 1 + + +@pytest.mark.parametrize("viewer", ["tester", "guest"]) +def test_shared_owner_recovers_using_the_source_question_without_querying_for_other_participants(durable_ui, viewer): + page, api = durable_ui + api.outcome = "failed" + api.accept({ + **SEEDS, "message": PROMPT, "conversation_id": CONVERSATION, "turn_id": "shared-planning-turn", + "planning_submission_id": "shared-planning-submission", "approval_mode": "manual", + }) + mirrored = copy.deepcopy(api.messages[0]) + mirrored.update(id="shared-question-copy", message_kind="ai_request", sender={"user_id": "tester"}) + mirrored["metadata"].update(source_message_id=QUESTION_ID, sender={"user_id": "tester"}) + mirrored["metadata"]["orchestration"].update(planning_status="planning") + mirrored["metadata"]["orchestration"].pop("planning_error", None) + page.evaluate( + """async (spec) => { + const H = window.OrchHarness; + const bootstrap = H.stores.bootstrap.useBootstrapStore.getState().data; + H.stores.bootstrap.useBootstrapStore.setState({ + data: {...bootstrap, user: {...bootstrap.user, id: spec.viewer}}, + }); + H.stores.chat.useChatStore.setState({ + activeConversationKind: 'collaborative', messages: [spec.message], + }); + await H.controller.resumeOrchestrationPlanning(spec.conversation); + }""", + {"message": mirrored, "viewer": viewer, "conversation": CONVERSATION}, + ) + expect(page.locator('[id="message-shared-question-copy"]')).to_have_count(1) + if viewer == "tester": + expect(page.get_by_role("alert").filter(has_text=FAILURE)).to_have_count(1) + assert api.status_reads[0]["message_id"] == [QUESTION_ID] + else: + expect(page.get_by_role("alert").filter(has_text=FAILURE)).to_have_count(0) + assert api.status_reads == [] + + +def test_saved_acknowledgement_merges_an_already_mirrored_question(durable_ui): + page, _api = durable_ui + page.evaluate( + """(spec) => { + const store = window.OrchHarness.stores.chat.useChatStore; + const pendingId = store.getState().beginOrchestrationTurn( + spec.conversation, spec.message, true, 'mirrored-turn', + ); + store.setState((state) => ({messages: [...state.messages, { + id: 'public-question', conversation_id: spec.conversation, role: 'user', + content: spec.message, metadata: { + orchestration_turn_id: 'mirrored-turn', source_message_id: 'source-question', + }, + }]})); + store.getState().reconcileOrchestrationUserMessage({ + conversationId: spec.conversation, turnId: 'mirrored-turn', + optimisticId: pendingId, savedId: 'source-question', + }); + }""", + {"conversation": CONVERSATION, "message": PROMPT}, + ) + expect(page.get_by_text(PROMPT, exact=True)).to_have_count(1) + expect(page.locator('[id="message-public-question"]')).to_have_count(1) + + +def test_restored_waiting_run_does_not_take_the_composer_stream(durable_ui): + page, api = durable_ui + api.outcome = "plan" + api.accept({ + **SEEDS, "message": PROMPT, "conversation_id": CONVERSATION, "turn_id": "waiting-turn", + "planning_submission_id": "waiting-submission", "approval_mode": "auto", + }) + api.turn["status"] = "waiting" + api.records[api.plan["run_id"]]["status"] = "waiting" + api.records[api.plan["run_id"]]["plan"]["status"] = "waiting" + api.save_question() + open_saved(page, api) + expect(page.get_by_text("Waiting for results", exact=True).first).to_be_visible() + expect(page.get_by_role("button", name="Send message", exact=True)).to_be_visible() + expect(page.get_by_role("button", name="Stop generating", exact=True)).to_have_count(0) + current = page.evaluate( + "() => {const s = window.OrchHarness.stores.chat.useChatStore.getState(); " + "return {streaming: s.streaming, phase: s.orchestrationSurface};}", + ) + assert current == {"streaming": False, "phase": None} + assert api.calls(editor_tests.RUN) == [] + + +def test_background_planning_does_not_steal_an_ordinary_stream_or_its_stop(durable_ui): + page, api = durable_ui + api.accept({ + **SEEDS, "message": PROMPT, "conversation_id": CONVERSATION, "turn_id": "background-turn", + "planning_submission_id": "background-submission", "approval_mode": "auto", + }) + page.evaluate( + """async (spec) => { + const H = window.OrchHarness; + H.stores.chat.useChatStore.setState({ + messages: spec.messages, streaming: true, orchestrationSurface: null, + streamingContent: 'An ordinary reply is being read.', + }); + await H.controller.resumeOrchestrationPlanning(spec.conversation); + }""", + {"messages": api.messages, "conversation": CONVERSATION}, + ) + current = page.evaluate( + "() => {const s = window.OrchHarness.stores.chat.useChatStore.getState(); " + "return {phase: s.orchestrationSurface, content: s.streamingContent};}", + ) + assert current == {"phase": None, "content": "An ordinary reply is being read."} + page.get_by_role("button", name="Stop generating", exact=True).click() + assert not [request for request in api.requests if request["path"].endswith("/cancel")] + assert api.turn["status"] == "planning" diff --git a/ui_tests/test_v2_orchestration_planning_retry.py b/ui_tests/test_v2_orchestration_planning_retry.py index a0cf65f8c..fb35a91da 100644 --- a/ui_tests/test_v2_orchestration_planning_retry.py +++ b/ui_tests/test_v2_orchestration_planning_retry.py @@ -1,7 +1,7 @@ # test_v2_orchestration_planning_retry.py """ Browser regressions for failed, unpersisted orchestration planning turns. -Version: 0.261.323 +Version: 0.261.415 Implemented in: 0.261.115 Leaving a planning turn for a new chat also releases its Thinking state and Stop: 0.261.226 @@ -12,7 +12,7 @@ import copy import json -from urllib.parse import urlsplit +from urllib.parse import unquote, urlsplit import pytest from playwright.sync_api import expect @@ -86,8 +86,22 @@ def handle(self, route): if is_pending_actions_list(request.method, path): route.fulfill(json=pending_actions_payload()) return + if request.method == "GET" and path in { + "/api/action-credentials/pending", "/api/v2/user-input/pending", + }: + route.fulfill(json={"requests": [], "csrf_token": "fixture-csrf"}) + return + if request.method == "GET" and path == "/api/v2/orchestration/questions": + route.fulfill(json={"questions": [], "csrf_token": "fixture-csrf"}) + return body = request.post_data_json if request.post_data else None self.requests.append({"path": path, "method": request.method, "body": copy.deepcopy(body)}) + if path.startswith("/api/v2/orchestration/turns/") and path.endswith("/cancel"): + route.fulfill(json={ + "success": True, "turn_id": unquote(path.split("/")[-2]), + "submission_id": body["submission_id"], + }) + return if path == PLAN: if self.mode == "held": self.pending.append((route, body)) @@ -245,7 +259,12 @@ def test_failed_planning_retry_reuses_exact_context_without_a_second_bubble(plan page.get_by_role("button", name="Retry", exact=True).click() expect(page.get_by_role("button", name="Approve and run the plan").first).to_be_visible() assert len(api.calls(PLAN)) == 2 - assert api.calls(PLAN)[1]["body"] == {**api.calls(PLAN)[0]["body"], "requires_fresh_review": True} + original_body, retry_body = [request["body"] for request in api.calls(PLAN)] + assert retry_body == { + **original_body, "requires_fresh_review": True, + "planning_submission_id": retry_body["planning_submission_id"], + } + assert retry_body["planning_submission_id"] != original_body["planning_submission_id"] state = page.evaluate("""() => { const S = window.OrchHarness.stores.chat.useChatStore.getState(); return {messages: S.messages, error: S.streamError, streaming: S.streaming}; @@ -286,7 +305,12 @@ def test_failed_planning_retry_keeps_the_original_group_agent(planning_ui): api.mode = "plan" page.get_by_role("button", name="Retry", exact=True).click() expect(page.get_by_role("button", name="Approve and run the plan").first).to_be_visible() - assert api.calls(PLAN)[1]["body"] == {**api.calls(PLAN)[0]["body"], "requires_fresh_review": True} + original_body, retry_body = [request["body"] for request in api.calls(PLAN)] + assert retry_body == { + **original_body, "requires_fresh_review": True, + "planning_submission_id": retry_body["planning_submission_id"], + } + assert retry_body["planning_submission_id"] != original_body["planning_submission_id"] assert api.calls(PLAN)[1]["body"]["agent_info"] == seeds["agent_info"] assert "model_deployment" not in api.calls(PLAN)[1]["body"]