diff --git a/application/single_app/app_settings_store.py b/application/single_app/app_settings_store.py index 7e975a626..c027e6130 100644 --- a/application/single_app/app_settings_store.py +++ b/application/single_app/app_settings_store.py @@ -33,6 +33,18 @@ """ +def _plain_document(document): + """Copy a Cosmos settings response as a plain ``dict``. + + ``read_item`` and ``replace_item`` return ``CosmosDict``, a ``dict`` subclass that + carries response headers, and ``copy.deepcopy`` keeps that subclass. Shared reads + decode JSON into a plain ``dict``. Every read path returns the same type, so callers + that require exactly ``dict`` behave the same whichever store served the read: with + Redis disabled, during another worker's save, on cache repair and on Redis fallback. + """ + return copy.deepcopy(dict(document) if isinstance(document, dict) else document) + + class SettingsConflictError(RuntimeError): """The settings changed after the caller's read.""" @@ -64,7 +76,7 @@ def capture_headers(response_headers, _body): session_token=session_token, response_hook=capture_headers, ) - return copy.deepcopy(document), headers.get("x-ms-session-token", session_token) + return _plain_document(document), headers.get("x-ms-session-token", session_token) @staticmethod def _decode(raw): @@ -218,5 +230,5 @@ def capture_headers(response_headers, _body): raise SettingsConflictError("Settings changed during the save.") continue - return copy.deepcopy(stored) + return _plain_document(stored) raise SettingsConflictError("Settings kept changing; reload and retry.") diff --git a/application/single_app/config.py b/application/single_app/config.py index fb120a060..d3344236a 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.236" +VERSION = "0.261.238" 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_m365_runtime.py b/application/single_app/functions_m365_runtime.py index d1eb2f1e1..181677b1e 100644 --- a/application/single_app/functions_m365_runtime.py +++ b/application/single_app/functions_m365_runtime.py @@ -41,7 +41,10 @@ from functions_m365_operations import ( M365_ACTION_DEFINITIONS, M365_LEGACY_OPERATION_SOURCES, + M365_WRITE_FUNCTIONS, + get_m365_enabled_function_names, ) +from functions_msgraph_operations import get_msgraph_enabled_function_names from functions_m365_connections import preflight_m365_chat_authentication from functions_m365_workflow_binding import ( M365_REVISION_AUTHORSHIP_FIELD, @@ -63,6 +66,10 @@ "selection_mode", "document_context_requested", "prompt_info", "conversation_task_document_ids", "image_generation", }) +# The approved orchestration step's own action, set only inside that step's execution +# bridge. Like every g.m365_* value, it is cleared when the step scope exits. +M365_STEP_SELECTION_KEY = "m365_step_selection" +_STEP_ORIGIN_FIELDS = ("run_id", "attempt_index", "step_id", "capability_id") def m365_resume_payload(payload): @@ -538,6 +545,220 @@ def validate_m365_workflow_execution(context): ) +def _step_action_manifest(action): + """A saved action's own enabled functions, with no agent overlay.""" + manifest = dict(action) + if action.get("type") == "msgraph": + fields = action.get("additionalFields") or {} + functions = set(get_msgraph_enabled_function_names( + fields.get("msgraph_capabilities", action.get("msgraph_capabilities")), + )) + if action.get("msgraph_capabilities") is not None: + functions.intersection_update(get_msgraph_enabled_function_names(action["msgraph_capabilities"])) + if action.get("enabled_functions") is not None: + functions.intersection_update(action["enabled_functions"]) + manifest["enabled_functions"] = sorted(functions) + else: + manifest["enabled_functions"] = get_m365_enabled_function_names(action["type"], action) + return manifest + + +def step_selected_m365_manifests(selection, actor_user_id): + """Resolve an orchestration action step's selection from current storage, never the plan. + + The selection names the approved step's saved action by its exact reference, and the + catalog reauthorizes that reference on every read. + """ + if not isinstance(selection, dict) or selection.get("kind") != "action": + raise M365PolicyError( + "m365_action_selection_unavailable", + "The selected Microsoft 365 actions could not be resolved.", + ) + # The catalog owns exact-reference reauthorization; it loads storage only when called. + from functions_action_catalog import resolve_action_manifest + + try: + action = resolve_action_manifest( + actor_user_id, selection.get("action_ref"), + user_groups=selection.get("user_groups") or None, + ) + except (PermissionError, LookupError) as error: + raise M365PolicyError("m365_action_not_authorized", "The step's action is unavailable.") from error + if action.get("type") != "msgraph" and action.get("type") not in M365_ACTION_DEFINITIONS: + return [] + manifest = _step_action_manifest(action) + return [manifest] if manifest["enabled_functions"] else [] + + +def _readable_m365_manifests(manifests): + """Plans only read Microsoft 365 data: send, invite and read-state functions are removed.""" + readable = [] + for manifest in manifests: + functions = [name for name in manifest.get("enabled_functions") or [] if name not in M365_WRITE_FUNCTIONS] + if functions: + readable.append({**manifest, "enabled_functions": functions}) + return readable + + +def _begin_step_m365_request(context, origin): + """Mark a plan step's request running. A retry of the step reuses the same record.""" + container = cosmos_m365_execution_runs_container + origin = { + key: value for key, value in (origin or {}).items() + if key in _STEP_ORIGIN_FIELDS and isinstance(value, (str, int)) and not isinstance(value, bool) + } + for attempt in range(3): + try: + prior = container.read_item(context.request_id, partition_key=context.data_user_id) + except CosmosResourceNotFoundError: + prior = None + if prior is not None and ( + prior.get("type") != "m365_execution_request" + or prior.get("conversation_id") != context.conversation_id + or prior.get("actor_user_id") != context.actor_user_id + or prior.get("workflow_id") + ): + raise M365PolicyError("m365_request_changed", "This Microsoft 365 request belongs to other work.") + record = { + **(prior or {}), + "id": context.request_id, "user_id": context.data_user_id, + "actor_user_id": context.actor_user_id, "type": "m365_execution_request", + "status": "running", "conversation_id": context.conversation_id, + "workflow_id": None, "run_id": None, "orchestration": origin, + } + try: + if prior is None: + container.create_item(body=record) + else: + container.replace_item( + record["id"], body=record, + etag=prior["_etag"], match_condition=MatchConditions.IfNotModified, + ) + g.m365_has_pending_record = True + return + except CosmosHttpResponseError as error: + if error.status_code not in {409, 412} or attempt == 2: + raise + + +def _report_step_approval_outcome(approval_id, context, status): + """Show a retried step's outcome on the approval it stopped for, as a resumed chat does.""" + try: + get_m365_approval_service().record_execution_status( + approval_id, context.data_user_id, context.request_id, status, + ) + except (M365PolicyError, LookupError): + # Still pending, or no longer this request's approval: the step's own outcome stands. + return + + +def _finish_step_m365_request(context, status, approval_id=None): + """Record a plan step's outcome. + + A step never pauses for a decision: a stop for approval is recorded as failed with its + approval, so deciding it does not resume a chat request, and the user retries the step. + """ + container = cosmos_m365_execution_runs_container + for attempt in range(3): + try: + record = container.read_item(context.request_id, partition_key=context.data_user_id) + except CosmosResourceNotFoundError: + return + if record.get("status") in {"cancelled", "recovery_required"}: + return + updated = {**record, "status": status} + if approval_id: + updated["approval_id"] = approval_id + try: + container.replace_item( + record["id"], body=updated, + etag=record["_etag"], match_condition=MatchConditions.IfNotModified, + ) + except CosmosHttpResponseError as error: + if error.status_code != 412 or attempt == 2: + raise + continue + if record.get("approval_id") and not approval_id: + _report_step_approval_outcome(record["approval_id"], context, status) + return + + +def _stopping_approval_id(error): + """The approval a step stopped for, read from the stop or the refusal it wraps.""" + for _depth in range(5): + if error is None: + return None + approval_id = getattr(error, "approval_id", None) + if isinstance(approval_id, str) and approval_id: + return approval_id + error = error.__cause__ or error.__context__ + return None + + +@contextmanager +def step_m365_context(*, user_id, conversation_id, request_id, selection, origin=None): + """Authorize Microsoft 365 for one orchestration action step inside its execution bridge. + + A plan step runs in a fresh request context, so the chat request's Microsoft 365 + context never reaches it. This context uses the signed-in user as actor and data user, + selects only the step's own action, and never enables send, invite or read-state + functions. Shared conversations are refused: their source-sharing approvals resume chat + requests, not plan steps. Delegated sign-in is checked here, before any model call or + Microsoft Graph request. + """ + if not has_request_context() or (session.get("user") or {}).get("oid") != user_id: + raise M365PolicyError("m365_session_required", "Sign in again to use Microsoft 365.") + conversation, _access, shared = _conversation_access(user_id, conversation_id) + if conversation is None or conversation.get("user_id") != user_id: + raise M365PolicyError("conversation_not_found", "Conversation not found.") + if shared is not None: + raise M365PolicyError( + "m365_shared_conversation_unsupported", + "Plans can't use Microsoft 365 in a shared conversation.", + ) + previous_state = {name: value for name, value in vars(g).items() if name.startswith("m365_")} + for name in previous_state: + delattr(g, name) + setattr(g, M365_STEP_SELECTION_KEY, deepcopy(selection)) + try: + context = M365ExecutionContext( + actor_user_id=user_id, data_user_id=user_id, tenant_id=TENANT_ID, + conversation_id=conversation_id, shared=False, request_id=request_id, + audience_version=_audience_version(conversation, None), + ) + install_m365_context(context) + selected = step_selected_m365_manifests(selection, user_id) + manifests = _readable_m365_manifests(selected) + if not manifests: + if selected: + raise M365PolicyError("m365_read_only_step", "Plans can only read Microsoft 365 data.") + raise M365PolicyError("m365_action_not_selected", "No Microsoft 365 action is selected for this step.") + _begin_step_m365_request(context, origin) + status, approval_id = "failed", None + try: + preflight_m365_manifests(manifests) + yield get_m365_execution_context() + status = "completed" + except BaseException as error: + approval_id = _stopping_approval_id(error) + raise + finally: + try: + _finish_step_m365_request(context, status, approval_id) + except (CosmosHttpResponseError, M365PolicyError) as error: + # The step's own outcome is already decided; a stale record only delays sharing. + log_event( + "[MS_GRAPH_PLUGIN] A plan step's Microsoft 365 request status was not saved.", + level=logging.WARNING, extra={"error_type": type(error).__name__, "status": status}, + ) + finally: + for name in list(vars(g)): + if name.startswith("m365_"): + delattr(g, name) + for name, value in previous_state.items(): + setattr(g, name, value) + + def configure_m365_pending_delivery_runtime(request_context_factory): """Bootstrap supplies a request-context factory, never another user's session.""" from config import cosmos_msgraph_pending_actions_container @@ -704,6 +925,9 @@ def resolve_m365_selected_manifests(context): current = load_current_workflow(workflow) manifests, _ = workflow_m365_manifests(current) return manifests + step = getattr(g, M365_STEP_SELECTION_KEY, None) if has_request_context() else None + if step is not None: + return _readable_m365_manifests(step_selected_m365_manifests(step, context.actor_user_id)) selection = getattr(g, "m365_selected_agent_ref", None) if has_request_context() else None if not selection: return [] diff --git a/application/single_app/functions_m365_transport.py b/application/single_app/functions_m365_transport.py index 3ebb7142d..c8f822a29 100644 --- a/application/single_app/functions_m365_transport.py +++ b/application/single_app/functions_m365_transport.py @@ -87,7 +87,11 @@ def log_m365_failure(code: str, *, source: str = "", operation: str = "") -> Non log_event( "[MS_GRAPH_PLUGIN] Microsoft 365 operation could not complete.", level=logging.WARNING, - extra={"source": source, "operation": operation, "error_code": code}, + # failure_code and resource are retained as text; error_code and source as lengths only. + extra={ + "source": source, "operation": operation, "error_code": code, + "failure_code": code, "resource": source, + }, ) diff --git a/application/single_app/functions_orchestration_actions.py b/application/single_app/functions_orchestration_actions.py index f63a255a8..19ce18a40 100644 --- a/application/single_app/functions_orchestration_actions.py +++ b/application/single_app/functions_orchestration_actions.py @@ -5,7 +5,8 @@ kernel holds only the built-in chart tools, never the action's own functions, so saved visual preferences can be applied there without reaching calls to the integration. -Version: 0.261.139 +Version: 0.261.238 +Microsoft 365 actions authorized for their own step in: 0.261.238 """ import asyncio @@ -13,7 +14,7 @@ import json import logging import uuid -from contextlib import nullcontext +from contextlib import ExitStack, nullcontext from copy import deepcopy from typing import Annotated @@ -34,7 +35,14 @@ extract_result_rows, normalize_chart_kind, ) +from functions_m365_approvals import M365PolicyError from functions_orchestration_invocation_capture import require_invocation_capture +from functions_orchestration_m365 import ( + action_step_scope, + is_m365_action_manifest, + result_refusal, + step_error, +) from functions_orchestration_model_capture import azure_chat_construction_metadata from functions_orchestration_registry import ( CAPABILITY_ACTION_INVOKE, @@ -431,7 +439,7 @@ def _step_chart_titles(invocations): async def invoke_action( action_ref, task, context, *, settings, user_id, cancel_requested, invocation_capture=None, - visual_request=None, + visual_request=None, m365_request_key=None, m365_origin=None, ): """Run only this action's enabled functions and return its findings and invocation scope. @@ -440,6 +448,9 @@ async def invoke_action( loop ends. The sub-step holds only the built-in chart tools and the rows already retrieved, so under invocation capture it acquires nothing the capture has not already attested. + + A Microsoft 365 action runs inside its own step's Microsoft 365 context, keyed by + ``m365_request_key`` so a retry of the step reuses its request and any approval. """ # Keep the planner/registry importable without SK and the Azure application bootstrap. from semantic_kernel import Kernel @@ -489,7 +500,9 @@ async def invoke_action( ) seen_before = {id(invocation) for invocation in budget.invocations()} bridge = identity.bridge(reference) if identity.bridge else nullcontext() - with bridge, agent_execution(frame) as frame: + # Entered once the saved manifest is known, and exited inside the bridge with the outcome. + m365_scope = ExitStack() + with bridge, agent_execution(frame) as frame, m365_scope: invocation_capture = frame.invocation_capture if invocation_capture is not None: invocation_capture('action', settings=settings, selector=action_ref) @@ -502,6 +515,14 @@ async def invoke_action( if invocation_capture is not None: current_settings = deepcopy(current_settings) manifest = deepcopy(manifest) + m365_step = is_m365_action_manifest(manifest) + if m365_step: + # Sign-in is checked here, before the model or Microsoft Graph is called. + m365_scope.enter_context(action_step_scope( + action_ref, user_id=user_id, conversation_id=getattr(context, 'conversation_id', None), + request_key=m365_request_key, user_groups=getattr(context, 'active_group_ids', None), + origin=m365_origin, + )) kernel = Kernel() loader = create_logged_plugin_loader(kernel) history = ChatHistory() @@ -557,6 +578,10 @@ async def guard_function(invocation, next): await next(invocation) except AgentExecutionCancelled: raise + except M365PolicyError as exc: + # Sign-in, approval and policy refusals stop the step with their own failure. + failure = step_error(exc) + raise failure from exc except Exception as exc: # Contain arbitrary plugin exceptions before SK puts them in model context. log_event( @@ -566,6 +591,11 @@ async def guard_function(invocation, next): ) failure = ActionExecutionError('An action function could not complete.') raise failure from exc + refusal = result_refusal(invocation.result.value) if m365_step and invocation.result is not None else None + if refusal is not None: + # A refused Microsoft 365 call is not data: the model would report it as findings. + failure = refusal + raise failure if invocation.result is not None: outputs.append(invocation.result.value) retrieved.append({ diff --git a/application/single_app/functions_orchestration_adapters.py b/application/single_app/functions_orchestration_adapters.py index 934208d06..48385110c 100644 --- a/application/single_app/functions_orchestration_adapters.py +++ b/application/single_app/functions_orchestration_adapters.py @@ -455,6 +455,23 @@ def _failed_result(summary, error, replan_hint=None): ) +def _m365_step_identity(step, context): + """The Microsoft 365 request key one plan step keeps across retries, and its record origin. + + Every retry of a plan shares its first attempt's run id, so a retried step reuses its + request, including an approval the user gave after the earlier attempt stopped. + """ + step_id = _text((step or {}).get('step_id')) + root = _ctx(context, 'attempt_root_run_id', None) or _ctx(context, 'run_id', None) + origin = { + 'run_id': _ctx(context, 'run_id', None), 'attempt_index': _ctx(context, 'attempt_index', None), + 'step_id': step_id or None, + } + if not isinstance(root, str) or not root or not step_id: + return None, origin + return f'{root}\x00{step_id}', origin + + def _progress(step, capability_id, label, status='running'): step = step or {} return { @@ -1657,6 +1674,7 @@ def run_action_invoke(step, context, *, settings, user_id, emit, cancel_requeste invocation_kwargs['invocation_capture']('action', settings=settings, selector=action_ref) if visual_request: invocation_kwargs['visual_request'] = visual_request + invocation_kwargs['m365_request_key'], invocation_kwargs['m365_origin'] = _m365_step_identity(step, context) from functions_orchestration_actions import invoke_action from semantic_kernel_plugins.plugin_invocation_logger import sanitize_plugin_invocation_value diff --git a/application/single_app/functions_orchestration_m365.py b/application/single_app/functions_orchestration_m365.py new file mode 100644 index 000000000..8444ee42e --- /dev/null +++ b/application/single_app/functions_orchestration_m365.py @@ -0,0 +1,169 @@ +# functions_orchestration_m365.py +"""Microsoft 365 for orchestration action steps. + +Each action step runs in its own request context, the execution identity's bridge. Classic +chat and workflows install a Microsoft 365 execution context on their own request, so a +bridge never had one: every Microsoft 365 function refused the call with +``m365_context_required`` before reaching Microsoft Graph, and the step still reported +completed. ``action_step_scope`` installs a context for one approved action step. The +helpers here turn Microsoft 365 sign-in, approval and policy refusals into +application-owned step failures, instead of findings the model reports as data. + +Agent steps need no scope: a plan runs only Foundry agents, whose tools run in Foundry, +and refuses local agents, the only agents that load Microsoft 365 actions. + +Version: 0.261.238 +Implemented in: 0.261.238 +""" + +import hashlib +import json +import logging +import uuid +from contextlib import contextmanager + +from functions_appinsights import log_event +from functions_m365_approvals import M365ApprovalRequired, M365PolicyError +from functions_m365_operations import M365_PLUGIN_TYPES, M365_SOURCES +from functions_msgraph_operations import MSGRAPH_PLUGIN_TYPE +from m365_interaction import M365_AUTH_INTERACTION_CODES + + +M365_STEP_ACTION_TYPES = frozenset({*M365_PLUGIN_TYPES, MSGRAPH_PLUGIN_TYPE}) +_APPROVAL_CODES = frozenset({'m365_approval_required', 'm365_approval_pending'}) +# Function-result codes that stop a step: no later call can succeed until the user acts. +_RESULT_STOP_CODES = frozenset({'execution_context_required', 'source_not_authorized', 'token_acquisition_failed'}) + + +class OrchestrationM365Error(RuntimeError): + """Stops a step with an application-owned Microsoft 365 failure. + + ``failure_from_exception`` reads ``orchestration_failure_code`` and ``m365_sources``. + ``m365_code`` is the stable Microsoft 365 reason, logged for diagnostics only. + """ + + def __init__(self, failure_code, *, m365_code=None, sources=(), approval_id=None): + super().__init__('A Microsoft 365 step could not continue.') + self.orchestration_failure_code = failure_code + self.m365_code = m365_code if isinstance(m365_code, str) else None + self.m365_sources = tuple(sorted({source for source in sources or () if source in M365_SOURCES})) + self.approval_id = approval_id if isinstance(approval_id, str) and approval_id else None + + +def failure_code(m365_code): + """The orchestration failure for a stable Microsoft 365 refusal code.""" + if m365_code == 'm365_session_required': + return 'external_session_required' + if m365_code == 'm365_shared_conversation_unsupported': + return 'm365_shared_conversation' + if m365_code == 'm365_read_only_step': + return 'm365_read_only' + if m365_code in M365_AUTH_INTERACTION_CODES: + return 'm365_sign_in_required' + if m365_code in _APPROVAL_CODES: + return 'm365_approval_required' + return 'm365_unavailable' + + +def step_error(error, *, sources=()): + """The step stop for a Microsoft 365 policy, sign-in or approval refusal.""" + if isinstance(error, OrchestrationM365Error): + return error + code = getattr(error, 'code', None) + code = code if isinstance(code, str) else None + payload = getattr(error, 'payload', None) + payload = payload if isinstance(payload, dict) else {} + reported = payload.get('sources') + sources = [*sources, *(reported if isinstance(reported, list) else []), payload.get('source')] + if isinstance(error, M365ApprovalRequired): + return OrchestrationM365Error( + 'm365_approval_required', m365_code=code, sources=sources, approval_id=error.approval_id, + ) + return OrchestrationM365Error(failure_code(code), m365_code=code, sources=sources) + + +def _stops_step(code): + return ( + code in M365_AUTH_INTERACTION_CODES or code in _APPROVAL_CODES or code in _RESULT_STOP_CODES + or code.startswith('m365_') + ) + + +def result_refusal(value): + """The step stop for a Microsoft 365 function result that refused its call, or None. + + Ordinary Microsoft Graph outcomes, such as nothing found or throttling, stay data that + the model reports. Sign-in, approval and authorization refusals stop the step. + """ + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + return None + if not isinstance(value, dict): + return None + error = value.get('error') + code = error.get('code') if isinstance(error, dict) else error + if not isinstance(code, str) or not _stops_step(code): + return None + reported = value.get('sources') + sources = [*(reported if isinstance(reported, list) else []), value.get('source')] + return OrchestrationM365Error(failure_code(code), m365_code=code, sources=sources) + + +def is_m365_action_manifest(manifest): + return isinstance(manifest, dict) and manifest.get('type') in M365_STEP_ACTION_TYPES + + +def step_request_id(request_key): + """A stable request id for one plan step, shared by its retries, so an approval carries over.""" + if not isinstance(request_key, str) or not request_key: + return f'orch-{uuid.uuid4().hex}' + digest = hashlib.sha256(f'orchestration-m365-step\x00{request_key}'.encode('utf-8')).hexdigest() + return f'orch-{digest[:48]}' + + +def _log_stop(error, capability_id): + log_event( + '[ORCHESTRATION_M365] A Microsoft 365 step stopped.', + level=logging.WARNING, + extra={ + 'failure_code': error.orchestration_failure_code, + 'authority_reason': error.m365_code, + 'capability_id': capability_id, + 'source_count': len(error.m365_sources), + }, + ) + + +@contextmanager +def _step_scope(*, user_id, conversation_id, request_key, selection, origin): + # The runtime owns Cosmos, the tenant and conversation access, so it loads only for a + # Microsoft 365 step. + from functions_m365_runtime import step_m365_context + + try: + with step_m365_context( + user_id=user_id, conversation_id=conversation_id, + request_id=step_request_id(request_key), selection=selection, origin=origin, + ) as context: + yield context + except OrchestrationM365Error as error: + _log_stop(error, origin.get('capability_id')) + raise + except M365PolicyError as error: + stop = step_error(error) + _log_stop(stop, origin.get('capability_id')) + raise stop from error + + +def action_step_scope(action_ref, *, user_id, conversation_id, request_key, user_groups=None, origin=None): + """Authorize one approved Microsoft 365 action step, selecting only that action.""" + selection = { + 'kind': 'action', 'action_ref': action_ref, + 'user_groups': [group for group in user_groups or () if isinstance(group, str)], + } + return _step_scope( + user_id=user_id, conversation_id=conversation_id, request_key=request_key, + selection=selection, origin={**(origin or {}), 'capability_id': 'action_invoke'}, + ) diff --git a/application/single_app/functions_orchestration_schema.py b/application/single_app/functions_orchestration_schema.py index 60cae90c2..deaab61d1 100644 --- a/application/single_app/functions_orchestration_schema.py +++ b/application/single_app/functions_orchestration_schema.py @@ -33,8 +33,9 @@ render through the very same card. Our own paging lives in a sibling ``ui_hints`` field rather than inside the schema, which keeps the schema itself MCP-clean. -Version: 0.261.209 +Version: 0.261.238 Missing signed-in session reported as its own step failure in: 0.261.209 +Microsoft 365 step failures, with their sources, added in: 0.261.238 """ import hashlib @@ -49,6 +50,7 @@ from openai import APIConnectionError, APITimeoutError from agent_execution_context import AgentDelegationTimeout +from functions_m365_operations import M365_SOURCES from functions_model_catalog import ModelCatalogError from functions_orchestration_registry import ( APPROVAL_FLOOR_MANUAL, @@ -1480,6 +1482,26 @@ def summarize_plan(plan): 'access to web search, web pages, deep research, agents or actions, or to start a saved ' 'workflow. Send the request again to use them.' ), + 'm365_sign_in_required': ( + 'Microsoft 365 needs you to sign in or grant access before this step can read your data. ' + 'Select Connect Microsoft 365, then Retry from failed step.' + ), + 'm365_approval_required': ( + 'This step needs your approval before it can use Microsoft 365. Review it in Approvals, ' + 'then select Retry from failed step.' + ), + 'm365_unavailable': ( + 'Microsoft 365 access for this step could not be confirmed, so no Microsoft 365 data was ' + "read. Check the action's settings and your access to it, then retry." + ), + 'm365_shared_conversation': ( + "Plans can't use Microsoft 365 in a shared conversation. Use Microsoft 365 actions and " + 'agents from your own conversation.' + ), + 'm365_read_only': ( + "Plans can only read Microsoft 365 data. This action's enabled functions all send or change " + 'data, so use it from chat instead.' + ), 'workflow_runtime_unavailable': ( 'Saved workflows were temporarily unavailable, so this workflow may not have started. ' "Retrying is safe: a workflow this plan already started won't start again." @@ -1514,8 +1536,18 @@ def summarize_plan(plan): LEGACY_PLAN_CODE: LEGACY_PLAN_MESSAGE, } +# Microsoft 365 refusals of an action or agent step. Each names what the user does next. +M365_STEP_FAILURE_CODES = frozenset({ + 'm365_sign_in_required', 'm365_approval_required', 'm365_unavailable', + 'm365_shared_conversation', 'm365_read_only', +}) +# Failures an exception may carry by code, through its ``orchestration_failure_code``. +EXCEPTION_FAILURE_CODES = M365_STEP_FAILURE_CODES | {'external_session_required'} + -def build_failure(code='step_failed', *, step_id=None, capability_id=None, provider_status=None): +def build_failure( + code='step_failed', *, step_id=None, capability_id=None, provider_status=None, m365_sources=None, +): """Only application-owned text may cross a failure boundary.""" code = code if code in FAILURE_MESSAGES else 'step_failed' result = {'code': code, 'message': FAILURE_MESSAGES[code]} @@ -1527,6 +1559,11 @@ def build_failure(code='step_failed', *, step_id=None, capability_id=None, provi result['provider_status'] = provider_status if code == 'provider_http_error': result['message'] = f'The service used by this step returned HTTP {provider_status}.' + if code in M365_STEP_FAILURE_CODES and isinstance(m365_sources, (list, tuple, set, frozenset)): + # The browser connects exactly these sources; anything else is dropped, never echoed. + sources = sorted({source for source in m365_sources if source in M365_SOURCES}) + if sources: + result['m365_sources'] = sources return result @@ -1536,11 +1573,15 @@ def safe_failure(value, *, step_id=None, capability_id=None): value.get('code'), step_id=step_id or value.get('step_id'), capability_id=capability_id or value.get('capability_id'), provider_status=value.get('provider_status'), + m365_sources=value.get('m365_sources'), ) def failure_from_exception(exc, *, answering=False, _depth=0): """Use types and structured status, never diagnostic prose or model content.""" + code = getattr(exc, 'orchestration_failure_code', None) + if type(code) is str and code in EXCEPTION_FAILURE_CODES: + return build_failure(code, m365_sources=getattr(exc, 'm365_sources', None)) if isinstance(exc, ModelCatalogError): return build_failure('model_routing_changed') status = getattr(exc, 'status_code', None) diff --git a/application/v2_ui/src/components/chat/OrchestrationM365Notice.tsx b/application/v2_ui/src/components/chat/OrchestrationM365Notice.tsx new file mode 100644 index 000000000..0feb51809 --- /dev/null +++ b/application/v2_ui/src/components/chat/OrchestrationM365Notice.tsx @@ -0,0 +1,77 @@ +// OrchestrationM365Notice.tsx +import { useState } from 'react'; +import { GlassButton } from '../ui/primitives'; +import { + M365_APPROVALS_HREF, + M365_PROFILE_CONNECTION_HREF, + connectMicrosoft365, + m365Sources, +} from '../../lib/m365Connect'; +import type { OrchestrationFailure } from '../../lib/orchestration'; + +const SOURCE_LABELS: Record = { + calendar: 'Calendar', + email: 'Email', + onedrive: 'OneDrive', + spo: 'SharePoint', +}; + +/** + * What to do next when an attempt stopped at a Microsoft 365 step: connect the sources that + * step needs, or review its approval. Nothing reruns here; the user retries afterward. + */ +export function OrchestrationM365Notice({ failures }: { failures: OrchestrationFailure[] }) { + const [state, setState] = useState<'idle' | 'connecting' | 'connected'>('idle'); + const [error, setError] = useState(null); + const signIn = failures.find((failure) => failure.code === 'm365_sign_in_required'); + const approval = failures.some((failure) => failure.code === 'm365_approval_required'); + if (!signIn && !approval) return null; + const sources = m365Sources(signIn?.m365_sources); + + const connect = () => { + setError(null); + setState('connecting'); + connectMicrosoft365(sources).then( + () => setState('connected'), + (reason: unknown) => { + setState('idle'); + setError(reason instanceof Error ? reason.message : 'Microsoft 365 sign-in could not be completed.'); + }, + ); + }; + + return ( +
+ {signIn && state === 'connected' ? ( +

+ Microsoft 365 is connected. Select Retry from failed step to continue. +

+ ) : null} + {signIn && state !== 'connected' && sources.length ? ( +
+ + {state === 'connecting' ? 'Waiting for Microsoft 365 sign-in...' : 'Connect Microsoft 365'} + + For {sources.map((source) => SOURCE_LABELS[source] ?? source).join(', ')}. +
+ ) : null} + {signIn && !sources.length ? ( +

+ + Connect Microsoft 365 in Profile settings + + , then select Retry from failed step. +

+ ) : null} + {error ?

{error}

: null} + {approval ? ( +

+ + Review the Microsoft 365 approval + + , then select Retry from failed step. +

+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx b/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx index 3d6655e12..aac5ef04d 100644 --- a/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx +++ b/application/v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx @@ -17,6 +17,7 @@ import { type OrchestrationPlan, type PlanStatus, } from '../../lib/orchestration'; import { legacyPlanErrorMessage } from '../../lib/orchestrationErrors'; +import { OrchestrationM365Notice } from './OrchestrationM365Notice'; function RecoveryConfirmation({ busy, onConfirm, onClose, @@ -135,6 +136,11 @@ export function OrchestrationRecoveryNotice({

{attempt.failure?.message ?

{attempt.failure.message}

: null} {saved?.error ?

{saved.error}

: null} + {failed && !saved?.transportUnknown && !legacy && (!newer || newer === runId) ? ( + + ) : null} {waiting ? (

This computation is still pending in the same attempt. Dependent tasks will wait for its results. diff --git a/application/v2_ui/src/lib/m365Connect.ts b/application/v2_ui/src/lib/m365Connect.ts new file mode 100644 index 000000000..556f523c1 --- /dev/null +++ b/application/v2_ui/src/lib/m365Connect.ts @@ -0,0 +1,150 @@ +// m365Connect.ts +// Connects Microsoft 365 for orchestration steps that stopped for sign-in. +// +// The flow is the classic Profile reconnect: the server starts an authorization code flow +// for the named sources and stores the result in the signed-in session. The sign-in runs in +// a popup that ends on the Profile page, which tells its opener it connected. That message +// can be lost when the popup visits Microsoft sign-in or the API is on another origin, so +// the saved connection is checked when the popup closes. + +import { apiUrl, request } from './apiClient'; + +export type M365Source = 'calendar' | 'email' | 'onedrive' | 'spo'; + +const M365_SOURCES: ReadonlySet = new Set(['calendar', 'email', 'onedrive', 'spo']); +const POPUP_NAME = 'simplechat-m365-orchestration-connect'; +const POPUP_FEATURES = 'popup,width=720,height=780,resizable=yes,scrollbars=yes'; +const CONNECT_TIMEOUT_MS = 300_000; +const POPUP_POLL_MS = 500; +const INVALID_URL_MESSAGE = 'The server did not return a valid HTTPS Microsoft 365 sign-in URL.'; + +/** Classic pages that own Microsoft 365 connections and approvals, on the Flask origin. */ +export const M365_PROFILE_CONNECTION_HREF = apiUrl('/profile?tab=settings#m365-chat-connection'); +export const M365_APPROVALS_HREF = apiUrl('/approvals'); + +interface ChatConnection { + status?: string; + sources?: unknown; + connected_at?: string; +} + +interface ChatConnectionResponse { + connection?: ChatConnection; + csrf_token?: string; +} + +interface ConnectResponse { + authorization_url?: unknown; +} + +/** Only known Microsoft 365 sources, in a stable order and without duplicates. */ +export function m365Sources(values: unknown): M365Source[] { + if (!Array.isArray(values)) return []; + return Array.from(new Set(values.filter((value): value is M365Source => ( + typeof value === 'string' && M365_SOURCES.has(value) + )))).sort(); +} + +function authorizationUrl(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) throw new Error(INVALID_URL_MESSAGE); + let target: URL; + try { + target = new URL(value); + } catch { + throw new Error(INVALID_URL_MESSAGE); + } + // The server validates the configured authority, including sovereign and custom clouds. + if (target.protocol !== 'https:' || !target.hostname || target.username || target.password) { + throw new Error(INVALID_URL_MESSAGE); + } + return target.href; +} + +function connectionCovers(connection: ChatConnection | undefined, sources: M365Source[], before?: string): boolean { + const connected = m365Sources(connection?.sources); + return connection?.status === 'available' + && typeof connection.connected_at === 'string' + && connection.connected_at !== before + && sources.every((source) => connected.includes(source)); +} + +/** + * Sign in to Microsoft 365 for these sources. Resolves once the sign-in is saved for this + * session; rejects with a user-facing message otherwise. Call it from a click handler: the + * popup opens before anything is awaited, so the browser does not block it. + */ +export function connectMicrosoft365(sources: M365Source[]): Promise { + if (!sources.length) { + return Promise.reject(new Error('Open Profile settings to choose the Microsoft 365 sources to connect.')); + } + const popup = window.open('about:blank', POPUP_NAME, POPUP_FEATURES); + if (!popup) { + return Promise.reject(new Error( + 'The sign-in window was blocked. Allow pop-ups for this site, or connect Microsoft 365 in Profile settings.', + )); + } + const apiOrigin = new URL(apiUrl('/'), window.location.origin).origin; + return new Promise((resolve, reject) => { + let finished = false; + let checking = false; + let connectedBefore: string | undefined; + const finish = (error?: Error) => { + if (finished) return; + finished = true; + window.clearInterval(poll); + window.clearTimeout(timeout); + window.removeEventListener('message', onMessage); + if (!popup.closed) popup.close(); + if (error) reject(error); + else resolve(); + }; + const onMessage = (event: MessageEvent) => { + if ( + event.source === popup + && (event.origin === window.location.origin || event.origin === apiOrigin) + && (event.data as { type?: unknown } | null)?.type === 'm365-profile-reconnected' + ) { + finish(); + } + }; + const confirmAfterClose = async () => { + if (checking || finished) return; + checking = true; + try { + const latest = await request('/api/m365/chat/connection'); + finish(connectionCovers(latest.connection, sources, connectedBefore) ? undefined + : new Error('Microsoft 365 sign-in was not completed. Connect again when you are ready.')); + } catch { + finish(new Error('Microsoft 365 sign-in could not be confirmed. Check Profile settings, then retry.')); + } + }; + const poll = window.setInterval(() => { + if (popup.closed) void confirmAfterClose(); + }, POPUP_POLL_MS); + const timeout = window.setTimeout(() => { + finish(new Error('Microsoft 365 sign-in timed out. Connect again when you are ready.')); + }, CONNECT_TIMEOUT_MS); + window.addEventListener('message', onMessage); + void (async () => { + try { + const current = await request('/api/m365/chat/connection'); + connectedBefore = current.connection?.connected_at; + const csrfToken = current.csrf_token; + if (typeof csrfToken !== 'string' || csrfToken.length < 32) { + throw new Error('Microsoft 365 controls are unavailable. Refresh the page and try again.'); + } + const started = await request('/api/m365/chat/connection/connect', { + method: 'POST', + body: { sources }, + headers: { 'X-M365-CSRF-Token': csrfToken }, + }); + if (!finished) { + popup.location.replace(authorizationUrl(started.authorization_url)); + popup.focus(); + } + } catch (error) { + finish(error instanceof Error ? error : new Error('Microsoft 365 sign-in could not start.')); + } + })(); + }); +} diff --git a/application/v2_ui/src/lib/orchestration.ts b/application/v2_ui/src/lib/orchestration.ts index 99cfd45f5..a702d7517 100644 --- a/application/v2_ui/src/lib/orchestration.ts +++ b/application/v2_ui/src/lib/orchestration.ts @@ -68,6 +68,8 @@ export interface OrchestrationFailure { step_id?: string; capability_id?: string; provider_status?: number; + /** The Microsoft 365 sources a sign-in or approval stop needs, when the step reported them. */ + m365_sources?: string[]; } export interface OrchestrationRecovery { @@ -112,6 +114,9 @@ export function normalizeOrchestrationFailure(value: unknown): OrchestrationFail ...(typeof data.step_id === 'string' ? { step_id: data.step_id } : {}), ...(typeof data.capability_id === 'string' ? { capability_id: data.capability_id } : {}), ...(typeof data.provider_status === 'number' ? { provider_status: data.provider_status } : {}), + ...(Array.isArray(data.m365_sources) + ? { m365_sources: data.m365_sources.filter((source): source is string => typeof source === 'string') } + : {}), }; } diff --git a/docs/admin/orchestration.md b/docs/admin/orchestration.md index 25a868e23..c9224d559 100644 --- a/docs/admin/orchestration.md +++ b/docs/admin/orchestration.md @@ -339,6 +339,40 @@ restrictions and confirmation behavior remain intact. The focused loop can make calls and is bounded by the existing `max_auto_invoke_attempts` setting, step/run timeouts and cancellation. No composer action picker is added. +#### Microsoft 365 actions in plans + +Since **0.261.238**, a **Use an action** step can run a Microsoft 365 Calendar, Email, +OneDrive, SharePoint Online or legacy Microsoft Graph action. Earlier versions refused every +Microsoft 365 call in a plan with `m365_context_required`, before reaching Microsoft Graph, +and still reported the step as completed. + +Each such step gets its own Microsoft 365 request, the same kind chat creates, with these +limits: + +- **The signed-in user's own access.** The step reads as the person who sent the request, + never as an action owner, the app, or a workflow Run as account. Their delegated sign-in + is checked before the step's model or any Microsoft Graph call. When it is missing or + expired, the step stops with "Microsoft 365 needs you to sign in or grant access", and + the V2 run details offer **Connect Microsoft 365** for the sources the step needs. +- **Only the selected action.** The request selects only the step's saved action, read + again from storage on each call. Its own capabilities and current governance apply. +- **Read only.** Send mail, calendar invitations and mark-as-read are removed from a plan + step, even when the action enables them. An action that enables only those functions + stops with "Plans can only read Microsoft 365 data". +- **Personal conversations only.** In a shared conversation the step stops before reading + anything. Source-sharing approvals resume chat requests, not plan steps. +- **Approvals don't resume plans.** A step that needs an approval, such as deeper file + analysis, stops and links to Approvals. The user decides there and then selects **Retry + from failed step**. A retried step reuses its request, so the decision applies while + the action is unchanged. + +A refused Microsoft 365 call stops the step instead of becoming findings that the model +reports as data. Ordinary Microsoft Graph outcomes, such as nothing found or throttling, +are still findings. An answer that used Microsoft 365 keeps the tool calls in its sources, +so sharing the conversation later asks the user to approve Microsoft 365 history, as it +does for a chat answer. An **Ask an agent** step doesn't make its agent's Microsoft 365 +actions available. + ### Charts, diagrams, and images in answers Since **0.261.132**, orchestrated answers can include inline charts, Mermaid diagrams, and @@ -550,11 +584,16 @@ then report a rejected proposal. | A pending plan reports changed conversation context | A referenced message or its visibility changed after planning. | Create a new plan using the current conversation. | | A web or research step says it continued in the background, where the user's sign-in is not available | The run continued without the user's browser session, for example after a restart, so their app roles couldn't be confirmed. | Ask the user to send the request again from the chat. The new run uses their signed-in session. | | Every web search, linked-page or deep research step says a required retained result is unavailable or changed | Before 0.261.209, orchestration checked each user's roles through Microsoft Graph, which needs `Directory.Read.All`, and refused research whenever query or linked-page planning was on. | Upgrade to 0.261.209 or later; no Graph permission or settings change is needed. If a step still fails, check `sc_authority_reason` on its failure event in [orchestration failure diagnostics](../reference/logging-tags.md#orchestration-failure-diagnostics). | +| Every step completed, but the run is partially completed with "A required retained result is unavailable or changed" | Before 0.261.237, settings read from Cosmos DB instead of the shared Redis copy came back as an SDK type that the access recheck refused. That happens while any settings save is in progress, for example the Cosmos throughput autoscale saving its status, and on every read when Redis is off. The failure event logs `sc_authority_reason=result_external_context_unavailable`. | Upgrade to 0.261.237 or later. No setting change is needed. See the [settings document type fix]({{ '/explanation/fixes/ORCHESTRATION_SETTINGS_DOCUMENT_TYPE_FIX/' | relative_url }}). | | Plans never propose deep research or reading a link | The user does not hold the required app role, or the capability is disabled in its own settings group. | Confirm the user holds `DeepResearchUser` or `UrlAccessUser` where your deployment requires them, and that the capability is enabled outside this page. | | Plans never propose an agent | Semantic Kernel is off, the user has turned agents off in their own settings, or the user has no agent they can reach. | Confirm Semantic Kernel is enabled, then check the user's own agent setting and that at least one agent is shared with them. | | The planner model shows "not in the current model list" | Its connection or model was removed or disabled, or connections were switched on or off since it was chosen. | Choose a listed model, or **Use the answer model (default)**. Until then planning keeps trying the saved model and fails rather than switching. | | Plans never propose Use an action | Action Access is off, Semantic Kernel is off, the capability is excluded, or no eligible action is available to this user. | Check the opt-in and capability selection, then the existing action scope and governance. Call agent actions are not eligible for direct use. | | An action step fails after plan approval | The action or its access changed, or its model/tool connection could not run. | Check current action access and configuration. Review the visible step failure; the run does not silently switch to an agent or another action. | +| Before 0.261.238, a Microsoft 365 action step showed completed, but the answer said it couldn't read mail, calendar or files | The plan step had no Microsoft 365 request, so every call was refused with `m365_context_required` before reaching Microsoft Graph. `[MS_GRAPH_PLUGIN]` failure events show it as `sc_error_code_length` 21. | Upgrade to 0.261.238 or later. See [Microsoft 365 actions in plans](#microsoft-365-actions-in-plans). | +| A Microsoft 365 step says Microsoft 365 needs the user to sign in or grant access | The user hasn't connected that source for chat, or their sign-in expired or Microsoft Graph rejected it. | The user selects **Connect Microsoft 365** in the run details, or connects in Profile, then **Retry from failed step**. `[ORCHESTRATION_M365] A Microsoft 365 step stopped.` logs the reason as `sc_authority_reason`. | +| A Microsoft 365 step says plans can only read Microsoft 365 data | The action enables only send, invitation or mark-as-read functions, which plans never run. | Enable a read function on the action, or use it from chat without a plan. | +| A Microsoft 365 step says plans can't use Microsoft 365 in a shared conversation | Plans don't run Microsoft 365 actions in shared conversations. | Ask from the user's own conversation. | | A plan proposed reading a link but found nothing | The link was not available in the eligible user-authored context. | Paste the URL into the current request. Assistant-generated links and omitted historical text do not authorize page reads. | | Earlier runs are missing after switching devices | The conversation list has loaded but its run history has not been fetched yet, or the fetch failed. | The orchestration panel shows its own loading and retry states. If retrying keeps failing, check that the user can reach `/api/v2/orchestration/runs` and is the owner of the conversation. | | A restored plan will not run | It was already approved on the other device. | This is expected. The conversation reloads to show the answer that run produced. | diff --git a/docs/explanation/fixes/ORCHESTRATION_M365_ACTION_CONTEXT_FIX.md b/docs/explanation/fixes/ORCHESTRATION_M365_ACTION_CONTEXT_FIX.md new file mode 100644 index 000000000..ccb6b1011 --- /dev/null +++ b/docs/explanation/fixes/ORCHESTRATION_M365_ACTION_CONTEXT_FIX.md @@ -0,0 +1,217 @@ +# Orchestration Microsoft 365 Action Context Fix + +**Version: 0.261.238** + +Fixed in version: **0.261.238**, recorded in +`application/single_app/config.py`. + +This affects the React V2 branch (`paullizer-react-v2-ui`) and deployments built +from it. + +## Issue + +A V2 chat plan that used a Microsoft 365 action never read Microsoft 365 data. +For "what are my latest emails", the planner chose the user's Microsoft 365 Email +action, and the **Retrieve latest emails** step reported completed. The answer +then said the mail couldn't be retrieved. No Microsoft Graph request was made. + +Production telemetry for the step showed one +`[MS_GRAPH_PLUGIN] Microsoft 365 operation could not complete.` event for the +`get_my_messages` operation, about half a second after the model called the +email function, with `sc_error_code_length` 21. The event logged only the code's +length. Without a Microsoft 365 execution context, the function's first check can +only refuse with `m365_context_required`, which is 21 characters. + +## Root cause + +Each orchestration action step runs inside the execution identity's bridge: a +fresh Flask app and request context, built because the plan runs on a worker +thread. The bridge copies the user's signed-in session, but Flask's `g` starts +empty. + +Microsoft 365 functions require an `M365ExecutionContext` in `g`. It names the +data user, the conversation and its audience, and the logical request. It also +selects the actions that request may use. Classic chat creates it for each chat +request, and workflows create it for their Run as account. Orchestration never +created one, so: + +1. The email function's first authorization check, `authorize_m365_source`, + raised `M365PolicyError("m365_context_required")`. +2. The plugin returned that refusal as an ordinary function result. +3. The orchestration action loop treated every function result as data. The + model described the refusal as findings, and the step completed. + +Two more problems would have stopped the step once a context existed. Microsoft +365 selects actions through a chat agent selection, so a direct action step would +have been refused with `m365_action_not_selected`. The V2 UI had no way for a user +to connect Microsoft 365 after a step stopped for sign-in. + +## Changes + +### A Microsoft 365 request for each action step + +`step_m365_context()` in `functions_m365_runtime.py` creates the step's +`M365ExecutionContext` inside its bridge, through the new +`functions_orchestration_m365.py`. `invoke_action()` enters it once the step's +saved action resolves to a Microsoft 365 type. The context: + +- Uses the signed-in user as actor and data user, after confirming the bridge's + session belongs to them. +- Selects only the step's own saved action, by its exact action reference. + `resolve_m365_selected_manifests()` reads that selection again from storage on + each call, so current governance and the action's own capabilities apply. +- Removes `send_mail`, `create_calendar_invite` and `mark_message_as_read`, so + plans only read. Those functions are never loaded into the step's kernel. +- Refuses shared conversations before any Microsoft 365 work. Their + source-sharing approvals resume chat requests, not plan steps. +- Runs the existing Microsoft 365 preflight before the step's model or any + Microsoft Graph call. It checks the user's delegated sign-in for the action's + sources. +- Records the request in the Microsoft 365 execution container as running, then + completed or failed. The record keeps the run, attempt and step, so sharing the + conversation waits for a running step, as it does for a chat request. + +The request ID is derived from the plan's first attempt and the step. A retried +step reuses the same request, so an approval the user gave after the step +stopped applies while the action is unchanged. A stop for approval records the +approval on the request and marks it failed. Deciding that approval then reports +the request as not resumable, instead of resuming a chat request. When a retry +of the step finishes, Approvals shows its outcome on that approval, as it does +for a resumed chat. + +### Refusals stop the step + +A Microsoft 365 sign-in, approval or authorization refusal now stops the step, +whether it is raised or returned as a function result. Ordinary Microsoft Graph +outcomes, such as nothing found, throttling or access denied, stay findings that +the model reports. + +The step fails with one of these new failures. Each names what to do next: + +| Failure code | When | +| --- | --- | +| `m365_sign_in_required` | Delegated sign-in or consent is missing or expired, or Microsoft Graph rejected the token. | +| `m365_approval_required` | The step needs the user's approval, for example deeper file analysis. | +| `m365_unavailable` | The action, its sources or the request couldn't be authorized. | +| `m365_shared_conversation` | The conversation is shared. | +| `m365_read_only` | The action enables only send, invitation or mark-as-read functions. | + +`failure_from_exception()` reads the failure code from the stop. The sign-in and +approval failures also carry `m365_sources`, which `build_failure()` limits to +`calendar`, `email`, `onedrive` and `spo`. + +### Connect Microsoft 365 from the V2 run details + +When a run's failures include `m365_sign_in_required`, the V2 recovery notice shows +**Connect Microsoft 365** for the failure's sources. It uses the existing Profile +reconnect flow (`POST /api/m365/chat/connection/connect` with the +`X-M365-CSRF-Token` header) in a popup. It treats the Profile page's +`m365-profile-reconnected` message, or a newer saved connection covering the +sources, as success. The user then selects **Retry from failed step**. An approval +failure links to Approvals. No backend route changed. + +### Telemetry + +`log_m365_failure()` now also logs the refusal code as `sc_failure_code` and the +source as `sc_resource`. A step that stops logs +`[ORCHESTRATION_M365] A Microsoft 365 step stopped.` with `sc_failure_code`, +`sc_authority_reason` (the Microsoft 365 code), `sc_capability_id` and +`sc_source_count`. + +## Files modified + +| File | Change | +| --- | --- | +| `functions_orchestration_m365.py` | New. Step scope, stable request IDs, refusal-to-failure mapping and stop logging. | +| `functions_m365_runtime.py` | `step_m365_context()`, the step selection in `resolve_m365_selected_manifests()`, read-only filtering and the step's request record. | +| `functions_orchestration_actions.py` | Enters the step scope for Microsoft 365 actions and stops on Microsoft 365 refusals. | +| `functions_orchestration_adapters.py` | Passes each action step's stable request key and record origin. | +| `functions_orchestration_schema.py` | The five failures and `m365_sources`. | +| `functions_m365_transport.py` | Logs the refusal code and source as text. | +| `v2_ui/src/lib/m365Connect.ts` | New. The popup connect flow. | +| `v2_ui/src/components/chat/OrchestrationM365Notice.tsx` | New. Connect and Approvals follow-ups. | +| `v2_ui/src/components/chat/OrchestrationRecoveryNotice.tsx`, `v2_ui/src/lib/orchestration.ts` | Show the follow-up and keep `m365_sources`. | +| `config.py` | Version `0.261.238`. | + +## Validation + +### Tests + +`functional_tests/test_orchestration_m365_actions.py` runs the real action runner, +step scope, Microsoft 365 runtime and execution boundary, approval service and +Email plugin through a real Flask bridge. Storage, tokens, Microsoft Graph and the +model are doubled, and network access is blocked: + +- An email step reads mail through its own step context. Both token requests use + the step's request, the signed-in user and the conversation, and the request + record ends completed with the step's origin. +- A plan step never loads send or mark-as-read functions, even when the action + enables them. +- Missing sign-in stops the step before any model, Microsoft Graph or plugin + load, with `m365_sign_in_required` and the `email` source. +- A Microsoft Graph 401 fails the step instead of becoming findings. A 404 stays a + finding. +- Shared conversations and send-only actions are refused before any Microsoft 365 + work or record. +- A stop for approval records the approval. A retry reuses the request and + completes, and the approval then shows that outcome. A retry that stops for + another approval reports nothing on either approval. +- The step scope hides a chat agent selection and restores it afterward. A step + without a signed-in session is refused. +- An answer built from the step's tool calls counts as Email history for + conversation sharing. + +Each of ten deliberate breaks makes at least one of these 14 tests fail. The +breaks are: no step scope, refusals kept as data, write functions kept, a new +request for every attempt, shared conversations allowed, the approval not +recorded, sources dropped from failures, no sign-in check before the model, no +outcome reported on the approval, and an outcome reported while the step stops +for another approval. + +`ui_tests/test_v2_orchestration_m365_recovery.py` runs the real V2 recovery +notice and connect flow in Chromium, with HTTP responses doubled: + +- A sign-in stop connects only the step's known sources and sends the Microsoft + 365 CSRF header. The sign-in window closes when Profile reports the + connection, and retrying stays the user's choice. +- A window closed without a saved sign-in says sign-in wasn't completed, and + the user can connect again. +- A window that can't report, as when the API is on another origin, is + confirmed by the saved connection. +- A sign-in stop without sources links to Profile, an approval stop links to + Approvals, and other failures show neither. + +Removing the notice from the recovery view, or dropping `m365_sources` when a +failure is read, makes these tests fail. + +The V2 UI type-checks and builds with `npm run typecheck` and `npm run build`. + +### Before and after + +| Before | After | +| --- | --- | +| Every Microsoft 365 call in a plan was refused with `m365_context_required`, and the step reported completed. | The step reads as the signed-in user through its own Microsoft 365 request. | +| A refusal became findings, so the answer said the data couldn't be read. | A refusal stops the step with a failure that names the fix. | +| Telemetry showed only the refusal code's length. | Telemetry shows the refusal code and source. | +| A user had no way to connect Microsoft 365 from a plan. | The run details offer **Connect Microsoft 365**, then **Retry from failed step**. | + +## Limitations + +- Plans only read Microsoft 365 data. Sending mail or invitations from a plan + needs pending-action review designed for plan steps. +- Shared conversations are refused. Plan steps would need their own + source-sharing approval flow. +- An **Ask an agent** step doesn't make its agent's Microsoft 365 actions + available. A local agent called through a classic chat **Call agent** action + also runs in a bridge without a Microsoft 365 request. Giving it one needs care + inside workflows, which must use their Run as account. +- The V2 connect popup relies on the classic Profile page to confirm sign-in. + When the API is on another origin, the saved connection check confirms it after + the popup closes. + +## Related + +- [Microsoft 365 actions in plans](../../admin/orchestration.md#microsoft-365-actions-in-plans) +- [Microsoft 365 data and approvals](../../guides/microsoft-365-conversation-data.md) +- [Orchestration settings document type fix](ORCHESTRATION_SETTINGS_DOCUMENT_TYPE_FIX.md) +- [Logging tags](../../reference/logging-tags.md) diff --git a/docs/explanation/fixes/ORCHESTRATION_SETTINGS_DOCUMENT_TYPE_FIX.md b/docs/explanation/fixes/ORCHESTRATION_SETTINGS_DOCUMENT_TYPE_FIX.md new file mode 100644 index 000000000..7c993fb8f --- /dev/null +++ b/docs/explanation/fixes/ORCHESTRATION_SETTINGS_DOCUMENT_TYPE_FIX.md @@ -0,0 +1,130 @@ +# Orchestration Settings Document Type Fix + +**Version: 0.261.237** + +Fixed in version: **0.261.237**, recorded in +`application/single_app/config.py`. + +This affects the React V2 branch (`paullizer-react-v2-ui`) and deployments built +from it. + +## Issue + +A V2 chat orchestration run could finish every step and still end as +**Partially completed**, with: + +> A required retained result is unavailable or changed. No preview was +> substituted. + +The Plan panel showed every step as completed, so the failure looked +unrelated to the work that had run. It affected runs whose steps retained +external content: web search, linked pages, deep research, agents, actions and +explicit Fact Memory results. Retrying could succeed or fail again, depending on +timing. + +Production telemetry recorded the refusal at finalization: + +- `[ORCHESTRATION_EXECUTOR] Retained content could not be reauthorized for finalization.` +- `sc_failure_code=result_unavailable` +- `sc_authority_reason=result_external_context_unavailable` + +## Root cause + +Orchestration rechecks a retained external result's access each time the result +is used: before the step's acquisition, when the result is admitted, and again +when the final answer reads it. Each check reads the current admin settings and +requires them to be exactly a `dict` (`type(settings) is dict`). This strict check +rejects mapping-like or forged values. + +The shared settings store did not always return a `dict`: + +- A read served from the shared Redis copy decoded JSON, which is a plain `dict`. +- A read served from Cosmos DB returned the SDK's response object. The + `azure-cosmos` client returns `CosmosDict`, a `dict` subclass that carries + response headers, and `copy.deepcopy` keeps that subclass. The orchestration + check refused it as invalid context. + +With Redis enabled, Cosmos serves settings reads whenever another worker's +settings save is in progress, a missing or abandoned shared copy is repaired, +Redis is unreachable, or a caller forces a Cosmos read. With Redis disabled, +Cosmos serves every read, so every such step would fail. + +In the reported run, the email step and the summary step both completed. The +Cosmos throughput autoscale background task then saved its status to the +settings, and the final answer's recheck read the settings during that save: + +| Time (UTC) | Event | +| --- | --- | +| 16:24:30.275 | Autoscale submitted a throughput update and started its settings save. | +| 16:24:30.433 | Finalization read settings from Cosmos DB, received `CosmosDict`, and refused the email result. | +| 16:24:30.444 | The settings save was published. | + +Settings saves happen throughout the day. Background tasks and admin changes +both write settings, so how often a run failed depended on timing. + +## Changes + +`application/single_app/app_settings_store.py` adds `_plain_document()`. It +copies a Cosmos response into a plain `dict` before the store returns it. Both +Cosmos paths use it: + +- `_read_cosmos()`, which serves every Cosmos read, including forced reads, + reads during another worker's save, cache repair and the Redis fallback. +- `_write()`, which returns the saved document after a write or the creation of + missing settings. + +Every settings read now returns the same type, whichever backend served it. The +orchestration checks are unchanged and still refuse anything that is not exactly +a `dict`. + +| File | Change | +| --- | --- | +| `app_settings_store.py` | Cosmos reads and writes return plain `dict` copies. | +| `config.py` | Version `0.261.237`. | +| `functional_tests/test_app_settings_store_plain_dict_reads.py` | New regression tests. | + +No setting, deployment or data change is needed. + +## Validation + +### Tests + +`functional_tests/test_app_settings_store_plain_dict_reads.py` uses a Cosmos fake +that returns the real SDK `CosmosDict` type: + +- Every store read path returns an independent plain `dict`: Redis disabled, the + shared copy, a forced Cosmos read, another worker's save in progress, an + abandoned save, a cache miss, and Redis unavailable. +- Writes and creating missing settings return plain `dict` copies. +- The real `get_settings()` returns a plain `dict` with Redis disabled, during + another worker's save, and with Redis unavailable. +- For web search, linked pages, deep research, agents and actions, the real + orchestration provider runs step preflight, admission and the finalization + recheck while another worker's settings save is in progress. The retained + result stays readable. +- The orchestration access checks still refuse a `CosmosDict`, so the fix stays in + the store and the strict check is kept. + +On the previous code, 17 of the 21 tests fail. All 21 pass with the fix. + +Related suites that pass with the change: `test_app_settings_store_consistency.py`, +`test_app_settings_import_boundaries.py`, `test_app_settings_embedding_write_guard.py`, +`test_embedding_settings_concurrency.py`, `test_admin_multi_endpoint_persistence_guard.py`, +`test_orchestration_external_sources.py`, `test_orchestration_external_identity.py`, +`test_orchestration_external_bootstrap.py` and `test_orchestration_external_pre_effect.py`. + +### Before and after + +| Before | After | +| --- | --- | +| A step or answer that read settings during a settings save, or with Redis disabled, failed with "A required retained result is unavailable or changed." | Settings have the same type from Redis and from Cosmos DB, and the retained result stays available. | +| Retrying could fail again, depending on when other settings saves happened. | The outcome no longer depends on settings save timing. | + +## Related + +The same reported run showed a second, unrelated problem: the email step +completed without reading mail, because Microsoft 365 actions could not +authorize inside orchestration. That is addressed separately. + +- [Orchestration session identity fix](ORCHESTRATION_SESSION_IDENTITY_FIX.md) +- [Orchestration settings](../../admin/orchestration.md) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 2b0f0c962..dfbbbf757 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,36 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.238)** + +#### Bug Fixes + +* **Microsoft 365 Actions Now Work In Chat Orchestration Plans** + * A plan step that used a Microsoft 365 action, such as "what are my latest emails", never read Microsoft 365 data. The step reported completed, and the answer said the data couldn't be retrieved. No Microsoft Graph request was made. + * Each plan step runs in its own request context, and Microsoft 365 functions require a Microsoft 365 execution context on that request. Classic chat and workflows create one, but orchestration never did, so every call was refused with `m365_context_required`. The plugin returned the refusal as a function result, and the step reported it as findings. + * An action step now gets its own Microsoft 365 request as the signed-in user. It selects only the step's saved action, checks delegated sign-in before the model or Microsoft Graph is called, and records the request so sharing the conversation waits for it. A retried step reuses the request, so an approval given after the first attempt applies. + * Plans only read Microsoft 365 data: send mail, calendar invitation and mark-as-read functions aren't loaded. Shared conversations are refused before any Microsoft 365 work. + * A sign-in, approval or authorization refusal now stops the step with `m365_sign_in_required`, `m365_approval_required`, `m365_unavailable`, `m365_shared_conversation` or `m365_read_only`. Each message says what to do next. Ordinary Microsoft Graph outcomes, such as nothing found, are still reported as findings. + * `[MS_GRAPH_PLUGIN] Microsoft 365 operation could not complete.` now logs the refusal code as `sc_failure_code` and the source as `sc_resource`. A stopped step logs `[ORCHESTRATION_M365] A Microsoft 365 step stopped.` + * (Ref: `functions_orchestration_m365.py`, `functions_m365_runtime.py`, `functions_orchestration_actions.py`, `functions_orchestration_adapters.py`, `functions_orchestration_schema.py`, `functions_m365_transport.py`, `test_orchestration_m365_actions.py`, [Orchestration Microsoft 365 Action Context Fix](fixes/ORCHESTRATION_M365_ACTION_CONTEXT_FIX.md)) + +#### User Interface Enhancements + +* **Connect Microsoft 365 From A Failed Plan Step** + * When a V2 plan step stops for Microsoft 365 sign-in, the run details show **Connect Microsoft 365** for the sources the step needs. It opens the Profile reconnect sign-in in a pop-up. When it finishes, select **Retry from failed step**. + * A step that stopped for approval links to Approvals instead. + * (Ref: `v2_ui/src/lib/m365Connect.ts`, `OrchestrationM365Notice.tsx`, `OrchestrationRecoveryNotice.tsx`, `test_v2_orchestration_m365_recovery.py`, [Microsoft 365 data and approvals](../guides/microsoft-365-conversation-data.md)) + +### **(v0.261.237)** + +#### Bug Fixes + +* **Orchestration Runs No Longer Fail When Settings Are Read During A Save** + * A chat orchestration run could finish every step and still end as **Partially completed** with "A required retained result is unavailable or changed." It affected runs that kept web search, linked-page, deep research, agent, action or Fact Memory results, and retrying could fail the same way. + * Orchestration rechecks a kept result's access with the current admin settings, and that check requires exactly a `dict`. Settings read from the shared Redis copy were a `dict`, but settings read from Cosmos DB came back as the SDK's `CosmosDict` type, so the check refused them. Cosmos serves the read while any settings save is in progress, such as the Cosmos throughput autoscale saving its status, when the shared copy is repaired or unreachable, and on every read when Redis is off. + * The settings store now returns a plain `dict` from every read and write, whichever backend served it. The orchestration check is unchanged. No setting or deployment change is needed. + * (Ref: `app_settings_store.py`, `functions_orchestration_external_sources.py`, `test_app_settings_store_plain_dict_reads.py`, [Orchestration Settings Document Type Fix](fixes/ORCHESTRATION_SETTINGS_DOCUMENT_TYPE_FIX.md)) + ### **(v0.261.236)** #### User Interface Enhancements diff --git a/docs/guides/microsoft-365-conversation-data.md b/docs/guides/microsoft-365-conversation-data.md index 33f793f4f..f7b845345 100644 --- a/docs/guides/microsoft-365-conversation-data.md +++ b/docs/guides/microsoft-365-conversation-data.md @@ -71,6 +71,35 @@ access. A disconnected workflow account does not mean interactive chat is disabled. Reloading already-published file evidence also does not require a fresh source sign-in. +## Use Microsoft 365 in a plan + +Implemented in version: **0.261.238** (`application/single_app/config.py`). + +With **Orchestrate** on in V2 chat, a plan can include a **Use an action** step +that runs one of your Calendar, Email, OneDrive, or SharePoint Online actions. +The step reads as you, the person who sent the request, and uses only that one +action. An agent step doesn't make its agent's Microsoft 365 actions available. + +Plans only read Microsoft 365 data. Sending mail, sending calendar invitations, +and marking mail as read aren't offered to a plan step, even when the action +allows them. Ask in chat without a plan to prepare a message for review. + +The step checks your delegated Microsoft 365 access before any model or +Microsoft Graph call. If you haven't connected the source, or your sign-in has +expired, the step stops and the plan's run details show **Connect Microsoft +365** for the sources that step needs. Sign in in the window that opens, then +select **Retry from failed step**. Completed steps aren't repeated. + +Some file analysis needs your approval first. The step then stops and links to +**Approvals**. Deciding the approval doesn't restart the plan. Select **Retry +from failed step** afterward: the retried step continues the same Microsoft 365 +request, so an approval you gave applies while the action is unchanged. + +Plans don't use Microsoft 365 in shared conversations, and a step there stops +before reading anything. An answer from a plan step that read Microsoft 365 +counts as Microsoft 365 history: sharing that conversation later asks for your +approval, as it does for a chat answer. + ## Review an email or meeting before delivery Chat shows a saved action card for manual or delayed Email and Calendar diff --git a/docs/reference/logging-tags.md b/docs/reference/logging-tags.md index 7151443d3..d9a5e18e5 100644 --- a/docs/reference/logging-tags.md +++ b/docs/reference/logging-tags.md @@ -178,6 +178,7 @@ Last inventoried: 2026-08-10 - `[ORCHESTRATION_ADAPTERS]` - `[ORCHESTRATION_CONTEXT]` - `[ORCHESTRATION_EXECUTOR]` +- `[ORCHESTRATION_M365]` - `[ORCHESTRATION_PLANNER]` - `[ORCHESTRATION_REGISTRY]` - `[ORCHESTRATION_RUNS]` @@ -383,6 +384,8 @@ file names, file content, prompts, or model responses. | `[ORCHESTRATION] Retrying the answer model without a JSON response format.` | Information | `sc_reason` (`json_format_retry`), `sc_stage`, `sc_error_type`. Content preparation asks the endpoint for a JSON object; an endpoint that refuses that option is asked again without it. | | `[ORCHESTRATION_ADAPTERS] Native result bridge did not complete.` | Warning | `sc_stage`, `sc_failure_code`, `sc_execution_code`, such as `native_compute_source_identity_mismatch` when a saved spreadsheet location now points at a different revision. | | `[CONTENT_SCREENING] Current source authority could not be verified.` | Warning | `sc_failure_code`, `sc_authority_reason`, `sc_error_type`. | +| `[ORCHESTRATION_M365] A Microsoft 365 step stopped.` | Warning | Since **0.261.238**. `sc_failure_code` (`m365_sign_in_required`, `m365_approval_required`, `m365_unavailable`, `m365_shared_conversation`, `m365_read_only` or `external_session_required`), `sc_authority_reason` (the Microsoft 365 refusal code, such as `interactive_auth_required`, `authentication_required`, `m365_action_not_authorized` or `m365_shared_conversation_unsupported`), `sc_capability_id`, and `sc_source_count`. | +| `[MS_GRAPH_PLUGIN] Microsoft 365 operation could not complete.` | Warning | `sc_operation`. Since **0.261.238**, also `sc_failure_code`, the refusal code (for example `m365_context_required`), and `sc_resource`, the source (`email`, `calendar`, `onedrive` or `spo`). Earlier versions logged only the code's length, `sc_error_code_length`. | Every `[ORCHESTRATION_EXECUTOR]` and `[ORCHESTRATION_ADAPTERS]` event in this table carries `sc_run_id_hash`, `sc_conversation_id_hash`, and `sc_step_id_hash`; the diff --git a/functional_tests/test_app_settings_store_plain_dict_reads.py b/functional_tests/test_app_settings_store_plain_dict_reads.py new file mode 100644 index 000000000..79613acc3 --- /dev/null +++ b/functional_tests/test_app_settings_store_plain_dict_reads.py @@ -0,0 +1,230 @@ +# test_app_settings_store_plain_dict_reads.py +""" +Regression tests for the type of the settings that orchestration access checks receive. +Version: 0.261.237 +Implemented in: 0.261.237 + +azure-cosmos returns the settings document as ``CosmosDict``, a ``dict`` subclass, and +``copy.deepcopy`` keeps that subclass. Orchestration's retained-result access checks +require exactly ``dict``. Before this fix, a settings read that Cosmos served instead of +the shared Redis copy made web search, linked-page, deep research, agent, action and +memory results fail with "A required retained result is unavailable or changed." That +was every read with Redis disabled, and with Redis enabled any read that overlapped +another worker's settings save, a cache repair or a Redis fallback. In production a +Cosmos throughput autoscale save made an email run fail this way at finalization, +after every step had completed. + +The Cosmos fake returns the real SDK response type, the real settings store and getter +serve the reads, and the real orchestration access checks consume them. Network access +is blocked by the reused fixtures. +""" + +import copy +import json +from pathlib import Path +import sys +import time + +import pytest +from azure.core.utils import CaseInsensitiveDict +from azure.cosmos._cosmos_responses import CosmosDict + +# The reused modules below put the application and this directory on sys.path. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from test_app_settings_store_consistency import ( # noqa: E402 - shared store fakes and real getter harness + FakeCosmos, + FakeRedis, + change, + load_get_settings, + store_module, +) +from test_orchestration_external_sources import ExternalSourceWorld # noqa: E402 +from functions_orchestration_results import ResultUnavailableError # noqa: E402 + + +AppSettingsStore = store_module.AppSettingsStore +GATHER_CAPABILITIES = ("web_search", "url_fetch", "deep_research", "agent_invoke", "action_invoke") + + +class SdkCosmos(FakeCosmos): + """The shared settings fake, answering with the response type azure-cosmos returns.""" + + def __init__(self, document=None): + super().__init__() + if document is not None: + self.document = copy.deepcopy(document) + self.document["nested"] = {"items": [1, 2]} + + def _response(self, response_hook=None): + return CosmosDict( + super()._response(response_hook), + response_headers=CaseInsensitiveDict({"x-ms-session-token": f"token-{self.etag}"}), + ) + + +def save_marker(deadline): + """Another worker's in-progress save, as the store writes it to Redis.""" + return json.dumps({ + "state": "pending", "owner": "another-worker", "deadline": deadline, "session_token": None, + }).encode() + + +def published_store(cosmos, redis): + store = AppSettingsStore(cosmos, redis, redis_required=True) + store.write(change(enabled=True)) + assert json.loads(redis.raw)["state"] == "ready" + return store + + +def read_without_redis(cosmos, _redis): + return AppSettingsStore(cosmos).read() + + +def read_shared_copy(cosmos, redis): + return published_store(cosmos, redis).read() + + +def read_forced_cosmos(cosmos, redis): + return published_store(cosmos, redis).read(use_cosmos=True) + + +def read_during_another_save(cosmos, redis): + store = published_store(cosmos, redis) + redis.raw = save_marker(time.time() + 60) + return store.read() + + +def read_after_abandoned_save(cosmos, redis): + store = published_store(cosmos, redis) + redis.raw = save_marker(time.time() - 1) + return store.read() + + +def read_after_cache_miss(cosmos, redis): + redis.raw = None + return AppSettingsStore(cosmos, redis, redis_required=True).read() + + +def read_with_redis_unavailable(cosmos, redis): + redis.failed = True + return AppSettingsStore(cosmos, redis, redis_required=True).read() + + +READ_PATHS = { + "redis disabled": read_without_redis, + "shared copy": read_shared_copy, + "forced Cosmos read": read_forced_cosmos, + "another worker's save in progress": read_during_another_save, + "abandoned save repaired": read_after_abandoned_save, + "cache miss repaired": read_after_cache_miss, + "Redis unavailable": read_with_redis_unavailable, +} + + +def assert_plain_independent_copy(value, cosmos): + assert type(value) is dict + assert value["_etag"] == cosmos.document["_etag"] + assert type(value["nested"]) is dict + value["nested"]["items"].append(3) + assert cosmos.document["nested"]["items"] == [1, 2] + + +def test_fake_reproduces_the_sdk_response_type(): + """The reproduction depends on the SDK subclass surviving deepcopy.""" + response = SdkCosmos().read_item("app_settings", "app_settings") + assert type(response) is CosmosDict + assert type(copy.deepcopy(response)) is CosmosDict + assert isinstance(response, dict) + + +@pytest.mark.parametrize("read", READ_PATHS.values(), ids=READ_PATHS.keys()) +def test_every_store_read_path_returns_a_plain_dict(read): + cosmos = SdkCosmos() + value = read(cosmos, FakeRedis()) + assert_plain_independent_copy(value, cosmos) + + +@pytest.mark.parametrize("redis_required", (False, True), ids=("redis disabled", "redis enabled")) +def test_store_writes_return_a_plain_dict(redis_required): + cosmos = SdkCosmos() + store = AppSettingsStore(cosmos, FakeRedis() if redis_required else None, redis_required=redis_required) + saved = store.write(change(enabled=True)) + assert saved["enabled"] is True + assert_plain_independent_copy(saved, cosmos) + + +def test_creating_missing_settings_returns_a_plain_dict(): + cosmos = SdkCosmos() + cosmos.document = None + created = AppSettingsStore(cosmos).write( + lambda document: document, defaults={"id": "app_settings", "nested": {"items": [1, 2]}}, + ) + assert_plain_independent_copy(created, cosmos) + + +@pytest.mark.parametrize( + "state", ("redis disabled", "another worker's save in progress", "Redis unavailable"), +) +def test_real_get_settings_returns_a_plain_dict(state): + cosmos, redis = SdkCosmos(), FakeRedis() + if state == "redis disabled": + store = AppSettingsStore(cosmos) + else: + store = published_store(cosmos, redis) + if state == "Redis unavailable": + redis.failed = True + else: + redis.raw = save_marker(time.time() + 60) + settings = load_get_settings(store)() + assert type(settings) is dict + assert type(settings["nested"]) is dict + + +def settings_store_during_another_save(world): + """Serve the world's settings from Cosmos while another worker's save is in progress.""" + cosmos, redis = SdkCosmos({**world.settings, "id": "app_settings", "_etag": "1"}), FakeRedis() + redis.raw = save_marker(time.time() + 600) + return cosmos, AppSettingsStore(cosmos, redis, redis_required=True) + + +@pytest.mark.parametrize("capability_id", GATHER_CAPABILITIES) +def test_retained_results_are_usable_while_another_worker_saves_settings(capability_id): + """Step preflight, admission and the finalization recheck all read settings mid-save.""" + with ExternalSourceWorld(capability_id) as world: + world.run["user_message"] = "Read https://public.example/page." + cosmos, store = settings_store_during_another_save(world) + read_settings = load_get_settings(store) + provider = world.provider(read_settings=read_settings) + world.preflight(provider) + catalog = world.admit(provider) + task = world.persist(world.service(provider, catalog), catalog) + reference = task.output("prepared") + finalizer = world.service(world.provider(read_settings=read_settings)) + reader = finalizer.open_result(reference, allow_partial=True, require_current_sources=True) + reader.recheck() + assert reader.read_value() == world.prepared + # Cosmos served every settings read, the path that used to return CosmosDict. + assert cosmos.tokens + assert json.loads(store.redis.raw)["state"] == "pending" + + +@pytest.mark.parametrize("capability_id", ("web_search", "action_invoke")) +def test_access_checks_still_require_exactly_dict(capability_id): + """The fix belongs to the store: a dict subclass is still refused, as in production.""" + with ExternalSourceWorld(capability_id) as world: + _provider, _catalog, reference = world.save() + sdk_settings = CosmosDict(copy.deepcopy(world.settings), response_headers=CaseInsensitiveDict()) + provider = world.provider(read_settings=lambda: copy.deepcopy(sdk_settings)) + with pytest.raises(ResultUnavailableError) as preflight: + world.preflight(provider) + assert preflight.value.code == "result_external_context_unavailable" + with pytest.raises(ResultUnavailableError) as finalization: + world.service(provider).open_result( + reference, allow_partial=True, require_current_sources=True, + ).recheck() + assert finalization.value.code == "result_external_context_unavailable" + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/functional_tests/test_orchestration_m365_actions.py b/functional_tests/test_orchestration_m365_actions.py new file mode 100644 index 000000000..4ce015342 --- /dev/null +++ b/functional_tests/test_orchestration_m365_actions.py @@ -0,0 +1,562 @@ +# test_orchestration_m365_actions.py +#!/usr/bin/env python3 +""" +Functional test for Microsoft 365 actions in chat orchestration. +Version: 0.261.238 +Implemented in: 0.261.238 + +An orchestration "Use an action" step runs in its own request context, the execution +identity's bridge. Before 0.261.238 no Microsoft 365 execution context existed there, so +every Microsoft 365 function refused the call with ``m365_context_required`` before reaching +Microsoft Graph, and the step still reported completed. + +These tests run the real action runner, step scope, Microsoft 365 runtime, execution +boundary, approval service and email plugin through a real Flask bridge. Storage, tokens, +Microsoft Graph and the model are doubled, and network access is blocked. +""" + +import asyncio +import importlib +import json +import logging +import socket +import sys +from contextlib import nullcontext +from copy import deepcopy +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import patch + +import pytest +import requests +from flask import Flask, g, session +from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion +from semantic_kernel.contents import AuthorRole, ChatMessageContent, FunctionCallContent + +TESTS = Path(__file__).resolve().parent +APP = TESTS.parent / "application" / "single_app" +sys.path.insert(0, str(APP)) +sys.path.insert(0, str(TESTS)) + +from test_support.m365 import CosmosContainer # noqa: E402 - the test path is set above + +USER, TENANT, CONVERSATION = "user-1", "tenant-1", "conversation-1" +ACTION_REF = "action:v1:personal:user-1:mail" +ROOT_RUN, STEP = "run-root", "retrieve_latest_emails" +REQUEST_KEY = f"{ROOT_RUN}\x00{STEP}" +MESSAGES = {"value": [{ + "id": "message-1", "subject": "Quarterly review", "isRead": False, + "from": {"emailAddress": {"name": "Ada", "address": "ada@example.test"}}, + "receivedDateTime": "2026-10-05T16:00:00Z", "bodyPreview": "Agenda attached.", +}]} + + +def module(name, **values): + result = ModuleType(name) + result.__dict__.update(values) + return result + + +def is_app_module(value): + path = getattr(value, "__file__", None) + try: + return bool(path) and Path(path).resolve().is_relative_to(APP) + except (OSError, ValueError): + return False + + +class FakeResponse: + def __init__(self, payload, status=200): + self.status_code = status + self.body = json.dumps(payload).encode() + self.headers = {"Content-Type": "application/json"} + + def iter_content(self, chunk_size): + yield self.body + + def close(self): + pass + + +def mail_action(**capabilities): + return { + "id": "mail", "name": "m365_email", "display_name": "Mail", "type": "m365_email", + "description": "Read recent mail.", "action_ref": ACTION_REF, + "scope_type": "personal", "scope_id": USER, "user_id": USER, "is_enabled": True, + "additionalFields": {"m365_capabilities": {"get_my_messages": True, **capabilities}}, + } + + +def stub_modules(state): + def read_conversation(item, partition_key): + if item != CONVERSATION or partition_key != CONVERSATION: + raise AssertionError("Only the step's own conversation may be read") + return deepcopy(state.conversation) + + def participation(user_id, conversation): + return dict(state.participation) + + return { + "config": module( + "config", TENANT_ID=TENANT, + cosmos_conversations_container=SimpleNamespace(read_item=read_conversation), + cosmos_m365_execution_runs_container=state.jobs, + cosmos_msgraph_pending_actions_container=None, + ), + "functions_appinsights": module( + "functions_appinsights", + log_event=lambda message, *args, **kwargs: state.logs.append((message, kwargs.get("extra") or {})), + debug_print=lambda *args, **kwargs: None, is_debug_enabled=lambda *args, **kwargs: False, + get_appinsights_logger=lambda: logging.getLogger("test_orchestration_m365"), + workflow_log_context=lambda **kwargs: {}, + ), + "functions_activity_logging": module("functions_activity_logging"), + "functions_settings": module( + "functions_settings", get_settings=lambda: deepcopy(state.settings), + get_user_settings=lambda user_id: {"id": user_id, "settings": {}}, + update_settings=lambda payload: True, sanitize_settings_for_user=lambda values: dict(values), + ), + "functions_authentication": module( + "functions_authentication", AZURE_ENVIRONMENT="public", CUSTOM_GRAPH_URL_VALUE="", + get_graph_base_url=lambda: "https://graph.microsoft.com/v1.0", + get_graph_authority=lambda: f"https://login.microsoftonline.com/{TENANT}", + get_current_user_info=lambda: {"userId": USER}, get_current_user_id=lambda: USER, + ), + "functions_group": module( + "functions_group", assert_group_role=lambda *args, **kwargs: None, + find_group_by_id=lambda *args: None, require_active_group=lambda *args: None, + get_user_groups=lambda user_id: [], + ), + "functions_collaboration": module( + "functions_collaboration", build_conversation_participation_context=participation, + get_collaboration_conversation=lambda shared_id: {"id": shared_id, "participants": [USER]}, + assert_user_can_participate_in_collaboration_conversation=lambda *args: None, + ), + "functions_action_catalog": module( + "functions_action_catalog", + resolve_action_manifest=lambda user_id, action_ref, **kwargs: state.resolve(user_id, action_ref), + build_action_planner_projection=lambda actions: [], + ), + "functions_personal_actions": module( + "functions_personal_actions", get_personal_actions=lambda user_id, **kwargs: [deepcopy(state.action)], + ), + "functions_global_actions": module("functions_global_actions", get_global_actions=lambda **kwargs: []), + "functions_group_actions": module( + "functions_group_actions", get_governed_group_actions=lambda *args, **kwargs: [], + ), + "functions_governance": module( + "functions_governance", + filter_actions_by_action_type_access=lambda user_id, actions, *args: actions, + filter_governed_global_actions_for_user=lambda user_id, actions: actions, + ), + "functions_keyvault": module("functions_keyvault", SecretReturnType=SimpleNamespace(NAME="name")), + "semantic_kernel_loader": module( + "semantic_kernel_loader", + get_max_auto_invoke_attempts=lambda settings: 5, + prepare_action_plugin_manifest=lambda manifest, settings: state.prepare(manifest), + ), + "semantic_kernel_plugins.logged_plugin_loader": module( + "semantic_kernel_plugins.logged_plugin_loader", + create_logged_plugin_loader=lambda kernel: state.loader(kernel), + ), + } + + +@pytest.fixture(scope="module") +def env(): + """Fresh copies of the real modules, bound to the doubles and discarded afterward.""" + state = SimpleNamespace(jobs=CosmosContainer("user_id"), logs=[], settings={}) + with patch.dict(sys.modules): + for name in [name for name, value in list(sys.modules.items()) if is_app_module(value)]: + sys.modules.pop(name) + sys.modules.update(stub_modules(state)) + modules = SimpleNamespace( + state=state, + actions=importlib.import_module("functions_orchestration_actions"), + adapters=importlib.import_module("functions_orchestration_adapters"), + orchestration=importlib.import_module("functions_orchestration_m365"), + runtime=importlib.import_module("functions_m365_runtime"), + execution=importlib.import_module("functions_m365_execution"), + connections=importlib.import_module("functions_m365_connections"), + approvals=importlib.import_module("functions_m365_approvals"), + contexts=importlib.import_module("agent_execution_context"), + capture=importlib.import_module("functions_orchestration_invocation_capture"), + schema=importlib.import_module("functions_orchestration_schema"), + history=importlib.import_module("functions_m365_history"), + email=importlib.import_module("semantic_kernel_plugins.m365_email_plugin"), + ) + modules.execution.configure_m365_execution( + action_config_resolver=modules.runtime.resolve_m365_action_config, + action_selection_resolver=modules.runtime.resolve_m365_action_selection, + ) + yield modules + + +@pytest.fixture +def world(env, monkeypatch): + real_connect = socket.socket.connect + + def local_only(sock, address, *args, **kwargs): + # The Windows asyncio loop wakes itself through a loopback socket pair. + if isinstance(address, tuple) and address[0] in ("127.0.0.1", "::1"): + return real_connect(sock, address, *args, **kwargs) + raise AssertionError("Tests must not contact external services") + + monkeypatch.setattr(socket.socket, "connect", local_only) + state = env.state + state.__dict__.clear() + state.__dict__.update( + jobs=CosmosContainer("user_id"), logs=[], requests=[], replies=[], graph_calls=[], + token_requests=[], loaded_functions=[], token_error=None, graph_error=None, + graph_response=(200, MESSAGES), participation={}, action=mail_action(send_mail=True), + conversation={"id": CONVERSATION, "user_id": USER}, + settings={ + "enable_chat_orchestration": True, "enable_chat_orchestration_actions": True, + "enable_semantic_kernel": True, "max_auto_invoke_attempts": 5, + }, + ) + env.runtime.cosmos_m365_execution_runs_container = state.jobs + sys.modules["config"].cosmos_m365_execution_runs_container = state.jobs + + def resolve(user_id, action_ref): + if user_id != USER or action_ref != ACTION_REF: + raise PermissionError("The action is unavailable.") + return deepcopy(state.action) + + def prepare(manifest): + # The loader's own Microsoft 365 preflight, which narrows the action to the selection. + manifests = [deepcopy(manifest)] + if env.execution.get_m365_execution_context() is not None: + manifests = env.execution.preflight_m365_manifests(manifests) + if len(manifests) != 1: + raise PermissionError("The selected action is not available for this execution.") + return manifests[0] + + class Loader: + def __init__(self, kernel): + self.kernel = kernel + self.plugin_instances = [] + + def load_plugin_from_manifest(self, manifest, user_id): + plugin = env.email.M365EmailPlugin(deepcopy(manifest)) + state.loaded_functions.append(sorted(plugin.get_functions())) + self.plugin_instances.append(plugin) + self.kernel.add_plugin(plugin.get_kernel_plugin("m365_email")) + return True + + def token(scopes, context=None, *, include_auth_url=True): + context = context or env.connections.get_m365_execution_context() + state.token_requests.append({ + "scopes": list(scopes), "request_id": context.request_id, + "actor": context.actor_user_id, "data": context.data_user_id, + "conversation": context.conversation_id, "shared": context.shared, + "session_user": (session.get("user") or {}).get("oid"), + }) + if state.token_error: + return {"error": state.token_error, "message": "Sign in again.", "scopes": list(scopes)} + return {"access_token": "delegated-token"} + + def graph(method, url, **kwargs): + state.graph_calls.append({"method": method, "url": url, "authorization": kwargs["headers"]["Authorization"]}) + if state.graph_error is not None: + raise state.graph_error + status, payload = state.graph_response + return FakeResponse(payload, status) + + async def reply(service, history, settings): + state.requests.append(str(history)) + if state.replies: + return [state.replies.pop(0)] + return [ChatMessageContent(role=AuthorRole.ASSISTANT, content="Ada sent Quarterly review.")] + + state.resolve, state.prepare, state.loader = resolve, prepare, Loader + monkeypatch.setattr(env.connections, "get_m365_access_token", token) + monkeypatch.setattr(requests, "request", graph) + monkeypatch.setattr(AzureChatCompletion, "_inner_get_chat_message_contents", reply) + state.service = AzureChatCompletion( + service_id="orchestration-action", deployment_name="test-model", + endpoint="https://example.invalid", api_key="not-a-real-key", api_version="2024-10-21", + ) + monkeypatch.setattr(env.actions, "_build_action_model", lambda *args, capture_configuration=False: ( + (state.service, {"provider": "aoai", "parameters": {}}) if capture_configuration else state.service + )) + app = Flask(__name__) + app.secret_key = "test-secret-key-for-orchestration-m365-tests" + state.app = app + yield state + if not state.service.client.is_closed(): + asyncio.run(state.service.client.close()) + + +def tool_call(function="m365_email-get_my_messages", **arguments): + return ChatMessageContent( + role=AuthorRole.ASSISTANT, + items=[FunctionCallContent(id="call-1", name=function, arguments=json.dumps(arguments or {"top": 2}))], + ) + + +def run_step(env, world, *, request_key=REQUEST_KEY, captured=None): + """Run the action step the way orchestration does: inside the signed-in request.""" + with world.app.test_request_context("/api/v2/orchestration/run", base_url="https://simplechat.example"): + session["user"] = {"oid": USER, "tid": TENANT, "roles": ["User"], "preferred_username": "user@example.test"} + identity = env.contexts.capture_execution_identity(USER, CONVERSATION) + context = SimpleNamespace( + action_catalog=[{key: world.action[key] for key in ( + "action_ref", "id", "name", "display_name", "description", "type", "scope_type", "scope_id", + )}], + active_group_ids=[], token_usage={}, notes=[], user_id=USER, conversation_id=CONVERSATION, + agent_execution_identity=identity, delegation_budget=env.contexts.DelegationBudget(), + ) + + def capture(source_type, *, settings, source=None, selector=None): + if captured is not None: + captured.append((source_type, selector)) + return None if source is None else {"captured": True} + + invocation_capture = env.capture.OrchestrationInvocationCapture(capture) + result = asyncio.run(env.actions.invoke_action( + ACTION_REF, "Retrieve my latest emails.", context, settings=deepcopy(world.settings), + user_id=USER, cancel_requested=lambda: False, invocation_capture=invocation_capture, + m365_request_key=request_key, + m365_origin={"run_id": "run-2", "attempt_index": 2, "step_id": STEP}, + )) + return result, context + + +def stopped(env, world, **kwargs): + with pytest.raises(env.orchestration.OrchestrationM365Error) as caught: + run_step(env, world, **kwargs) + return caught.value + + +def request_record(env, world, request_key=REQUEST_KEY): + return world.jobs.read_item(env.orchestration.step_request_id(request_key), partition_key=USER) + + +def test_email_action_step_reads_mail_through_its_own_step_context(env, world): + world.replies = [tool_call()] + captured = [] + result, context = run_step(env, world, captured=captured) + + request_id = env.orchestration.step_request_id(REQUEST_KEY) + assert result["calls"] == 1 + assert "Quarterly review" in result["findings"] + assert [call["url"] for call in world.graph_calls] == [ + "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages", + ] + assert world.graph_calls[0]["authorization"].endswith("delegated-token") + # The sign-in check runs before the model; the Graph call reuses the same step request. + assert len(world.token_requests) == 2 + assert all(request == { + "scopes": request["scopes"], "request_id": request_id, "actor": USER, "data": USER, + "conversation": CONVERSATION, "shared": False, "session_user": USER, + } for request in world.token_requests) + assert request_id.startswith("orch-") and request_id == env.orchestration.step_request_id(REQUEST_KEY) + record = request_record(env, world) + assert record["status"] == "completed" + assert record["conversation_id"] == CONVERSATION and record["actor_user_id"] == USER + assert record["workflow_id"] is None and "approval_id" not in record + assert record["orchestration"] == { + "run_id": "run-2", "attempt_index": 2, "step_id": STEP, "capability_id": "action_invoke", + } + assert captured and all(item == ("action", ACTION_REF) for item in captured) + invocation = result["invocations"][0] + assert invocation.function_name == "get_my_messages" + assert invocation.result["source"] == "email" + assert not any("m365_context_required" in json.dumps(extra, default=str) for _message, extra in world.logs) + + +def test_plans_never_see_send_or_read_state_functions(env, world): + world.action = mail_action(send_mail=True, mark_message_as_read=True) + world.replies = [tool_call()] + run_step(env, world) + assert world.loaded_functions == [["get_my_messages"]] + + +def test_missing_sign_in_stops_the_step_before_the_model_or_graph(env, world): + world.token_error = "interactive_auth_required" + error = stopped(env, world) + + assert error.orchestration_failure_code == "m365_sign_in_required" + assert error.m365_code == "interactive_auth_required" + assert error.m365_sources == ("email",) + assert world.requests == [] and world.graph_calls == [] and world.loaded_functions == [] + assert request_record(env, world)["status"] == "failed" + failure = env.schema.failure_from_exception(error) + assert failure == { + "code": "m365_sign_in_required", + "message": env.schema.FAILURE_MESSAGES["m365_sign_in_required"], + "m365_sources": ["email"], + } + stop_logs = [extra for message, extra in world.logs if "A Microsoft 365 step stopped" in message] + assert stop_logs == [{ + "failure_code": "m365_sign_in_required", "authority_reason": "interactive_auth_required", + "capability_id": "action_invoke", "source_count": 1, + }] + + +def test_rejected_graph_sign_in_fails_the_step_instead_of_becoming_findings(env, world): + world.graph_response = (401, {"error": {"code": "InvalidAuthenticationToken"}}) + world.replies = [tool_call()] + error = stopped(env, world) + + assert error.orchestration_failure_code == "m365_sign_in_required" + assert error.m365_code == "authentication_required" + assert error.m365_sources == ("email",) + assert len(world.graph_calls) == 1 + assert request_record(env, world)["status"] == "failed" + + +def test_ordinary_graph_outcomes_stay_findings(env, world): + world.graph_response = (404, {"error": {"code": "ErrorItemNotFound"}}) + world.replies = [tool_call()] + result, _context = run_step(env, world) + assert result["calls"] == 1 + assert request_record(env, world)["status"] == "completed" + + +def test_shared_conversations_are_refused_before_any_microsoft_365_work(env, world): + world.participation = {"collaboration_conversation_id": "shared-1"} + error = stopped(env, world) + assert error.orchestration_failure_code == "m365_shared_conversation" + assert world.token_requests == [] and world.requests == [] and world.jobs.items == {} + + +def test_send_only_action_is_refused_as_read_only(env, world): + world.action = {**mail_action(send_mail=True), "additionalFields": { + "m365_capabilities": {"get_my_messages": False, "send_mail": True}, + }} + error = stopped(env, world) + assert error.orchestration_failure_code == "m365_read_only" + assert world.token_requests == [] and world.requests == [] and world.jobs.items == {} + + +def test_approval_stop_is_recorded_and_a_retry_reuses_the_request(env, world, monkeypatch): + approval = { + "id": "m365-approval-1", "request_type": env.approvals.TYPE_EXTENDED_ANALYSIS, + "subject_user_id": USER, "resume_key": "resume", "execution_status": None, + } + service = env.runtime.get_m365_approval_service() + reported = [] + + class ReportingApprovals: + def __getattr__(self, name): + return getattr(service, name) + + def record_execution_status(self, *arguments): + reported.append(arguments) + + monkeypatch.setattr(env.runtime, "get_m365_approval_service", ReportingApprovals) + world.graph_error = env.approvals.M365ApprovalRequired(approval) + world.replies = [tool_call()] + error = stopped(env, world) + + assert error.orchestration_failure_code == "m365_approval_required" + assert error.approval_id == "m365-approval-1" + record = request_record(env, world) + # A decision on this approval sees a stopped step, so it never resumes a chat request. + assert record["status"] == "failed" and record["approval_id"] == "m365-approval-1" + # The approval is still waiting for its decision, so the stop is not reported on it. + assert reported == [] + + # A retry that stops for another approval reports nothing either, as a paused chat doesn't. + world.graph_error = env.approvals.M365ApprovalRequired({**approval, "id": "m365-approval-2"}) + world.replies = [tool_call()] + assert stopped(env, world).approval_id == "m365-approval-2" + assert request_record(env, world)["approval_id"] == "m365-approval-2" + assert reported == [] + + world.graph_error = None + world.replies = [tool_call()] + result, _context = run_step(env, world) + retried = request_record(env, world) + assert result["calls"] == 1 + assert len(world.jobs.items) == 1 + assert retried["status"] == "completed" and retried["approval_id"] == "m365-approval-2" + assert {request["request_id"] for request in world.token_requests} == { + env.orchestration.step_request_id(REQUEST_KEY), + } + # Approvals then shows the retried step's outcome, as it does for a resumed chat. + assert reported == [("m365-approval-2", USER, env.orchestration.step_request_id(REQUEST_KEY), "completed")] + + +def test_each_plan_step_has_its_own_stable_request(env, world): + first = env.orchestration.step_request_id(REQUEST_KEY) + assert first == env.orchestration.step_request_id(REQUEST_KEY) + assert first != env.orchestration.step_request_id(f"{ROOT_RUN}\x00another_step") + assert first != env.orchestration.step_request_id(f"another-run\x00{STEP}") + assert env.orchestration.step_request_id(None) != env.orchestration.step_request_id(None) + + +def test_step_scope_isolates_and_restores_request_state(env, world): + selection = {"kind": "action", "action_ref": ACTION_REF, "user_groups": []} + with world.app.test_request_context("/internal/agent-execution"): + session["user"] = {"oid": USER, "tid": TENANT} + g.m365_selected_agent_ref = {"id": "chat-agent"} + with env.runtime.step_m365_context( + user_id=USER, conversation_id=CONVERSATION, request_id="orch-scope", selection=selection, + ) as context: + assert context.request_id == "orch-scope" and context.workflow_id is None + assert not hasattr(g, "m365_selected_agent_ref") + assert getattr(g, env.runtime.M365_STEP_SELECTION_KEY) == selection + assert env.runtime.resolve_m365_action_selection(context) == ["mail"] + assert g.m365_selected_agent_ref == {"id": "chat-agent"} + assert not hasattr(g, env.runtime.M365_STEP_SELECTION_KEY) + assert env.runtime.get_m365_execution_context() is None + + +def test_a_step_without_a_signed_in_session_is_refused(env, world): + scope = env.orchestration.action_step_scope( + ACTION_REF, user_id=USER, conversation_id=CONVERSATION, request_key=REQUEST_KEY, + ) + with world.app.test_request_context("/internal/agent-execution"): + with pytest.raises(env.orchestration.OrchestrationM365Error) as caught: + with scope: + pass + assert caught.value.orchestration_failure_code == "external_session_required" + assert world.jobs.items == {} + + +def test_refusal_codes_map_to_step_failures(env, world): + refusal = env.orchestration.result_refusal + for value in ( + None, "not json", [], {"value": []}, {"error": "throttled"}, {"error": "not_found"}, + {"error": "access_denied"}, {"error": "function_not_enabled"}, {"error": "source_not_allowed"}, + ): + assert refusal(value) is None + assert refusal({"error": "consent_required", "source": "email"}).orchestration_failure_code == "m365_sign_in_required" + assert refusal(json.dumps({"error": "authentication_required", "sources": ["calendar"]})).m365_sources == ("calendar",) + assert refusal({"error": {"code": "m365_action_not_selected"}}).orchestration_failure_code == "m365_unavailable" + assert refusal({"error": "m365_context_required", "source": "email"}).orchestration_failure_code == "m365_unavailable" + assert refusal({"error": "source_not_authorized"}).orchestration_failure_code == "m365_unavailable" + assert refusal({"error": "m365_approval_pending"}).orchestration_failure_code == "m365_approval_required" + + +def test_failures_keep_only_known_microsoft_365_sources(env, world): + schema = env.schema + failure = schema.build_failure("m365_sign_in_required", m365_sources=["spo", "email", "email", "evil"]) + assert failure["m365_sources"] == ["email", "spo"] + assert "m365_sources" not in schema.build_failure("step_failed", m365_sources=["email"]) + assert "m365_sources" not in schema.build_failure("m365_unavailable", m365_sources="email") + assert schema.safe_failure(failure) == failure + wrapped = RuntimeError("An action function could not complete.") + wrapped.__cause__ = env.orchestration.OrchestrationM365Error("m365_unavailable", m365_code="m365_context_required") + assert schema.failure_from_exception(wrapped)["code"] == "m365_unavailable" + unknown = env.orchestration.OrchestrationM365Error("not_a_failure_code") + assert schema.failure_from_exception(unknown)["code"] == "step_failed" + + +def test_orchestration_answers_keep_microsoft_365_sharing_provenance(env, world): + world.replies = [tool_call()] + result, context = run_step(env, world) + citations = env.adapters._agent_citations( + None, USER, CONVERSATION, set(), + root_id=context.delegation_budget.root_id, scoped_invocations=result["invocations"], + ) + message = {"role": "assistant", "metadata": {"orchestration": {}}, "agent_citations": citations} + # Sharing a conversation then needs the owner's Microsoft 365 approval for this answer. + assert env.history.history_sources([message]) == {"email": "request"} + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/ui_tests/test_v2_orchestration_m365_recovery.py b/ui_tests/test_v2_orchestration_m365_recovery.py new file mode 100644 index 000000000..f211a9268 --- /dev/null +++ b/ui_tests/test_v2_orchestration_m365_recovery.py @@ -0,0 +1,204 @@ +# test_v2_orchestration_m365_recovery.py +""" +Real-component browser coverage for plan steps that stopped for Microsoft 365. +Version: 0.261.238 +Implemented in: 0.261.238 + +A step that stopped for Microsoft 365 sign-in offers Connect Microsoft 365 in its run's +recovery notice, and a step that stopped for approval links to Approvals. The production +notice, connect flow, controller, stores, and message list run in Chromium. Only HTTP +responses are deterministic, including the sign-in window's last page: a stand-in for the +Profile page, which either tells its opener it connected or closes without saying so. + +Build CSS with the existing V2 build, keeping outputs in UI test artifacts: +npm --prefix .\\application\\v2_ui run build -- --outDir ..\\..\\ui_tests\\artifacts\\orchestration-plan-editor +Run: python -m pytest .\\ui_tests\\test_v2_orchestration_m365_recovery.py -q +""" + +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import expect + +import test_v2_orchestration_recovery as recovery_tests +from test_v2_orchestration_plan_editor import ( # noqa: F401 + connect_options, + editor_assets, + editor_browser, +) + + +pytestmark = pytest.mark.ui +# The server's own messages for these failures, from FAILURE_MESSAGES. +SIGN_IN_MESSAGE = ( + "Microsoft 365 needs you to sign in or grant access before this step can read your data. " + "Select Connect Microsoft 365, then Retry from failed step." +) +APPROVAL_MESSAGE = ( + "This step needs your approval before it can use Microsoft 365. Review it in Approvals, " + "then select Retry from failed step." +) +CONNECTED = "Microsoft 365 is connected. Select Retry from failed step to continue." +NOT_COMPLETED = "Microsoft 365 sign-in was not completed. Connect again when you are ready." +CSRF_TOKEN = "m365-recovery-test-csrf-token-000000000001" +PROFILE_PATH = "/profile" +SIGN_IN_URL = ( + "https://simplechat.test/profile?tab=settings&m365_chat_connection=connected#m365-chat-connection" +) +EARLIER = "2026-09-01T08:00:00+00:00" +LATER = "2026-09-08T12:02:00+00:00" + + +class M365RecoveryApi(recovery_tests.RecoveryApi): + """The recovery API plus the Profile connection routes the notice uses.""" + + def __init__(self, assets): + super().__init__(assets) + self.connection = {"status": "available", "sources": ["calendar"], "connected_at": EARLIER} + self.connects = [] + + def handle(self, route): + request = route.request + path = urlsplit(request.url).path + if path == "/api/m365/chat/connection" and request.method == "GET": + self.requests.append({"path": path, "method": "GET", "body": None}) + route.fulfill(json={"connection": dict(self.connection), "csrf_token": CSRF_TOKEN}) + return + if path == "/api/m365/chat/connection/connect" and request.method == "POST": + body = request.post_data_json + self.requests.append({"path": path, "method": "POST", "body": body}) + self.connects.append({"body": body, "csrf": request.headers.get("x-m365-csrf-token")}) + route.fulfill(json={"authorization_url": SIGN_IN_URL}) + return + super().handle(route) + + +@pytest.fixture +def m365_ui(editor_browser, editor_assets): + context = editor_browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + api = M365RecoveryApi(editor_assets) + page.route("**/*", api.handle) + page.on("pageerror", lambda error: api.errors.append(str(error))) + try: + yield page, api + finally: + api.release() + context.close() + assert not api.errors, api.errors + + +def sign_in_window(page, api, *, reports, saves=True): + """Serve the sign-in window's last page, which only the popup ever requests. + + ``reports`` posts the Profile page's message to the opener. Without it the window + closes without a word, as when the message is lost; ``saves`` decides whether the + sign-in was saved first. + """ + def finish(route): + if urlsplit(route.request.url).path != PROFILE_PATH: + route.fulfill(status=204) + return + if saves: + api.connection = {"status": "available", "sources": ["calendar", "email"], "connected_at": LATER} + script = ( + "window.opener.postMessage({ type: 'm365-profile-reconnected' }, window.location.origin);" + if reports else "window.close();" + ) + route.fulfill(content_type="text/html", body=f"Profile") + + # Context routes reach the popup; the page's own route still answers the chat page first. + page.context.route("**/*", finish) + + +def start(page, api, message): + recovery_tests.mount_recovery(page, api) + page.get_by_role("button", name="Approve and run the plan").click() + expect(page.get_by_text(message, exact=True).first).to_be_visible() + expect(page.get_by_role("button", name="Retry from failed step").first).to_be_enabled() + return page.get_by_label("Microsoft 365 follow-up").first + + +def wait_closed(popup): + if not popup.is_closed(): + popup.wait_for_event("close") + + +def test_sign_in_stop_connects_only_known_sources_then_waits_for_retry(m365_ui): + page, api = m365_ui + api.failure = { + "code": "m365_sign_in_required", "message": SIGN_IN_MESSAGE, + "m365_sources": ["email", "not-a-source"], + } + sign_in_window(page, api, reports=True) + notice = start(page, api, SIGN_IN_MESSAGE) + expect(notice.get_by_text("For Email.", exact=True)).to_be_visible() + + with page.expect_popup() as opened: + notice.get_by_role("button", name="Connect Microsoft 365").click() + expect(notice.get_by_role("status")).to_have_text(CONNECTED) + wait_closed(opened.value) + + assert api.connects == [{"body": {"sources": ["email"]}, "csrf": CSRF_TOKEN}] + expect(notice.get_by_role("button", name="Connect Microsoft 365")).to_have_count(0) + expect(page.get_by_role("button", name="Retry from failed step").first).to_be_enabled() + # Connecting saves the sign-in only; the user retries the failed step. + assert not api.calls("/retry") and len(api.calls("/run")) == 1 + + +def test_closed_sign_in_window_without_a_saved_sign_in_can_connect_again(m365_ui): + page, api = m365_ui + api.failure = {"code": "m365_sign_in_required", "message": SIGN_IN_MESSAGE, "m365_sources": ["email"]} + sign_in_window(page, api, reports=False, saves=False) + notice = start(page, api, SIGN_IN_MESSAGE) + + with page.expect_popup() as opened: + notice.get_by_role("button", name="Connect Microsoft 365").click() + wait_closed(opened.value) + expect(notice.get_by_role("alert")).to_have_text(NOT_COMPLETED) + expect(notice.get_by_role("button", name="Connect Microsoft 365")).to_be_enabled() + expect(notice.get_by_role("status")).to_have_count(0) + assert len(api.connects) == 1 and not api.calls("/retry") + + +def test_saved_sign_in_is_confirmed_when_the_window_closes_without_reporting(m365_ui): + # A Profile page on another origin can't message the chat page, so the saved + # connection confirms the sign-in once the window closes. + page, api = m365_ui + api.failure = {"code": "m365_sign_in_required", "message": SIGN_IN_MESSAGE, "m365_sources": ["email"]} + sign_in_window(page, api, reports=False) + notice = start(page, api, SIGN_IN_MESSAGE) + + with page.expect_popup() as opened: + notice.get_by_role("button", name="Connect Microsoft 365").click() + wait_closed(opened.value) + expect(notice.get_by_role("status")).to_have_text(CONNECTED) + expect(notice.get_by_role("alert")).to_have_count(0) + + +def test_sign_in_stop_without_sources_links_to_profile(m365_ui): + page, api = m365_ui + api.failure = {"code": "m365_sign_in_required", "message": SIGN_IN_MESSAGE} + notice = start(page, api, SIGN_IN_MESSAGE) + expect(notice.get_by_role("link", name="Connect Microsoft 365 in Profile settings")).to_have_attribute( + "href", "/profile?tab=settings#m365-chat-connection", + ) + expect(notice.get_by_role("button", name="Connect Microsoft 365")).to_have_count(0) + + +def test_approval_stop_links_to_approvals_without_connecting(m365_ui): + page, api = m365_ui + api.failure = {"code": "m365_approval_required", "message": APPROVAL_MESSAGE, "m365_sources": ["onedrive"]} + notice = start(page, api, APPROVAL_MESSAGE) + expect(notice.get_by_role("link", name="Review the Microsoft 365 approval")).to_have_attribute( + "href", "/approvals", + ) + expect(notice.get_by_role("button", name="Connect Microsoft 365")).to_have_count(0) + assert not api.connects + + +def test_other_step_failures_have_no_microsoft_365_follow_up(m365_ui): + page, api = m365_ui + recovery_tests.start_failure(page, api) + expect(page.get_by_label("Microsoft 365 follow-up")).to_have_count(0) + assert not any(request["path"].startswith("/api/m365/") for request in api.requests) diff --git a/ui_tests/test_v2_orchestration_recovery.py b/ui_tests/test_v2_orchestration_recovery.py index a525d5a34..1dbedeb01 100644 --- a/ui_tests/test_v2_orchestration_recovery.py +++ b/ui_tests/test_v2_orchestration_recovery.py @@ -1,10 +1,11 @@ # test_v2_orchestration_recovery.py """ Real-component browser coverage for orchestration failure and checkpoint recovery. -Version: 0.261.141 +Version: 0.261.238 Implemented in: 0.261.105 Earlier-version run refusals covered in: 0.261.139 Settled runs whose stored plan still reads running covered in: 0.261.141 +Configurable step failure, reused by Microsoft 365 recovery coverage, in: 0.261.238 The production controller, SSE reader, stores, message list, and Run drawer execute in the existing local/Azure Playwright harness. Only API responses are deterministic. @@ -55,6 +56,8 @@ def __init__(self, assets): self.prepare_mode = "success" self.stream_mode = "failure" self.waiting = [] + # The failed step's failure; Microsoft 365 recovery tests replace it. + self.failure = {"code": "step_timeout", "message": FAILURE} # Runs the server refuses as an earlier orchestration version: every by-id request # answers 409 legacy_plan and the run list leaves them out. self.legacy = set() @@ -92,7 +95,7 @@ def finish(self, run_id, status="failed"): ) record["plan"]["status"] = status record["plan_summary"]["status"] = status - failure = {"code": "step_timeout", "message": FAILURE, "step_id": "research"} + failure = {**copy.deepcopy(self.failure), "step_id": "research"} record["failure"] = failure if status == "failed" else None record["failures"] = [failure] if status == "failed" else [] record["recovery"] = self.recovery(run_id) @@ -109,7 +112,7 @@ def finish(self, run_id, status="failed"): "status": "completed" if status == "completed" else "skipped", "summary": ""}, ] content = "Comparison from saved reports and the recovered agent." if status == "completed" else ( - "This run was stopped. Available partial results were kept." if status == "cancelled" else FAILURE) + "This run was stopped. Available partial results were kept." if status == "cancelled" else failure["message"]) metadata = {"orchestration": { key: record[key] for key in ("run_id", "turn_id", "attempt_index", "retry_of_run_id", "outcome", "failure", "failures", "recovery",