From ef8b33ab0a7044c698a5aea0a1e93f829f3dcc82 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 10:58:59 -0700 Subject: [PATCH 01/13] Refactoring existing selector logic and allowing specification of channel id for various route hooks --- .../hosting/core/app/_selector.py | 147 ++++++++++++ .../hosting/core/app/agent_application.py | 144 ++++++------ .../hosting/msteams/config/config.py | 8 +- .../hosting/msteams/message/message.py | 8 +- .../message_extension/message_extension.py | 37 +--- .../hosting/msteams/teams_agent_extension.py | 32 ++- test_samples/README.md | 209 ++++++++++-------- .../app/test_agent_application_routes.py | 149 ++++++++++++- tests/hosting_core/app/test_selector.py | 153 +++++++++++++ 9 files changed, 676 insertions(+), 211 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py create mode 100644 tests/hosting_core/app/test_selector.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py new file mode 100644 index 000000000..ad75e5c83 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py @@ -0,0 +1,147 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re + + +from microsoft_agents.activity import ActivityTypes, ChannelId +from microsoft_agents.hosting.core import TurnContext +from ._type_defs import RouteSelector + + +def _text_selector( + select: str | re.Pattern[str] | list[str | re.Pattern[str]], +) -> RouteSelector: + """Checks if the given text matches the selection criteria. + + :param select: A string, regex pattern, or a list of strings/regex patterns to match against. + :return: True if the text matches any of the selection criteria, False otherwise. + """ + + def __selector(context: TurnContext) -> bool: + """Selector function that checks if the given text matches the selection criteria.""" + text = context.activity.text or "" + if isinstance(select, list): + for item in select: + if isinstance(item, re.Pattern): + if re.fullmatch(item, text) is not None: + return True + elif text == item: + return True + return False + + if isinstance(select, re.Pattern): + return re.fullmatch(select, text) is not None + + return text == select + + return __selector + + +def _invoke_event_selector(event_name: str) -> RouteSelector: + """Creates a route selector that matches the given invoke event name. + + :param event_name: The name of the invoke event to match against. + :return: A route selector function that matches the specified invoke event name. + """ + + def __selector(context: TurnContext) -> bool: + """Selector function that checks if the given invoke event name matches the selection criteria.""" + if context.activity.name is None: + return False + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == event_name + ) + + return __selector + + +def _activity_type_selector(activity_type: str | list[str]) -> RouteSelector: + """Creates a route selector that matches the given activity type. + + :param activity_type: The activity type or list of activity types to match against. + :return: A route selector function that matches the specified activity type. + """ + if isinstance(activity_type, list): + return lambda context: context.activity.type in activity_type + return lambda context: context.activity.type == activity_type + + +def _channel_selector(channel_id: str | ChannelId | None) -> RouteSelector: + """Creates a route selector that matches the given channel ID. + + :param channel_id: The channel ID to match against. + :return: A route selector function that matches the specified channel ID. + """ + + norm_channel_id = ChannelId(channel_id) if channel_id is not None else None + + if norm_channel_id is None: + return lambda context: True + else: + + def __selector(context: TurnContext) -> bool: + """Selector function that checks if the given channel ID matches the selection criteria.""" + if context.activity.channel_id is None: + return False + if norm_channel_id.sub_channel: + # if passed in a sub-channel, the incoming activity must also come from + # the sub-channel + return context.activity.channel_id == norm_channel_id + else: + # if passed in only a parent channel, only check the parent channel of + # the incoming activity + return ( + ChannelId.get_channel(context.activity.channel_id) + == norm_channel_id.channel + ) + + return __selector + + +def _all_selector(*selectors: RouteSelector) -> RouteSelector: + """Combines multiple route selectors into one that only passes if all selectors pass. + + :param selectors: A variable number of route selector functions to combine. + :return: A new route selector function that only passes if all provided selectors pass. + """ + + def selector(context: TurnContext) -> bool: + return all(sel(context) for sel in selectors) + + return selector + + +class _SimpleRouteSelector: + """A simple route selector that combines multiple selection criteria such as channel ID, activity type, and text pattern.""" + + def __init__( + self, + *, + channel_id: str | ChannelId | None = None, + activity_type: str | list[str] | None = None, + text_pattern: str | re.Pattern[str] | list[str | re.Pattern[str]] | None = None, + ) -> None: + """Initializes a simple route selector with optional channel ID, activity type, and text pattern. + + :param channel_id: The channel ID to match against, or None to match any channel. + :param activity_type: The activity type to match against, or None to match any activity type. + :param text_pattern: A text pattern or list of patterns to match against the activity text, or None to match any text. + """ + + self._selectors = [] + if channel_id is not None: + self._selectors.append(_channel_selector(channel_id)) + if activity_type is not None: + self._selectors.append(_activity_type_selector(activity_type)) + if text_pattern is not None: + self._selectors.append(_text_selector(text_pattern)) + + def __call__(self, context: TurnContext) -> bool: + """Calls the route selector with the given context. + + :param context: The turn context to evaluate. + :return: True if all selectors pass for the given context, False otherwise. + """ + return all(selector(context) for selector in self._selectors) 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 248caba1a..e5b6a4b2d 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 @@ -23,12 +23,12 @@ TypeVar, cast, overload, - Optional, ) from microsoft_agents.activity import ( Activity, ActivityTypes, + ChannelId, ConversationUpdateTypes, MessageReactionTypes, MessageUpdateTypes, @@ -58,6 +58,12 @@ from ._routes import _RouteList, _Route, RouteRank, _agentic_selector from .proactive import Proactive from .adaptive_card import AdaptiveCard +from ._selector import ( + _SimpleRouteSelector, + _invoke_event_selector, + _channel_selector, + _all_selector, +) logger = logging.getLogger(__name__) @@ -103,11 +109,11 @@ def __init__( Creates a new AgentApplication instance. :param options: Configuration options for the application. - :type options: Optional[:class:`microsoft_agents.hosting.core.app.app_options.ApplicationOptions`] + :type options: :class:`microsoft_agents.hosting.core.app.app_options.ApplicationOptions` | None :param connection_manager: OAuth connection manager. - :type connection_manager: Optional[:class:`microsoft_agents.hosting.core.authorization.Connections`] + :type connection_manager: :class:`microsoft_agents.hosting.core.authorization.Connections` | None :param authorization: Authorization manager for handling authentication flows. - :type authorization: Optional[:class:`microsoft_agents.hosting.core.app.oauth.Authorization`] + :type authorization: :class:`microsoft_agents.hosting.core.app.oauth.Authorization` | None :param kwargs: Additional configuration parameters. :type kwargs: Any """ @@ -346,7 +352,7 @@ def add_route( is_invoke: bool = False, is_agentic: bool = False, rank: RouteRank = RouteRank.DEFAULT, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, ) -> None: """Adds a new route to the application. @@ -364,7 +370,7 @@ def add_route( :param rank: The rank of the route, defaults to RouteRank.DEFAULT :type rank: :class:`microsoft_agents.hosting.core.app._routes.route_rank.RouteRank`, Optional :param auth_handlers: A list of authentication handler IDs to use for this route, defaults to None - :type auth_handlers: Optional[list[str]], Optional + :type auth_handlers: list[str] | None, Optional :raises ApplicationError: If the selector or handler are not valid. """ if not selector or not handler: @@ -385,7 +391,8 @@ def activity( self, activity_type: str | ActivityTypes | list[str | ActivityTypes], *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -402,20 +409,19 @@ async def on_event(context: TurnContext, state: TurnState): :param activity_type: Activity type or collection of types that should trigger the handler. :type activity_type: str | microsoft_agents.activity.ActivityTypes | list[str | microsoft_agents.activity.ActivityTypes] :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - def __selector(context: TurnContext): - if isinstance(activity_type, list): - return context.activity.type in activity_type - return activity_type == context.activity.type + selector = _SimpleRouteSelector( + activity_type=activity_type, channel_id=channel_id + ) def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering activity handler for route handler {func.__name__} with type: {activity_type} with auth handlers: {auth_handlers}" ) - self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) + self.add_route(selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call @@ -424,7 +430,8 @@ def message( self, select: str | re.Pattern[str] | list[str | re.Pattern[str]], *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -440,36 +447,23 @@ async def on_hi_message(context: TurnContext, state: TurnState): :param select: Literal text, compiled regex, or list of either used to match the incoming message. :type select: str | re.Pattern[str] | list[str | re.Pattern[str]] + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - - def __selector(context: TurnContext): - if context.activity.type != ActivityTypes.message: - return False - - text = context.activity.text if context.activity.text else "" - - if isinstance(select, list): - for item in select: - if isinstance(item, re.Pattern): - if re.fullmatch(item, text) is not None: - return True - elif text == item: - return True - return False - - if isinstance(select, re.Pattern): - return re.fullmatch(select, text) is not None - - return text == select + selector = _SimpleRouteSelector( + channel_id=channel_id, + activity_type=ActivityTypes.message, + text_pattern=select, + ) def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" ) - self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) + self.add_route(selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call @@ -478,7 +472,8 @@ def conversation_update( self, type: ConversationUpdateTypes | str, *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -494,15 +489,22 @@ async def on_channel_created(context: TurnContext, state: TurnState): :param type: Conversation update category that must match the incoming activity. :type type: microsoft_agents.activity.ConversationUpdateTypes | str + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ update_type = type.value if isinstance(type, ConversationUpdateTypes) else type + _simple_selector = _SimpleRouteSelector( + activity_type=ActivityTypes.conversation_update, + channel_id=channel_id, + ) + def __selector(context: TurnContext): - if context.activity.type != ActivityTypes.conversation_update: + if not _simple_selector(context): return False if update_type == "membersAdded": @@ -534,7 +536,8 @@ def message_reaction( self, type: MessageReactionTypes | str, *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -550,15 +553,22 @@ async def on_reactions_added(context: TurnContext, state: TurnState): :param type: Reaction category that must match the incoming activity. :type type: microsoft_agents.activity.MessageReactionTypes | str + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ reaction_type = type.value if isinstance(type, MessageReactionTypes) else type + _simple_selector = _SimpleRouteSelector( + channel_id=channel_id, + activity_type=ActivityTypes.message_reaction, + ) + def __selector(context: TurnContext): - if context.activity.type != ActivityTypes.message_reaction: + if not _simple_selector(context): return False if reaction_type == "reactionsAdded": @@ -586,7 +596,8 @@ def message_update( self, type: MessageUpdateTypes | str, *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -602,14 +613,21 @@ async def on_edit_message(context: TurnContext, state: TurnState): :param type: Message update category that must match the incoming activity. :type type: microsoft_agents.activity.MessageUpdateTypes | str + :param channel_id: Optional channel ID to filter the incoming activities. + :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ update_type = type.value if isinstance(type, MessageUpdateTypes) else type + check_channel = _channel_selector(channel_id) + def __selector(context: TurnContext): + if not check_channel(context): + return False + if update_type == "editMessage": if ( context.activity.type == ActivityTypes.message_update @@ -652,7 +670,7 @@ def handoff( self, func: HandoffHandler[StateT], *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> HandoffHandler[StateT]: ... @@ -660,15 +678,16 @@ def handoff( def handoff( self, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs: Any, ) -> _RouteDecorator[HandoffHandler[StateT]]: ... def handoff( self, - func: Optional[HandoffHandler[StateT]] = None, + func: HandoffHandler[StateT] | None = None, *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> HandoffHandler[StateT] | _RouteDecorator[HandoffHandler[StateT]]: """ @@ -682,17 +701,18 @@ async def on_handoff(context: TurnContext, state: TurnState, continuation: str): print(continuation) :param func: Optional handler to register directly without using decorator syntax. - :type func: Optional[HandoffHandler[StateT]] + :type func: HandoffHandler[StateT] | None + :param channel_id: Optional channel ID to filter the incoming activities. + :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "handoff/action" - ) + selector = _all_selector( + _invoke_event_selector("handoff/action"), + _channel_selector(channel_id), + ) def __call( func: HandoffHandler[StateT], @@ -716,7 +736,7 @@ async def __handler(context: TurnContext, state: StateT): f"Registering handoff handler for route handler {func.__name__} with auth handlers: {auth_handlers}" ) - self.add_route(__selector, __handler, auth_handlers=auth_handlers, **kwargs) + self.add_route(selector, __handler, auth_handlers=auth_handlers, **kwargs) return func if func is not None: @@ -724,8 +744,8 @@ async def __handler(context: TurnContext, state: StateT): return __call def on_sign_in_success( - self, func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] - ) -> Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]]: + self, func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] + ) -> Callable[[TurnContext, StateT, str | None], Awaitable[None]]: """ Register a callback that executes when a user successfully signs in. @@ -737,7 +757,7 @@ async def sign_in_success(context: TurnContext, state: TurnState, connection_id: print("sign-in succeeded") :param func: Callable that handles the sign-in success event. - :type func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] + :type func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] :raises ApplicationError: If authorization services are not configured. """ @@ -758,8 +778,8 @@ async def sign_in_success(context: TurnContext, state: TurnState, connection_id: return func def on_sign_in_failure( - self, func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] - ) -> Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]]: + self, func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] + ) -> Callable[[TurnContext, StateT, str | None], Awaitable[None]]: """ Register a callback that executes when a user fails to sign in. @@ -771,7 +791,7 @@ async def sign_in_failure(context: TurnContext, state: TurnState, connection_id: print("sign-in failed") :param func: Callable that handles the sign-in failure event. - :type func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] + :type func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] :raises ApplicationError: If authorization services are not configured. """ diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py index 8f55e9f19..b840f7ebf 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py @@ -5,7 +5,6 @@ from typing import Generic, Optional, overload -from microsoft_agents.activity import ActivityTypes from microsoft_agents.hosting.core import ( AgentApplication, RouteRank, @@ -15,6 +14,7 @@ StateT, _RouteDecorator, ) +from microsoft_agents.hosting.core.app._selector import _invoke_event_selector from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.msteams._utils import _send_invoke_response @@ -50,11 +50,7 @@ def _create_decorator( :return: A decorator that registers the handler. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == name - ) + __selector = _invoke_event_selector(name) def __call(func: ConfigHandler[StateT]) -> ConfigHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py index d807b1918..1c37412a5 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py @@ -17,6 +17,7 @@ StateT, _RouteDecorator, ) +from microsoft_agents.hosting.core.app._selector import _invoke_event_selector from microsoft_agents.hosting.msteams.route_handlers import ( TeamsRouteHandler, @@ -261,12 +262,7 @@ def execute_action( :return: The registered handler, or a decorator when used without a handler. """ - def __selector(context: TurnContext) -> bool: - """Return True when the activity is an actionable message execute invoke.""" - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "actionableMessage/executeAction" - ) + __selector = _invoke_event_selector("actionableMessage/executeAction") def __call(func: ExecuteActionHandler[StateT]) -> ExecuteActionHandler[StateT]: """Register the supplied execute action handler.""" diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py index 102fded13..1c75c305a 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py @@ -22,6 +22,7 @@ StateT, _RouteDecorator, ) +from microsoft_agents.hosting.core.app._selector import _invoke_event_selector from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext @@ -151,11 +152,7 @@ def select_item( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/selectItem" - ) + __selector = _invoke_event_selector("composeExtension/selectItem") def __call(func: SelectItemHandler[StateT]) -> SelectItemHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: @@ -400,11 +397,7 @@ def query_link( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/queryLink" - ) + __selector = _invoke_event_selector("composeExtension/queryLink") def __call(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: @@ -452,11 +445,7 @@ def anonymous_query_link( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/anonymousQueryLink" - ) + __selector = _invoke_event_selector("composeExtension/anonymousQueryLink") def __call(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: @@ -506,11 +495,7 @@ def query_setting_url( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/querySettingUrl" - ) + __selector = _invoke_event_selector("composeExtension/querySettingUrl") def __call( func: QueryUrlSettingHandler[StateT], @@ -560,11 +545,7 @@ def setting( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/setting" - ) + __selector = _invoke_event_selector("composeExtension/setting") def __call(func: SettingHandler[StateT]) -> SettingHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: @@ -616,11 +597,7 @@ def card_button_clicked( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/onCardButtonClicked" - ) + __selector = _invoke_event_selector("composeExtension/onCardButtonClicked") def __call( func: CardButtonClickedHandler[StateT], diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 8d48690ce..559fc9ed1 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -214,7 +214,12 @@ def activity( :return: A decorator that registers the handler and returns it. """ return self._wrap_decorator( - self._app.activity(activity_type, auth_handlers=auth_handlers, **kwargs) + self._app.activity( + activity_type, + auth_handlers=auth_handlers, + channel_id=Channels.msteams, + **kwargs, + ) ) def message( @@ -232,7 +237,12 @@ def message( :return: A decorator that registers the handler and returns it. """ return self._wrap_decorator( - self._app.message(select, auth_handlers=auth_handlers, **kwargs) + self._app.message( + select, + auth_handlers=auth_handlers, + channel_id=Channels.msteams, + **kwargs, + ) ) def conversation_update( @@ -249,7 +259,9 @@ def conversation_update( :return: A decorator that registers the handler and returns it. """ return self._wrap_decorator( - self._app.conversation_update(type, auth_handlers=auth_handlers, **kwargs) + self._app.conversation_update( + type, auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + ) ) def message_reaction( @@ -266,7 +278,9 @@ def message_reaction( :return: A decorator that registers the handler and returns it. """ return self._wrap_decorator( - self._app.message_reaction(type, auth_handlers=auth_handlers, **kwargs) + self._app.message_reaction( + type, auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + ) ) def message_update( @@ -283,7 +297,9 @@ def message_update( :return: A decorator that registers the handler and returns it. """ return self._wrap_decorator( - self._app.message_update(type, auth_handlers=auth_handlers, **kwargs) + self._app.message_update( + type, auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + ) ) def handoff( @@ -299,9 +315,9 @@ def handoff( """ def __call(func: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: - return self._app.handoff(auth_handlers=auth_handlers, **kwargs)( - wrap_teams_handoff_handler(func, self._app) - ) + return self._app.handoff( + auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + )(wrap_teams_handoff_handler(func, self._app)) return __call diff --git a/test_samples/README.md b/test_samples/README.md index fd035498a..1b04e49ec 100644 --- a/test_samples/README.md +++ b/test_samples/README.md @@ -1,97 +1,112 @@ -# Setting Up Virtual Environment and installing the SDK - -This guide explains how to create and activate a Python virtual environment using `venv` for Python versions 3.10 to 3.14. - -## What is a Virtual Environment? - -A virtual environment is an isolated Python environment that allows you to install packages for a specific project without affecting your system's global Python installation. This helps avoid package conflicts between different projects. - -## Prerequisites - -- Python 3.10, 3.11, 3.12, 3.13, or 3.14 installed on your system -- Basic knowledge of command line operations - -## Creating a Virtual Environment - -### On Linux/macOS - -1. Open a terminal window -2. Navigate to your project directory: - ```bash - cd the-root-of-this-project - ``` -3. Create a virtual environment: - ```bash - python3 -m venv venv - ``` - -### On Windows - -1. Open Command Prompt or PowerShell -2. Navigate to your project directory: - ``` - cd the-root-of-this-project - ``` -3. Create a virtual environment: - ``` - python -m venv venv - ``` - -## Activating the Virtual Environment - -### On Linux/macOS - -```bash -source venv/bin/activate -``` - -### On Windows - -#### Command Prompt -``` -venv\Scripts\activate.bat -``` - -#### PowerShell -``` -venv\Scripts\Activate.ps1 -``` - -Once activated, you'll notice your command prompt changes to show the name of the activated environment. For example: -``` -(venv) $ -``` - -## Deactivating the Virtual Environment - -When you're done working in the virtual environment, you can deactivate it by running: - -```bash -deactivate -``` - -## Verifying Python Version - -To verify the Python version in your virtual environment: - -```bash -python --version -``` - -Make sure it shows a version of at least 3.10. - -## Installing Test Packages - -After activating your virtual environment, you can install packages from pypi: - -```bash -pip install microsoft-agents-activity -pip install microsoft-agents-authorization -pip install microsoft-agents-connector -pip install microsoft-agents-client -pip install microsoft-agents-hosting-core -pip install microsoft-agents-authentication-msal -pip install microsoft-agents-copilotstudio-client -pip install microsoft-agents-hosting-core-aiohttp -pip install microsoft-agents-storage-core -``` \ No newline at end of file +# Python SDK test samples + +This directory contains runnable examples, compatibility ports, and tests for +the Microsoft Agents SDK for Python. Start with the linked sample guide when +one is available; it has the most specific setup and configuration details. +Unless stated otherwise, run commands from the sample directory. + +## Common setup + +- Python samples generally require Python 3.10 or later. Check the sample + guide for any additional version or service requirements. +- From the repository root, create and activate a virtual environment, then + install the repository's SDK libraries: + + ```powershell + python -m venv .venv + .\.venv\Scripts\Activate.ps1 + .\scripts\dev_setup.ps1 + ``` + + On Linux or macOS, activate with `source .venv/bin/activate` and run + `./scripts/dev_setup.sh`. +- When a sample has a `requirements.txt`, install it from the repository root, + for example: + `python -m pip install -r test_samples/proactive/requirements.txt`. + For samples with a `pyproject.toml`, follow that sample's `uv sync` + instructions instead. +- Where an `env.TEMPLATE` is provided, copy it to `.env` and fill in the + sample's required settings. Do not commit credentials. Most bot-hosted + samples need an Azure Bot/Entra app registration; Teams invoke features may + also need Teams and a public development tunnel. OAuth, Graph, and cloud + identity examples have additional requirements noted below or in their + guides. + +The distribution names in this repository's `libraries/` projects are +`microsoft-agents-activity`, +`microsoft-agents-authentication-entra-auth-sidecar`, +`microsoft-agents-authentication-msal`, +`microsoft-agents-copilotstudio-client`, +`microsoft-agents-hosting-aiohttp`, +`microsoft-agents-hosting-core`, +`microsoft-agents-hosting-dialogs`, +`microsoft-agents-hosting-fastapi`, +`microsoft-agents-hosting-msteams`, +`microsoft-agents-hosting-teams`, +`microsoft-agents-storage-blob`, +`microsoft-agents-storage-cosmos`, and +`microsoft-agents-testing`. Use a sample's requirements or project setup to +select the needed packages; not every sample needs every library. + +## Agent and channel samples + +| Sample | Purpose and additional prerequisites | Run / details | +| --- | --- | --- | +| [`app_style/`](app_style/README.md) | App-style aiohttp agents: empty, authorization, Copilot Studio, proactive echo, and streaming examples. Configure the credentials/connections needed by the selected agent. | From `test_samples/app_style`: `python empty_agent.py`, `python authorization_agent.py`, `python mcs_agent.py`, `python echo_proactive_agent.py`, or `python streaming_agent.py`. | +| [`fastapi/`](fastapi/README.md) | FastAPI versions of the empty and authorization agents; the authorization example uses Microsoft Graph and GitHub. | From `test_samples/fastapi`: `python empty_agent.py` or `python authorization_agent.py`. | +| [`cards/`](cards/README.md) | Adaptive Cards, submit/execute actions, dynamic search, and media cards. `Action.Execute` and dynamic search require Teams. | From `test_samples/cards`: `python agent.py`. | +| [`handling_attachments/`](handling_attachments/README.md) | Sends and receives inline, URL, and uploaded attachments; uploaded attachments require Teams. | From `test_samples/handling_attachments`: `python -m src.main`. | +| [`proactive/`](proactive/README.md) | Proactive conversation continuation, stored references, OAuth, and an external notification endpoint. Requires an Azure Bot OAuth connection and a tunnel or Teams/Agents Playground for interaction. | From the repository root: `python test_samples/proactive/proactive_agent.py`. | +| [`bizchat-sso/`](bizchat-sso/) | Echo agent with sign-in and sign-out handlers. Configure the Azure Bot OAuth connection named for the sample's SSO handler. | From `test_samples/bizchat-sso`: `python src/main.py`; install `requirements.txt` first. | +| [`agentic-test/`](agentic-test/) | Acquires an agentic user token and displays token claims in an Adaptive Card. Requires the configured `AGENTIC` authentication connection. | From `test_samples/agentic-test`: `python src/main.py`; install `requirements.txt` first. | +| [`copilot_studio_client_sample/`](copilot_studio_client_sample/app.py) | Console client for a Copilot Studio agent using MSAL interactive sign-in and the Copilot Studio client library. Requires a configured Entra app and Copilot Studio connection. | `python app.py`. No sample README or `requirements.txt` is present; install its SDK and MSAL dependencies in the environment. | +| [`copilot_studio_connector/`](copilot_studio_connector/README.md) | Receives Copilot Studio connector requests and uses OAuth/OBO to call Graph. Requires an Azure Bot app, Copilot Studio connector, and Graph OAuth permissions. | From `test_samples/copilot_studio_connector`: `pip install -r requirements.txt`, then `python app.py`. | +| [`compat/weather-agent-open-ai/`](compat/weather-agent-open-ai/app.py) | Compatibility weather agent backed by Azure OpenAI. Requires Azure OpenAI configuration as well as bot credentials. | From `test_samples/compat/weather-agent-open-ai`: install `requirements.txt`, then `python app.py`. | +| [`extensions/extension-starter/`](extensions/extension-starter/README.md) | Minimal example of extending the SDK; the guide recommends Microsoft 365 Agents Playground for interaction. | From `test_samples/extensions/extension-starter`: `python -m src.sample.main`. | +| [`tethered-agent/`](tethered-agent/src/main.py) | Echo agent using `tethered` to limit outbound network access to the configured login and Bot Framework hosts. Requires the normal agent configuration and the `tethered` dependency. | From `test_samples/tethered-agent`: `python -m src.main`. The directory's README is empty; the entry point and purpose are identified from the source. | + +## Teams samples + +| Sample | Purpose and additional prerequisites | Run / details | +| --- | --- | --- | +| [`hosting_msteams/conversation-agent/`](hosting_msteams/conversation-agent/README.md) | Teams conversation lifecycle events, member lookups, mentions, and proactive messages. Requires a Teams app and bot registration. | From the sample directory: `python -m src.main`. | +| [`hosting_msteams/graph-clients/`](hosting_msteams/graph-clients/README.md) | Teams commands using app-only and delegated Microsoft Graph clients; requires Graph application/delegated permissions and consent. | From the sample directory: `python -m src.main`. | +| [`hosting_msteams/message-extensions/`](hosting_msteams/message-extensions/README.md) | Teams message-extension search, actions, link previews, and settings. Requires a Teams app and a reachable tunnel for settings. | From the sample directory: `python -m src.main`. | +| [`hosting_msteams/task-modules/`](hosting_msteams/task-modules/README.md) | Teams task modules with card forms, a webpage dialog, and multi-step submits. Requires a Teams app and a reachable tunnel for the webpage dialog. | From the sample directory: `python -m src.main`. | +| [`teams_extension/`](teams_extension/README.md) | Three `TeamsAgentExtension` examples: message extensions, task modules, and meeting events. Requires Teams; install `microsoft-teams-api` as described in the guide. | From `test_samples/teams_extension`, run `python message_extensions_agent.py`, `python task_modules_agent.py`, or `python meeting_events_agent.py`. | + +## Authentication and cloud identity + +| Sample | Purpose and additional prerequisites | Run / details | +| --- | --- | --- | +| [`entra_sidecar/`](entra_sidecar/README.md) | Credential-free outbound token acquisition through the Entra ID Agent Container sidecar. Requires the sidecar and its downstream API configuration. | Follow the guide to install requirements and run `python agent.py` from `test_samples/entra_sidecar`. | +| [`fic/`](fic/FIC_README.md) | Federated identity credentials for an Azure App Service agent using a user-assigned managed identity. Requires Azure CLI, an Azure subscription, and an Entra app registration. | From the repository root, provision with `.\test_samples\fic\setup.ps1 -ResourceGroup -AppName `, then deploy with `.\test_samples\fic\deploy.ps1`. | +| [`workload_identity/`](workload_identity/README.md) | End-to-end AKS Workload Identity deployment and test. Requires Azure CLI, `kubectl`, an Azure subscription, and an AKS cluster or permission to create one. | Follow the guide's setup and deployment steps; run `.\test_samples\workload_identity\test.ps1` for the in-cluster test. | +| [`mi/`](mi/) | Echo-agent sample with Azure resource templates under `infra/`. Requires Azure identity/bot configuration and the resources described by those templates. | From `test_samples/mi`: install `requirements.txt`, then run `python src/main.py`. | + +## Compatibility samples + +| Sample | Purpose | Run / details | +| --- | --- | --- | +| [`compat/dialogs/complex_dialog/`](compat/dialogs/complex_dialog/README.md) | BotBuilder complex-dialog port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/dialogs/custom_dialogs/`](compat/dialogs/custom_dialogs/README.md) | BotBuilder custom-dialogs port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/dialogs/multi_turn/`](compat/dialogs/multi_turn/README.md) | BotBuilder multi-turn-prompt port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/dialogs/oauth_prompt/`](compat/dialogs/oauth_prompt/README.md) | OAuth prompt sample. Requires an Azure Bot OAuth connection. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/dialogs/user_auth/`](compat/dialogs/user_auth/README.md) | User-authentication sample that accesses Graph; requires the corresponding OAuth connection and permissions. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/teams_agent/`](compat/teams_agent/app.py) | Legacy Teams handler examples for SSO and other Teams features. | Purpose is identifiable from the source, but a runnable command could not be verified: its imports reference `test_samples.activity_handler`, which is not present here. | +| [`compat/agent_to_agent/`](compat/agent_to_agent/) | Two-agent channel/agent communication example. | The source imports also reference `test_samples.activity_handler`; a runnable command could not be verified against this checkout. | + +## Observability and testing + +| Sample | Purpose and prerequisites | Run / test | +| --- | --- | --- | +| [`otel/quickstart/`](otel/quickstart/README.md) | In-code OpenTelemetry configuration and instrumentation; `uv` and the configured agent connections are required. | In the sample directory: `uv sync`, then `uv run python -m src.main`. The guide also starts the Aspire dashboard with `.\start_dashboard.ps1`. | +| [`otel/zero-code/`](otel/zero-code/README.md) | OpenTelemetry auto-instrumentation configured through environment variables; `uv` and the configured agent connections are required. | In the sample directory: `uv sync`, then run `.\start_dashboard.ps1` and `.\start_agent.ps1`. | +| [`testing/`](testing/README.md) | Tests an `AgentApplication` in memory with `TestAdapter` and `TestFlow`; no live server is needed for the tests. | From `test_samples/testing`: `uv sync` then `uv run pytest`. To run the aiohttp host, use `uv run python src/app.py`. | +| [`streaming_activity_lifecycle_net/`](streaming_activity_lifecycle_net/README.md) | .NET diagnostic for updating/deleting finalized streaming activities in Teams. Requires .NET 10, a sibling `Agents-for-net` checkout, and Teams/Bot setup. | From the repository root: `dotnet run --project test_samples\streaming_activity_lifecycle_net\StreamingActivityLifecycle.csproj`. | + +## Entries without verifiable sample instructions + +The `a2a/` and `demo/` directories currently have no sample README or runnable +source files in this checkout, so their purpose and run/test commands could +not be verified. diff --git a/tests/hosting_core/app/test_agent_application_routes.py b/tests/hosting_core/app/test_agent_application_routes.py index fcb6548db..9edb606db 100644 --- a/tests/hosting_core/app/test_agent_application_routes.py +++ b/tests/hosting_core/app/test_agent_application_routes.py @@ -38,9 +38,9 @@ def _make_app() -> AgentApplication[TurnState]: ) -def _make_activity(**kwargs) -> Activity: +def _make_activity(channel_id: str = "test", **kwargs) -> Activity: return Activity( - channel_id="test", + channel_id=channel_id, conversation={"id": "conv1"}, from_property={"id": "user1"}, recipient={"id": "bot1"}, @@ -416,3 +416,148 @@ async def evt_handler(context: TurnContext, state: TurnState): TurnState(), ) assert self.call_order == ["event", "message"] + + +class TestChannelIdRouteFiltering: + def setup_method(self): + self.app = _make_app() + self.called = False + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("webchat", False)], + ) + @pytest.mark.asyncio + async def test_activity_route_filters_by_channel_id( + self, incoming_channel_id: str, expected: bool + ): + @self.app.activity("event", channel_id="msteams") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(channel_id=incoming_channel_id, type="event")), + TurnState(), + ) + assert self.called is expected + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("msteams:meeting", False)], + ) + @pytest.mark.asyncio + async def test_message_route_filters_by_exact_subchannel( + self, incoming_channel_id: str, expected: bool + ): + @self.app.message("hello", channel_id="msteams:copilot") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity( + channel_id=incoming_channel_id, + type=ActivityTypes.message, + text="hello", + ) + ), + TurnState(), + ) + assert self.called is expected + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("webchat", False)], + ) + @pytest.mark.asyncio + async def test_conversation_update_route_filters_by_channel_id( + self, incoming_channel_id: str, expected: bool + ): + @self.app.conversation_update("membersAdded", channel_id="msteams") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity( + channel_id=incoming_channel_id, + type=ActivityTypes.conversation_update, + members_added=[{"id": "new-member"}], + ) + ), + TurnState(), + ) + assert self.called is expected + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("msteams:meeting", False)], + ) + @pytest.mark.asyncio + async def test_message_reaction_route_filters_by_exact_subchannel( + self, incoming_channel_id: str, expected: bool + ): + @self.app.message_reaction("reactionsAdded", channel_id="msteams:copilot") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity( + channel_id=incoming_channel_id, + type=ActivityTypes.message_reaction, + reactions_added=[{"type": "like"}], + ) + ), + TurnState(), + ) + assert self.called is expected + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("webchat", False)], + ) + @pytest.mark.asyncio + async def test_message_update_route_filters_by_channel_id( + self, incoming_channel_id: str, expected: bool + ): + @self.app.message_update("editMessage", channel_id="msteams") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity( + channel_id=incoming_channel_id, + type=ActivityTypes.message_update, + channel_data={"event_type": "editMessage"}, + ) + ), + TurnState(), + ) + assert self.called is expected + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("msteams:meeting", False)], + ) + @pytest.mark.asyncio + async def test_handoff_route_filters_by_exact_subchannel( + self, incoming_channel_id: str, expected: bool + ): + @self.app.handoff(channel_id="msteams:copilot") + async def handler(context, state, continuation): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity( + channel_id=incoming_channel_id, + type=ActivityTypes.invoke, + name="handoff/action", + value={"continuation": "cont123"}, + ) + ), + TurnState(), + ) + assert self.called is expected diff --git a/tests/hosting_core/app/test_selector.py b/tests/hosting_core/app/test_selector.py new file mode 100644 index 000000000..58ffaa2a7 --- /dev/null +++ b/tests/hosting_core/app/test_selector.py @@ -0,0 +1,153 @@ +"""Tests for AgentApplication route selector helpers.""" + +import re +from types import SimpleNamespace + +from microsoft_agents.activity import Activity, ActivityTypes, ChannelId +from microsoft_agents.hosting.core.app._selector import ( + _SimpleRouteSelector, + _activity_type_selector, + _all_selector, + _channel_selector, + _invoke_event_selector, + _text_selector, +) + + +def _context(**activity_values): + activity_values.setdefault("type", ActivityTypes.message) + return SimpleNamespace(activity=Activity(**activity_values)) + + +def test_text_selector_matches_exact_text(): + selector = _text_selector("hello") + + assert selector(_context(text="hello")) + assert not selector(_context(text="hello world")) + assert not selector(_context()) + + +def test_text_selector_uses_regex_fullmatch(): + selector = _text_selector(re.compile(r"hello.*")) + + assert selector(_context(text="hello world")) + assert not selector(_context(text="well, hello")) + + +def test_text_selector_matches_any_value_in_mixed_list(): + selector = _text_selector(["hello", re.compile(r"goodbye.*")]) + + assert selector(_context(text="hello")) + assert selector(_context(text="goodbye everyone")) + assert not selector(_context(text="good morning")) + + +def test_text_selector_treats_missing_text_as_empty_string(): + assert _text_selector("")(_context()) + assert not _text_selector("hello")(_context()) + + +def test_invoke_event_selector_requires_matching_invoke_name(): + selector = _invoke_event_selector("handoff/action") + + assert selector(_context(type=ActivityTypes.invoke, name="handoff/action")) + assert not selector(_context(type=ActivityTypes.invoke, name="other/action")) + assert not selector(_context(type=ActivityTypes.message, name="handoff/action")) + assert not selector(_context(type=ActivityTypes.invoke)) + + +def test_activity_type_selector_matches_single_type_or_list(): + assert _activity_type_selector("event")(_context(type="event")) + assert not _activity_type_selector("event")(_context(type="message")) + + selector = _activity_type_selector(["event", ActivityTypes.message]) + assert selector(_context(type="event")) + assert selector(_context(type=ActivityTypes.message)) + assert not selector(_context(type=ActivityTypes.invoke)) + + +def test_channel_selector_without_filter_matches_any_channel(): + selector = _channel_selector(None) + + assert selector(_context(channel_id="msteams")) + assert selector(_context()) + + +def test_parent_channel_selector_matches_any_subchannel_of_parent(): + selector = _channel_selector("msteams") + + assert selector(_context(channel_id="msteams:copilot")) + assert selector(_context(channel_id="msteams:meeting")) + assert not selector(_context(channel_id="webchat")) + assert not selector(_context()) + + +def test_subchannel_selector_requires_exact_subchannel(): + selector = _channel_selector(ChannelId("msteams:copilot")) + + assert selector(_context(channel_id="msteams:copilot")) + assert not selector(_context(channel_id="msteams:meeting")) + assert not selector(_context(channel_id="msteams")) + + +def test_all_selector_requires_every_selector_to_match(): + selector = _all_selector( + _activity_type_selector(ActivityTypes.message), + _channel_selector("msteams"), + _text_selector("hello"), + ) + + assert selector( + _context( + type=ActivityTypes.message, + channel_id="msteams:copilot", + text="hello", + ) + ) + assert not selector( + _context(type=ActivityTypes.message, channel_id="webchat", text="hello") + ) + + +def test_simple_route_selector_combines_channel_type_and_text(): + selector = _SimpleRouteSelector( + channel_id="msteams", + activity_type=[ActivityTypes.message, ActivityTypes.event], + text_pattern=["hello", re.compile(r"goodbye.*")], + ) + + assert selector( + _context( + type=ActivityTypes.message, + channel_id="msteams:copilot", + text="hello", + ) + ) + assert selector( + _context( + type=ActivityTypes.event, + channel_id="msteams", + text="goodbye everyone", + ) + ) + assert not selector( + _context( + type=ActivityTypes.invoke, + channel_id="msteams", + text="hello", + ) + ) + assert not selector( + _context( + type=ActivityTypes.message, + channel_id="webchat", + text="hello", + ) + ) + assert not selector( + _context( + type=ActivityTypes.message, + channel_id="msteams", + text="unmatched", + ) + ) From abc6feab0a80900b267421aa9293777ab14aacf4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 11:05:28 -0700 Subject: [PATCH 02/13] More tests --- .../test_routing_integration.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/hosting_msteams/test_routing_integration.py b/tests/hosting_msteams/test_routing_integration.py index f5c2cd5d6..e21dffe4c 100644 --- a/tests/hosting_msteams/test_routing_integration.py +++ b/tests/hosting_msteams/test_routing_integration.py @@ -271,3 +271,73 @@ async def on_event(context, state): _make_context(_make_activity(type=ActivityTypes.message, text="hello")) ) assert self.call_order == ["event", "message"] + + +class TestDirectRouteHooksChannelFiltering: + """Direct hooks must only route Teams activities at core dispatch.""" + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + self.calls: list[str] = [] + + @pytest.mark.parametrize( + "route_name", + [ + "activity", + "message", + "conversation_update", + "message_reaction", + "message_update", + "handoff", + ], + ) + @pytest.mark.parametrize( + ("channel_id", "expected"), + [("msteams", True), ("msteams:copilot", True), ("webchat", False)], + ) + @pytest.mark.asyncio + async def test_direct_route_only_matches_msteams_channel( + self, route_name: str, channel_id: str, expected: bool + ): + async def handler(context, state, continuation=None): + self.calls.append("called") + + if route_name == "activity": + self.ext.activity(ActivityTypes.event)(handler) + route_values = {"type": ActivityTypes.event} + elif route_name == "message": + self.ext.message("hello")(handler) + route_values = {"type": ActivityTypes.message, "text": "hello"} + elif route_name == "conversation_update": + self.ext.conversation_update("membersAdded")(handler) + route_values = { + "type": ActivityTypes.conversation_update, + "members_added": [{"id": "new-member"}], + } + elif route_name == "message_reaction": + self.ext.message_reaction("reactionsAdded")(handler) + route_values = { + "type": ActivityTypes.message_reaction, + "reactions_added": [{"type": "like"}], + } + elif route_name == "message_update": + self.ext.message_update("editMessage")(handler) + route_values = { + "type": ActivityTypes.message_update, + "channel_data": {"event_type": "editMessage"}, + } + else: + self.ext.handoff()(handler) + route_values = { + "type": ActivityTypes.invoke, + "name": "handoff/action", + "value": {"continuation": "continuation"}, + } + + await self.app._on_activity( + _make_context(_make_activity(channel_id=channel_id, **route_values)), + TurnState(), + ) + + assert self.calls == (["called"] if expected else []) From 800106028483a205852e053a7d2e01c22b023eb4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 11:09:32 -0700 Subject: [PATCH 03/13] Updating changelog --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 6da07dbe1..920c8a503 100644 --- a/changelog.md +++ b/changelog.md @@ -12,6 +12,7 @@ ## Major Features & Enhancements - **Outbound Host Validation Hardening**: Enhanced `OutboundHostValidator` with HTTPS-only URL enforcement, DNS resolution validation for public and optionally private network addresses, and an SSRF-aware `aiohttp` connector that validates resolved IPv4 and IPv6 addresses before connecting. +- **Channel-Aware Route Selectors**: Added reusable route selectors, optional `channel_id` filtering to `AgentApplication` route hooks, and Teams-channel scoping for direct `TeamsAgentExtension` routes. ## Deprecation From 91783988d195e3e1d87321c210d793a642a70aa3 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 11:16:42 -0700 Subject: [PATCH 04/13] Updating test_sampels\README.md --- test_samples/README.md | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/test_samples/README.md b/test_samples/README.md index 1b04e49ec..9fd26a840 100644 --- a/test_samples/README.md +++ b/test_samples/README.md @@ -57,7 +57,6 @@ select the needed packages; not every sample needs every library. | [`cards/`](cards/README.md) | Adaptive Cards, submit/execute actions, dynamic search, and media cards. `Action.Execute` and dynamic search require Teams. | From `test_samples/cards`: `python agent.py`. | | [`handling_attachments/`](handling_attachments/README.md) | Sends and receives inline, URL, and uploaded attachments; uploaded attachments require Teams. | From `test_samples/handling_attachments`: `python -m src.main`. | | [`proactive/`](proactive/README.md) | Proactive conversation continuation, stored references, OAuth, and an external notification endpoint. Requires an Azure Bot OAuth connection and a tunnel or Teams/Agents Playground for interaction. | From the repository root: `python test_samples/proactive/proactive_agent.py`. | -| [`bizchat-sso/`](bizchat-sso/) | Echo agent with sign-in and sign-out handlers. Configure the Azure Bot OAuth connection named for the sample's SSO handler. | From `test_samples/bizchat-sso`: `python src/main.py`; install `requirements.txt` first. | | [`agentic-test/`](agentic-test/) | Acquires an agentic user token and displays token claims in an Adaptive Card. Requires the configured `AGENTIC` authentication connection. | From `test_samples/agentic-test`: `python src/main.py`; install `requirements.txt` first. | | [`copilot_studio_client_sample/`](copilot_studio_client_sample/app.py) | Console client for a Copilot Studio agent using MSAL interactive sign-in and the Copilot Studio client library. Requires a configured Entra app and Copilot Studio connection. | `python app.py`. No sample README or `requirements.txt` is present; install its SDK and MSAL dependencies in the environment. | | [`copilot_studio_connector/`](copilot_studio_connector/README.md) | Receives Copilot Studio connector requests and uses OAuth/OBO to call Graph. Requires an Azure Bot app, Copilot Studio connector, and Graph OAuth permissions. | From `test_samples/copilot_studio_connector`: `pip install -r requirements.txt`, then `python app.py`. | @@ -80,19 +79,16 @@ select the needed packages; not every sample needs every library. | Sample | Purpose and additional prerequisites | Run / details | | --- | --- | --- | | [`entra_sidecar/`](entra_sidecar/README.md) | Credential-free outbound token acquisition through the Entra ID Agent Container sidecar. Requires the sidecar and its downstream API configuration. | Follow the guide to install requirements and run `python agent.py` from `test_samples/entra_sidecar`. | -| [`fic/`](fic/FIC_README.md) | Federated identity credentials for an Azure App Service agent using a user-assigned managed identity. Requires Azure CLI, an Azure subscription, and an Entra app registration. | From the repository root, provision with `.\test_samples\fic\setup.ps1 -ResourceGroup -AppName `, then deploy with `.\test_samples\fic\deploy.ps1`. | -| [`workload_identity/`](workload_identity/README.md) | End-to-end AKS Workload Identity deployment and test. Requires Azure CLI, `kubectl`, an Azure subscription, and an AKS cluster or permission to create one. | Follow the guide's setup and deployment steps; run `.\test_samples\workload_identity\test.ps1` for the in-cluster test. | -| [`mi/`](mi/) | Echo-agent sample with Azure resource templates under `infra/`. Requires Azure identity/bot configuration and the resources described by those templates. | From `test_samples/mi`: install `requirements.txt`, then run `python src/main.py`. | ## Compatibility samples | Sample | Purpose | Run / details | | --- | --- | --- | -| [`compat/dialogs/complex_dialog/`](compat/dialogs/complex_dialog/README.md) | BotBuilder complex-dialog port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | -| [`compat/dialogs/custom_dialogs/`](compat/dialogs/custom_dialogs/README.md) | BotBuilder custom-dialogs port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | -| [`compat/dialogs/multi_turn/`](compat/dialogs/multi_turn/README.md) | BotBuilder multi-turn-prompt port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | -| [`compat/dialogs/oauth_prompt/`](compat/dialogs/oauth_prompt/README.md) | OAuth prompt sample. Requires an Azure Bot OAuth connection. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | -| [`compat/dialogs/user_auth/`](compat/dialogs/user_auth/README.md) | User-authentication sample that accesses Graph; requires the corresponding OAuth connection and permissions. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/dialogs/complex_dialog/`](compat/dialogs/complex_dialog/README.md) | BotBuilder complex-dialog port. | From the sample directory: install `requirements.txt`, then run `python -m src.main`. | +| [`compat/dialogs/custom_dialogs/`](compat/dialogs/custom_dialogs/README.md) | BotBuilder custom-dialogs port. | From the sample directory: install `requirements.txt`, then run `python -m src.main`. | +| [`compat/dialogs/multi_turn/`](compat/dialogs/multi_turn/README.md) | BotBuilder multi-turn-prompt port. | From the sample directory: install `requirements.txt`, then run `python -m src.main`. | +| [`compat/dialogs/oauth_prompt/`](compat/dialogs/oauth_prompt/README.md) | OAuth prompt sample. Requires an Azure Bot OAuth connection. | From the sample directory: install `requirements.txt`, then run `python -m src.main`. | +| [`compat/dialogs/user_auth/`](compat/dialogs/user_auth/README.md) | User-authentication sample that accesses Graph; requires the corresponding OAuth connection and permissions. | From the sample directory: install `requirements.txt`, then run `python -m src.main`. | | [`compat/teams_agent/`](compat/teams_agent/app.py) | Legacy Teams handler examples for SSO and other Teams features. | Purpose is identifiable from the source, but a runnable command could not be verified: its imports reference `test_samples.activity_handler`, which is not present here. | | [`compat/agent_to_agent/`](compat/agent_to_agent/) | Two-agent channel/agent communication example. | The source imports also reference `test_samples.activity_handler`; a runnable command could not be verified against this checkout. | @@ -103,10 +99,3 @@ select the needed packages; not every sample needs every library. | [`otel/quickstart/`](otel/quickstart/README.md) | In-code OpenTelemetry configuration and instrumentation; `uv` and the configured agent connections are required. | In the sample directory: `uv sync`, then `uv run python -m src.main`. The guide also starts the Aspire dashboard with `.\start_dashboard.ps1`. | | [`otel/zero-code/`](otel/zero-code/README.md) | OpenTelemetry auto-instrumentation configured through environment variables; `uv` and the configured agent connections are required. | In the sample directory: `uv sync`, then run `.\start_dashboard.ps1` and `.\start_agent.ps1`. | | [`testing/`](testing/README.md) | Tests an `AgentApplication` in memory with `TestAdapter` and `TestFlow`; no live server is needed for the tests. | From `test_samples/testing`: `uv sync` then `uv run pytest`. To run the aiohttp host, use `uv run python src/app.py`. | -| [`streaming_activity_lifecycle_net/`](streaming_activity_lifecycle_net/README.md) | .NET diagnostic for updating/deleting finalized streaming activities in Teams. Requires .NET 10, a sibling `Agents-for-net` checkout, and Teams/Bot setup. | From the repository root: `dotnet run --project test_samples\streaming_activity_lifecycle_net\StreamingActivityLifecycle.csproj`. | - -## Entries without verifiable sample instructions - -The `a2a/` and `demo/` directories currently have no sample README or runnable -source files in this checkout, so their purpose and run/test commands could -not be verified. From bd68be1e1c25aeb8d1ed039cd458af89d24dd7b5 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 11:24:05 -0700 Subject: [PATCH 05/13] Updating overload signature and docstrings --- .../microsoft_agents/hosting/core/app/_selector.py | 6 ++++-- .../microsoft_agents/hosting/core/app/agent_application.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py index ad75e5c83..38eb033e7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py @@ -12,10 +12,12 @@ def _text_selector( select: str | re.Pattern[str] | list[str | re.Pattern[str]], ) -> RouteSelector: - """Checks if the given text matches the selection criteria. + """Creates a route selector that matches the activity text against the selection criteria. + + Strings must match the text exactly; regex patterns must fully match it. :param select: A string, regex pattern, or a list of strings/regex patterns to match against. - :return: True if the text matches any of the selection criteria, False otherwise. + :return: A route selector function that returns True if the activity text matches any of the selection criteria. """ def __selector(context: TurnContext) -> bool: 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 e5b6a4b2d..e073b7db2 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 @@ -670,6 +670,7 @@ def handoff( self, func: HandoffHandler[StateT], *, + channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, **kwargs, ) -> HandoffHandler[StateT]: ... @@ -678,6 +679,7 @@ def handoff( def handoff( self, *, + channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, **kwargs: Any, ) -> _RouteDecorator[HandoffHandler[StateT]]: ... From af9093839a13ab0f4a4ef14bc48afb9009debcae Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 13:28:39 -0700 Subject: [PATCH 06/13] Updating test --- changelog.md | 2 + .../hosting/core/app/_selector.py | 40 +-- .../hosting/core/app/_type_defs.py | 2 + .../hosting/core/app/_utils.py | 30 ++ .../hosting/core/app/agent_application.py | 227 ++++++++++++++- .../hosting/msteams/teams_agent_extension.py | 47 +++- .../hosting/slack/slack_agent_extension.py | 247 ++++++++++------ .../microsoft-agents-hosting-slack/readme.md | 10 +- .../app/test_agent_application_routes.py | 266 ++++++++++++++++++ .../test_routing_integration.py | 8 + .../test_slack_agent_extension.py | 175 +++++++++++- 11 files changed, 924 insertions(+), 130 deletions(-) diff --git a/changelog.md b/changelog.md index 920c8a503..0569ea195 100644 --- a/changelog.md +++ b/changelog.md @@ -7,12 +7,14 @@ - **Slack-Aware Turn Context**: Added `SlackTurnContext`, `SlackActivity`, and `SlackRouteHandler` so Slack handlers receive a typed context with direct access to Slack channel data and the per-turn API client. - **Route Decorators**: Added typed `SlackAgentExtension.message()` and `.event()` decorators. Deprecated `on_message()` and `on_event()` while retaining them as backward-compatible aliases. +- **Slack-Scoped Application Route Hooks**: Added `SlackAgentExtension.conversation_update()`, `.members_added()`, `.members_removed()`, and `.installation_update()`, which delegate to the matching `AgentApplication` hooks filtered to the Slack channel. - **Typed Model Lookups**: Added a `type_` parameter and typed overloads to `SlackModel.get()` for converting and validating nested values with Pydantic. ## Major Features & Enhancements - **Outbound Host Validation Hardening**: Enhanced `OutboundHostValidator` with HTTPS-only URL enforcement, DNS resolution validation for public and optionally private network addresses, and an SSRF-aware `aiohttp` connector that validates resolved IPv4 and IPv6 addresses before connecting. - **Channel-Aware Route Selectors**: Added reusable route selectors, optional `channel_id` filtering to `AgentApplication` route hooks, and Teams-channel scoping for direct `TeamsAgentExtension` routes. +- **New Application Route Hooks**: Added `AgentApplication.event()`, `.invoke()`, `.installation_update()`, `.members_added()`, `.members_removed()`, `.end_of_conversation()`, `.message_reaction_added()`, and `.message_reactions_removed()`; `message()`, `event()`, and `invoke()` accept a string, regex, or list selector, and match everything at `RouteRank.LAST` when the selector is omitted. Added Teams-scoped `TeamsAgentExtension.installation_update()` and `.end_of_conversation()`, plus the Slack-scoped hooks listed above. ## Deprecation diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py index 38eb033e7..016d22c08 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py @@ -3,10 +3,10 @@ import re - from microsoft_agents.activity import ActivityTypes, ChannelId from microsoft_agents.hosting.core import TurnContext -from ._type_defs import RouteSelector +from ._type_defs import RouteSelector, TextSelector +from ._utils import _matches_text def _text_selector( @@ -22,28 +22,31 @@ def _text_selector( def __selector(context: TurnContext) -> bool: """Selector function that checks if the given text matches the selection criteria.""" - text = context.activity.text or "" - if isinstance(select, list): - for item in select: - if isinstance(item, re.Pattern): - if re.fullmatch(item, text) is not None: - return True - elif text == item: - return True - return False + return _matches_text(select, context.activity.text or "") + + return __selector + - if isinstance(select, re.Pattern): - return re.fullmatch(select, text) is not None +def _event_selector(event_selector: TextSelector | None) -> RouteSelector: + """Creates a route selector that matches the given event name. - return text == select + :param event_selector: The text selector for the event name to match against. + :return: A route selector function that matches the specified event name. + """ + + def __selector(context: TurnContext) -> bool: + """Selector function that checks if the given event name matches the selection criteria.""" + return context.activity.type == ActivityTypes.event and _matches_text( + event_selector, context.activity.name or "" + ) return __selector -def _invoke_event_selector(event_name: str) -> RouteSelector: +def _invoke_event_selector(event_selector: TextSelector | None) -> RouteSelector: """Creates a route selector that matches the given invoke event name. - :param event_name: The name of the invoke event to match against. + :param event_selector: The text selector for the invoke event name to match against. :return: A route selector function that matches the specified invoke event name. """ @@ -51,9 +54,8 @@ def __selector(context: TurnContext) -> bool: """Selector function that checks if the given invoke event name matches the selection criteria.""" if context.activity.name is None: return False - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == event_name + return context.activity.type == ActivityTypes.invoke and _matches_text( + event_selector, context.activity.name or "" ) return __selector diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py index 51dd88c20..32f439755 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import re from typing import Callable, TypeVar, Awaitable, Protocol from ..turn_context import TurnContext @@ -11,6 +12,7 @@ StateT = TypeVar("StateT", bound=TurnState) _StateContra = TypeVar("_StateContra", bound=TurnState, contravariant=True) _RouteHandlerT = TypeVar("_RouteHandlerT", bound=Callable) +TextSelector = str | re.Pattern[str] | list[str | re.Pattern[str]] class _RouteDecorator(Protocol[_RouteHandlerT]): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_utils.py index f6715087e..50d87d197 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_utils.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_utils.py @@ -1,9 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import re + from email.message import Message from urllib.parse import urlparse +from ._type_defs import TextSelector + _CONTENT_TYPE = "Content-Type" @@ -34,3 +38,29 @@ def _basic_url_check(url: str) -> bool: return parsed.scheme == "https" or ( parsed.scheme == "http" and parsed.hostname == "localhost" ) + + +def _matches_text(select: TextSelector | None = None, text: str | None = None) -> bool: + """Checks if the given text matches the specified text selector. + + :param select: The text selector to match against. Can be a string, a compiled regex pattern, or a list of these. + :param text: The text to check. Defaults to an empty string if None is provided. + :return: True if the text matches the selector, False otherwise. + """ + if select is None: + return True + + text = text or "" + if isinstance(select, list): + for item in select: + if isinstance(item, re.Pattern): + if re.fullmatch(item, text) is not None: + return True + elif text == item: + return True + return False + + if isinstance(select, re.Pattern): + return re.fullmatch(select, text) is not None + + return text == select 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 e073b7db2..555340243 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 @@ -50,19 +50,22 @@ from .telemetry import spans from ._type_defs import ( - RouteHandler, HandoffHandler, RouteSelector, _RouteDecorator, + RouteHandler, + RouteSelector, + TextSelector, ) from ._routes import _RouteList, _Route, RouteRank, _agentic_selector from .proactive import Proactive from .adaptive_card import AdaptiveCard from ._selector import ( _SimpleRouteSelector, - _invoke_event_selector, - _channel_selector, _all_selector, + _channel_selector, + _event_selector, + _invoke_event_selector, ) logger = logging.getLogger(__name__) @@ -426,9 +429,105 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call + def installation_update( + self, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for installation update activities as either a decorator or a method. + + :param channel_id: Optional channel ID to match the incoming installation update. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.activity( + ActivityTypes.installation_update, + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + + def event( + self, + select: TextSelector | None = None, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for event activities as either a decorator or a method. + + :param select: Literal text, compiled regex, or list of either used to match the incoming event name. When ``None``, every event matches. + :type select: str | re.Pattern[str] | list[str | re.Pattern[str]] | None + :param channel_id: Optional channel ID to match the incoming event. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + + if select is None and "rank" not in kwargs: + kwargs["rank"] = RouteRank.LAST + + selector = _all_selector( + _event_selector(select), + _channel_selector(channel_id), + ) + + def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: + logger.debug( + f"Registering event handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" + ) + self.add_route(selector, func, auth_handlers=auth_handlers, **kwargs) + return func + + return __call + + def invoke( + self, + select: TextSelector | None = None, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for invoke activities as either a decorator or a method. + + :param select: Literal text, compiled regex, or list of either used to match the incoming invoke event name. When ``None``, every invoke event matches. + :type select: str | re.Pattern[str] | list[str | re.Pattern[str]] | None + :param channel_id: Optional channel ID to match the incoming invoke event. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + if select is None and "rank" not in kwargs: + kwargs["rank"] = RouteRank.LAST + + selector = _all_selector( + _invoke_event_selector(select), + _channel_selector(channel_id), + ) + + def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: + logger.debug( + f"Registering invoke handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" + ) + self.add_route(selector, func, auth_handlers=auth_handlers, **kwargs) + return func + + return __call + def message( self, - select: str | re.Pattern[str] | list[str | re.Pattern[str]], + select: TextSelector | None = None, *, channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, @@ -445,14 +544,17 @@ async def on_hi_message(context: TurnContext, state: TurnState): print("hello!") return True - :param select: Literal text, compiled regex, or list of either used to match the incoming message. - :type select: str | re.Pattern[str] | list[str | re.Pattern[str]] + :param select: Literal text, compiled regex, or list of either used to match the incoming message. When ``None``, every message matches. + :type select: str | re.Pattern[str] | list[str | re.Pattern[str]] | None :param channel_id: Optional channel ID to match the incoming message. :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ + if select is None and "rank" not in kwargs: + kwargs["rank"] = RouteRank.LAST + selector = _SimpleRouteSelector( channel_id=channel_id, activity_type=ActivityTypes.message, @@ -532,6 +634,75 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call + def members_added( + self, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for members added conversation update activities as either a decorator or a method. + + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.conversation_update( + type=ConversationUpdateTypes.MEMBERS_ADDED, + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + + def members_removed( + self, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for members removed conversation update activities as either a decorator or a method. + + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.conversation_update( + type=ConversationUpdateTypes.MEMBERS_REMOVED, + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + + def end_of_conversation( + self, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for end of conversation activities as either a decorator or a method. + + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.activity( + activity_type=ActivityTypes.end_of_conversation, + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + def message_reaction( self, type: MessageReactionTypes | str, @@ -592,6 +763,50 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call + def message_reaction_added( + self, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler specifically for the "reactionsAdded" message reaction as either a decorator or a method. + + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.message_reaction( + "reactionsAdded", + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + + def message_reactions_removed( + self, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler specifically for the "reactionsRemoved" message reaction as either a decorator or a method. + + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.message_reaction( + "reactionsRemoved", + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + def message_update( self, type: MessageUpdateTypes | str, diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 559fc9ed1..4378a1645 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -14,7 +14,6 @@ from typing import ( Callable, Generic, - Optional, Pattern, Protocol, ) @@ -204,7 +203,7 @@ def activity( self, activity_type: str | ActivityTypes | list[str | ActivityTypes], *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _AppRouteDecorator[StateT]: """Register a handler for one or more activity types. @@ -226,7 +225,7 @@ def message( self, select: str | Pattern[str] | list[str | Pattern[str]], *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _AppRouteDecorator[StateT]: """Register a handler for message activities matching *select*. @@ -249,7 +248,7 @@ def conversation_update( self, type: ConversationUpdateTypes, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _AppRouteDecorator[StateT]: """Register a handler for a specific conversation update event type. @@ -268,7 +267,7 @@ def message_reaction( self, type: MessageReactionTypes, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _AppRouteDecorator[StateT]: """Register a handler for a specific message reaction type. @@ -287,7 +286,7 @@ def message_update( self, type: MessageUpdateTypes, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _AppRouteDecorator[StateT]: """Register a handler for a specific message update event type. @@ -302,10 +301,44 @@ def message_update( ) ) + def installation_update( + self, + *, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for installation update activities. + + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.installation_update( + auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + ) + ) + + def end_of_conversation( + self, + *, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for end of conversation activities. + + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.end_of_conversation( + auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + ) + ) + def handoff( self, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> Callable[[TeamsHandoffHandler[StateT]], HandoffHandler[StateT]]: """Register a Teams handoff handler. diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index aa6542f09..1b71c17e7 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -5,15 +5,21 @@ from __future__ import annotations -import re -from typing import Any, Generic, Pattern, TypeVar +from typing import Any, Callable, Generic, TypeVar from typing_extensions import deprecated -from microsoft_agents.activity import ActivityTypes, Channels +from microsoft_agents.activity import ( + Channels, + ConversationUpdateTypes, +) from microsoft_agents.hosting.core import TurnContext -from microsoft_agents.hosting.core.app import AgentApplication, RouteRank -from microsoft_agents.hosting.core.app._type_defs import _RouteDecorator +from microsoft_agents.hosting.core.app import AgentApplication +from microsoft_agents.hosting.core.app._type_defs import ( + RouteHandler, + _RouteDecorator, + TextSelector, +) from microsoft_agents.hosting.core.app.state import TurnState from .api import ( @@ -26,18 +32,6 @@ StateT = TypeVar("StateT", bound=TurnState) -TextSelector = str | Pattern[str] | None - - -def _matches_text(selector: TextSelector, text: str | None) -> bool: - if selector is None: - return True - if text is None: - return False - if isinstance(selector, Pattern): - return re.fullmatch(selector, text) is not None - return text == selector - def _is_slack_channel(context: TurnContext) -> bool: return context.activity.channel_id == Channels.slack.value @@ -48,8 +42,11 @@ class SlackAgentExtension(Generic[StateT]): Slack-specific route registration and helpers for an :class:`~microsoft_agents.hosting.core.app.AgentApplication`. - Handlers registered with :meth:`message` or :meth:`event` receive a - :class:`SlackTurnContext` with a typed :class:`SlackActivity`. + Handlers registered with :meth:`message`, :meth:`event`, + :meth:`conversation_update`, :meth:`members_added`, + :meth:`members_removed`, or :meth:`installation_update` match only Slack + activities and receive a :class:`SlackTurnContext` with a typed + :class:`SlackActivity`. Pass ``slack_api=...`` to configure the extension's default API client. Before each Slack turn, this client is registered in turn services only if no :class:`SlackApi` is already present. Clients supplied by middleware @@ -182,116 +179,180 @@ async def create_stream( def message( self, - select: TextSelector = None, + select: TextSelector | None = None, *, auth_handlers: list[str] | None = None, - rank: RouteRank = RouteRank.DEFAULT, + **kwargs, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Register a handler for Slack message activities. - When ``select`` is ``None``, every Slack message matches; otherwise the - activity's ``text`` must equal ``select`` (case-sensitive string) or - fully match it (compiled regex). Non-Slack activities never match. - The handler receives a :class:`SlackTurnContext` and the turn state. - - ``rank`` defaults to :attr:`RouteRank.DEFAULT`; when ``select`` is - ``None`` the rank is downgraded to :attr:`RouteRank.LAST` so explicit - text routes win — matching the C# behavior. - - ``auth_handlers`` selects the authentication handlers for the route. + :param select: Optional text selector to match the incoming message text. + :type select: TextSelector | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - effective_rank = ( - RouteRank.LAST if (select is None and rank == RouteRank.DEFAULT) else rank - ) - - def __selector(context: TurnContext) -> bool: - if context.activity.type != ActivityTypes.message or not _is_slack_channel( - context - ): - return False - return _matches_text(select, context.activity.text) - - def __call( - func: SlackRouteHandler[StateT], - ) -> SlackRouteHandler[StateT]: - self._app.add_route( - __selector, - wrap_slack_route_handler(func, self._app), - rank=effective_rank, - auth_handlers=auth_handlers, + return self._wrap_decorator( + self._app.message( + select, auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs ) - return func - - return __call + ) @deprecated( "SlackAgentExtension.on_message is deprecated; use SlackAgentExtension.message instead." ) def on_message( self, - select: TextSelector = None, + select: TextSelector | None = None, *, auth_handlers: list[str] | None = None, - rank: RouteRank = RouteRank.DEFAULT, + **kwargs, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Deprecated alias for :meth:`message`; emits ``DeprecationWarning``.""" - return self.message(select, auth_handlers=auth_handlers, rank=rank) + return self.message(select, auth_handlers=auth_handlers, **kwargs) # ── event routes ─────────────────────────────────────────────────────── def event( self, - event_name: TextSelector = None, + select: TextSelector | None = None, *, auth_handlers: list[str] | None = None, - rank: RouteRank = RouteRank.DEFAULT, + **kwargs, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Register a handler for Slack event activities. - When ``event_name`` is ``None``, every Slack event matches; otherwise - the activity's ``name`` must equal it (case-sensitive string) or fully - match it (compiled regex). Non-Slack activities never match. - The handler receives a :class:`SlackTurnContext` and the turn state. - - Omitting ``event_name`` with the default rank uses - :attr:`RouteRank.LAST` so specific event routes win. An explicit rank - is preserved. ``auth_handlers`` selects authentication handlers. + :param select: Optional text selector to match the incoming event name. + :type select: TextSelector | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - effective_rank = ( - RouteRank.LAST - if (event_name is None and rank == RouteRank.DEFAULT) - else rank - ) - - def __selector(context: TurnContext) -> bool: - if context.activity.type != ActivityTypes.event or not _is_slack_channel( - context - ): - return False - return _matches_text(event_name, context.activity.name) - - def __call( - func: SlackRouteHandler[StateT], - ) -> SlackRouteHandler[StateT]: - self._app.add_route( - __selector, - wrap_slack_route_handler(func, self._app), - rank=effective_rank, - auth_handlers=auth_handlers, + return self._wrap_decorator( + self._app.event( + select, auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs ) - return func - - return __call + ) @deprecated( "SlackAgentExtension.on_event is deprecated; use SlackAgentExtension.event instead." ) def on_event( self, - event_name: TextSelector = None, + select: TextSelector | None = None, *, auth_handlers: list[str] | None = None, - rank: RouteRank = RouteRank.DEFAULT, + **kwargs, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Deprecated alias for :meth:`event`; emits ``DeprecationWarning``.""" - return self.event(event_name, auth_handlers=auth_handlers, rank=rank) + return self.event(select, auth_handlers=auth_handlers, **kwargs) + + # ── AgentApplication route hooks ─────────────────────────────────────── + + def _wrap_decorator( + self, decorator: Callable[[RouteHandler[StateT]], RouteHandler[StateT]] + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: + """Wrap a core route decorator so it accepts a :class:`SlackRouteHandler`. + + The handler is converted via :func:`wrap_slack_route_handler` before being + passed to *decorator*, and the original handler is returned. + + :param decorator: A core route decorator from :class:`AgentApplication`. + :return: A decorator that registers a :class:`SlackRouteHandler`. + """ + + def __call(func: SlackRouteHandler[StateT]) -> SlackRouteHandler[StateT]: + decorator(wrap_slack_route_handler(func, self._app)) + return func + + return __call + + def conversation_update( + self, + type: ConversationUpdateTypes | str, + *, + auth_handlers: list[str] | None = None, + **kwargs: Any, + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: + """Register a handler for a Slack conversation update event type. + + Non-Slack activities never match. The handler receives a + :class:`SlackTurnContext` and the turn state. + + :param type: The :class:`ConversationUpdateTypes` value to match (e.g. ``membersAdded``). + :param auth_handlers: Optional list of auth handler names to run before the route. + :param kwargs: Additional route configuration, such as ``rank``, passed to + :meth:`AgentApplication.add_route`. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.conversation_update( + type, auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs + ) + ) + + def members_added( + self, + *, + auth_handlers: list[str] | None = None, + **kwargs: Any, + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: + """Register a handler for the Slack ``membersAdded`` event. + + Non-Slack activities never match. The handler receives a + :class:`SlackTurnContext` and the turn state. + + :param auth_handlers: Optional list of auth handler names to run before the route. + :param kwargs: Additional route configuration, such as ``rank``, passed to + :meth:`AgentApplication.add_route`. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.members_added( + auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs + ) + ) + + def members_removed( + self, + *, + auth_handlers: list[str] | None = None, + **kwargs: Any, + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: + """Register a handler for the Slack ``membersRemoved`` event. + + Non-Slack activities never match. The handler receives a + :class:`SlackTurnContext` and the turn state. + + :param auth_handlers: Optional list of auth handler names to run before the route. + :param kwargs: Additional route configuration, such as ``rank``, passed to + :meth:`AgentApplication.add_route`. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.members_removed( + auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs + ) + ) + + def installation_update( + self, + *, + auth_handlers: list[str] | None = None, + **kwargs: Any, + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: + """Register a handler for the Slack ``installationUpdate`` event. + + Non-Slack activities never match. The handler receives a + :class:`SlackTurnContext` and the turn state. + + :param auth_handlers: Optional list of auth handler names to run before the route. + :param kwargs: Additional route configuration, such as ``rank``, passed to + :meth:`AgentApplication.add_route`. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.installation_update( + auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs + ) + ) diff --git a/libraries/microsoft-agents-hosting-slack/readme.md b/libraries/microsoft-agents-hosting-slack/readme.md index 386d387e7..7a723d6ea 100644 --- a/libraries/microsoft-agents-hosting-slack/readme.md +++ b/libraries/microsoft-agents-hosting-slack/readme.md @@ -56,8 +56,14 @@ async def on_slack_message(context: SlackTurnContext, state: TurnState) -> None: ``` Use `@slack.message("hello")` for a specific message or `@slack.event("reaction_added")` -for a specific event. Both accept compiled regular expressions and match only Slack -activities. `on_message()` and `on_event()` are deprecated aliases. +for a specific event. Both accept a string, a compiled regular expression, or a list of +either, and match only Slack activities. When called without a selector, they match every +Slack message or event and register at `RouteRank.LAST`, so more specific routes win. +`on_message()` and `on_event()` are deprecated aliases. + +`@slack.conversation_update(...)`, `@slack.members_added()`, `@slack.members_removed()`, +and `@slack.installation_update()` mirror the corresponding `AgentApplication` route +hooks, but match only Slack activities and pass a `SlackTurnContext` to the handler. Handlers receive a `SlackTurnContext` with typed `activity.slack_channel_data` and access to the per-turn Slack API client through `context.client`. Envelope, payload, diff --git a/tests/hosting_core/app/test_agent_application_routes.py b/tests/hosting_core/app/test_agent_application_routes.py index 9edb606db..070499ba6 100644 --- a/tests/hosting_core/app/test_agent_application_routes.py +++ b/tests/hosting_core/app/test_agent_application_routes.py @@ -12,6 +12,7 @@ from microsoft_agents.hosting.core.app import ( AgentApplication, ApplicationOptions, + RouteRank, TurnState, ) from microsoft_agents.hosting.core.app.oauth import Authorization @@ -181,6 +182,31 @@ async def handler(context: TurnContext, state: TurnState): ) assert not self.called + @pytest.mark.asyncio + @pytest.mark.parametrize("text", ["anything", None]) + async def test_none_select_matches_any_message(self, text): + @self.app.message(None) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text=text)), + TurnState(), + ) + assert self.called + + @pytest.mark.asyncio + async def test_none_select_ignores_non_message_activity(self): + @self.app.message(None) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type="event", text="hello")), + TurnState(), + ) + assert not self.called + @pytest.mark.asyncio async def test_pattern_match(self): @self.app.message(re.compile(r"hello.*")) @@ -561,3 +587,243 @@ async def handler(context, state, continuation): TurnState(), ) assert self.called is expected + + +def _only_route(app: AgentApplication): + routes = list(app._route_list) + assert len(routes) == 1 + return routes[0] + + +async def _noop(context: TurnContext, state: TurnState): + pass + + +class TestEventRoute: + def setup_method(self): + self.app = _make_app() + + @pytest.mark.parametrize( + ("select", "name", "expected"), + [ + ("ping", "ping", True), + ("ping", "pong", False), + (re.compile(r"app_.*"), "app_mention", True), + (re.compile(r"app_.*"), "message", False), + (["ping", re.compile(r"app_.*")], "app_mention", True), + (["ping", re.compile(r"app_.*")], "pong", False), + (None, "anything", True), + ], + ) + def test_selector_matches_event_name(self, select, name, expected): + self.app.event(select)(_noop) + + selector = _only_route(self.app).selector + assert ( + selector(_make_context(_make_activity(type="event", name=name))) is expected + ) + + def test_selector_ignores_non_event_activity(self): + self.app.event("ping")(_noop) + + selector = _only_route(self.app).selector + assert not selector( + _make_context(_make_activity(type=ActivityTypes.message, name="ping")) + ) + + @pytest.mark.parametrize( + ("select", "expected_rank"), + [(None, RouteRank.LAST), ("ping", RouteRank.DEFAULT)], + ) + def test_default_rank(self, select, expected_rank): + self.app.event(select)(_noop) + + assert _only_route(self.app).rank == expected_rank + + def test_explicit_rank_is_preserved_without_select(self): + self.app.event(rank=RouteRank.FIRST)(_noop) + + assert _only_route(self.app).rank == RouteRank.FIRST + + @pytest.mark.asyncio + async def test_dispatches_matching_event(self): + called = [] + + @self.app.event("ping") + async def handler(context: TurnContext, state: TurnState): + called.append(context.activity.name) + + await self.app._on_activity( + _make_context(_make_activity(type="event", name="ping")), TurnState() + ) + assert called == ["ping"] + + +class TestInvokeRoute: + def setup_method(self): + self.app = _make_app() + + @pytest.mark.parametrize( + ("select", "name", "expected"), + [ + ("task/fetch", "task/fetch", True), + ("task/fetch", "task/submit", False), + (re.compile(r"task/.*"), "task/submit", True), + (["a", "task/submit"], "task/submit", True), + (None, "anything", True), + ], + ) + def test_selector_matches_invoke_name(self, select, name, expected): + self.app.invoke(select)(_noop) + + selector = _only_route(self.app).selector + assert ( + selector( + _make_context(_make_activity(type=ActivityTypes.invoke, name=name)) + ) + is expected + ) + + def test_selector_ignores_non_invoke_activity(self): + self.app.invoke("task/fetch")(_noop) + + selector = _only_route(self.app).selector + assert not selector( + _make_context(_make_activity(type="event", name="task/fetch")) + ) + + @pytest.mark.parametrize( + ("select", "expected_rank"), + [(None, RouteRank.LAST), ("task/fetch", RouteRank.DEFAULT)], + ) + def test_default_rank(self, select, expected_rank): + self.app.invoke(select)(_noop) + + assert _only_route(self.app).rank == expected_rank + + +class TestMessageRouteRank: + @pytest.mark.parametrize( + ("select", "kwargs", "expected_rank"), + [ + (None, {}, RouteRank.LAST), + ("hello", {}, RouteRank.DEFAULT), + (None, {"rank": RouteRank.FIRST}, RouteRank.FIRST), + ], + ) + def test_rank(self, select, kwargs, expected_rank): + app = _make_app() + app.message(select, **kwargs)(_noop) + + assert _only_route(app).rank == expected_rank + + @pytest.mark.asyncio + async def test_catch_all_registered_first_does_not_shadow_specific_route(self): + app = _make_app() + calls = [] + + @app.message() + async def catch_all(context: TurnContext, state: TurnState): + calls.append("catch_all") + + @app.message("hello") + async def specific(context: TurnContext, state: TurnState): + calls.append("specific") + + await app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")), + TurnState(), + ) + assert calls == ["specific"] + + +def _members_added_activity(**kwargs) -> Activity: + return _make_activity( + type=ActivityTypes.conversation_update, members_added=[{"id": "m"}], **kwargs + ) + + +def _members_removed_activity(**kwargs) -> Activity: + return _make_activity( + type=ActivityTypes.conversation_update, members_removed=[{"id": "m"}], **kwargs + ) + + +def _reactions_added_activity(**kwargs) -> Activity: + return _make_activity( + type=ActivityTypes.message_reaction, + reactions_added=[{"type": "like"}], + **kwargs, + ) + + +def _reactions_removed_activity(**kwargs) -> Activity: + return _make_activity( + type=ActivityTypes.message_reaction, + reactions_removed=[{"type": "like"}], + **kwargs, + ) + + +_SHORTCUT_ROUTE_CASES = [ + ("members_added", _members_added_activity, _members_removed_activity), + ("members_removed", _members_removed_activity, _members_added_activity), + ("message_reaction_added", _reactions_added_activity, _reactions_removed_activity), + ( + "message_reactions_removed", + _reactions_removed_activity, + _reactions_added_activity, + ), + ( + "installation_update", + lambda **kw: _make_activity(type=ActivityTypes.installation_update, **kw), + lambda **kw: _make_activity(type=ActivityTypes.message, **kw), + ), + ( + "end_of_conversation", + lambda **kw: _make_activity(type=ActivityTypes.end_of_conversation, **kw), + lambda **kw: _make_activity(type=ActivityTypes.message, **kw), + ), +] + + +class TestShortcutRoutes: + @pytest.mark.parametrize(("route_name", "matching", "other"), _SHORTCUT_ROUTE_CASES) + def test_selector_matches_only_its_activity(self, route_name, matching, other): + app = _make_app() + getattr(app, route_name)()(_noop) + + selector = _only_route(app).selector + assert selector(_make_context(matching())) + assert not selector(_make_context(other())) + + @pytest.mark.parametrize(("route_name", "matching", "other"), _SHORTCUT_ROUTE_CASES) + def test_selector_filters_by_channel_id(self, route_name, matching, other): + app = _make_app() + getattr(app, route_name)(channel_id="msteams")(_noop) + + selector = _only_route(app).selector + assert selector(_make_context(matching(channel_id="msteams:copilot"))) + assert not selector(_make_context(matching(channel_id="webchat"))) + + @pytest.mark.parametrize(("route_name", "matching", "other"), _SHORTCUT_ROUTE_CASES) + def test_forwards_auth_handlers_and_rank(self, route_name, matching, other): + app = _make_app() + getattr(app, route_name)(auth_handlers=["graph"], rank=RouteRank.FIRST)(_noop) + + route = _only_route(app) + assert route.auth_handlers == ["graph"] + assert route.rank == RouteRank.FIRST + + @pytest.mark.asyncio + @pytest.mark.parametrize(("route_name", "matching", "other"), _SHORTCUT_ROUTE_CASES) + async def test_dispatches_matching_activity(self, route_name, matching, other): + app = _make_app() + calls = [] + + @getattr(app, route_name)() + async def handler(context: TurnContext, state: TurnState): + calls.append(context.activity.type) + + await app._on_activity(_make_context(matching()), TurnState()) + assert calls == [matching().type] diff --git a/tests/hosting_msteams/test_routing_integration.py b/tests/hosting_msteams/test_routing_integration.py index e21dffe4c..6f78f0445 100644 --- a/tests/hosting_msteams/test_routing_integration.py +++ b/tests/hosting_msteams/test_routing_integration.py @@ -289,6 +289,8 @@ def setup_method(self): "conversation_update", "message_reaction", "message_update", + "installation_update", + "end_of_conversation", "handoff", ], ) @@ -327,6 +329,12 @@ async def handler(context, state, continuation=None): "type": ActivityTypes.message_update, "channel_data": {"event_type": "editMessage"}, } + elif route_name == "installation_update": + self.ext.installation_update()(handler) + route_values = {"type": ActivityTypes.installation_update} + elif route_name == "end_of_conversation": + self.ext.end_of_conversation()(handler) + route_values = {"type": ActivityTypes.end_of_conversation} else: self.ext.handoff()(handler) route_values = { diff --git a/tests/hosting_slack/test_slack_agent_extension.py b/tests/hosting_slack/test_slack_agent_extension.py index 2b3f57848..55f4fe05e 100644 --- a/tests/hosting_slack/test_slack_agent_extension.py +++ b/tests/hosting_slack/test_slack_agent_extension.py @@ -16,6 +16,7 @@ ChannelAccount, ChannelId, ConversationAccount, + ConversationUpdateTypes, DeliveryModes, ) from microsoft_agents.hosting.core import ( @@ -34,9 +35,13 @@ from microsoft_agents.hosting.slack.api import SlackApi, SlackResponse -def _make_app() -> MagicMock: - app = MagicMock(spec=AgentApplication) +def _make_app() -> AgentApplication: + app = AgentApplication( + storage=MemoryStorage(), + connection_manager=MagicMock(spec=Connections), + ) app.routes = [] + original_add_route = app.add_route def _add_route( selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None @@ -50,8 +55,15 @@ def _add_route( auth_handlers=auth_handlers, ) ) + original_add_route( + selector, + handler, + is_invoke=is_invoke, + rank=rank, + auth_handlers=auth_handlers, + ) - app.add_route.side_effect = _add_route + app.add_route = _add_route return app @@ -104,6 +116,9 @@ async def handler(context, state): sel = self.app.routes[-1]["selector"] assert sel(_make_context(ActivityTypes.message, text="hello")) + assert sel( + _make_context(ActivityTypes.message, channel_id="slack:sub", text="hello") + ) assert not sel(_make_context(ActivityTypes.message, text="bye")) def test_regex_text_match(self): @@ -246,6 +261,17 @@ def test_alias_forwards_registration_options(self, route_name, select): assert self.app.routes[-1]["auth_handlers"] == auth_handlers assert self.app.routes[-1]["rank"] == RouteRank.FIRST + @pytest.mark.parametrize("route_name", ["message", "event"]) + def test_alias_without_select_defaults_to_last_rank(self, route_name): + register = ( + self.slack.on_message if route_name == "message" else self.slack.on_event + ) + + with pytest.deprecated_call(): + register()(AsyncMock()) + + assert self.app.routes[-1]["rank"] == RouteRank.LAST + class TestCall: @pytest.mark.asyncio @@ -655,3 +681,146 @@ async def test_create_stream_accepts_explicit_timestamp_without_event_timestamp( }, "", ) + + +def _make_activity_context(channel_id: str = "slack", **activity_fields) -> TurnContext: + activity = Activity( + channel_id=channel_id, + service_url="https://example.invalid", + conversation=ConversationAccount(id="conversation"), + from_property=ChannelAccount(id="user"), + recipient=ChannelAccount(id="bot"), + **activity_fields, + ) + return TurnContext(ChannelServiceAdapter(None), activity) + + +_APP_ROUTE_CASES = [ + ( + "conversation_update", + (ConversationUpdateTypes.MEMBERS_ADDED,), + dict( + type=ActivityTypes.conversation_update, + members_added=[ChannelAccount(id="new-user")], + ), + ), + ( + "members_added", + (), + dict( + type=ActivityTypes.conversation_update, + members_added=[ChannelAccount(id="new-user")], + ), + ), + ( + "members_removed", + (), + dict( + type=ActivityTypes.conversation_update, + members_removed=[ChannelAccount(id="old-user")], + ), + ), + ( + "installation_update", + (), + dict(type=ActivityTypes.installation_update), + ), +] + + +class TestAppRouteHooks: + @pytest.mark.parametrize("route_name, route_args, fields", _APP_ROUTE_CASES) + def test_selector_matches_only_slack(self, route_name, route_args, fields): + app = _make_app() + slack = SlackAgentExtension(app) + + async def handler(context, state): + pass + + assert getattr(slack, route_name)(*route_args)(handler) is handler + + selector = app.routes[-1]["selector"] + assert selector(_make_activity_context(**fields)) + assert selector(_make_activity_context(channel_id="slack:sub", **fields)) + assert not selector(_make_activity_context(channel_id="msteams", **fields)) + + @pytest.mark.parametrize("route_name, route_args, fields", _APP_ROUTE_CASES) + def test_selector_rejects_non_matching_activity( + self, route_name, route_args, fields + ): + app = _make_app() + slack = SlackAgentExtension(app) + + @getattr(slack, route_name)(*route_args) + async def handler(context, state): + pass + + selector = app.routes[-1]["selector"] + assert not selector(_make_activity_context(type=ActivityTypes.message)) + + @pytest.mark.parametrize( + "route_name, other_fields", + [ + ( + "members_added", + dict( + type=ActivityTypes.conversation_update, + members_removed=[ChannelAccount(id="old-user")], + ), + ), + ( + "members_removed", + dict( + type=ActivityTypes.conversation_update, + members_added=[ChannelAccount(id="new-user")], + ), + ), + ], + ) + def test_member_routes_reject_opposite_update(self, route_name, other_fields): + app = _make_app() + slack = SlackAgentExtension(app) + + @getattr(slack, route_name)() + async def handler(context, state): + pass + + selector = app.routes[-1]["selector"] + assert not selector(_make_activity_context(**other_fields)) + + @pytest.mark.parametrize("route_name, route_args, fields", _APP_ROUTE_CASES) + def test_registration_forwards_auth_handlers_and_rank( + self, route_name, route_args, fields + ): + app = _make_app() + slack = SlackAgentExtension(app) + + @getattr(slack, route_name)( + *route_args, auth_handlers=["graph"], rank=RouteRank.FIRST + ) + async def handler(context, state): + pass + + route = app.routes[-1] + assert route["auth_handlers"] == ["graph"] + assert route["rank"] == RouteRank.FIRST + + @pytest.mark.asyncio + @pytest.mark.parametrize("route_name, route_args, fields", _APP_ROUTE_CASES) + async def test_handler_receives_slack_context(self, route_name, route_args, fields): + app = _make_app() + slack = SlackAgentExtension(app) + context = _make_activity_context(**fields) + state = TurnState() + + async def handler(slack_context, received_state): + assert isinstance(slack_context, SlackTurnContext) + assert isinstance(slack_context.activity, SlackActivity) + assert received_state is state + + delegate = AsyncMock(side_effect=handler) + getattr(slack, route_name)(*route_args)(delegate) + + await app.routes[-1]["handler"](context, state) + + delegate.assert_awaited_once() From 5f329e82b37d205d39ca331c0c3e0142ff5833d0 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 13:54:01 -0700 Subject: [PATCH 07/13] ChannelId.includes method added --- .../microsoft_agents/activity/channel_id.py | 48 +++++++++++++ .../hosting/core/app/_selector.py | 12 +--- .../hosting/core/app/agent_application.py | 8 ++- .../hosting/msteams/teams_agent_extension.py | 26 ++++++- tests/activity/test_channel_id.py | 69 +++++++++++++++++++ 5 files changed, 150 insertions(+), 13 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py index 270399b63..5072047a1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py @@ -136,3 +136,51 @@ def get_channel(channel_id: str | ChannelId | None) -> str | None: channel_id = channel_id.strip() parsed = channel_id.split(":", 1)[0].strip() if ":" in channel_id else None return parsed or channel_id + + def includes(self, other: str | ChannelId | None) -> bool: + """Check whether this channel ID, used as a filter, includes another channel ID. + + The comparison is directional: ``self`` is the filter and ``other`` is the + candidate. Matching rules: + + - The channel must be equal, unless this channel is the wildcard ``*``. + - A filter without a sub-channel (e.g. ``msteams``) includes the bare channel + and every sub-channel of it (e.g. ``msteams:copilot``). + - A filter with the wildcard sub-channel (e.g. ``msteams:*``) behaves the same + as a filter without a sub-channel. + - A filter with a specific sub-channel (e.g. ``msteams:copilot``) includes only + that exact sub-channel. + + Comparisons are case-sensitive. ``None`` and invalid candidates are never + included. + + Example:: + + ChannelId("msteams").includes("msteams:copilot") # True + ChannelId("msteams:copilot").includes("msteams") # False + ChannelId("*").includes("slack") # True + + :param other: The candidate channel ID to check. + :return: True if the candidate is included by this channel ID, False otherwise. + """ + + if other is None: + return False + + candidate: ChannelId + try: + candidate = ChannelId(other) + except Exception: + return False + + if self.channel != "*" and self.channel != candidate.channel: + return False + + if ( + self.sub_channel + and self.sub_channel != "*" + and self.sub_channel != candidate.sub_channel + ): + return False + + return True diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py index 016d22c08..fe7ba462a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py @@ -89,17 +89,7 @@ def __selector(context: TurnContext) -> bool: """Selector function that checks if the given channel ID matches the selection criteria.""" if context.activity.channel_id is None: return False - if norm_channel_id.sub_channel: - # if passed in a sub-channel, the incoming activity must also come from - # the sub-channel - return context.activity.channel_id == norm_channel_id - else: - # if passed in only a parent channel, only check the parent channel of - # the incoming activity - return ( - ChannelId.get_channel(context.activity.channel_id) - == norm_channel_id.channel - ) + return norm_channel_id.includes(context.activity.channel_id) return __selector 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 555340243..1c376bf2c 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 @@ -511,6 +511,10 @@ def invoke( if select is None and "rank" not in kwargs: kwargs["rank"] = RouteRank.LAST + if "is_invoke" in kwargs: + if kwargs["is_invoke"] is False: + raise ValueError("Cannot set is_invoke to False for an invoke handler.") + selector = _all_selector( _invoke_event_selector(select), _channel_selector(channel_id), @@ -520,7 +524,9 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering invoke handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" ) - self.add_route(selector, func, auth_handlers=auth_handlers, **kwargs) + self.add_route( + selector, func, auth_handlers=auth_handlers, is_invoke=True, **kwargs + ) return func return __call diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 4378a1645..7ad931048 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -33,9 +33,10 @@ TurnContext, ) from microsoft_agents.hosting.core.app._type_defs import ( - RouteHandler, HandoffHandler, + RouteHandler, StateT, + TextSelector, ) from .channel import Channel @@ -244,6 +245,29 @@ def message( ) ) + def event( + self, + select: TextSelector | None = None, + *, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for event activities matching *select*. + + :param select: A literal string, regex pattern, or list of either to match against + the event name. + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.event( + select, + auth_handlers=auth_handlers, + channel_id=Channels.msteams, + **kwargs, + ) + ) + def conversation_update( self, type: ConversationUpdateTypes, diff --git a/tests/activity/test_channel_id.py b/tests/activity/test_channel_id.py index 78adb52a9..04747699d 100644 --- a/tests/activity/test_channel_id.py +++ b/tests/activity/test_channel_id.py @@ -75,3 +75,72 @@ def test_init_errors(self): ChannelId("", channel=" ", sub_channel="support") with pytest.raises(Exception): ChannelId(channel="a:b", sub_channel="support") + + +class TestChannelIdIncludes: + @pytest.mark.parametrize( + ("filter_id", "candidate", "expected"), + [ + # Exact channel + ("msteams", "msteams", True), + ("msteams", "webchat", False), + # Parent filter includes every sub-channel + ("msteams", "msteams:copilot", True), + ("msteams", "msteams:meeting", True), + # Specific sub-channel filter is exact + ("msteams:copilot", "msteams:copilot", True), + ("msteams:copilot", "msteams:meeting", False), + ("msteams:copilot", "msteams", False), + ("msteams:copilot", "slack:copilot", False), + # Wildcard sub-channel behaves like the parent filter + ("msteams:*", "msteams", True), + ("msteams:*", "msteams:copilot", True), + ("msteams:*", "slack:copilot", False), + # Wildcard channel + ("*", "msteams", True), + ("*", "msteams:copilot", True), + ("*:copilot", "slack:copilot", True), + ("*:copilot", "msteams:copilot", True), + ("*:copilot", "msteams:meeting", False), + ("*:copilot", "msteams", False), + ("*:*", "slack:anything", True), + # Case-sensitive + ("msteams", "MSTeams", False), + ("msteams:copilot", "msteams:COPILOT", False), + # Whitespace is normalized by ChannelId parsing + ("msteams", " msteams:copilot ", True), + ], + ) + def test_includes(self, filter_id, candidate, expected): + assert ChannelId(filter_id).includes(candidate) is expected + + @pytest.mark.parametrize( + ("filter_id", "candidate", "expected"), + [ + ("msteams", ChannelId("msteams:copilot"), True), + ("msteams:copilot", ChannelId(channel="msteams"), False), + ("msteams", ChannelId(channel="msteams", sub_channel="copilot"), True), + ], + ) + def test_includes_accepts_channel_id_candidate( + self, filter_id, candidate, expected + ): + assert ChannelId(filter_id).includes(candidate) is expected + + @pytest.mark.parametrize("candidate", [None, "", " ", ":copilot"]) + def test_includes_rejects_missing_or_invalid_candidate(self, candidate): + assert ChannelId("msteams").includes(candidate) is False + assert ChannelId("*").includes(candidate) is False + + def test_includes_is_directional(self): + parent = ChannelId("msteams") + child = ChannelId("msteams:copilot") + + assert parent.includes(child) + assert not child.includes(parent) + + def test_includes_does_not_change_equality(self): + parent = ChannelId("msteams") + + assert parent.includes("msteams:copilot") + assert parent != "msteams:copilot" From aad022b9992becc5ec9ddca5b69d0e28fcb035f0 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 15:05:27 -0700 Subject: [PATCH 08/13] Addressing PR feedback --- .../microsoft_agents/hosting/core/app/_selector.py | 6 ++++-- .../hosting/core/app/agent_application.py | 11 +++++++++-- .../hosting/slack/slack_agent_extension.py | 14 ++++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py index fe7ba462a..4ef50ceb0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py @@ -36,6 +36,8 @@ def _event_selector(event_selector: TextSelector | None) -> RouteSelector: def __selector(context: TurnContext) -> bool: """Selector function that checks if the given event name matches the selection criteria.""" + if not event_selector: + return True return context.activity.type == ActivityTypes.event and _matches_text( event_selector, context.activity.name or "" ) @@ -52,8 +54,8 @@ def _invoke_event_selector(event_selector: TextSelector | None) -> RouteSelector def __selector(context: TurnContext) -> bool: """Selector function that checks if the given invoke event name matches the selection criteria.""" - if context.activity.name is None: - return False + if not event_selector: + return True return context.activity.type == ActivityTypes.invoke and _matches_text( event_selector, context.activity.name or "" ) 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 1c376bf2c..5b4228e1c 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 @@ -508,12 +508,14 @@ def invoke( :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - if select is None and "rank" not in kwargs: - kwargs["rank"] = RouteRank.LAST if "is_invoke" in kwargs: if kwargs["is_invoke"] is False: raise ValueError("Cannot set is_invoke to False for an invoke handler.") + del kwargs["is_invoke"] + + if select is None and "rank" not in kwargs: + kwargs["rank"] = RouteRank.LAST selector = _all_selector( _invoke_event_selector(select), @@ -932,6 +934,11 @@ async def on_handoff(context: TurnContext, state: TurnState, continuation: str): :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ + if "is_invoke" in kwargs: + if kwargs["is_invoke"] is False: + raise ValueError("Cannot set is_invoke to False for an invoke handler.") + del kwargs["is_invoke"] + selector = _all_selector( _invoke_event_selector("handoff/action"), _channel_selector(channel_id), diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index 1b71c17e7..6e51d1e18 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -5,8 +5,9 @@ from __future__ import annotations -from typing import Any, Callable, Generic, TypeVar +import warnings +from typing import Any, Callable, Generic, TypeVar from typing_extensions import deprecated from microsoft_agents.activity import ( @@ -217,6 +218,7 @@ def event( self, select: TextSelector | None = None, *, + event_name: str | None = None, auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: @@ -224,10 +226,18 @@ def event( :param select: Optional text selector to match the incoming event name. :type select: TextSelector | None + :param event_name: (Deprecated) Optional specific event name to match. + :type event_name: str | None :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ + if event_name is not None: + warnings.warn( + "The `event_name` parameter is deprecated; use `select` instead.", + DeprecationWarning, + stacklevel=2, + ) return self._wrap_decorator( self._app.event( select, auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs @@ -239,7 +249,7 @@ def event( ) def on_event( self, - select: TextSelector | None = None, + event_name: TextSelector | None = None, *, auth_handlers: list[str] | None = None, **kwargs, From 87726749e6ce0c81fcbcb95547c26a1fdbfb2abd Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 15:25:40 -0700 Subject: [PATCH 09/13] Fixing small issue --- .../hosting/core/app/_selector.py | 12 +++++----- .../hosting/slack/slack_agent_extension.py | 2 +- .../app/test_agent_application_routes.py | 22 +++++++++++++++++++ .../test_slack_agent_extension.py | 11 ++++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py index 4ef50ceb0..8961737da 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py @@ -36,9 +36,9 @@ def _event_selector(event_selector: TextSelector | None) -> RouteSelector: def __selector(context: TurnContext) -> bool: """Selector function that checks if the given event name matches the selection criteria.""" - if not event_selector: - return True - return context.activity.type == ActivityTypes.event and _matches_text( + if not context.activity.type == ActivityTypes.event: + return False + return event_selector is None or _matches_text( event_selector, context.activity.name or "" ) @@ -54,9 +54,9 @@ def _invoke_event_selector(event_selector: TextSelector | None) -> RouteSelector def __selector(context: TurnContext) -> bool: """Selector function that checks if the given invoke event name matches the selection criteria.""" - if not event_selector: - return True - return context.activity.type == ActivityTypes.invoke and _matches_text( + if not context.activity.type == ActivityTypes.invoke: + return False + return event_selector is None or _matches_text( event_selector, context.activity.name or "" ) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index 6e51d1e18..bb92d7863 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -255,7 +255,7 @@ def on_event( **kwargs, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Deprecated alias for :meth:`event`; emits ``DeprecationWarning``.""" - return self.event(select, auth_handlers=auth_handlers, **kwargs) + return self.event(event_name, auth_handlers=auth_handlers, **kwargs) # ── AgentApplication route hooks ─────────────────────────────────────── diff --git a/tests/hosting_core/app/test_agent_application_routes.py b/tests/hosting_core/app/test_agent_application_routes.py index 070499ba6..d2bad5e54 100644 --- a/tests/hosting_core/app/test_agent_application_routes.py +++ b/tests/hosting_core/app/test_agent_application_routes.py @@ -631,6 +631,17 @@ def test_selector_ignores_non_event_activity(self): _make_context(_make_activity(type=ActivityTypes.message, name="ping")) ) + @pytest.mark.parametrize( + "activity_type", [ActivityTypes.message, ActivityTypes.invoke] + ) + def test_none_select_ignores_non_event_activity(self, activity_type): + self.app.event()(_noop) + + selector = _only_route(self.app).selector + assert not selector( + _make_context(_make_activity(type=activity_type, name="ping")) + ) + @pytest.mark.parametrize( ("select", "expected_rank"), [(None, RouteRank.LAST), ("ping", RouteRank.DEFAULT)], @@ -692,6 +703,17 @@ def test_selector_ignores_non_invoke_activity(self): _make_context(_make_activity(type="event", name="task/fetch")) ) + @pytest.mark.parametrize( + "activity_type", [ActivityTypes.message, ActivityTypes.event] + ) + def test_none_select_ignores_non_invoke_activity(self, activity_type): + self.app.invoke()(_noop) + + selector = _only_route(self.app).selector + assert not selector( + _make_context(_make_activity(type=activity_type, name="task/fetch")) + ) + @pytest.mark.parametrize( ("select", "expected_rank"), [(None, RouteRank.LAST), ("task/fetch", RouteRank.DEFAULT)], diff --git a/tests/hosting_slack/test_slack_agent_extension.py b/tests/hosting_slack/test_slack_agent_extension.py index 55f4fe05e..fea080a4c 100644 --- a/tests/hosting_slack/test_slack_agent_extension.py +++ b/tests/hosting_slack/test_slack_agent_extension.py @@ -272,6 +272,17 @@ def test_alias_without_select_defaults_to_last_rank(self, route_name): assert self.app.routes[-1]["rank"] == RouteRank.LAST + def test_on_event_accepts_event_name_keyword(self): + with pytest.deprecated_call(): + + @self.slack.on_event(event_name="app_mention") + async def handler(context, state): + return True + + sel = self.app.routes[-1]["selector"] + assert sel(_make_context(ActivityTypes.event, name="app_mention")) + assert not sel(_make_context(ActivityTypes.event, name="other")) + class TestCall: @pytest.mark.asyncio From cd7750bc252d0d5211563c72dc778d82ca07e529 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 16 Oct 2026 11:56:25 -0700 Subject: [PATCH 10/13] Fixing merge conflicts --- .../microsoft_agents/hosting/core/app/agent_application.py | 1 - .../hosting/msteams/teams_agent_extension.py | 6 ------ tests/hosting_core/app/test_agent_application_routes.py | 1 - 3 files changed, 8 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 e47167712..48dc3b2fd 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 @@ -640,7 +640,6 @@ async def on_channel_created(context: TurnContext, state: TurnState): ) def __selector(context: TurnContext): - if not _simple_selector(context): if not _simple_selector(context): return False diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 15b538e3b..5d394702d 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -220,12 +220,6 @@ def activity( channel_id=Channels.msteams, **kwargs, ) - self._app.activity( - activity_type, - auth_handlers=auth_handlers, - channel_id=Channels.msteams, - **kwargs, - ) ) def message( diff --git a/tests/hosting_core/app/test_agent_application_routes.py b/tests/hosting_core/app/test_agent_application_routes.py index 134629a1a..773cbd554 100644 --- a/tests/hosting_core/app/test_agent_application_routes.py +++ b/tests/hosting_core/app/test_agent_application_routes.py @@ -39,7 +39,6 @@ def _make_app() -> AgentApplication[TurnState]: ) -def _make_activity(channel_id: str = "test", **kwargs) -> Activity: def _make_activity(channel_id: str = "test", **kwargs) -> Activity: return Activity( channel_id=channel_id, From f76cb48f9216cd68bec00fb04f31cd0e05287822 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 16 Oct 2026 12:04:42 -0700 Subject: [PATCH 11/13] Fixing merge conflicts (again) --- .../hosting/core/app/agent_application.py | 66 ------------------- .../hosting/core/app/state/turn_state.py | 15 ----- .../hosting/msteams/teams_agent_extension.py | 9 --- .../app/test_agent_application_routes.py | 3 +- 4 files changed, 2 insertions(+), 91 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 48dc3b2fd..c2420d672 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 @@ -29,7 +29,6 @@ Activity, ActivityTypes, ChannelId, - ChannelId, ConversationUpdateTypes, MessageReactionTypes, MessageUpdateTypes, @@ -52,7 +51,6 @@ from ._type_defs import ( HandoffHandler, - RouteSelector, _RouteDecorator, RouteHandler, RouteSelector, @@ -114,13 +112,10 @@ def __init__( :param options: Configuration options for the application. :type options: :class:`microsoft_agents.hosting.core.app.app_options.ApplicationOptions` | None - :type options: :class:`microsoft_agents.hosting.core.app.app_options.ApplicationOptions` | None :param connection_manager: OAuth connection manager. :type connection_manager: :class:`microsoft_agents.hosting.core.authorization.Connections` | None - :type connection_manager: :class:`microsoft_agents.hosting.core.authorization.Connections` | None :param authorization: Authorization manager for handling authentication flows. :type authorization: :class:`microsoft_agents.hosting.core.app.oauth.Authorization` | None - :type authorization: :class:`microsoft_agents.hosting.core.app.oauth.Authorization` | None :param kwargs: Additional configuration parameters. :type kwargs: Any """ @@ -360,7 +355,6 @@ def add_route( is_agentic: bool = False, rank: RouteRank = RouteRank.DEFAULT, auth_handlers: list[str] | None = None, - auth_handlers: list[str] | None = None, ) -> None: """Adds a new route to the application. @@ -379,7 +373,6 @@ def add_route( :type rank: :class:`microsoft_agents.hosting.core.app._routes.route_rank.RouteRank`, Optional :param auth_handlers: A list of authentication handler IDs to use for this route, defaults to None :type auth_handlers: list[str] | None, Optional - :type auth_handlers: list[str] | None, Optional :raises ApplicationError: If the selector or handler are not valid. """ if not selector or not handler: @@ -402,8 +395,6 @@ def activity( *, channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, - channel_id: str | ChannelId | None = None, - auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -421,13 +412,9 @@ async def on_event(context: TurnContext, state: TurnState): :type activity_type: str | microsoft_agents.activity.ActivityTypes | list[str | microsoft_agents.activity.ActivityTypes] :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: list[str] | None - :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - selector = _SimpleRouteSelector( - activity_type=activity_type, channel_id=channel_id - ) selector = _SimpleRouteSelector( activity_type=activity_type, channel_id=channel_id ) @@ -551,8 +538,6 @@ def message( *, channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, - channel_id: str | ChannelId | None = None, - auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -572,7 +557,6 @@ async def on_hi_message(context: TurnContext, state: TurnState): :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: list[str] | None - :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ if select is None and "rank" not in kwargs: @@ -589,7 +573,6 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: f"Registering message handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" ) self.add_route(selector, func, auth_handlers=auth_handlers, **kwargs) - self.add_route(selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call @@ -600,8 +583,6 @@ def conversation_update( *, channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, - channel_id: str | ChannelId | None = None, - auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -619,11 +600,8 @@ async def on_channel_created(context: TurnContext, state: TurnState): :type type: microsoft_agents.activity.ConversationUpdateTypes | str :param channel_id: Optional channel ID to match the incoming message. :type channel_id: str | ChannelId | None - :param channel_id: Optional channel ID to match the incoming message. - :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: list[str] | None - :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ @@ -634,11 +612,6 @@ async def on_channel_created(context: TurnContext, state: TurnState): channel_id=channel_id, ) - _simple_selector = _SimpleRouteSelector( - activity_type=ActivityTypes.conversation_update, - channel_id=channel_id, - ) - def __selector(context: TurnContext): if not _simple_selector(context): return False @@ -743,8 +716,6 @@ def message_reaction( *, channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, - channel_id: str | ChannelId | None = None, - auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -762,11 +733,8 @@ async def on_reactions_added(context: TurnContext, state: TurnState): :type type: microsoft_agents.activity.MessageReactionTypes | str :param channel_id: Optional channel ID to match the incoming message. :type channel_id: str | ChannelId | None - :param channel_id: Optional channel ID to match the incoming message. - :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: list[str] | None - :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ @@ -777,13 +745,7 @@ async def on_reactions_added(context: TurnContext, state: TurnState): activity_type=ActivityTypes.message_reaction, ) - _simple_selector = _SimpleRouteSelector( - channel_id=channel_id, - activity_type=ActivityTypes.message_reaction, - ) - def __selector(context: TurnContext): - if not _simple_selector(context): if not _simple_selector(context): return False @@ -858,8 +820,6 @@ def message_update( *, channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, - channel_id: str | ChannelId | None = None, - auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -877,11 +837,8 @@ async def on_edit_message(context: TurnContext, state: TurnState): :type type: microsoft_agents.activity.MessageUpdateTypes | str :param channel_id: Optional channel ID to filter the incoming activities. :type channel_id: str | ChannelId | None - :param channel_id: Optional channel ID to filter the incoming activities. - :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: list[str] | None - :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ @@ -889,15 +846,10 @@ async def on_edit_message(context: TurnContext, state: TurnState): check_channel = _channel_selector(channel_id) - check_channel = _channel_selector(channel_id) - def __selector(context: TurnContext): if not check_channel(context): return False - if not check_channel(context): - return False - if update_type == "editMessage": if ( context.activity.type == ActivityTypes.message_update @@ -942,8 +894,6 @@ def handoff( *, channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, - channel_id: str | ChannelId | None = None, - auth_handlers: list[str] | None = None, **kwargs, ) -> HandoffHandler[StateT]: ... @@ -953,20 +903,15 @@ def handoff( *, channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, - channel_id: str | ChannelId | None = None, - auth_handlers: list[str] | None = None, **kwargs: Any, ) -> _RouteDecorator[HandoffHandler[StateT]]: ... def handoff( self, func: HandoffHandler[StateT] | None = None, - func: HandoffHandler[StateT] | None = None, *, channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, - channel_id: str | ChannelId | None = None, - auth_handlers: list[str] | None = None, **kwargs, ) -> HandoffHandler[StateT] | _RouteDecorator[HandoffHandler[StateT]]: """ @@ -983,12 +928,8 @@ async def on_handoff(context: TurnContext, state: TurnState, continuation: str): :type func: HandoffHandler[StateT] | None :param channel_id: Optional channel ID to filter the incoming activities. :type channel_id: str | ChannelId | None - :type func: HandoffHandler[StateT] | None - :param channel_id: Optional channel ID to filter the incoming activities. - :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: list[str] | None - :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ @@ -1024,7 +965,6 @@ async def __handler(context: TurnContext, state: StateT): f"Registering handoff handler for route handler {func.__name__} with auth handlers: {auth_handlers}" ) - self.add_route(selector, __handler, auth_handlers=auth_handlers, **kwargs) self.add_route(selector, __handler, auth_handlers=auth_handlers, **kwargs) return func @@ -1034,8 +974,6 @@ async def __handler(context: TurnContext, state: StateT): def on_sign_in_success( self, func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] - ) -> Callable[[TurnContext, StateT, str | None], Awaitable[None]]: - self, func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] ) -> Callable[[TurnContext, StateT, str | None], Awaitable[None]]: """ Register a callback that executes when a user successfully signs in. @@ -1049,7 +987,6 @@ async def sign_in_success(context: TurnContext, state: TurnState, connection_id: :param func: Callable that handles the sign-in success event. :type func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] - :type func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] :raises ApplicationError: If authorization services are not configured. """ @@ -1071,8 +1008,6 @@ async def sign_in_success(context: TurnContext, state: TurnState, connection_id: def on_sign_in_failure( self, func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] - ) -> Callable[[TurnContext, StateT, str | None], Awaitable[None]]: - self, func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] ) -> Callable[[TurnContext, StateT, str | None], Awaitable[None]]: """ Register a callback that executes when a user fails to sign in. @@ -1086,7 +1021,6 @@ async def sign_in_failure(context: TurnContext, state: TurnState, connection_id: :param func: Callable that handles the sign-in failure event. :type func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] - :type func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] :raises ApplicationError: If authorization services are not configured. """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/turn_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/turn_state.py index 0f17366b0..beebfbc68 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/turn_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/turn_state.py @@ -231,21 +231,6 @@ def add(self, agent_state: AgentState) -> "TurnState": self._scopes[agent_state.__class__.__name__] = agent_state return self - async def load(self, turn_context: TurnContext, force: bool = False) -> None: - """ - Loads all agent state records in parallel. - - Args: - turn_context: The turn context. - force: Whether data should be forced into cache. - """ - tasks = [ - bs.load(turn_context, force) - for bs in self._scopes.values() - if hasattr(bs, "load") - ] - await asyncio.gather(*tasks) - def clear(self, turn_context: TurnContext, scope: str = None) -> None: """ Clears a state scope. diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 5d394702d..7ad931048 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -285,9 +285,6 @@ def conversation_update( self._app.conversation_update( type, auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs ) - self._app.conversation_update( - type, auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs - ) ) def message_reaction( @@ -307,9 +304,6 @@ def message_reaction( self._app.message_reaction( type, auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs ) - self._app.message_reaction( - type, auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs - ) ) def message_update( @@ -381,9 +375,6 @@ def __call(func: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: return self._app.handoff( auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs )(wrap_teams_handoff_handler(func, self._app)) - return self._app.handoff( - auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs - )(wrap_teams_handoff_handler(func, self._app)) return __call diff --git a/tests/hosting_core/app/test_agent_application_routes.py b/tests/hosting_core/app/test_agent_application_routes.py index 773cbd554..5ba0720f0 100644 --- a/tests/hosting_core/app/test_agent_application_routes.py +++ b/tests/hosting_core/app/test_agent_application_routes.py @@ -41,7 +41,6 @@ def _make_app() -> AgentApplication[TurnState]: def _make_activity(channel_id: str = "test", **kwargs) -> Activity: return Activity( - channel_id=channel_id, channel_id=channel_id, conversation={"id": "conv1"}, from_property={"id": "user1"}, @@ -379,6 +378,7 @@ async def handler(context, state, continuation): result = self.app.handoff()(handler) assert result is handler + _only_route(self.app) def test_direct_style_returns_original_handler(self): async def handler(context, state, continuation): @@ -386,6 +386,7 @@ async def handler(context, state, continuation): result = self.app.handoff(handler) assert result is handler + _only_route(self.app) class TestRouteOrdering: From a281de33c958868e281be94156c85e37898cad25 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 16 Oct 2026 12:09:51 -0700 Subject: [PATCH 12/13] Updating extension starter sample code --- .../src/extension/extension.py | 67 ++++++------------- .../extension-starter/src/sample/app.py | 1 - 2 files changed, 22 insertions(+), 46 deletions(-) diff --git a/test_samples/extensions/extension-starter/src/extension/extension.py b/test_samples/extensions/extension-starter/src/extension/extension.py index 98d7c61da..83fc6b972 100644 --- a/test_samples/extensions/extension-starter/src/extension/extension.py +++ b/test_samples/extensions/extension-starter/src/extension/extension.py @@ -1,4 +1,5 @@ import logging +import re from typing import Callable, Generic, TypeVar from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse @@ -35,48 +36,37 @@ def __init__(self, app: AgentApplication[StateT]): """ self.app = app - # Allowing event decorators to accept **kwargs and passing - # **kwargs to app.add_route is recommended. + # Forward route options to the application's decorators. def _on_message_has_hello_event( self, **kwargs, ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: """Decorator for message activities that contain the word 'hello'. - This demonstrates a custom event selector and handler that extracts - additional data from the activity and passes it to the handler. + This uses the application's regex message selector and wraps the handler + to extract additional data from the activity. - :param kwargs: Additional keyword arguments to pass to app.add_route. + :param kwargs: Additional keyword arguments to pass to app.message. :return: A decorator that registers the route handler. Usage: @extension_agent.on_message_has_hello_event() async def handle_hello_event(context: TurnContext, state: StateT, event_data: CustomEventData): - await context.send_activity(f"Hello! You said: {event_data.text}") + await context.send_activity(f"Hello! User ID: {event_data.user_id}") """ - # the function the AgentApplication uses to determine if this route should be called - def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message - and "hello" in context.activity.text.lower() - ) + def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: + logger.debug("Registering route for message has hello event") - # the function that wraps the user's handler to extract custom data - def create_handler(handler: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: + @self.app.message( + re.compile(r".*hello.*", re.IGNORECASE | re.DOTALL), **kwargs + ) async def route_handler(context: TurnContext, state: StateT): custom_event_data = CustomEventData.from_context(context) - await handler(context, state, custom_event_data) + await func(context, state, custom_event_data) return route_handler - # the decorator that registers the route handler with the app - def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: - logger.debug("Registering route for message has hello event") - handler = create_handler(func) - self.app.add_route(route_selector, handler, **kwargs) - return handler - return __call def on_message_has_hello_event( @@ -99,33 +89,27 @@ def on_invoke_custom_event( ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: """Decorator for invoke activities with a specific custom event type. - This demonstrates a custom event selector and handler that extracts - additional data from the activity and passes it to the handler. + This uses the application's channel-scoped invoke selector and wraps + the handler to extract custom data and send an invoke response. :param custom_event_type: The custom event type to listen for. - :param kwargs: Additional keyword arguments to pass to app.add_route. + :param kwargs: Additional keyword arguments to pass to app.invoke. :return: A decorator that registers the route handler. Usage: @extension_agent.on_invoke_custom_event("my_custom_event") async def handle_custom_event(context: TurnContext, state: StateT, event_data: CustomEventData): - await context.send_activity(f"Received custom event with data: {event_data.data}") - return CustomEventResult(success=True) + return CustomEventResult(user_id=event_data.user_id, field=event_data.field) """ - # the function the AgentApplication uses to determine if this route should be called - def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.channel_id == MY_CHANNEL - and context.activity.name == f"invoke/{custom_event_type}" - ) + def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: + logger.debug("Registering route for custom event") - # the function that wraps the user's handler to extract custom data - # it also sends back an invoke response with the handler's result - def create_handler(handler: CustomRouteHandler) -> RouteHandler[StateT]: + @self.app.invoke( + f"invoke/{custom_event_type}", channel_id=MY_CHANNEL, **kwargs + ) async def route_handler(context: TurnContext, state: StateT): custom_event_data = CustomEventData.from_context(context) - result = await handler(context, state, custom_event_data) + result = await func(context, state, custom_event_data) if not result: result = CustomEventResult() @@ -141,11 +125,4 @@ async def route_handler(context: TurnContext, state: StateT): return route_handler - # the decorator that registers the route handler with the app - def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: - logger.debug("Registering route for custom event") - handler = create_handler(func) - self.app.add_route(route_selector, handler, is_invoke=True, **kwargs) - return handler - return __call diff --git a/test_samples/extensions/extension-starter/src/sample/app.py b/test_samples/extensions/extension-starter/src/sample/app.py index 0ad4b8fc4..62bc02d44 100644 --- a/test_samples/extensions/extension-starter/src/sample/app.py +++ b/test_samples/extensions/extension-starter/src/sample/app.py @@ -11,7 +11,6 @@ MemoryStorage, AgentApplication, TurnState, - MemoryStorage, RouteRank, TurnContext, ) From 87aa84e2339ddcc75db1aba31e0da53dc883fc39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Fri, 16 Oct 2026 12:11:37 -0700 Subject: [PATCH 13/13] Refactor is_invoke check in agent_application.py Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/hosting/core/app/agent_application.py | 7 +++---- 1 file changed, 3 insertions(+), 4 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 c2420d672..c3802357f 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 @@ -933,10 +933,9 @@ async def on_handoff(context: TurnContext, state: TurnState, continuation: str): :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - if "is_invoke" in kwargs: - if kwargs["is_invoke"] is False: - raise ValueError("Cannot set is_invoke to False for an invoke handler.") - del kwargs["is_invoke"] + if kwargs.get("is_invoke") is False: + raise ValueError("Cannot set is_invoke to False for an invoke handler.") + kwargs["is_invoke"] = True selector = _all_selector( _invoke_event_selector("handoff/action"),