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 f5ceb61a..2520a294 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 @@ -12,4 +12,4 @@ class RouteHandler(Protocol[StateT]): - def __call__(self, context: TurnContext, state: StateT) -> Awaitable[None]: ... + def __call__(self, context: TurnContext, state: StateT, /) -> Awaitable[None]: ... 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 3edf81e6..9f8bf220 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 @@ -71,9 +71,9 @@ class AgentApplication(Agent, Generic[StateT]): _adapter: Optional[ChannelServiceAdapter] = None _auth: Optional[Authorization] = None _proactive: Optional[Proactive] = None - _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] - _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] - _route_list: _RouteList[StateT] = _RouteList[StateT]() + _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] + _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] + _route_list: _RouteList[StateT] _error: Optional[Callable[[TurnContext, Exception], Awaitable[None]]] = None _turn_state_factory: Optional[Callable[[TurnContext], StateT]] = None @@ -98,6 +98,8 @@ def __init__( :type kwargs: Any """ self._route_list = _RouteList[StateT]() + self._internal_before_turn = [] + self._internal_after_turn = [] configuration = kwargs @@ -159,6 +161,15 @@ def __init__( if authorization: self._auth = authorization else: + if not connection_manager: + logger.error( + "AgentApplication: connection_manager is required for Authorization.", + stack_info=True, + ) + raise ApplicationError( + "The `AgentApplication` requires a `connection_manager` to initialize the `Authorization` instance." + ) + auth_options = { key: value for key, value in configuration.items() @@ -244,6 +255,34 @@ def proactive(self) -> Proactive: """) return self._proactive + def before_turn( + self, handler: Callable[[TurnContext, StateT], Awaitable[bool]] + ) -> Callable[[TurnContext, StateT], Awaitable[bool]]: + """ + Adds a handler to be called before each turn of the conversation. + + :param handler: A function that takes a TurnContext and a StateT and returns an Awaitable. + :type handler: Callable[[TurnContext, StateT], Awaitable[bool]] + :return: The added handler. + :rtype: Callable[[TurnContext, StateT], Awaitable[bool]] + """ + self._internal_before_turn.append(handler) + return handler + + def after_turn( + self, handler: Callable[[TurnContext, StateT], Awaitable[bool]] + ) -> Callable[[TurnContext, StateT], Awaitable[bool]]: + """ + Adds a handler to be called after each turn of the conversation. + + :param handler: A function that takes a TurnContext and a StateT and returns an Awaitable. + :type handler: Callable[[TurnContext, StateT], Awaitable[bool]] + :return: The added handler. + :rtype: Callable[[TurnContext, StateT], Awaitable[bool]] + """ + self._internal_after_turn.append(handler) + return handler + def add_route( self, selector: RouteSelector, @@ -259,7 +298,7 @@ def add_route( :param selector: A function that takes a TurnContext and returns a boolean indicating whether the route should be selected. :type selector: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`], bool] - :param handler: A function that takes a TurnContext and a TurnState and returns an Awaitable. + :param handler: A function that takes a TurnContext and a StateT and returns an Awaitable. :type handler: :class:`microsoft_agents.hosting.core.app._type_defs.RouteHandler`[StateT] :param is_invoke: Whether the route is for an invoke activity, defaults to False :type is_invoke: bool, Optional diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index a69f74e3..542b2866 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -3,10 +3,8 @@ Licensed under the MIT License. """ -from datetime import datetime import logging -from typing import TypeVar, Optional, Callable, Awaitable, Generic, cast -import jwt +from typing import Optional, Callable, Awaitable, cast from microsoft_agents.activity import Activity, Channels, SignInConstants, TokenResponse from microsoft_agents.activity.activity_types import ActivityTypes @@ -126,6 +124,18 @@ def _init_handlers(self) -> None: auth_handler=auth_handler, ) + @property + def connection_manager(self) -> Connections: + """ + The connection manager for the authorization instance. + + The connection manager is responsible for managing the connections to the various authentication providers. + + :return: The connection manager. + :rtype: :class:`microsoft_agents.hosting.core.authorization.Connections` + """ + return self._connection_manager + @staticmethod def _sign_in_state_key(context: TurnContext) -> str: """Generate a unique storage key for the sign-in state based on the context. diff --git a/tests/hosting_core/app/_oauth/test_authorization.py b/tests/hosting_core/app/_oauth/test_authorization.py index c6e6e020..81cdd6e7 100644 --- a/tests/hosting_core/app/_oauth/test_authorization.py +++ b/tests/hosting_core/app/_oauth/test_authorization.py @@ -187,6 +187,10 @@ def test_resolve_handler(self, connection_manager, storage, auth_handler_id): auth_handler_id, **handler_config ) + def test_connection_manager_property(self, connection_manager, storage): + auth = Authorization(storage, connection_manager, **ENV_DICT) + assert auth.connection_manager is connection_manager + def test_sign_in_state_key(self, mocker, connection_manager, storage): auth = Authorization(storage, connection_manager, **ENV_DICT) context = self.TurnContext(mocker) diff --git a/tests/hosting_core/app/test_agent_application.py b/tests/hosting_core/app/test_agent_application.py index 40e184d5..54871803 100644 --- a/tests/hosting_core/app/test_agent_application.py +++ b/tests/hosting_core/app/test_agent_application.py @@ -15,6 +15,38 @@ ApplicationOptions, TurnState, ) +from microsoft_agents.hosting.core.app.app_error import ApplicationError +from microsoft_agents.hosting.core.app.oauth import Authorization +from tests._common.testing_objects import TestingConnectionManager as _ConnectionManager + + +def _make_event_activity() -> Activity: + """Minimal event Activity with all fields required by state loading.""" + return Activity( + type=ActivityTypes.event, + channel_id="test_channel", + conversation={"id": "test_conv"}, + from_property={"id": "test_user"}, + ) + + +def _make_integration_app() -> AgentApplication: + """AgentApplication wired for on_turn integration tests (no typing, no mention-strip).""" + app = AgentApplication[TurnState]( + options=ApplicationOptions( + storage=MemoryStorage(), + start_typing_timer=False, + remove_recipient_mention=False, + ), + authorization=Authorization( + storage=MemoryStorage(), + connection_manager=_ConnectionManager(), + ), + ) + # Shadow class-level lists with fresh instance-level lists for test isolation + app._internal_before_turn = [] + app._internal_after_turn = [] + return app class StubAdapter: @@ -45,6 +77,24 @@ async def send_activity(self, activity): self.responded = True +def make_auth(): + return Authorization( + storage=MemoryStorage(), + connection_manager=_ConnectionManager(), + ) + + +def make_app(): + app = AgentApplication[TurnState]( + options=ApplicationOptions(storage=MemoryStorage()), + authorization=make_auth(), + ) + # Reset to instance-level lists to isolate tests from the class-level defaults + app._internal_before_turn = [] + app._internal_after_turn = [] + return app + + @pytest.mark.asyncio async def test_on_turn_no_typing_when_start_typing_timer_false(): """When start_typing_timer=False, no typing indicators should be sent.""" @@ -53,7 +103,8 @@ async def test_on_turn_no_typing_when_start_typing_timer_false(): options=ApplicationOptions( storage=MemoryStorage(), start_typing_timer=False, - ) + ), + authorization=make_auth(), ) context = StubTurnContext( @@ -94,3 +145,291 @@ async def test_on_turn_no_typing_when_start_typing_timer_false(): # No on_send_activities hook should have been registered assert len(context._on_send_handlers) == 0 + + +# --------------------------------------------------------------------------- +# AgentApplication.__init__ guard: connection_manager required +# --------------------------------------------------------------------------- + + +def test_init_raises_without_authorization_or_connection_manager(): + with pytest.raises(ApplicationError): + AgentApplication[TurnState](options=ApplicationOptions(storage=MemoryStorage())) + + +def test_init_succeeds_when_authorization_provided_without_connection_manager(): + auth = make_auth() + app = AgentApplication[TurnState]( + options=ApplicationOptions(storage=MemoryStorage()), + authorization=auth, + ) + assert app.auth is auth + + +# --------------------------------------------------------------------------- +# before_turn / after_turn – registration +# --------------------------------------------------------------------------- + + +def test_before_turn_registers_handler(): + app = make_app() + + async def handler(ctx, state): + return True + + app.before_turn(handler) + assert handler in app._internal_before_turn + + +def test_after_turn_registers_handler(): + app = make_app() + + async def handler(ctx, state): + return True + + app.after_turn(handler) + assert handler in app._internal_after_turn + + +def test_before_turn_multiple_handlers_preserved_in_order(): + app = make_app() + + async def first(ctx, state): + return True + + async def second(ctx, state): + return True + + app.before_turn(first) + app.before_turn(second) + assert app._internal_before_turn == [first, second] + + +def test_after_turn_multiple_handlers_preserved_in_order(): + app = make_app() + + async def first(ctx, state): + return True + + async def second(ctx, state): + return True + + app.after_turn(first) + app.after_turn(second) + assert app._internal_after_turn == [first, second] + + +# --------------------------------------------------------------------------- +# before_turn / after_turn – execution via middleware helpers +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_run_before_turn_middleware_calls_handler_with_context_and_state(): + app = make_app() + calls = [] + + async def handler(ctx, state): + calls.append((ctx, state)) + return True + + app.before_turn(handler) + + context = StubTurnContext(Activity(type=ActivityTypes.event)) + state = TurnState() + result = await app._run_before_turn_middleware(context, state) + + assert result is True + assert len(calls) == 1 + assert calls[0] == (context, state) + + +@pytest.mark.asyncio +async def test_run_before_turn_middleware_returns_false_and_stops_on_false_handler(): + app = make_app() + calls = [] + + async def first(ctx, state): + calls.append("first") + return False + + async def second(ctx, state): + calls.append("second") + return True + + app.before_turn(first) + app.before_turn(second) + + context = StubTurnContext(Activity(type=ActivityTypes.event)) + state = TurnState() + result = await app._run_before_turn_middleware(context, state) + + assert result is False + assert calls == ["first"] + + +@pytest.mark.asyncio +async def test_run_before_turn_middleware_returns_true_when_no_handlers(): + app = make_app() + + context = StubTurnContext(Activity(type=ActivityTypes.event)) + state = TurnState() + result = await app._run_before_turn_middleware(context, state) + + assert result is True + + +@pytest.mark.asyncio +async def test_run_after_turn_middleware_calls_handler_with_context_and_state(): + app = make_app() + calls = [] + + async def handler(ctx, state): + calls.append((ctx, state)) + return True + + app.after_turn(handler) + + context = StubTurnContext(Activity(type=ActivityTypes.event)) + state = TurnState() + result = await app._run_after_turn_middleware(context, state) + + assert result is True + assert len(calls) == 1 + assert calls[0] == (context, state) + + +@pytest.mark.asyncio +async def test_run_after_turn_middleware_returns_false_and_stops_on_false_handler(): + app = make_app() + calls = [] + + async def first(ctx, state): + calls.append("first") + return False + + async def second(ctx, state): + calls.append("second") + return True + + app.after_turn(first) + app.after_turn(second) + + context = StubTurnContext(Activity(type=ActivityTypes.event)) + state = TurnState() + result = await app._run_after_turn_middleware(context, state) + + assert result is False + assert calls == ["first"] + + +@pytest.mark.asyncio +async def test_run_after_turn_middleware_returns_true_when_no_handlers(): + app = make_app() + + context = StubTurnContext(Activity(type=ActivityTypes.event)) + state = TurnState() + result = await app._run_after_turn_middleware(context, state) + + assert result is True + + +# --------------------------------------------------------------------------- +# Integration tests: before_turn / after_turn through on_turn +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_on_turn_calls_before_turn_handler(): + app = _make_integration_app() + calls = [] + + async def before(ctx, state): + calls.append("before") + return True + + app.before_turn(before) + await app.on_turn(StubTurnContext(_make_event_activity())) + + assert calls == ["before"] + + +@pytest.mark.asyncio +async def test_on_turn_calls_after_turn_handler(): + app = _make_integration_app() + calls = [] + + async def after(ctx, state): + calls.append("after") + return True + + app.after_turn(after) + await app.on_turn(StubTurnContext(_make_event_activity())) + + assert calls == ["after"] + + +@pytest.mark.asyncio +async def test_on_turn_before_turn_false_skips_activity_handler(): + app = _make_integration_app() + calls = [] + + async def before(ctx, state): + calls.append("before") + return False + + app.before_turn(before) + + @app.activity(ActivityTypes.event) + async def on_event(ctx, state): + calls.append("event") + + await app.on_turn(StubTurnContext(_make_event_activity())) + + assert calls == ["before"] + + +@pytest.mark.asyncio +async def test_on_turn_execution_order_before_activity_after(): + app = _make_integration_app() + calls = [] + + async def before(ctx, state): + calls.append("before") + return True + + async def after(ctx, state): + calls.append("after") + return True + + app.before_turn(before) + app.after_turn(after) + + @app.activity(ActivityTypes.event) + async def on_event(ctx, state): + calls.append("event") + + await app.on_turn(StubTurnContext(_make_event_activity())) + + assert calls == ["before", "event", "after"] + + +@pytest.mark.asyncio +async def test_on_turn_after_turn_false_still_runs_after_activity_handler(): + """after_turn returning False stops state save but does not prevent the activity handler.""" + app = _make_integration_app() + calls = [] + + async def after(ctx, state): + calls.append("after") + return False + + app.after_turn(after) + + @app.activity(ActivityTypes.event) + async def on_event(ctx, state): + calls.append("event") + + await app.on_turn(StubTurnContext(_make_event_activity())) + + assert calls == ["event", "after"] diff --git a/tests/hosting_core/app/test_agent_application_routes.py b/tests/hosting_core/app/test_agent_application_routes.py index 80a448bf..fcb6548d 100644 --- a/tests/hosting_core/app/test_agent_application_routes.py +++ b/tests/hosting_core/app/test_agent_application_routes.py @@ -14,6 +14,8 @@ ApplicationOptions, TurnState, ) +from microsoft_agents.hosting.core.app.oauth import Authorization +from tests._common.testing_objects import TestingConnectionManager as _ConnectionManager class _StubAdapter: @@ -26,8 +28,13 @@ async def send_activities(self, context, activities): def _make_app() -> AgentApplication[TurnState]: + storage = MemoryStorage() return AgentApplication[TurnState]( - options=ApplicationOptions(storage=MemoryStorage()) + options=ApplicationOptions(storage=storage), + authorization=Authorization( + storage=storage, + connection_manager=_ConnectionManager(), + ), )