From 28e5d092d1621401ea54c12ee216c1210636c4bd Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 9 Oct 2026 19:35:59 -0400 Subject: [PATCH 1/2] Fix saved-input chat retries and review-first orchestration regeneration Preserve viewed-attempt settings, durably admit retries once, and scope progress and errors to their logical turn. Keep fresh orchestration planning distinct from checkpoint recovery and require explicit review before execution. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../functions_chat_model_catalog.py | 141 ++++ .../single_app/functions_chat_retry.py | 657 +++++++++++++++++ .../functions_orchestration_context.py | 20 +- .../functions_orchestration_execution.py | 27 + .../functions_orchestration_plan_revisions.py | 13 +- .../functions_orchestration_runs.py | 1 + application/single_app/route_backend_chats.py | 217 +++++- .../single_app/route_backend_conversations.py | 693 +++++------------- .../single_app/route_backend_orchestration.py | 550 +++++++++++++- .../single_app/route_frontend_chats.py | 143 +--- .../single_app/static/js/chat/chat-retry.js | 15 +- .../src/components/chat/MessageActions.tsx | 2 +- .../v2_ui/src/components/chat/MessageList.tsx | 110 ++- .../components/chat/OrchestrationPlanCard.tsx | 6 + .../v2_ui/src/lib/chatRetryAttempts.ts | 154 ++++ application/v2_ui/src/lib/endpoints.ts | 37 +- application/v2_ui/src/lib/orchestration.ts | 2 + .../v2_ui/src/lib/orchestrationController.ts | 93 ++- .../v2_ui/src/lib/orchestrationPlan.ts | 4 +- application/v2_ui/src/stores/chatStore.ts | 290 ++++++-- .../fixes/CHAT_MESSAGE_RETRY_FIX.md | 115 +++ .../review-and-edit-orchestration-plans.md | 36 +- docs/reference/chat-controls.md | 34 +- .../test_route_blueprint_policy_inventory.py | 14 +- .../test_chat_retry_attempt_lifecycle.py | 395 ++++++++++ .../test_chat_retry_request_contract.py | 212 ++++++ ...est_chat_retry_thought_tracker_init_fix.py | 22 +- ...test_orchestration_message_regeneration.py | 499 +++++++++++++ .../test_v2_model_identity_and_scope.py | 12 +- ui_tests/test_v2_chat_retry.py | 443 +++++++++++ .../test_v2_orchestration_planning_retry.py | 29 +- ui_tests/test_v2_orchestration_recovery.py | 8 +- 33 files changed, 4156 insertions(+), 840 deletions(-) create mode 100644 application/single_app/functions_chat_model_catalog.py create mode 100644 application/single_app/functions_chat_retry.py create mode 100644 application/v2_ui/src/lib/chatRetryAttempts.ts create mode 100644 docs/explanation/fixes/CHAT_MESSAGE_RETRY_FIX.md create mode 100644 functional_tests/test_chat_retry_attempt_lifecycle.py create mode 100644 functional_tests/test_chat_retry_request_contract.py create mode 100644 functional_tests/test_orchestration_message_regeneration.py create mode 100644 ui_tests/test_v2_chat_retry.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 9125c2757..3ba6aef1f 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.316" +VERSION = "0.261.317" 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_model_catalog.py b/application/single_app/functions_chat_model_catalog.py new file mode 100644 index 000000000..31e087652 --- /dev/null +++ b/application/single_app/functions_chat_model_catalog.py @@ -0,0 +1,141 @@ +# functions_chat_model_catalog.py +"""The authorized model catalog shared by chat selection and request replay.""" + +from functions_ai_connections import filter_model_endpoints_by_capability +from functions_governance import ensure_governance_access +from functions_group import get_group_model_endpoints +from functions_model_capabilities import REASONING_IDENTIFIER_FIELDS, resolve_model_reasoning_policy +from functions_model_catalog import get_effective_model_profiles, model_profile_projection +from functions_model_endpoint_types import resolve_model_endpoint_request_model +from functions_settings import normalize_model_endpoints, sanitize_model_endpoints_for_frontend + + +def normalize_chat_model_value(value): + return str(value or '').strip() + + +def filter_chat_model_endpoints_by_governance(user_id, endpoints, feature_key): + try: + ensure_governance_access(feature_key, user_id) + except PermissionError: + return [] + + allowed_endpoints = [] + for endpoint in endpoints or []: + if not isinstance(endpoint, dict): + continue + endpoint_id = str(endpoint.get('id') or '').strip() + if endpoint_id: + try: + ensure_governance_access( + feature_key, + user_id, + item_entity_type='global_endpoint', + item_id=endpoint_id, + ) + except PermissionError: + continue + allowed_endpoints.append(endpoint) + return allowed_endpoints + + +def chat_model_reasoning_metadata(model): + model_name = next(( + model[field].strip() for field in REASONING_IDENTIFIER_FIELDS + if isinstance(model.get(field), str) and model[field].strip() + ), '') + return { + 'model_name': model_name, + 'reasoning_capabilities': resolve_model_reasoning_policy(model), + } + + +def build_chat_model_catalog(*, user_id, settings, user_settings_dict, user_groups_raw): + profiles = get_effective_model_profiles(settings) + if not settings.get('enable_multi_model_endpoints', False): + if settings.get('enable_gpt_apim', False): + models = [ + {'deploymentName': name.strip(), 'modelName': name.strip()} + for name in str(settings.get('azure_apim_gpt_deployment') or '').split(',') + if name.strip() + ] + else: + models = (settings.get('gpt_model') or {}).get('selected', []) + catalog = [] + for model in models: + if not isinstance(model, dict): + continue + deployment = normalize_chat_model_value(model.get('deploymentName')) + if deployment: + reasoning_metadata = chat_model_reasoning_metadata(model) + catalog.append({ + 'selection_key': deployment, + 'deployment_name': deployment, + 'display_name': reasoning_metadata['model_name'], + **reasoning_metadata, + **model_profile_projection(model, {}, settings, profiles), + }) + return catalog + + catalog = [] + + def append_models(endpoints, scope_type, scope_id=None, scope_name=None): + sanitized_endpoints = sanitize_model_endpoints_for_frontend(endpoints, catalog_settings=settings) + normalized_endpoints, _ = normalize_model_endpoints(sanitized_endpoints) + + for endpoint in filter_model_endpoints_by_capability(normalized_endpoints): + if not endpoint.get('enabled', True): + continue + + endpoint_id = endpoint.get('id') or '' + provider = endpoint.get('provider') or 'aoai' + models = endpoint.get('models') or [] + for model in models: + if not isinstance(model, dict) or not model.get('enabled', True): + continue + + model_id = model.get('id') or model.get('deploymentName') or model.get('deployment') or model.get('modelName') or model.get('name') or '' + request_model = resolve_model_endpoint_request_model(endpoint, model) + deployment_name = request_model + display_name = model.get('displayName') or model.get('modelName') or request_model or deployment_name or model.get('name') or model_id + selection_key = f"{scope_type}:{scope_id or ''}:{endpoint_id}:{model_id or deployment_name or request_model}" + catalog.append({ + 'selection_key': selection_key, + 'model_id': model_id, + **chat_model_reasoning_metadata(model), + 'display_name': display_name, + 'request_model': request_model, + 'deployment_name': deployment_name, + 'endpoint_id': endpoint_id, + 'provider': provider, + 'scope_type': scope_type, + 'scope_id': scope_id, + 'scope_name': scope_name, + 'icon': model.get('icon') if isinstance(model.get('icon'), dict) else {}, + **model_profile_projection(model, endpoint, settings, profiles), + }) + + append_models( + filter_chat_model_endpoints_by_governance( + user_id, settings.get('model_endpoints', []) or [], 'governance_global_endpoints', + ), + 'global', None, 'Global', + ) + if settings.get('allow_user_custom_endpoints', False): + append_models( + filter_chat_model_endpoints_by_governance( + user_id, user_settings_dict.get('personal_model_endpoints', []) or [], 'governance_user_endpoints', + ), + 'personal', user_id, 'Personal', + ) + if settings.get('enable_group_workspaces', False) and settings.get('allow_group_custom_endpoints', False): + for group_doc in user_groups_raw: + group_id = group_doc.get('id') + if group_id: + append_models( + filter_chat_model_endpoints_by_governance( + user_id, get_group_model_endpoints(group_id), 'governance_group_endpoints', + ), + 'group', group_id, group_doc.get('name', 'Unnamed Group'), + ) + return catalog diff --git a/application/single_app/functions_chat_retry.py b/application/single_app/functions_chat_retry.py new file mode 100644 index 000000000..f79759bac --- /dev/null +++ b/application/single_app/functions_chat_retry.py @@ -0,0 +1,657 @@ +# functions_chat_retry.py +"""Canonical inputs and attempt identities for message retry and edit.""" + +from copy import deepcopy +from datetime import datetime, timezone +import hashlib +import json +import uuid + +from azure.core import MatchConditions +from azure.cosmos.exceptions import ( + CosmosAccessConditionFailedError, CosmosResourceExistsError, CosmosResourceNotFoundError, +) + +from collaboration_models import is_shared_conversation_backing +from functions_assist_submissions import normalize_submission_id +from functions_chat_content_review import patch_chat_message_metadata +from functions_message_deletion import is_soft_deleted_message + + +MODEL_FIELDS = ('model_deployment', 'model_id', 'model_endpoint_id', 'model_provider') +AGENT_FIELDS = ('id', 'name', 'display_name', 'is_global', 'is_group', 'group_id', 'group_name', 'catalog_key') + + +class ChatRetryError(ValueError): + """An actionable, public-safe retry refusal.""" + + def __init__(self, message, *, code='retry_unavailable', status_code=409): + super().__init__(message) + self.public_message = message + self.code = code + self.status_code = status_code + + +def _mapping(value): + if value is None: + return {} + if not isinstance(value, dict): + raise ChatRetryError('The saved message settings are invalid. Review your selections and send a new request.') + return value + + +def _text(value): + if value is None: + return '' + if not isinstance(value, str): + raise ChatRetryError('The message selection is invalid. Review your selections and retry explicitly.') + return value.strip() + + +def agent_request_from_metadata(selection): + """Stored agent metadata is not the schema accepted by the chat endpoint.""" + selection = _mapping(selection) + agent = {key: deepcopy(selection[key]) for key in AGENT_FIELDS if key in selection} + for target, saved in ( + ('id', 'agent_id'), ('name', 'selected_agent'), ('display_name', 'agent_display_name'), + ): + if not agent.get(target) and selection.get(saved): + agent[target] = selection[saved] + return agent + + +def has_replay_agent_selection(metadata, overrides): + explicit_model = any(_text(overrides.get(key)) for key in (*MODEL_FIELDS, 'model')) + if overrides.get('agent_info'): + return True + agent = agent_request_from_metadata(_mapping(metadata.get('agent_selection'))) + return not explicit_model and bool(_text(agent.get('id')) or _text(agent.get('name'))) + + +def _agent_scope(selection): + for field in ('is_global', 'is_group'): + if field in selection and type(selection[field]) is not bool: + raise ChatRetryError('The agent scope is invalid.', code='invalid_retry_selection', status_code=400) + if selection.get('is_group'): + return 'group', _text(selection.get('group_id')) + return ('global', '') if selection.get('is_global') else ('personal', '') + + +def resolve_replay_selection(metadata, overrides, *, models, agents, allow_legacy_default=False, image_generation=False): + metadata, overrides = _mapping(metadata), _mapping(overrides) + explicit_model = any(_text(overrides.get(key)) for key in (*MODEL_FIELDS, 'model')) + explicit_agent = overrides.get('agent_info') + if explicit_agent and explicit_model: + raise ChatRetryError('Choose either an agent or a model.', code='invalid_retry_selection', status_code=400) + saved_agent = _mapping(metadata.get('agent_selection')) + requested_agent = ( + agent_request_from_metadata(explicit_agent) if explicit_agent + else agent_request_from_metadata(saved_agent) if has_replay_agent_selection(metadata, overrides) + else {} + ) + if requested_agent: + agent_id, name = _text(requested_agent.get('id')), _text(requested_agent.get('name')) + scope = _agent_scope(requested_agent) + catalog_key = _text(requested_agent.get('catalog_key')) + candidates = [ + agent for agent in agents + if agent.get('enabled', True) and _agent_scope(agent) == scope + and (not catalog_key or agent.get('catalog_key') == catalog_key) + and (agent.get('id') == agent_id if agent_id else bool(name) and agent.get('name') == name) + ] + if len(candidates) != 1: + raise ChatRetryError( + 'The original agent is unavailable or ambiguous. Choose an available agent or model explicitly.', + code='retry_agent_unavailable', + ) + return {'agent_info': {key: deepcopy(candidates[0][key]) for key in AGENT_FIELDS if key in candidates[0]}} + + model = _mapping(metadata.get('model_selection')) + identity = { + 'model_deployment': _text(model.get('selected_model') or model.get('frontend_requested_model')), + **{key: _text(model.get(key)) for key in MODEL_FIELDS if key != 'model_deployment'}, + } + if explicit_model: + identity = {key: _text(overrides.get(key)) for key in MODEL_FIELDS} + identity['model_deployment'] = identity['model_deployment'] or _text(overrides.get('model')) + identity = {key: value for key, value in identity.items() if value} + + if identity.get('model_id') and not identity.get('model_endpoint_id'): + raise ChatRetryError('The saved model has no endpoint identity. Choose a model explicitly.', code='retry_model_unavailable') + if not identity.get('model_endpoint_id'): + if explicit_model and identity.get('model_provider'): + raise ChatRetryError('Choose a model together with its endpoint.', code='invalid_retry_selection', status_code=400) + # Legacy metadata records a provider, but legacy picker entries have no endpoint tuple. + identity.pop('model_provider', None) + if image_generation: + selected = identity + elif not identity and allow_legacy_default: + selected = {} + else: + catalog_fields = { + 'model_deployment': 'deployment_name', 'model_id': 'model_id', + 'model_endpoint_id': 'endpoint_id', 'model_provider': 'provider', + } + candidates = [ + candidate for candidate in models + if candidate.get('enabled', True) + and all( + _text(candidate.get(catalog_fields[key])).lower() == value.lower() + if key == 'model_provider' else candidate.get(catalog_fields[key]) == value + for key, value in identity.items() + ) + ] + if len(candidates) != 1: + raise ChatRetryError( + 'The original model or endpoint is unavailable or ambiguous. Choose an available model explicitly.', + code='retry_model_unavailable', + ) + selected = { + key: candidates[0][field] + for key, field in catalog_fields.items() + if candidates[0].get(field) + } + reasoning = ( + overrides.get('reasoning_effort') if 'reasoning_effort' in overrides + else metadata.get('requested_reasoning_effort') + or model.get('reasoning_effort') or metadata.get('reasoning_effort') + ) + if reasoning: + selected['reasoning_effort'] = _text(reasoning) + return selected + + +def build_replay_request(source, overrides, *, document_context, models, agents, allow_legacy_default=False): + metadata = _mapping(source.get('metadata')) + if metadata.get('masked') or metadata.get('masked_ranges'): + raise ChatRetryError('This question is masked. Unmask and review it, or send a new request.', code='retry_message_masked') + buttons = _mapping(metadata.get('button_states')) + usage = _mapping(metadata.get('capability_usage')) + image_generation = bool(_mapping(metadata.get('image_generation')).get('enabled', buttons.get('image_generation', False))) + selection = resolve_replay_selection( + metadata, overrides, models=models, agents=agents, + allow_legacy_default=allow_legacy_default, image_generation=image_generation, + ) + context = _mapping(metadata.get('chat_context')) + request_body = { + 'message': source.get('content', ''), + 'conversation_id': source['conversation_id'], + **deepcopy(document_context), + **selection, + 'image_generation': image_generation, + 'chat_type': context.get('chat_type') or context.get('type') or 'user', + 'active_group_id': context.get('group_id'), + 'active_public_workspace_id': context.get('public_workspace_id'), + } + for name in ('web_search', 'url_access', 'deep_research'): + request_body[f'{name}_enabled'] = bool(buttons.get(name, _mapping(usage.get(name)).get('enabled', False))) + request_body['source_review_enabled'] = bool( + buttons.get('source_review', False) + or _mapping(usage.get('url_access')).get('source_review_enabled', False) + or _mapping(usage.get('deep_research')).get('source_review_enabled', request_body['deep_research_enabled']) + ) + if metadata.get('prompt_selection'): + request_body['prompt_info'] = deepcopy(metadata['prompt_selection']) + if metadata.get('time_zone'): + request_body['time_zone'] = metadata['time_zone'] + return request_body + + +def build_replay_metadata(source_metadata, request_body): + """Carry input intent, never previous execution approvals, receipts, or failures.""" + source_metadata = _mapping(source_metadata) + metadata = { + key: deepcopy(source_metadata[key]) + for key in ('user_info', 'prompt_selection', 'image_references', 'image_generation', 'time_zone') + if key in source_metadata + } + metadata['chat_context'] = { + 'conversation_id': request_body['conversation_id'], + 'chat_type': request_body.get('chat_type', 'user'), + 'group_id': request_body.get('active_group_id'), + 'public_workspace_id': request_body.get('active_public_workspace_id'), + } + metadata['button_states'] = { + 'image_generation': request_body.get('image_generation', False), + 'document_search': request_body.get('document_context_requested', False), + **{name: request_body.get(f'{name}_enabled', False) for name in ('web_search', 'url_access', 'source_review', 'deep_research')}, + } + document_fields = { + 'search_enabled': 'document_context_requested', 'document_context_requested': 'document_context_requested', + 'hybrid_search_preference': 'hybrid_search', 'selection_mode': 'selection_mode', + 'document_scope': 'doc_scope', 'public_workspace_selection': 'public_workspace_selection', + 'selected_document_id': 'selected_document_id', 'selected_document_ids': 'selected_document_ids', + 'requested_document_ids': 'selected_document_ids', 'tags': 'tags', 'classifications': 'classifications', + 'active_group_ids': 'active_group_ids', 'active_public_workspace_ids': 'active_public_workspace_ids', + 'document_filter_mode': 'document_filter_mode', 'top_n': 'top_n', + } + metadata['workspace_search'] = { + stored: deepcopy(request_body[request_field]) + for stored, request_field in document_fields.items() if request_field in request_body + } + agent = request_body.get('agent_info') + if agent: + metadata['agent_selection'] = { + 'selected_agent': agent.get('name'), 'agent_id': agent.get('id'), + 'agent_display_name': agent.get('display_name'), + **{key: deepcopy(agent[key]) for key in AGENT_FIELDS if key not in ('name', 'id', 'display_name') and key in agent}, + } + else: + metadata['model_selection'] = { + 'selected_model': request_body.get('model_deployment'), + **{key: request_body[key] for key in MODEL_FIELDS if key != 'model_deployment' and key in request_body}, + 'reasoning_effort': request_body.get('reasoning_effort'), + } + if request_body.get('reasoning_effort'): + metadata['requested_reasoning_effort'] = request_body['reasoning_effort'] + return metadata + + +def load_owned_retry_message(messages, conversations, user_id, message_id): + found = list(messages.query_items( + query='SELECT * FROM c WHERE c.id = @message_id', + parameters=[{'name': '@message_id', 'value': message_id}], + enable_cross_partition_query=True, + )) + if not found or is_soft_deleted_message(found[0]): + raise ChatRetryError('Message not found.', code='message_not_found', status_code=404) + message = found[0] + try: + conversation = conversations.read_item(item=message['conversation_id'], partition_key=message['conversation_id']) + except CosmosResourceNotFoundError: + raise ChatRetryError('Conversation not found.', code='conversation_not_found', status_code=404) from None + author_id = _mapping(_mapping(message.get('metadata')).get('user_info')).get('user_id') + if conversation.get('user_id') != user_id or author_id and author_id != user_id: + raise ChatRetryError('You can only retry messages from your own conversations.', code='forbidden', status_code=403) + if is_shared_conversation_backing(conversation): + raise ChatRetryError('Retry and edit are not available in shared conversations.', code='shared_retry_unsupported') + return message, conversation + + +def retry_source_user(messages, clicked): + if clicked.get('role') == 'user': + return clicked + thread = _mapping(_mapping(clicked.get('metadata')).get('thread_info')) + thread_id, attempt = thread.get('thread_id'), thread.get('thread_attempt') + if not thread_id or attempt is None: + raise ChatRetryError('This response has no saved question identity. Retry its original question.', code='retry_source_unavailable') + questions = list(messages.query_items( + query=( + "SELECT * FROM c WHERE c.conversation_id = @conversation_id " + "AND c.metadata.thread_info.thread_id = @thread_id AND c.role = 'user'" + ), + parameters=[ + {'name': '@conversation_id', 'value': clicked['conversation_id']}, + {'name': '@thread_id', 'value': thread_id}, + ], + partition_key=clicked['conversation_id'], + )) + candidates = [ + question for question in questions + if not is_soft_deleted_message(question) + and _attempt_number(_mapping(_mapping(question.get('metadata')).get('thread_info')).get('thread_attempt')) == _attempt_number(attempt) + ] + if len(candidates) != 1: + raise ChatRetryError('The question for this attempt is unavailable. Select an existing attempt.', code='retry_source_unavailable') + return candidates[0] + + +def retry_source_fingerprint(source): + metadata = _mapping(source.get('metadata')) + inputs = { + key: metadata[key] for key in ( + 'model_selection', 'agent_selection', 'workspace_search', 'document_search', + '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', + ) if key in metadata + } + return hashlib.sha256(json.dumps( + {'id': source['id'], 'content': source.get('content'), 'inputs': inputs}, + sort_keys=True, ensure_ascii=False, separators=(',', ':'), + ).encode('utf-8')).hexdigest() + + +def retry_thread_messages(messages, conversation_id, thread_id): + return list(messages.query_items( + query='SELECT * FROM c WHERE c.conversation_id = @conversation_id AND c.metadata.thread_info.thread_id = @thread_id', + parameters=[ + {'name': '@conversation_id', 'value': conversation_id}, + {'name': '@thread_id', 'value': thread_id}, + ], + partition_key=conversation_id, + )) + + +def available_retry_attempts(messages, conversation_id, thread_id): + return sorted({ + _attempt_number(_mapping(_mapping(message.get('metadata')).get('thread_info')).get('thread_attempt')) or 1 + for message in retry_thread_messages(messages, conversation_id, thread_id) + if message.get('role') == 'user' and not is_soft_deleted_message(message) + }) + + +def reconcile_prepared_retry(messages, conversations, user_id, question, *, kind='chat'): + current, _ = load_owned_retry_message(messages, conversations, user_id, question['id']) + metadata = _mapping(current.get('metadata')) + attempt = _mapping(metadata.get('response_attempt')) + if ( + current.get('role') != 'user' or attempt.get('kind') != kind + or attempt.get('state') != 'prepared' + or _mapping(metadata.get('thread_info')).get('active_thread') is False + ): + raise ChatRetryError('The prepared attempt changed. Reload it.', code='retry_attempt_changed') + try: + source = messages.read_item(item=attempt['source_user_message_id'], partition_key=current['conversation_id']) + except CosmosResourceNotFoundError: + raise ChatRetryError('The source question is unavailable. Review a new request.', code='retry_source_changed') from None + if is_soft_deleted_message(source) or retry_source_fingerprint(source) != attempt.get('source_fingerprint'): + raise ChatRetryError('The original question changed. Review a new request.', code='retry_source_changed') + return _publish_retry_attempt(messages, conversations, current) + + +def _attempt_number(value): + if value is None: + return 0 + if isinstance(value, bool) or not isinstance(value, (int, str)): + raise ChatRetryError('The saved attempt identity is invalid. Reload the conversation.', code='retry_attempt_changed') + try: + number = int(value) + except ValueError: + raise ChatRetryError('The saved attempt identity is invalid. Reload the conversation.', code='retry_attempt_changed') from None + if number < 0: + raise ChatRetryError('The saved attempt identity is invalid. Reload the conversation.', code='retry_attempt_changed') + return number + + +def order_retry_messages(messages): + """Keep a retried answer beside its question, without moving unrelated legacy turns.""" + roots = { + _mapping(_mapping(message.get('metadata')).get('thread_info')).get('thread_id') + for message in messages + if _mapping(message.get('metadata')).get('response_attempt') + or _mapping(message.get('metadata')).get('retried') + or _mapping(message.get('metadata')).get('edited') + } - {None, ''} + if not roots: + return list(messages) + groups, positions = {}, {} + entries = [] + for index, message in enumerate(messages): + thread = _mapping(_mapping(message.get('metadata')).get('thread_info')) + root = thread.get('thread_id') + timestamp = message.get('timestamp') or '' + if root not in roots: + entries.append((timestamp, index, [message])) + continue + groups.setdefault(root, []).append(message) + anchor = thread.get('root_timestamp') or timestamp + previous = positions.get(root) + if previous is None or anchor < previous[0]: + positions[root] = (anchor, index) + entries.extend((*positions[root], group) for root, group in groups.items()) + entries.sort(key=lambda entry: (entry[0], entry[1])) + return [message for _timestamp, _index, group in entries for message in group] + + +def retry_history_prefix(messages, user_message_id): + """A replay uses prior logical turns and its own question, never later answers.""" + target = next((message for message in messages if message.get('id') == user_message_id), None) + if not target: + raise ChatRetryError('The retry question is unavailable. Reload the conversation.', code='retry_source_unavailable') + thread_id = _mapping(_mapping(target.get('metadata')).get('thread_info')).get('thread_id') + if not thread_id: + raise ChatRetryError('The retry question has no thread identity.', code='retry_source_unavailable') + ordered = order_retry_messages(messages) + for index, message in enumerate(ordered): + if _mapping(_mapping(message.get('metadata')).get('thread_info')).get('thread_id') == thread_id: + return [*ordered[:index], target] + raise ChatRetryError('The retry thread is unavailable.', code='retry_source_unavailable') + + +def _publish_retry_attempt(messages, conversations, question): + conversation_id = question['conversation_id'] + thread_id = question['metadata']['thread_info']['thread_id'] + conversation = conversations.read_item(item=conversation_id, partition_key=conversation_id) + counter = _mapping(conversation.get('retry_threads')).get(thread_id) or {} + if counter.get('user_message_id') != question['id']: + raise ChatRetryError('The attempt changed during preparation. Reload it.', code='retry_attempt_changed') + if counter.get('published'): + return question + for message in retry_thread_messages(messages, conversation_id, thread_id): + if message['id'] == question['id']: + continue + message.setdefault('metadata', {}).setdefault('thread_info', {})['active_thread'] = False + patch_chat_message_metadata(messages, message) + for _attempt in range(3): + conversation = conversations.read_item(item=conversation_id, partition_key=conversation_id) + updated = deepcopy(conversation) + counter = _mapping(updated.get('retry_threads')).get(thread_id) or {} + if counter.get('user_message_id') != question['id']: + raise ChatRetryError('The attempt changed during preparation. Reload it.', code='retry_attempt_changed') + counter['published'] = True + try: + conversations.replace_item( + item=conversation_id, body=updated, etag=conversation['_etag'], + match_condition=MatchConditions.IfNotModified, + ) + return question + except CosmosAccessConditionFailedError: + continue + raise ChatRetryError('The retry question was saved, but preparation needs reconciliation. Reload it.', code='retry_attempt_changed') + + +def prepare_retry_attempt(messages, conversations, user_id, source, request_body, *, submission_id=None, edited=False, kind='chat', extra_metadata=None): + """Reserve an attempt with a conditional counter, then publish its question once.""" + submission_id = normalize_submission_id(submission_id) or str(uuid.uuid4()) + metadata = _mapping(source.get('metadata')) + thread = _mapping(metadata.get('thread_info')) + thread_id = thread.get('thread_id') + if not thread_id: + raise ChatRetryError('This question has no saved thread identity. Review it and send a new request.', code='retry_source_unavailable') + conversation_id = source['conversation_id'] + source_now = messages.read_item(item=source['id'], partition_key=conversation_id) + if is_soft_deleted_message(source_now) or retry_source_fingerprint(source_now) != retry_source_fingerprint(source): + raise ChatRetryError('This question changed. Reload it before retrying.', code='retry_source_changed') + user_message_id = f"user_retry_{uuid.uuid5(uuid.NAMESPACE_URL, f'{conversation_id}:{thread_id}:{submission_id}').hex}" + request_fingerprint = hashlib.sha256(json.dumps( + {'source_id': source['id'], 'request': request_body, 'edited': edited, 'kind': kind}, + sort_keys=True, ensure_ascii=False, separators=(',', ':'), + ).encode('utf-8')).hexdigest() + try: + existing = messages.read_item(item=user_message_id, partition_key=conversation_id) + except CosmosResourceNotFoundError: + existing = None + if existing: + attempt = _mapping(_mapping(existing.get('metadata')).get('response_attempt')) + if is_soft_deleted_message(existing) or attempt.get('request_fingerprint') != request_fingerprint: + raise ChatRetryError('This retry submission changed. Review the message and retry again.', code='submission_conflict') + return _publish_retry_attempt(messages, conversations, existing) if attempt.get('state') == 'prepared' else existing + + existing_messages = retry_thread_messages(messages, conversation_id, thread_id) + maximum = max(( + _attempt_number(_mapping(_mapping(message.get('metadata')).get('thread_info')).get('thread_attempt')) + for message in existing_messages + ), default=0) + for _attempt in range(3): + conversation = conversations.read_item(item=conversation_id, partition_key=conversation_id) + if conversation.get('user_id') != user_id: + raise ChatRetryError('This conversation is unavailable.', code='forbidden', status_code=403) + updated = deepcopy(conversation) + counters = updated.setdefault('retry_threads', {}) + previous = counters.get(thread_id) or {} + if previous.get('submission_id') == submission_id: + if previous.get('request_fingerprint') != request_fingerprint: + raise ChatRetryError('This retry submission changed.', code='submission_conflict') + number = previous['attempt'] + break + if previous.get('user_message_id'): + try: + pending = messages.read_item(item=previous['user_message_id'], partition_key=conversation_id) + except CosmosResourceNotFoundError: + pending = None + if pending is None and not previous.get('published'): + if previous.get('request_fingerprint') != request_fingerprint: + raise ChatRetryError( + 'A retry is still being prepared. Reload before changing this request.', + code='retry_preparation_pending', + ) + submission_id = previous['submission_id'] + user_message_id = previous['user_message_id'] + number = previous['attempt'] + break + state = _mapping(_mapping((pending or {}).get('metadata')).get('response_attempt')).get('state') + if pending and not is_soft_deleted_message(pending) and state == 'prepared' and previous.get('request_fingerprint') == request_fingerprint: + return _publish_retry_attempt(messages, conversations, pending) + if pending and not is_soft_deleted_message(pending) and state in ('prepared', 'running', 'planning', 'awaiting_clarification'): + raise ChatRetryError( + 'This question already has an unfinished attempt. Reload or reconnect to it before retrying.', + code='retry_in_progress', + ) + number = max(maximum, _attempt_number(previous.get('attempt'))) + 1 + counters[thread_id] = { + 'submission_id': submission_id, 'request_fingerprint': request_fingerprint, + 'attempt': number, 'user_message_id': user_message_id, 'published': False, + } + try: + conversations.replace_item( + item=conversation_id, body=updated, etag=conversation['_etag'], + match_condition=MatchConditions.IfNotModified, + ) + break + except CosmosAccessConditionFailedError: + continue + else: + raise ChatRetryError('The attempt changed. Reload the conversation and retry.', code='retry_attempt_changed') + + now = datetime.now(timezone.utc).isoformat() + new_metadata = build_replay_metadata(metadata, request_body) + new_metadata.update(deepcopy(extra_metadata or {})) + new_metadata['edited' if edited else 'retried'] = True + new_metadata['thread_info'] = { + 'thread_id': thread_id, 'previous_thread_id': thread.get('previous_thread_id'), + 'thread_attempt': number, 'active_thread': True, + 'root_timestamp': min(( + _mapping(_mapping(message.get('metadata')).get('thread_info')).get('root_timestamp') + or message.get('timestamp') or now + for message in existing_messages if message.get('role') == 'user' + ), default=source.get('timestamp') or now), + } + new_metadata['response_attempt'] = { + 'kind': kind, 'state': 'prepared', 'submission_id': submission_id, + 'source_user_message_id': source['id'], + 'source_fingerprint': retry_source_fingerprint(source), + 'request_fingerprint': request_fingerprint, 'created_at': now, 'updated_at': now, + } + question = { + 'id': user_message_id, 'conversation_id': conversation_id, 'role': 'user', + 'content': request_body['message'], 'timestamp': now, + 'model_deployment_name': None, 'metadata': new_metadata, + } + try: + question = messages.create_item(body=question) + except CosmosResourceExistsError: + question = messages.read_item(item=user_message_id, partition_key=conversation_id) + attempt = _mapping(_mapping(question.get('metadata')).get('response_attempt')) + if attempt.get('request_fingerprint') != request_fingerprint: + raise ChatRetryError('This retry submission changed.', code='submission_conflict') from None + return _publish_retry_attempt(messages, conversations, question) + + +def claim_retry_attempt(messages, conversations, user_id, conversation_id, user_message_id, *, kind='chat', thread_id=None, thread_attempt=None): + """Only one invocation can consume a prepared retry, including after reconnect.""" + try: + question = messages.read_item(item=user_message_id, partition_key=conversation_id) + except CosmosResourceNotFoundError: + raise ChatRetryError('Retry question not found.', code='message_not_found', status_code=404) from None + if ( + question.get('role') != 'user' or question.get('conversation_id') != conversation_id + or is_soft_deleted_message(question) + ): + raise ChatRetryError('Retry question not found.', code='message_not_found', status_code=404) + conversation = conversations.read_item(item=conversation_id, partition_key=conversation_id) + if conversation.get('user_id') != user_id or is_shared_conversation_backing(conversation): + raise ChatRetryError('This conversation is unavailable.', code='forbidden', status_code=403) + metadata = _mapping(question.get('metadata')) + author_id = _mapping(metadata.get('user_info')).get('user_id') + if author_id and author_id != user_id: + raise ChatRetryError('This question is unavailable.', code='forbidden', status_code=403) + if metadata.get('masked') or metadata.get('masked_ranges'): + raise ChatRetryError('This question is masked. Review it before retrying.', code='retry_message_masked') + attempt, thread = _mapping(metadata.get('response_attempt')), _mapping(metadata.get('thread_info')) + counter = _mapping(conversation.get('retry_threads')).get(thread.get('thread_id')) or {} + if counter.get('user_message_id') == user_message_id and counter.get('published') is False: + raise ChatRetryError('This attempt is still being prepared. Retry the same preparation.', code='retry_preparation_pending') + if thread.get('active_thread') is False or ( + thread_id is not None and thread.get('thread_id') != thread_id + ) or ( + thread_attempt is not None and _attempt_number(thread.get('thread_attempt')) != _attempt_number(thread_attempt) + ) or ( + counter.get('user_message_id') and counter['user_message_id'] != user_message_id + ): + raise ChatRetryError('The selected attempt changed. Reload the conversation.', code='retry_attempt_changed') + if not attempt: + if not (metadata.get('retried') or metadata.get('edited')) or metadata.get('orchestration'): + raise ChatRetryError('Prepare a new retry before generating its response.', code='retry_not_prepared') + attempt = { + 'kind': 'chat', 'state': 'prepared', 'source_user_message_id': question['id'], + 'source_fingerprint': retry_source_fingerprint(question), + } + if attempt.get('kind') != kind or attempt.get('state') != 'prepared': + raise ChatRetryError( + 'This attempt was already submitted. Reload or reconnect to its existing response; retry deliberately to create another.', + code='retry_already_submitted', + ) + try: + source = messages.read_item(item=attempt['source_user_message_id'], partition_key=conversation_id) + except CosmosResourceNotFoundError: + raise ChatRetryError('The source question is unavailable. Review a new request.', code='retry_source_changed') from None + if ( + is_soft_deleted_message(source) + or retry_source_fingerprint(source) != attempt.get('source_fingerprint') + ): + raise ChatRetryError('The source question changed. Review it before retrying.', code='retry_source_changed') + attempt.update({ + 'state': 'planning' if kind == 'orchestration' else 'running', + 'updated_at': datetime.now(timezone.utc).isoformat(), + }) + updated = deepcopy(question) + updated.setdefault('metadata', {})['response_attempt'] = attempt + try: + return messages.replace_item( + item=user_message_id, body=updated, etag=question['_etag'], + match_condition=MatchConditions.IfNotModified, + ) + except CosmosAccessConditionFailedError: + raise ChatRetryError('This attempt changed or was already submitted. Reload it.', code='retry_attempt_changed') from None + + +def set_retry_attempt_state(messages, conversation_id, user_message_id, state, *, error=None, code=None, run_id=None, expected_states=None): + if not user_message_id: + return + for _attempt in range(3): + question = messages.read_item(item=user_message_id, partition_key=conversation_id) + attempt = _mapping(_mapping(question.get('metadata')).get('response_attempt')) + if not attempt or attempt.get('state') in ('completed', 'failed', 'interrupted'): + return + if expected_states is not None and attempt.get('state') not in expected_states: + return + if state in ('awaiting_review', 'awaiting_clarification') and attempt.get('state') not in ( + 'prepared', 'planning', 'awaiting_clarification', 'awaiting_review', + ): + return + updated = deepcopy(question) + attempt.update({'state': state, 'updated_at': datetime.now(timezone.utc).isoformat()}) + for key, value in (('error', error), ('code', code), ('run_id', run_id)): + if value: + attempt[key] = value + updated.setdefault('metadata', {})['response_attempt'] = attempt + try: + messages.replace_item( + item=user_message_id, body=updated, etag=question['_etag'], + match_condition=MatchConditions.IfNotModified, + ) + return + except CosmosAccessConditionFailedError: + continue + raise ChatRetryError('The attempt status changed. Reload the conversation.', code='retry_attempt_changed') diff --git a/application/single_app/functions_orchestration_context.py b/application/single_app/functions_orchestration_context.py index 3ae54ba91..439b5f224 100644 --- a/application/single_app/functions_orchestration_context.py +++ b/application/single_app/functions_orchestration_context.py @@ -1851,15 +1851,23 @@ def conversation_snapshot_size(snapshot): return len(json.dumps(snapshot, ensure_ascii=False, separators=(',', ':')).encode('utf-8')) -def build_conversation_snapshot(messages, settings=None, *, turn_id=None, truncated=False): +def build_conversation_snapshot( + messages, settings=None, *, turn_id=None, truncated=False, logical_order=False, +): """Keep the recent eligible conversation, bounded independently of the current request. An attempt a later retry replaced is left out: its failure text no longer describes the request, and the retry's own answer is what the conversation now says. """ eligible = [] - superseded = superseded_orchestration_run_ids(messages) - for message in messages or (): + rows = list(messages or ()) + superseded = superseded_orchestration_run_ids(rows) + if logical_order: + # Retry ordering depends on initialized application-owned message storage. + from functions_chat_retry import order_retry_messages + + rows = order_retry_messages([message for message in rows if isinstance(message, dict)]) + for message in rows: if not isinstance(message, dict): continue metadata = message.get('metadata') or {} @@ -1873,7 +1881,8 @@ def build_conversation_snapshot(messages, settings=None, *, turn_id=None, trunca if not normalized['id']: raise ConversationContextError('The conversation contains a message without an ID.') eligible.append(normalized) - eligible.sort(key=_history_order) + if not logical_order: + eligible.sort(key=_history_order) limit = history_message_limit(settings) retained = eligible[-limit:] if limit else [] snapshot = { @@ -1881,6 +1890,8 @@ def build_conversation_snapshot(messages, settings=None, *, turn_id=None, trunca 'messages': retained, 'truncated': bool(truncated or len(eligible) > len(retained)), } + if logical_order: + snapshot['logical_order'] = True while len(retained) > 1 and conversation_snapshot_size(snapshot) > HISTORY_MAX_BYTES: retained.pop(0) snapshot['truncated'] = True @@ -1950,6 +1961,7 @@ def validate_conversation_snapshot(snapshot, messages): [sources[item['id']] for item in snapshot['messages']], {'conversation_history_limit': len(snapshot['messages'])}, truncated=bool(snapshot.get('truncated')), + logical_order=bool(snapshot.get('logical_order')), ) if rebuilt != snapshot: raise ConversationContextError('This plan needs to be created again.') diff --git a/application/single_app/functions_orchestration_execution.py b/application/single_app/functions_orchestration_execution.py index e73df9bd9..05d162ba0 100644 --- a/application/single_app/functions_orchestration_execution.py +++ b/application/single_app/functions_orchestration_execution.py @@ -1156,6 +1156,17 @@ def _reasoning(self): ) return reasoning + def _regeneration_thread(self): + if not self.record.get("regeneration_of_run_id"): + return None + question = self.lease.message_container.read_item( + item=self.record.get("user_message_id"), partition_key=self.record["conversation_id"], + ) + thread = (question.get("metadata") or {}).get("thread_info") or {} + if question.get("role") != "user" or not thread.get("thread_id"): + raise HarnessExecutionError("context_unavailable") + return deepcopy(thread) + def _file_state(self): if self.services is None or not any( step.get("enabled", True) and step["capability_id"] == "render_file" @@ -1559,6 +1570,9 @@ def _finalize(self, result, error, *, refreshes=0): if workflow_result_reads and workflow_result_contexts else {} ), } + thread = self._regeneration_thread() + if thread: + metadata["thread_info"] = thread documents, web, tools = _partition_citations(citations) document = { "id": message_id, "conversation_id": self.record["conversation_id"], @@ -1675,6 +1689,19 @@ def _finish(self, result, error): finalization_status="failed", outputs=[], ), ] + if self.record.get("requires_fresh_review"): + status = (self._final_record or {}).get("status") or "failed" + state = {"completed": "completed", "failed": "failed", "cancelled": "interrupted"}.get(status) + if state: + # The attempt store is application-owned and must remain deferred until execution. + from functions_chat_retry import set_retry_attempt_state + + set_retry_attempt_state( + self.lease.message_container, self.record["conversation_id"], + self.record.get("user_message_id"), state, + error=(self._final_record or {}).get("error") if state != "completed" else None, + run_id=self.record["id"], + ) return list(self._frames) def _preparation_error(self, error): diff --git a/application/single_app/functions_orchestration_plan_revisions.py b/application/single_app/functions_orchestration_plan_revisions.py index 2b8213fe5..58372e024 100644 --- a/application/single_app/functions_orchestration_plan_revisions.py +++ b/application/single_app/functions_orchestration_plan_revisions.py @@ -69,12 +69,14 @@ ) # Turn fields only some turns have, kept exactly like the immutable fields: a revision plans # the same request, in the same time zone, from the same server-built workflow context. -_OPTIONAL_TURN_FIELDS = ('time_zone', 'workflow_planning') +_OPTIONAL_TURN_FIELDS = ( + 'time_zone', 'workflow_planning', 'regeneration_of_run_id', 'requires_fresh_review', +) _PLAN_FIELDS = ( 'plan_id', 'run_id', 'turn_id', 'revision', 'conversation_id', 'user_id', 'planner_contract_version', 'intent', 'assumptions', 'approval', 'status', 'steps', 'inputs', 'outputs', 'validation', 'edit_version', - 'final_response', 'model_routing', 'planner', 'deliverables', + 'final_response', 'model_routing', 'planner', 'deliverables', 'requires_fresh_review', ) _STEP_FIELDS = ( 'step_id', 'capability_id', 'title', 'rationale', 'arguments', 'depends_on', @@ -987,7 +989,7 @@ def _require_approval_floor(record, plan): def claim_plan_run( run_id, user_id, conversation_id, *, plan_id=None, expected_version=None, edits=None, conversation_context=None, result_alias_resolver=None, - export_catalog=None, composition_profiles=None, settings=None, + export_catalog=None, composition_profiles=None, settings=None, reviewed_regeneration=False, ): """Atomically turn a current pre-execution plan into the one executable run. @@ -996,6 +998,11 @@ def claim_plan_run( The start and deadline use server settings and share that same conditional write. """ record = read_revision_run(run_id, user_id, conversation_id) + if record.get('requires_fresh_review') and not record.get('retry_of_run_id') and reviewed_regeneration is not True: + raise PlanRevisionError( + 'Review and approve this regenerated plan before running it.', + code='fresh_review_required', + ) if record.get('checkpoints_deleted') or record.get('latest_attempt_run_id'): raise PlanRevisionError('This execution is no longer current.', code='already_run') if record.get('retry_of_run_id') and edits: diff --git a/application/single_app/functions_orchestration_runs.py b/application/single_app/functions_orchestration_runs.py index 9c694b9d1..f540c7112 100644 --- a/application/single_app/functions_orchestration_runs.py +++ b/application/single_app/functions_orchestration_runs.py @@ -312,6 +312,7 @@ def create_orchestration_run( 'resolved_message', 'planning_token_usage', 'original_seeds', 'prompt_selection', 'memory_audience', 'memory_scope', 'result_aliases', 'time_zone', 'workflow_planning', + 'regeneration_of_run_id', 'requires_fresh_review', ): if isinstance(turn_context, dict) and key in turn_context: record[key] = deepcopy(turn_context[key]) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index bad8bfe94..80ecd96e3 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -156,6 +156,7 @@ SemanticSearchQuotaExceededError, ) from functions_chat_content_checks import ( + CHECK_METADATA, attach_chat_check, blocked_chat_payload, check_chat_content, @@ -167,11 +168,17 @@ ) from functions_chat_content_review import ( ChatContentReviewConflict, checked_history_messages, persist_chat_reply, - record_chat_content_incident, retracted_stream_payload, reply_is_retracted, + patch_chat_message_metadata, record_chat_content_incident, retracted_stream_payload, reply_is_retracted, +) +from functions_chat_retry import ( + ChatRetryError, claim_retry_attempt, load_owned_retry_message, + order_retry_messages, retry_history_prefix, set_retry_attempt_state, ) from azure.core import MatchConditions from functions_prompt_metadata import build_prompt_selection_metadata from functions_settings import * +from functions_model_catalog import ModelCatalogError +from public_chat_scope_state import PublicChatScopeError from functions_assigned_knowledge import ( ASSIGNED_KNOWLEDGE_USER_ACTION_ANALYZE, ASSIGNED_KNOWLEDGE_USER_ACTION_COMPARE, @@ -8529,6 +8536,157 @@ def wrapped(*args, **kwargs): return wrapped +def _persist_retry_terminal_payload(question, payload): + """The generation worker, not the HTTP consumer, owns attempt completion.""" + if not question: + return None + if payload.get('cancelled') or payload.get('canceled') or payload.get('interrupted'): + state = 'interrupted' + elif payload.get('error') or payload.get('blocked'): + state = 'failed' + elif payload.get('done'): + state = 'completed' + else: + return None + error = payload.get('error') if isinstance(payload.get('error'), str) else None + if state == 'failed' and not error: + error = 'This attempt could not complete. Review the request before retrying.' + try: + set_retry_attempt_state( + cosmos_messages_container, question['conversation_id'], question['id'], state, + error=error, code=payload.get('error_code') or payload.get('code'), + ) + except (AzureError, ChatRetryError) as exc: + log_event( + '[CHAT_RETRY] Could not save terminal attempt status.', + extra={'conversation_id': question['conversation_id'], 'user_message_id': question['id'], + 'error_type': type(exc).__name__}, + level=logging.ERROR, + ) + payload['error'] = error or 'The response finished, but its attempt status could not be saved. Reload before retrying.' + payload['attempt_status_error'] = 'retry_status_unavailable' + thread = question['metadata']['thread_info'] + payload.update({ + 'user_message_id': question['id'], 'conversation_id': question['conversation_id'], + 'retry_thread_id': thread['thread_id'], 'retry_thread_attempt': thread['thread_attempt'], + 'attempt_state': state, + }) + return state + + +def _with_prepared_chat_retry(view): + """Use only authorized, saved attempt inputs before any model or agent work.""" + @wraps(view) + def wrapped(*args, **kwargs): + data = request.get_json(silent=True) + retry_id = (data.get('retry_user_message_id') or data.get('edited_user_message_id')) if isinstance(data, dict) else None + if not retry_id: + return view(*args, **kwargs) + already_claimed = getattr(g, 'chat_retry_question', None) + if ( + already_claimed and already_claimed['id'] == retry_id + and already_claimed['conversation_id'] == data.get('conversation_id') + ): + return view(*args, **kwargs) + user_id = get_current_user_id() + question = None + + def fail_unstarted_attempt(message, code): + if question and question.get('conversation_id') == data.get('conversation_id'): + set_retry_attempt_state( + cosmos_messages_container, question['conversation_id'], question['id'], + 'failed', error=message, code=code, expected_states=('prepared',), + ) + + try: + question, _ = load_owned_retry_message( + cosmos_messages_container, cosmos_conversations_container, user_id, retry_id, + ) + if question.get('role') != 'user' or question['conversation_id'] != data.get('conversation_id'): + raise ChatRetryError('Retry question not found.', code='message_not_found', status_code=404) + if (question.get('metadata') or {}).get('orchestration'): + raise ChatRetryError('Regenerate this orchestration plan instead.', code='retry_requires_orchestration') + # Import at the operation boundary: both route modules register during bootstrap. + from route_backend_conversations import _build_authorized_message_replay_request + + settings = get_settings() + body = _build_authorized_message_replay_request(user_id, question, {}, settings) + input_check = check_chat_content(body['message'], 'chat_input', user_id=user_id, settings=settings) + if input_check.blocked: + set_retry_attempt_state( + cosmos_messages_container, question['conversation_id'], question['id'], + 'failed', error=input_check.notice, code='retry_input_blocked', expected_states=('prepared',), + ) + raise ChatRetryError(input_check.notice, code='retry_input_blocked', status_code=422) + claimed = claim_retry_attempt( + cosmos_messages_container, cosmos_conversations_container, user_id, + question['conversation_id'], question['id'], + thread_id=data.get('retry_thread_id'), thread_attempt=data.get('retry_thread_attempt'), + ) + thread = claimed['metadata']['thread_info'] + identity_field = 'edited_user_message_id' if claimed['metadata'].get('edited') else 'retry_user_message_id' + body.update({ + identity_field: claimed['id'], 'retry_thread_id': thread['thread_id'], + 'retry_thread_attempt': thread['thread_attempt'], + }) + # The compatibility handler reads this same cached Flask JSON object. + data.clear() + data.update(body) + g.chat_retry_question = claimed + except ChatRetryError as error: + log_event('[CHAT_RETRY] Invocation refused.', extra={'code': error.code, 'user_id': user_id}, level=logging.INFO) + if error.code not in ('retry_already_submitted', 'retry_attempt_changed'): + fail_unstarted_attempt(error.public_message, error.code) + return jsonify({'error': error.public_message, 'code': error.code}), error.status_code + except ScreeningError as error: + fail_unstarted_attempt(error.public_message, error.code) + return jsonify({'error': error.public_message, 'error_code': error.code}), error.status_code + except PublicChatScopeError as error: + fail_unstarted_attempt(error.public_message, error.code) + return jsonify({'error': error.public_message, 'code': error.code}), error.status_code + except ModelCatalogError as error: + log_event('[CHAT_RETRY] Invocation catalog is unavailable.', extra={'error_type': type(error).__name__}, level=logging.WARNING) + fail_unstarted_attempt('The original model is unavailable. Review the request.', 'retry_model_unavailable') + return jsonify({'error': 'The original model is unavailable. Review the request.', 'code': 'retry_model_unavailable'}), 409 + except PermissionError: + log_event('[CHAT_RETRY] Invocation selection is no longer authorized.', extra={'user_id': user_id}, level=logging.WARNING) + fail_unstarted_attempt('An original selection is no longer authorized. Review the request.', 'forbidden') + return jsonify({'error': 'An original selection is no longer authorized. Review the request.', 'code': 'forbidden'}), 403 + except AzureError as error: + log_event('[CHAT_RETRY] Invocation storage is unavailable.', extra={'error_type': type(error).__name__}, level=logging.ERROR) + return jsonify({'error': 'The attempt could not start. Reload to check its status before retrying.', 'code': 'retry_storage_unavailable'}), 503 + try: + result = view(*args, **kwargs) + except Exception: + log_event('[CHAT_RETRY] Invocation failed before returning a response.', level=logging.ERROR, exceptionTraceback=True) + set_retry_attempt_state( + cosmos_messages_container, claimed['conversation_id'], claimed['id'], + 'failed', error='This attempt could not complete. Reload before retrying.', + ) + raise + response = current_app.make_response(result) + if response.mimetype != 'text/event-stream': + payload = response.get_json(silent=True) or {} + if response.status_code >= 400 and not payload.get('error'): + payload['error'] = 'This attempt could not start. Reload before retrying.' + if not payload.get('error') and not payload.get('blocked'): + payload['done'] = True + _persist_retry_terminal_payload(claimed, payload) + response.set_data(current_app.json.dumps(payload)) + return response + return result + return wrapped + + +def _save_chat_user_metadata(message, is_retry): + if not is_retry: + return cosmos_messages_container.upsert_item(message) + return patch_chat_message_metadata( + cosmos_messages_container, message, + fields=tuple(key for key in message.get('metadata', {}) if key not in ('thread_info', 'response_attempt')), + ) + + def _extract_sse_event_payload(event_text): """Parse JSON data lines from a raw SSE event string.""" if not isinstance(event_text, str): @@ -8790,6 +8948,8 @@ def publish(self, event_text): is_terminal_event = isinstance(payload, dict) and (payload.get('done') or payload.get('error') or is_cancel_event) metadata = self._build_metadata(active=not is_terminal_event, existing=self._get_metadata()) + if isinstance(payload, dict) and payload.get('user_message_id'): + metadata['user_message_id'] = payload['user_message_id'] if isinstance(payload, dict) and payload.get("message_id") and payload.get("type") != "user_message_persisted": metadata["message_id"] = payload["message_id"] metadata['event_count'] = _safe_int(metadata.get('event_count')) + 1 @@ -14902,9 +15062,11 @@ def build_background_stream_response(event_generator_factory, stream_session=Non viewer_user_id = get_current_user_id() stream_user_message_id = None content_check_settings = get_settings() + retry_question = getattr(g, 'chat_retry_question', None) + retry_terminal_seen = False def publish_background_event(event_text): - nonlocal stream_user_message_id + nonlocal stream_user_message_id, retry_terminal_seen if event_text is None: return False @@ -14937,7 +15099,10 @@ def publish_background_event(event_text): enriched['conversation_id'] = enriched.get('conversation_id') or references[0]['conversation_id'] enriched['request_id'] = enriched.get('request_id') or references[0]['request_id'] if enriched is not payload: - event_text = f"data: {json.dumps(enriched)}\n\n" + payload = enriched + if _persist_retry_terminal_payload(retry_question, payload): + retry_terminal_seen = True + event_text = f"data: {json.dumps(payload)}\n\n" if stream_session: if not stream_session.publish(event_text): @@ -14965,6 +15130,7 @@ def publish_pending_action(reference): @copy_current_request_context def stream_worker(): + g.chat_retry_question = retry_question with m365_action_card_events(publish_pending_action): event_iterator = None try: @@ -15011,6 +15177,11 @@ def stream_worker(): if event_iterator is not None and callable(getattr(event_iterator, 'close', None)): event_iterator.close() finally: + if retry_question and not retry_terminal_seen: + publish_background_event(build_stream_error_event( + 'This response was interrupted before it completed. Reload before retrying.', + interrupted=True, error_code='retry_stream_incomplete', + )) if stream_session: stream_session.close() stream_bridge.finish() @@ -17515,6 +17686,7 @@ def image_proposal_status(conversation_id): @swagger_route(security=get_auth_security()) @login_required @user_required + @_with_prepared_chat_retry @public_chat_scope_required( _authorize_personal_conversation_access, get_current_user_id, get_settings, resolve_public_chat_workspace_ids, log_event, @@ -18541,7 +18713,7 @@ def result_requires_message_reload(result: Any) -> bool: elif image_gen_enabled and chat_image_reference_request.get('image_mask') is None: user_metadata.pop('image_reference_mask', None) attach_chat_check(user_message_doc, input_check) - cosmos_messages_container.upsert_item(user_message_doc) + user_message_doc = _save_chat_user_metadata(user_message_doc, is_retry) debug_print(f"🔍 Chat API - Read retry user message:") debug_print(f" thread_id: {user_message_doc.get('metadata', {}).get('thread_info', {}).get('thread_id')}") @@ -18962,7 +19134,7 @@ def result_requires_message_reload(result: Any) -> bool: 'search_query': search_query, } user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + user_message_doc = _save_chat_user_metadata(user_message_doc, is_retry) else: thought_tracker.add_thought( 'history_context', @@ -19679,7 +19851,7 @@ def result_requires_message_reload(result: Any) -> bool: debug_print(f"Updated message metadata with chat_type: {message_chat_type}") # Update the user message in Cosmos DB with the final chat_type information - cosmos_messages_container.upsert_item(user_message_doc) + user_message_doc = _save_chat_user_metadata(user_message_doc, is_retry) debug_print(f"User message re-saved to Cosmos DB with updated chat_context") # Image Generation @@ -19912,7 +20084,7 @@ def record_tabular_post_processing_thought(thought_payload): if continuity_decision: user_metadata['source_continuity'] = continuity_decision user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + user_message_doc = _save_chat_user_metadata(user_message_doc, is_retry) if continuity_decision: emit_mixed_source_telemetry( settings, @@ -21751,7 +21923,7 @@ def gpt_error(e): user_message_doc['metadata']['model_selection'].update(_build_chat_reasoning_metadata( reasoning_resolution, reasoning_effort, gpt_reasoning_model_name, )) - cosmos_messages_container.upsert_item(user_message_doc) + user_message_doc = _save_chat_user_metadata(user_message_doc, is_retry) except Exception as e: debug_print(f"Warning: Could not update user message metadata: {e}") @@ -21883,6 +22055,7 @@ def gpt_error(e): @swagger_route(security=get_auth_security()) @login_required @user_required + @_with_prepared_chat_retry @public_chat_scope_required( _authorize_personal_conversation_access, get_current_user_id, get_settings, resolve_public_chat_workspace_ids, log_event, @@ -23121,7 +23294,9 @@ def collect_stream_response_conversation_metadata(): yield f"data: {json.dumps(payload)}\n\n" return attach_chat_check(user_message_doc, retry_input_check) - cosmos_messages_container.upsert_item(user_message_doc) + user_message_doc = patch_chat_message_metadata( + cosmos_messages_container, user_message_doc, fields=(CHECK_METADATA,), + ) user_metadata = user_message_doc.get('metadata') if isinstance(user_message_doc.get('metadata'), dict) else {} thread_info = user_metadata.get('thread_info') if isinstance(user_metadata.get('thread_info'), dict) else {} requested_thread_id = str(retry_thread_id or '').strip() @@ -23156,6 +23331,7 @@ def collect_stream_response_conversation_metadata(): f"previous_thread_id={previous_thread_id} | " f"attempt={effective_retry_thread_attempt}" ) + yield build_user_message_persisted_stream_event(conversation_id, user_message_id) else: # Save user message user_message_id = f"{conversation_id}_user_{int(time.time())}_{random.randint(1000,9999)}" @@ -23590,7 +23766,7 @@ def record_and_publish_streaming_thought(thought_payload): 'search_query': search_query, } user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + user_message_doc = _save_chat_user_metadata(user_message_doc, is_retry) else: yield emit_thought( 'history_context', @@ -24189,7 +24365,7 @@ def record_and_publish_streaming_thought(thought_payload): if continuity_decision: user_metadata['source_continuity'] = continuity_decision user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + user_message_doc = _save_chat_user_metadata(user_message_doc, is_retry) if continuity_decision: emit_mixed_source_telemetry( settings, @@ -24579,7 +24755,7 @@ def record_and_publish_streaming_thought(thought_payload): ) user_metadata['chat_context']['chat_type'] = message_chat_type user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + user_message_doc = _save_chat_user_metadata(user_message_doc, is_retry) # Prepare conversation history conversation_history_for_api = [] @@ -26086,7 +26262,7 @@ def finalize_cancelled_agent_stream_response(): user_message_doc['metadata'].setdefault('model_selection', {}).update(_build_chat_reasoning_metadata( reasoning_resolution, reasoning_effort, gpt_reasoning_model_name, )) - cosmos_messages_container.upsert_item(user_message_doc) + user_message_doc = _save_chat_user_metadata(user_message_doc, is_retry) except Exception as e: debug_print(f"Warning: Could not update streaming user message metadata: {e}") @@ -26305,7 +26481,7 @@ def finalize_cancelled_agent_stream_response(): 'thread_id': user_thread_id, 'previous_thread_id': user_previous_thread_id, 'active_thread': True, - 'thread_attempt': 1 + 'thread_attempt': assistant_thread_attempt } } }) @@ -28470,6 +28646,10 @@ def build_conversation_history_segments( include_assistant_citation_context=True, ): """Build shared conversation history segments for chat completions.""" + target = next((message for message in all_messages if message.get('id') == user_message_id), None) + retry_history = bool(target and any((target.get('metadata') or {}).get(key) for key in ('response_attempt', 'retried', 'edited'))) + if retry_history: + all_messages = retry_history_prefix(all_messages, user_message_id) all_messages = checked_history_messages(all_messages, for_model=True) all_messages = _sanitize_saved_analysis_history(all_messages) conversation_history_messages = [] @@ -28479,7 +28659,7 @@ def build_conversation_history_segments( artifact_payload_map = build_message_artifact_payload_map(all_messages or []) filtered_messages = filter_assistant_artifact_items(all_messages or []) filtered_messages = hydrate_agent_citations_from_artifacts(filtered_messages, artifact_payload_map) - ordered_messages = sort_messages_by_thread(filtered_messages) + ordered_messages = order_retry_messages(filtered_messages) if retry_history else sort_messages_by_thread(filtered_messages) # Dropped after ordering, so the surviving messages keep the order the thread chain gives # them. A deleted message must not reach the summary or the recent window, nor take a slot # in the history limit; its mask is only a fail-safe, not what keeps it out. @@ -28509,6 +28689,7 @@ def build_conversation_history_segments( masked_range_message_refs = [] history_message_source_refs = [] appended_fallback_user_message = False + last_history_user_message_id = None if enable_summarize_older_messages and older_messages_to_summarize and gpt_client and gpt_model: debug_print( @@ -28617,6 +28798,7 @@ def build_conversation_history_segments( if role == 'assistant' and include_assistant_citation_context: content = build_assistant_history_content_with_citations(message, content) conversation_history_messages.append({"role": role, "content": content}) + last_history_user_message_id = message.get('id') if role == 'user' else None history_message_source_refs.append(_format_history_message_ref(message)) elif role == 'file': filename = message.get('filename', 'uploaded_file') @@ -28723,7 +28905,10 @@ def build_conversation_history_segments( history_message_source_refs.append(f"system:image:{message.get('id', 'unknown')}") - if not conversation_history_messages or conversation_history_messages[-1].get('role') != 'user': + if ( + not conversation_history_messages or conversation_history_messages[-1].get('role') != 'user' + or user_message_id and last_history_user_message_id != user_message_id + ): debug_print("Warning: Last message in history is not the user's current message. Appending.") user_msg_found = False for message in reversed(recent_messages): diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 6c7714c73..9e522ff82 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -3,6 +3,7 @@ import logging import math import re +import uuid from functools import partial from azure.core.exceptions import AzureError @@ -16,8 +17,17 @@ ) from config import * from functions_appinsights import log_event -from functions_chat_content_checks import attach_chat_check, check_chat_content, strip_private_chat_checks +from functions_chat_content_checks import CHECK_METADATA, attach_chat_check, check_chat_content, strip_private_chat_checks from functions_chat_content_review import patch_chat_message_metadata, record_blocked_chat_attempt +from functions_agent_catalog import build_accessible_agent_catalog +from functions_assist_submissions import SubmissionIdError +from functions_chat_model_catalog import build_chat_model_catalog +from functions_chat_retry import ( + ChatRetryError, available_retry_attempts, build_replay_request, has_replay_agent_selection, load_owned_retry_message, + order_retry_messages, prepare_retry_attempt, reconcile_prepared_retry, retry_source_user, set_retry_attempt_state, +) +from functions_governance import ensure_governance_access +from functions_model_catalog import ModelCatalogError from functions_authentication import * from functions_collaboration import ( assert_user_can_view_collaboration_conversation, @@ -73,6 +83,8 @@ serialize_chat_upload_workspace_documents_for_conversation, ) from functions_group import get_user_groups +from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings, resolve_public_chat_workspace_ids +from public_chat_scope_state import PublicChatScopeError from functions_azure_maps import refresh_azure_maps_message_citations from functions_message_artifacts import ( build_message_artifact_payload_map, @@ -302,8 +314,8 @@ def _build_replayed_document_context(original_metadata): selected_document_ids = ( workspace_search.get('requested_document_ids') - or workspace_search.get('selected_document_ids') - or [] + if 'requested_document_ids' in workspace_search + else workspace_search.get('selected_document_ids') or [] ) if not isinstance(selected_document_ids, list): selected_document_ids = [selected_document_ids] @@ -317,7 +329,10 @@ def _build_replayed_document_context(original_metadata): or workspace_search.get('document_id') or '' ).strip() - if selected_document_id and selected_document_id not in selected_document_ids: + if ( + selected_document_id and selected_document_id not in selected_document_ids + and 'requested_document_ids' not in workspace_search + ): selected_document_ids.insert(0, selected_document_id) selection_mode = str(workspace_search.get('selection_mode') or '').strip().lower() @@ -345,6 +360,7 @@ def _build_replayed_document_context(original_metadata): 'doc_scope': workspace_search.get('document_scope') or workspace_search.get('scope'), 'public_workspace_selection': workspace_search.get('public_workspace_selection'), 'top_n': workspace_search.get('top_n'), + 'document_filter_mode': workspace_search.get('document_filter_mode') or 'intersection', 'classifications': ( workspace_search.get('classification') or workspace_search.get('classifications') @@ -362,6 +378,182 @@ def _build_replayed_document_context(original_metadata): } +def _build_authorized_message_replay_request(user_id, source, options, settings, *, allow_auto_selection=False): + metadata = source.get('metadata') or {} + agent_requested = has_replay_agent_selection(metadata, options) + groups = get_user_groups(user_id) if settings.get('enable_group_workspaces', False) else [] + document_context = _build_replayed_document_context(metadata) + models, agents = [], [] + if agent_requested: + if not settings.get('enable_semantic_kernel', False): + raise ChatRetryError('Agent chat is no longer enabled. Choose an available model explicitly.', code='retry_agent_unavailable') + agents = build_accessible_agent_catalog(user_id, settings=settings, user_groups=groups) + elif not isinstance(metadata.get('image_generation'), dict) or not metadata['image_generation'].get('enabled'): + user_settings = get_user_settings(user_id) if settings.get('allow_user_custom_endpoints', False) else {} + models = build_chat_model_catalog( + user_id=user_id, settings=settings, + user_settings_dict=(user_settings or {}).get('settings', {}), + user_groups_raw=groups, + ) + if allow_auto_selection and not models: + raise ChatRetryError('No enabled chat model is available for Auto routing. Review your selections.', code='retry_model_unavailable') + body = build_replay_request( + source, options, document_context=document_context, models=models, agents=agents, + allow_legacy_default=allow_auto_selection or not settings.get('enable_multi_model_endpoints', False), + ) + if body.get('agent_info'): + agent = body['agent_info'] + if agent.get('is_global'): + ensure_governance_access( + 'governance_global_agents_usage', user_id, + item_entity_type='global_agent', item_id=str(agent.get('id') or agent.get('name') or ''), + ) + else: + ensure_governance_access( + 'governance_group_agents' if agent.get('is_group') else 'governance_user_agents', + user_id, + ) + requested_groups = set(body.get('active_group_ids') or []) + if body.get('active_group_id'): + requested_groups.add(body['active_group_id']) + if requested_groups - {group['id'] for group in groups if group.get('id')}: + raise ChatRetryError('An original group workspace is unavailable. Review the message sources.', code='retry_source_unavailable') + requested_public = set(body.get('active_public_workspace_ids') or []) + if body.get('active_public_workspace_id'): + requested_public.add(body['active_public_workspace_id']) + if requested_public: + available_public = ( + resolve_public_chat_workspace_ids(user_id, body['public_workspace_selection'], settings=settings) + if body.get('public_workspace_selection') + else get_user_visible_public_workspace_ids_from_settings(user_id) or [] + ) + if requested_public - set(available_public): + raise ChatRetryError('An original public workspace is unavailable. Review the message sources.', code='retry_source_unavailable') + image_references = references_from_metadata(metadata) + if image_references: + body['image_references'] = image_references + if metadata.get('image_reference_mask'): + body['image_mask_dropped'] = True + return body + + +def _message_attempt_payload(question, body, *, edited=False): + thread = question['metadata']['thread_info'] + body.update({ + 'edited_user_message_id' if edited else 'retry_user_message_id': question['id'], + 'retry_thread_id': thread['thread_id'], 'retry_thread_attempt': thread['thread_attempt'], + 'retry_source_user_message_id': question['metadata']['response_attempt']['source_user_message_id'], + }) + return { + 'success': True, 'message': 'Edit initiated' if edited else 'Retry initiated', + 'thread_id': thread['thread_id'], 'new_attempt': thread['thread_attempt'], + 'user_message_id': question['id'], 'user_message': question, + 'attempt_state': question['metadata']['response_attempt']['state'], + 'available_attempts': available_retry_attempts(cosmos_messages_container, question['conversation_id'], thread['thread_id']), + 'edited': edited, 'chat_request': body, + } + + +def _refuse_prepared_message_attempt(question, message, code): + if question: + set_retry_attempt_state( + cosmos_messages_container, question['conversation_id'], question['id'], + 'failed', error=message, code=code, expected_states={'prepared'}, + ) + + +def _prepare_message_attempt_response(message_id, *, edited=False): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + prepared_question = None + try: + data = request.get_json(silent=True) + if data is None and not request.get_data(): + data = {} + if not isinstance(data, dict): + raise ChatRetryError('A retry request must be an object.', code='invalid_request', status_code=400) + clicked, _conversation = load_owned_retry_message( + cosmos_messages_container, cosmos_conversations_container, user_id, message_id, + ) + attempt = (clicked.get('metadata') or {}).get('response_attempt') or {} + if not edited and attempt.get('kind') == 'chat' and attempt.get('state') == 'prepared': + if any(data.get(key) for key in ('model', 'model_deployment', 'model_id', 'model_endpoint_id', 'agent_info', 'reasoning_effort')): + raise ChatRetryError('This attempt is already prepared. Generate its saved response before changing selections.', code='retry_in_progress') + prepared_question = clicked + body = _build_authorized_message_replay_request(user_id, clicked, {}, get_settings()) + question = reconcile_prepared_retry(cosmos_messages_container, cosmos_conversations_container, user_id, clicked) + return jsonify(_message_attempt_payload(question, body, edited=bool(clicked['metadata'].get('edited')))), 200 + if is_workflow_delivery_message(clicked): + payload, status = workflow_delivery_refusal_payload( + DELIVERY_EDIT_UNSUPPORTED if edited else DELIVERY_RETRY_UNSUPPORTED, + ) + return jsonify(payload), status + if edited and clicked.get('role') != 'user': + raise ChatRetryError('Only user messages can be edited.', code='invalid_request', status_code=400) + source = retry_source_user(cosmos_messages_container, clicked) + source_author = (source.get('metadata') or {}).get('user_info', {}).get('user_id') + if source_author and source_author != user_id: + raise ChatRetryError('You can only retry your own questions.', code='forbidden', status_code=403) + if (source.get('metadata') or {}).get('orchestration'): + raise ChatRetryError( + 'Regenerate an orchestration plan from this message instead of replaying it as ordinary chat.', + code='retry_requires_orchestration', + ) + if message_asks_about_workflow_result(clicked) or message_asks_about_workflow_result(source): + payload, status = workflow_result_error_payload(WorkflowResultUnavailable('workflow_result_retry_unsupported')) + return jsonify(payload), status + content = data.get('content') if edited else source.get('content') + if not isinstance(content, str) or not content.strip(): + raise ChatRetryError('Message content cannot be empty.', code='invalid_request', status_code=400) + settings = get_settings() + input_check = check_chat_content(content, 'chat_input', user_id=user_id, settings=settings) + if input_check.blocked: + record_blocked_chat_attempt(input_check, user_id, source['conversation_id']) + return jsonify({'error': input_check.notice, 'blocked': True}), 422 + body = _build_authorized_message_replay_request(user_id, source, {} if edited else data, settings) + body['message'] = content.strip() if edited else content + submission_id = data.get('submission_id') or str(uuid.uuid4()) + question = prepare_retry_attempt( + cosmos_messages_container, cosmos_conversations_container, user_id, source, body, + submission_id=submission_id, edited=edited, + ) + attach_chat_check(question, input_check) + patch_chat_message_metadata(cosmos_messages_container, question, fields=(CHECK_METADATA,)) + _rebuild_authorized_personal_conversation_used_documents(user_id, source['conversation_id']) + _invalidate_conversation_cache_after_message_mutation( + source['conversation_id'], user_id, 'message_edit_created' if edited else 'message_retry_created', + ) + return jsonify(_message_attempt_payload(question, body, edited=edited)), 200 + except ChatRetryError as error: + _refuse_prepared_message_attempt(prepared_question, error.public_message, error.code) + log_event('[CHAT_RETRY] Retry refused.', extra={'code': error.code, 'user_id': user_id}, level=logging.INFO) + return jsonify({'error': error.public_message, 'code': error.code}), error.status_code + except (SubmissionIdError, ModelCatalogError) as error: + _refuse_prepared_message_attempt(prepared_question, str(error), 'invalid_retry_selection') + log_event('[CHAT_RETRY] Invalid retry selection.', extra={'error_type': type(error).__name__}, level=logging.WARNING) + return jsonify({'error': str(error), 'code': 'invalid_retry_selection'}), 400 + except PermissionError: + message = 'An original selection is no longer authorized. Review your selections.' + _refuse_prepared_message_attempt(prepared_question, message, 'forbidden') + log_event('[CHAT_RETRY] Retry selection is no longer authorized.', extra={'user_id': user_id}, level=logging.WARNING) + return jsonify({'error': message, 'code': 'forbidden'}), 403 + except ScreeningError as error: + return jsonify({'error': error.public_message, 'error_code': error.code}), error.status_code + except PublicChatScopeError as error: + _refuse_prepared_message_attempt(prepared_question, error.public_message, error.code) + return jsonify({'error': error.public_message, 'code': error.code}), error.status_code + except AzureError as error: + log_event( + '[CHAT_RETRY] Retry preparation storage is unavailable.', + extra={'error_type': type(error).__name__, 'user_id': user_id}, level=logging.ERROR, + ) + return jsonify({ + 'error': 'The retry could not be prepared. Reload to check the attempt before retrying.', + 'code': 'retry_storage_unavailable', + }), 503 + + def _get_requested_workspace_document_delete_ids_for_conversation(payload, conversation_id): if not isinstance(payload, dict): return [] @@ -1295,6 +1487,7 @@ def api_get_messages(): int(item.get('fork_sequence')) if str(item.get('fork_sequence') or '').isdigit() else 0, str(item.get('id') or ''), )) + all_items = order_retry_messages(all_items) # Deleted while archiving was enabled. They are masked too, but only as a # fail-safe: returned here, they would render as masked messages. all_items = exclude_soft_deleted_messages(all_items) @@ -3400,500 +3593,16 @@ def delete_message(message_id): @login_required @user_required def retry_message(message_id): - """ - Retry/regenerate a message by creating new user+system+assistant messages - with incremented thread_attempt and same thread_id. - Only the message author can retry their messages. - """ - user_id = get_current_user_id() - if not user_id: - return jsonify({'error': 'User not authenticated'}), 401 - - try: - data = request.get_json() or {} - selected_model = data.get('model') - reasoning_effort = data.get('reasoning_effort') - agent_info = data.get('agent_info') # Get agent info if provided - - # Find the original message - query = "SELECT * FROM c WHERE c.id = @message_id" - params = [{"name": "@message_id", "value": message_id}] - message_results = list(cosmos_messages_container.query_items( - query=query, - parameters=params, - enable_cross_partition_query=True - )) - - if not message_results: - return jsonify({'error': 'Message not found'}), 404 - - original_msg = message_results[0] - if is_soft_deleted_message(original_msg): - return jsonify({'error': 'Message not found'}), 404 - conversation_id = original_msg.get('conversation_id') - original_role = original_msg.get('role') - - # Verify ownership - message_user_id = original_msg.get('metadata', {}).get('user_info', {}).get('user_id') - if not message_user_id: - # Fallback to conversation ownership - try: - conversation = cosmos_conversations_container.read_item( - item=conversation_id, - partition_key=conversation_id - ) - if conversation.get('user_id') != user_id: - return jsonify({'error': 'You can only retry messages from your own conversations'}), 403 - except Exception as ex: - return jsonify({'error': 'Conversation not found'}), 404 - elif message_user_id != user_id: - return jsonify({'error': 'You can only retry your own messages'}), 403 - - if is_workflow_delivery_message(original_msg): - # A workflow run posted this message, so there is no question to replay. - payload, status = workflow_delivery_refusal_payload(DELIVERY_RETRY_UNSUPPORTED) - return jsonify(payload), status - - # Get thread info from original message - thread_id = original_msg.get('metadata', {}).get('thread_info', {}).get('thread_id') - previous_thread_id = original_msg.get('metadata', {}).get('thread_info', {}).get('previous_thread_id') - - if not thread_id: - return jsonify({'error': 'Message has no thread_id'}), 400 - - # The question is replayed from the earliest attempt that still exists. A deleted - # attempt is not a source: its metadata would make the new question deleted too. - user_msg_results = exclude_soft_deleted_messages(list(cosmos_messages_container.query_items( - query=( - "SELECT * FROM c WHERE c.conversation_id = @conversation " - "AND c.metadata.thread_info.thread_id = @thread AND c.role = 'user' " - "ORDER BY c.metadata.thread_info.thread_attempt ASC" - ), - parameters=[ - {"name": "@conversation", "value": conversation_id}, - {"name": "@thread", "value": thread_id}, - ], - partition_key=conversation_id, - ))) - if not user_msg_results: - return jsonify({"error": "User message not found in thread"}), 404 - original_user_msg = user_msg_results[0] - if ( - message_asks_about_workflow_result(original_msg) - or message_asks_about_workflow_result(original_user_msg) - ): - # A retry would replay a Follow up question as an ordinary turn that still names the result. - # A later answer that only inherited the lineage replays an ordinary question and re-checks it. - payload, status = workflow_result_error_payload( - WorkflowResultUnavailable("workflow_result_retry_unsupported") - ) - return jsonify(payload), status - user_content = original_user_msg.get("content", "") - input_check = check_chat_content(user_content, "chat_input", user_id=user_id) - if input_check.blocked: - record_blocked_chat_attempt(input_check, user_id, conversation_id) - return jsonify({"error": input_check.notice, "blocked": True}), 422 - - # Find current max thread_attempt for this thread_id - attempt_query = f""" - SELECT VALUE MAX(c.metadata.thread_info.thread_attempt) - FROM c - WHERE c.conversation_id = '{conversation_id}' - AND c.metadata.thread_info.thread_id = '{thread_id}' - """ - attempt_results = list(cosmos_messages_container.query_items( - query=attempt_query, - partition_key=conversation_id - )) - - current_max_attempt = attempt_results[0] if attempt_results and attempt_results[0] is not None else 0 - new_attempt = current_max_attempt + 1 - - # Set all existing attempts for this thread to active_thread=false - deactivate_query = f""" - SELECT * FROM c - WHERE c.conversation_id = '{conversation_id}' - AND c.metadata.thread_info.thread_id = '{thread_id}' - """ - existing_messages = list(cosmos_messages_container.query_items( - query=deactivate_query, - partition_key=conversation_id - )) - - print(f"🔍 Retry - Found {len(existing_messages)} existing messages to deactivate") - - for msg in existing_messages: - msg_id = msg.get('id', 'unknown') - msg_role = msg.get('role', 'unknown') - old_active = msg.get('metadata', {}).get('thread_info', {}).get('active_thread', None) - - if 'metadata' not in msg: - msg['metadata'] = {} - if 'thread_info' not in msg['metadata']: - msg['metadata']['thread_info'] = {} - msg['metadata']['thread_info']['active_thread'] = False - patch_chat_message_metadata(cosmos_messages_container, msg) - - print(f" ✏️ Deactivated: {msg_id} (role={msg_role}, was_active={old_active}, now_active=False)") - - # Get the first user message (attempt 1) to get original content and metadata - original_metadata = original_user_msg.get('metadata', {}) - original_thread_info = original_metadata.get('thread_info', {}) - - print(f"🔍 Retry - Original user message: {original_user_msg.get('id')}") - print(f"🔍 Retry - Original thread_id: {original_thread_info.get('thread_id')}") - print(f"🔍 Retry - Original previous_thread_id: {original_thread_info.get('previous_thread_id')}") - print(f"🔍 Retry - Original attempt: {original_thread_info.get('thread_attempt')}") - print(f"🔍 Retry - New attempt will be: {new_attempt}") - - # Create new user message with same content but new attempt number - import uuid - import time - import random - - new_user_message_id = f"{conversation_id}_user_{int(time.time())}_{random.randint(1000,9999)}" - - # Copy metadata but update thread_attempt and keep same thread_id and previous_thread_id from original - new_metadata = dict(original_metadata) - strip_soft_delete_metadata(new_metadata) - new_metadata['retried'] = True # Mark as retried - new_metadata['thread_info'] = { - 'thread_id': thread_id, # Keep same thread_id - 'previous_thread_id': original_thread_info.get('previous_thread_id'), # Preserve original previous_thread_id - 'active_thread': True, - 'thread_attempt': new_attempt - } - - print(f"🔍 Retry - New user message ID: {new_user_message_id}") - print(f"🔍 Retry - New thread_info: {new_metadata['thread_info']}") - - # Create new user message - new_user_message = { - 'id': new_user_message_id, - 'conversation_id': conversation_id, - 'role': 'user', - 'content': user_content, - 'timestamp': datetime.utcnow().isoformat(), - 'model_deployment_name': None, - 'metadata': new_metadata - } - attach_chat_check(new_user_message, input_check) - cosmos_messages_container.upsert_item(new_user_message) - - _rebuild_authorized_personal_conversation_used_documents( - user_id, - conversation_id, - ) - _invalidate_conversation_cache_after_message_mutation( - conversation_id, - user_id, - "message_retry_created", - ) - # Build chat request parameters from original message metadata - replayed_document_context = _build_replayed_document_context(original_metadata) - chat_request = { - 'message': user_content, - 'conversation_id': conversation_id, - 'model_deployment': selected_model or original_metadata.get('model_selection', {}).get('selected_model'), - 'reasoning_effort': reasoning_effort or original_metadata.get('reasoning_effort'), - **replayed_document_context, - 'image_generation': original_metadata.get('image_generation', {}).get('enabled', False), - 'active_group_id': original_metadata.get('chat_context', {}).get('group_id'), - 'active_public_workspace_id': original_metadata.get('chat_context', {}).get('public_workspace_id'), - 'chat_type': original_metadata.get('chat_context', {}).get('type', 'user'), - 'retry_user_message_id': new_user_message_id, # Pass this to skip user message creation - 'retry_thread_id': thread_id, # Pass thread_id to maintain same thread - 'retry_thread_attempt': new_attempt # Pass attempt number - } - image_references = references_from_metadata(original_metadata) - if image_references: - chat_request['image_references'] = image_references - if original_metadata.get('image_reference_mask'): - chat_request['image_mask_dropped'] = True - - # Add agent_info to chat request if provided (for agent-based retry) - if agent_info: - chat_request['agent_info'] = agent_info - print(f"🤖 Retry - Using agent: {agent_info.get('display_name')} ({agent_info.get('name')})") - elif original_metadata.get('agent_selection'): - # Use original agent selection if no new agent specified - chat_request['agent_info'] = original_metadata.get('agent_selection') - print(f"🤖 Retry - Using original agent from metadata") - - print(f"🔍 Retry - Chat request params: retry_user_message_id={new_user_message_id}, retry_thread_id={thread_id}, retry_thread_attempt={new_attempt}") - - # Make internal request to chat API - from flask import g - g.conversation_id = conversation_id - - # Import and call chat function directly - # We'll need to modify the chat_api to handle retry requests - return jsonify({ - 'success': True, - 'message': 'Retry initiated', - 'thread_id': thread_id, - 'new_attempt': new_attempt, - 'user_message_id': new_user_message_id, - 'chat_request': chat_request - }), 200 - - except Exception as e: - print(f"Error retrying message: {str(e)}") - import traceback - traceback.print_exc() - return jsonify({'error': 'Failed to retry message'}), 500 + """Prepare a new attempt using the viewed question's authorized input settings.""" + return _prepare_message_attempt_response(message_id) @bp.route('/api/message//edit', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required @user_required def edit_message(message_id): - """ - Edit a user message and regenerate the response with the edited content. - Creates a new attempt with edited content while preserving original model/settings. - Only the message author can edit their messages. - """ - user_id = get_current_user_id() - if not user_id: - return jsonify({'error': 'User not authenticated'}), 401 - - try: - data = request.get_json() or {} - edited_content = data.get('content', '').strip() - - if not edited_content: - return jsonify({'error': 'Message content cannot be empty'}), 400 - - # Find the original message - query = "SELECT * FROM c WHERE c.id = @message_id" - params = [{"name": "@message_id", "value": message_id}] - message_results = list(cosmos_messages_container.query_items( - query=query, - parameters=params, - enable_cross_partition_query=True - )) - - if not message_results: - return jsonify({'error': 'Message not found'}), 404 - - original_msg = message_results[0] - if is_soft_deleted_message(original_msg): - return jsonify({'error': 'Message not found'}), 404 - conversation_id = original_msg.get('conversation_id') - original_role = original_msg.get('role') - - if is_workflow_delivery_message(original_msg): - # A workflow run posted this message, so there is nothing of the user's to edit. - payload, status = workflow_delivery_refusal_payload(DELIVERY_EDIT_UNSUPPORTED) - return jsonify(payload), status - - # Only allow editing user messages - if original_role != 'user': - return jsonify({'error': 'Only user messages can be edited'}), 400 - - # Verify ownership - message_user_id = original_msg.get('metadata', {}).get('user_info', {}).get('user_id') - if not message_user_id: - # Fallback to conversation ownership - try: - conversation = cosmos_conversations_container.read_item( - item=conversation_id, - partition_key=conversation_id - ) - if conversation.get('user_id') != user_id: - return jsonify({'error': 'You can only edit messages from your own conversations'}), 403 - except Exception as ex: - return jsonify({'error': 'Conversation not found'}), 404 - elif message_user_id != user_id: - return jsonify({'error': 'You can only edit your own messages'}), 403 - - if message_asks_about_workflow_result(original_msg): - # An edit would replay a Follow up question as an ordinary turn that still names the result. - payload, status = workflow_result_error_payload( - WorkflowResultUnavailable("workflow_result_retry_unsupported") - ) - return jsonify(payload), status - - input_check = check_chat_content(edited_content, "chat_input", user_id=user_id) - if input_check.blocked: - record_blocked_chat_attempt(input_check, user_id, conversation_id) - return jsonify({"error": input_check.notice, "blocked": True}), 422 - - # Get thread info from original message - thread_id = original_msg.get('metadata', {}).get('thread_info', {}).get('thread_id') - previous_thread_id = original_msg.get('metadata', {}).get('thread_info', {}).get('previous_thread_id') - - if not thread_id: - return jsonify({'error': 'Message has no thread_id'}), 400 - - # Find current max thread_attempt for this thread_id - attempt_query = f""" - SELECT VALUE MAX(c.metadata.thread_info.thread_attempt) - FROM c - WHERE c.conversation_id = '{conversation_id}' - AND c.metadata.thread_info.thread_id = '{thread_id}' - """ - attempt_results = list(cosmos_messages_container.query_items( - query=attempt_query, - partition_key=conversation_id - )) - - current_max_attempt = attempt_results[0] if attempt_results and attempt_results[0] is not None else 0 - new_attempt = current_max_attempt + 1 - - # Set all existing attempts for this thread to active_thread=false - deactivate_query = f""" - SELECT * FROM c - WHERE c.conversation_id = '{conversation_id}' - AND c.metadata.thread_info.thread_id = '{thread_id}' - """ - existing_messages = list(cosmos_messages_container.query_items( - query=deactivate_query, - partition_key=conversation_id - )) - - print(f"🔍 Edit - Found {len(existing_messages)} existing messages to deactivate") - - for msg in existing_messages: - msg_id = msg.get('id', 'unknown') - msg_role = msg.get('role', 'unknown') - old_active = msg.get('metadata', {}).get('thread_info', {}).get('active_thread', None) - - if 'metadata' not in msg: - msg['metadata'] = {} - if 'thread_info' not in msg['metadata']: - msg['metadata']['thread_info'] = {} - msg['metadata']['thread_info']['active_thread'] = False - patch_chat_message_metadata(cosmos_messages_container, msg) - - print(f" ✏️ Deactivated: {msg_id} (role={msg_role}, was_active={old_active}, now_active=False)") - - # Get the earliest user message in this thread that still exists, for its metadata. - # A deleted attempt is not a source: its metadata would make the edit deleted too. - user_msg_query = f""" - SELECT * FROM c - WHERE c.conversation_id = '{conversation_id}' - AND c.metadata.thread_info.thread_id = '{thread_id}' - AND c.role = 'user' - ORDER BY c.metadata.thread_info.thread_attempt ASC - """ - user_msg_results = exclude_soft_deleted_messages(list(cosmos_messages_container.query_items( - query=user_msg_query, - partition_key=conversation_id - ))) - - if not user_msg_results: - return jsonify({'error': 'User message not found in thread'}), 404 - - # Get the first user message (attempt 1) to get original metadata - original_user_msg = user_msg_results[0] - original_metadata = original_user_msg.get('metadata', {}) - original_thread_info = original_metadata.get('thread_info', {}) - - print(f"🔍 Edit - Original user message: {original_user_msg.get('id')}") - print(f"🔍 Edit - Original thread_id: {original_thread_info.get('thread_id')}") - print(f"🔍 Edit - Original previous_thread_id: {original_thread_info.get('previous_thread_id')}") - print(f"🔍 Edit - Original attempt: {original_thread_info.get('thread_attempt')}") - print(f"🔍 Edit - New attempt will be: {new_attempt}") - - # Create new user message with edited content - import time - import random - - new_user_message_id = f"{conversation_id}_user_{int(time.time())}_{random.randint(1000,9999)}" - - # Copy metadata but update thread_attempt, add edited flag, and keep same thread_id - new_metadata = dict(original_metadata) - strip_soft_delete_metadata(new_metadata) - new_metadata['edited'] = True # Mark as edited - new_metadata['thread_info'] = { - 'thread_id': thread_id, # Keep same thread_id - 'previous_thread_id': original_thread_info.get('previous_thread_id'), # Preserve original - 'active_thread': True, - 'thread_attempt': new_attempt - } - - print(f"🔍 Edit - New user message ID: {new_user_message_id}") - print(f"🔍 Edit - New thread_info: {new_metadata['thread_info']}") - print(f"🔍 Edit - Edited flag set: {new_metadata.get('edited')}") - - # Create new user message with edited content - new_user_message = { - 'id': new_user_message_id, - 'conversation_id': conversation_id, - 'role': 'user', - 'content': edited_content, # Use edited content - 'timestamp': datetime.utcnow().isoformat(), - 'model_deployment_name': None, - 'metadata': new_metadata - } - attach_chat_check(new_user_message, input_check) - cosmos_messages_container.upsert_item(new_user_message) - - _rebuild_authorized_personal_conversation_used_documents( - user_id, - conversation_id, - ) - _invalidate_conversation_cache_after_message_mutation( - conversation_id, - user_id, - "message_edit_created", - ) - # Build chat request parameters from original message metadata - # Keep all original settings (model, reasoning, doc search, etc.) - replayed_document_context = _build_replayed_document_context(original_metadata) - chat_request = { - 'message': edited_content, # Use edited content - 'conversation_id': conversation_id, - 'model_deployment': original_metadata.get('model_selection', {}).get('selected_model'), - 'reasoning_effort': original_metadata.get('reasoning_effort'), - **replayed_document_context, - 'image_generation': original_metadata.get('image_generation', {}).get('enabled', False), - 'active_group_id': original_metadata.get('chat_context', {}).get('group_id'), - 'active_public_workspace_id': original_metadata.get('chat_context', {}).get('public_workspace_id'), - 'chat_type': original_metadata.get('chat_context', {}).get('type', 'user'), - 'edited_user_message_id': new_user_message_id, # Pass this to skip user message creation - 'retry_thread_id': thread_id, # Pass thread_id to maintain same thread - 'retry_thread_attempt': new_attempt # Pass attempt number - } - image_references = references_from_metadata(original_metadata) - if image_references: - chat_request['image_references'] = image_references - if original_metadata.get('image_reference_mask'): - chat_request['image_mask_dropped'] = True - - # Include agent_info from original metadata if present (for agent-based edits) - if original_metadata.get('agent_selection'): - agent_selection = original_metadata.get('agent_selection') - chat_request['agent_info'] = { - 'name': agent_selection.get('selected_agent'), - 'display_name': agent_selection.get('agent_display_name'), - 'id': agent_selection.get('agent_id'), - 'is_global': agent_selection.get('is_global', False), - 'is_group': agent_selection.get('is_group', False), - 'group_id': agent_selection.get('group_id'), - 'group_name': agent_selection.get('group_name') - } - print(f"🤖 Edit - Using agent: {chat_request['agent_info'].get('display_name')} ({chat_request['agent_info'].get('name')})") - - print(f"🔍 Edit - Chat request params: edited_user_message_id={new_user_message_id}, retry_thread_id={thread_id}, retry_thread_attempt={new_attempt}") - - # Return success with chat_request for frontend to call chat API - return jsonify({ - 'success': True, - 'message': 'Edit initiated', - 'thread_id': thread_id, - 'new_attempt': new_attempt, - 'user_message_id': new_user_message_id, - 'edited': True, - 'chat_request': chat_request - }), 200 - - except Exception as e: - print(f"Error editing message: {str(e)}") - import traceback - traceback.print_exc() - return jsonify({'error': 'Failed to edit message'}), 500 + """Prepare an edited attempt without changing the viewed attempt's routing.""" + return _prepare_message_attempt_response(message_id, edited=True) @bp.route('/api/message//switch-attempt', methods=['POST']) @swagger_route(security=get_auth_security()) diff --git a/application/single_app/route_backend_orchestration.py b/application/single_app/route_backend_orchestration.py index 370bfd5d9..7a7cd58dc 100644 --- a/application/single_app/route_backend_orchestration.py +++ b/application/single_app/route_backend_orchestration.py @@ -40,27 +40,34 @@ from copy import deepcopy from agent_execution_context import capture_execution_identity from datetime import datetime, timezone +from functools import wraps from azure.cosmos import exceptions 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, request, session, stream_with_context +from flask import Response, g, has_request_context, jsonify, make_response, request, session, stream_with_context from config import cosmos_conversations_container, cosmos_messages_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_assist_submissions import SubmissionIdError, normalize_submission_id from public_chat_scope import prepare_public_chat_scope, public_chat_scope_required -from public_chat_scope_state import current_public_chat_scope +from public_chat_scope_state import PublicChatScopeError, current_public_chat_scope from functions_public_workspaces import resolve_public_chat_workspace_ids from functions_chat_content_checks import ( CHECK_METADATA, check_chat_content, orchestration_input_text, should_withhold_chat_event, strip_private_chat_checks, ) from functions_chat_content_review import ( - checked_history_messages, record_blocked_chat_attempt, reply_is_retracted, + 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, + prepare_retry_attempt, reconcile_prepared_retry, retry_history_prefix, set_retry_attempt_state, ) +from functions_message_deletion import is_soft_deleted_message from functions_saved_analysis import ( analysis_result_contexts, load_saved_analysis, @@ -419,6 +426,7 @@ def _prepare_execution_stream(record, data, user_id, settings, snapshot, identit result_alias_resolver=lambda current: admitted_result_aliases(current, services.results), export_catalog=services.export_catalog(), composition_profiles=composition_profiles(), settings=settings, + reviewed_regeneration=data.get('reviewed_regeneration') is True, ) except ( PlanRevisionError, AzureError, ResultContractError, PermissionError, @@ -427,6 +435,11 @@ def _prepare_execution_stream(record, data, user_id, settings, snapshot, identit ) as exc: payload, status = _plan_edit_error(exc) return jsonify(payload), status + if claimed.get('requires_fresh_review'): + set_retry_attempt_state( + cosmos_messages_container, conversation_id, claimed.get('user_message_id'), 'running', + run_id=claimed['run_id'], expected_states={'awaiting_review'}, + ) lease = ExecutionLease( claimed, lambda: _authorize_context_conversation(conversation_id, user_id), message_container=cosmos_messages_container, @@ -512,7 +525,7 @@ def _document_labels(candidates): } -def _load_ledger(conversation_id, user_id, settings): +def _load_ledger(conversation_id, user_id, settings, *, message_ids=None): """Earlier runs in this conversation, summarised for the planner. A ledger that cannot be read is not a reason to refuse to plan; it only means this turn @@ -579,6 +592,7 @@ def _load_ledger(conversation_id, user_id, settings): run for run in runs if run.get('user_message_id') in visible and (not run.get('assistant_message_id') or run['assistant_message_id'] in visible) + and (message_ids is None or run.get('user_message_id') in message_ids) ] safe_runs = [] for run in runs: @@ -633,6 +647,421 @@ def _authorize_context_conversation(conversation_id, user_id): WORKFLOW_REPLAY_RUN_MANAGED_CODE = 'workflow_replay_run_managed' +def _regeneration_unavailable(): + return ChatRetryError( + 'This request has no complete saved planning inputs. Copy the question to the composer, ' + 'review its selections, and deliberately create a new plan.', + code='orchestration_regeneration_context_unavailable', + ) + + +def _regeneration_source(message_id, user_id): + clicked, _ = load_owned_retry_message( + cosmos_messages_container, cosmos_conversations_container, user_id, message_id, + ) + metadata = clicked.get('metadata') or {} + orchestration = metadata.get('orchestration') or {} + run_id = orchestration.get('run_id') or (metadata.get('response_attempt') or {}).get('run_id') + turn_id = orchestration.get('turn_id') or metadata.get('orchestration_turn_id') + record = ( + get_latest_turn_run(clicked['conversation_id'], user_id, turn_id) + if clicked.get('role') == 'user' and turn_id + else get_orchestration_run(run_id, user_id, clicked['conversation_id'], strict=True) if run_id else None + ) + if not record and clicked.get('role') == 'user': + attempt = metadata.get('response_attempt') or {} + if attempt.get('kind') == 'orchestration' and attempt.get('state') in ('failed', 'interrupted'): + _restore_prepared_regeneration(clicked, user_id, get_settings()) + parent = get_orchestration_run( + (metadata.get('orchestration_regeneration') or {}).get('source_run_id'), + user_id, clicked['conversation_id'], strict=True, + ) + return clicked, parent + if not record or is_legacy_plan(record.get('plan')): + raise _regeneration_unavailable() + if _is_workflow_replay_run(record): + raise ChatRetryError(_workflow_replay_run_message(), code=WORKFLOW_REPLAY_RUN_MANAGED_CODE) + if clicked.get('role') == 'user': + source = clicked + elif clicked.get('role') == 'assistant' and record.get('user_message_id'): + source, _ = load_owned_retry_message( + cosmos_messages_container, cosmos_conversations_container, user_id, record['user_message_id'], + ) + else: + raise _regeneration_unavailable() + if record.get('user_message_id') != source['id'] or record.get('user_message') != source.get('content'): + raise _regeneration_unavailable() + if record.get('latest_attempt_run_id'): + latest = get_orchestration_run( + record['latest_attempt_run_id'], user_id, source['conversation_id'], strict=True, + ) + if not latest or latest.get('user_message_id') != source['id']: + raise _regeneration_unavailable() + record = latest + _require_regeneration_source_idle(record) + return source, record + + +def _require_regeneration_source_idle(record): + terminal = {'completed', 'failed', 'cancelled', 'canceled', 'interrupted', 'expired', 'timed_out'} + if record.get('status') in ('running', 'waiting') or ( + record.get('started_at') and record.get('status') not in terminal + ): + raise ChatRetryError( + 'This execution is still active or waiting for input. Finish or cancel it before regenerating its plan.', + code='orchestration_regeneration_busy', + ) + + +def _regeneration_request(record): + seeds = record.get('seeds') or record.get('original_seeds') + if not isinstance(seeds, dict) or 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'], + 'selected_document_ids': deepcopy(seeds.get('document_ids') or []), + 'doc_scope': seeds.get('doc_scope') or 'all', + 'tags': deepcopy(seeds.get('tags') or []), + 'document_filter_mode': seeds.get('document_filter_mode') or 'intersection', + 'active_group_ids': deepcopy(seeds.get('active_group_ids') or []), + 'active_public_workspace_ids': deepcopy(seeds.get('active_public_workspace_ids') or []), + 'public_workspace_selection': deepcopy(seeds.get('public_workspace_selection')), + 'reasoning_effort': seeds.get('reasoning_effort'), + 'web_search_enabled': bool(seeds.get('web_search')), + 'image_generation_enabled': bool(seeds.get('image_generation')), + 'image_references': deepcopy(seeds.get('image_references') or []), + 'required_capabilities': deepcopy(seeds.get('required_capabilities') or []), + 'elicitation_references': deepcopy(seeds.get('elicitation_references') or []), + } + if seeds.get('agent'): + body['agent_info'] = deepcopy(seeds['agent']) + if seeds.get('model_routing') == 'auto': + body['model_routing'] = 'auto' + elif seeds.get('model'): + body.update({key: value for key, value in seeds['model'].items() if key in MODEL_FIELDS}) + if seeds.get('prompt'): + body['prompt_info'] = deepcopy(seeds['prompt']) + if record.get('time_zone'): + body['time_zone'] = record['time_zone'] + return body + + +def _regeneration_input_fingerprint(record): + return hashlib.sha256(json.dumps( + {'request': _regeneration_request(record), 'answers': record.get('answered_questions') or []}, + sort_keys=True, ensure_ascii=False, separators=(',', ':'), + ).encode('utf-8')).hexdigest() + + +def _authorize_regeneration_request(source, body, user_id, settings, *, overrides=None): + # Shared replay authorization loads after both route modules have registered. + from route_backend_conversations import _build_authorized_message_replay_request + + seeds = resolve_seeds(body) + seeds['elicitation_references'] = deepcopy(body.get('elicitation_references') or []) + synthetic = deepcopy(source) + metadata = synthetic.setdefault('metadata', {}) + metadata['image_generation'] = {'enabled': False} + metadata.pop('agent_selection', None) + metadata['model_selection'] = { + 'selected_model': body.get('model_deployment'), + **{key: body[key] for key in MODEL_FIELDS if key != 'model_deployment' and key in body}, + 'reasoning_effort': body.get('reasoning_effort'), + } + if body.get('agent_info'): + metadata['agent_selection'] = deepcopy(body['agent_info']) + metadata['workspace_search'] = { + 'requested_document_ids': seeds['document_ids'], 'document_scope': seeds['doc_scope'], + 'tags': seeds['tags'], 'active_group_ids': seeds['active_group_ids'], + 'active_public_workspace_ids': seeds['active_public_workspace_ids'], + 'public_workspace_selection': seeds['public_workspace_selection'], + 'document_filter_mode': seeds['document_filter_mode'], + } + authorized = _build_authorized_message_replay_request( + user_id, synthetic, overrides or {}, settings, allow_auto_selection=seeds.get('model_routing') == 'auto', + ) + result = deepcopy(body) + explicit_selection = any((overrides or {}).get(key) for key in (*MODEL_FIELDS, 'model', 'agent_info')) + if explicit_selection: + result.pop('model_routing', None) + for key in (*MODEL_FIELDS, 'agent_info', 'reasoning_effort'): + result.pop(key, None) + if authorized.get('agent_info'): + result['agent_info'] = authorized['agent_info'] + else: + for key in (*MODEL_FIELDS, 'reasoning_effort'): + if authorized.get(key): + result[key] = authorized[key] + if seeds.get('public_workspace_selection'): + prepare_public_chat_scope( + seeds, user_id, settings, resolve_public_chat_workspace_ids, + _authorize_context_conversation(source['conversation_id'], user_id), + ) + resolve_elicitation_references( + seeds['elicitation_references'], user_id, source['conversation_id'], settings=settings, + ) + if seeds['document_ids']: + candidates, _ = resolve_candidate_documents( + source['content'], user_id, seeds=seeds, + conversation_id=source['conversation_id'], settings=settings, + ) + available = {candidate.get('document_id') for candidate in candidates} + if set(seeds['document_ids']) - available: + raise ChatRetryError('An original document is unavailable. Review the request sources.', code='retry_source_unavailable') + else: + candidates = [] + if seeds.get('image_references'): + resolve_image_reference_candidates( + seeds, user_id, source['conversation_id'], candidates=candidates, settings=settings, + ) + return result + + +def _load_regeneration_snapshot(source, user_id, settings): + """Bound context by the original logical turn, including earlier retried turns.""" + _authorize_context_conversation(source['conversation_id'], user_id) + preview = deepcopy(source) + metadata = preview.setdefault('metadata', {}) + metadata['retried'] = True + thread = metadata.setdefault('thread_info', {}) + thread.setdefault('thread_id', f"orchestration_thread_{uuid.uuid5(uuid.NAMESPACE_URL, source['id']).hex}") + anchor = thread.get('root_timestamp') or source['timestamp'] + thread['root_timestamp'] = anchor + rows = list(cosmos_messages_container.query_items( + query=( + f'SELECT TOP {HISTORY_SCAN_LIMIT} * FROM c WHERE c.conversation_id = @conversation_id ' + 'AND (c.timestamp < @anchor OR c.metadata.thread_info.root_timestamp < @anchor) ' + 'AND c.role IN ("user", "assistant") ORDER BY c.timestamp DESC' + ), + parameters=[ + {'name': '@conversation_id', 'value': source['conversation_id']}, + {'name': '@anchor', 'value': anchor}, + ], + partition_key=source['conversation_id'], + )) + truncated = len(rows) >= HISTORY_SCAN_LIMIT + rows = retry_history_prefix([*rows, preview], preview['id'])[:-1] + rows = checked_history_messages(rows, for_model=True) + rows = sanitize_saved_analysis_messages(rows, user_id) + return build_conversation_snapshot( + rows, settings, truncated=truncated, logical_order=True, + ) + + +def _link_regeneration_thread(source, record, user_id): + if (source.get('metadata') or {}).get('thread_info', {}).get('thread_id'): + return source + thread = { + 'thread_id': f"orchestration_thread_{uuid.uuid5(uuid.NAMESPACE_URL, source['id']).hex}", + 'thread_attempt': 1, 'active_thread': True, 'previous_thread_id': None, + 'root_timestamp': source['timestamp'], + } + source.setdefault('metadata', {})['thread_info'] = thread + source = patch_chat_message_metadata(cosmos_messages_container, source) + records = [record, *list_conversation_runs(source['conversation_id'], user_id, limit=100, strict=True)] + linked = set() + for saved_run in records: + answer_id = saved_run.get('assistant_message_id') + if not answer_id or answer_id in linked or saved_run.get('user_message_id') != source['id']: + continue + linked.add(answer_id) + try: + answer = cosmos_messages_container.read_item(item=answer_id, partition_key=source['conversation_id']) + except CosmosResourceNotFoundError: + continue + if answer.get('conversation_id') != source['conversation_id'] or is_soft_deleted_message(answer): + continue + answer.setdefault('metadata', {})['thread_info'] = deepcopy(thread) + patch_chat_message_metadata(cosmos_messages_container, answer) + return source + + +def _prepared_regeneration_response(question): + turn_id = (question.get('metadata') or {}).get('orchestration', {}).get('turn_id') + if not turn_id: + turn_id = f"turn_regeneration_{uuid.uuid5(uuid.NAMESPACE_URL, question['id']).hex}" + metadata = question.setdefault('metadata', {}) + metadata.setdefault('orchestration', {}).update({'turn_id': turn_id, 'requires_fresh_review': True}) + metadata['orchestration_turn_id'] = turn_id + question = patch_chat_message_metadata( + cosmos_messages_container, question, fields=('orchestration', 'orchestration_turn_id'), + ) + thread = question['metadata']['thread_info'] + return { + 'success': True, 'user_message': question, 'user_message_id': question['id'], + 'thread_id': thread['thread_id'], 'new_attempt': thread['thread_attempt'], + 'attempt_state': question['metadata']['response_attempt']['state'], + 'turn_id': turn_id, 'requires_fresh_review': True, + 'available_attempts': available_retry_attempts(cosmos_messages_container, question['conversation_id'], thread['thread_id']), + 'plan_request': { + **deepcopy(question['metadata']['orchestration_inputs']), + 'turn_id': turn_id, 'retry_user_message_id': question['id'], 'approval_mode': 'manual', + 'retry_thread_id': thread['thread_id'], 'retry_thread_attempt': thread['thread_attempt'], + }, + } + + +def _restore_prepared_regeneration(question, user_id, settings): + metadata = question.get('metadata') or {} + lineage = metadata.get('orchestration_regeneration') or {} + parent = get_orchestration_run( + lineage.get('source_run_id'), user_id, question['conversation_id'], strict=True, + ) + if not parent or _regeneration_input_fingerprint(parent) != lineage.get('source_input_fingerprint'): + raise ChatRetryError('The original planning inputs changed. Review a new request.', code='retry_source_changed') + _require_regeneration_source_idle(parent) + if parent.get('latest_attempt_run_id'): + latest = get_orchestration_run( + parent['latest_attempt_run_id'], user_id, question['conversation_id'], strict=True, + ) + if not latest or latest.get('user_message_id') != parent.get('user_message_id'): + raise _regeneration_unavailable() + _require_regeneration_source_idle(latest) + body = metadata.get('orchestration_inputs') + if not isinstance(body, dict) or body.get('message') != question.get('content') or body.get('conversation_id') != question['conversation_id']: + raise _regeneration_unavailable() + normalized = normalize_history_message(question) + if not normalized: + raise ChatRetryError('This question is unavailable or no longer selected. Reload it.', code='retry_source_changed') + body = _authorize_regeneration_request(question, body, user_id, settings) + snapshot = _load_regeneration_snapshot(question, user_id, settings) + answers = deepcopy(metadata.get('orchestration_clarification_answers', parent.get('answered_questions') or [])) + validate_clarification_answers(answers) + return body, { + 'user_message_id': question['id'], 'user_message_fingerprint': normalized['fingerprint'], + 'conversation_context': snapshot, 'answered_questions': answers, + 'regeneration_of_run_id': parent['run_id'], 'requires_fresh_review': True, + 'prompt_selection': deepcopy(metadata.get('prompt_selection') or parent.get('prompt_selection')), + 'retry_thread_info': deepcopy(metadata.get('thread_info') or {}), + } + + +def _save_regeneration_inputs(turn_context, user_id, conversation_id): + if not turn_context.get('requires_fresh_review') or not turn_context.get('user_message_id'): + return + question, _ = load_owned_retry_message( + cosmos_messages_container, cosmos_conversations_container, user_id, turn_context['user_message_id'], + ) + metadata = question.setdefault('metadata', {}) + if (metadata.get('response_attempt') or {}).get('kind') != 'orchestration': + return + metadata['orchestration_inputs'] = _regeneration_request({ + **turn_context, 'conversation_id': conversation_id, + }) + metadata['orchestration_clarification_answers'] = deepcopy(turn_context.get('answered_questions') or []) + patch_chat_message_metadata( + cosmos_messages_container, question, fields=('orchestration_inputs', 'orchestration_clarification_answers'), + ) + set_retry_attempt_state( + cosmos_messages_container, conversation_id, question['id'], 'planning', + expected_states={'awaiting_clarification'}, + ) + + +def _regeneration_error(exc): + if isinstance(exc, ChatRetryError): + payload, status = {'error': exc.public_message, 'code': exc.code}, exc.status_code + elif isinstance(exc, SubmissionIdError): + payload, status = {'error': str(exc), 'code': 'invalid_request'}, 400 + elif isinstance(exc, (ModelCatalogError, ImageReferenceError, PublicChatScopeError)): + payload, status = {'error': exc.public_message, 'code': exc.code}, getattr(exc, 'status_code', 400) + else: + return _plan_edit_error(exc) + log_event('[ORCHESTRATION] Regeneration was refused.', level=logging.WARNING, extra={'code': payload['code']}) + return payload, status + + +def _with_prepared_regeneration(view): + @wraps(view) + def wrapped(*args, **kwargs): + data = request.get_json(silent=True) or {} + question_id = data.get('retry_user_message_id') if isinstance(data, dict) else None + if not question_id: + return view(*args, **kwargs) + question = None + try: + user_id = get_current_user_id() + question, _ = load_owned_retry_message( + cosmos_messages_container, cosmos_conversations_container, user_id, question_id, + ) + body, context = _restore_prepared_regeneration(question, user_id, get_settings()) + question = claim_retry_attempt( + cosmos_messages_container, cosmos_conversations_container, user_id, + question['conversation_id'], question['id'], kind='orchestration', + thread_id=data.get('retry_thread_id'), thread_attempt=data.get('retry_thread_attempt'), + ) + turn_id = question['metadata']['orchestration']['turn_id'] + body.update({'turn_id': turn_id, 'retry_user_message_id': question['id'], 'approval_mode': 'manual'}) + data.clear() + data.update(body) + g.orchestration_regeneration_context = context + result = make_response(view(*args, **kwargs)) + if result.status_code >= 400: + payload = result.get_json(silent=True) or {} + set_retry_attempt_state( + cosmos_messages_container, question['conversation_id'], question['id'], 'failed', + error=payload.get('error') or 'Planning could not start.', expected_states={'planning'}, + ) + return result + except ( + ChatRetryError, ConversationContextError, ElicitationContextError, ModelCatalogError, + ImageReferenceError, PublicChatScopeError, CatalogResolutionError, + PermissionError, ScreeningError, AzureError, + ) as exc: + payload, status = _regeneration_error(exc) + if question: + set_retry_attempt_state( + cosmos_messages_container, question['conversation_id'], question['id'], 'failed', + error=payload['error'], expected_states={'prepared'}, + ) + return jsonify(payload), status + except Exception: + if question: + set_retry_attempt_state( + cosmos_messages_container, question['conversation_id'], question['id'], 'failed', + error='The regenerated plan could not be confirmed.', expected_states={'prepared', 'planning'}, + ) + raise + return wrapped + + +def _regeneration_planning_events(events, context): + question_id = context.get('user_message_id') if context.get('requires_fresh_review') else None + terminal = False + try: + for frame in events: + if not question_id or not frame.startswith('data:'): + yield frame + continue + event = json.loads(frame.partition('data:')[2].strip()) + state = { + 'orchestration_plan': 'awaiting_review', 'orchestration_elicitation': 'awaiting_clarification', + }.get(event.get('type')) + if event.get('error'): + state = 'failed' + if state: + set_retry_attempt_state( + cosmos_messages_container, context['conversation_id'], question_id, state, + error=event.get('error'), run_id=(event.get('plan') or {}).get('run_id'), + ) + terminal = True + thread = context.get('retry_thread_info') or {} + yield serialize_sse({ + **event, 'user_message_id': question_id, + 'retry_thread_id': thread.get('thread_id'), 'retry_thread_attempt': thread.get('thread_attempt'), + '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'}, + ) + + def _plan_replay(): """The saved-plan replay module, imported on first use like the proposal decisions module.""" import functions_workflow_plan_replay @@ -1055,6 +1484,11 @@ 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, ): + if turn_context.get('requires_fresh_review'): + plan['requires_fresh_review'] = True + plan['approval_mode'] = 'manual' + plan['approval'] = {**(plan.get('approval') or {}), 'mode': 'manual', 'state': 'pending'} + plan['status'] = 'awaiting_approval' _validate_turn_memory_context(turn_context, user_id, conversation_id) if has_request_context() and getattr(g, 'orchestration_analysis_result_contexts', None): turn_context['analysis_result_contexts'] = list(g.orchestration_analysis_result_contexts) @@ -1078,6 +1512,15 @@ def _persist_planned_turn( plan, user_id, conversation_id=conversation_id, idempotent=True, turn_context=turn_context, expected_previous_run=expected_previous_run, ) + if turn_context.get('requires_fresh_review'): + question = cosmos_messages_container.read_item(item=message_id, partition_key=conversation_id) + question.setdefault('metadata', {}).setdefault('orchestration', {}).update({ + 'run_id': plan['run_id'], 'turn_id': turn_context['turn_id'], 'requires_fresh_review': True, + }) + patch_chat_message_metadata(cosmos_messages_container, question, fields=('orchestration',)) + set_retry_attempt_state( + cosmos_messages_container, conversation_id, message_id, 'awaiting_review', run_id=plan['run_id'], + ) if submission: prepare_elicitation_outcome(submission, 'plan', plan, turn_context) @@ -1115,7 +1558,7 @@ def _save_turn_message( metadata[CHECK_METADATA] = deepcopy(content_check) if image_references: metadata['image_references'] = deepcopy(image_references) - cosmos_messages_container.upsert_item(stored) + patch_chat_message_metadata(cosmos_messages_container, stored, fields=(CHECK_METADATA, 'image_references')) return message_id, normalized['fingerprint'] # The flat turn id is what ties a reloaded thread back to its run: the live card stamps # the same field on its optimistic bubble, and a message fetched from the server has @@ -1755,10 +2198,75 @@ def orchestration_retry_output(run_id, output_id): 'code': 'output_storage_unavailable', }), 503 + @bp.route("/api/v2/orchestration/messages//regenerate", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def regenerate_orchestration_message(message_id): + settings = get_settings() + if not _orchestration_enabled(settings): + return jsonify({'error': 'Chat orchestration is not enabled.'}), 403 + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + data = request.get_json(silent=True) + if data is None and not request.get_data(): + data = {} + if not isinstance(data, dict): + return jsonify({'error': 'A regeneration request must be an object.'}), 400 + try: + normalize_submission_id(data.get('submission_id')) + clicked, _ = load_owned_retry_message( + cosmos_messages_container, cosmos_conversations_container, user_id, message_id, + ) + attempt = (clicked.get('metadata') or {}).get('response_attempt') or {} + if attempt.get('kind') == 'orchestration' and attempt.get('state') == 'prepared': + if any(data.get(key) for key in (*MODEL_FIELDS, 'model', 'agent_info', 'reasoning_effort')): + raise ChatRetryError( + 'This plan request is already prepared. Plan it before changing selections.', + code='retry_in_progress', + ) + _restore_prepared_regeneration(clicked, user_id, settings) + question = reconcile_prepared_retry( + cosmos_messages_container, cosmos_conversations_container, user_id, clicked, kind='orchestration', + ) + return jsonify(_prepared_regeneration_response(question)) + source, record = _regeneration_source(message_id, user_id) + source_metadata = source.get('metadata') or {} + failed_planning = record.get('user_message_id') != source['id'] + inputs = source_metadata['orchestration_inputs'] if failed_planning else _regeneration_request(record) + answers = source_metadata.get('orchestration_clarification_answers', record.get('answered_questions') or []) + body = _authorize_regeneration_request(source, inputs, user_id, settings, overrides=data) + validate_clarification_answers(answers) + _load_regeneration_snapshot(source, user_id, settings) + source = _link_regeneration_thread(source, record, user_id) + question = prepare_retry_attempt( + cosmos_messages_container, cosmos_conversations_container, user_id, source, body, + submission_id=data.get('submission_id'), kind='orchestration', + extra_metadata={ + 'orchestration_inputs': body, + 'orchestration_clarification_answers': deepcopy(answers), + 'orchestration_regeneration': { + 'source_run_id': record['run_id'], + 'source_input_fingerprint': _regeneration_input_fingerprint(record), + }, + 'prompt_selection': deepcopy(record.get('prompt_selection')), + }, + ) + return jsonify(_prepared_regeneration_response(question)) + except ( + ChatRetryError, SubmissionIdError, ConversationContextError, ElicitationContextError, ModelCatalogError, + ImageReferenceError, PublicChatScopeError, CatalogResolutionError, + PlanRevisionError, PermissionError, ScreeningError, AzureError, + ) as exc: + payload, status = _regeneration_error(exc) + return jsonify(payload), status + @bp.route("/api/v2/orchestration/plan", methods=["POST"]) @swagger_route(security=get_auth_security()) @login_required @user_required + @_with_prepared_regeneration @public_chat_scope_required( lambda user_id, conversation_id: _authorize_context_conversation(conversation_id, user_id), get_current_user_id, get_settings, resolve_public_chat_workspace_ids, log_event, @@ -1816,6 +2324,17 @@ def orchestration_plan(): 'replan_hint': replan_hint, 'planner_contract_version': DEPENDENCY_PLAN_CONTRACT_VERSION, } + if data.get('requires_fresh_review') is True: + turn_context['requires_fresh_review'] = True + approval_mode = 'manual' + turn_context['approval_mode'] = 'manual' + regeneration_context = getattr(g, 'orchestration_regeneration_context', None) + if regeneration_context: + turn_context.update(regeneration_context) + approval_mode = 'manual' + turn_context['approval_mode'] = 'manual' + answered_record = deepcopy(turn_context.get('answered_questions') or []) + turn_context['conversation_id'] = conversation_id shared_details = _shared_turn_details(data) if shared_details: turn_context['collaboration'] = shared_details @@ -1932,6 +2451,8 @@ def orchestration_plan(): approval_mode = turn_context.get('approval_mode', '') if not settings.get('chat_orchestration_allow_user_approval_override', True): approval_mode = '' + if turn_context.get('requires_fresh_review'): + approval_mode = 'manual' replan_hint = turn_context.get('replan_hint', '') revision = pending['question']['revision'] + 1 resolve_elicitation_references( @@ -2029,7 +2550,7 @@ def generate(): observed_pending = None current_revision = revision planning_base = None - if submission: + if submission or turn_context.get('regeneration_of_run_id'): snapshot = _conversation_context_for_run({ **turn_context, 'conversation_id': resolved_conversation_id, }, user_id, settings) @@ -2105,6 +2626,7 @@ def generate(): seeds.get('elicitation_references') or [], user_id, resolved_conversation_id, settings=settings, ) + _save_regeneration_inputs(turn_context, user_id, resolved_conversation_id) action_catalog = resolve_action_catalog( user_id, seeds=seeds, settings=settings, user_groups=seeds.get('active_group_ids') or None, @@ -2218,7 +2740,11 @@ def generate(): 'image_reference_messages': [], } turn_context['seeds'] = seeds - ledger = _load_ledger(resolved_conversation_id, user_id, settings) + context_ids = ( + {item['id'] for item in snapshot['messages']} + if turn_context.get('requires_fresh_review') else None + ) + ledger = _load_ledger(resolved_conversation_id, user_id, settings, message_ids=context_ids) signals = build_conversation_signals( snapshot['messages'], message, truncated=snapshot['truncated'], message_ids=resolution['message_ids'], @@ -2277,10 +2803,10 @@ def generate(): services = _orchestration_services( user_id, resolved_conversation_id, settings=settings, ) - retained = discover_result_aliases( - list_conversation_runs(resolved_conversation_id, user_id, limit=10, strict=True), - services.results, - ) + retained_runs = list_conversation_runs(resolved_conversation_id, user_id, limit=10, strict=True) + if context_ids is not None: + retained_runs = [record for record in retained_runs if record.get('user_message_id') in context_ids] + retained = discover_result_aliases(retained_runs, services.results) if retained['unavailable_count']: yield build_planning_thought( 'Some saved results are no longer accessible and are not offered as inputs.', @@ -2396,7 +2922,7 @@ def generate(): finally: close_planning_resources() - streamed = _sse(stream_with_context(generate())) + streamed = _sse(stream_with_context(_regeneration_planning_events(generate(), turn_context))) streamed.call_on_close(close_planning_resources) return streamed diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index a3f5bd608..4078ac02c 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -12,6 +12,12 @@ from functions_authentication import * from functions_content import * from functions_settings import * +from functions_chat_model_catalog import ( + build_chat_model_catalog as _build_chat_model_catalog, + chat_model_reasoning_metadata as _chat_model_reasoning_metadata, + filter_chat_model_endpoints_by_governance as _filter_chat_model_endpoints_by_governance, + normalize_chat_model_value as _normalize_chat_model_value, +) from functions_model_endpoint_types import resolve_model_endpoint_request_model from functions_agent_catalog import build_accessible_agent_catalog from functions_ai_connections import filter_model_endpoints_by_capability @@ -473,10 +479,6 @@ def _serialize_chat_prompt_option(prompt, *, scope_type, scope_id=None, scope_na } -def _normalize_chat_model_value(value): - return str(value or '').strip() - - def _is_chat_agent_allowed_by_governance(user_id, agent, scope_type): try: if scope_type == 'global': @@ -495,31 +497,6 @@ def _is_chat_agent_allowed_by_governance(user_id, agent, scope_type): return False -def _filter_chat_model_endpoints_by_governance(user_id, endpoints, feature_key): - try: - ensure_governance_access(feature_key, user_id) - except PermissionError: - return [] - - allowed_endpoints = [] - for endpoint in endpoints or []: - if not isinstance(endpoint, dict): - continue - endpoint_id = str(endpoint.get('id') or '').strip() - if endpoint_id: - try: - ensure_governance_access( - feature_key, - user_id, - item_entity_type='global_endpoint', - item_id=endpoint_id, - ) - except PermissionError: - continue - allowed_endpoints.append(endpoint) - return allowed_endpoints - - def _build_initial_chat_model_selection(*, chat_model_options, preferred_model_id=None, preferred_model_deployment=None): scope_order = { 'global': 0, @@ -626,114 +603,6 @@ def sort_key(option): return serialize_option(default_option) -def _chat_model_reasoning_metadata(model): - model_name = next(( - model[field].strip() for field in REASONING_IDENTIFIER_FIELDS - if isinstance(model.get(field), str) and model[field].strip() - ), '') - return { - 'model_name': model_name, - 'reasoning_capabilities': resolve_model_reasoning_policy(model), - } - - -def _build_chat_model_catalog(*, user_id, settings, user_settings_dict, user_groups_raw): - profiles = get_effective_model_profiles(settings) - if not settings.get('enable_multi_model_endpoints', False): - if settings.get('enable_gpt_apim', False): - models = [ - {'deploymentName': name.strip(), 'modelName': name.strip()} - for name in str(settings.get('azure_apim_gpt_deployment') or '').split(',') - if name.strip() - ] - else: - models = (settings.get('gpt_model') or {}).get('selected', []) - catalog = [] - for model in models: - if not isinstance(model, dict): - continue - deployment = _normalize_chat_model_value(model.get('deploymentName')) - if deployment: - reasoning_metadata = _chat_model_reasoning_metadata(model) - catalog.append({ - 'selection_key': deployment, - 'deployment_name': deployment, - 'display_name': reasoning_metadata['model_name'], - **reasoning_metadata, - **model_profile_projection(model, {}, settings, profiles), - }) - return catalog - - catalog = [] - - def append_models(endpoints, scope_type, scope_id=None, scope_name=None): - sanitized_endpoints = sanitize_model_endpoints_for_frontend(endpoints, catalog_settings=settings) - normalized_endpoints, _ = normalize_model_endpoints(sanitized_endpoints) - - for endpoint in filter_model_endpoints_by_capability(normalized_endpoints): - if not endpoint.get('enabled', True): - continue - - endpoint_id = endpoint.get('id') or '' - provider = endpoint.get('provider') or 'aoai' - models = endpoint.get('models') or [] - - for model in models: - if not isinstance(model, dict) or not model.get('enabled', True): - continue - - model_id = model.get('id') or model.get('deploymentName') or model.get('deployment') or model.get('modelName') or model.get('name') or '' - request_model = resolve_model_endpoint_request_model(endpoint, model) - deployment_name = request_model - display_name = model.get('displayName') or model.get('modelName') or request_model or deployment_name or model.get('name') or model_id - selection_key = f"{scope_type}:{scope_id or ''}:{endpoint_id}:{model_id or deployment_name or request_model}" - - catalog.append({ - 'selection_key': selection_key, - 'model_id': model_id, - **_chat_model_reasoning_metadata(model), - 'display_name': display_name, - 'request_model': request_model, - 'deployment_name': deployment_name, - 'endpoint_id': endpoint_id, - 'provider': provider, - 'scope_type': scope_type, - 'scope_id': scope_id, - 'scope_name': scope_name, - 'icon': model.get('icon') if isinstance(model.get('icon'), dict) else {}, - **model_profile_projection(model, endpoint, settings, profiles), - }) - - append_models( - _filter_chat_model_endpoints_by_governance(user_id, settings.get('model_endpoints', []) or [], 'governance_global_endpoints'), - 'global', - None, - 'Global' - ) - - if settings.get('allow_user_custom_endpoints', False): - append_models( - _filter_chat_model_endpoints_by_governance(user_id, user_settings_dict.get('personal_model_endpoints', []) or [], 'governance_user_endpoints'), - 'personal', - user_id, - 'Personal' - ) - - if settings.get('enable_group_workspaces', False) and settings.get('allow_group_custom_endpoints', False): - for group_doc in user_groups_raw: - group_id = group_doc.get('id') - if not group_id: - continue - append_models( - _filter_chat_model_endpoints_by_governance(user_id, get_group_model_endpoints(group_id), 'governance_group_endpoints'), - 'group', - group_id, - group_doc.get('name', 'Unnamed Group') - ) - - return catalog - - def _build_chat_prompt_catalog(*, user_id, settings, user_groups_raw, user_visible_public_workspaces): catalog = [] diff --git a/application/single_app/static/js/chat/chat-retry.js b/application/single_app/static/js/chat/chat-retry.js index 99d3cfdf3..cc0d3d8eb 100644 --- a/application/single_app/static/js/chat/chat-retry.js +++ b/application/single_app/static/js/chat/chat-retry.js @@ -93,7 +93,8 @@ export async function handleRetryButtonClick(messageDiv, messageId, messageType) window.pendingMessageRetry = { messageDiv, messageId, - messageType + messageType, + submissionId: crypto.randomUUID() }; // Populate retry modal with current model options @@ -240,7 +241,7 @@ window.executeMessageRetry = function() { const isAgentMode = retryModeAgent && retryModeAgent.checked; // Prepare retry request body - const requestBody = {}; + const requestBody = { submission_id: pendingRetry.submissionId }; if (isAgentMode) { // Agent mode - get agent info @@ -266,6 +267,16 @@ window.executeMessageRetry = function() { const selectedOption = retryModelSelect ? retryModelSelect.options[retryModelSelect.selectedIndex] : null; const selectedModel = selectedOption?.dataset?.deploymentName || (retryModelSelect ? retryModelSelect.value : null); requestBody.model = selectedModel; + requestBody.model_deployment = selectedModel; + if (selectedOption?.dataset?.endpointId) { + requestBody.model_endpoint_id = selectedOption.dataset.endpointId; + if (selectedOption.dataset.modelId) { + requestBody.model_id = selectedOption.dataset.modelId; + } + if (selectedOption.dataset.provider) { + requestBody.model_provider = selectedOption.dataset.provider; + } + } let reasoningEffort = null; const retryReasoningContainer = document.getElementById('retry-reasoning-container'); diff --git a/application/v2_ui/src/components/chat/MessageActions.tsx b/application/v2_ui/src/components/chat/MessageActions.tsx index bad2ec360..9f4b8aa31 100644 --- a/application/v2_ui/src/components/chat/MessageActions.tsx +++ b/application/v2_ui/src/components/chat/MessageActions.tsx @@ -591,7 +591,7 @@ export function MessageActions({ ) ) : workflowDelivery ? null : ( void retryMessage(message.id)} disabled={streaming} > diff --git a/application/v2_ui/src/components/chat/MessageList.tsx b/application/v2_ui/src/components/chat/MessageList.tsx index f92942f97..fe0ab0e4a 100644 --- a/application/v2_ui/src/components/chat/MessageList.tsx +++ b/application/v2_ui/src/components/chat/MessageList.tsx @@ -2,7 +2,7 @@ import { ReasoningAdjustmentNotice } from './ReasoningAdjustmentNotice'; // Renders the message thread, the in-flight streaming bubble and the reasoning panel. -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { clsx } from 'clsx'; import { BookOpen, @@ -74,6 +74,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 { answerGeneratedImages, extractProposalSpecs, @@ -1321,6 +1322,7 @@ function StreamingBubble() { reconnectPhase, orchestrationSurface, activeConversationId, + retryPresentation, } = useChatStore(); const collaborative = useChatStore((state) => state.activeConversationKind === 'collaborative'); const chatWidth = useUiStore((state) => state.chatWidth); @@ -1341,7 +1343,8 @@ function StreamingBubble() { const connecting = reconnectPhase === 'connecting'; const runShownOnCard = orchestrationSurface === 'running' && runCardShowsProgress; - const activityLabel = orchestrationSurface === 'planning' ? 'Planning' : 'Thinking'; + const activityLabel = retryPresentation && !retryPresentation.admitted ? 'Preparing retry' + : orchestrationSurface === 'planning' ? 'Planning' : 'Thinking'; if (runShownOnCard && !streamingContent) { return null; @@ -1522,6 +1525,27 @@ function ActiveOrchestrationCard({ conversationId }: { conversationId: string }) return null; } +function ResponseError({ message, authUrl }: { message: string; authUrl?: string | null }) { + const safeAuthUrl = normalizeFoundryAuthUrl(authUrl); + return ( + + +
+

