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 1b76103b..3edf81e6 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 @@ -16,9 +16,9 @@ Callable, Generic, Optional, - Pattern, TypeVar, cast, + overload, ) from microsoft_agents.activity import ( @@ -312,6 +312,8 @@ async def on_event(context: TurnContext, state: TurnState): """ def __selector(context: TurnContext): + if isinstance(activity_type, list): + return context.activity.type in activity_type return activity_type == context.activity.type def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: @@ -325,7 +327,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: def message( self, - select: str | Pattern[str] | list[str | Pattern[str]], + select: str | re.Pattern[str] | list[str | re.Pattern[str]], *, auth_handlers: Optional[list[str]] = None, **kwargs, @@ -342,7 +344,7 @@ async def on_hi_message(context: TurnContext, state: TurnState): return True :param select: Literal text, compiled regex, or list of either used to match the incoming message. - :type select: str | Pattern[str] | list[str | Pattern[str]] + :type select: str | re.Pattern[str] | list[str | re.Pattern[str]] :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: Optional[list[str]] :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. @@ -353,9 +355,18 @@ def __selector(context: TurnContext): return False text = context.activity.text if context.activity.text else "" - if isinstance(select, Pattern): - hits = re.fullmatch(select, text) - return hits is not None + + 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 @@ -535,12 +546,39 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call + @overload + def handoff( + self, + func: Callable[[TurnContext, StateT, str], Awaitable[None]], + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> Callable[[TurnContext, StateT, str], Awaitable[None]]: ... + + @overload def handoff( - self, *, auth_handlers: Optional[list[str]] = None, **kwargs + self, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs: Any, ) -> Callable[ [Callable[[TurnContext, StateT, str], Awaitable[None]]], Callable[[TurnContext, StateT, str], Awaitable[None]], - ]: + ]: ... + + def handoff( + self, + func: Optional[Callable[[TurnContext, StateT, str], Awaitable[None]]] = None, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> ( + Callable[[TurnContext, StateT, str], Awaitable[None]] + | Callable[ + [Callable[[TurnContext, StateT, str], Awaitable[None]]], + Callable[[TurnContext, StateT, str], Awaitable[None]], + ] + ): """ Register a handler to hand off conversations from one copilot to another. @@ -551,6 +589,8 @@ def handoff( 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[Callable[[TurnContext, StateT, str], Awaitable[None]]] :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: Optional[list[str]] :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. @@ -566,24 +606,29 @@ def __call( func: Callable[[TurnContext, StateT, str], Awaitable[None]], ) -> Callable[[TurnContext, StateT, str], Awaitable[None]]: async def __handler(context: TurnContext, state: StateT): - if not context.activity.value: - return False - await func(context, state, context.activity.value["continuation"]) + if ( + isinstance(context.activity.value, dict) + and "continuation" in context.activity.value + ): + await func(context, state, context.activity.value["continuation"]) + else: + logger.warning("Invalid handoff action received") await context.send_activity( Activity( type=ActivityTypes.invoke_response, value=InvokeResponse(status=200), ) ) - return True logger.debug( f"Registering handoff handler for route handler {func.__name__} with auth handlers: {auth_handlers}" ) - self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) + self.add_route(__selector, __handler, auth_handlers=auth_handlers, **kwargs) return func + if func is not None: + return __call(func) return __call def on_sign_in_success( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index af71ffac..ac56b93d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -93,12 +93,12 @@ def copy_to(self, context: "TurnContext") -> None: setattr(context, attribute, getattr(self, attribute)) @property - def activity(self): + def activity(self) -> Activity: """ The received activity. :return: """ - return self._activity + return self._activity # type: ignore[return-value] @activity.setter def activity(self, value): diff --git a/tests/hosting_core/app/proactive/test_conversation_builder.py b/tests/hosting_core/app/proactive/test_conversation_builder.py index eab7ded1..931e44ea 100644 --- a/tests/hosting_core/app/proactive/test_conversation_builder.py +++ b/tests/hosting_core/app/proactive/test_conversation_builder.py @@ -11,7 +11,6 @@ ) from microsoft_agents.hosting.core.authorization import ClaimsIdentity - # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- diff --git a/tests/hosting_core/app/proactive/test_conversation_reference_builder.py b/tests/hosting_core/app/proactive/test_conversation_reference_builder.py index 4ab646e2..98d2bc4f 100644 --- a/tests/hosting_core/app/proactive/test_conversation_reference_builder.py +++ b/tests/hosting_core/app/proactive/test_conversation_reference_builder.py @@ -10,7 +10,6 @@ _service_url_for_channel, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/hosting_core/app/proactive/test_proactive.py b/tests/hosting_core/app/proactive/test_proactive.py index f3afa664..d7c73f51 100644 --- a/tests/hosting_core/app/proactive/test_proactive.py +++ b/tests/hosting_core/app/proactive/test_proactive.py @@ -26,7 +26,6 @@ from microsoft_agents.hosting.core.authorization import ClaimsIdentity from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/hosting_core/app/test_agent_application_routes.py b/tests/hosting_core/app/test_agent_application_routes.py new file mode 100644 index 00000000..80a448bf --- /dev/null +++ b/tests/hosting_core/app/test_agent_application_routes.py @@ -0,0 +1,411 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import re + +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import MemoryStorage, TurnContext +from microsoft_agents.hosting.core.app import ( + AgentApplication, + ApplicationOptions, + TurnState, +) + + +class _StubAdapter: + def __init__(self): + self.sent_activities: list[Activity] = [] + + async def send_activities(self, context, activities): + self.sent_activities.extend(activities) + return [None] * len(activities) + + +def _make_app() -> AgentApplication[TurnState]: + return AgentApplication[TurnState]( + options=ApplicationOptions(storage=MemoryStorage()) + ) + + +def _make_activity(**kwargs) -> Activity: + return Activity( + channel_id="test", + conversation={"id": "conv1"}, + from_property={"id": "user1"}, + recipient={"id": "bot1"}, + service_url="https://test", + **kwargs, + ) + + +def _make_context( + activity: Activity, adapter: _StubAdapter | None = None +) -> TurnContext: + return TurnContext(adapter or _StubAdapter(), activity) + + +class TestActivityRoute: + def setup_method(self): + self.app = _make_app() + self.called = False + self.received_type: str | None = None + + @pytest.mark.asyncio + async def test_string_match(self): + @self.app.activity("event") + async def handler(context: TurnContext, state: TurnState): + self.called = True + self.received_type = context.activity.type + + await self.app._on_activity( + _make_context(_make_activity(type="event")), TurnState() + ) + assert self.called + assert self.received_type == "event" + + @pytest.mark.asyncio + async def test_string_no_match(self): + @self.app.activity("event") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type="message")), TurnState() + ) + assert not self.called + + @pytest.mark.asyncio + async def test_activity_types_enum_match(self): + @self.app.activity(ActivityTypes.invoke) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.invoke)), TurnState() + ) + assert self.called + + @pytest.mark.asyncio + async def test_list_first_element_matches(self): + @self.app.activity(["event", ActivityTypes.message]) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type="event")), TurnState() + ) + assert self.called + + @pytest.mark.asyncio + async def test_list_second_element_matches(self): + @self.app.activity(["event", ActivityTypes.message]) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message)), TurnState() + ) + assert self.called + + @pytest.mark.asyncio + async def test_list_no_match(self): + @self.app.activity(["event", ActivityTypes.message]) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type="invoke")), TurnState() + ) + assert not self.called + + def test_decorator_returns_original_handler(self): + async def handler(context: TurnContext, state: TurnState): + pass + + result = self.app.activity("event")(handler) + assert result is handler + + +class TestMessageRoute: + def setup_method(self): + self.app = _make_app() + self.called = False + self.received_text: str | None = None + + @pytest.mark.asyncio + async def test_string_exact_match(self): + @self.app.message("hello") + async def handler(context: TurnContext, state: TurnState): + self.called = True + self.received_text = context.activity.text + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")), + TurnState(), + ) + assert self.called + assert self.received_text == "hello" + + @pytest.mark.asyncio + async def test_string_no_match(self): + @self.app.message("hello") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="world")), + TurnState(), + ) + assert not self.called + + @pytest.mark.asyncio + async def test_non_message_activity_is_ignored(self): + @self.app.message("hello") + 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.*")) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity(type=ActivityTypes.message, text="hello world") + ), + TurnState(), + ) + assert self.called + + @pytest.mark.asyncio + async def test_pattern_no_match(self): + @self.app.message(re.compile(r"hello.*")) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="bye world")), + TurnState(), + ) + assert not self.called + + @pytest.mark.asyncio + async def test_list_string_match(self): + @self.app.message(["hi", "hello", "hey"]) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")), + TurnState(), + ) + assert self.called + + @pytest.mark.asyncio + async def test_list_pattern_match(self): + @self.app.message([re.compile(r"hi.*"), re.compile(r"bye.*")]) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity(type=ActivityTypes.message, text="bye everyone") + ), + TurnState(), + ) + assert self.called + + @pytest.mark.asyncio + async def test_list_mixed_string_and_pattern_match(self): + @self.app.message(["hello", re.compile(r"bye.*")]) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="bye world")), + TurnState(), + ) + assert self.called + + @pytest.mark.asyncio + async def test_list_no_match(self): + @self.app.message(["hello", re.compile(r"bye.*")]) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="greetings")), + TurnState(), + ) + assert not self.called + + def test_decorator_returns_original_handler(self): + async def handler(context: TurnContext, state: TurnState): + pass + + result = self.app.message("hello")(handler) + assert result is handler + + +class TestHandoffRoute: + def setup_method(self): + self.adapter = _StubAdapter() + self.app = _make_app() + self.received_continuation: str | None = None + + def _handoff_context(self, continuation: str = "cont123") -> TurnContext: + return TurnContext( + self.adapter, + _make_activity( + type=ActivityTypes.invoke, + name="handoff/action", + value={"continuation": continuation}, + ), + ) + + @pytest.mark.asyncio + async def test_factory_style_routes_correctly(self): + @self.app.handoff() + async def handler(context, state, continuation): + self.received_continuation = continuation + + await self.app._on_activity(self._handoff_context("abc"), TurnState()) + assert self.received_continuation == "abc" + + @pytest.mark.asyncio + async def test_direct_style_routes_correctly(self): + @self.app.handoff + async def handler(context, state, continuation): + self.received_continuation = continuation + + await self.app._on_activity(self._handoff_context("xyz"), TurnState()) + assert self.received_continuation == "xyz" + + @pytest.mark.asyncio + async def test_sends_invoke_response(self): + """The route wrapper must send a 200 invoke response regardless of func's return value.""" + + @self.app.handoff + async def handler(context, state, continuation): + pass + + await self.app._on_activity(self._handoff_context(), TurnState()) + + invoke_responses = [ + a + for a in self.adapter.sent_activities + if a.type == ActivityTypes.invoke_response + ] + assert len(invoke_responses) == 1 + from microsoft_agents.activity import InvokeResponse + + value = invoke_responses[0].value + assert isinstance(value, InvokeResponse) + assert value.status == 200 + + @pytest.mark.asyncio + async def test_does_not_match_other_invoke_names(self): + @self.app.handoff + async def handler(context, state, continuation): + self.received_continuation = continuation + + ctx = TurnContext( + self.adapter, + _make_activity(type=ActivityTypes.invoke, name="other/action"), + ) + await self.app._on_activity(ctx, TurnState()) + assert self.received_continuation is None + + @pytest.mark.asyncio + async def test_does_not_match_non_invoke(self): + @self.app.handoff + async def handler(context, state, continuation): + self.received_continuation = continuation + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")), + TurnState(), + ) + assert self.received_continuation is None + + def test_factory_style_returns_original_handler(self): + async def handler(context, state, continuation): + pass + + result = self.app.handoff()(handler) + assert result is handler + + def test_direct_style_returns_original_handler(self): + async def handler(context, state, continuation): + pass + + result = self.app.handoff(handler) + assert result is handler + + +class TestRouteOrdering: + def setup_method(self): + self.app = _make_app() + self.call_order: list[str] = [] + + @pytest.mark.asyncio + async def test_only_first_matching_route_fires(self): + @self.app.message("hello") + async def first(context: TurnContext, state: TurnState): + self.call_order.append("first") + + @self.app.message("hello") + async def second(context: TurnContext, state: TurnState): + self.call_order.append("second") + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")), + TurnState(), + ) + assert self.call_order == ["first"] + + @pytest.mark.asyncio + async def test_non_matching_route_is_skipped(self): + @self.app.message("bye") + async def first(context: TurnContext, state: TurnState): + self.call_order.append("first") + + @self.app.message("hello") + async def second(context: TurnContext, state: TurnState): + self.call_order.append("second") + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")), + TurnState(), + ) + assert self.call_order == ["second"] + + @pytest.mark.asyncio + async def test_multiple_routes_different_types_each_fires_for_own_activity(self): + @self.app.message("hello") + async def msg_handler(context: TurnContext, state: TurnState): + self.call_order.append("message") + + @self.app.activity("event") + async def evt_handler(context: TurnContext, state: TurnState): + self.call_order.append("event") + + await self.app._on_activity( + _make_context(_make_activity(type="event")), TurnState() + ) + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")), + TurnState(), + ) + assert self.call_order == ["event", "message"] diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py index ac7f1529..f16b514d 100644 --- a/tests/hosting_teams/test_teams_agent_extension.py +++ b/tests/hosting_teams/test_teams_agent_extension.py @@ -39,7 +39,9 @@ def _make_app() -> AgentApplication: app = MagicMock(spec=AgentApplication) app._routes = [] - def _add_route(selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None): + def _add_route( + selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None + ): app._routes.append( dict( selector=selector, @@ -111,12 +113,12 @@ async def handler(ctx, state, req): ... selector = self.app._routes[0]["selector"] ctx_match = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": {"verb": "myVerb"}} + ActivityTypes.invoke, name="task/fetch", value={"data": {"verb": "myVerb"}} ) ctx_no_match = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": {"verb": "otherVerb"}} + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "otherVerb"}}, ) assert selector(ctx_match) is True assert selector(ctx_no_match) is False @@ -128,12 +130,14 @@ async def handler(ctx, state, req): ... selector = self.app._routes[0]["selector"] ctx_match = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": {"verb": "mySpecialVerb"}} + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "mySpecialVerb"}}, ) ctx_no_match = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": {"verb": "otherVerb"}} + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "otherVerb"}}, ) assert selector(ctx_match) is True assert selector(ctx_no_match) is False @@ -163,12 +167,12 @@ async def handler(ctx, state, req): ... selector = self.app._routes[0]["selector"] ctx_match = _make_context( - ActivityTypes.invoke, name="task/submit", - value={"data": {"verb": "submitVerb"}} + ActivityTypes.invoke, + name="task/submit", + value={"data": {"verb": "submitVerb"}}, ) ctx_no_match = _make_context( - ActivityTypes.invoke, name="task/submit", - value={"data": {"verb": "other"}} + ActivityTypes.invoke, name="task/submit", value={"data": {"verb": "other"}} ) assert selector(ctx_match) is True assert selector(ctx_no_match) is False @@ -202,8 +206,9 @@ async def handler(ctx, state, req: TaskModuleRequest): route_handler = self.app._routes[0]["handler"] state = MagicMock() ctx = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": {"verb": "myVerb"}, "context": None} + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "myVerb"}, "context": None}, ) with patch( "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", @@ -223,8 +228,9 @@ async def handler(ctx, state, req): ... route_handler = self.app._routes[0]["handler"] ctx = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": None, "context": None} + ActivityTypes.invoke, + name="task/fetch", + value={"data": None, "context": None}, ) with patch( "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", @@ -249,12 +255,18 @@ async def handler(ctx, state, query): ... ctx_match = _make_context( ActivityTypes.invoke, name="composeExtension/query", - value={"commandId": "searchCmd", "parameters": [{"name": "searchQuery", "value": "pizza"}]}, + value={ + "commandId": "searchCmd", + "parameters": [{"name": "searchQuery", "value": "pizza"}], + }, ) ctx_no_match = _make_context( ActivityTypes.invoke, name="composeExtension/query", - value={"commandId": "other", "parameters": [{"name": "searchQuery", "value": "searchCmd"}]}, + value={ + "commandId": "other", + "parameters": [{"name": "searchQuery", "value": "searchCmd"}], + }, ) assert selector(ctx_match) is True assert selector(ctx_no_match) is False @@ -378,7 +390,9 @@ def test_on_anonymous_query_link_selector(self): async def handler(ctx, state, query): ... selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="composeExtension/anonymousQueryLink") + ctx = _make_context( + ActivityTypes.invoke, name="composeExtension/anonymousQueryLink" + ) assert selector(ctx) is True @pytestmark @@ -407,7 +421,9 @@ def test_on_card_button_clicked_selector(self): async def handler(ctx, state, data): ... selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="composeExtension/onCardButtonClicked") + ctx = _make_context( + ActivityTypes.invoke, name="composeExtension/onCardButtonClicked" + ) assert selector(ctx) is True @pytestmark @@ -465,8 +481,12 @@ def test_on_start_selector(self): async def handler(ctx, state, meeting): ... selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.meetingStart") - ctx_other = _make_context(ActivityTypes.event, name="application/vnd.microsoft.meetingEnd") + ctx = _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingStart" + ) + ctx_other = _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingEnd" + ) assert selector(ctx) is True assert selector(ctx_other) is False @@ -476,7 +496,9 @@ def test_on_end_selector(self): async def handler(ctx, state, meeting): ... selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.meetingEnd") + ctx = _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingEnd" + ) assert selector(ctx) is True @pytestmark @@ -645,7 +667,9 @@ def test_on_read_receipt_selector(self): async def handler(ctx, state, receipt): ... selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.readReceipt") + ctx = _make_context( + ActivityTypes.event, name="application/vnd.microsoft.readReceipt" + ) assert selector(ctx) is True @pytestmark @@ -763,7 +787,9 @@ def test_on_o365_connector_card_action_selector(self): async def handler(ctx, state, query): ... selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="actionableMessage/executeAction") + ctx = _make_context( + ActivityTypes.invoke, name="actionableMessage/executeAction" + ) assert selector(ctx) is True @pytestmark