From c4d55194c34c1e0eed0ccfdb742835ccda986263 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Wed, 1 Apr 2026 19:02:04 -0700 Subject: [PATCH 1/8] Proactive testing WIP --- .../hosting/core/app/__init__.py | 17 + .../hosting/core/app/agent_application.py | 28 ++ .../hosting/core/app/app_options.py | 8 + .../hosting/core/app/proactive/__init__.py | 20 + .../core/app/proactive/conversation.py | 142 +++++++ .../app/proactive/conversation_builder.py | 235 +++++++++++ .../conversation_reference_builder.py | 234 +++++++++++ .../proactive/create_conversation_options.py | 59 +++ .../hosting/core/app/proactive/proactive.py | 392 ++++++++++++++++++ .../core/app/proactive/proactive_options.py | 35 ++ test_samples/proactive/README.md | 171 ++++++++ test_samples/proactive/env.TEMPLATE | 11 + test_samples/proactive/proactive_agent.py | 359 ++++++++++++++++ test_samples/proactive/requirements.txt | 6 + 14 files changed, 1717 insertions(+) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py create mode 100644 test_samples/proactive/README.md create mode 100644 test_samples/proactive/env.TEMPLATE create mode 100644 test_samples/proactive/proactive_agent.py create mode 100644 test_samples/proactive/requirements.txt diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py index 2751bf41..a143c6a3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py @@ -21,6 +21,16 @@ AgenticUserAuthorization, ) +# Proactive +from .proactive import ( + Conversation, + ConversationBuilder, + ConversationReferenceBuilder, + CreateConversationOptions, + Proactive, + ProactiveOptions, +) + # App State from .state.conversation_state import ConversationState from .state.state import State, StatePropertyAccessor, state @@ -47,4 +57,11 @@ "Authorization", "AuthHandler", "AgenticUserAuthorization", + # Proactive + "Conversation", + "ConversationBuilder", + "ConversationReferenceBuilder", + "CreateConversationOptions", + "Proactive", + "ProactiveOptions", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index d0eb6c1e..162306a4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -43,6 +43,7 @@ from ._type_defs import RouteHandler, RouteSelector from ._routes import _RouteList, _Route, RouteRank, _agentic_selector +from .proactive import Proactive, ProactiveOptions logger = logging.getLogger(__name__) @@ -67,6 +68,7 @@ class AgentApplication(Agent, Generic[StateT]): _options: ApplicationOptions _adapter: Optional[ChannelServiceAdapter] = None _auth: Optional[Authorization] = None + _proactive: Optional[Proactive] = None _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] _route_list: _RouteList[StateT] = _RouteList[StateT]() @@ -145,6 +147,12 @@ def __init__( or partial(TurnState.with_storage, self._options.storage) ) + if options.proactive: + proactive_opts = options.proactive + if not proactive_opts.storage: + proactive_opts.storage = self._options.storage + self._proactive = Proactive(self, proactive_opts) + # TODO: decide how to initialize the Authorization (params vs options vs kwargs) if authorization: self._auth = authorization @@ -214,6 +222,26 @@ def options(self) -> ApplicationOptions: """ return self._options + @property + def proactive(self) -> Proactive: + """ + The application's proactive messaging manager. + + :return: The proactive messaging manager. + :rtype: :class:`microsoft_agents.hosting.core.app.proactive.proactive.Proactive` + :raises ApplicationError: If proactive options were not configured. + """ + if not self._proactive: + logger.error( + "AgentApplication.proactive(): proactive options are not configured.", + stack_info=True, + ) + raise ApplicationError(""" + The `AgentApplication.proactive` property is unavailable because + no ProactiveOptions were configured in ApplicationOptions. + """) + return self._proactive + def add_route( self, selector: RouteSelector, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index 21312c76..a66ca494 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -17,6 +17,7 @@ from ..channel_service_adapter import ChannelServiceAdapter from .state.turn_state import TurnState +from .proactive.proactive_options import ProactiveOptions # from .teams_adapter import TeamsAdapter @@ -89,3 +90,10 @@ class ApplicationOptions: Optional. Authorization handler for OAuth flows. If not provided, no OAuth flows will be supported. """ + + proactive: Optional[ProactiveOptions] = None + """ + Optional. Options for the proactive messaging subsystem. + When set, :attr:`AgentApplication.proactive` is available for storing + conversations and initiating proactive turns. + """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/__init__.py new file mode 100644 index 00000000..f0d7eaa9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/__init__.py @@ -0,0 +1,20 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .conversation import Conversation +from .conversation_builder import ConversationBuilder +from .conversation_reference_builder import ConversationReferenceBuilder +from .create_conversation_options import CreateConversationOptions +from .proactive import Proactive +from .proactive_options import ProactiveOptions + +__all__ = [ + "Conversation", + "ConversationBuilder", + "ConversationReferenceBuilder", + "CreateConversationOptions", + "Proactive", + "ProactiveOptions", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py new file mode 100644 index 00000000..815f5650 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py @@ -0,0 +1,142 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +from microsoft_agents.activity import ConversationReference +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from microsoft_agents.hosting.core.storage.store_item import StoreItem + +if TYPE_CHECKING: + from microsoft_agents.hosting.core.turn_context import TurnContext + from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter + +# JWT claim keys that are persisted alongside a ConversationReference. +_PERSISTED_CLAIM_KEYS = frozenset({"aud", "azp", "appid", "idtyp", "ver", "iss", "tid"}) + + +class Conversation(StoreItem): + """ + Bundles a :class:`~microsoft_agents.activity.ConversationReference` together + with a filtered set of JWT claims so that a proactive continuation can be + performed without holding onto the full :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`. + + Instances are typically created via + :meth:`~microsoft_agents.hosting.core.app.proactive.conversation_builder.ConversationBuilder` + or via :meth:`from_turn_context`. + + :param claims: Filtered JWT claims (``aud``, ``azp``, ``appid``, ``idtyp``, + ``ver``, ``iss``, ``tid``). May be a raw ``dict`` or a + :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`. + :type claims: dict[str, str] or ClaimsIdentity + :param conversation_reference: The conversation reference. + :type conversation_reference: :class:`~microsoft_agents.activity.ConversationReference` + """ + + def __init__( + self, + claims: "dict[str, str] | ClaimsIdentity", + conversation_reference: ConversationReference, + ) -> None: + if isinstance(claims, ClaimsIdentity): + self.claims: dict[str, str] = Conversation.claims_from_identity(claims) + else: + self.claims = {k: v for k, v in claims.items() if k in _PERSISTED_CLAIM_KEYS} + self.conversation_reference: ConversationReference = conversation_reference + + # ------------------------------------------------------------------ + # Factory helpers + # ------------------------------------------------------------------ + + @classmethod + def from_turn_context(cls, context: "TurnContext") -> "Conversation": + """ + Create a :class:`Conversation` from the current turn context. + + :param context: The active turn context. + :type context: :class:`~microsoft_agents.hosting.core.turn_context.TurnContext` + :return: A new :class:`Conversation` capturing the current turn's identity + and conversation reference. + :rtype: :class:`Conversation` + """ + from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter + + identity: Optional[ClaimsIdentity] = context.turn_state.get( + ChannelAdapter.AGENT_IDENTITY_KEY + ) + reference = context.activity.get_conversation_reference() + return cls(identity or {}, reference) + + # ------------------------------------------------------------------ + # Claims helpers + # ------------------------------------------------------------------ + + @staticmethod + def claims_from_identity(identity: ClaimsIdentity) -> "dict[str, str]": + """ + Return the subset of claims from *identity* that are relevant for proactive + messaging (``aud``, ``azp``, ``appid``, ``idtyp``, ``ver``, ``iss``, ``tid``). + + :param identity: The full claims identity. + :type identity: :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + :return: Filtered claims dictionary. + :rtype: dict[str, str] + """ + return {k: v for k, v in identity.claims.items() if k in _PERSISTED_CLAIM_KEYS} + + @staticmethod + def identity_from_claims(claims: "dict[str, str]") -> ClaimsIdentity: + """ + Reconstruct a :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + from a previously persisted claims dict. + + :param claims: Filtered claims dictionary (as produced by :meth:`claims_from_identity`). + :type claims: dict[str, str] + :return: Reconstituted claims identity. + :rtype: :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + """ + return ClaimsIdentity(claims=dict(claims), is_authenticated=True) + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + + def validate(self) -> None: + """ + Raise :exc:`ValueError` if required fields are missing. + + :raises ValueError: If ``conversation_reference``, its nested + ``conversation``, or ``service_url`` are absent. + """ + if not self.conversation_reference: + raise ValueError("Conversation.conversation_reference is required.") + if not self.conversation_reference.conversation: + raise ValueError("Conversation.conversation_reference.conversation is required.") + if not self.conversation_reference.service_url: + raise ValueError("Conversation.conversation_reference.service_url is required.") + + # ------------------------------------------------------------------ + # StoreItem serialization + # ------------------------------------------------------------------ + + def store_item_to_json(self) -> dict: + return { + "claims": self.claims, + "conversation_reference": self.conversation_reference.model_dump( + mode="json", by_alias=True, exclude_unset=True + ), + } + + @staticmethod + def from_json_to_store_item(json_data: dict) -> "Conversation": + reference = ConversationReference.model_validate( + json_data.get("conversation_reference", {}) + ) + return Conversation( + claims=json_data.get("claims", {}), + conversation_reference=reference, + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py new file mode 100644 index 00000000..87278b4e --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py @@ -0,0 +1,235 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Optional + +from microsoft_agents.activity import ( + ChannelAccount, + Channels, + ConversationAccount, + ConversationReference, +) +from microsoft_agents.hosting.core.authorization import ClaimsIdentity + +from .conversation import Conversation +from .conversation_reference_builder import _service_url_for_channel + + +class ConversationBuilder: + """ + Fluent builder for :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + + Typical usage — building from a minimal set of claims:: + + conversation = ( + ConversationBuilder + .create("agent-app-id", "msteams", service_url="https://smba.trafficmanager.net/teams/") + .with_user("user-aad-oid", "User Display Name") + .with_conversation("19:thread-id@thread.v2") + .build() + ) + + Or from an existing :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`:: + + conversation = ( + ConversationBuilder + .create_from_identity(claims_identity, "msteams") + .with_conversation("19:thread-id@thread.v2") + .build() + ) + """ + + def __init__(self) -> None: + self._claims: dict[str, str] = {} + self._channel_id: Optional[str] = None + self._service_url: Optional[str] = None + self._agent_id: Optional[str] = None + self._agent_name: Optional[str] = None + self._user_id: Optional[str] = None + self._user_name: Optional[str] = None + self._conversation_id: Optional[str] = None + self._conversation_name: Optional[str] = None + self._tenant_id: Optional[str] = None + self._activity_id: Optional[str] = None + + # ------------------------------------------------------------------ + # Entry-point factories + # ------------------------------------------------------------------ + + @classmethod + def create( + cls, + agent_client_id: str, + channel_id: str, + service_url: Optional[str] = None, + requestor_id: Optional[str] = None, + ) -> "ConversationBuilder": + """ + Start building a :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + from a minimal set of claims. + + :param agent_client_id: The agent's AAD application ID (becomes the ``aud`` + claim and optionally the ``appid`` claim). + :type agent_client_id: str + :param channel_id: The channel identifier (e.g. ``"msteams"``). + :type channel_id: str + :param service_url: Override the service URL. Defaults to the canonical + URL for *channel_id*. + :type service_url: Optional[str] + :param requestor_id: If provided, stored as the ``appid`` claim (useful + when the requestor differs from the audience). + :type requestor_id: Optional[str] + :return: A builder pre-populated with the supplied claims. + :rtype: :class:`ConversationBuilder` + """ + builder = cls() + builder._channel_id = channel_id + builder._service_url = service_url or _service_url_for_channel(channel_id) + builder._claims["aud"] = agent_client_id + if requestor_id: + builder._claims["appid"] = requestor_id + + # Set agent ID with Teams prefix if needed. + if channel_id == Channels.ms_teams or channel_id == "msteams": + builder._agent_id = f"28:{agent_client_id}" + else: + builder._agent_id = agent_client_id + + return builder + + @classmethod + def create_from_identity( + cls, + identity: ClaimsIdentity, + channel_id: str, + service_url: Optional[str] = None, + ) -> "ConversationBuilder": + """ + Start building a :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + from a full :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`. + + :param identity: The claims identity to extract claims from. + :type identity: :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + :param channel_id: The channel identifier. + :type channel_id: str + :param service_url: Override the service URL. + :type service_url: Optional[str] + :return: A builder pre-populated with the identity's claims. + :rtype: :class:`ConversationBuilder` + """ + builder = cls() + builder._channel_id = channel_id + builder._service_url = service_url or _service_url_for_channel(channel_id) + builder._claims = Conversation.claims_from_identity(identity) + + app_id = identity.get_app_id() + if app_id: + if channel_id == Channels.ms_teams or channel_id == "msteams": + builder._agent_id = f"28:{app_id}" + else: + builder._agent_id = app_id + + return builder + + # ------------------------------------------------------------------ + # Fluent setters + # ------------------------------------------------------------------ + + def with_user( + self, + user_id: str, + user_name: Optional[str] = None, + ) -> "ConversationBuilder": + """ + Set the user account. + + :param user_id: The user's channel account ID. + :type user_id: str + :param user_name: Optional display name. + :type user_name: Optional[str] + :return: ``self`` for chaining. + :rtype: :class:`ConversationBuilder` + """ + self._user_id = user_id + self._user_name = user_name + return self + + def with_conversation( + self, + conversation_id: str, + conversation_name: Optional[str] = None, + tenant_id: Optional[str] = None, + ) -> "ConversationBuilder": + """ + Set the conversation account details. + + :param conversation_id: The conversation ID. + :type conversation_id: str + :param conversation_name: Optional conversation name. + :type conversation_name: Optional[str] + :param tenant_id: Optional tenant ID. + :type tenant_id: Optional[str] + :return: ``self`` for chaining. + :rtype: :class:`ConversationBuilder` + """ + self._conversation_id = conversation_id + self._conversation_name = conversation_name + self._tenant_id = tenant_id + return self + + def with_activity_id(self, activity_id: str) -> "ConversationBuilder": + """ + Set the activity ID on the underlying conversation reference. + + :param activity_id: The activity ID. + :type activity_id: str + :return: ``self`` for chaining. + :rtype: :class:`ConversationBuilder` + """ + self._activity_id = activity_id + return self + + # ------------------------------------------------------------------ + # Build + # ------------------------------------------------------------------ + + def build(self) -> Conversation: + """ + Construct the :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + + :raises ValueError: If required fields (``channel_id``) are missing. + :return: The built :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + :rtype: :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + """ + if not self._channel_id: + raise ValueError("ConversationBuilder: channel_id is required.") + + agent = ( + ChannelAccount(id=self._agent_id, name=self._agent_name) + if self._agent_id + else None + ) + user = ( + ChannelAccount(id=self._user_id, name=self._user_name) + if self._user_id + else None + ) + + reference = ConversationReference( + channel_id=self._channel_id, + service_url=self._service_url or _service_url_for_channel(self._channel_id), + conversation=ConversationAccount( + id=self._conversation_id or "", + name=self._conversation_name, + tenant_id=self._tenant_id, + ), + bot=agent, + user=user, + activity_id=self._activity_id, + ) + + return Conversation(claims=self._claims, conversation_reference=reference) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py new file mode 100644 index 00000000..c49f1919 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py @@ -0,0 +1,234 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Optional + +from microsoft_agents.activity import ( + ChannelAccount, + Channels, + ConversationAccount, + ConversationReference, +) + + +def _service_url_for_channel(channel_id: str) -> str: + """Return the default service URL for a given channel. + + :param channel_id: The channel identifier (e.g. ``"msteams"``). + :type channel_id: str + :return: The service URL for that channel. + :rtype: str + """ + if channel_id == Channels.ms_teams or channel_id == "msteams": + return "https://smba.trafficmanager.net/teams/" + return f"https://{channel_id}.botframework.com/" + + +class ConversationReferenceBuilder: + """ + Fluent builder for :class:`~microsoft_agents.activity.ConversationReference`. + + Typical usage:: + + reference = ( + ConversationReferenceBuilder + .create("msteams", "19:conversation-id@thread.v2") + .with_agent("28:agent-app-id", "My Agent") + .with_user("user-aad-oid", "User Display Name") + .with_locale("en-US") + .build() + ) + """ + + def __init__(self) -> None: + self._channel_id: Optional[str] = None + self._conversation_id: Optional[str] = None + self._service_url: Optional[str] = None + self._agent_id: Optional[str] = None + self._agent_name: Optional[str] = None + self._user_id: Optional[str] = None + self._user_name: Optional[str] = None + self._activity_id: Optional[str] = None + self._locale: Optional[str] = None + + # ------------------------------------------------------------------ + # Entry-point factories + # ------------------------------------------------------------------ + + @classmethod + def create( + cls, + channel_id: str, + conversation_id: str, + ) -> "ConversationReferenceBuilder": + """ + Start building a :class:`~microsoft_agents.activity.ConversationReference` + from a channel ID and an existing conversation ID. + + :param channel_id: The channel identifier (e.g. ``"msteams"``). + :type channel_id: str + :param conversation_id: The existing conversation ID. + :type conversation_id: str + :return: A builder pre-populated with the channel and conversation. + :rtype: :class:`ConversationReferenceBuilder` + """ + builder = cls() + builder._channel_id = channel_id + builder._conversation_id = conversation_id + return builder + + @classmethod + def create_for_agent( + cls, + agent_client_id: str, + channel_id: str, + service_url: Optional[str] = None, + ) -> "ConversationReferenceBuilder": + """ + Start building a :class:`~microsoft_agents.activity.ConversationReference` + from an agent application ID and channel. + + For the ``msteams`` channel the agent ID is automatically prefixed with + ``28:`` as required by Teams. + + :param agent_client_id: The agent's AAD application ID. + :type agent_client_id: str + :param channel_id: The channel identifier. + :type channel_id: str + :param service_url: Override the service URL. When ``None`` the default + URL for the channel is used. + :type service_url: Optional[str] + :return: A builder pre-populated for the agent. + :rtype: :class:`ConversationReferenceBuilder` + """ + builder = cls() + builder._channel_id = channel_id + builder._service_url = service_url or _service_url_for_channel(channel_id) + + # Teams requires the "28:" prefix on agent IDs. + if channel_id == Channels.ms_teams or channel_id == "msteams": + builder._agent_id = f"28:{agent_client_id}" + else: + builder._agent_id = agent_client_id + + return builder + + # ------------------------------------------------------------------ + # Fluent setters + # ------------------------------------------------------------------ + + def with_agent( + self, + agent_id: str, + agent_name: Optional[str] = None, + ) -> "ConversationReferenceBuilder": + """ + Set the agent (bot) account on the reference. + + :param agent_id: The agent's channel account ID. + :type agent_id: str + :param agent_name: Optional display name. + :type agent_name: Optional[str] + :return: ``self`` for chaining. + :rtype: :class:`ConversationReferenceBuilder` + """ + self._agent_id = agent_id + self._agent_name = agent_name + return self + + def with_user( + self, + user_id: str, + user_name: Optional[str] = None, + ) -> "ConversationReferenceBuilder": + """ + Set the user account on the reference. + + :param user_id: The user's channel account ID. + :type user_id: str + :param user_name: Optional display name. + :type user_name: Optional[str] + :return: ``self`` for chaining. + :rtype: :class:`ConversationReferenceBuilder` + """ + self._user_id = user_id + self._user_name = user_name + return self + + def with_service_url(self, service_url: str) -> "ConversationReferenceBuilder": + """ + Override the service URL. + + :param service_url: The service URL to use. + :type service_url: str + :return: ``self`` for chaining. + :rtype: :class:`ConversationReferenceBuilder` + """ + self._service_url = service_url + return self + + def with_activity_id(self, activity_id: str) -> "ConversationReferenceBuilder": + """ + Set the activity ID on the reference. + + :param activity_id: The activity ID. + :type activity_id: str + :return: ``self`` for chaining. + :rtype: :class:`ConversationReferenceBuilder` + """ + self._activity_id = activity_id + return self + + def with_locale(self, locale: str) -> "ConversationReferenceBuilder": + """ + Set the locale on the reference. + + :param locale: BCP-47 locale string (e.g. ``"en-US"``). + :type locale: str + :return: ``self`` for chaining. + :rtype: :class:`ConversationReferenceBuilder` + """ + self._locale = locale + return self + + # ------------------------------------------------------------------ + # Build + # ------------------------------------------------------------------ + + def build(self) -> ConversationReference: + """ + Construct the :class:`~microsoft_agents.activity.ConversationReference`. + + :raises ValueError: If ``channel_id`` has not been set. + :return: The built conversation reference. + :rtype: :class:`~microsoft_agents.activity.ConversationReference` + """ + if not self._channel_id: + raise ValueError("ConversationReferenceBuilder: channel_id is required.") + + service_url = self._service_url or _service_url_for_channel(self._channel_id) + + agent = ( + ChannelAccount(id=self._agent_id, name=self._agent_name) + if self._agent_id + else None + ) + user = ( + ChannelAccount(id=self._user_id, name=self._user_name) + if self._user_id + else None + ) + + return ConversationReference( + channel_id=self._channel_id, + conversation=ConversationAccount(id=self._conversation_id or ""), + service_url=service_url, + bot=agent, + user=user, + activity_id=self._activity_id, + locale=self._locale, + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py new file mode 100644 index 00000000..8051465c --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py @@ -0,0 +1,59 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +from microsoft_agents.activity import ConversationParameters +from microsoft_agents.hosting.core.authorization import ClaimsIdentity + + +@dataclass +class CreateConversationOptions: + """ + Options for :meth:`~microsoft_agents.hosting.core.app.proactive.proactive.Proactive.create_conversation`. + + :param identity: The :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + used to authenticate the outbound call. + :type identity: :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + :param channel_id: The target channel identifier (e.g. ``"msteams"``). + :type channel_id: str + :param parameters: The :class:`~microsoft_agents.activity.ConversationParameters` + passed to the channel when creating the conversation. + :type parameters: :class:`~microsoft_agents.activity.ConversationParameters` + :param service_url: Optional override for the channel service URL. + :type service_url: Optional[str] + :param audience: Optional OAuth audience override. When ``None`` the + audience is derived from *identity*. + :type audience: Optional[str] + :param store_conversation: When ``True`` the newly created conversation is + automatically stored via + :meth:`~microsoft_agents.hosting.core.app.proactive.proactive.Proactive.store_conversation` + so it can be resumed later. Defaults to ``False``. + :type store_conversation: bool + """ + + identity: ClaimsIdentity = field(default=None) + channel_id: str = field(default="") + parameters: Optional[ConversationParameters] = field(default=None) + service_url: Optional[str] = field(default=None) + audience: Optional[str] = field(default=None) + store_conversation: bool = field(default=False) + + def validate(self) -> None: + """ + Raise :exc:`ValueError` if required fields are missing. + + :raises ValueError: If ``identity``, ``channel_id``, or ``parameters`` + are absent or ``service_url`` is missing and cannot be derived. + """ + if not self.identity: + raise ValueError("CreateConversationOptions.identity is required.") + if not self.channel_id: + raise ValueError("CreateConversationOptions.channel_id is required.") + if not self.parameters: + raise ValueError("CreateConversationOptions.parameters is required.") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py new file mode 100644 index 00000000..e02498f7 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py @@ -0,0 +1,392 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import logging +from typing import Awaitable, Callable, Generic, Optional, TypeVar, TYPE_CHECKING + +from microsoft_agents.activity import Activity, ResourceResponse + +from microsoft_agents.hosting.core.app.state.turn_state import TurnState +from microsoft_agents.hosting.core.storage import Storage + +from .conversation import Conversation +from .create_conversation_options import CreateConversationOptions +from .proactive_options import ProactiveOptions + +if TYPE_CHECKING: + from microsoft_agents.hosting.core.turn_context import TurnContext + from microsoft_agents.hosting.core.channel_service_adapter import ChannelServiceAdapter + from microsoft_agents.hosting.core.app.agent_application import AgentApplication + +logger = logging.getLogger(__name__) + +StateT = TypeVar("StateT", bound=TurnState) + +_STORAGE_KEY_PREFIX = "proactive/conversations/" + +RouteHandler = Callable[["TurnContext", StateT], Awaitable[None]] + + +class Proactive(Generic[StateT]): + """ + Proactive messaging support for :class:`~microsoft_agents.hosting.core.app.agent_application.AgentApplication`. + + This class is attached to :attr:`AgentApplication.proactive` automatically when + :attr:`~microsoft_agents.hosting.core.app.app_options.ApplicationOptions.proactive` options are + provided. It provides methods to: + + * **Persist** a conversation reference so it can be resumed later + (:meth:`store_conversation`, :meth:`get_conversation`, + :meth:`delete_conversation`). + * **Continue** an existing conversation proactively + (:meth:`continue_conversation`). + * **Send** a single activity into an existing conversation + (:meth:`send_activity`). + * **Create** a brand-new conversation with a user + (:meth:`create_conversation`). + + Example — store then resume:: + + # During a normal turn, save the conversation for later: + await app.proactive.store_conversation(context) + + # Later (e.g. from a webhook), resume it: + async def notify(context, state): + await context.send_activity("Here is your notification!") + + await app.proactive.continue_conversation(adapter, conversation_id, notify) + """ + + def __init__( + self, + app: "AgentApplication[StateT]", + options: ProactiveOptions, + ) -> None: + self._app = app + self._options = options + + # ------------------------------------------------------------------ + # Storage helpers + # ------------------------------------------------------------------ + + @property + def _storage(self) -> Storage: + storage = self._options.storage or self._app.options.storage + if not storage: + raise RuntimeError( + "Proactive messaging requires a Storage instance. " + "Configure ProactiveOptions.storage or ApplicationOptions.storage." + ) + return storage + + @staticmethod + def _storage_key(conversation_id: str) -> str: + return f"{_STORAGE_KEY_PREFIX}{conversation_id}" + + # ------------------------------------------------------------------ + # Conversation persistence + # ------------------------------------------------------------------ + + async def store_conversation( + self, + context_or_conversation: "TurnContext | Conversation", + ) -> None: + """ + Persist a :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + to storage so it can be resumed later. + + Accepts either: + + * A :class:`~microsoft_agents.hosting.core.turn_context.TurnContext` — the + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + is built automatically from the current turn. + * A :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + — stored directly. + + :param context_or_conversation: The turn context or an already-built + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + :raises ValueError: If required fields on the conversation are missing. + """ + from microsoft_agents.hosting.core.turn_context import TurnContext + + if isinstance(context_or_conversation, TurnContext): + conversation = Conversation.from_turn_context(context_or_conversation) + else: + conversation = context_or_conversation + + conversation.validate() + key = self._storage_key(conversation.conversation_reference.conversation.id) + logger.debug("Storing conversation with key: %s", key) + await self._storage.write({key: conversation}) + + async def get_conversation(self, conversation_id: str) -> Optional[Conversation]: + """ + Retrieve a previously stored + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + + :param conversation_id: The conversation ID used as the storage key. + :type conversation_id: str + :return: The stored :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`, + or ``None`` if not found. + :rtype: Optional[:class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`] + """ + key = self._storage_key(conversation_id) + results = await self._storage.read([key], target_cls=Conversation) + return results.get(key) + + async def delete_conversation(self, conversation_id: str) -> None: + """ + Delete a previously stored conversation. + + :param conversation_id: The conversation ID to delete. + :type conversation_id: str + """ + key = self._storage_key(conversation_id) + logger.debug("Deleting conversation with key: %s", key) + await self._storage.delete([key]) + + # ------------------------------------------------------------------ + # Send a single activity + # ------------------------------------------------------------------ + + async def send_activity( + self, + adapter: "ChannelServiceAdapter", + conversation_id_or_conversation: "str | Conversation", + activity: Activity, + ) -> Optional[ResourceResponse]: + """ + Send a single activity into an existing conversation. + + :param adapter: The channel service adapter. + :type adapter: :class:`~microsoft_agents.hosting.core.channel_service_adapter.ChannelServiceAdapter` + :param conversation_id_or_conversation: Either a conversation ID string + (the conversation is loaded from storage) or a + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + object. + :type conversation_id_or_conversation: str or + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + :param activity: The activity to send. + :type activity: :class:`~microsoft_agents.activity.Activity` + :return: The :class:`~microsoft_agents.activity.ResourceResponse` from the + channel, or ``None``. + :rtype: Optional[:class:`~microsoft_agents.activity.ResourceResponse`] + :raises KeyError: If *conversation_id_or_conversation* is a string and the + conversation is not found in storage. + """ + conversation = await self._resolve_conversation(conversation_id_or_conversation) + return await Proactive._send_activity_impl(adapter, conversation, activity) + + @staticmethod + async def _send_activity_impl( + adapter: "ChannelServiceAdapter", + conversation: Conversation, + activity: Activity, + ) -> Optional[ResourceResponse]: + result: Optional[ResourceResponse] = None + captured_exc: Optional[BaseException] = None + + claims = Conversation.identity_from_claims(conversation.claims) + continuation = conversation.conversation_reference.get_continuation_activity() + + async def _callback(context: "TurnContext") -> None: + nonlocal result, captured_exc + try: + result = await context.send_activity(activity) + except Exception as exc: # noqa: BLE001 + captured_exc = exc + + await adapter.continue_conversation_with_claims(claims, continuation, _callback) + + if captured_exc is not None: + raise captured_exc + return result + + # ------------------------------------------------------------------ + # Continue a conversation + # ------------------------------------------------------------------ + + async def continue_conversation( + self, + adapter: "ChannelServiceAdapter", + conversation_id_or_conversation: "str | Conversation", + handler: RouteHandler, + *, + continuation_activity: Optional[Activity] = None, + token_handlers: Optional[list[str]] = None, + ) -> None: + """ + Continue an existing conversation by invoking *handler* inside a full + turn with state loaded/saved. + + :param adapter: The channel service adapter. + :type adapter: :class:`~microsoft_agents.hosting.core.channel_service_adapter.ChannelServiceAdapter` + :param conversation_id_or_conversation: Conversation ID (loaded from + storage) or a direct + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + :type conversation_id_or_conversation: str or + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + :param handler: Async callable ``(context, state) -> None`` that performs + the proactive work. + :type handler: Callable + :param continuation_activity: Optional override for the continuation activity + sent to the channel. When ``None`` the default continuation event from + :meth:`~microsoft_agents.activity.ConversationReference.get_continuation_activity` + is used. Supply a custom activity to carry additional payload (e.g. the + original message) into the proactive turn via ``context.activity.value``. + :type continuation_activity: Optional[:class:`~microsoft_agents.activity.Activity`] + :param token_handlers: Optional list of OAuth connection names whose + tokens must be available before *handler* is invoked. When + :attr:`~ProactiveOptions.fail_on_unsigned_in_connections` is ``True`` + (the default) and a token is missing a :exc:`RuntimeError` is raised. + :type token_handlers: Optional[list[str]] + :raises KeyError: If *conversation_id_or_conversation* is a string and the + conversation is not found in storage. + :raises RuntimeError: If a required OAuth token is not available and + :attr:`~ProactiveOptions.fail_on_unsigned_in_connections` is ``True``. + """ + conversation = await self._resolve_conversation(conversation_id_or_conversation) + + captured_exc: Optional[BaseException] = None + claims = Conversation.identity_from_claims(conversation.claims) + continuation = ( + continuation_activity + or conversation.conversation_reference.get_continuation_activity() + ) + + async def _callback(context: "TurnContext") -> None: + nonlocal captured_exc + try: + await self._on_turn(context, handler, token_handlers) + except Exception as exc: # noqa: BLE001 + captured_exc = exc + + await adapter.continue_conversation_with_claims(claims, continuation, _callback) + + if captured_exc is not None: + raise captured_exc + + # ------------------------------------------------------------------ + # Create a new conversation + # ------------------------------------------------------------------ + + async def create_conversation( + self, + adapter: "ChannelServiceAdapter", + options: CreateConversationOptions, + handler: Optional[RouteHandler] = None, + ) -> Conversation: + """ + Create a brand-new conversation with a user and optionally run *handler*. + + :param adapter: The channel service adapter. + :type adapter: :class:`~microsoft_agents.hosting.core.channel_service_adapter.ChannelServiceAdapter` + :param options: Options specifying the identity, channel, conversation + parameters, etc. + :type options: :class:`~microsoft_agents.hosting.core.app.proactive.create_conversation_options.CreateConversationOptions` + :param handler: Optional async callable invoked inside the new + conversation's first turn. + :type handler: Optional[Callable] + :return: A :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + representing the newly created conversation. + :rtype: :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + :raises ValueError: If required fields in *options* are missing. + """ + options.validate() + + new_conversation: Optional[Conversation] = None + captured_exc: Optional[BaseException] = None + + audience = options.audience or options.identity.get_token_audience() + + async def _callback(context: "TurnContext") -> None: + nonlocal new_conversation, captured_exc + try: + reference = context.activity.get_conversation_reference() + new_conversation = Conversation( + claims=options.identity, + conversation_reference=reference, + ) + + if options.store_conversation: + await self.store_conversation(new_conversation) + + if handler is not None: + state = await self._load_state(context) + await handler(context, state) + await state.save(context) + except Exception as exc: # noqa: BLE001 + captured_exc = exc + + await adapter.create_conversation( + options.identity.get_app_id() or "", + options.channel_id, + options.service_url or "", + audience, + options.parameters, + _callback, + ) + + if captured_exc is not None: + raise captured_exc + + return new_conversation + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + async def _on_turn( + self, + context: "TurnContext", + handler: RouteHandler, + token_handlers: Optional[list[str]] = None, + ) -> None: + """Run a proactive turn: load state → optional OAuth check → handler → save state.""" + state = await self._load_state(context) + + if token_handlers and self._app._auth: + for handler_id in token_handlers: + result = await self._app._auth._start_or_continue_sign_in( + context, state, handler_id + ) + if not result.sign_in_complete(): + if self._options.fail_on_unsigned_in_connections: + raise RuntimeError( + f"Proactive continuation aborted: user is not signed in " + f"for OAuth connection '{handler_id}'." + ) + logger.warning( + "Proactive continuation skipped: user not signed in for '%s'.", + handler_id, + ) + return + + await handler(context, state) + await state.save(context) + + async def _load_state(self, context: "TurnContext") -> StateT: + if self._app._turn_state_factory: + state = self._app._turn_state_factory() + else: + state = TurnState.with_storage(self._storage) + await state.load(context, self._storage) + return state + + async def _resolve_conversation( + self, + conversation_id_or_conversation: "str | Conversation", + ) -> Conversation: + if isinstance(conversation_id_or_conversation, str): + conversation = await self.get_conversation(conversation_id_or_conversation) + if conversation is None: + raise KeyError( + f"Proactive conversation not found in storage: " + f"'{conversation_id_or_conversation}'" + ) + return conversation + return conversation_id_or_conversation diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py new file mode 100644 index 00000000..d2e9c63f --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py @@ -0,0 +1,35 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +from microsoft_agents.hosting.core.storage import Storage + + +@dataclass +class ProactiveOptions: + """ + Options for the Proactive messaging subsystem. + + :param storage: The storage instance used to persist and retrieve conversations. + :type storage: Optional[:class:`microsoft_agents.hosting.core.storage.Storage`] + :param fail_on_unsigned_in_connections: If ``True`` (the default), a + :exc:`RuntimeError` is raised when a required OAuth token is not available + during a proactive continuation. Set to ``False`` to silently skip the + handler instead. + :type fail_on_unsigned_in_connections: bool + """ + + storage: Optional[Storage] = None + """Storage used to persist Conversation objects.""" + + fail_on_unsigned_in_connections: bool = True + """ + When ``True`` (default), raise an error if a required OAuth connection is + not signed-in at the time a proactive continuation runs. + """ diff --git a/test_samples/proactive/README.md b/test_samples/proactive/README.md new file mode 100644 index 00000000..fda5c10e --- /dev/null +++ b/test_samples/proactive/README.md @@ -0,0 +1,171 @@ +# Proactive Agent Sample + +Mirrors the pattern from the C# `ProactiveAgent` sample. Demonstrates how +`AgentApplication.proactive` enables proactive messaging entirely through the +standard bot messaging endpoint — no separate HTTP trigger endpoint is needed +for in-conversation proactive flows. + +## Patterns shown + +| Pattern | Command / endpoint | Description | +|---------|--------------------|-------------| +| **Store** | `-s` | Persist the current conversation reference | +| **Sign in** | `-signin` | OAuth sign-in via the "me" connection | +| **Sign out** | `-signout` | OAuth sign-out from the "me" connection | +| **Continue (self)** | `-c` | Proactively continue *this* conversation (requires sign-in) | +| **Continue (stored)** | `-c ` | Proactively continue a previously stored conversation | +| **Echo via proactive** | any other message | Echo text back from inside a proactive turn using a custom continuation activity | +| **External notify** | `POST /api/proactive/notify` | Send a one-off notification from outside the bot | + +## Prerequisites + +- Python 3.10+ +- An Azure Bot registration (App ID + Secret) +- An OAuth connection configured in your Azure Bot Service registration +- A tunnel tool such as [dev tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started) or ngrok +- The [M365 Agents Playground](https://github.com/OfficeDev/microsoft-365-agents-toolkit) or Teams + +## Setup + +```bash +cp env.TEMPLATE .env +# Fill in CLIENT_ID, CLIENT_SECRET, TENANT_ID, and the OAuth connection name +``` + +Install dependencies (from the repo root with the venv active): + +```bash +pip install -r test_samples/proactive/requirements.txt +``` + +Or using editable installs from the repo root: + +```bash +. ./scripts/dev_setup.sh +``` + +## Running + +```bash +python test_samples/proactive/proactive_agent.py +``` + +Listens on `localhost:3978` by default. Set `PORT` in `.env` to override. + +## Bot commands + +All commands go through the standard `POST /api/messages` endpoint. + +| Command | Effect | +|---------|--------| +| `-s` | Store this conversation. Prints the conversation ID. | +| `-signin` | Start the OAuth sign-in flow for the "me" connection. | +| `-signout` | Sign out from the "me" connection. | +| `-c` | Continue *this* conversation proactively (requires sign-in). | +| `-c ` | Continue a stored conversation by its ID (requires sign-in). | +| `/help` | Show usage. | +| anything else | Echo via a proactive turn (demonstrates custom continuation activity). | + +### Walkthrough + +1. Send `-s` → bot replies with the conversation ID. +2. Send `-signin` → complete the OAuth flow. +3. Send `-c` → bot opens a proactive turn on this conversation and reports the token length. +4. Send `-c ` → bot opens a proactive turn on the stored conversation and reports the token length. +5. Send `-signout` → clears the "me" token. +6. Send `-c` again → bot replies "Send **-signin** first." +7. Send any text → bot echoes it back through a proactive turn. + +## HTTP endpoint + +### `POST /api/proactive/notify` + +For **external** triggers (schedulers, webhooks). Requires the conversation to +have been stored with `-s` first. + +```json +{ + "conversationId": "", + "message": "Hello from outside the bot!" +} +``` + +```bash +curl -X POST http://localhost:3978/api/proactive/notify \ + -H "Content-Type: application/json" \ + -d '{"conversationId":"","message":"Hello!"}' +``` + +## How it works + +### `-signin` — OAuth sign-in + +```python +@AGENT_APP.message("-signin", auth_handlers=["me"]) +async def on_signin(context, state): + await context.send_activity("Signed in.") +``` + +The `auth_handlers=["me"]` parameter causes the SDK to start or resume the +OAuth flow before the handler runs. By the time the handler is invoked the +user is fully signed in and the token is cached. + +### `-signout` — OAuth sign-out + +```python +@AGENT_APP.message("-signout") +async def on_signout(context, state): + await AGENT_APP.auth.sign_out(context, auth_handler_id="me") + await context.send_activity("Signed out.") +``` + +### `-c` / `-c ` — Continue with sign-in guard + +`token_handlers=["me"]` is passed to `continue_conversation`. Internally the +SDK calls `_start_or_continue_sign_in` for each listed handler before invoking +the user's handler. If the user is not signed in and +`ProactiveOptions.fail_on_unsigned_in_connections` is `True` (the default), a +`RuntimeError` is raised — mirroring C#'s `UserNotSignedIn` exception. + +```python +try: + await AGENT_APP.proactive.continue_conversation( + ADAPTER, conversation_id, _on_continue, + token_handlers=["me"], + ) +except RuntimeError: + await context.send_activity("Send **-signin** first.") +``` + +Inside `_on_continue` the token is retrieved via: + +```python +token_response = await AGENT_APP.auth.get_token(context, auth_handler_id="me") +``` + +### Echo via proactive — custom `continuation_activity` + +```python +conversation = Conversation.from_turn_context(context) +continuation = conversation.conversation_reference.get_continuation_activity() +continuation.value = context.activity # carry the original message + +await AGENT_APP.proactive.continue_conversation( + ADAPTER, conversation, _on_echo, + continuation_activity=continuation, +) +``` + +Inside `_on_echo`, `context.activity.value` holds the original `Activity` so +the handler can read `original.text` without shared state. + +## Key classes + +| Class | Module | +|-------|--------| +| `Proactive` | `microsoft_agents.hosting.core.app.proactive` | +| `ProactiveOptions` | `microsoft_agents.hosting.core.app.proactive` | +| `Conversation` | `microsoft_agents.hosting.core.app.proactive` | +| `ConversationBuilder` | `microsoft_agents.hosting.core.app.proactive` | +| `ConversationReferenceBuilder` | `microsoft_agents.hosting.core.app.proactive` | +| `CreateConversationOptions` | `microsoft_agents.hosting.core.app.proactive` | diff --git a/test_samples/proactive/env.TEMPLATE b/test_samples/proactive/env.TEMPLATE new file mode 100644 index 00000000..cc62547e --- /dev/null +++ b/test_samples/proactive/env.TEMPLATE @@ -0,0 +1,11 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=your-azure-app-client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=your-azure-app-client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=your-tenant-id + +# OAuth connection for the "me" sign-in handler used by -signin / -c +# Create an OAuth connection in your Azure Bot Service registration and put its name here. +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__ME__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=your-oauth-connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__ME__SETTINGS__TYPE=UserAuthorization + +# Optional: override the port (default 3978) +# PORT=3978 diff --git a/test_samples/proactive/proactive_agent.py b/test_samples/proactive/proactive_agent.py new file mode 100644 index 00000000..16ccc4eb --- /dev/null +++ b/test_samples/proactive/proactive_agent.py @@ -0,0 +1,359 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Proactive messaging sample. + +Mirrors the pattern from the C# ProactiveAgent sample. + +Bot commands (all via /api/messages): + -s — store this conversation so it can be resumed later. + -c — continue THIS conversation proactively (no stored ID needed). + -c — continue a previously stored conversation by ID. + The continuation requires the user to be signed in via "me". + -signin — sign in with the "me" OAuth connection. + -signout — sign out from the "me" OAuth connection. + /help — show help text. + anything else — echo the message back via a proactive continuation turn, + passing the original activity as the continuation value. + +HTTP endpoints: + POST /api/messages — standard bot channel endpoint (all commands above). + POST /api/proactive/notify — send a one-off notification to a stored conversation. + Body: {"conversationId": "", "message": ""} + GET / — health check. +""" + +from __future__ import annotations + +import json +import logging +import re +from os import environ, path +from typing import Any, Dict + +from aiohttp import web +from dotenv import load_dotenv + +from microsoft_agents.activity import Activity, ActivityTypes, load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import ( + AgentApplication, + MemoryStorage, + MessageFactory, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.app import ApplicationOptions, Authorization, ProactiveOptions +from microsoft_agents.hosting.core.app.proactive import Conversation + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Bootstrap +# --------------------------------------------------------------------------- + +load_dotenv(path.join(path.dirname(__file__), ".env")) +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) + +# Authorization is built from agents_sdk_config so the "me" handler +# defined in the env file is automatically registered. +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + options=ApplicationOptions( + storage=STORAGE, + adapter=ADAPTER, + proactive=ProactiveOptions(), + ), + authorization=AUTHORIZATION, +) + +# --------------------------------------------------------------------------- +# Proactive handlers +# --------------------------------------------------------------------------- + + +async def _on_continue(context: TurnContext, state: TurnState) -> None: + """Proactive turn handler for -c. + + Requires the user to be signed in via the "me" OAuth connection — enforced + by passing token_handlers=["me"] to continue_conversation. If sign-in is + complete, retrieve the token and report its length (mirrors C# sample). + """ + token_response = await AGENT_APP.auth.get_token(context, auth_handler_id="me") + token_len = len(token_response.token) if token_response and token_response.token else 0 + await context.send_activity( + f"This is the proactive continuation turn. " + f"Token length = {token_len if token_len else 'not signed in'}." + ) + + +async def _on_echo(context: TurnContext, state: TurnState) -> None: + """Proactive turn handler for the catch-all echo pattern. + + The original activity is carried in context.activity.value so this handler + can reply without touching shared state. + """ + original: Activity = context.activity.value + text = original.text if original and original.text else "(empty)" + await context.send_activity(f"You said: {text}") + + +# --------------------------------------------------------------------------- +# Agent handlers +# --------------------------------------------------------------------------- + + +@AGENT_APP.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState) -> None: + await context.send_activity( + "Welcome to the Proactive Agent sample!\n\n" + "Commands:\n" + " **-s** — store this conversation for later\n" + " **-c** — proactively continue this conversation (requires sign-in)\n" + " **-c \** — proactively continue a stored conversation\n" + " **-signin** — sign in with the 'me' OAuth connection\n" + " **-signout** — sign out from the 'me' OAuth connection\n" + " **/help** — show this message\n\n" + "Send anything else to see the echo-via-proactive pattern." + ) + + +@AGENT_APP.message("/help") +async def on_help(context: TurnContext, _state: TurnState) -> None: + await context.send_activity( + "Commands:\n" + " **-s** — store this conversation\n" + " **-c** — proactively continue this conversation (requires sign-in)\n" + " **-c \** — proactively continue a stored conversation\n" + " **-signin** — sign in with the 'me' OAuth connection\n" + " **-signout** — sign out from the 'me' OAuth connection\n" + " **/help** — show this message\n\n" + "Anything else is echoed back via a proactive continuation turn.\n\n" + "HTTP: POST /api/proactive/notify to send an external notification." + ) + + +@AGENT_APP.message("-s") +async def on_store(context: TurnContext, _state: TurnState) -> None: + """Store the current conversation so it can be resumed proactively.""" + await AGENT_APP.proactive.store_conversation(context) + conversation_id = context.activity.conversation.id + await context.send_activity( + f"Conversation stored. Use this ID with **-c** or the notify endpoint:\n\n" + f"```\n{conversation_id}\n```" + ) + logger.info("Stored conversation: %s", conversation_id) + + +@AGENT_APP.message("-signin", auth_handlers=["me"]) +async def on_signin(context: TurnContext, _state: TurnState) -> None: + """Trigger the OAuth sign-in flow for the 'me' connection. + + The auth_handlers=["me"] parameter causes the SDK to start or resume the + OAuth flow before this handler runs. By the time we reach here the user + is signed in. + """ + await context.send_activity("Signed in.") + + +@AGENT_APP.message("-signout") +async def on_signout(context: TurnContext, state: TurnState) -> None: + """Sign the user out from the 'me' OAuth connection.""" + await AGENT_APP.auth.sign_out(context, auth_handler_id="me") + await context.send_activity("Signed out.") + + +@AGENT_APP.message(re.compile(r"^-c(\s+\S+)?$")) +async def on_continue(context: TurnContext, _state: TurnState) -> None: + """-c [id] — trigger a proactive continuation via the messaging turn. + + With no argument, continues THIS conversation (no prior -s needed). + With an argument, continues the stored conversation with that ID. + + Passes token_handlers=["me"] so the proactive turn will fail with a + RuntimeError if the user is not yet signed in — mirrors the C# sample's + UserNotSignedIn exception handling. + """ + parts = (context.activity.text or "").split(maxsplit=1) + + if len(parts) == 2: + # -c + conversation_id = parts[1].strip() + try: + await AGENT_APP.proactive.continue_conversation( + ADAPTER, + conversation_id, + _on_continue, + token_handlers=["me"], + ) + await context.send_activity( + f"Proactive continuation sent to conversation `{conversation_id}`." + ) + except KeyError: + await context.send_activity( + f"Conversation `{conversation_id}` not found. Send **-s** first to store it." + ) + except RuntimeError: + await context.send_activity("Send **-signin** first.") + else: + # -c alone: continue THIS conversation without a storage lookup. + conversation = Conversation.from_turn_context(context) + try: + await AGENT_APP.proactive.continue_conversation( + ADAPTER, + conversation, + _on_continue, + token_handlers=["me"], + ) + except RuntimeError: + await context.send_activity("Send **-signin** first.") + + +@AGENT_APP.activity(ActivityTypes.message) +async def on_message(context: TurnContext, _state: TurnState) -> None: + """Catch-all: echo the message back via a proactive continuation turn. + + Mirrors C# ProactiveAgent.OnMessageAsync — builds a Conversation from the + current turn context, attaches the original activity as the continuation + value, and lets _on_echo reply from inside the proactive turn. + """ + conversation = Conversation.from_turn_context(context) + + # Attach the original activity as the value on the continuation event so + # _on_echo can read it from context.activity.value without touching state. + continuation = conversation.conversation_reference.get_continuation_activity() + continuation.value = context.activity + + await AGENT_APP.proactive.continue_conversation( + ADAPTER, + conversation, + _on_echo, + continuation_activity=continuation, + ) + + +@AGENT_APP.error +async def on_error(context: TurnContext, error: Exception) -> None: + logger.exception("Unhandled error in AgentApplication: %s", error) + await context.send_activity("An unexpected error occurred. Please try again.") + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + + +async def _read_json(request: web.Request) -> Dict[str, Any]: + if request.content_length in (0, None): + return {} + try: + return await request.json() + except json.JSONDecodeError: + return {} + + +# --------------------------------------------------------------------------- +# HTTP route handlers +# --------------------------------------------------------------------------- + + +async def _handle_root(_request: web.Request) -> web.Response: + return web.json_response({"status": "ready", "sample": "proactive-agent"}) + + +async def _handle_messages(request: web.Request) -> web.Response: + agent_app: AgentApplication = request.app["agent_app"] + adapter: CloudAdapter = request.app["adapter"] + response = await start_agent_process(request, agent_app, adapter) + return response or web.Response(status=202) + + +async def _handle_proactive_notify(request: web.Request) -> web.Response: + """Send a one-off proactive activity to a stored conversation. + + Intended for external triggers (schedulers, webhooks, etc.). + For in-conversation proactive messaging use the **-c** command instead. + + Expected JSON body:: + + { + "conversationId": "", + "message": "Hello from the server!" + } + """ + payload = await _read_json(request) + conversation_id: str = (payload.get("conversationId") or "").strip() + message: str = (payload.get("message") or "").strip() + + if not conversation_id: + return web.json_response({"error": "'conversationId' is required."}, status=400) + if not message: + return web.json_response({"error": "'message' is required."}, status=400) + + try: + await AGENT_APP.proactive.send_activity( + ADAPTER, + conversation_id, + MessageFactory.text(f"[Notification] {message}"), + ) + except KeyError: + return web.json_response( + { + "error": ( + f"Conversation '{conversation_id}' not found. " + "Send -s in the conversation first." + ) + }, + status=404, + ) + except Exception as exc: + logger.exception("Error sending proactive notification: %s", exc) + return web.json_response({"error": str(exc)}, status=500) + + logger.info("Notification delivered to: %s", conversation_id) + return web.json_response( + {"status": "delivered", "conversationId": conversation_id}, + status=202, + ) + + +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- + + +def create_app() -> web.Application: + app = web.Application(middlewares=[jwt_authorization_middleware]) + + app["agent_app"] = AGENT_APP + app["adapter"] = ADAPTER + app["agent_configuration"] = CONNECTION_MANAGER.get_default_connection_configuration() + + app.router.add_get("/", _handle_root) + app.router.add_post("/api/messages", _handle_messages) + app.router.add_post("/api/proactive/notify", _handle_proactive_notify) + + return app + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +if __name__ == "__main__": + host = environ.get("HOST", "localhost") + port = int(environ.get("PORT", "3978")) + + web.run_app(create_app(), host=host, port=port) diff --git a/test_samples/proactive/requirements.txt b/test_samples/proactive/requirements.txt new file mode 100644 index 00000000..5d3543a1 --- /dev/null +++ b/test_samples/proactive/requirements.txt @@ -0,0 +1,6 @@ +microsoft-agents-activity +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-hosting-aiohttp +aiohttp +python-dotenv From b9d2bdab6710a0680c73d48353e24e3630b4cb74 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Wed, 1 Apr 2026 19:07:34 -0700 Subject: [PATCH 2/8] formating: Proactive testing WIP --- .../hosting/core/app/proactive/conversation.py | 12 +++++++++--- .../hosting/core/app/proactive/proactive.py | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py index 815f5650..2476ebe5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py @@ -45,7 +45,9 @@ def __init__( if isinstance(claims, ClaimsIdentity): self.claims: dict[str, str] = Conversation.claims_from_identity(claims) else: - self.claims = {k: v for k, v in claims.items() if k in _PERSISTED_CLAIM_KEYS} + self.claims = { + k: v for k, v in claims.items() if k in _PERSISTED_CLAIM_KEYS + } self.conversation_reference: ConversationReference = conversation_reference # ------------------------------------------------------------------ @@ -115,9 +117,13 @@ def validate(self) -> None: if not self.conversation_reference: raise ValueError("Conversation.conversation_reference is required.") if not self.conversation_reference.conversation: - raise ValueError("Conversation.conversation_reference.conversation is required.") + raise ValueError( + "Conversation.conversation_reference.conversation is required." + ) if not self.conversation_reference.service_url: - raise ValueError("Conversation.conversation_reference.service_url is required.") + raise ValueError( + "Conversation.conversation_reference.service_url is required." + ) # ------------------------------------------------------------------ # StoreItem serialization diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py index e02498f7..383af02a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py @@ -19,7 +19,9 @@ if TYPE_CHECKING: from microsoft_agents.hosting.core.turn_context import TurnContext - from microsoft_agents.hosting.core.channel_service_adapter import ChannelServiceAdapter + from microsoft_agents.hosting.core.channel_service_adapter import ( + ChannelServiceAdapter, + ) from microsoft_agents.hosting.core.app.agent_application import AgentApplication logger = logging.getLogger(__name__) From d06e7b5d83e3e683f02bda41377a0e33ea925b0a Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Thu, 2 Apr 2026 19:30:44 -0700 Subject: [PATCH 3/8] Readme changes --- test_samples/proactive/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test_samples/proactive/README.md b/test_samples/proactive/README.md index fda5c10e..961832ab 100644 --- a/test_samples/proactive/README.md +++ b/test_samples/proactive/README.md @@ -1,7 +1,6 @@ # Proactive Agent Sample -Mirrors the pattern from the C# `ProactiveAgent` sample. Demonstrates how -`AgentApplication.proactive` enables proactive messaging entirely through the +Demonstrates how`AgentApplication.proactive` enables proactive messaging entirely through the standard bot messaging endpoint — no separate HTTP trigger endpoint is needed for in-conversation proactive flows. From 144b9aa671183d0a08f72d84012d2298c2b39b87 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Tue, 7 Apr 2026 15:51:44 -0700 Subject: [PATCH 4/8] Proactive testing WIP --- test_samples/proactive/proactive_agent.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test_samples/proactive/proactive_agent.py b/test_samples/proactive/proactive_agent.py index 16ccc4eb..def6f94b 100644 --- a/test_samples/proactive/proactive_agent.py +++ b/test_samples/proactive/proactive_agent.py @@ -90,7 +90,7 @@ async def _on_continue(context: TurnContext, state: TurnState) -> None: by passing token_handlers=["me"] to continue_conversation. If sign-in is complete, retrieve the token and report its length (mirrors C# sample). """ - token_response = await AGENT_APP.auth.get_token(context, auth_handler_id="me") + token_response = await AGENT_APP.auth.get_token(context, auth_handler_id="ME") token_len = len(token_response.token) if token_response and token_response.token else 0 await context.send_activity( f"This is the proactive continuation turn. " @@ -156,7 +156,7 @@ async def on_store(context: TurnContext, _state: TurnState) -> None: logger.info("Stored conversation: %s", conversation_id) -@AGENT_APP.message("-signin", auth_handlers=["me"]) +@AGENT_APP.message("-signin", auth_handlers=["ME"]) async def on_signin(context: TurnContext, _state: TurnState) -> None: """Trigger the OAuth sign-in flow for the 'me' connection. @@ -195,7 +195,7 @@ async def on_continue(context: TurnContext, _state: TurnState) -> None: ADAPTER, conversation_id, _on_continue, - token_handlers=["me"], + token_handlers=["ME"], ) await context.send_activity( f"Proactive continuation sent to conversation `{conversation_id}`." @@ -214,7 +214,7 @@ async def on_continue(context: TurnContext, _state: TurnState) -> None: ADAPTER, conversation, _on_continue, - token_handlers=["me"], + token_handlers=["ME"], ) except RuntimeError: await context.send_activity("Send **-signin** first.") From f65d923841dfee19c56dd32d599df892ad1b9a64 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Thu, 9 Apr 2026 16:10:46 -0700 Subject: [PATCH 5/8] Adding unit tests to proactive scenario --- .gitignore | 5 +- tests/hosting_core/app/proactive/__init__.py | 0 .../app/proactive/test_conversation.py | 202 ++++++ .../proactive/test_conversation_builder.py | 290 +++++++++ .../test_conversation_reference_builder.py | 253 ++++++++ .../test_create_conversation_options.py | 116 ++++ .../app/proactive/test_proactive.py | 585 ++++++++++++++++++ 7 files changed, 1450 insertions(+), 1 deletion(-) create mode 100644 tests/hosting_core/app/proactive/__init__.py create mode 100644 tests/hosting_core/app/proactive/test_conversation.py create mode 100644 tests/hosting_core/app/proactive/test_conversation_builder.py create mode 100644 tests/hosting_core/app/proactive/test_conversation_reference_builder.py create mode 100644 tests/hosting_core/app/proactive/test_create_conversation_options.py create mode 100644 tests/hosting_core/app/proactive/test_proactive.py diff --git a/.gitignore b/.gitignore index 900f432a..f019283b 100644 --- a/.gitignore +++ b/.gitignore @@ -127,4 +127,7 @@ cython_debug/ .vscode/ # Binary files -bin/ \ No newline at end of file +bin/ + +# Claude +.claude/ \ No newline at end of file diff --git a/tests/hosting_core/app/proactive/__init__.py b/tests/hosting_core/app/proactive/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/app/proactive/test_conversation.py b/tests/hosting_core/app/proactive/test_conversation.py new file mode 100644 index 00000000..edcffc5d --- /dev/null +++ b/tests/hosting_core/app/proactive/test_conversation.py @@ -0,0 +1,202 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest +from unittest.mock import MagicMock + +from microsoft_agents.activity import ConversationAccount, ConversationReference +from microsoft_agents.hosting.core.app.proactive import Conversation +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter + + +def _make_reference( + conversation_id="conv-1", + service_url="https://smba.trafficmanager.net/teams/", + channel_id="msteams", +): + return ConversationReference( + conversation=ConversationAccount(id=conversation_id), + service_url=service_url, + channel_id=channel_id, + ) + + +class TestConversationInit: + def test_init_with_dict_keeps_allowed_claims(self): + claims = { + "aud": "app-id", + "azp": "azp-val", + "appid": "app-id", + "idtyp": "app", + "ver": "2.0", + "iss": "issuer", + "tid": "tenant-id", + } + conv = Conversation(claims=claims, conversation_reference=_make_reference()) + for key in ("aud", "azp", "appid", "idtyp", "ver", "iss", "tid"): + assert conv.claims[key] == claims[key] + + def test_init_with_dict_filters_unknown_claims(self): + claims = {"aud": "app-id", "extra": "should-be-filtered", "another": "drop"} + conv = Conversation(claims=claims, conversation_reference=_make_reference()) + assert "extra" not in conv.claims + assert "another" not in conv.claims + assert conv.claims["aud"] == "app-id" + + def test_init_with_claims_identity_filters_correctly(self): + identity = ClaimsIdentity( + claims={"aud": "app-id", "tid": "tenant", "unrelated": "drop"}, + is_authenticated=True, + ) + conv = Conversation(claims=identity, conversation_reference=_make_reference()) + assert conv.claims["aud"] == "app-id" + assert conv.claims["tid"] == "tenant" + assert "unrelated" not in conv.claims + + def test_init_stores_conversation_reference(self): + ref = _make_reference() + conv = Conversation(claims={}, conversation_reference=ref) + assert conv.conversation_reference is ref + + def test_init_empty_claims_dict(self): + conv = Conversation(claims={}, conversation_reference=_make_reference()) + assert conv.claims == {} + + +class TestConversationFromTurnContext: + def test_from_turn_context_extracts_reference_and_identity(self): + ref = _make_reference("ctx-conv") + identity = ClaimsIdentity(claims={"aud": "app-id", "tid": "t"}, is_authenticated=True) + + ctx = MagicMock() + ctx.activity.get_conversation_reference.return_value = ref + ctx.turn_state = {ChannelAdapter.AGENT_IDENTITY_KEY: identity} + + conv = Conversation.from_turn_context(ctx) + + assert conv.conversation_reference is ref + assert conv.claims.get("aud") == "app-id" + assert conv.claims.get("tid") == "t" + + def test_from_turn_context_handles_missing_identity(self): + ref = _make_reference("ctx-conv") + ctx = MagicMock() + ctx.activity.get_conversation_reference.return_value = ref + ctx.turn_state = {} + + conv = Conversation.from_turn_context(ctx) + + assert conv.conversation_reference is ref + assert conv.claims == {} + + +class TestConversationClaimsHelpers: + def test_claims_from_identity_keeps_allowed_keys(self): + identity = ClaimsIdentity( + claims={"aud": "a", "tid": "t", "ver": "2.0", "other": "drop"}, + is_authenticated=True, + ) + result = Conversation.claims_from_identity(identity) + assert result == {"aud": "a", "tid": "t", "ver": "2.0"} + + def test_claims_from_identity_empty_claims(self): + identity = ClaimsIdentity(claims={}, is_authenticated=True) + result = Conversation.claims_from_identity(identity) + assert result == {} + + def test_identity_from_claims_is_authenticated(self): + claims = {"aud": "app-id", "tid": "tenant"} + identity = Conversation.identity_from_claims(claims) + assert identity.is_authenticated is True + + def test_identity_from_claims_preserves_values(self): + claims = {"aud": "app-id", "tid": "tenant", "ver": "2.0"} + identity = Conversation.identity_from_claims(claims) + assert identity.claims["aud"] == "app-id" + assert identity.claims["tid"] == "tenant" + assert identity.claims["ver"] == "2.0" + + def test_identity_from_claims_does_not_mutate_input(self): + claims = {"aud": "app-id"} + Conversation.identity_from_claims(claims) + assert claims == {"aud": "app-id"} + + +class TestConversationValidate: + def test_validate_ok(self): + conv = Conversation(claims={}, conversation_reference=_make_reference()) + conv.validate() # must not raise + + def test_validate_missing_conversation_reference_raises(self): + conv = Conversation(claims={}, conversation_reference=_make_reference()) + conv.conversation_reference = None + with pytest.raises(ValueError, match="conversation_reference"): + conv.validate() + + def test_validate_missing_conversation_account_raises(self): + ref = _make_reference() + conv = Conversation(claims={}, conversation_reference=ref) + # ConversationReference.conversation is a required Pydantic field so it cannot + # be omitted at construction time. Set it to None after construction to exercise + # the Conversation.validate() guard directly. + conv.conversation_reference.conversation = None + with pytest.raises(ValueError, match="conversation"): + conv.validate() + + def test_validate_missing_service_url_raises(self): + ref = ConversationReference( + conversation=ConversationAccount(id="conv1"), + channel_id="msteams", + ) + conv = Conversation(claims={}, conversation_reference=ref) + with pytest.raises(ValueError, match="service_url"): + conv.validate() + + +class TestConversationSerialization: + def test_store_item_to_json_contains_claims(self): + conv = Conversation( + claims={"aud": "app-id", "tid": "tenant"}, + conversation_reference=_make_reference(), + ) + data = conv.store_item_to_json() + assert data["claims"]["aud"] == "app-id" + assert data["claims"]["tid"] == "tenant" + + def test_store_item_to_json_contains_conversation_reference(self): + conv = Conversation( + claims={"aud": "app-id"}, + conversation_reference=_make_reference("my-conv"), + ) + data = conv.store_item_to_json() + assert "conversation_reference" in data + + def test_round_trip_preserves_claims(self): + original = Conversation( + claims={"aud": "app-id", "tid": "tenant"}, + conversation_reference=_make_reference("rt-conv"), + ) + json_data = original.store_item_to_json() + restored = Conversation.from_json_to_store_item(json_data) + assert restored.claims == {"aud": "app-id", "tid": "tenant"} + + def test_round_trip_preserves_conversation_id(self): + original = Conversation( + claims={}, + conversation_reference=_make_reference("rt-conv-id"), + ) + json_data = original.store_item_to_json() + restored = Conversation.from_json_to_store_item(json_data) + assert restored.conversation_reference.conversation.id == "rt-conv-id" + + def test_round_trip_preserves_service_url(self): + original = Conversation( + claims={}, + conversation_reference=_make_reference(service_url="https://custom.service/"), + ) + json_data = original.store_item_to_json() + restored = Conversation.from_json_to_store_item(json_data) + assert restored.conversation_reference.service_url == "https://custom.service/" diff --git a/tests/hosting_core/app/proactive/test_conversation_builder.py b/tests/hosting_core/app/proactive/test_conversation_builder.py new file mode 100644 index 00000000..479c9216 --- /dev/null +++ b/tests/hosting_core/app/proactive/test_conversation_builder.py @@ -0,0 +1,290 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest + +from microsoft_agents.hosting.core.app.proactive import Conversation, ConversationBuilder +from microsoft_agents.hosting.core.authorization import ClaimsIdentity + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _prep_build(builder: ConversationBuilder) -> ConversationBuilder: + """Make a ConversationBuilder ready to call .build() without Pydantic errors. + + The implementation passes _agent_name and _conversation_id directly to + Pydantic models that reject None / empty-string values. There is no public + API to set these on the builder, so tests set them directly. + """ + if builder._agent_id and builder._agent_name is None: + builder._agent_name = "Agent" + if not builder._conversation_id: + builder._conversation_id = "conv-1" + return builder + + +# --------------------------------------------------------------------------- +# create() +# --------------------------------------------------------------------------- + + +class TestConversationBuilderCreate: + def test_create_sets_aud_claim(self): + builder = ConversationBuilder.create("my-app-id", "msteams") + assert builder._claims["aud"] == "my-app-id" + + def test_create_sets_channel_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + assert builder._channel_id == "msteams" + + def test_create_teams_prefixes_agent_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + assert builder._agent_id == "28:app-id" + + def test_create_non_teams_no_prefix(self): + builder = ConversationBuilder.create("app-id", "directline") + assert builder._agent_id == "app-id" + + def test_create_with_requestor_id_sets_appid_claim(self): + builder = ConversationBuilder.create("app-id", "msteams", requestor_id="requestor-id") + assert builder._claims["appid"] == "requestor-id" + + def test_create_without_requestor_id_no_appid_claim(self): + builder = ConversationBuilder.create("app-id", "msteams") + assert "appid" not in builder._claims + + def test_create_custom_service_url(self): + builder = ConversationBuilder.create( + "app-id", "msteams", service_url="https://custom.service/" + ) + assert builder._service_url == "https://custom.service/" + + def test_create_default_service_url_teams(self): + builder = ConversationBuilder.create("app-id", "msteams") + assert builder._service_url == "https://smba.trafficmanager.net/teams/" + + def test_create_default_service_url_generic(self): + builder = ConversationBuilder.create("app-id", "directline") + assert builder._service_url == "https://directline.botframework.com/" + + def test_create_returns_builder_instance(self): + result = ConversationBuilder.create("app-id", "msteams") + assert isinstance(result, ConversationBuilder) + + +# --------------------------------------------------------------------------- +# create_from_identity() +# --------------------------------------------------------------------------- + + +class TestConversationBuilderCreateFromIdentity: + def test_create_from_identity_sets_channel_id(self): + identity = ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + builder = ConversationBuilder.create_from_identity(identity, "msteams") + assert builder._channel_id == "msteams" + + def test_create_from_identity_filters_claims(self): + identity = ClaimsIdentity( + claims={"aud": "app-id", "tid": "tenant", "unrelated": "drop"}, + is_authenticated=True, + ) + builder = ConversationBuilder.create_from_identity(identity, "msteams") + assert builder._claims["aud"] == "app-id" + assert builder._claims["tid"] == "tenant" + assert "unrelated" not in builder._claims + + def test_create_from_identity_teams_prefixes_agent(self): + identity = ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + builder = ConversationBuilder.create_from_identity(identity, "msteams") + assert builder._agent_id == "28:app-id" + + def test_create_from_identity_non_teams_no_prefix(self): + identity = ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + builder = ConversationBuilder.create_from_identity(identity, "directline") + assert builder._agent_id == "app-id" + + def test_create_from_identity_no_app_id_no_agent(self): + identity = ClaimsIdentity(claims={}, is_authenticated=True) + builder = ConversationBuilder.create_from_identity(identity, "msteams") + assert builder._agent_id is None + + def test_create_from_identity_custom_service_url(self): + identity = ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + builder = ConversationBuilder.create_from_identity( + identity, "msteams", service_url="https://override/" + ) + assert builder._service_url == "https://override/" + + def test_create_from_identity_returns_builder_instance(self): + identity = ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + result = ConversationBuilder.create_from_identity(identity, "msteams") + assert isinstance(result, ConversationBuilder) + + +# --------------------------------------------------------------------------- +# Fluent setters +# --------------------------------------------------------------------------- + + +class TestConversationBuilderSetters: + def test_with_user_sets_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_user("user-id") + assert builder._user_id == "user-id" + + def test_with_user_sets_name(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_user("user-id", "Alice") + assert builder._user_name == "Alice" + + def test_with_user_returns_self(self): + builder = ConversationBuilder.create("app-id", "msteams") + result = builder.with_user("user-id") + assert result is builder + + def test_with_user_name_optional(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_user("user-id") + assert builder._user_name is None + + def test_with_conversation_sets_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_conversation("conv-123") + assert builder._conversation_id == "conv-123" + + def test_with_conversation_sets_tenant_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_conversation("conv-123", tenant_id="tenant-abc") + assert builder._tenant_id == "tenant-abc" + + def test_with_conversation_sets_name(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_conversation("conv-123", conversation_name="My Chat") + assert builder._conversation_name == "My Chat" + + def test_with_conversation_returns_self(self): + builder = ConversationBuilder.create("app-id", "msteams") + result = builder.with_conversation("conv-123") + assert result is builder + + def test_with_activity_id_sets_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_activity_id("act-456") + assert builder._activity_id == "act-456" + + def test_with_activity_id_returns_self(self): + builder = ConversationBuilder.create("app-id", "msteams") + result = builder.with_activity_id("act-456") + assert result is builder + + +# --------------------------------------------------------------------------- +# build() +# --------------------------------------------------------------------------- + + +class TestConversationBuilderBuild: + def test_build_sets_channel_id_on_reference(self): + conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() + assert conv.conversation_reference.channel_id == "msteams" + + def test_build_sets_aud_claim(self): + conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() + assert conv.claims.get("aud") == "app-id" + + def test_build_sets_service_url(self): + conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() + assert conv.conversation_reference.service_url == "https://smba.trafficmanager.net/teams/" + + def test_build_sets_agent_with_teams_prefix(self): + conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() + assert conv.conversation_reference.agent.id == "28:app-id" + + def test_build_no_agent_when_id_none(self): + # When _agent_id is not set, build() passes bot=None to ConversationReference. + # ConversationReference.agent has Field(None, alias="bot") with ChannelAccount type, + # so explicitly passing bot=None raises a Pydantic ValidationError. + from pydantic import ValidationError + builder = ConversationBuilder() + builder._channel_id = "directline" + builder._service_url = "https://directline.botframework.com/" + builder._conversation_id = "conv-placeholder" + with pytest.raises(ValidationError): + builder.build() + + def test_build_sets_user(self): + conv = ( + _prep_build(ConversationBuilder.create("app-id", "msteams")) + .with_user("user-oid", "Alice") + .build() + ) + assert conv.conversation_reference.user.id == "user-oid" + assert conv.conversation_reference.user.name == "Alice" + + def test_build_sets_conversation_id(self): + conv = ( + _prep_build(ConversationBuilder.create("app-id", "msteams")) + .with_conversation("19:thread@thread.v2") + .build() + ) + assert conv.conversation_reference.conversation.id == "19:thread@thread.v2" + + def test_build_sets_conversation_tenant_id(self): + conv = ( + _prep_build(ConversationBuilder.create("app-id", "msteams")) + .with_conversation("conv-1", tenant_id="tenant-xyz") + .build() + ) + assert conv.conversation_reference.conversation.tenant_id == "tenant-xyz" + + def test_build_sets_activity_id(self): + conv = ( + _prep_build(ConversationBuilder.create("app-id", "msteams")) + .with_activity_id("act-1") + .build() + ) + assert conv.conversation_reference.activity_id == "act-1" + + def test_build_requires_channel_id(self): + builder = ConversationBuilder() + with pytest.raises(ValueError): + builder.build() + + def test_build_with_identity_preserves_claims(self): + identity = ClaimsIdentity( + claims={"aud": "app-id", "tid": "tenant", "ver": "2.0"}, + is_authenticated=True, + ) + conv = _prep_build( + ConversationBuilder.create_from_identity(identity, "msteams") + ).build() + assert conv.claims["aud"] == "app-id" + assert conv.claims["tid"] == "tenant" + assert conv.claims["ver"] == "2.0" + + def test_build_conversation_is_conversation_instance(self): + conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() + assert isinstance(conv, Conversation) + + def test_fluent_chaining_full_build(self): + conv = ( + _prep_build( + ConversationBuilder.create("app-id", "msteams", requestor_id="req-id") + ) + .with_user("user-oid", "Bob") + .with_conversation("19:thread@thread.v2", tenant_id="tenant-1") + .with_activity_id("act-xyz") + .build() + ) + ref = conv.conversation_reference + assert conv.claims["aud"] == "app-id" + assert conv.claims["appid"] == "req-id" + assert ref.user.id == "user-oid" + assert ref.conversation.id == "19:thread@thread.v2" + assert ref.activity_id == "act-xyz" + assert ref.agent.id == "28:app-id" diff --git a/tests/hosting_core/app/proactive/test_conversation_reference_builder.py b/tests/hosting_core/app/proactive/test_conversation_reference_builder.py new file mode 100644 index 00000000..20289cf3 --- /dev/null +++ b/tests/hosting_core/app/proactive/test_conversation_reference_builder.py @@ -0,0 +1,253 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest + +from microsoft_agents.hosting.core.app.proactive import ConversationReferenceBuilder +from microsoft_agents.hosting.core.app.proactive.conversation_reference_builder import ( + _service_url_for_channel, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _buildable(channel_id="msteams", conv_id="conv-1"): + """Return a builder that can call .build() without Pydantic errors. + + The implementation passes name explicitly to ChannelAccount, so an agent + with a name must always be present before calling .build(). + """ + return ( + ConversationReferenceBuilder.create(channel_id, conv_id) + .with_agent("28:app-id", "Bot") + ) + + +# --------------------------------------------------------------------------- +# _service_url_for_channel +# --------------------------------------------------------------------------- + + +class TestServiceUrlForChannel: + def test_teams_returns_smba_url(self): + assert _service_url_for_channel("msteams") == "https://smba.trafficmanager.net/teams/" + + def test_directline_returns_generic_url(self): + assert _service_url_for_channel("directline") == "https://directline.botframework.com/" + + def test_webchat_returns_generic_url(self): + assert _service_url_for_channel("webchat") == "https://webchat.botframework.com/" + + def test_unknown_channel_uses_pattern(self): + assert _service_url_for_channel("mychannel") == "https://mychannel.botframework.com/" + + +# --------------------------------------------------------------------------- +# create() +# --------------------------------------------------------------------------- + + +class TestConversationReferenceBuilderCreate: + def test_create_sets_channel_id(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-123") + assert builder._channel_id == "msteams" + + def test_create_sets_conversation_id(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-123") + assert builder._conversation_id == "conv-123" + + def test_create_returns_builder_instance(self): + result = ConversationReferenceBuilder.create("msteams", "conv-1") + assert isinstance(result, ConversationReferenceBuilder) + + +# --------------------------------------------------------------------------- +# create_for_agent() +# --------------------------------------------------------------------------- + + +class TestConversationReferenceBuilderCreateForAgent: + def test_create_for_agent_teams_prefixes_id(self): + builder = ConversationReferenceBuilder.create_for_agent("app-id", "msteams") + assert builder._agent_id == "28:app-id" + + def test_create_for_agent_non_teams_no_prefix(self): + builder = ConversationReferenceBuilder.create_for_agent("app-id", "directline") + assert builder._agent_id == "app-id" + + def test_create_for_agent_default_service_url_teams(self): + builder = ConversationReferenceBuilder.create_for_agent("app-id", "msteams") + assert builder._service_url == "https://smba.trafficmanager.net/teams/" + + def test_create_for_agent_custom_service_url(self): + builder = ConversationReferenceBuilder.create_for_agent( + "app-id", "msteams", service_url="https://custom.url/" + ) + assert builder._service_url == "https://custom.url/" + + def test_create_for_agent_returns_builder_instance(self): + result = ConversationReferenceBuilder.create_for_agent("app-id", "msteams") + assert isinstance(result, ConversationReferenceBuilder) + + +# --------------------------------------------------------------------------- +# Fluent setters +# --------------------------------------------------------------------------- + + +class TestConversationReferenceBuilderSetters: + def test_with_agent_sets_id_and_name(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_agent("agent-id", "My Agent") + assert builder._agent_id == "agent-id" + assert builder._agent_name == "My Agent" + + def test_with_agent_returns_self(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + result = builder.with_agent("agent-id", "Agent") + assert result is builder + + def test_with_agent_name_optional(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_agent("agent-id") + assert builder._agent_id == "agent-id" + assert builder._agent_name is None + + def test_with_user_sets_id_and_name(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_user("user-id", "Alice") + assert builder._user_id == "user-id" + assert builder._user_name == "Alice" + + def test_with_user_returns_self(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + result = builder.with_user("user-id", "Alice") + assert result is builder + + def test_with_service_url_sets_url(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_service_url("https://override/") + assert builder._service_url == "https://override/" + + def test_with_service_url_returns_self(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + result = builder.with_service_url("https://override/") + assert result is builder + + def test_with_activity_id_sets_id(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_activity_id("act-123") + assert builder._activity_id == "act-123" + + def test_with_activity_id_returns_self(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + result = builder.with_activity_id("act-123") + assert result is builder + + def test_with_locale_sets_locale(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_locale("en-US") + assert builder._locale == "en-US" + + def test_with_locale_returns_self(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + result = builder.with_locale("fr-FR") + assert result is builder + + +# --------------------------------------------------------------------------- +# build() +# --------------------------------------------------------------------------- + + +class TestConversationReferenceBuilderBuild: + def test_build_sets_channel_id(self): + ref = _buildable().build() + assert ref.channel_id == "msteams" + + def test_build_sets_conversation_id(self): + ref = _buildable(conv_id="conv-abc").build() + assert ref.conversation.id == "conv-abc" + + def test_build_default_service_url_teams(self): + ref = _buildable("msteams").build() + assert ref.service_url == "https://smba.trafficmanager.net/teams/" + + def test_build_default_service_url_generic(self): + ref = _buildable("directline").build() + assert ref.service_url == "https://directline.botframework.com/" + + def test_build_respects_explicit_service_url(self): + ref = ( + _buildable() + .with_service_url("https://custom/") + .build() + ) + assert ref.service_url == "https://custom/" + + def test_build_sets_agent_account(self): + ref = ( + ConversationReferenceBuilder.create("msteams", "conv-1") + .with_agent("28:app-id", "My Bot") + .build() + ) + assert ref.agent.id == "28:app-id" + assert ref.agent.name == "My Bot" + + def test_build_sets_user_account(self): + ref = ( + _buildable() + .with_user("user-oid", "Alice") + .build() + ) + assert ref.user.id == "user-oid" + assert ref.user.name == "Alice" + + def test_build_sets_activity_id(self): + ref = _buildable().with_activity_id("act-xyz").build() + assert ref.activity_id == "act-xyz" + + def test_build_sets_locale(self): + ref = _buildable().with_locale("en-GB").build() + assert ref.locale == "en-GB" + + def test_build_no_user_when_not_set(self): + ref = _buildable().build() + assert ref.user is None + + def test_build_requires_channel_id(self): + builder = ConversationReferenceBuilder() + with pytest.raises(ValueError): + builder.build() + + def test_build_teams_prefix_from_create_for_agent(self): + builder = ConversationReferenceBuilder.create_for_agent("app-id", "msteams") + # Provide name: implementation passes it explicitly to ChannelAccount (rejects None) + builder._agent_name = "Bot" + # Provide conversation_id: create_for_agent doesn't set it; ConversationAccount.id + # is NonEmptyString so the "" fallback in build() raises a Pydantic error + builder._conversation_id = "conv-1" + ref = builder.build() + assert ref.agent.id == "28:app-id" + + def test_fluent_chaining_all_methods(self): + ref = ( + ConversationReferenceBuilder.create("msteams", "conv-1") + .with_agent("28:app-id", "Bot") + .with_user("user-id", "Bob") + .with_locale("de-DE") + .with_activity_id("act-99") + .with_service_url("https://override.url/") + .build() + ) + assert ref.channel_id == "msteams" + assert ref.agent.id == "28:app-id" + assert ref.user.id == "user-id" + assert ref.locale == "de-DE" + assert ref.activity_id == "act-99" + assert ref.service_url == "https://override.url/" diff --git a/tests/hosting_core/app/proactive/test_create_conversation_options.py b/tests/hosting_core/app/proactive/test_create_conversation_options.py new file mode 100644 index 00000000..11e13219 --- /dev/null +++ b/tests/hosting_core/app/proactive/test_create_conversation_options.py @@ -0,0 +1,116 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest + +from microsoft_agents.activity import ConversationParameters +from microsoft_agents.hosting.core.app.proactive import CreateConversationOptions +from microsoft_agents.hosting.core.authorization import ClaimsIdentity + + +def _make_identity(): + return ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + + +def _make_params(): + return ConversationParameters() + + +class TestCreateConversationOptionsDefaults: + def test_default_identity_is_none(self): + opts = CreateConversationOptions() + assert opts.identity is None + + def test_default_channel_id_is_empty(self): + opts = CreateConversationOptions() + assert opts.channel_id == "" + + def test_default_parameters_is_none(self): + opts = CreateConversationOptions() + assert opts.parameters is None + + def test_default_service_url_is_none(self): + opts = CreateConversationOptions() + assert opts.service_url is None + + def test_default_audience_is_none(self): + opts = CreateConversationOptions() + assert opts.audience is None + + def test_default_store_conversation_is_false(self): + opts = CreateConversationOptions() + assert opts.store_conversation is False + + +class TestCreateConversationOptionsAssignment: + def test_identity_assigned(self): + identity = _make_identity() + opts = CreateConversationOptions(identity=identity) + assert opts.identity is identity + + def test_channel_id_assigned(self): + opts = CreateConversationOptions(channel_id="msteams") + assert opts.channel_id == "msteams" + + def test_parameters_assigned(self): + params = _make_params() + opts = CreateConversationOptions(parameters=params) + assert opts.parameters is params + + def test_service_url_assigned(self): + opts = CreateConversationOptions(service_url="https://custom/") + assert opts.service_url == "https://custom/" + + def test_audience_assigned(self): + opts = CreateConversationOptions(audience="https://api.botframework.com") + assert opts.audience == "https://api.botframework.com" + + def test_store_conversation_assigned(self): + opts = CreateConversationOptions(store_conversation=True) + assert opts.store_conversation is True + + +class TestCreateConversationOptionsValidate: + def test_validate_passes_with_required_fields(self): + opts = CreateConversationOptions( + identity=_make_identity(), + channel_id="msteams", + parameters=_make_params(), + ) + opts.validate() # must not raise + + def test_validate_optional_fields_not_required(self): + opts = CreateConversationOptions( + identity=_make_identity(), + channel_id="msteams", + parameters=_make_params(), + service_url=None, + audience=None, + ) + opts.validate() # must not raise + + def test_validate_raises_when_identity_missing(self): + opts = CreateConversationOptions(channel_id="msteams", parameters=_make_params()) + with pytest.raises(ValueError, match="identity"): + opts.validate() + + def test_validate_raises_when_channel_id_empty(self): + opts = CreateConversationOptions( + identity=_make_identity(), parameters=_make_params() + ) + with pytest.raises(ValueError, match="channel_id"): + opts.validate() + + def test_validate_raises_when_parameters_missing(self): + opts = CreateConversationOptions( + identity=_make_identity(), channel_id="msteams" + ) + with pytest.raises(ValueError, match="parameters"): + opts.validate() + + def test_validate_raises_when_all_missing(self): + opts = CreateConversationOptions() + with pytest.raises(ValueError): + opts.validate() diff --git a/tests/hosting_core/app/proactive/test_proactive.py b/tests/hosting_core/app/proactive/test_proactive.py new file mode 100644 index 00000000..40264a3e --- /dev/null +++ b/tests/hosting_core/app/proactive/test_proactive.py @@ -0,0 +1,585 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + ConversationParameters, + ConversationReference, + ResourceResponse, +) +from microsoft_agents.hosting.core import MemoryStorage +from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.hosting.core.app.proactive import ( + Conversation, + CreateConversationOptions, + Proactive, + ProactiveOptions, +) +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_reference( + conversation_id="conv-1", + service_url="https://smba.trafficmanager.net/teams/", + channel_id="msteams", +): + # bot and user must be present: get_continuation_activity() passes them directly + # to Activity(recipient=..., from_property=...) which rejects explicit None values. + return ConversationReference( + conversation=ConversationAccount(id=conversation_id), + service_url=service_url, + channel_id=channel_id, + bot=ChannelAccount(id="28:bot-id"), + user=ChannelAccount(id="user-id"), + ) + + +def _make_conversation(conversation_id="conv-1"): + return Conversation( + claims={"aud": "app-id", "tid": "tenant"}, + conversation_reference=_make_reference(conversation_id), + ) + + +def _make_app(storage=None): + app = MagicMock() + app.options.storage = storage + app._turn_state_factory = None + app._auth = None + return app + + +def _make_mock_state(): + state = MagicMock() + state.save = AsyncMock() + state.load = AsyncMock() + return state + + +# --------------------------------------------------------------------------- +# Storage property +# --------------------------------------------------------------------------- + + +class TestProactiveStorageProperty: + def test_storage_resolved_from_proactive_options(self): + storage = MemoryStorage() + app = _make_app(storage=None) + opts = ProactiveOptions(storage=storage) + proactive = Proactive(app, opts) + assert proactive._storage is storage + + def test_storage_resolved_from_app_options(self): + storage = MemoryStorage() + app = _make_app(storage=storage) + opts = ProactiveOptions() + proactive = Proactive(app, opts) + assert proactive._storage is storage + + def test_proactive_options_storage_takes_precedence(self): + storage_app = MemoryStorage() + storage_opts = MemoryStorage() + app = _make_app(storage=storage_app) + opts = ProactiveOptions(storage=storage_opts) + proactive = Proactive(app, opts) + assert proactive._storage is storage_opts + + def test_storage_raises_runtime_error_when_missing(self): + app = _make_app(storage=None) + opts = ProactiveOptions() + proactive = Proactive(app, opts) + with pytest.raises(RuntimeError): + _ = proactive._storage + + +class TestProactiveStorageKey: + def test_storage_key_format(self): + key = Proactive._storage_key("conv-123") + assert key == "proactive/conversations/conv-123" + + def test_storage_key_includes_conversation_id(self): + key = Proactive._storage_key("my-unique-id") + assert "my-unique-id" in key + + +# --------------------------------------------------------------------------- +# Store / Get / Delete conversations +# --------------------------------------------------------------------------- + + +class TestProactiveStoreConversation: + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def proactive(self, storage): + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + @pytest.mark.asyncio + async def test_store_then_get_returns_conversation(self, proactive): + conv = _make_conversation("store-get") + await proactive.store_conversation(conv) + result = await proactive.get_conversation("store-get") + assert result is not None + assert result.conversation_reference.conversation.id == "store-get" + + @pytest.mark.asyncio + async def test_store_preserves_claims(self, proactive): + conv = _make_conversation("claims-test") + await proactive.store_conversation(conv) + result = await proactive.get_conversation("claims-test") + assert result.claims.get("aud") == "app-id" + assert result.claims.get("tid") == "tenant" + + @pytest.mark.asyncio + async def test_store_overwrites_existing_conversation(self, proactive): + await proactive.store_conversation(_make_conversation("overwrite-me")) + new_conv = Conversation( + claims={"aud": "new-app-id"}, + conversation_reference=_make_reference("overwrite-me"), + ) + await proactive.store_conversation(new_conv) + result = await proactive.get_conversation("overwrite-me") + assert result.claims.get("aud") == "new-app-id" + + @pytest.mark.asyncio + async def test_store_from_turn_context(self, proactive): + ref = _make_reference("ctx-conv") + identity = ClaimsIdentity(claims={"aud": "ctx-app"}, is_authenticated=True) + + # spec=TurnContext is required: store_conversation checks isinstance(ctx, TurnContext) + ctx = MagicMock(spec=TurnContext) + ctx.activity = MagicMock() + ctx.activity.get_conversation_reference.return_value = ref + ctx.turn_state = {ChannelAdapter.AGENT_IDENTITY_KEY: identity} + + await proactive.store_conversation(ctx) + result = await proactive.get_conversation("ctx-conv") + assert result is not None + assert result.claims.get("aud") == "ctx-app" + + +class TestProactiveGetConversation: + @pytest.fixture + def proactive(self): + storage = MemoryStorage() + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + @pytest.mark.asyncio + async def test_get_returns_none_when_not_found(self, proactive): + result = await proactive.get_conversation("nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_get_returns_none_after_delete(self, proactive): + await proactive.store_conversation(_make_conversation("to-delete")) + await proactive.delete_conversation("to-delete") + result = await proactive.get_conversation("to-delete") + assert result is None + + +class TestProactiveDeleteConversation: + @pytest.fixture + def proactive(self): + storage = MemoryStorage() + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + @pytest.mark.asyncio + async def test_delete_removes_stored_conversation(self, proactive): + await proactive.store_conversation(_make_conversation("del-conv")) + await proactive.delete_conversation("del-conv") + assert await proactive.get_conversation("del-conv") is None + + @pytest.mark.asyncio + async def test_delete_nonexistent_does_not_raise(self, proactive): + await proactive.delete_conversation("not-stored") # must not raise + + @pytest.mark.asyncio + async def test_delete_only_removes_target(self, proactive): + await proactive.store_conversation(_make_conversation("keep-me")) + await proactive.store_conversation(_make_conversation("remove-me")) + await proactive.delete_conversation("remove-me") + assert await proactive.get_conversation("keep-me") is not None + assert await proactive.get_conversation("remove-me") is None + + +# --------------------------------------------------------------------------- +# Send activity +# --------------------------------------------------------------------------- + + +class TestProactiveSendActivity: + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def proactive(self, storage): + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + def _make_adapter(self, response_id="resp-1"): + adapter = MagicMock() + + async def fake_continue(claims, continuation, callback): + ctx = MagicMock() + ctx.send_activity = AsyncMock(return_value=ResourceResponse(id=response_id)) + await callback(ctx) + + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + return adapter + + @pytest.mark.asyncio + async def test_send_activity_with_conversation_object(self, proactive): + conv = _make_conversation("send-obj") + adapter = self._make_adapter() + activity = Activity(type=ActivityTypes.message, text="Hello!") + await proactive.send_activity(adapter, conv, activity) + adapter.continue_conversation_with_claims.assert_called_once() + + @pytest.mark.asyncio + async def test_send_activity_with_conversation_id(self, proactive): + conv = _make_conversation("send-id") + await proactive.store_conversation(conv) + adapter = self._make_adapter() + activity = Activity(type=ActivityTypes.message, text="Notify!") + await proactive.send_activity(adapter, "send-id", activity) + adapter.continue_conversation_with_claims.assert_called_once() + + @pytest.mark.asyncio + async def test_send_activity_raises_for_missing_conversation(self, proactive): + adapter = self._make_adapter() + activity = Activity(type=ActivityTypes.message, text="Hello") + with pytest.raises(KeyError): + await proactive.send_activity(adapter, "no-such-conv", activity) + + @pytest.mark.asyncio + async def test_send_activity_passes_activity_to_adapter(self, proactive): + conv = _make_conversation("pass-activity") + sent_activity = None + + async def fake_continue(claims, continuation, callback): + nonlocal sent_activity + ctx = MagicMock() + + async def capture_send(act): + nonlocal sent_activity + sent_activity = act + return ResourceResponse(id="r") + + ctx.send_activity = capture_send + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + activity = Activity(type=ActivityTypes.message, text="Specific text") + await proactive.send_activity(adapter, conv, activity) + assert sent_activity is activity + + @pytest.mark.asyncio + async def test_send_activity_propagates_exception_from_callback(self, proactive): + conv = _make_conversation("exc-conv") + + async def fake_continue(claims, continuation, callback): + ctx = MagicMock() + ctx.send_activity = AsyncMock(side_effect=RuntimeError("channel error")) + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + with pytest.raises(RuntimeError, match="channel error"): + await proactive.send_activity( + adapter, conv, Activity(type=ActivityTypes.message) + ) + + +# --------------------------------------------------------------------------- +# Continue conversation +# --------------------------------------------------------------------------- + + +class TestProactiveContinueConversation: + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def proactive(self, storage): + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + def _make_adapter(self, proactive_instance, state=None): + state = state or _make_mock_state() + + async def fake_continue(claims, continuation, callback): + ctx = MagicMock() + with patch.object(proactive_instance, "_load_state", AsyncMock(return_value=state)): + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + return adapter, state + + @pytest.mark.asyncio + async def test_continue_invokes_handler(self, proactive): + conv = _make_conversation("cont-invoke") + handler_called = False + + async def handler(ctx, state): + nonlocal handler_called + handler_called = True + + adapter, _ = self._make_adapter(proactive) + await proactive.continue_conversation(adapter, conv, handler) + assert handler_called + + @pytest.mark.asyncio + async def test_continue_with_conversation_id(self, proactive): + conv = _make_conversation("cont-by-id") + await proactive.store_conversation(conv) + + handler_called = False + + async def handler(ctx, state): + nonlocal handler_called + handler_called = True + + adapter, _ = self._make_adapter(proactive) + await proactive.continue_conversation(adapter, "cont-by-id", handler) + assert handler_called + + @pytest.mark.asyncio + async def test_continue_raises_key_error_for_missing(self, proactive): + async def handler(ctx, state): + pass + + adapter = MagicMock() + with pytest.raises(KeyError): + await proactive.continue_conversation(adapter, "not-in-storage", handler) + + @pytest.mark.asyncio + async def test_continue_uses_default_continuation_activity(self, proactive): + conv = _make_conversation("default-act") + captured_continuation = None + + async def fake_continue(claims, continuation, callback): + nonlocal captured_continuation + captured_continuation = continuation + state = _make_mock_state() + ctx = MagicMock() + with patch.object(proactive, "_load_state", AsyncMock(return_value=state)): + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + + async def handler(ctx, state): + pass + + await proactive.continue_conversation(adapter, conv, handler) + default_act = conv.conversation_reference.get_continuation_activity() + assert captured_continuation.type == default_act.type + + @pytest.mark.asyncio + async def test_continue_uses_custom_continuation_activity(self, proactive): + conv = _make_conversation("custom-act") + custom_activity = Activity(type=ActivityTypes.event, name="custom.event") + captured_continuation = None + + async def fake_continue(claims, continuation, callback): + nonlocal captured_continuation + captured_continuation = continuation + state = _make_mock_state() + ctx = MagicMock() + with patch.object(proactive, "_load_state", AsyncMock(return_value=state)): + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + + async def handler(ctx, state): + pass + + await proactive.continue_conversation( + adapter, conv, handler, continuation_activity=custom_activity + ) + assert captured_continuation is custom_activity + + @pytest.mark.asyncio + async def test_continue_calls_adapter_with_correct_claims(self, proactive): + conv = _make_conversation("claims-check") + captured_claims = None + + async def fake_continue(claims, continuation, callback): + nonlocal captured_claims + captured_claims = claims + state = _make_mock_state() + ctx = MagicMock() + with patch.object(proactive, "_load_state", AsyncMock(return_value=state)): + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + + async def handler(ctx, state): + pass + + await proactive.continue_conversation(adapter, conv, handler) + assert captured_claims is not None + assert captured_claims.claims.get("aud") == "app-id" + + @pytest.mark.asyncio + async def test_continue_propagates_exception_from_handler(self, proactive): + conv = _make_conversation("exc-handler") + + async def bad_handler(ctx, state): + raise ValueError("handler error") + + adapter, _ = self._make_adapter(proactive) + with pytest.raises(ValueError, match="handler error"): + await proactive.continue_conversation(adapter, conv, bad_handler) + + +# --------------------------------------------------------------------------- +# Create conversation +# --------------------------------------------------------------------------- + + +class TestProactiveCreateConversation: + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def proactive(self, storage): + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + @pytest.fixture + def identity(self): + return ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + + @pytest.fixture + def options(self, identity): + return CreateConversationOptions( + identity=identity, + channel_id="msteams", + parameters=ConversationParameters(), + service_url="https://smba.trafficmanager.net/teams/", + ) + + def _make_adapter(self, new_conversation_id="new-conv"): + ref = _make_reference(new_conversation_id) + + async def fake_create(app_id, channel_id, service_url, audience, params, callback): + ctx = MagicMock() + ctx.activity.get_conversation_reference.return_value = ref + await callback(ctx) + + adapter = MagicMock() + adapter.create_conversation = AsyncMock(side_effect=fake_create) + return adapter + + @pytest.mark.asyncio + async def test_create_returns_conversation(self, proactive, options): + adapter = self._make_adapter("new-conv-1") + result = await proactive.create_conversation(adapter, options) + assert result is not None + assert isinstance(result, Conversation) + + @pytest.mark.asyncio + async def test_create_sets_conversation_id(self, proactive, options): + adapter = self._make_adapter("created-id") + result = await proactive.create_conversation(adapter, options) + assert result.conversation_reference.conversation.id == "created-id" + + @pytest.mark.asyncio + async def test_create_sets_identity_on_result(self, proactive, options, identity): + adapter = self._make_adapter("id-check") + result = await proactive.create_conversation(adapter, options) + assert result.claims.get("aud") == "app-id" + + @pytest.mark.asyncio + async def test_create_calls_adapter_create_conversation(self, proactive, options): + adapter = self._make_adapter("adapter-called") + await proactive.create_conversation(adapter, options) + adapter.create_conversation.assert_called_once() + + @pytest.mark.asyncio + async def test_create_validates_options(self, proactive): + adapter = MagicMock() + with pytest.raises(ValueError): + await proactive.create_conversation(adapter, CreateConversationOptions()) + + @pytest.mark.asyncio + async def test_create_stores_conversation_when_flag_set(self, proactive, identity): + opts = CreateConversationOptions( + identity=identity, + channel_id="msteams", + parameters=ConversationParameters(), + service_url="https://smba.trafficmanager.net/teams/", + store_conversation=True, + ) + adapter = self._make_adapter("auto-stored") + await proactive.create_conversation(adapter, opts) + stored = await proactive.get_conversation("auto-stored") + assert stored is not None + + @pytest.mark.asyncio + async def test_create_does_not_store_by_default(self, proactive, options): + adapter = self._make_adapter("not-stored") + await proactive.create_conversation(adapter, options) + stored = await proactive.get_conversation("not-stored") + assert stored is None + + @pytest.mark.asyncio + async def test_create_invokes_handler(self, proactive, options): + handler_called = False + + async def handler(ctx, state): + nonlocal handler_called + handler_called = True + + state = _make_mock_state() + adapter = self._make_adapter("handler-conv") + + with patch.object(proactive, "_load_state", AsyncMock(return_value=state)): + await proactive.create_conversation(adapter, options, handler=handler) + + assert handler_called + + @pytest.mark.asyncio + async def test_create_without_handler_does_not_raise(self, proactive, options): + adapter = self._make_adapter("no-handler") + result = await proactive.create_conversation(adapter, options, handler=None) + assert result is not None + + @pytest.mark.asyncio + async def test_create_passes_channel_id_to_adapter(self, proactive, options): + captured_channel_id = None + + async def fake_create(app_id, channel_id, service_url, audience, params, callback): + nonlocal captured_channel_id + captured_channel_id = channel_id + ref = _make_reference("x") + ctx = MagicMock() + ctx.activity.get_conversation_reference.return_value = ref + await callback(ctx) + + adapter = MagicMock() + adapter.create_conversation = AsyncMock(side_effect=fake_create) + + await proactive.create_conversation(adapter, options) + assert captured_channel_id == "msteams" From cbf906d67ff2863076e994e161648143cd9f537c Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 13 Apr 2026 12:07:00 -0700 Subject: [PATCH 6/8] Addressing PR comments --- .../hosting/core/app/agent_application.py | 2 +- .../hosting/core/app/proactive/conversation.py | 4 ++++ .../hosting/core/app/proactive/conversation_builder.py | 6 ++++-- .../core/app/proactive/conversation_reference_builder.py | 8 ++++++-- .../core/app/proactive/create_conversation_options.py | 6 ++++-- .../hosting/core/app/proactive/proactive.py | 2 +- .../hosting/core/app/proactive/proactive_options.py | 2 +- test_samples/proactive/proactive_agent.py | 4 ++-- 8 files changed, 23 insertions(+), 11 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index efbfbb4a..fbfdf8a2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -150,7 +150,7 @@ def __init__( ) if options.proactive: - proactive_opts = options.proactive + proactive_opts = copy(options.proactive) if not proactive_opts.storage: proactive_opts.storage = self._options.storage self._proactive = Proactive(self, proactive_opts) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py index 2476ebe5..d75b3be1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py @@ -120,6 +120,10 @@ def validate(self) -> None: raise ValueError( "Conversation.conversation_reference.conversation is required." ) + if not self.conversation_reference.conversation.id: + raise ValueError( + "Conversation.conversation_reference.conversation.id is required." + ) if not self.conversation_reference.service_url: raise ValueError( "Conversation.conversation_reference.service_url is required." diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py index 87278b4e..6c800bed 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py @@ -201,12 +201,14 @@ def build(self) -> Conversation: """ Construct the :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. - :raises ValueError: If required fields (``channel_id``) are missing. + :raises ValueError: If required fields (``channel_id``, ``conversation_id``) are missing. :return: The built :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. :rtype: :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` """ if not self._channel_id: raise ValueError("ConversationBuilder: channel_id is required.") + if not self._conversation_id: + raise ValueError("ConversationBuilder: conversation_id is required.") agent = ( ChannelAccount(id=self._agent_id, name=self._agent_name) @@ -223,7 +225,7 @@ def build(self) -> Conversation: channel_id=self._channel_id, service_url=self._service_url or _service_url_for_channel(self._channel_id), conversation=ConversationAccount( - id=self._conversation_id or "", + id=self._conversation_id, name=self._conversation_name, tenant_id=self._tenant_id, ), diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py index c49f1919..05189da3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py @@ -203,12 +203,16 @@ def build(self) -> ConversationReference: """ Construct the :class:`~microsoft_agents.activity.ConversationReference`. - :raises ValueError: If ``channel_id`` has not been set. + :raises ValueError: If ``channel_id`` or ``conversation_id`` have not been set. :return: The built conversation reference. :rtype: :class:`~microsoft_agents.activity.ConversationReference` """ if not self._channel_id: raise ValueError("ConversationReferenceBuilder: channel_id is required.") + if not self._conversation_id: + raise ValueError( + "ConversationReferenceBuilder: conversation_id is required." + ) service_url = self._service_url or _service_url_for_channel(self._channel_id) @@ -225,7 +229,7 @@ def build(self) -> ConversationReference: return ConversationReference( channel_id=self._channel_id, - conversation=ConversationAccount(id=self._conversation_id or ""), + conversation=ConversationAccount(id=self._conversation_id), service_url=service_url, bot=agent, user=user, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py index 8051465c..f144c9e5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py @@ -48,8 +48,8 @@ def validate(self) -> None: """ Raise :exc:`ValueError` if required fields are missing. - :raises ValueError: If ``identity``, ``channel_id``, or ``parameters`` - are absent or ``service_url`` is missing and cannot be derived. + :raises ValueError: If ``identity``, ``channel_id``, ``parameters``, + or ``service_url`` are absent. """ if not self.identity: raise ValueError("CreateConversationOptions.identity is required.") @@ -57,3 +57,5 @@ def validate(self) -> None: raise ValueError("CreateConversationOptions.channel_id is required.") if not self.parameters: raise ValueError("CreateConversationOptions.parameters is required.") + if not self.service_url: + raise ValueError("CreateConversationOptions.service_url is required.") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py index 383af02a..68776d1b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py @@ -327,7 +327,7 @@ async def _callback(context: "TurnContext") -> None: await adapter.create_conversation( options.identity.get_app_id() or "", options.channel_id, - options.service_url or "", + options.service_url, audience, options.parameters, _callback, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py index d2e9c63f..2ff0c0b3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py @@ -5,7 +5,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Optional from microsoft_agents.hosting.core.storage import Storage diff --git a/test_samples/proactive/proactive_agent.py b/test_samples/proactive/proactive_agent.py index def6f94b..1459b819 100644 --- a/test_samples/proactive/proactive_agent.py +++ b/test_samples/proactive/proactive_agent.py @@ -121,7 +121,7 @@ async def on_members_added(context: TurnContext, _state: TurnState) -> None: "Commands:\n" " **-s** — store this conversation for later\n" " **-c** — proactively continue this conversation (requires sign-in)\n" - " **-c \** — proactively continue a stored conversation\n" + " **-c ** — proactively continue a stored conversation\n" " **-signin** — sign in with the 'me' OAuth connection\n" " **-signout** — sign out from the 'me' OAuth connection\n" " **/help** — show this message\n\n" @@ -135,7 +135,7 @@ async def on_help(context: TurnContext, _state: TurnState) -> None: "Commands:\n" " **-s** — store this conversation\n" " **-c** — proactively continue this conversation (requires sign-in)\n" - " **-c \** — proactively continue a stored conversation\n" + " **-c ** — proactively continue a stored conversation\n" " **-signin** — sign in with the 'me' OAuth connection\n" " **-signout** — sign out from the 'me' OAuth connection\n" " **/help** — show this message\n\n" From 831902c309b041ac1a6d60f73fc030df8fd1c4a0 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 13 Apr 2026 14:10:34 -0700 Subject: [PATCH 7/8] Fixing tests --- .../app/proactive/test_create_conversation_options.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/hosting_core/app/proactive/test_create_conversation_options.py b/tests/hosting_core/app/proactive/test_create_conversation_options.py index 11e13219..3b7d43e2 100644 --- a/tests/hosting_core/app/proactive/test_create_conversation_options.py +++ b/tests/hosting_core/app/proactive/test_create_conversation_options.py @@ -78,6 +78,7 @@ def test_validate_passes_with_required_fields(self): identity=_make_identity(), channel_id="msteams", parameters=_make_params(), + service_url="https://custom/", ) opts.validate() # must not raise @@ -86,7 +87,7 @@ def test_validate_optional_fields_not_required(self): identity=_make_identity(), channel_id="msteams", parameters=_make_params(), - service_url=None, + service_url="https://custom/", audience=None, ) opts.validate() # must not raise From 5813d9aa6a55cbcce56c9b378592827d5a297dcd Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 13 Apr 2026 14:22:16 -0700 Subject: [PATCH 8/8] More PR comments --- test_samples/proactive/README.md | 8 ++++---- test_samples/proactive/proactive_agent.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test_samples/proactive/README.md b/test_samples/proactive/README.md index 961832ab..76fc3771 100644 --- a/test_samples/proactive/README.md +++ b/test_samples/proactive/README.md @@ -100,12 +100,12 @@ curl -X POST http://localhost:3978/api/proactive/notify \ ### `-signin` — OAuth sign-in ```python -@AGENT_APP.message("-signin", auth_handlers=["me"]) +@AGENT_APP.message("-signin", auth_handlers=["ME"]) async def on_signin(context, state): await context.send_activity("Signed in.") ``` -The `auth_handlers=["me"]` parameter causes the SDK to start or resume the +The `auth_handlers=["ME"]` parameter causes the SDK to start or resume the OAuth flow before the handler runs. By the time the handler is invoked the user is fully signed in and the token is cached. @@ -120,7 +120,7 @@ async def on_signout(context, state): ### `-c` / `-c ` — Continue with sign-in guard -`token_handlers=["me"]` is passed to `continue_conversation`. Internally the +`token_handlers=["ME"]` is passed to `continue_conversation`. Internally the SDK calls `_start_or_continue_sign_in` for each listed handler before invoking the user's handler. If the user is not signed in and `ProactiveOptions.fail_on_unsigned_in_connections` is `True` (the default), a @@ -130,7 +130,7 @@ the user's handler. If the user is not signed in and try: await AGENT_APP.proactive.continue_conversation( ADAPTER, conversation_id, _on_continue, - token_handlers=["me"], + token_handlers=["ME"], ) except RuntimeError: await context.send_activity("Send **-signin** first.") diff --git a/test_samples/proactive/proactive_agent.py b/test_samples/proactive/proactive_agent.py index 1459b819..dbd1a04b 100644 --- a/test_samples/proactive/proactive_agent.py +++ b/test_samples/proactive/proactive_agent.py @@ -87,7 +87,7 @@ async def _on_continue(context: TurnContext, state: TurnState) -> None: """Proactive turn handler for -c. Requires the user to be signed in via the "me" OAuth connection — enforced - by passing token_handlers=["me"] to continue_conversation. If sign-in is + by passing token_handlers=["ME"] to continue_conversation. If sign-in is complete, retrieve the token and report its length (mirrors C# sample). """ token_response = await AGENT_APP.auth.get_token(context, auth_handler_id="ME") @@ -160,7 +160,7 @@ async def on_store(context: TurnContext, _state: TurnState) -> None: async def on_signin(context: TurnContext, _state: TurnState) -> None: """Trigger the OAuth sign-in flow for the 'me' connection. - The auth_handlers=["me"] parameter causes the SDK to start or resume the + The auth_handlers=["ME"] parameter causes the SDK to start or resume the OAuth flow before this handler runs. By the time we reach here the user is signed in. """ @@ -181,7 +181,7 @@ async def on_continue(context: TurnContext, _state: TurnState) -> None: With no argument, continues THIS conversation (no prior -s needed). With an argument, continues the stored conversation with that ID. - Passes token_handlers=["me"] so the proactive turn will fail with a + Passes token_handlers=["ME"] so the proactive turn will fail with a RuntimeError if the user is not yet signed in — mirrors the C# sample's UserNotSignedIn exception handling. """