{message}

+ {safeAuthUrl && ( + <> + + Sign in or grant Foundry access + +

After signing in, return to this chat and retry your message. It will not retry automatically.

+ + )} +
+
+ ); +} + export function MessageList() { const { messages, @@ -1532,8 +1556,9 @@ export function MessageList() { streamError, streamAuthUrl, activeConversationId, + retryPresentation, } = useChatStore(); - const safeStreamAuthUrl = normalizeFoundryAuthUrl(streamAuthUrl); + const activeTurnId = useOrchestrationStore((state) => selectActiveTurn(state, activeConversationId ?? '')); const appTitle = useBootstrapStore((state) => state.data?.branding?.app_title); const chatWidth = useUiStore((state) => state.chatWidth); @@ -1548,6 +1573,8 @@ export function MessageList() { * nothing needs to re-render when it changes. */ const pinnedRef = useRef(true); + const retryIndex = retryPresentation ? messages.findIndex((message) => message.id === retryPresentation.userMessageId) : -1; + const retryingEarlierTurn = retryIndex >= 0 && messages.some((message, index) => index > retryIndex && message.role === 'user'); const scrollToBottom = useCallback(() => { const element = scrollRef.current; @@ -1561,10 +1588,10 @@ export function MessageList() { // Auto-scroll only while the user is already at the bottom, so reading back through a // long answer is not interrupted by incoming tokens. useEffect(() => { - if (pinnedRef.current) { + if (pinnedRef.current && !retryingEarlierTurn) { scrollToBottom(); } - }, [messages, streamingContent, scrollToBottom]); + }, [messages, streamingContent, scrollToBottom, retryingEarlierTurn]); /** * Follow content that grows after it was laid out. @@ -1586,13 +1613,13 @@ export function MessageList() { return; } const observer = new ResizeObserver(() => { - if (pinnedRef.current) { + if (pinnedRef.current && !retryingEarlierTurn) { scrollToBottom(); } }); observer.observe(content); return () => observer.disconnect(); - }, [scrollToBottom]); + }, [scrollToBottom, retryingEarlierTurn]); const onScroll = () => { const element = scrollRef.current; @@ -1630,7 +1657,10 @@ export function MessageList() { // resolves. An orchestration attempt a later retry replaced is hidden the same way: // the thread shows the latest attempt, and the earlier one stays saved for the record. const supersededRuns = supersededOrchestrationRunIds(messages); - const shown = messages.filter((message) => !isSupersededByWorkflowReply(message) + const shown = messages.filter((message) => !( + retryPresentation && !retryPresentation.admitted && message.id !== retryPresentation.userMessageId + && retryPresentation.hiddenMessageIds.includes(message.id) + ) && !isSupersededByWorkflowReply(message) && !isSupersededOrchestrationAttempt(message, supersededRuns)); const grouped = groupProposalImages(shown); if (grouped.size === 0) { @@ -1657,7 +1687,14 @@ export function MessageList() { ); return { threadMessages: visible, proposalImagesByMessage: grouped }; - }, [messages]); + }, [messages, retryPresentation]); + const streamingAnchor = streaming && retryPresentation?.conversationId === activeConversationId + && threadMessages.some((message) => message.id === retryPresentation.userMessageId) + ? retryPresentation.userMessageId : null; + const planAnchor = activeTurnId + ? threadMessages.find((message) => message.role === 'user' && messageOrchestrationTurn(message) === activeTurnId)?.id + : undefined; + const tailPlan = !planAnchor && !threadMessages.some((message) => message.role === 'user' && messageOrchestrationTurn(message)); return (
- {threadMessages.map((message) => ( - - ))} - {streaming && } - - {activeConversationId && ( + {threadMessages.map((message) => { + const here = streamingAnchor === message.id; + const localRetry = retryPresentation?.userMessageId === message.id ? retryPresentation : null; + const error = message.role === 'user' && !here + ? localRetry?.error || selectedAttemptError(message, threadMessages) : undefined; + return ( + + + {here && } + {here && } + {error &&
} + {!here && savedResponseAttempt(message).state === 'prepared' && ( +

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

+ )} + {activeConversationId && planAnchor === message.id && !(localRetry && !localRetry.admitted) && ( + + )} +
+ ); + })} + {streaming && !streamingAnchor && } + {!streamingAnchor && } + {activeConversationId && tailPlan && ( )} @@ -1715,27 +1765,7 @@ export function MessageList() {
- {streamError && ( - - -
-

{streamError}

- {safeStreamAuthUrl ? ( - <> - - Sign in or grant Foundry access - -

After signing in, return to this chat and retry your message. It will not retry automatically.

- - ) : null} -
-
- )} + {streamError && }
); diff --git a/application/v2_ui/src/components/chat/OrchestrationPlanCard.tsx b/application/v2_ui/src/components/chat/OrchestrationPlanCard.tsx index 997d9e2ae..f64bd4c2f 100644 --- a/application/v2_ui/src/components/chat/OrchestrationPlanCard.tsx +++ b/application/v2_ui/src/components/chat/OrchestrationPlanCard.tsx @@ -328,6 +328,12 @@ export function OrchestrationPlanCard({ + {plan.requires_fresh_review ? ( +

+ Fresh plan: review before running. Earlier completed actions are not undone and may be repeated. +

+ ) : null} + {repairs.length > 0 ? ( // The plan that runs may differ from what the model proposed; saying so is the // point. `validation.repairs` is the honest record of what the server corrected. diff --git a/application/v2_ui/src/lib/chatRetryAttempts.ts b/application/v2_ui/src/lib/chatRetryAttempts.ts new file mode 100644 index 000000000..62b35d147 --- /dev/null +++ b/application/v2_ui/src/lib/chatRetryAttempts.ts @@ -0,0 +1,154 @@ +// chatRetryAttempts.ts +// Attempt identity and logical placement, independent of the streaming transport. + +import type { ChatMessage, Json } from './types'; +import { currentAttempt, messageThreadId } from './threads'; + +export interface RetryPresentation { + id: string; + conversationId: string; + userMessageId: string; + sourceUserMessageId: string; + hiddenMessageIds: string[]; + threadId?: string; + attempt: number; + admitted: boolean; + kind: 'chat' | 'orchestration'; + error?: string; + authUrl?: string | null; +} + +export interface ResponseAttempt { + kind?: string; + state?: string; + error?: string; + run_id?: string; +} + +function isRecord(value: unknown): value is Json { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function savedResponseAttempt(message: ChatMessage | undefined): ResponseAttempt { + const value = message?.metadata?.response_attempt; + if (!isRecord(value)) return {}; + return { + kind: typeof value.kind === 'string' ? value.kind : undefined, + state: typeof value.state === 'string' ? value.state : undefined, + error: typeof value.error === 'string' ? value.error : undefined, + run_id: typeof value.run_id === 'string' ? value.run_id : undefined, + }; +} + +export function messageOrchestrationTurn(message: ChatMessage | undefined): string | undefined { + const orchestration = message?.metadata?.orchestration; + const value = message?.metadata?.orchestration_turn_id ?? (isRecord(orchestration) ? orchestration.turn_id : undefined); + return typeof value === 'string' && value ? value : undefined; +} + +export function retryQuestion(messages: ChatMessage[], message: ChatMessage): ChatMessage | undefined { + if (message.role === 'user') return message; + const thread = messageThreadId(message); + const turn = messageOrchestrationTurn(message); + if (thread || turn) { + return messages.find((candidate) => candidate.role === 'user' && ( + thread + ? messageThreadId(candidate) === thread && currentAttempt(candidate) === currentAttempt(message) + : messageOrchestrationTurn(candidate) === turn + )); + } + const before = messages.slice(0, messages.findIndex((candidate) => candidate.id === message.id)); + return before.reverse().find((candidate) => candidate.role === 'user'); +} + +export function beginRetryPresentation( + messages: ChatMessage[], + question: ChatMessage, + kind: RetryPresentation['kind'], +): RetryPresentation { + const start = messages.findIndex((message) => message.id === question.id); + const next = messages.findIndex((message, index) => index > start && message.role === 'user'); + const thread = messageThreadId(question); + const turn = messageOrchestrationTurn(question); + const hidden = messages.filter((message, index) => ( + message.id === question.id + || (thread && messageThreadId(message) === thread && currentAttempt(message) === currentAttempt(question)) + || (turn && messageOrchestrationTurn(message) === turn) + || (index > start && (next < 0 || index < next)) + )); + return { + id: crypto.randomUUID(), + conversationId: question.conversation_id, userMessageId: question.id, + sourceUserMessageId: question.id, hiddenMessageIds: hidden.map((message) => message.id), + threadId: thread, attempt: currentAttempt(question), admitted: false, kind, + }; +} + +export function adoptRetryQuestion( + messages: ChatMessage[], presentation: RetryPresentation, question: ChatMessage, +): ChatMessage[] { + const index = messages.findIndex((message) => message.id === presentation.userMessageId); + const before = messages.slice(0, index < 0 ? messages.length : index); + const after = index < 0 ? [] : messages.slice(index); + const retained = (rows: ChatMessage[]) => rows.filter((message) => + !presentation.hiddenMessageIds.includes(message.id) && message.id !== question.id); + return [...retained(before), question, ...retained(after)]; +} + +export function insertRetryReply( + messages: ChatMessage[], reply: ChatMessage, presentation: RetryPresentation | null, +): ChatMessage[] { + const withoutReply = messages.filter((message) => message.id !== reply.id); + const question = presentation ? undefined : withoutReply.find((message) => + message.role === 'user' && messageThreadId(reply) && messageThreadId(message) === messageThreadId(reply) + && currentAttempt(message) === currentAttempt(reply) + && (savedResponseAttempt(message).kind || message.metadata?.retried || message.metadata?.edited)); + const questionId = presentation?.userMessageId ?? question?.id; + const index = withoutReply.findIndex((message) => message.id === questionId); + if (index < 0) return [...withoutReply, reply]; + return [...withoutReply.slice(0, index + 1), reply, ...withoutReply.slice(index + 1)]; +} + +export function updateRetryState( + messages: ChatMessage[], presentation: RetryPresentation | null, state: string, error?: string, +): ChatMessage[] { + if (!presentation?.admitted) return messages; + return messages.map((message) => message.id === presentation.userMessageId ? { + ...message, + metadata: { + ...message.metadata, + response_attempt: { + ...(isRecord(message.metadata?.response_attempt) ? message.metadata.response_attempt : {}), + state, ...(error ? { error } : {}), + }, + }, + } : message); +} + +export function retryReplyMetadata(metadata: Json, presentation: RetryPresentation | null): Json { + if (!presentation?.admitted || metadata.thread_info) return metadata; + return { + ...metadata, + thread_info: { + thread_id: presentation.threadId, thread_attempt: presentation.attempt, active_thread: true, + }, + }; +} + +export function selectedAttemptError(question: ChatMessage, messages: ChatMessage[]): string | undefined { + const attempt = savedResponseAttempt(question); + if (!['failed', 'interrupted'].includes(attempt.state ?? '')) return undefined; + const thread = messageThreadId(question); + const safetyNotice = thread && messages.some((message) => + message.role === 'safety' && messageThreadId(message) === thread + && currentAttempt(message) === currentAttempt(question)); + if (safetyNotice) return undefined; + const recovered = thread && messages.some((message) => + message.role === 'assistant' && messageThreadId(message) === thread + && currentAttempt(message) === currentAttempt(question) + && isRecord(message.metadata?.orchestration) && message.metadata.orchestration.status === 'completed'); + return recovered ? undefined : attempt.error || ( + attempt.state === 'interrupted' ? 'This response was interrupted. You can retry this question.' + : 'This attempt could not complete. You can retry this question.' + ); +} diff --git a/application/v2_ui/src/lib/endpoints.ts b/application/v2_ui/src/lib/endpoints.ts index e29571728..452afce79 100644 --- a/application/v2_ui/src/lib/endpoints.ts +++ b/application/v2_ui/src/lib/endpoints.ts @@ -6,6 +6,8 @@ // Keeping them in one module means a backend path change is a one-line edit here. import { api, apiUrl, request, uploadFile, ApiError, API_BASE, CREDENTIALS_MODE } from './apiClient'; +import type { SelectionFields } from './chatRequestSelection'; +import type { OrchestrationPlanRequest } from './orchestration'; import { buildDocumentListParams } from './documentExplorer'; import { artifactDownloadPath, artifactFileName } from './generatedArtifacts'; import { ANALYSIS_PAGE_SIZE, analysisResultContext, validateAnalysisPage } from './savedAnalysis'; @@ -292,19 +294,48 @@ export const deleteMessage = (messageId: string, deleteThread = false) => */ export interface AttemptChatRequest { success: boolean; + thread_id?: string; new_attempt?: number; user_message_id?: string; + user_message?: ChatMessage; + attempt_state?: string; + available_attempts?: number[]; chat_request: ChatStreamRequest & { conversation_id: string }; } +export interface AttemptOrchestrationRequest { + success: boolean; + thread_id: string; + new_attempt: number; + user_message_id: string; + user_message: ChatMessage; + attempt_state: string; + available_attempts: number[]; + turn_id: string; + requires_fresh_review: true; + plan_request: OrchestrationPlanRequest; +} + +export const regenerateOrchestrationMessage = ( + messageId: string, + options: SelectionFields & { submission_id?: string } = {}, +) => api.post(`/api/v2/orchestration/messages/${encodeURIComponent(messageId)}/regenerate`, { + ...options, + submission_id: options.submission_id || crypto.randomUUID(), +}); + export const retryMessage = ( messageId: string, - options: { model?: string; reasoning_effort?: string; agent_info?: unknown } = {}, -) => api.post(`/api/message/${encodeURIComponent(messageId)}/retry`, options); + options: SelectionFields & { model?: string; submission_id?: string } = {}, +) => api.post(`/api/message/${encodeURIComponent(messageId)}/retry`, { + ...options, + submission_id: options.submission_id || crypto.randomUUID(), +}); -export const editMessage = (messageId: string, content: string) => +export const editMessage = (messageId: string, content: string, submissionId = crypto.randomUUID()) => api.post(`/api/message/${encodeURIComponent(messageId)}/edit`, { content, + submission_id: submissionId, }); export const switchAttempt = (messageId: string, direction: 'prev' | 'next') => diff --git a/application/v2_ui/src/lib/orchestration.ts b/application/v2_ui/src/lib/orchestration.ts index 810c6763d..bf849e075 100644 --- a/application/v2_ui/src/lib/orchestration.ts +++ b/application/v2_ui/src/lib/orchestration.ts @@ -547,6 +547,7 @@ export interface OrchestrationPlanner { */ export interface OrchestrationPlan { reasoning_adjustments?: ReasoningResolution[]; + requires_fresh_review?: boolean; plan_id: string; run_id: string; /** Conditional approval token for a manually held or revised plan. */ @@ -805,6 +806,7 @@ export interface OrchestrationRunRequest { conversation_id?: string | null; edits?: PlanEdits; expected_version?: string; + reviewed_regeneration?: boolean; [key: string]: unknown; } diff --git a/application/v2_ui/src/lib/orchestrationController.ts b/application/v2_ui/src/lib/orchestrationController.ts index f1ba41836..c2259562d 100644 --- a/application/v2_ui/src/lib/orchestrationController.ts +++ b/application/v2_ui/src/lib/orchestrationController.ts @@ -14,7 +14,7 @@ // component coming and going exactly as `chatStore`'s own stream controller does — and for the // same reason. -import { createConversation } from './endpoints'; +import { createConversation, type AttemptOrchestrationRequest } from './endpoints'; import { ApiError } from './apiClient'; import { legacyPlanErrorMessage } from './orchestrationErrors'; import { @@ -144,6 +144,7 @@ interface TurnContext { seeds: OrchestrationSeeds; revision: number; pendingUserMessageId: string; + requiresFreshReview?: boolean; } const turnContexts = new Map(); @@ -378,12 +379,16 @@ async function dispatchPlan( turn_id: currentTurnId, revision: context.revision, approval_mode: context.approvalMode, + ...(context.requiresFreshReview ? { requires_fresh_review: true } : {}), }; const timeZone = requestTimeZone(); - if (timeZone) { + if (timeZone && !body.time_zone) { body.time_zone = timeZone; } if (continuation) { + delete body.retry_user_message_id; + delete body.retry_thread_id; + delete body.retry_thread_attempt; body.elicitation = continuation.elicitation; body.elicitation_response = continuation.response; body.elicitation_id = continuation.elicitationId; @@ -412,7 +417,7 @@ async function dispatchPlan( currentConversationId, currentTurnId, event.reasoning_adjustments, ); useChatStore.getState() - .pushOrchestrationThought(currentConversationId, event as RunStreamEvent); + .pushOrchestrationThought(currentConversationId, { ...event, turn_id: currentTurnId } as RunStreamEvent); } }, onConversationMetadata: (event) => { @@ -428,12 +433,14 @@ async function dispatchPlan( if (!isCurrentRequest()) { return; } - const normalized = normalizePlan(plan); + const normalized = normalizePlan(context.requiresFreshReview + ? { ...plan, requires_fresh_review: true } : plan); if (!normalized?.plan_id || !normalized.run_id || !normalized.turn_id || normalized.conversation_id !== currentConversationId || !isPlanRunnable(normalized)) { errored = true; failure = 'The planner returned an invalid plan. Please try again.'; useChatStore.getState().settleOrchestrationTurn(currentConversationId, { + turnId: currentTurnId, status: 'failed', error: failure, }); @@ -475,6 +482,7 @@ async function dispatchPlan( useChatStore .getState() .settleOrchestrationTurn(currentConversationId, { + turnId: currentTurnId, status: 'failed', error: message, }); @@ -502,6 +510,8 @@ async function dispatchPlan( const autoRun = settledPlan !== null && settledPlan.approval.mode === 'auto' && + !context.requiresFreshReview && + !settledPlan.requires_fresh_review && !planHasApprovalFloor(settledPlan) && !selectHasPlanHold(useOrchestrationStore.getState(), currentConversationId, currentTurnId) && isPlanApproved(settledPlan) && @@ -513,7 +523,12 @@ async function dispatchPlan( } else { useChatStore .getState() - .settleOrchestrationTurn(currentConversationId, { status: 'planned' }); + .settleOrchestrationTurn(currentConversationId, { + turnId: currentTurnId, + status: 'planned', + attemptState: selectElicitation(useOrchestrationStore.getState(), currentConversationId, currentTurnId) + ? 'awaiting_clarification' : 'awaiting_review', + }); } return { ok: true }; } else if (!errored && controller.signal.aborted) { @@ -521,7 +536,7 @@ async function dispatchPlan( // rather than leaving an empty plan slot the drawer would puzzle over. useChatStore .getState() - .settleOrchestrationTurn(currentConversationId, { status: 'cancelled', accumulated: '' }); + .settleOrchestrationTurn(currentConversationId, { turnId: currentTurnId, status: 'cancelled', accumulated: '' }); if (!continuation) { useOrchestrationStore.getState().clearActiveTurn(currentConversationId); } @@ -530,6 +545,7 @@ async function dispatchPlan( failure ||= 'The planner did not return a plan or a question. Please try again.'; if (!errored) { useChatStore.getState().settleOrchestrationTurn(currentConversationId, { + turnId: currentTurnId, status: 'failed', error: failure, }); @@ -567,6 +583,23 @@ export async function startOrchestrationPlan(params: StartPlanParams): Promise { + const { message, ...seeds } = prepared.plan_request; + return dispatchPlan( + prepared.user_message.conversation_id, + prepared.turn_id, + { + message, + seeds, + approvalMode: 'manual', + revision: 0, + pendingUserMessageId: prepared.user_message_id, + requiresFreshReview: true, + }, + false, + ); +} + /** Retry only an unsaved planning turn whose original request is still held in this tab. */ export async function retryOrchestrationPlanning( conversationId: string, @@ -595,7 +628,7 @@ export async function retryOrchestrationPlanning( if (selectElicitation(store, conversationId, turnId)) { return { ok: false, error: 'This request has a planner question. Continue from that question so your answers are preserved.' }; } - return dispatchPlan(conversationId, turnId, { ...context }, false); + return dispatchPlan(conversationId, turnId, { ...context, approvalMode: 'manual', requiresFreshReview: true }, false); } /** A deleted conversation must not regain a plan from a late response or retained Retry context. */ @@ -729,14 +762,18 @@ export async function approveAndRunPlan(params: { // Nothing automatic -- a countdown or auto-run -- may start a plan that starts a saved // workflow; only the user's own click does. if (!plan || selectPlanRunBlocked(store, conversationId, turnId) - || (params.automatic && (selectHasPlanHold(store, conversationId, turnId) || planHasApprovalFloor(plan)))) { + || (params.automatic && (plan.requires_fresh_review + || turnContexts.get(scopeKey(conversationId, turnId))?.requiresFreshReview + || selectHasPlanHold(store, conversationId, turnId) || planHasApprovalFloor(plan)))) { return; } const edits = selectEdits(store, conversationId, turnId); if (!isPlanRunnable(applyPlanEdits(plan, edits))) { return; } - await executeSavedPlan(conversationId, turnId, plan, edits); + await executeSavedPlan(conversationId, turnId, plan, edits, !params.automatic && ( + plan.requires_fresh_review === true || turnContexts.get(scopeKey(conversationId, turnId))?.requiresFreshReview === true + )); } async function executeSavedPlan( @@ -744,6 +781,7 @@ async function executeSavedPlan( turnId: string, plan: OrchestrationPlan, edits?: PlanEdits, + reviewedRegeneration = false, ): Promise { const store = useOrchestrationStore.getState(); @@ -767,7 +805,7 @@ async function executeSavedPlan( // Enter the streaming state for the answer without a second user bubble — the question is // already in the thread from planning. Taken as the run phase: the plan card shows a run's // progress, so the streaming bubble stays out of the way until there is an answer to show. - useChatStore.getState().beginOrchestrationTurn(conversationId, '', false, undefined, undefined, 'running'); + useChatStore.getState().beginOrchestrationTurn(conversationId, '', false, turnId, undefined, 'running'); const controller = new AbortController(); activeControllers.get(conversationId)?.abort(); @@ -781,6 +819,7 @@ async function executeSavedPlan( ...(edits ? { edits } : {}), // A retry uses its returned child plan version, not the source recovery token. ...(plan.edit_version ? { expected_version: plan.edit_version } : {}), + ...(reviewedRegeneration ? { reviewed_regeneration: true } : {}), }; let settled = false; @@ -798,7 +837,7 @@ async function executeSavedPlan( } // Release only the browser's streaming surface. Keep the attempt in flight, without // manufacturing an assistant answer or a new execution for the durable computation. - useChatStore.getState().settleOrchestrationTurn(conversationId, { status: 'planned' }); + useChatStore.getState().settleOrchestrationTurn(conversationId, { turnId, status: 'planned', attemptState: 'waiting' }); }; const result = await runOrchestration( runBody, @@ -818,10 +857,10 @@ async function executeSavedPlan( ); useChatStore .getState() - .pushOrchestrationThought(conversationId, event as RunStreamEvent); + .pushOrchestrationThought(conversationId, { ...event, turn_id: turnId } as RunStreamEvent); }, onContent: (_delta, accumulated) => - useChatStore.getState().pushOrchestrationContent(conversationId, accumulated), + useChatStore.getState().pushOrchestrationContent(conversationId, accumulated, turnId), onWaiting: acknowledgeWaiting, onDone: (event, accumulated) => { settled = true; @@ -841,6 +880,7 @@ async function executeSavedPlan( conversationId, turnId, event.reasoning_adjustments ?? event.metadata?.reasoning_adjustments, ); useChatStore.getState().settleOrchestrationTurn(conversationId, { + turnId, status, event: terminal, accumulated, @@ -867,7 +907,7 @@ async function executeSavedPlan( }); useChatStore .getState() - .settleOrchestrationTurn(conversationId, { status: 'cancelled', event: terminal, accumulated }); + .settleOrchestrationTurn(conversationId, { turnId, status: 'cancelled', event: terminal, accumulated }); useOrchestrationStore.getState().endRun(runId, 'cancelled'); }, // Older error-only frames do not prove that finalization reached storage. @@ -880,7 +920,7 @@ async function executeSavedPlan( current.updatePlanEditor(conversationId, turnId, (editor) => ({ ...editor, blocked: true, error: message, })); - useChatStore.getState().settleOrchestrationTurn(conversationId, { status: 'planned' }); + useChatStore.getState().settleOrchestrationTurn(conversationId, { turnId, status: 'planned' }); }, // The plan was already run somewhere else. // @@ -919,11 +959,11 @@ async function executeSavedPlan( if (legacyMessage) { // The server will never run this plan, so it is put away and the thread says why. current.clearPlan(conversationId, turnId); - current.clearActiveTurn(conversationId); - useChatStore.getState().settleOrchestrationTurn(conversationId, { status: 'failed', error: legacyMessage }); + if (current.activeTurns[conversationId] === turnId) current.clearActiveTurn(conversationId); + useChatStore.getState().settleOrchestrationTurn(conversationId, { turnId, status: 'failed', error: legacyMessage }); return; } - useChatStore.getState().settleOrchestrationTurn(conversationId, { status: 'planned' }); + useChatStore.getState().settleOrchestrationTurn(conversationId, { turnId, status: 'planned' }); return; } if (result.waiting) { @@ -937,6 +977,7 @@ async function executeSavedPlan( }); if (!activeControllers.has(conversationId)) { useChatStore.getState().settleOrchestrationTurn(conversationId, { + turnId, status: 'unknown', event: { run_id: runId, turn_id: turnId }, accumulated: result.accumulated, @@ -1052,9 +1093,10 @@ export async function reconcileOrchestrationRun(conversationId: string, runId: s .some((run) => run.conversationId === conversationId && run.runId !== runId); const activeTurn = current.activeTurns[conversationId]; if (chat.activeConversationId === conversationId && !otherRun - && (!chat.streaming || !activeTurn || activeTurn === record.turn_id)) { + && (!chat.streaming || (chat.orchestrationSurface && (!activeTurn || activeTurn === record.turn_id)))) { const partial = chat.messages.find((message) => message.id === `orchestration-status-${runId}`)?.content ?? ''; chat.settleOrchestrationTurn(conversationId, { + turnId: record.turn_id ?? tracked?.turnId, status, event, accumulated: record.assistant_message_id ? '' : partial, }); // Saved messages own final content, citations and artifact metadata. @@ -1308,18 +1350,23 @@ export function hasActiveOrchestration(conversationId: string): boolean { * history is left untouched — a run that already finished stays in the timeline. */ export function dismissOrchestrationTurn(conversationId: string, turnId: string): void { - cancelOrchestration(conversationId); + const store = useOrchestrationStore.getState(); + if (store.activeTurns[conversationId] === turnId) { + void cancelOrchestration(conversationId); + } else { + const runId = selectPlan(store, conversationId, turnId)?.run_id; + if (runId) void cancelOrchestration(conversationId, runId); + } const key = scopeKey(conversationId, turnId); turnContexts.delete(key); editorControllers.get(key)?.abort(); editorControllers.delete(key); - const store = useOrchestrationStore.getState(); store.clearPlan(conversationId, turnId); store.clearElicitation(conversationId, turnId); - store.clearActiveTurn(conversationId); + if (store.activeTurns[conversationId] === turnId) store.clearActiveTurn(conversationId); useChatStore .getState() - .settleOrchestrationTurn(conversationId, { status: 'cancelled', accumulated: '' }); + .settleOrchestrationTurn(conversationId, { turnId, status: 'cancelled', accumulated: '' }); } function editorRequestFailure(error: unknown): { message: string; info: OrchestrationRequestError } { diff --git a/application/v2_ui/src/lib/orchestrationPlan.ts b/application/v2_ui/src/lib/orchestrationPlan.ts index 6e4f197a6..dccd6fc8a 100644 --- a/application/v2_ui/src/lib/orchestrationPlan.ts +++ b/application/v2_ui/src/lib/orchestrationPlan.ts @@ -531,6 +531,7 @@ export function normalizePlan(raw: unknown): OrchestrationPlan | null { return { plan_id: asString(source.plan_id), reasoning_adjustments: normalizeReasoningAdjustments(source.reasoning_adjustments), + ...(source.requires_fresh_review === true ? { requires_fresh_review: true } : {}), run_id: asString(source.run_id), edit_version: typeof source.edit_version === 'string' ? source.edit_version : undefined, turn_id: asString(source.turn_id), @@ -547,7 +548,8 @@ export function normalizePlan(raw: unknown): OrchestrationPlan | null { ...(normalizeDeliverables(source.deliverables) ? { deliverables: normalizeDeliverables(source.deliverables) } : {}), ...(normalizePlanner(source.planner) ? { planner: normalizePlanner(source.planner) } : {}), ...(source.model_routing === 'auto' ? { model_routing: 'auto' as const } : {}), - approval: normalizeApproval(source.approval), + approval: source.requires_fresh_review === true + ? { ...normalizeApproval(source.approval), mode: 'manual' } : normalizeApproval(source.approval), validation: normalizeValidation(source.validation), status: oneOf(source.status, PLAN_STATUSES, 'awaiting_approval'), }; diff --git a/application/v2_ui/src/stores/chatStore.ts b/application/v2_ui/src/stores/chatStore.ts index 01d0f5274..45436fa82 100644 --- a/application/v2_ui/src/stores/chatStore.ts +++ b/application/v2_ui/src/stores/chatStore.ts @@ -20,6 +20,7 @@ import { markConversationRead, maskMessage as maskMessageApi, renameConversation as renameConversationApi, + regenerateOrchestrationMessage as regenerateOrchestrationMessageApi, retryMessage as retryMessageApi, setMessageBlockRevision as setMessageBlockRevisionApi, setMessageImageRevision as setMessageImageRevisionApi, @@ -34,6 +35,7 @@ import { type ImageRevisionOrigin, type ImageRevisionOperation, type MessageBlockRevisions, + type AttemptChatRequest, } from '../lib/endpoints'; import { cancelStream, @@ -126,7 +128,11 @@ import { contextTags, type ContextItem, } from '../lib/chatContext'; -import { messageThreadId } from '../lib/threads'; +import { currentAttempt, messageThreadId } from '../lib/threads'; +import { + adoptRetryQuestion, beginRetryPresentation, insertRetryReply, messageOrchestrationTurn, + retryQuestion, retryReplyMetadata, savedResponseAttempt, updateRetryState, type RetryPresentation, +} from '../lib/chatRetryAttempts'; import { withoutDeletedMessages } from '../lib/deletedMessages'; import { proposalSourceMessageId, type ImageProposalSpec } from '../lib/imageProposalSpec'; import { toast } from './toastStore'; @@ -134,7 +140,7 @@ import { ApiError } from '../lib/apiClient'; import { normalizeOrchestrationAttempt } from '../lib/orchestration'; import { forgetOrchestrationConversation, - openOrchestrationRecovery, + planRegeneratedMessage, retryOrchestrationPlanning, } from '../lib/orchestrationController'; import { foundryAuthUrl } from '../lib/foundryAuth'; @@ -188,7 +194,7 @@ export type DrawerMode = 'contents' | 'documents' | 'plan' | null; * streaming state. `completed` carries the chat-shaped terminal frame (`RunStreamEvent` extends * `ChatStreamEvent`) so the final message is built exactly as a chat completion's is. */ -export type OrchestrationTurnOutcome = +export type OrchestrationTurnOutcome = { turnId?: string } & ( | { status: 'completed' | 'failed' | 'cancelled' | 'unknown'; event: RunStreamEvent; @@ -199,7 +205,8 @@ export type OrchestrationTurnOutcome = | { status: 'cancelled'; accumulated: string } | { status: 'failed'; error: string } /** Planning produced a plan or a question: leave the thinking state without adding a message. */ - | { status: 'planned' }; + | { status: 'planned'; attemptState?: 'awaiting_review' | 'awaiting_clarification' | 'waiting' } +); /** * Which API family the open conversation belongs to. @@ -358,6 +365,7 @@ interface ChatState { thoughts: ThoughtEntry[]; streamError: string | null; streamAuthUrl: string | null; + retryPresentation: RetryPresentation | null; /** * Where a stream recovery has got to, or null when nothing is being recovered. * @@ -495,7 +503,7 @@ interface ChatState { phase?: OrchestrationSurfacePhase, ) => string; pushOrchestrationThought: (conversationId: string, event: RunStreamEvent) => void; - pushOrchestrationContent: (conversationId: string, accumulated: string) => void; + pushOrchestrationContent: (conversationId: string, accumulated: string, turnId?: string) => void; settleOrchestrationTurn: ( conversationId: string, outcome: OrchestrationTurnOutcome, @@ -918,7 +926,7 @@ let streamingConversationKind: ConversationKind = 'personal'; * conversation whose turn is still running in this tab can put it back. The phase comes back with * it, so a reopened run still shows its progress on the plan card alone. */ -const orchestrationSurfaces = new Map(); +const orchestrationSurfaces = new Map(); /** * Bumped each time the reader starts a new chat or opens a conversation. @@ -1314,6 +1322,11 @@ function buildStreamHandlers( // message is persisted. let streamUserMessageId = String(pendingUserMessageId ?? '').trim() || latestUserMessageId(getState().messages); + const streamRetry = () => { + const presentation = getState().retryPresentation; + return presentation?.conversationId === conversationId && presentation.userMessageId === streamUserMessageId + ? presentation : null; + }; return { onM365PendingActions: (event) => { // The viewed conversation's id rather than the frame's own: a shared conversation's @@ -1415,7 +1428,7 @@ function buildStreamHandlers( model_deployment_name: event.model_deployment_name, agent_display_name: event.agent_display_name, augmented: event.augmented, - metadata: completionMetadata(event), + metadata: retryReplyMetadata(completionMetadata(event) ?? {}, streamRetry()), // Carried onto the finished message so its Microsoft 365 citation chips resolve // without reading the thread again. ...(m365Citations.length > 0 && !event.blocked ? { m365_citations: m365Citations } : {}), @@ -1430,10 +1443,9 @@ function buildStreamHandlers( // assistant message also arrives as a `collaboration.message.created` event, // and whichever of the two lands second must update the message rather than // add a second copy of it. - messages: mergeCollaborationMessage( - state.messages, - finalMessage as CollaborationMessage, - ), + messages: kind === 'personal' + ? insertRetryReply(updateRetryState(state.messages, streamRetry(), event.blocked ? 'failed' : 'completed'), finalMessage, streamRetry()) + : mergeCollaborationMessage(state.messages, finalMessage as CollaborationMessage), streaming: false, streamingContent: '', streamingReasoningAdjustments: [], @@ -1464,19 +1476,19 @@ function buildStreamHandlers( const finalContent = resolveStreamContent(_event, accumulated); if (finalContent) { set((state) => ({ - messages: [ - ...state.messages, + messages: insertRetryReply( + updateRetryState(state.messages, streamRetry(), 'interrupted'), { id: `cancelled-${Date.now()}`, conversation_id: conversationId, role: 'assistant', content: finalContent, timestamp: new Date().toISOString(), - metadata: completionMetadata(_event), + metadata: retryReplyMetadata(completionMetadata(_event) ?? {}, streamRetry()), thoughts: state.thoughts.length > 0 ? [...state.thoughts] : undefined, - }, - ], + }, streamRetry(), + ), })); } set({ streaming: false, streamingContent: '', streamingReasoningAdjustments: [], reconnectPhase: null }); @@ -1489,14 +1501,19 @@ function buildStreamHandlers( // the server's fixed wording, and its chip removed: the turn is never answered // some other way instead. const refusal = workflowResultRefusal(event); - set({ + const failure = refusal?.message ?? message; + const presentation = streamRetry(); + set((state) => ({ streaming: false, streamingContent: '', streamingReasoningAdjustments: [], reconnectPhase: null, - streamError: refusal?.message ?? message, - streamAuthUrl: foundryAuthUrl(event), - }); + ...(presentation?.admitted ? { + messages: updateRetryState(state.messages, presentation, event ? 'failed' : 'unknown', failure), + retryPresentation: { ...presentation, error: failure, authUrl: foundryAuthUrl(event) }, + streamError: null, streamAuthUrl: null, + } : { streamError: failure, streamAuthUrl: foundryAuthUrl(event) }), + })); const selectedResult = getState().workflowResultContext; if (refusal?.clears && selectedResult && (selectedResult.conversation_id === conversationId || selectedResult.conversation_id === null)) { @@ -1661,6 +1678,14 @@ async function resumeChatStream(conversationId: string): Promise { return false; } + const runningQuestions = getState().messages.filter((message) => message.role === 'user' + && savedResponseAttempt(message).kind === 'chat' && savedResponseAttempt(message).state === 'running'); + const savedQuestionId = typeof status.user_message_id === 'string' ? status.user_message_id : undefined; + const runningQuestion = savedQuestionId + ? getState().messages.find((message) => message.id === savedQuestionId && message.role === 'user') + : runningQuestions.length === 1 ? runningQuestions[0] : undefined; + const presentation = runningQuestion && savedResponseAttempt(runningQuestion).kind === 'chat' + ? { ...beginRetryPresentation(getState().messages, runningQuestion, 'chat'), admitted: true } : null; const controller = new AbortController(); activeStreamController = controller; // Deliberately NOT setting streamingConversationId. That marks a stream this tab @@ -1674,6 +1699,7 @@ async function resumeChatStream(conversationId: string): Promise { set({ streaming: true, + retryPresentation: presentation, orchestrationSurface: null, streamingContent: '', thoughts: [], @@ -1683,7 +1709,7 @@ async function resumeChatStream(conversationId: string): Promise { reconnectPhase: 'connecting', }); - const reattachedUserMessageId = latestUserMessageId(getState().messages); + const reattachedUserMessageId = runningQuestion?.id ?? latestUserMessageId(getState().messages); if (reattachedUserMessageId) { chatPendingActionsStore .getState() @@ -1692,7 +1718,7 @@ async function resumeChatStream(conversationId: string): Promise { await reattachChatStream( conversationId, - buildStreamHandlers(conversationId, isCurrent, set, getState), + buildStreamHandlers(conversationId, isCurrent, set, getState, reattachedUserMessageId), controller.signal, ); @@ -1706,6 +1732,31 @@ async function resumeChatStream(conversationId: string): Promise { return true; } +function adoptPreparedRetry(result: Pick, presentation: RetryPresentation): RetryPresentation { + const question = result.user_message; + const threadId = result.thread_id; + const attempt = result.new_attempt; + if (!question || question.id !== result.user_message_id || question.role !== 'user' + || question.conversation_id !== presentation.conversationId || !threadId || typeof attempt !== 'number' + || messageThreadId(question) !== threadId || currentAttempt(question) !== attempt) { + throw new Error('The saved retry could not be confirmed. Reload the conversation before retrying.'); + } + const admitted: RetryPresentation = { + ...presentation, admitted: true, userMessageId: question.id, threadId, attempt, + }; + useChatStore.setState((state) => ({ + messages: adoptRetryQuestion(state.messages, presentation, question), + retryPresentation: admitted, + attemptsByThread: Array.isArray(result.available_attempts) ? { + ...state.attemptsByThread, [threadId]: result.available_attempts, + } : state.attemptsByThread, + metadata: null, + })); + return admitted; +} + /** * Work out which API family a conversation belongs to by asking. * @@ -2221,6 +2272,7 @@ export const useChatStore = create((set, get) => ({ streamAuthUrl: null, reconnectPhase: null, orchestrationSurface: null, + retryPresentation: null, drawerMode: null, metadata: null, @@ -2331,13 +2383,14 @@ export const useChatStore = create((set, get) => ({ // thoughts and its settle land here as usual. A chat stream is picked back up by // `resumeChatStream` below. streaming: conversationId ? orchestrationSurfaces.has(conversationId) : false, - orchestrationSurface: conversationId ? orchestrationSurfaces.get(conversationId) ?? null : null, + orchestrationSurface: conversationId ? orchestrationSurfaces.get(conversationId)?.phase ?? null : null, streamingContent: '', thoughts: [], streamingReasoningAdjustments: [], streamError: null, streamAuthUrl: null, reconnectPhase: null, + retryPresentation: null, // Metadata belongs to the previous conversation; drop it so the drawer never // shows another thread's documents. metadata: null, @@ -2528,6 +2581,7 @@ export const useChatStore = create((set, get) => ({ // when someone clicks New Chat and navigates away. set({ activeConversationId: null, + retryPresentation: null, publicWorkspaceSelection: null, analysisResultContext: null, workflowResultContext: null, @@ -3121,6 +3175,7 @@ export const useChatStore = create((set, get) => ({ set((state) => ({ messages: [...state.messages, optimisticUserMessage], streaming: willStream, + retryPresentation: null, streamingContent: '', thoughts: [], streamingReasoningAdjustments: [], @@ -3386,6 +3441,9 @@ export const useChatStore = create((set, get) => ({ stopStreaming: () => { if (!activeStreamController) { + if (get().retryPresentation && !get().retryPresentation?.admitted) { + set({ streaming: false, retryPresentation: null, streamingContent: '', thoughts: [] }); + } return; } // The one place a cancel belongs: the reader asked for the answer to stop being @@ -3393,6 +3451,22 @@ export const useChatStore = create((set, get) => ({ // actually belongs to, which may no longer be the one on screen, through whichever // cancel route that conversation uses. const stoppedConversationId = streamingConversationId; + const presentation = get().retryPresentation; + if (presentation?.admitted && presentation.conversationId === stoppedConversationId) { + const partial = get().streamingContent; + set((state) => { + const messages = updateRetryState(state.messages, presentation, 'interrupted'); + return { + messages: partial ? insertRetryReply(messages, { + id: `cancelled-${Date.now()}`, conversation_id: presentation.conversationId, + role: 'assistant', content: partial, timestamp: new Date().toISOString(), + metadata: retryReplyMetadata({}, presentation), + thoughts: state.thoughts.length ? [...state.thoughts] : undefined, + }, presentation) : messages, + streaming: false, streamingContent: '', retryPresentation: { ...presentation, error: undefined }, + }; + }); + } if (stoppedConversationId) { // The reply is detached below, so its ending is never seen here. Read the list // once the server has the request instead. An action the reply was still saving @@ -3434,12 +3508,21 @@ export const useChatStore = create((set, get) => ({ const pendingUserMessageId = addUserMessage ? `pending-user-${Date.now()}` : ''; // Recorded whether or not the conversation is on screen, so opening it while this turn // is still in flight shows it working. - orchestrationSurfaces.set(conversationId, phase); + orchestrationSurfaces.set(conversationId, { phase, turnId: turnId ?? null }); // Guarded on the open conversation, exactly like sendMessage's optimistic write: a run // started here keeps going after the reader opens another thread, and its question must // not appear inside that other thread. if (get().activeConversationId === conversationId) { + const question = !addUserMessage && turnId + ? get().messages.find((message) => message.role === 'user' && messageOrchestrationTurn(message) === turnId) + : undefined; + const existingRetry = get().retryPresentation; + const retryPresentation = question && savedResponseAttempt(question).kind === 'orchestration' + ? existingRetry?.userMessageId === question.id ? existingRetry + : { ...beginRetryPresentation(get().messages, question, 'orchestration'), admitted: true } + : null; set((state) => ({ + retryPresentation, messages: addUserMessage ? [ ...state.messages, @@ -3478,7 +3561,9 @@ export const useChatStore = create((set, get) => ({ }, pushOrchestrationThought: (conversationId, event) => { - if (get().activeConversationId !== conversationId) { + const surface = orchestrationSurfaces.get(conversationId); + if (get().activeConversationId !== conversationId || !surface + || (surface.turnId && event.turn_id && surface.turnId !== event.turn_id)) { return; } const content = @@ -3507,14 +3592,27 @@ export const useChatStore = create((set, get) => ({ })); }, - pushOrchestrationContent: (conversationId, accumulated) => { - if (get().activeConversationId !== conversationId) { + pushOrchestrationContent: (conversationId, accumulated, turnId) => { + const surface = orchestrationSurfaces.get(conversationId); + if (get().activeConversationId !== conversationId || !surface + || (surface.turnId && turnId && surface.turnId !== turnId)) { return; } set({ streamingContent: accumulated }); }, settleOrchestrationTurn: (conversationId, outcome) => { + const settledTurnId = outcome.turnId ?? ('event' in outcome ? outcome.event.turn_id : undefined); + const surface = orchestrationSurfaces.get(conversationId); + if (surface?.turnId && settledTurnId && surface.turnId !== settledTurnId) return; + const presentation = get().retryPresentation; + if (presentation && (presentation.kind !== 'orchestration' + || (settledTurnId && messageOrchestrationTurn(get().messages.find( + (message) => message.id === presentation.userMessageId, + )) !== settledTurnId))) { + return; + } + if (activeStreamController && !get().orchestrationSurface) return; // Released whether or not the conversation is on screen, so reopening it later does not // bring back a Thinking state for a turn that has already finished. orchestrationSurfaces.delete(conversationId); @@ -3529,26 +3627,31 @@ export const useChatStore = create((set, get) => ({ // adding a message. Thoughts are cleared because the planning reasoning is ephemeral — // the plan is the artifact worth keeping, and stale thoughts would otherwise flash in // the next turn's streaming bubble. - set({ + set((state) => ({ + messages: outcome.attemptState + ? updateRetryState(state.messages, state.retryPresentation, outcome.attemptState) : state.messages, streaming: false, orchestrationSurface: null, streamingContent: '', thoughts: [], streamingReasoningAdjustments: [], reconnectPhase: null, - }); + })); return; } if (outcome.status === 'failed' && !('event' in outcome)) { - set({ + set((state) => ({ streaming: false, orchestrationSurface: null, streamingContent: '', reconnectPhase: null, - streamError: outcome.error, + messages: updateRetryState(state.messages, state.retryPresentation, 'failed', outcome.error), + retryPresentation: state.retryPresentation + ? { ...state.retryPresentation, error: outcome.error } : null, + streamError: state.retryPresentation ? null : outcome.error, streamAuthUrl: null, - }); + })); return; } @@ -3558,19 +3661,20 @@ export const useChatStore = create((set, get) => ({ const { accumulated } = outcome; set((state) => ({ messages: accumulated - ? [ - ...state.messages, + ? insertRetryReply( + updateRetryState(state.messages, state.retryPresentation, 'interrupted'), { id: `cancelled-${Date.now()}`, conversation_id: conversationId, role: 'assistant', content: accumulated, timestamp: new Date().toISOString(), + metadata: retryReplyMetadata({}, state.retryPresentation), thoughts: state.thoughts.length > 0 ? [...state.thoughts] : undefined, - }, - ] - : state.messages, + }, state.retryPresentation, + ) + : updateRetryState(state.messages, state.retryPresentation, 'interrupted'), streaming: false, orchestrationSurface: null, streamingContent: '', @@ -3640,9 +3744,10 @@ export const useChatStore = create((set, get) => ({ ) : existing; return { - messages: mergeCollaborationMessage( - messages, - finalMessage as CollaborationMessage, + messages: insertRetryReply( + updateRetryState(messages, state.retryPresentation, outcome.status === 'cancelled' ? 'interrupted' : outcome.status), + { ...finalMessage, metadata: retryReplyMetadata(finalMessage.metadata ?? {}, state.retryPresentation) }, + state.retryPresentation, ), streaming: false, orchestrationSurface: null, @@ -3682,9 +3787,13 @@ export const useChatStore = create((set, get) => ({ return; } // The turn's claim on the streaming surface follows it to the id the server named. - const surfacePhase = orchestrationSurfaces.get(fromConversationId); - if (conversationChanged && surfacePhase && orchestrationSurfaces.delete(fromConversationId)) { - orchestrationSurfaces.set(toConversationId, surfacePhase); + const surface = orchestrationSurfaces.get(fromConversationId); + if (surface && (!surface.turnId || surface.turnId === fromTurnId)) { + if (conversationChanged) orchestrationSurfaces.delete(fromConversationId); + orchestrationSurfaces.set(conversationChanged ? toConversationId : fromConversationId, { + ...surface, + turnId: turnChanged ? toTurnId : surface.turnId, + }); } set((state) => { const messages = state.messages.map((message) => { @@ -3868,7 +3977,6 @@ export const useChatStore = create((set, get) => ({ return; } const message = get().messages.find((candidate) => candidate.id === messageId); - const attempt = normalizeOrchestrationAttempt(message?.metadata?.orchestration); const recoveryConversationId = get().activeConversationId; if (messageId.startsWith('pending-user-')) { const turnId = message?.metadata?.orchestration_turn_id; @@ -3884,17 +3992,17 @@ export const useChatStore = create((set, get) => ({ } return; } - if (attempt.run_id && recoveryConversationId) { - openOrchestrationRecovery(recoveryConversationId, attempt.run_id); - return; - } - if (message?.metadata?.orchestration) { - set({ - streamError: 'This historical orchestration message has no saved attempt identity. It cannot be resumed; create a new plan deliberately if needed.', - streamAuthUrl: null, - }); + const question = message && retryQuestion(get().messages, message); + if (!question || !recoveryConversationId || question.conversation_id !== recoveryConversationId) { + set({ streamError: 'The original question is unavailable. Reload the conversation before retrying.', streamAuthUrl: null }); return; } + const kind = messageOrchestrationTurn(message) || messageOrchestrationTurn(question) + || savedResponseAttempt(question).kind === 'orchestration' ? 'orchestration' : 'chat'; + let presentation = beginRetryPresentation(get().messages, question, kind); + const epoch = conversationEpoch; + const ownsPresentation = () => get().activeConversationId === recoveryConversationId + && conversationEpoch === epoch && get().retryPresentation?.id === presentation.id; set({ streaming: true, orchestrationSurface: null, @@ -3904,13 +4012,11 @@ export const useChatStore = create((set, get) => ({ streamError: null, streamAuthUrl: null, reconnectPhase: null, + retryPresentation: presentation, }); try { const bootstrap = useBootstrapStore.getState().data; - // Same exclusive rule as a fresh send, so a retry cannot reintroduce the - // combination the server reads as a model override of the agent. The retry - // endpoint takes a flat deployment name, which `buildSelectionFields` has already - // resolved from the catalog — the option value is a selection key, not a name. + // Overrides are deliberate; without them the server restores the viewed attempt. const selection = buildSelectionFields({ agents: bootstrap?.catalogs?.agents as Record[] | undefined, models: bootstrap?.catalogs?.models as ModelCatalogEntry[] | undefined, @@ -3918,27 +4024,44 @@ export const useChatStore = create((set, get) => ({ modelDeployment: options?.modelDeployment, reasoningEffort: options?.reasoningEffort, }); - const result = await retryMessageApi(messageId, { - model: selection.model_deployment, - reasoning_effort: selection.reasoning_effort, - agent_info: selection.agent_info, - }); - if (!result?.chat_request) { + if (options?.agentSelection && !selection.agent_info) { + throw new Error('The selected agent is unavailable. Review your selection.'); + } + if (options?.modelDeployment && !selection.model_deployment) { + throw new Error('The selected model is unavailable. Review your selection.'); + } + if (kind === 'orchestration') { + const result = await regenerateOrchestrationMessageApi(messageId, selection); + if (!ownsPresentation() || !get().streaming) return; + if (!result.plan_request || result.requires_fresh_review !== true + || result.plan_request.conversation_id !== recoveryConversationId) { + throw new Error('The server did not return a review-required planning request.'); + } + presentation = adoptPreparedRetry(result, presentation); + await planRegeneratedMessage(result); + return; + } + const result = await retryMessageApi(messageId, selection); + if (!ownsPresentation() || !get().streaming) return; + if (!result?.chat_request || result.chat_request.conversation_id !== recoveryConversationId) { throw new Error('The server did not return a retry request.'); } - // The retry endpoint only creates the next attempt; this second call is what - // actually generates the response. + presentation = adoptPreparedRetry(result, presentation); await runChatStream( result.chat_request, result.chat_request.conversation_id, - { reloadOnDone: true }, + { reloadOnDone: true, pendingUserMessageId: result.user_message_id }, ); } catch (error) { - set({ + if (!ownsPresentation()) return; + const failure = error instanceof Error ? error.message : 'Retry failed.'; + set((state) => ({ streaming: false, - streamError: error instanceof Error ? error.message : 'Retry failed.', + messages: presentation.admitted ? updateRetryState(state.messages, presentation, 'unknown', failure) : state.messages, + retryPresentation: { ...presentation, hiddenMessageIds: [], error: failure }, + streamError: null, streamAuthUrl: null, - }); + })); } }, @@ -3952,6 +4075,13 @@ export const useChatStore = create((set, get) => ({ set({ streamError: WORKFLOW_RESULT_RETRY_UNSUPPORTED_MESSAGE, streamAuthUrl: null }); return; } + const question = get().messages.find((message) => message.id === messageId && message.role === 'user'); + const conversationId = get().activeConversationId; + if (!question || !conversationId) return; + let presentation = beginRetryPresentation(get().messages, question, 'chat'); + const epoch = conversationEpoch; + const ownsPresentation = () => get().activeConversationId === conversationId + && conversationEpoch === epoch && get().retryPresentation?.id === presentation.id; set({ streaming: true, orchestrationSurface: null, @@ -3961,29 +4091,41 @@ export const useChatStore = create((set, get) => ({ streamError: null, streamAuthUrl: null, reconnectPhase: null, + retryPresentation: presentation, }); try { const result = await editMessageApi(messageId, trimmed); - if (!result?.chat_request) { + if (!ownsPresentation() || !get().streaming) return; + if (!result?.chat_request || result.chat_request.conversation_id !== conversationId) { throw new Error('The server did not return an edit request.'); } + presentation = adoptPreparedRetry(result, presentation); await runChatStream( result.chat_request, result.chat_request.conversation_id, - { reloadOnDone: true }, + { reloadOnDone: true, pendingUserMessageId: result.user_message_id }, ); } catch (error) { - set({ + if (!ownsPresentation()) return; + const failure = error instanceof Error ? error.message : 'Edit failed.'; + set((state) => ({ streaming: false, - streamError: error instanceof Error ? error.message : 'Edit failed.', + messages: presentation.admitted ? updateRetryState(state.messages, presentation, 'unknown', failure) : state.messages, + retryPresentation: { ...presentation, hiddenMessageIds: [], error: failure }, + streamError: null, streamAuthUrl: null, - }); + })); } }, changeAttempt: async (messageId, direction) => { + if (get().streaming) return; + const conversationId = get().activeConversationId; + const epoch = conversationEpoch; try { const result = await switchAttemptApi(messageId, direction); + if (get().activeConversationId !== conversationId || conversationEpoch !== epoch) return; + set({ retryPresentation: null, streamError: null, streamAuthUrl: null }); // The only place the server reports the full attempt set, so it is remembered // against the thread rather than discarded. diff --git a/docs/explanation/fixes/CHAT_MESSAGE_RETRY_FIX.md b/docs/explanation/fixes/CHAT_MESSAGE_RETRY_FIX.md new file mode 100644 index 000000000..34cbf2aa3 --- /dev/null +++ b/docs/explanation/fixes/CHAT_MESSAGE_RETRY_FIX.md @@ -0,0 +1,115 @@ +--- +layout: page +title: "Chat message retry reliability" +description: "Canonical saved-input retries, review-first orchestration regeneration, and attempt-scoped progress and errors." +section: "Explanation" +audience: developer +version: "0.261.317" +--- + +## Issue and root cause + +Model and agent retries could reconstruct incompatible or incomplete invocation +selections, discard saved options, or replay wording from a different attempt. +Orchestrated questions saved a turn identity while their Retry action expected a +run identity for checkpoint recovery. Deleting the answer exposed that mismatch. +Progress appended at the conversation tail and conversation-wide errors left old +answers visible during retries and failures visible over successful older answers. + +Fixed/Implemented in version: **0.261.317** + +Related config.py update: `VERSION = "0.261.317"` in +`application/single_app/config.py`. + +## Request reconstruction and durable attempts + +`functions_chat_model_catalog.py` shares the authorized chat model catalog. +`functions_chat_retry.py` reconstructs allowlisted inputs, resolves the viewed +question, checks ownership and source fingerprints, reserves and publishes one +prepared question, and admits its invocation once. Model identity includes the +deployment, model ID, endpoint ID, and provider. Agent metadata becomes the +canonical agent request and takes precedence over ordinary model/reasoning fields. + +The Retry and Edit routes in `route_backend_conversations.py` use that contract. +`route_backend_chats.py` reauthorizes the saved question before invocation, ignores +browser substitutions, persists terminal attempt state from the worker, and keeps +partial answers on their actual attempt. Identical unconsumed preparations can +be reconciled after a lost response or a Stop race without allocating another +question. Changed requests and consumed attempts cannot reuse that admission. + +Logical ordering keeps answers beside their questions and bounds retry context +before the target turn. Later messages are retained, not included as future +context or automatically regenerated. Deleted and inactive answers remain excluded. + +## Fresh orchestration planning versus recovery + +`POST /api/v2/orchestration/messages//regenerate` resolves owned saved +input intent and prepares a new planning turn. The existing planning SSE route +claims that question once. Input-only `regeneration_of_run_id` lineage stays +separate from checkpoint recovery's `retry_of_run_id`. + +Complete inputs and accepted clarification answers are saved on the question so +planning failures without a new run remain retryable. Preparation and invocation +check current authorization and active recovery descendants. Image-generation +intent cannot bypass authorization of the planning model. + +`requires_fresh_review` survives planning, clarification, normalization, and plan +revision. Execution requires explicit `reviewed_regeneration: true`. Auto and +administrator approval defaults cannot bypass this review floor. Planning never +restores execution receipts or repeats completed actions; approving the new plan +can repeat actions and does not undo their earlier effects. + +The review floor also applies to retrying a planning request retained only in the +browser. After approval, that request executes as an ordinary new turn; the +review flag alone does not invent message-carousel lineage. + +## In-place presentation + +The V2 `chatRetryAttempts.ts` helpers and `chatStore.ts` own preparation, saved +identity adoption, reply placement, and attempt-local errors. `MessageList.tsx` +hides the selected answer during preparation and anchors progress and the fresh +plan beside its question. Refused preparation restores the old answer and count. +Carousel navigation clears transient errors and reloads the selected attempt's +durable state. Earlier-turn reattachment uses the running question rather than +the last user message. Stop preserves partial output, and late preparation cannot +repopulate a new chat. A content-check block keeps its safe notice and failed +attempt state together; it does not become a successful answer or add a duplicate +generic failure panel. + +The classic explicit-selection retry modal sends the full model tuple. Shared +conversation restrictions and workflow-delivery/result retry refusals are unchanged. + +## Validation and limitations + +Regression coverage uses real application modules and initialized offline HTTP +routes, plus the real V2 stores/controller/components with production CSS: + +- `functional_tests/test_chat_retry_request_contract.py` +- `functional_tests/test_chat_retry_attempt_lifecycle.py` +- `functional_tests/test_orchestration_message_regeneration.py` +- `ui_tests/test_v2_chat_retry.py` +- `ui_tests/test_v2_orchestration_planning_retry.py` +- Authenticated route-policy coverage in `functional_tests/route_tests/` + +Coverage includes model/agent identity, edited wording, deleted answers, duplicate +requests, prepared reconciliation, failed planning, clarification preservation, +review floors, bounded history, refused preparation, carousel errors, Stop, +earlier-turn placement, reload/reattachment, and late responses. + +Final targeted results: + +| Area | Result | +|---|---| +| Retry contracts, attempt lifecycle, regeneration, and backend compatibility | 104 passed | +| Retry, planning recovery, checkpoint recovery, streaming, Microsoft 365 approvals, images, and plan editor browser selection | 113 passed | +| Documentation coverage/quality and route-policy inventories | 32 passed | +| Existing unauthenticated route-policy contract | Passed | +| Standalone plan-narrowing and pending-approval restoration harnesses | 5/5 passed in each | +| V2 TypeScript check and production build | Passed; existing large-bundle warning remains | + +Provider and storage boundaries are deterministic and external networking is +blocked. No live Azure/model-provider success is asserted. Historical questions +without complete saved planning inputs require a deliberately reviewed new request. + +See [Chat controls]({{ '/reference/chat-controls/' | relative_url }}) and +[Review and edit orchestration plans]({{ '/guides/review-and-edit-orchestration-plans/' | relative_url }}). diff --git a/docs/guides/review-and-edit-orchestration-plans.md b/docs/guides/review-and-edit-orchestration-plans.md index 087cd464c..24ad6da43 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.201" +version: "0.261.317" --- ## Decide what should run @@ -278,8 +278,10 @@ document or integration you can no longer use may not be restorable. ## Recover before a plan exists Since **0.261.115**, **Retry** on a failed planning question retries planning with -the original prompt, model or agent selection, documents, scope, and approval -mode retained in that browser tab. It does not use the temporary chat bubble as +the original prompt, model or agent selection, documents, and scope retained in +that browser tab. Since **0.261.317**, the resulting plan always requires review, +including when the original approval mode was Auto. It does not use the temporary +chat bubble as a saved message ID or append a duplicate question. Stop interrupts the local planning request; a late response cannot populate another or deleted conversation. @@ -289,6 +291,34 @@ The application does not guess a model or document selection from current contro If a plan or clarification already exists, continue from that plan or question instead; Retry must not discard accepted answers or start an independent execution. +## Generate a fresh plan from a saved question + +Implemented in version: **0.261.317** (`application/single_app/config.py`). +Select the message's **Retry** when you want a new plan for the question you are +viewing, not a continuation of its old execution. This works after deleting the +answer, provided the question and complete saved planning inputs remain available. + +Retry preserves the viewed attempt's wording, prompt, model or agent, sources, +options, and accepted clarification answers. Current composer selections do not +replace them. Sources and permissions are checked again; a missing original +selection is explained rather than replaced with a different one. + +The old answer is hidden while the new attempt plans at the question's position. +It remains available through the message's attempt arrows unless you deleted it. +Failures belong to their own attempt, so viewing a successful older answer does +not leave the newer failure over that answer. + +Review the new plan and select **Approve and run the plan** when you want its work +to execute. Retry cannot run it automatically, even with Auto configured or after +a clarification. Editing the regenerated plan does not remove that requirement. +Earlier completed actions are not undone and could be repeated by the new plan. +Use **Retry from failed step** for recovery that reuses saved completed work. + +Finish or stop a running or waiting execution before requesting another plan. +Retrying an earlier question keeps later messages, but excludes them from the +earlier question's planning context. A request whose planning failed before a run +was saved remains retryable when its saved inputs are complete. + ## Recover from a failed change The last valid plan remains available when an instruction cannot be planned or diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md index 14a2a2701..d550be6da 100644 --- a/docs/reference/chat-controls.md +++ b/docs/reference/chat-controls.md @@ -4,7 +4,7 @@ title: "Chat interface controls" description: "Reference for every documented control in the SimpleChat chat interface." section: "Reference" audience: user -version: "0.261.310" +version: "0.261.317" --- ## How to use this reference @@ -589,6 +589,38 @@ server's actual dependency order, including repeated roles. Plans created by an output, and whether partial data is allowed. The server remains authoritative for binding compatibility, capability admission, source access, and limits. +## Message Retry and attempt history (V2 interface) + +Implemented in version: **0.261.317** (`application/single_app/config.py`). +Use **Retry** when you want another answer to the question you are viewing, +including an edited question. It reuses that attempt's saved model or agent, +prompt, sources, and options; changing the composer does not change the retry. +Unavailable selections are refused rather than silently replaced. + +The previous answer disappears immediately while **Preparing retry**, then +**Thinking** or **Planning**, appears beside the same question. Earlier answers +stay in **Previous attempt** / **Next attempt** unless you deleted them. A refused +preparation restores the previous answer without adding an attempt. After +preparation succeeds, a failure belongs to the new attempt: returning to an older +successful answer hides that failure, and returning to the failed attempt shows +it again. **Stop** retains available partial text. + +Retry still works from a surviving question after deleting its answer. Retrying +an earlier question does not delete or regenerate later messages, and those later +messages are not used to answer the earlier question. + +For an orchestrated question, **Retry** creates a **fresh plan requiring review**, +even when normal approval is Auto. **Approve and run the plan** starts that new +plan only after your approval. It does not reuse execution approvals, restore +checkpoints, or undo actions that an earlier run completed. Finish or stop a live +or waiting execution before regenerating its plan. + +Use **Retry from failed step** below when you want checkpoint recovery instead. +A prepared message retry that has not started offers a notice to select **Retry** +to continue the same saved preparation. Reloading does not start a new generation +or automatically execute an orchestration plan. Historical requests without +complete saved inputs explain how to review a new request in the composer. + ## Orchestration failure recovery (V2 interface) Implemented in **0.261.105**. A failed run keeps an explanation in the conversation 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 2ebc14481..b5c133725 100644 --- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py +++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for route blueprint policy inventory. -Version: 0.261.315 +Version: 0.261.317 Implemented in: 0.242.069 Plan editor policy coverage: 0.261.102 Selected-group context policy coverage: 0.261.126 @@ -610,6 +610,18 @@ def test_workflow_proposal_routes_keep_the_orchestration_security_policy() -> No assert {"swagger_route", "login_required", "user_required"} <= set(route.decorator_names) +def test_message_regeneration_keeps_the_authenticated_orchestration_policy() -> None: + """A fresh generation never bypasses the owned conversation or authenticated Blueprint.""" + path = "/api/v2/orchestration/messages//regenerate" + routes = [route for route in iter_route_functions() if route.path == path] + assert len(routes) == 1 + route = routes[0] + assert route.file_name == "route_backend_orchestration.py" + assert route.function_name == "regenerate_orchestration_message" + assert route.route_target == "bp" + assert {"swagger_route", "login_required", "user_required"} <= set(route.decorator_names) + + def test_workflow_run_link_route_keeps_the_orchestration_security_policy() -> None: """The links to runs a plan started stay on the authenticated orchestration Blueprint, read-only.""" path = "/api/v2/orchestration/runs//workflow-runs" diff --git a/functional_tests/test_chat_retry_attempt_lifecycle.py b/functional_tests/test_chat_retry_attempt_lifecycle.py new file mode 100644 index 000000000..37e4df11f --- /dev/null +++ b/functional_tests/test_chat_retry_attempt_lifecycle.py @@ -0,0 +1,395 @@ +# test_chat_retry_attempt_lifecycle.py +""" +Functional regression coverage for durable chat retry attempts. +Version: 0.261.317 +Implemented in: 0.261.317 + +Real replay, route admission, history, and background-worker functions run with +external I/O blocked. Storage and provider boundaries are in memory. +""" + +from contextlib import nullcontext +from copy import deepcopy +import importlib +import inspect +import json +from pathlib import Path +import sys +from types import SimpleNamespace +import uuid + +from azure.cosmos.exceptions import CosmosResourceExistsError +from flask import Flask, g, jsonify, request +import pytest + +from test_chat_soft_deleted_message_visibility_fix import ( + CONVERSATION, USER, FakeContainer, message, soft_deleted, +) +from test_support.offline_bootstrap import offline_app_imports + + +APP_ROOT = Path(__file__).resolve().parents[1] / 'application' / 'single_app' +MODEL = { + 'deployment_name': 'shared-model', 'model_id': 'model-b', + 'endpoint_id': 'endpoint-b', 'provider': 'aoai', +} +AGENT = { + 'id': 'agent-a', 'name': 'research', 'display_name': 'Research', + 'is_global': True, 'is_group': False, +} + + +class AttemptContainer(FakeContainer): + def __init__(self, items=()): + super().__init__(items) + self.after_replace = None + + def create_item(self, body, **kwargs): + if body['id'] in self.items: + raise CosmosResourceExistsError(status_code=409, message='Exists') + return self._store(body) + + def replace_item(self, **kwargs): + result = super().replace_item(**kwargs) + if self.after_replace: + callback, self.after_replace = self.after_replace, None + callback() + return result + + +@pytest.fixture(scope='module') +def modules(): + with offline_app_imports(): + original_path = list(sys.path) + sys.path.insert(0, str(APP_ROOT)) + try: + yield SimpleNamespace( + replay=importlib.import_module('functions_chat_retry'), + chats=importlib.import_module('route_backend_chats'), + conversations=importlib.import_module('route_backend_conversations'), + ) + finally: + sys.path[:] = original_path + + +@pytest.fixture +def context(modules, monkeypatch): + question = message('q1', 'user', 'Original question', '2026-01-01T00:00:03', 'thread') + question['metadata']['model_selection'] = { + 'selected_model': MODEL['deployment_name'], 'model_id': MODEL['model_id'], + 'model_endpoint_id': MODEL['endpoint_id'], 'model_provider': MODEL['provider'], + } + answer = message('a1', 'assistant', 'Original answer', '2026-01-01T00:00:04', 'thread') + messages = AttemptContainer([question, answer]) + conversations = AttemptContainer([{'id': CONVERSATION, 'user_id': USER}]) + settings = {'enable_multi_model_endpoints': True, 'enable_semantic_kernel': True} + models = [deepcopy(MODEL)] + agents = [deepcopy(AGENT)] + from functions_chat_content_checks import ChatContentDecision + + allowed = ChatContentDecision('chat_input', 'not_required', 'allow', {}) + for module in (modules.chats, modules.conversations): + monkeypatch.setattr(module, 'cosmos_messages_container', messages) + monkeypatch.setattr(module, 'cosmos_conversations_container', conversations) + monkeypatch.setattr(module, 'get_current_user_id', lambda: USER) + monkeypatch.setattr(module, 'get_settings', lambda *args, **kwargs: settings) + monkeypatch.setattr(module, 'check_chat_content', lambda *args, **kwargs: allowed) + monkeypatch.setattr(module, 'log_event', lambda *args, **kwargs: None) + monkeypatch.setattr(modules.conversations, 'build_chat_model_catalog', lambda **kwargs: models) + monkeypatch.setattr(modules.conversations, 'build_accessible_agent_catalog', lambda *args, **kwargs: agents) + monkeypatch.setattr(modules.conversations, 'ensure_governance_access', lambda *args, **kwargs: None) + monkeypatch.setattr(modules.chats, 'debug_print', lambda *args, **kwargs: None) + + def prepare(*, submission_id=None, source=None, edited=False, content=None): + source = source or messages.get('q1') + body = modules.conversations._build_authorized_message_replay_request(USER, source, {}, settings) + if content is not None: + body['message'] = content + return modules.replay.prepare_retry_attempt( + messages, conversations, USER, source, body, + submission_id=submission_id or str(uuid.uuid4()), edited=edited, + ) + + app = Flask('retry-attempt-lifecycle') + app.config['TESTING'] = True + app.secret_key = 'test-only' + return SimpleNamespace( + **vars(modules), messages=messages, store=conversations, settings=settings, + models=models, agents=agents, prepare=prepare, app=app, + ) + + +def claim(context, question, **kwargs): + return context.replay.claim_retry_attempt( + context.messages, context.store, USER, CONVERSATION, question['id'], **kwargs, + ) + + +def test_duplicate_preparation_has_one_identity_and_one_carousel_attempt(context): + submission_id = str(uuid.uuid4()) + first = context.prepare(submission_id=submission_id) + second = context.prepare(submission_id=submission_id) + assert second['id'] == first['id'] + assert second['metadata']['thread_info']['thread_attempt'] == 2 + assert len(context.messages.items) == 3 + assert context.messages.get('a1')['metadata']['thread_info']['active_thread'] is False + + +def test_changed_duplicate_submission_is_rejected(context): + submission_id = str(uuid.uuid4()) + context.prepare(submission_id=submission_id) + with pytest.raises(context.replay.ChatRetryError) as failure: + context.prepare(submission_id=submission_id, content='Different wording', edited=True) + assert failure.value.code == 'submission_conflict' + assert len(context.messages.items) == 3 + + +def test_competing_preparation_cannot_start_another_attempt(context): + first = context.prepare() + reconciled = context.prepare() + assert reconciled['id'] == first['id'] + with pytest.raises(context.replay.ChatRetryError) as failure: + context.prepare(content='Changed selections before invocation') + assert failure.value.code == 'retry_in_progress' + claimed = claim(context, first) + assert claimed['metadata']['response_attempt']['state'] == 'running' + with pytest.raises(context.replay.ChatRetryError) as failure: + context.prepare() + assert failure.value.code == 'retry_in_progress' + assert len(context.messages.items) == 3 + + +def test_reloaded_prepared_question_reconciles_without_a_new_attempt(context): + first = context.prepare() + recovered = context.replay.reconcile_prepared_retry( + context.messages, context.store, USER, first, + ) + assert recovered['id'] == first['id'] + assert recovered['metadata']['response_attempt']['state'] == 'prepared' + assert len(context.messages.items) == 3 + claim(context, recovered) + with pytest.raises(context.replay.ChatRetryError) as failure: + context.replay.reconcile_prepared_retry(context.messages, context.store, USER, recovered) + assert failure.value.code == 'retry_attempt_changed' + + +def test_hard_deleted_source_cannot_be_reconciled(context): + prepared = context.prepare() + context.messages.items.pop('q1') + with pytest.raises(context.replay.ChatRetryError) as failure: + context.replay.reconcile_prepared_retry(context.messages, context.store, USER, prepared) + assert failure.value.code == 'retry_source_changed' + + +def test_prepared_agent_reauthorization_failure_is_durable(context, monkeypatch): + source = context.messages.get('q1') + source['metadata']['agent_selection'] = { + **AGENT, 'agent_id': AGENT['id'], 'selected_agent': AGENT['name'], + } + context.messages.upsert_item(source) + prepared = context.prepare() + + def refuse(*args, **kwargs): + raise PermissionError('Access removed') + + monkeypatch.setattr(context.conversations, 'ensure_governance_access', refuse) + with context.app.test_request_context('/retry', method='POST', json={}): + response, status = context.conversations._prepare_message_attempt_response(prepared['id']) + assert status == 403 + assert response.get_json()['code'] == 'forbidden' + saved = context.messages.get(prepared['id'])['metadata']['response_attempt'] + assert saved['state'] == 'failed' + assert saved['code'] == 'forbidden' + assert saved['error'] == response.get_json()['error'] + assert len(context.messages.items) == 3 + + +def test_interleaved_reservation_recovers_the_same_question(context): + recovered = [] + context.store.after_replace = lambda: recovered.append(context.prepare()) + first = context.prepare() + assert recovered[0]['id'] == first['id'] + assert len(context.messages.items) == 3 + assert context.messages.get('q1')['metadata']['thread_info']['active_thread'] is False + + +def test_one_time_claim_and_terminal_status(context): + question = context.prepare() + first = claim(context, question, thread_id='thread', thread_attempt=2) + assert first['metadata']['response_attempt']['state'] == 'running' + with pytest.raises(context.replay.ChatRetryError) as failure: + claim(context, question) + assert failure.value.code == 'retry_already_submitted' + context.replay.set_retry_attempt_state(context.messages, CONVERSATION, question['id'], 'completed') + context.replay.set_retry_attempt_state(context.messages, CONVERSATION, question['id'], 'failed', error='Late callback') + saved = context.messages.get(question['id']) + assert saved['metadata']['response_attempt']['state'] == 'completed' + assert 'error' not in saved['metadata']['response_attempt'] + + +@pytest.mark.parametrize('change', ['content', 'mask', 'delete']) +def test_changed_source_cannot_be_consumed(context, change): + question = context.prepare() + source = context.messages.get('q1') + if change == 'content': + source['content'] = 'Changed after preparation' + elif change == 'mask': + source['metadata']['masked'] = True + else: + source = soft_deleted(source) + context.messages.upsert_item(source) + with pytest.raises(context.replay.ChatRetryError) as failure: + claim(context, question) + assert failure.value.code == 'retry_source_changed' + + +def test_deleted_answer_does_not_remove_the_question_retry(context): + context.messages.upsert_item(soft_deleted(context.messages.get('a1'))) + question = context.prepare() + claimed = claim(context, question) + assert claimed['metadata']['thread_info']['thread_attempt'] == 2 + assert context.messages.get('a1')['metadata']['is_deleted'] is True + + +def test_history_stops_at_the_retried_logical_turn(context): + context.messages.upsert_item(message('before', 'user', 'Earlier context', '2026-01-01T00:00:01')) + context.messages.upsert_item(message('before-answer', 'assistant', 'Earlier answer', '2026-01-01T00:00:02')) + context.messages.upsert_item(message('later', 'user', 'Later question', '2026-01-01T00:00:05', 'later-thread')) + context.messages.upsert_item(message('later-answer', 'assistant', 'Later answer', '2026-01-01T00:00:06', 'later-thread')) + question = context.prepare(edited=True, content='Viewed edited wording') + rows = list(context.messages.query_items(query='SELECT * FROM c ORDER BY c.timestamp ASC')) + with context.app.test_request_context(): + result = context.chats.build_conversation_history_segments( + rows, 50, user_message_id=question['id'], fallback_user_message=question['content'], + include_assistant_citation_context=False, + ) + contents = [row['content'] for row in result['history_messages']] + assert contents == ['Earlier context', 'Earlier answer', 'Viewed edited wording'] + ordered = context.replay.order_retry_messages(rows) + assert next(index for index, row in enumerate(ordered) if row['id'] == question['id']) < next( + index for index, row in enumerate(ordered) if row['id'] == 'later' + ) + assert context.messages.get('later-answer')['content'] == 'Later answer' + + +@pytest.mark.parametrize('agent', [False, True]) +def test_real_invocation_admission_ignores_tampered_browser_selections(context, agent): + if agent: + source = context.messages.get('q1') + source['metadata']['agent_selection'] = { + 'selected_agent': AGENT['name'], 'agent_id': AGENT['id'], + 'agent_display_name': AGENT['display_name'], 'is_global': True, 'is_group': False, + } + context.messages.upsert_item(source) + question = context.prepare() + calls = [] + + @context.chats._with_prepared_chat_retry + def invoke(): + calls.append(deepcopy(request.get_json())) + return jsonify({'reply': 'Provider boundary response'}) + + context.app.add_url_rule('/invoke', view_func=invoke, methods=['POST']) + client = context.app.test_client() + payload = { + 'message': 'Browser changed the question', 'conversation_id': CONVERSATION, + 'retry_user_message_id': question['id'], 'retry_thread_id': 'thread', 'retry_thread_attempt': 2, + 'model_deployment': 'another-model', 'model_endpoint_id': 'another-endpoint', + 'agent_info': {'id': 'another-agent', 'name': 'another-agent'}, + } + first = client.post('/invoke', json=payload) + assert first.status_code == 200 + assert calls[0]['message'] == 'Original question' + if agent: + assert calls[0]['agent_info']['id'] == AGENT['id'] + assert 'model_endpoint_id' not in calls[0] + assert 'reasoning_effort' not in calls[0] + else: + assert calls[0]['model_endpoint_id'] == MODEL['endpoint_id'] + assert calls[0]['model_id'] == MODEL['model_id'] + assert 'agent_info' not in calls[0] + duplicate = client.post('/invoke', json=payload) + assert duplicate.status_code == 409 + assert len(calls) == 1 + assert context.messages.get(question['id'])['metadata']['response_attempt']['state'] == 'completed' + + +def test_removed_model_fails_the_admitted_attempt_without_provider_work(context): + question = context.prepare() + context.models.clear() + calls = [] + + @context.chats._with_prepared_chat_retry + def invoke(): + calls.append(True) + return jsonify({'reply': 'Must not be generated'}) + + context.app.add_url_rule('/invoke', view_func=invoke, methods=['POST']) + response = context.app.test_client().post('/invoke', json={ + 'conversation_id': CONVERSATION, 'retry_user_message_id': question['id'], + }) + assert response.status_code == 409 + assert response.get_json()['code'] == 'retry_model_unavailable' + assert calls == [] + assert context.messages.get(question['id'])['metadata']['response_attempt']['state'] == 'failed' + + +def test_stale_runtime_metadata_cannot_reactivate_or_regress_an_attempt(context): + question = context.prepare() + stale = claim(context, question) + context.replay.set_retry_attempt_state(context.messages, CONVERSATION, question['id'], 'completed') + current = context.messages.get(question['id']) + current['metadata']['thread_info']['active_thread'] = False + context.messages.upsert_item(current) + stale['metadata']['capability_usage'] = {'web_search': {'used': True}} + context.chats._save_chat_user_metadata(stale, True) + saved = context.messages.get(question['id']) + assert saved['metadata']['response_attempt']['state'] == 'completed' + assert saved['metadata']['thread_info']['active_thread'] is False + assert saved['metadata']['capability_usage']['web_search']['used'] is True + + +@pytest.mark.parametrize(('payload', 'state'), [ + ({'done': True}, 'completed'), + ({'error': 'Provider failed', 'error_code': 'provider_failed'}, 'failed'), + ({'done': True, 'canceled': True}, 'interrupted'), + ({'error': 'Interrupted response', 'interrupted': True}, 'interrupted'), + ({'done': True, 'blocked': True}, 'failed'), +]) +def test_terminal_events_belong_to_the_actual_attempt(context, payload, state): + question = context.prepare() + claimed = claim(context, question) + result = context.chats._persist_retry_terminal_payload(claimed, payload) + assert result == state + assert payload['user_message_id'] == question['id'] + assert payload['retry_thread_attempt'] == 2 + assert context.messages.get(question['id'])['metadata']['response_attempt']['state'] == state + + +def test_browser_disconnect_does_not_interrupt_background_generation(context, monkeypatch): + question = context.prepare() + claimed = claim(context, question) + monkeypatch.setattr(context.chats, 'login_required', lambda function: function) + monkeypatch.setattr(context.chats, 'user_required', lambda function: function) + monkeypatch.setattr(context.chats, 'm365_action_card_events', lambda *args: nullcontext()) + monkeypatch.setattr(context.chats, 'get_request_pending_action_references', lambda: []) + completions = [] + monkeypatch.setattr(context.chats, 'complete_m365_request', lambda **kwargs: completions.append(kwargs['success'])) + context.chats.register_route_backend_chats(context.app) + entry = next(view for view in context.app.view_functions.values() if view.__name__ == 'chat_stream_api') + worker_response = inspect.getclosurevars(inspect.unwrap(entry)).nonlocals['build_background_stream_response'] + jobs = [] + context.app.extensions['executor'] = SimpleNamespace(submit=jobs.append) + + def generate(): + yield f"data: {json.dumps({'done': True, 'full_content': 'Finished after disconnect'})}\n\n" + + with context.app.test_request_context('/api/chat/stream', method='POST', json={}): + g.chat_retry_question = claimed + response = worker_response(generate) + response.close() + saved = context.messages.get(question['id']) + assert saved['metadata']['response_attempt']['state'] == 'running' + jobs[0]() + assert context.messages.get(question['id'])['metadata']['response_attempt']['state'] == 'completed' + assert completions == [True] diff --git a/functional_tests/test_chat_retry_request_contract.py b/functional_tests/test_chat_retry_request_contract.py new file mode 100644 index 000000000..f8e582157 --- /dev/null +++ b/functional_tests/test_chat_retry_request_contract.py @@ -0,0 +1,212 @@ +# test_chat_retry_request_contract.py +""" +Functional regression coverage for canonical chat retry inputs. +Version: 0.261.317 +Implemented in: 0.261.317 + +Real replay helpers run with external bootstrap I/O blocked. These checks protect +endpoint identity, agent-wins routing, saved options, and explicit overrides. +""" + +from copy import deepcopy +import importlib +from pathlib import Path +import sys + +import pytest + +from test_support.offline_bootstrap import offline_app_imports + + +APP_ROOT = Path(__file__).resolve().parents[1] / 'application' / 'single_app' +MODELS = [ + {'deployment_name': 'shared-model', 'model_id': 'model-a', 'endpoint_id': 'endpoint-a', 'provider': 'aoai'}, + {'deployment_name': 'shared-model', 'model_id': 'model-b', 'endpoint_id': 'endpoint-b', 'provider': 'aoai'}, +] +AGENTS = [ + {'id': 'agent-a', 'name': 'research', 'display_name': 'Research', 'is_global': True, 'is_group': False}, + {'id': 'agent-b', 'name': 'research', 'display_name': 'Personal research', 'is_global': False, 'is_group': False}, +] +DOCUMENT_CONTEXT = { + 'document_context_requested': True, 'hybrid_search': False, + 'selection_mode': 'selected', 'selected_document_ids': ['document-a'], + 'doc_scope': 'user', 'tags': ['finance'], 'document_filter_mode': 'union', +} + + +@pytest.fixture(scope='module') +def replay(): + with offline_app_imports(): + original_path = list(sys.path) + sys.path.insert(0, str(APP_ROOT)) + try: + module = importlib.import_module('functions_chat_retry') + yield module + finally: + sys.path[:] = original_path + + +def source(): + return { + 'id': 'question-2', 'conversation_id': 'conversation', 'role': 'user', + 'content': 'What is the capital of Germany?', + 'metadata': { + 'model_selection': { + 'selected_model': 'shared-model', 'model_id': 'model-b', + 'model_endpoint_id': 'endpoint-b', 'model_provider': 'aoai', + 'reasoning_effort': 'medium', + }, + 'button_states': {'web_search': True, 'url_access': False, 'deep_research': False}, + 'thread_info': {'thread_id': 'thread', 'thread_attempt': 2, 'active_thread': True}, + }, + } + + +def prepare(replay, question, overrides=None, models=None, agents=None): + return replay.build_replay_request( + question, overrides or {}, document_context=DOCUMENT_CONTEXT, + models=MODELS if models is None else models, agents=AGENTS if agents is None else agents, + ) + + +def test_retry_restores_complete_identity_and_saved_options(replay): + question = source() + original = deepcopy(question) + result = prepare(replay, question) + assert {key: result[key] for key in replay.MODEL_FIELDS} == { + 'model_deployment': 'shared-model', 'model_id': 'model-b', + 'model_endpoint_id': 'endpoint-b', 'model_provider': 'aoai', + } + assert result['message'] == 'What is the capital of Germany?' + assert result['reasoning_effort'] == 'medium' + assert result['web_search_enabled'] is True + assert result['url_access_enabled'] is False + assert result['selected_document_ids'] == ['document-a'] + assert result['document_filter_mode'] == 'union' + assert result['tags'] == ['finance'] + assert question == original + + +def test_retry_restores_requested_not_adjusted_reasoning(replay): + question = source() + question['metadata'].update({'requested_reasoning_effort': 'high', 'reasoning_effort': 'low'}) + result = prepare(replay, question) + assert result['reasoning_effort'] == 'high' + result = prepare(replay, question, {'reasoning_effort': None}) + assert 'reasoning_effort' not in result + + +def test_saved_agent_metadata_becomes_an_exclusive_agent_request(replay): + question = source() + question['metadata']['agent_selection'] = { + 'agent_id': 'agent-a', 'selected_agent': 'research', + 'agent_display_name': 'Old label', 'is_global': True, 'is_group': False, + } + result = prepare(replay, question) + assert result['agent_info']['id'] == 'agent-a' + assert result['agent_info']['name'] == 'research' + assert result['agent_info']['display_name'] == 'Research' + assert result['agent_info']['is_global'] is True + assert not set(replay.MODEL_FIELDS).intersection(result) + assert 'reasoning_effort' not in result + + +def test_explicit_model_override_can_replace_an_agent_without_losing_endpoint(replay): + question = source() + question['metadata']['agent_selection'] = { + 'agent_id': 'agent-a', 'selected_agent': 'research', 'is_global': True, + } + result = prepare(replay, question, { + 'model_deployment': 'shared-model', 'model_id': 'model-a', + 'model_endpoint_id': 'endpoint-a', 'model_provider': 'aoai', + }) + assert result['model_endpoint_id'] == 'endpoint-a' + assert result['model_id'] == 'model-a' + assert 'agent_info' not in result + + +def test_legacy_provider_is_not_mistaken_for_endpoint_identity(replay): + question = source() + question['metadata']['model_selection'] = {'selected_model': 'legacy', 'model_provider': 'aoai'} + result = prepare(replay, question, models=[{'deployment_name': 'legacy'}]) + assert result['model_deployment'] == 'legacy' + assert 'model_endpoint_id' not in result + assert 'model_provider' not in result + + +def test_empty_legacy_agent_metadata_does_not_turn_a_model_response_into_an_agent(replay): + question = source() + question['metadata']['agent_selection'] = { + 'selected_agent': None, 'agent_id': None, 'is_global': False, 'is_group': False, + } + result = prepare(replay, question) + assert result['model_endpoint_id'] == 'endpoint-b' + assert 'agent_info' not in result + + +@pytest.mark.parametrize('scope', ['global', 'personal', 'group']) +def test_agent_identity_and_scope_must_both_match(replay, scope): + question = source() + agent = { + 'id': 'same-id', 'name': 'same-name', 'is_global': scope == 'global', + 'is_group': scope == 'group', 'group_id': 'group-a' if scope == 'group' else None, + } + question['metadata']['agent_selection'] = { + **agent, 'agent_id': agent['id'], 'selected_agent': agent['name'], + } + result = prepare(replay, question, agents=[agent, {**agent, 'id': 'different-id'}]) + assert result['agent_info']['id'] == 'same-id' + assert result['agent_info'].get('group_id') == agent['group_id'] + + +@pytest.mark.parametrize('kind', ['model', 'agent']) +def test_unavailable_original_selection_is_not_replaced(replay, kind): + question = source() + if kind == 'agent': + question['metadata']['agent_selection'] = { + 'agent_id': 'removed-agent', 'selected_agent': 'research', 'is_global': True, + } + with pytest.raises(replay.ChatRetryError) as error: + prepare(replay, question, models=[], agents=AGENTS) + assert error.value.code == f'retry_{kind}_unavailable' + + +def test_ambiguous_deployment_only_override_requires_an_explicit_endpoint(replay): + with pytest.raises(replay.ChatRetryError) as error: + prepare(replay, source(), {'model': 'shared-model'}) + assert error.value.code == 'retry_model_unavailable' + + +def test_agent_id_never_falls_back_to_another_agent_with_the_same_name(replay): + question = source() + question['metadata']['agent_selection'] = { + 'agent_id': 'removed', 'selected_agent': 'research', 'is_global': True, + } + with pytest.raises(replay.ChatRetryError): + prepare(replay, question) + + +def test_masked_question_is_not_replayed(replay): + question = source() + question['metadata']['masked_ranges'] = [{'start': 0, 'end': 5}] + with pytest.raises(replay.ChatRetryError) as error: + prepare(replay, question) + assert error.value.code == 'retry_message_masked' + + +def test_new_metadata_contains_inputs_not_previous_execution_approvals(replay): + question = source() + question['metadata'].update({ + 'm365_approval': {'approved': True}, 'execution_receipt': {'id': 'old'}, + 'orchestration': {'run_id': 'old'}, 'saved_analysis': {'id': 'old'}, + 'response_attempt': {'state': 'failed'}, 'is_deleted': False, + }) + request_body = prepare(replay, question) + result = replay.build_replay_metadata(question['metadata'], request_body) + assert result['model_selection']['model_endpoint_id'] == 'endpoint-b' + assert result['button_states']['web_search'] is True + assert not {'m365_approval', 'execution_receipt', 'orchestration', 'saved_analysis', 'response_attempt', 'is_deleted'}.intersection(result) + + +if __name__ == '__main__': + raise SystemExit(pytest.main([str(Path(__file__).resolve()), '-q'])) diff --git a/functional_tests/test_chat_retry_thought_tracker_init_fix.py b/functional_tests/test_chat_retry_thought_tracker_init_fix.py index 24539bf41..e269b6121 100644 --- a/functional_tests/test_chat_retry_thought_tracker_init_fix.py +++ b/functional_tests/test_chat_retry_thought_tracker_init_fix.py @@ -2,12 +2,11 @@ #!/usr/bin/env python3 """ Functional test for chat retry thought tracker initialization. -Version: 0.241.004 +Version: 0.261.317 Implemented in: 0.241.004 This test ensures retry and edit flows that route through the compatibility -bridge initialize assistant response tracking before content safety uses the -thought tracker. +bridge initialize assistant response tracking before any tracked thought. """ import os @@ -39,8 +38,8 @@ def read_config_version(): raise AssertionError('VERSION assignment not found in config.py') -def test_retry_and_edit_paths_initialize_thought_tracker_before_content_safety(): - """Verify chat_api uses shared assistant tracking setup before content safety.""" +def test_retry_and_edit_paths_initialize_thought_tracker_before_first_use(): + """Verify chat_api uses shared assistant tracking setup before tracked thoughts.""" print('🔍 Testing retry/edit thought-tracker initialization...') route_source = read_file_text(ROUTE_FILE) @@ -56,14 +55,15 @@ def test_retry_and_edit_paths_initialize_thought_tracker_before_content_safety() helper_marker = 'def _initialize_assistant_response_tracking(' shared_init_marker = 'assistant_message_id, thought_tracker, assistant_thread_attempt, response_message_context = _initialize_assistant_response_tracking(' - content_safety_marker = "thought_tracker.add_thought('content_safety', 'Checking content safety...')" + tracked_thought_marker = "thought_tracker.add_thought(" assert helper_marker in route_source, 'Expected shared assistant-response tracking helper in route_backend_chats.py.' - assert "retry_user_message_id = data.get('retry_user_message_id') or data.get('edited_user_message_id')" in chat_api_source + assert "data.get('retry_user_message_id')" in chat_api_source + assert "data.get('edited_user_message_id')" in chat_api_source assert shared_init_marker in chat_api_source, 'Expected chat_api to initialize assistant tracking through the shared helper.' - assert content_safety_marker in chat_api_source, 'Expected content safety thought logging in chat_api.' - assert chat_api_source.find(shared_init_marker) < chat_api_source.find(content_safety_marker), ( - 'Expected assistant tracking initialization before content safety uses thought_tracker.' + assert tracked_thought_marker in chat_api_source, 'Expected tracked thoughts in chat_api.' + assert chat_api_source.find(shared_init_marker) < chat_api_source.find(tracked_thought_marker), ( + 'Expected assistant tracking initialization before any thought uses thought_tracker.' ) stream_source = route_source[chat_stream_index:] @@ -91,7 +91,7 @@ def test_version_and_fix_documentation_alignment(): if __name__ == '__main__': tests = [ - test_retry_and_edit_paths_initialize_thought_tracker_before_content_safety, + test_retry_and_edit_paths_initialize_thought_tracker_before_first_use, test_version_and_fix_documentation_alignment, ] diff --git a/functional_tests/test_orchestration_message_regeneration.py b/functional_tests/test_orchestration_message_regeneration.py new file mode 100644 index 000000000..37ab2c77e --- /dev/null +++ b/functional_tests/test_orchestration_message_regeneration.py @@ -0,0 +1,499 @@ +# test_orchestration_message_regeneration.py +""" +Functional coverage for fresh, review-required orchestration message generations. +Version: 0.261.317 +Implemented in: 0.261.317 + +The real planning, revision, execution, and retry state machines run against the +existing offline HTTP harness. Only storage, catalog, and provider boundaries are +replaced; no Azure or model service is called. +""" + +from copy import deepcopy +import importlib +import json +from types import SimpleNamespace +import uuid + +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_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 + + +QUESTION = 'Write a short original explanation.' + + +@pytest.fixture(scope='module') +def modules(): + with offline_app_imports(): + importlib.import_module('route_backend_chats') + environment = harness_modules.__wrapped__() + try: + yield next(environment) + finally: + with pytest.raises(StopIteration): + next(environment) + + +def frames(response): + return [ + json.loads(frame.partition('data:')[2].strip()) + for frame in response.get_data(as_text=True).split('\n\n') + if frame.startswith('data:') and frame.partition('data:')[2].strip() != '[DONE]' + ] + +def planning_replies(*, answered=False): + return ([json.dumps(resolution(QUESTION))] if answered else []) + [ + json.dumps({'kind': 'plan', 'steps': [compose_step()], 'final_response': input_binding('prepare')}), + ] + + +@pytest.fixture +def context(real_http_harness, modules, monkeypatch): + harness = real_http_harness.harness + harness.settings['enable_user_workspace'] = False + harness.conversations.create_item({'id': 'conv1', 'user_id': 'owner'}) + case = SimpleNamespace( + harness=harness, client=real_http_harness.client, route=modules.route, + settings=harness.settings, messages=harness.messages, runs=harness.runs, + conversations=harness.conversations, model=SimpleNamespace(calls=harness.model_calls), + ) + conversations = importlib.import_module('route_backend_conversations') + monkeypatch.setattr(modules.auth, 'get_user_profile_image', lambda: None) + monkeypatch.setattr(conversations, 'get_user_settings', lambda *args, **kwargs: {'settings': {}}) + monkeypatch.setattr(conversations, 'build_chat_model_catalog', lambda **kwargs: [{ + 'deployment_name': 'gpt-4o', 'provider': 'aoai', + }]) + monkeypatch.setattr(conversations, 'get_user_groups', lambda *args: []) + monkeypatch.setattr(conversations, 'ensure_governance_access', lambda *args, **kwargs: None) + query = case.messages.query_items + + def bounded_query(query, parameters=None, **kwargs): + rows = original_query(query, parameters=parameters, **kwargs) + params = {item['name']: item['value'] for item in parameters or []} + if '@message_id' in params: + rows = [row for row in rows if row['id'] == params['@message_id']] + if '@thread_id' in params: + rows = [ + row for row in rows + if (row.get('metadata') or {}).get('thread_info', {}).get('thread_id') == params['@thread_id'] + ] + if "c.role = 'user'" in query: + rows = [row for row in rows if row.get('role') == 'user'] + anchor = params.get('@anchor') + if anchor: + rows = [ + row for row in rows + if row.get('timestamp', '') < anchor + or (row.get('metadata') or {}).get('thread_info', {}).get('root_timestamp', anchor) < anchor + ] + return rows + + original_query = query + monkeypatch.setattr(case.messages, 'query_items', bounded_query) + return case + + +def source_run(context, *, execute=True, **selection): + if not selection: + selection = {'model_deployment': 'gpt-4o'} + context.harness.replies = planning_replies() + response = context.client.post('/api/v2/orchestration/plan', json={ + 'conversation_id': 'conv1', 'turn_id': 'source-turn', + 'message': QUESTION, 'approval_mode': 'manual', **selection, + }, buffered=True) + events = frames(response) + assert response.status_code == 200 and not any(event.get('error') for event in events), events + plan = next(event['plan'] for event in events if event.get('plan')) + if execute: + context.harness.replies = ['The original complete answer.'] + response = context.client.post('/api/v2/orchestration/run', json={ + 'conversation_id': 'conv1', 'run_id': plan['run_id'], 'plan_id': plan['plan_id'], + }, buffered=True) + events = frames(response) + assert not any(event.get('error') for event in events), events + return context.runs.read_item(plan['run_id'], 'conv1') + + +def prepare(context, source, *, submission_id=None, **fields): + response = context.client.post( + f"/api/v2/orchestration/messages/{source}/regenerate", + json={'submission_id': submission_id or str(uuid.uuid4()), **fields}, + ) + return response, response.get_json() + + +def plan_again(context, prepared, **overrides): + context.harness.replies = planning_replies(answered=bool( + prepared['user_message']['metadata'].get('orchestration_clarification_answers'), + )) + response = context.client.post( + '/api/v2/orchestration/plan', json={**prepared['plan_request'], **overrides}, buffered=True, + ) + return response, frames(response) + + +def test_deleted_answer_regenerates_from_surviving_owned_question(context): + original = source_run(context) + context.messages.delete_item( + original['assistant_message_id'], 'conv1', + etag=context.messages.read_item(original['assistant_message_id'], 'conv1')['_etag'], + ) + response, prepared = prepare(context, original['user_message_id']) + assert response.status_code == 200, prepared + assert prepared['turn_id'] != original['turn_id'] + assert prepared['new_attempt'] == 2 + assert prepared['user_message']['content'] == original['user_message'] + response, events = plan_again(context, prepared) + assert response.status_code == 200, events + assert not any(event.get('error') for event in events), events + plan = next(event['plan'] for event in events if event.get('plan')) + assert plan['run_id'] != original['run_id'] + saved = context.runs.read_item(plan['run_id'], 'conv1') + assert saved['regeneration_of_run_id'] == original['run_id'] + assert 'retry_of_run_id' not in saved + assert 'execution_lease' not in saved + assert not saved.get('started_at') + assert saved['user_message_id'] == prepared['user_message_id'] + assert saved['requires_fresh_review'] is True + assert saved['status'] == 'awaiting_approval' + assert context.messages.read_item(prepared['user_message_id'], 'conv1')['metadata']['response_attempt']['state'] == 'awaiting_review' + + +def test_regeneration_forces_review_despite_auto_and_disabled_approval_override(context): + original = source_run(context) + context.settings.update({ + 'chat_orchestration_approval_mode': 'auto', + 'chat_orchestration_allow_user_approval_override': False, + }) + response, prepared = prepare(context, original['assistant_message_id']) + assert response.status_code == 200, prepared + _, events = plan_again(context, prepared, approval_mode='auto') + plan = next(event['plan'] for event in events if event.get('plan')) + assert plan['approval']['mode'] == 'manual' + assert plan['approval']['state'] == 'pending' + assert plan['status'] == 'awaiting_approval' + refused = context.client.post('/api/v2/orchestration/run', json={ + 'conversation_id': 'conv1', 'run_id': plan['run_id'], 'plan_id': plan['plan_id'], + }) + 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'], + 'reviewed_regeneration': True, + }, buffered=True) + events = frames(approved) + assert not any(event.get('error') for event in events), events + saved = context.runs.read_item(plan['run_id'], 'conv1') + answer = context.messages.read_item(saved['assistant_message_id'], 'conv1') + assert answer['metadata']['thread_info']['thread_attempt'] == 2 + assert answer['metadata']['thread_info']['thread_id'] == prepared['thread_id'] + assert context.messages.read_item(prepared['user_message_id'], 'conv1')['metadata']['response_attempt']['state'] == 'completed' + + +def test_duplicate_preparation_and_invocation_cannot_create_extra_generations(context): + original = source_run(context) + submission = str(uuid.uuid4()) + first_response, first = prepare(context, original['user_message_id'], submission_id=submission) + second_response, second = prepare(context, original['user_message_id'], submission_id=submission) + assert first_response.status_code == second_response.status_code == 200 + assert second['user_message_id'] == first['user_message_id'] + assert second['new_attempt'] == first['new_attempt'] == 2 + _, events = plan_again(context, first) + assert not any(event.get('error') for event in events), events + calls = len(context.model.calls) + refused, _ = plan_again(context, first) + assert refused.status_code == 409 + assert refused.get_json()['code'] == 'retry_already_submitted' + assert len(context.model.calls) == calls + assert context.messages.read_item(first['user_message_id'], 'conv1')['metadata']['response_attempt']['state'] == 'awaiting_review' + + +@pytest.mark.parametrize('status', ['running', 'waiting']) +def test_active_execution_cannot_be_regenerated(context, status): + original = source_run(context, execute=False) + context.runs.upsert_item({**original, 'status': status, 'started_at': '2026-01-01T00:00:00Z'}) + before = len(context.model.calls) + response, payload = prepare(context, original['user_message_id']) + assert response.status_code == 409 + assert payload['code'] == 'orchestration_regeneration_busy' + assert len(context.model.calls) == before + question = context.messages.read_item(original['user_message_id'], 'conv1') + assert 'thread_info' not in question['metadata'] + + +def test_running_recovery_descendant_is_not_competed_with(context): + original = source_run(context) + child = { + **original, 'id': 'current-recovery', 'run_id': 'current-recovery', + 'retry_of_run_id': original['run_id'], 'status': 'running', + } + context.runs.upsert_item(child) + context.runs.upsert_item({**original, 'latest_attempt_run_id': child['run_id']}) + response, payload = prepare(context, original['assistant_message_id']) + assert response.status_code == 409 + assert payload['code'] == 'orchestration_regeneration_busy' + + +def test_later_turns_and_old_execution_outputs_are_not_planning_context(context): + original = source_run(context) + for role in ('user', 'assistant'): + context.messages.upsert_item({ + 'id': f'later-{role}', 'conversation_id': 'conv1', 'role': role, + 'content': 'LATER TURN MUST NOT APPEAR', 'timestamp': '2999-01-01T00:00:00Z', 'metadata': {}, + }) + response, prepared = prepare(context, original['user_message_id']) + assert response.status_code == 200, prepared + before = len(context.model.calls) + _, events = plan_again(context, prepared) + assert not any(event.get('error') for event in events), events + plan = next(event['plan'] for event in events if event.get('plan')) + saved = context.runs.read_item(plan['run_id'], 'conv1') + ids = {row['id'] for row in saved['conversation_context']['messages']} + assert original['user_message_id'] not in ids + assert original['assistant_message_id'] not in ids + assert not ids.intersection({'later-user', 'later-assistant'}) + assert saved['conversation_context']['logical_order'] is True + request_text = json.dumps(context.model.calls[before:]) + assert 'LATER TURN MUST NOT APPEAR' not in request_text + assert not saved['result_aliases'] + + +def test_saved_auto_routing_drops_planners_resolved_pinned_identity(context): + original = source_run(context, execute=False) + original['seeds'].update({'model_routing': 'auto', 'model': deepcopy(TERRA_SELECTION)}) + body = context.route._regeneration_request(original) + seeds = context.route.resolve_seeds(body) + assert seeds['model_routing'] == 'auto' + assert not seeds.get('model') + assert 'model_endpoint_id' not in body + + +def test_changed_inputs_are_refused_before_planning(context): + original = source_run(context) + response, prepared = prepare(context, original['user_message_id']) + assert response.status_code == 200, prepared + changed = context.runs.read_item(original['run_id'], 'conv1') + changed['seeds']['tags'] = ['changed-after-preparation'] + context.runs.upsert_item(changed) + before = len(context.model.calls) + refused, _ = plan_again(context, prepared) + assert refused.status_code == 409 + assert refused.get_json()['code'] == 'retry_source_changed' + assert len(context.model.calls) == before + question = context.messages.read_item(prepared['user_message_id'], 'conv1') + assert question['metadata']['response_attempt']['state'] == 'failed' + + +def test_browser_changes_do_not_replace_saved_planning_inputs(context): + original = source_run(context) + response, prepared = prepare(context, original['user_message_id']) + assert response.status_code == 200, prepared + _, events = plan_again( + context, prepared, message='INJECTED WORDING', model_deployment='unavailable', + model_endpoint_id='unavailable', model_id='unavailable', model_provider='openai', + approval_mode='auto', + ) + assert not any(event.get('error') for event in events), events + plan = next(event['plan'] for event in events if event.get('plan')) + saved = context.runs.read_item(plan['run_id'], 'conv1') + assert saved['user_message'] == original['user_message'] + assert saved['seeds']['model']['model_deployment'] == 'gpt-4o' + assert saved['approval']['mode'] == 'manual' + + +@pytest.mark.parametrize('change', ['deleted', 'masked', 'foreign']) +def test_unavailable_questions_do_not_reserve_an_attempt(context, change): + original = source_run(context) + question = context.messages.read_item(original['user_message_id'], 'conv1') + if change == 'deleted': + question['metadata']['is_deleted'] = True + elif change == 'masked': + question['metadata']['masked'] = True + else: + question['metadata']['user_info'] = {'user_id': 'somebody-else'} + context.messages.upsert_item(question) + response, payload = prepare(context, original['user_message_id']) + assert response.status_code in (403, 404, 409), payload + assert not context.conversations.read_item('conv1', 'conv1').get('retry_threads') + + +def test_incomplete_historical_context_returns_deliberate_composer_fallback(context): + original = source_run(context) + original.pop('seeds') + original.pop('original_seeds') + context.runs.upsert_item(original) + response, payload = prepare(context, original['user_message_id']) + assert response.status_code == 409 + assert payload['code'] == 'orchestration_regeneration_context_unavailable' + assert 'composer' in payload['error'] + + +def test_logical_snapshot_can_be_revalidated_after_an_earlier_turn_was_retried(context): + rows = [ + {'id': 'first-q', 'role': 'user', 'content': 'first', 'timestamp': '2026-01-01T00:00:01Z', 'metadata': {}}, + {'id': 'third-q', 'role': 'user', 'content': 'third', 'timestamp': '2026-01-01T00:00:03Z', 'metadata': {}}, + { + 'id': 'second-retry', 'role': 'user', 'content': 'second', + 'timestamp': '2026-01-01T00:00:08Z', + 'metadata': {'retried': True, 'thread_info': {'thread_id': 'second', 'root_timestamp': '2026-01-01T00:00:02Z'}}, + }, + ] + snapshot = context.route.build_conversation_snapshot(rows, logical_order=True) + validated = context.route.validate_conversation_snapshot(snapshot, rows) + assert [row['id'] for row in validated['messages']] == ['first-q', 'second-retry', 'third-q'] + + +def test_lost_preparation_response_and_reloaded_prepared_question_reuse_one_attempt(context): + original = source_run(context) + _, first = prepare(context, original['user_message_id']) + response, recovered = prepare(context, original['user_message_id']) + assert response.status_code == 200, recovered + assert recovered['user_message_id'] == first['user_message_id'] + response, restored = prepare(context, first['user_message_id']) + assert response.status_code == 200, restored + assert restored['plan_request'] == first['plan_request'] + assert restored['available_attempts'] == [1, 2] + _, events = plan_again(context, restored) + assert not any(event.get('error') for event in events), events + + +def test_failed_planning_without_a_run_remains_retryable_from_saved_inputs(context): + original = source_run(context) + _, prepared = prepare(context, original['user_message_id']) + context.harness.replies = [] + failed = context.client.post('/api/v2/orchestration/plan', json=prepared['plan_request'], buffered=True) + assert any(event.get('error') for event in frames(failed)), frames(failed) + question = context.messages.read_item(prepared['user_message_id'], 'conv1') + assert question['metadata']['response_attempt']['state'] == 'failed' + assert context.route.get_latest_turn_run('conv1', 'owner', prepared['turn_id']) is None + response, next_attempt = prepare(context, question['id']) + assert response.status_code == 200, next_attempt + assert next_attempt['new_attempt'] == 3 + assert next_attempt['plan_request']['message'] == question['content'] + _, events = plan_again(context, next_attempt) + assert not any(event.get('error') for event in events), events + + +@pytest.mark.parametrize('failed_continuation', [False, True]) +def test_clarification_preserves_answers_and_manual_review(context, failed_continuation): + original = source_run(context) + context.settings.update({ + 'chat_orchestration_approval_mode': 'auto', + 'chat_orchestration_allow_user_approval_override': False, + }) + _, prepared = prepare(context, original['user_message_id']) + context.harness.replies = [json.dumps(style_question())] + 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) + answer = reply_payload(question, turn_id=prepared['turn_id']) + answer['conversation_id'] = 'conv1' + answer['approval_mode'] = 'auto' + response = context.client.post('/api/v2/orchestration/plan', json=answer, buffered=True) + events = frames(response) + if failed_continuation: + assert any(event.get('error') for event in events), events + response, next_attempt = prepare(context, prepared['user_message_id']) + assert response.status_code == 200, next_attempt + _, events = plan_again(context, next_attempt) + assert not any(event.get('error') for event in events), events + plan = next(event['plan'] for event in events if event.get('plan')) + saved = context.runs.read_item(plan['run_id'], 'conv1') + assert saved['answered_questions'][0]['answer'] == {'style': 'brief'} + assert plan['approval']['mode'] == 'manual' + assert saved['requires_fresh_review'] is True + + +def test_reloaded_prepared_request_refuses_a_new_running_recovery_descendant(context): + original = source_run(context) + _, prepared = prepare(context, original['user_message_id']) + child = {**original, 'id': 'late-recovery', 'run_id': 'late-recovery', 'status': 'waiting'} + context.runs.upsert_item(child) + context.runs.upsert_item({**original, 'latest_attempt_run_id': child['run_id']}) + refused, _ = plan_again(context, prepared) + assert refused.status_code == 409 + assert refused.get_json()['code'] == 'orchestration_regeneration_busy' + + +def test_deliberate_model_override_replaces_saved_auto_routing(context, monkeypatch): + original = source_run(context) + original['seeds']['model_routing'] = 'auto' + context.runs.upsert_item(original) + selection = { + 'model_deployment': 'another-model', 'model_endpoint_id': 'another-endpoint', + 'model_id': 'another-id', 'model_provider': 'aoai', + } + conversations = importlib.import_module('route_backend_conversations') + monkeypatch.setattr(conversations, 'build_chat_model_catalog', lambda **kwargs: [{ + 'deployment_name': 'another-model', 'endpoint_id': 'another-endpoint', + 'model_id': 'another-id', 'provider': 'aoai', + }]) + response, prepared = prepare(context, original['user_message_id'], **selection) + assert response.status_code == 200, prepared + for key, value in selection.items(): + assert prepared['plan_request'][key] == value + assert 'model_routing' not in prepared['plan_request'] + assert 'agent_info' not in prepared['plan_request'] + + +def test_image_generation_does_not_bypass_planning_model_authorization(context, monkeypatch): + original = source_run(context) + original['seeds']['image_generation'] = True + context.runs.upsert_item(original) + conversations = importlib.import_module('route_backend_conversations') + monkeypatch.setattr(conversations, 'build_chat_model_catalog', lambda **kwargs: []) + response, payload = prepare(context, original['user_message_id']) + assert response.status_code == 409, payload + assert payload['code'] == 'retry_model_unavailable' + assert not context.conversations.read_item('conv1', 'conv1').get('retry_threads') + + +@pytest.mark.parametrize('payload', [[], False, 7, {'submission_id': 'not a valid token'}, {'submission_id': True}]) +def test_malformed_preparation_does_not_mutate_the_source(context, payload): + original = source_run(context) + response = context.client.post( + f"/api/v2/orchestration/messages/{original['user_message_id']}/regenerate", json=payload, + ) + assert response.status_code == 400, response.get_json() + assert not context.conversations.read_item('conv1', 'conv1').get('retry_threads') + + +def test_unsaved_planning_retry_review_floor_cannot_be_overridden_by_auto(context): + context.settings.update({ + 'chat_orchestration_approval_mode': 'auto', + 'chat_orchestration_allow_user_approval_override': False, + }) + context.harness.replies = planning_replies() + response = context.client.post('/api/v2/orchestration/plan', json={ + 'conversation_id': 'conv1', 'turn_id': 'unsaved-retry', 'message': QUESTION, + 'model_deployment': 'gpt-4o', 'requires_fresh_review': True, 'approval_mode': 'auto', + }, buffered=True) + events = frames(response) + assert not any(event.get('error') for event in events), events + plan = next(event['plan'] for event in events if event.get('plan')) + assert plan['approval']['mode'] == 'manual' + saved = context.runs.read_item(plan['run_id'], 'conv1') + assert saved['requires_fresh_review'] is True + assert not saved.get('regeneration_of_run_id') + refused = context.client.post('/api/v2/orchestration/run', json={ + 'conversation_id': 'conv1', 'run_id': plan['run_id'], 'plan_id': plan['plan_id'], + }) + assert refused.status_code == 409 + assert refused.get_json()['code'] == 'fresh_review_required' + context.harness.replies = ['The reviewed planning retry answer.'] + approved = context.client.post('/api/v2/orchestration/run', json={ + 'conversation_id': 'conv1', 'run_id': plan['run_id'], 'plan_id': plan['plan_id'], + 'reviewed_regeneration': True, + }, buffered=True) + events = frames(approved) + assert approved.status_code == 200 and not any(event.get('error') for event in events), events + terminal = next(event for event in events if event.get('done')) + assert terminal['status'] == 'completed', terminal + assert terminal['full_content'] == 'The reviewed planning retry answer.' diff --git a/functional_tests/test_v2_model_identity_and_scope.py b/functional_tests/test_v2_model_identity_and_scope.py index ae4bef2f7..7a6ff4b63 100644 --- a/functional_tests/test_v2_model_identity_and_scope.py +++ b/functional_tests/test_v2_model_identity_and_scope.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Functional test for V2 model identity and document search scope. -Version: 0.261.016 +Version: 0.261.317 Implemented in: 0.261.016 Document search failed in the V2 chat page with "Something went wrong while streaming the @@ -125,10 +125,10 @@ def test_model_picker_keys_on_selection_key(): """A deployment name can repeat across endpoints; selection_key cannot.""" print("Testing model picker keying...") try: - catalog = read(APP, "route_frontend_chats.py") + catalog = read(APP, "functions_chat_model_catalog.py") # selection_key is scope:scopeId:endpointId:modelId. assert ( - "selection_key = f\"{scope_type}:{scope_id or ''}:{endpoint_id}:{model_id or deployment_name}\"" + "selection_key = f\"{scope_type}:{scope_id or ''}:{endpoint_id}:{model_id or deployment_name or request_model}\"" in catalog ), "selection_key is what makes a catalog entry unique" @@ -227,9 +227,9 @@ def test_retry_resolves_the_model_the_same_way(): "Retry must resolve the deployment name from the catalog, since the option " "value is a selection key rather than a model name" ) - assert "model: selection.model_deployment" in retry, ( - "The retry endpoint takes a flat deployment name, which the selection rule " - "has already resolved" + assert "retryMessageApi(messageId, selection)" in retry, ( + "Retry must send the complete, explicitly selected model identity, not a " + "flattened deployment name" ) assert "model: options?.modelDeployment" not in retry, ( "Sending the selection key as the model name would not resolve" diff --git a/ui_tests/test_v2_chat_retry.py b/ui_tests/test_v2_chat_retry.py new file mode 100644 index 000000000..126a2e2d7 --- /dev/null +++ b/ui_tests/test_v2_chat_retry.py @@ -0,0 +1,443 @@ +# test_v2_chat_retry.py +""" +Browser coverage for saved model, agent, and orchestration retry attempts. +Version: 0.261.317 +Implemented in: 0.261.317 + +Runs the real message list, composer, stores, and controller with production CSS. +Only HTTP and streaming provider responses are replaced. No live service is called. +""" + +import copy +import json +import re +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import expect + +import test_v2_orchestration_plan_editor as editor_tests +from test_v2_orchestration_plan_editor import connect_options, editor_assets, editor_browser # noqa: F401 +from test_v2_orchestration_planning_retry import CONVERSATION, PLAN, PlanningApi, mount + + +pytestmark = pytest.mark.ui +THREAD = "saved-retry-thread" +FAILURE = "This retry's provider could not complete." +WORDING = "Use the wording of this viewed edited attempt." +MODEL = { + "model_deployment": "original-model", "model_id": "original-model-id", + "model_endpoint_id": "original-endpoint", "model_provider": "aoai", +} + + +def message(identifier, role, content, attempt=1, *, kind="chat", state=None): + metadata = { + "thread_info": { + "thread_id": THREAD, "thread_attempt": attempt, "active_thread": True, + "root_timestamp": "2026-01-01T00:00:01Z", + }, + } + if role == "user": + metadata["model_selection"] = {"selected_model": MODEL["model_deployment"], **MODEL} + if state: + metadata["response_attempt"] = {"kind": kind, "state": state} + if kind == "orchestration": + metadata["orchestration"] = {"turn_id": f"saved-turn-{attempt}"} + return { + "id": identifier, "conversation_id": CONVERSATION, "role": role, + "content": content, "timestamp": "2026-01-01T00:00:01Z", "metadata": metadata, + } + + +class RetryApi(PlanningApi): + def __init__(self, assets): + super().__init__(assets) + self.kind = "chat" + self.prepare_mode = "ready" + self.stream_mode = "success" + self.held_preparations = [] + self.selected = 1 + self.attempts = {1: [ + message("question-1", "user", WORDING), + message("answer-1", "assistant", "The previous successful answer."), + ]} + self.later = [ + {"id": "later-question", "conversation_id": CONVERSATION, "role": "user", "content": "A later question."}, + {"id": "later-answer", "conversation_id": CONVERSATION, "role": "assistant", "content": "A later answer."}, + ] + self.mode = "plan" + self.pending_stream = False + + def rows(self): + return copy.deepcopy(self.attempts[self.selected] + self.later) + + def prepared(self): + if 2 not in self.attempts: + question = message("question-2", "user", WORDING, 2, kind=self.kind, state="prepared") + if self.attempts[1][0]["metadata"].get("agent_selection"): + question["metadata"]["agent_selection"] = copy.deepcopy(self.attempts[1][0]["metadata"]["agent_selection"]) + self.attempts[2] = [question] + question = self.attempts[2][0] + self.selected = 2 + body = { + "conversation_id": CONVERSATION, "message": question["content"], **MODEL, + "retry_user_message_id": question["id"], "retry_thread_id": THREAD, "retry_thread_attempt": 2, + "web_search_enabled": True, "selected_document_ids": ["original-document"], + } + if question["metadata"].get("agent_selection"): + for key in MODEL: + body.pop(key) + body["agent_info"] = {"id": "agent-original", "name": "original-agent", "is_global": True} + payload = { + "success": True, "user_message": copy.deepcopy(question), "user_message_id": question["id"], + "thread_id": THREAD, "new_attempt": 2, "available_attempts": sorted(self.attempts), + "attempt_state": "prepared", + } + if self.kind == "orchestration": + payload.update({ + "turn_id": "saved-turn-2", "requires_fresh_review": True, + "plan_request": {**body, "turn_id": "saved-turn-2", "approval_mode": "manual"}, + }) + else: + payload["chat_request"] = body + return payload + + def release_preparations(self): + pending, self.held_preparations = self.held_preparations, [] + for route in pending: + route.fulfill(json=self.prepared()) + + def handle(self, route): + request = route.request + path = urlsplit(request.url).path + preparation = path.endswith("/retry") or path.endswith("/regenerate") + if preparation and (path.startswith("/api/message/") or "/orchestration/messages/" in path): + self.requests.append({"path": path, "method": request.method, "body": request.post_data_json}) + if self.prepare_mode == "held": + self.held_preparations.append(route) + elif self.prepare_mode == "refuse": + route.fulfill(status=403, json={"error": "The original model is no longer available."}) + else: + route.fulfill(json=self.prepared()) + return + if path == "/api/get_messages": + route.fulfill(json={"messages": self.rows()}) + return + if path.endswith("/switch-attempt"): + self.requests.append({"path": path, "method": request.method, "body": request.post_data_json}) + self.selected = 1 if request.post_data_json["direction"] == "prev" else max(self.attempts) + route.fulfill(json={ + "success": True, "target_attempt": self.selected, "available_attempts": sorted(self.attempts), + }) + return + if path.startswith("/api/chat/stream/status/"): + route.fulfill(json={ + "pending": self.pending_stream, "active": self.pending_stream, "user_message_id": "question-2", + }) + return + if path.startswith("/api/chat/stream/cancel/"): + self.requests.append({"path": path, "method": request.method, "body": None}) + self.attempts[2][0]["metadata"]["response_attempt"]["state"] = "interrupted" + route.fulfill(json={"success": True}) + return + if path == "/api/chat/stream" or path.startswith("/api/chat/stream/reattach/"): + self.requests.append({"path": path, "method": request.method, + "body": request.post_data_json if request.post_data else None}) + question = self.attempts[2][0] + if self.stream_mode == "failure": + question["metadata"]["response_attempt"].update({"state": "failed", "error": FAILURE}) + event = {"error": FAILURE, "done": True, "conversation_id": CONVERSATION} + elif self.stream_mode == "blocked": + question["metadata"]["response_attempt"]["state"] = "failed" + reply = message("answer-2", "safety", "The content check blocked this response.", 2) + self.attempts[2].append(reply) + event = { + "done": True, "blocked": True, "replace_content": True, + "conversation_id": CONVERSATION, "user_message_id": question["id"], + "message_id": reply["id"], "full_content": reply["content"], + "metadata": reply["metadata"], + } + else: + question["metadata"]["response_attempt"]["state"] = "completed" + reply = message("answer-2", "assistant", "The regenerated answer.", 2) + self.attempts[2].append(reply) + event = { + "done": True, "conversation_id": CONVERSATION, "user_message_id": question["id"], + "message_id": reply["id"], "full_content": reply["content"], + "metadata": reply["metadata"], + } + self.pending_stream = False + route.fulfill(content_type="text/event-stream", body=f"data: {json.dumps(event)}\n\n") + return + if path == PLAN: + self.requests.append({"path": path, "method": request.method, "body": request.post_data_json}) + plan = editor_tests.make_plan(CONVERSATION, "saved-turn-2", "auto") + plan["requires_fresh_review"] = True + self.attempts[2][0]["metadata"]["response_attempt"]["state"] = "awaiting_review" + event = {"type": "orchestration_plan", "plan": plan, "done": True} + route.fulfill(content_type="text/event-stream", body=f"data: {json.dumps(event)}\n\n") + return + if path == editor_tests.RUN: + self.requests.append({"path": path, "method": request.method, "body": request.post_data_json}) + self.attempts[2][0]["metadata"]["response_attempt"]["state"] = "completed" + event = { + "done": True, "status": "completed", "conversation_id": CONVERSATION, + "turn_id": "saved-turn-2", "run_id": request.post_data_json["run_id"], + "user_message_id": "question-2", "message_id": "answer-2", "full_content": "A fresh orchestrated answer.", + "metadata": {"thread_info": {"thread_id": THREAD, "thread_attempt": 2, "active_thread": True}}, + } + route.fulfill(content_type="text/event-stream", body=f"data: {json.dumps(event)}\n\n") + return + if path.startswith("/api/conversations/") and path.endswith("/read"): + route.fulfill(json={"success": True}) + return + super().handle(route) + + +@pytest.fixture +def retry_ui(editor_browser, editor_assets): + context = editor_browser.new_context(viewport={"width": 1280, "height": 1100}) + page = context.new_page() + api = RetryApi(editor_assets) + page.route("**/*", api.handle) + page.on("pageerror", lambda error: api.errors.append(str(error))) + try: + yield page, api + finally: + api.release_preparations() + api.release() + context.close() + assert not api.errors, api.errors + + +def start(page, api): + mount(page, api, api.rows()) + page.evaluate("""({thread, attempts}) => window.OrchHarness.stores.chat.useChatStore.setState({ + attemptsByThread: {[thread]: attempts}, retryPresentation: null, + })""", {"thread": THREAD, "attempts": sorted(api.attempts)}) + + +def chat_state(page): + return page.evaluate("""() => { + const S = window.OrchHarness.stores.chat.useChatStore.getState(); + return {messages: S.messages, streaming: S.streaming, error: S.streamError, + retry: S.retryPresentation, attempts: S.attemptsByThread}; + }""") + + +@pytest.mark.parametrize("agent", [False, True]) +def test_retry_hides_answer_immediately_and_reuses_saved_selections_in_place(retry_ui, agent): + page, api = retry_ui + if agent: + api.attempts[1][0]["metadata"]["agent_selection"] = { + "selected_agent": "original-agent", "agent_id": "agent-original", "is_global": True, + } + api.prepare_mode = "held" + start(page, api) + page.get_by_role("button", name="Retry", exact=True).first.click() + expect(page.get_by_text("The previous successful answer.", exact=True)).not_to_be_visible() + expect(page.get_by_text(re.compile("Preparing retry"))).to_be_visible() + assert chat_state(page)["attempts"][THREAD] == sorted(api.attempts) + page.evaluate("""() => { + void window.OrchHarness.stores.chat.useChatStore.getState().retryMessage('question-1'); + }""") + assert len(api.held_preparations) == 1 + api.release_preparations() + expect(page.get_by_text("The regenerated answer.", exact=True)).to_be_visible() + state = chat_state(page) + assert [row["id"] for row in state["messages"]] == ["question-2", "answer-2", "later-question", "later-answer"] + request = api.calls("/api/chat/stream")[0]["body"] + assert request["message"] == WORDING + assert request["retry_user_message_id"] == "question-2" + if agent: + assert request["agent_info"]["id"] == "agent-original" + else: + for key, value in MODEL.items(): + assert request[key] == value + assert request["web_search_enabled"] is True + assert request["selected_document_ids"] == ["original-document"] + + +def test_refused_preparation_restores_old_answer_without_a_phantom_attempt(retry_ui): + page, api = retry_ui + api.prepare_mode = "refuse" + start(page, api) + page.get_by_role("button", name="Retry", exact=True).first.click() + expect(page.get_by_text("The original model is no longer available.", exact=True)).to_be_visible() + expect(page.get_by_text("The previous successful answer.", exact=True)).to_be_visible() + state = chat_state(page) + assert [row["id"] for row in state["messages"]][:2] == ["question-1", "answer-1"] + assert state["attempts"][THREAD] == [1] + assert not api.calls("/api/chat/stream") + + +def test_failed_attempt_error_follows_carousel_and_survives_reload(retry_ui): + page, api = retry_ui + api.stream_mode = "failure" + start(page, api) + page.get_by_role("button", name="Retry", exact=True).first.click() + expect(page.get_by_text(FAILURE, exact=True)).to_be_visible() + expect(page.get_by_text("The previous successful answer.", exact=True)).not_to_be_visible() + page.get_by_role("button", name="Previous attempt", exact=True).first.click() + expect(page.get_by_text("The previous successful answer.", exact=True)).to_be_visible() + expect(page.get_by_text(FAILURE, exact=True)).not_to_be_visible() + page.get_by_role("button", name="Next attempt", exact=True).first.click() + expect(page.get_by_text(FAILURE, exact=True)).to_be_visible() + start(page, api) + expect(page.get_by_text(FAILURE, exact=True)).to_be_visible() + assert chat_state(page)["error"] is None + + +def test_blocked_retry_keeps_safety_notice_and_failed_state_on_its_own_attempt(retry_ui): + page, api = retry_ui + api.stream_mode = "blocked" + start(page, api) + page.get_by_role("button", name="Retry", exact=True).first.click() + notice = page.get_by_text("The content check blocked this response.", exact=True) + expect(notice).to_be_visible() + state = chat_state(page) + assert state["messages"][0]["metadata"]["response_attempt"]["state"] == "failed" + assert state["messages"][1]["role"] == "safety" + expect(page.get_by_text("This attempt could not complete. You can retry this question.", exact=True)).not_to_be_visible() + page.get_by_role("button", name="Previous attempt", exact=True).first.click() + expect(page.get_by_text("The previous successful answer.", exact=True)).to_be_visible() + expect(notice).not_to_be_visible() + page.get_by_role("button", name="Next attempt", exact=True).first.click() + expect(notice).to_be_visible() + start(page, api) + expect(notice).to_be_visible() + assert len(api.calls("/api/chat/stream")) == 1 + + +@pytest.mark.parametrize("deleted_answer", [False, True]) +def test_orchestration_retry_makes_a_fresh_manual_plan_even_when_auto_is_selected(retry_ui, deleted_answer): + page, api = retry_ui + api.kind = "orchestration" + api.attempts[1][0] = message("question-1", "user", WORDING, kind="orchestration") + if deleted_answer: + api.attempts[1] = api.attempts[1][:1] + start(page, api) + page.get_by_role("button", name="Retry", exact=True).first.click() + approve = page.get_by_role("button", name="Approve and run the plan").first + expect(approve).to_be_visible() + expect(page.get_by_text("Fresh plan: review before running. Earlier completed actions are not undone and may be repeated.")).to_be_visible() + assert len(api.calls(PLAN)) == 1 + assert not api.calls(editor_tests.RUN) + assert not api.calls("/api/chat/stream") + state = chat_state(page) + assert [row["id"] for row in state["messages"]] == ["question-2", "later-question", "later-answer"] + assert state["messages"][0]["metadata"]["response_attempt"]["state"] == "awaiting_review" + approve.click() + expect(page.get_by_text("A fresh orchestrated answer.", exact=True)).to_be_visible() + assert api.calls(editor_tests.RUN)[0]["body"]["reviewed_regeneration"] is True + assert [row["id"] for row in chat_state(page)["messages"]][:2] == ["question-2", "answer-2"] + + +def test_stop_before_preparation_ignores_late_response_and_reconciles_next_retry(retry_ui): + page, api = retry_ui + api.prepare_mode = "held" + start(page, api) + page.get_by_role("button", name="Retry", exact=True).first.click() + expect(page.get_by_text(re.compile("Preparing retry"))).to_be_visible() + page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().stopStreaming()") + expect(page.get_by_text("The previous successful answer.", exact=True)).to_be_visible() + api.release_preparations() + page.wait_for_function("() => !window.OrchHarness.stores.chat.useChatStore.getState().streaming") + assert not api.calls("/api/chat/stream") + api.prepare_mode = "ready" + page.get_by_role("button", name="Retry", exact=True).first.click() + expect(page.get_by_text("The regenerated answer.", exact=True)).to_be_visible() + assert len(api.attempts) == 2 + assert len(api.calls("/api/chat/stream")) == 1 + + +def test_fresh_plan_card_follows_its_selected_orchestration_attempt(retry_ui): + page, api = retry_ui + api.kind = "orchestration" + api.attempts[1][0] = message("question-1", "user", WORDING, kind="orchestration") + start(page, api) + page.get_by_role("button", name="Retry", exact=True).first.click() + approve = page.get_by_role("button", name="Approve and run the plan") + expect(approve).to_be_visible() + page.get_by_role("button", name="Previous attempt", exact=True).first.click() + expect(page.get_by_text("The previous successful answer.", exact=True)).to_be_visible() + expect(approve).not_to_be_visible() + page.get_by_role("button", name="Next attempt", exact=True).first.click() + expect(approve).to_be_visible() + assert len(api.calls(PLAN)) == 1 + assert not api.calls(editor_tests.RUN) + + +def test_reload_reattaches_earlier_running_retry_to_its_question(retry_ui): + page, api = retry_ui + api.prepared() + api.attempts[2][0]["metadata"]["response_attempt"]["state"] = "running" + api.pending_stream = True + start(page, api) + page.evaluate("(conversation) => window.OrchHarness.stores.chat.useChatStore.getState().selectConversation(conversation)", + CONVERSATION) + expect(page.get_by_text("The regenerated answer.", exact=True)).to_be_visible() + state = chat_state(page) + assert [row["id"] for row in state["messages"]] == ["question-2", "answer-2", "later-question", "later-answer"] + assert state["retry"]["userMessageId"] == "question-2" + assert not api.calls("/api/chat/stream") + + +def test_stop_keeps_partial_native_output_with_correct_attempt(retry_ui): + page, api = retry_ui + start(page, api) + page.evaluate("""() => { + const realFetch = window.fetch.bind(window); + window.fetch = (input, options) => { + if (new URL(typeof input === 'string' ? input : input.url, location.href).pathname !== '/api/chat/stream') { + return realFetch(input, options); + } + return Promise.resolve(new Response(new ReadableStream({start(controller) { + controller.enqueue(new TextEncoder().encode('data: {"content":"Partial retry output."}\\n\\n')); + options.signal.addEventListener('abort', () => controller.error(new DOMException('Stopped', 'AbortError'))); + }}), {headers: {'Content-Type': 'text/event-stream'}})); + }; + }""") + page.get_by_role("button", name="Retry", exact=True).first.click() + expect(page.get_by_text("Partial retry output.", exact=True)).to_be_visible() + page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().stopStreaming()") + expect(page.get_by_text("Partial retry output.", exact=True)).to_be_visible() + state = chat_state(page) + assert not state["streaming"] + assert state["messages"][1]["metadata"]["thread_info"]["thread_attempt"] == 2 + assert state["messages"][1]["id"].startswith("cancelled-") + assert state["messages"][0]["metadata"]["response_attempt"]["state"] == "interrupted" + assert [row["id"] for row in state["messages"]][-2:] == ["later-question", "later-answer"] + + +def test_late_preparation_cannot_repopulate_a_new_chat(retry_ui): + page, api = retry_ui + api.prepare_mode = "held" + start(page, api) + page.get_by_role("button", name="Retry", exact=True).first.click() + expect(page.get_by_text(re.compile("Preparing retry"))).to_be_visible() + page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().startNewConversation()") + api.release_preparations() + page.wait_for_function("() => !window.OrchHarness.stores.chat.useChatStore.getState().streaming") + assert chat_state(page)["messages"] == [] + assert chat_state(page)["retry"] is None + assert not api.calls("/api/chat/stream") + + +def test_late_orchestration_callbacks_cannot_settle_a_newer_turn(retry_ui): + page, api = retry_ui + start(page, api) + page.evaluate("""(conversation) => { + const chat = window.OrchHarness.stores.chat.useChatStore.getState(); + chat.beginOrchestrationTurn(conversation, 'Newer planning question', undefined, true, 'current-turn'); + chat.pushOrchestrationThought(conversation, {turn_id: 'earlier-turn', content: 'Stale thought'}); + chat.pushOrchestrationContent(conversation, 'Stale content', 'earlier-turn'); + chat.settleOrchestrationTurn(conversation, {turnId: 'earlier-turn', status: 'failed', error: 'Stale failure'}); + }""", CONVERSATION) + state = chat_state(page) + assert state["streaming"] + assert state["error"] is None + expect(page.get_by_text("Stale thought", exact=True)).not_to_be_visible() + expect(page.get_by_text("Stale content", exact=True)).not_to_be_visible() + expect(page.get_by_text("Stale failure", exact=True)).not_to_be_visible() diff --git a/ui_tests/test_v2_orchestration_planning_retry.py b/ui_tests/test_v2_orchestration_planning_retry.py index 2f92afaef..43bee4472 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.226 +Version: 0.261.317 Implemented in: 0.261.115 Leaving a planning turn for a new chat also releases its Thinking state and Stop: 0.261.226 @@ -98,15 +98,32 @@ def handle(self, route): if "pending-user-" in path: route.fulfill(status=404, json={"error": "Message not found."}) else: - route.fulfill(json={"chat_request": { - "conversation_id": CONVERSATION, "message": "Ordinary persisted message", - }}) + question = { + "id": "ordinary-retry-user", "conversation_id": CONVERSATION, + "role": "user", "content": "Ordinary persisted message", + "metadata": { + "thread_info": {"thread_id": "ordinary-thread", "thread_attempt": 2, "active_thread": True}, + "response_attempt": {"kind": "chat", "state": "prepared"}, + }, + } + self.messages = [question] + route.fulfill(json={ + "user_message": question, "user_message_id": question["id"], + "thread_id": "ordinary-thread", "new_attempt": 2, "available_attempts": [1, 2], + "chat_request": { + "conversation_id": CONVERSATION, "message": question["content"], + "retry_user_message_id": question["id"], "retry_thread_id": "ordinary-thread", + "retry_thread_attempt": 2, + }, + }) return if path == "/api/chat/stream": self.messages.append({ "id": "ordinary-answer", "conversation_id": CONVERSATION, "role": "assistant", "content": "Ordinary retry answer.", + "metadata": {"thread_info": {"thread_id": "ordinary-thread", "thread_attempt": 2, "active_thread": True}}, }) + self.messages[0]["metadata"]["response_attempt"]["state"] = "completed" event = { "done": True, "conversation_id": CONVERSATION, "message_id": "ordinary-answer", "full_content": "Ordinary retry answer.", @@ -228,7 +245,7 @@ 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)[0]["body"] == api.calls(PLAN)[1]["body"] + assert api.calls(PLAN)[1]["body"] == {**api.calls(PLAN)[0]["body"], "requires_fresh_review": True} state = page.evaluate("""() => { const S = window.OrchHarness.stores.chat.useChatStore.getState(); return {messages: S.messages, error: S.streamError, streaming: S.streaming}; @@ -269,7 +286,7 @@ 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)[0]["body"] == api.calls(PLAN)[1]["body"] + assert api.calls(PLAN)[1]["body"] == {**api.calls(PLAN)[0]["body"], "requires_fresh_review": True} assert api.calls(PLAN)[1]["body"]["agent_info"] == seeds["agent_info"] assert "model_deployment" not in api.calls(PLAN)[1]["body"] diff --git a/ui_tests/test_v2_orchestration_recovery.py b/ui_tests/test_v2_orchestration_recovery.py index 29af02586..257cbc6ed 100644 --- a/ui_tests/test_v2_orchestration_recovery.py +++ b/ui_tests/test_v2_orchestration_recovery.py @@ -1,7 +1,7 @@ # test_v2_orchestration_recovery.py """ Real-component browser coverage for orchestration failure and checkpoint recovery. -Version: 0.261.303 +Version: 0.261.317 Implemented in: 0.261.105 Earlier-version run refusals covered in: 0.261.139 Settled runs whose stored plan still reads running covered in: 0.261.141 @@ -369,14 +369,16 @@ def test_failure_persists_in_main_and_drawer_and_confirmed_retry_reuses_progress assert state["messages"][-1]["model_deployment_name"] == "original-model" -def test_reload_keeps_failure_without_automatic_retry_and_message_retry_is_not_plain_chat(recovery_ui): +def test_reload_keeps_failure_without_automatic_retry_and_recovery_is_explicit(recovery_ui): page, api = recovery_ui api.finish(api.plan["run_id"]) api.plan["approval"]["mode"] = "auto" mount_recovery(page, api, saved=True) expect(page.get_by_role("button", name="Retry from failed step").first).to_be_enabled() assert not api.calls("/run") and not api.calls("/retry") - page.get_by_role("button", name="Review orchestration recovery").click(force=True) + expect(page.get_by_role("button", name="Retry", exact=True).first).to_be_enabled() + expect(page.get_by_role("button", name="Review orchestration recovery")).to_have_count(0) + page.get_by_role("button", name="Review saved attempt").first.click(force=True) expect(page.get_by_role("complementary", name="Review drawer")).to_be_visible() assert not any("/api/message/" in request["path"] for request in api.requests) From c609b3742421d635d439bcd5e4e2e865a954946c Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 9 Oct 2026 20:23:41 -0400 Subject: [PATCH 2/2] Fix retry error exposure and bootstrap import boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../functions_chat_content_review.py | 24 +--- .../functions_chat_message_metadata.py | 34 +++++ .../single_app/functions_chat_retry.py | 10 +- .../functions_orchestration_context.py | 2 +- .../single_app/route_backend_conversations.py | 5 +- .../single_app/route_backend_orchestration.py | 4 +- .../single_app/route_frontend_chats.py | 4 - .../fixes/CHAT_MESSAGE_RETRY_FIX.md | 16 ++- .../test_chat_retry_attempt_lifecycle.py | 27 +++- .../test_chat_retry_import_boundaries.py | 116 ++++++++++++++++++ ...test_orchestration_message_regeneration.py | 22 +++- ui_tests/test_v2_chat_retry.py | 7 +- 13 files changed, 236 insertions(+), 37 deletions(-) create mode 100644 application/single_app/functions_chat_message_metadata.py create mode 100644 functional_tests/test_chat_retry_import_boundaries.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 7c1fad809..63d13642f 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.319" +VERSION = "0.261.320" 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_content_review.py b/application/single_app/functions_chat_content_review.py index fb79effca..7cb946b73 100644 --- a/application/single_app/functions_chat_content_review.py +++ b/application/single_app/functions_chat_content_review.py @@ -26,6 +26,7 @@ attach_chat_check, check_chat_content, enabled_chat_scanners, orchestration_input_text, remember_checked_reply, retract_message_content, strip_private_chat_checks, ) +from functions_chat_message_metadata import patch_message_metadata # The messages the unchecked queue holds: allowed through while a required check could not @@ -128,26 +129,9 @@ def persist_chat_reply(container, message): def patch_chat_message_metadata(container, message, fields=("thread_info",)): """Apply only intended metadata edits to a fresh body, preserving check decisions.""" - incoming = message.get("metadata") or {} - for _attempt in range(3): - current = container.read_item(item=message["id"], partition_key=message["conversation_id"]) - updated = deepcopy(current) - metadata = updated.setdefault("metadata", {}) - for key in fields: - if key not in incoming: - continue - if key == "thread_info": - metadata.setdefault("thread_info", {}).update(deepcopy(incoming[key])) - else: - metadata[key] = deepcopy(incoming[key]) - try: - return container.replace_item( - item=current["id"], body=updated, etag=current["_etag"], - match_condition=MatchConditions.IfNotModified, - ) - except CosmosAccessConditionFailedError: - continue - raise ChatContentReviewConflict() + return patch_message_metadata( + container, message, fields, conflict_error=ChatContentReviewConflict, + ) def refresh_checked_message(message, *, source=None, stores=None): diff --git a/application/single_app/functions_chat_message_metadata.py b/application/single_app/functions_chat_message_metadata.py new file mode 100644 index 000000000..419995d28 --- /dev/null +++ b/application/single_app/functions_chat_message_metadata.py @@ -0,0 +1,34 @@ +# functions_chat_message_metadata.py +"""Conditional message metadata writes with caller-owned conflict handling. + +This storage primitive does not import content checks, settings, or bootstrap +owners. Callers supply their domain exception factory for exhausted conflicts. +""" + +from copy import deepcopy + +from azure.core import MatchConditions +from azure.cosmos.exceptions import CosmosAccessConditionFailedError + + +def patch_message_metadata(container, message, fields=("thread_info",), *, conflict_error): + incoming = message.get("metadata") or {} + for _attempt in range(3): + current = container.read_item(item=message["id"], partition_key=message["conversation_id"]) + updated = deepcopy(current) + metadata = updated.setdefault("metadata", {}) + for key in fields: + if key not in incoming: + continue + if key == "thread_info": + metadata.setdefault("thread_info", {}).update(deepcopy(incoming[key])) + else: + metadata[key] = deepcopy(incoming[key]) + try: + return container.replace_item( + item=current["id"], body=updated, etag=current["_etag"], + match_condition=MatchConditions.IfNotModified, + ) + except CosmosAccessConditionFailedError: + continue + raise conflict_error() diff --git a/application/single_app/functions_chat_retry.py b/application/single_app/functions_chat_retry.py index f79759bac..ef9430e47 100644 --- a/application/single_app/functions_chat_retry.py +++ b/application/single_app/functions_chat_retry.py @@ -14,7 +14,7 @@ from collaboration_models import is_shared_conversation_backing from functions_assist_submissions import normalize_submission_id -from functions_chat_content_review import patch_chat_message_metadata +from functions_chat_message_metadata import patch_message_metadata from functions_message_deletion import is_soft_deleted_message @@ -422,7 +422,13 @@ def _publish_retry_attempt(messages, conversations, question): if message['id'] == question['id']: continue message.setdefault('metadata', {}).setdefault('thread_info', {})['active_thread'] = False - patch_chat_message_metadata(messages, message) + patch_message_metadata( + messages, message, + conflict_error=lambda: ChatRetryError( + 'The message changed during preparation. Reload before retrying.', + code='retry_attempt_changed', + ), + ) for _attempt in range(3): conversation = conversations.read_item(item=conversation_id, partition_key=conversation_id) updated = deepcopy(conversation) diff --git a/application/single_app/functions_orchestration_context.py b/application/single_app/functions_orchestration_context.py index 439b5f224..243645bca 100644 --- a/application/single_app/functions_orchestration_context.py +++ b/application/single_app/functions_orchestration_context.py @@ -1863,7 +1863,7 @@ def build_conversation_snapshot( rows = list(messages or ()) superseded = superseded_orchestration_run_ids(rows) if logical_order: - # Retry ordering depends on initialized application-owned message storage. + # Ordering is needed only for retry snapshots; the helper has no bootstrap dependencies. from functions_chat_retry import order_retry_messages rows = order_retry_messages([message for message in rows if isinstance(message, dict)]) diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 9e522ff82..15ff7fd45 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -530,9 +530,10 @@ def _prepare_message_attempt_response(message_id, *, edited=False): log_event('[CHAT_RETRY] Retry refused.', extra={'code': error.code, 'user_id': user_id}, level=logging.INFO) return jsonify({'error': error.public_message, 'code': error.code}), error.status_code except (SubmissionIdError, ModelCatalogError) as error: - _refuse_prepared_message_attempt(prepared_question, str(error), 'invalid_retry_selection') + message = 'The retry selection or request identifier is invalid. Review your selections.' + _refuse_prepared_message_attempt(prepared_question, message, 'invalid_retry_selection') log_event('[CHAT_RETRY] Invalid retry selection.', extra={'error_type': type(error).__name__}, level=logging.WARNING) - return jsonify({'error': str(error), 'code': 'invalid_retry_selection'}), 400 + return jsonify({'error': message, 'code': 'invalid_retry_selection'}), 400 except PermissionError: message = 'An original selection is no longer authorized. Review your selections.' _refuse_prepared_message_attempt(prepared_question, message, 'forbidden') diff --git a/application/single_app/route_backend_orchestration.py b/application/single_app/route_backend_orchestration.py index dc332e10b..24003a890 100644 --- a/application/single_app/route_backend_orchestration.py +++ b/application/single_app/route_backend_orchestration.py @@ -964,7 +964,7 @@ def _regeneration_error(exc): if isinstance(exc, ChatRetryError): payload, status = {'error': exc.public_message, 'code': exc.code}, exc.status_code elif isinstance(exc, SubmissionIdError): - payload, status = {'error': str(exc), 'code': 'invalid_request'}, 400 + payload, status = {'error': 'The retry request identifier is invalid.', 'code': 'invalid_request'}, 400 elif isinstance(exc, (ModelCatalogError, ImageReferenceError, PublicChatScopeError)): payload, status = {'error': exc.public_message, 'code': exc.code}, getattr(exc, 'status_code', 400) else: @@ -1006,7 +1006,7 @@ def wrapped(*args, **kwargs): ) return result except ( - ChatRetryError, ConversationContextError, ElicitationContextError, ModelCatalogError, + ChatRetryError, SubmissionIdError, ConversationContextError, ElicitationContextError, ModelCatalogError, ImageReferenceError, PublicChatScopeError, CatalogResolutionError, PermissionError, ScreeningError, AzureError, ) as exc: diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 4078ac02c..c4b83d59d 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -14,15 +14,11 @@ from functions_settings import * from functions_chat_model_catalog import ( build_chat_model_catalog as _build_chat_model_catalog, - chat_model_reasoning_metadata as _chat_model_reasoning_metadata, filter_chat_model_endpoints_by_governance as _filter_chat_model_endpoints_by_governance, normalize_chat_model_value as _normalize_chat_model_value, ) from functions_model_endpoint_types import resolve_model_endpoint_request_model from functions_agent_catalog import build_accessible_agent_catalog -from functions_ai_connections import filter_model_endpoints_by_capability -from functions_model_capabilities import REASONING_IDENTIFIER_FIELDS, resolve_model_reasoning_policy -from functions_model_catalog import get_effective_model_profiles, model_profile_projection from functions_ai_notice import get_ai_notice_config, is_ai_notice_dismissed from functions_collaboration import ( assert_user_can_participate_in_collaboration_conversation, diff --git a/docs/explanation/fixes/CHAT_MESSAGE_RETRY_FIX.md b/docs/explanation/fixes/CHAT_MESSAGE_RETRY_FIX.md index c725c8ca8..63a68b5bb 100644 --- a/docs/explanation/fixes/CHAT_MESSAGE_RETRY_FIX.md +++ b/docs/explanation/fixes/CHAT_MESSAGE_RETRY_FIX.md @@ -4,7 +4,7 @@ title: "Chat message retry reliability" description: "Canonical saved-input retries, review-first orchestration regeneration, and attempt-scoped progress and errors." section: "Explanation" audience: developer -version: "0.261.319" +version: "0.261.320" --- ## Issue and root cause @@ -23,6 +23,20 @@ Related config.py update: `VERSION = "0.261.317"` in ## Request reconstruction and durable attempts +The **0.261.320** review follow-up removes raw submission/catalog exception text +from retry refusal responses. Invalid identifiers receive stable notices rather +than exception details. Conditional metadata writes now live in +`functions_chat_message_metadata.py`, below retry and content-review owners, +removing the retry/context bootstrap cycle while preserving fresh-body merges, +ETag checks, and explicit exhausted-conflict failures. The existing content-review +wrapper retains its domain-specific conflict exception. + +`test_chat_retry_import_boundaries.py` covers real cold imports in both orders, +web/scheduler bootstrap, and storage failures in normal and optimized Python. +Retry lifecycle and regeneration tests also inject private exception details and +verify that refusal responses do not expose them. The related `config.py` patch +version is **0.261.320**; the original retry implementation remains **0.261.317**. + `functions_chat_model_catalog.py` shares the authorized chat model catalog. `functions_chat_retry.py` reconstructs allowlisted inputs, resolves the viewed question, checks ownership and source fingerprints, reserves and publishes one diff --git a/functional_tests/test_chat_retry_attempt_lifecycle.py b/functional_tests/test_chat_retry_attempt_lifecycle.py index c1d9cd4c0..cba1a7541 100644 --- a/functional_tests/test_chat_retry_attempt_lifecycle.py +++ b/functional_tests/test_chat_retry_attempt_lifecycle.py @@ -1,7 +1,7 @@ # test_chat_retry_attempt_lifecycle.py """ Functional regression coverage for durable chat retry attempts. -Version: 0.261.319 +Version: 0.261.320 Implemented in: 0.261.317 Real replay, route admission, history, and background-worker functions run with @@ -204,6 +204,31 @@ def refuse(*args, **kwargs): assert len(context.messages.items) == 3 +@pytest.mark.parametrize('error_type', ['submission', 'catalog']) +@pytest.mark.parametrize('already_prepared', [False, True]) +def test_retry_preparation_never_returns_exception_details(context, monkeypatch, error_type, already_prepared): + question = context.prepare() if already_prepared else context.messages.get('q1') + sentinel = 'Traceback: private storage endpoint and credential' + error = ( + context.conversations.SubmissionIdError(sentinel) if error_type == 'submission' + else context.conversations.ModelCatalogError(sentinel) + ) + + def refuse(*args, **kwargs): + raise error + + monkeypatch.setattr(context.conversations, '_build_authorized_message_replay_request', refuse) + with context.app.test_request_context('/retry', method='POST', json={}): + response, status = context.conversations._prepare_message_attempt_response(question['id']) + assert status == 400 + assert response.get_json()['code'] == 'invalid_retry_selection' + assert sentinel not in response.get_data(as_text=True) + if already_prepared: + saved = context.messages.get(question['id'])['metadata']['response_attempt'] + assert saved['state'] == 'failed' + assert saved['error'] == response.get_json()['error'] + + def test_interleaved_reservation_recovers_the_same_question(context): recovered = [] context.store.after_replace = lambda: recovered.append(context.prepare()) diff --git a/functional_tests/test_chat_retry_import_boundaries.py b/functional_tests/test_chat_retry_import_boundaries.py new file mode 100644 index 000000000..cbe23e5d5 --- /dev/null +++ b/functional_tests/test_chat_retry_import_boundaries.py @@ -0,0 +1,116 @@ +# test_chat_retry_import_boundaries.py +"""Retry metadata storage and bootstrap import regressions. + +Version: 0.261.320 +Implemented in: 0.261.320 + +Real modules run in fresh normal/optimized processes with networking blocked. +Storage success and exhausted conflicts must preserve the caller's exception. +""" + +import ast +from pathlib import Path + +import pytest + +from test_orchestration_result_imports import APP_PROBE, run_probe + + +APP = Path(__file__).resolve().parents[1] / 'application' / 'single_app' +EARLY_PROBE = r''' +import builtins +from copy import deepcopy +import importlib +import socket +import sys +from unittest.mock import patch + +sys.path[:0] = sys.argv[1:3] +real_import = builtins.__import__ +forbidden = {'config', 'functions_settings', 'functions_chat_content_review', 'functions_orchestration_context'} + +def guarded_import(name, *args, **kwargs): + if name in forbidden: + raise AssertionError('Unexpected owner import: ' + name) + return real_import(name, *args, **kwargs) + +def no_network(*args, **kwargs): + raise AssertionError('Unexpected network access') + +with patch.object(builtins, '__import__', guarded_import), patch.object(socket.socket, 'connect', no_network): + for name in sys.argv[3:]: + importlib.import_module(name) + from azure.cosmos.exceptions import CosmosAccessConditionFailedError + from functions_chat_message_metadata import patch_message_metadata + from functions_chat_retry import ChatRetryError + class Store: + def __init__(self): + self.fail = False + self.writes = 0 + self.body = {'id': 'question', 'conversation_id': 'chat', '_etag': '1', + 'metadata': {'response_attempt': {'state': 'completed'}, 'thread_info': {'thread_attempt': 2}}} + def read_item(self, **kwargs): + return deepcopy(self.body) + def replace_item(self, **kwargs): + self.writes += 1 + if self.fail: + raise CosmosAccessConditionFailedError(status_code=412) + self.body = deepcopy(kwargs['body']) + return self.body + store = Store() + message = {'id': 'question', 'conversation_id': 'chat', 'metadata': { + 'thread_info': {'active_thread': False}, 'response_attempt': {'state': 'prepared'}}} + result = patch_message_metadata(store, message, conflict_error=lambda: ChatRetryError('Retry changed')) + if result['metadata']['response_attempt']['state'] != 'completed' or result['metadata']['thread_info'] != { + 'thread_attempt': 2, 'active_thread': False} or store.writes != 1: + raise AssertionError('Metadata write replaced authoritative state or disappeared') + store.fail = True + try: + patch_message_metadata(store, message, conflict_error=lambda: ChatRetryError('Retry changed')) + except ChatRetryError as error: + if error.public_message != 'Retry changed' or store.writes != 4: + raise AssertionError('Conflict contract changed') + else: + raise AssertionError('Exhausted storage conflicts returned success') +print('PASS: owner-free imports and explicit storage failure') +''' + + +@pytest.mark.parametrize('optimized', [False, True]) +@pytest.mark.parametrize('order', [ + ('functions_chat_retry', 'functions_chat_message_metadata'), + ('functions_chat_message_metadata', 'functions_chat_retry'), +]) +def test_retry_storage_cold_imports_and_conflicts(order, optimized): + run_probe(EARLY_PROBE, order, optimized) + + +@pytest.mark.parametrize('optimized', [False, True]) +@pytest.mark.parametrize('order', [ + ('functions_chat_retry', 'functions_orchestration_context', 'app', 'background_tasks'), + ('functions_orchestration_context', 'functions_chat_retry', 'app', 'background_tasks'), + ('app', 'functions_chat_retry', 'background_tasks'), + ('background_tasks', 'functions_chat_retry', 'functions_orchestration_context'), +]) +def test_retry_and_context_real_web_scheduler_bootstrap(order, optimized): + run_probe(APP_PROBE, order, optimized) + + +def test_retry_transitive_imports_remain_owner_free_including_local_imports(): + pending = ['functions_chat_retry', 'functions_chat_message_metadata'] + visited = set() + forbidden = {'config', 'functions_settings', 'functions_chat_content_review', 'functions_orchestration_context'} + while pending: + name = pending.pop() + if name in visited: + continue + visited.add(name) + tree = ast.parse((APP / f'{name}.py').read_text(encoding='utf-8')) + imports = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + imports.append(node.module or '') + assert not forbidden.intersection(imports), (name, imports) + pending.extend(module for module in imports if (APP / f'{module}.py').is_file()) diff --git a/functional_tests/test_orchestration_message_regeneration.py b/functional_tests/test_orchestration_message_regeneration.py index 78a6ff5a4..4b292cf84 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.319 +Version: 0.261.320 Implemented in: 0.261.317 The real planning, revision, execution, and retry state machines run against the @@ -465,6 +465,26 @@ def test_malformed_preparation_does_not_mutate_the_source(context, payload): assert not context.conversations.read_item('conv1', 'conv1').get('retry_threads') +@pytest.mark.parametrize('prepared_invocation', [False, True]) +def test_regeneration_submission_errors_never_expose_exception_details(context, monkeypatch, prepared_invocation): + original = source_run(context) + _, prepared = prepare(context, original['user_message_id']) + sentinel = 'Traceback: private storage endpoint and credential' + + def refuse(*args, **kwargs): + raise context.route.SubmissionIdError(sentinel) + + if prepared_invocation: + monkeypatch.setattr(context.route, '_restore_prepared_regeneration', refuse) + response = context.client.post('/api/v2/orchestration/plan', json=prepared['plan_request']) + else: + monkeypatch.setattr(context.route, 'normalize_submission_id', refuse) + response, _ = prepare(context, original['user_message_id']) + assert response.status_code == 400 + assert response.get_json()['code'] == 'invalid_request' + assert sentinel not in response.get_data(as_text=True) + + def test_unsaved_planning_retry_review_floor_cannot_be_overridden_by_auto(context): context.settings.update({ 'chat_orchestration_approval_mode': 'auto', diff --git a/ui_tests/test_v2_chat_retry.py b/ui_tests/test_v2_chat_retry.py index 13afd99dc..7c16654e4 100644 --- a/ui_tests/test_v2_chat_retry.py +++ b/ui_tests/test_v2_chat_retry.py @@ -1,7 +1,7 @@ # test_v2_chat_retry.py """ Browser coverage for saved model, agent, and orchestration retry attempts. -Version: 0.261.319 +Version: 0.261.320 Implemented in: 0.261.317 Runs the real message list, composer, stores, and controller with production CSS. @@ -17,10 +17,13 @@ from playwright.sync_api import expect import test_v2_orchestration_plan_editor as editor_tests -from test_v2_orchestration_plan_editor import connect_options, editor_assets, editor_browser # noqa: F401 from test_v2_orchestration_planning_retry import CONVERSATION, PLAN, PlanningApi, mount +connect_options = editor_tests.connect_options +editor_assets = editor_tests.editor_assets +editor_browser = editor_tests.editor_browser + pytestmark = pytest.mark.ui THREAD = "saved-retry-thread" FAILURE = "This retry's provider could not complete."