diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 60cf0e79..5eed20d0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -262,28 +262,12 @@ async def create_conversation( # pylint: disable=arguments-differ claims_identity = self.create_claims_identity(agent_app_id) claims_identity.claims[AuthenticationConstants.SERVICE_URL_CLAIM] = service_url - # Create a turn context and run the pipeline. - context = self._create_turn_context( - claims_identity, - None, - callback, - ) - - # Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.) - user_token_client: UserTokenClient = ( - await self._channel_service_client_factory.create_user_token_client( - context, claims_identity - ) - ) - context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client - # Create the connector client to use for outbound requests. connector_client: ConnectorClient = ( await self._channel_service_client_factory.create_connector_client( - context, claims_identity, service_url, audience + None, claims_identity, service_url, audience ) ) - context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client # Make the actual create conversation call using the connector. create_conversation_result = ( @@ -297,7 +281,22 @@ async def create_conversation( # pylint: disable=arguments-differ create_conversation_result, channel_id, service_url, conversation_parameters ) - context.activity = create_activity + # Create a turn context and run the pipeline. + context = self._create_turn_context( + claims_identity, + None, + callback, + create_activity, + ) + context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client + + # Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.) + user_token_client: UserTokenClient = ( + await self._channel_service_client_factory.create_user_token_client( + context, claims_identity + ) + ) + context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client # Run the pipeline await self.run_pipeline(context, callback) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_client_factory_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_client_factory_base.py index faf46646..b7a6b68b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_client_factory_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_client_factory_base.py @@ -16,7 +16,7 @@ class ChannelServiceClientFactoryBase(Protocol): @abstractmethod async def create_connector_client( self, - context: TurnContext, + context: TurnContext | None, claims_identity: ClaimsIdentity, service_url: str, audience: str, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index 9e639480..b2bc6440 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -83,15 +83,15 @@ async def _get_agentic_token(self, context: TurnContext, service_url: str) -> st async def create_connector_client( self, - context: TurnContext, + context: TurnContext | None, claims_identity: ClaimsIdentity, service_url: str, audience: str, scopes: Optional[list[str]] = None, use_anonymous: bool = False, ) -> ConnectorClientBase: - if not context or not claims_identity: - raise TypeError("context and claims_identity are required") + if not claims_identity: + raise TypeError("claims_identity is required") if not service_url: raise TypeError( "RestChannelServiceClientFactory.create_connector_client: service_url can't be None or Empty" @@ -101,7 +101,7 @@ async def create_connector_client( "RestChannelServiceClientFactory.create_connector_client: audience can't be None or Empty" ) - if context.activity.is_agentic_request(): + if context and context.activity.is_agentic_request(): token = await self._get_agentic_token(context, service_url) else: token_provider: AccessTokenProviderBase = ( diff --git a/tests/_common/data/default_test_values.py b/tests/_common/data/default_test_values.py index 9687c5a6..1bbafbb6 100644 --- a/tests/_common/data/default_test_values.py +++ b/tests/_common/data/default_test_values.py @@ -14,6 +14,7 @@ def __init__(self): self.user_id = "__user_id" self.bot_url = "https://botframework.com" self.ms_app_id = "__ms_app_id" + self.service_url = "https://service.url/" # Auth Handler Settings self.abs_oauth_connection_name = "connection_name" diff --git a/tests/hosting_core/test_channel_service_adapter.py b/tests/hosting_core/test_channel_service_adapter.py new file mode 100644 index 00000000..777f7cef --- /dev/null +++ b/tests/hosting_core/test_channel_service_adapter.py @@ -0,0 +1,96 @@ +import pytest + +from microsoft_agents.activity import ( + ConversationResourceResponse, + ConversationParameters, +) +from microsoft_agents.hosting.core import ( + ChannelServiceAdapter, + TurnContext, + ConnectorClientBase, + UserTokenClientBase, + ChannelServiceClientFactoryBase, + RestChannelServiceClientFactory, + TeamsConnectorClient, + UserTokenClient, + Connections, +) + +from microsoft_agents.hosting.core.connector.conversations_base import ConversationsBase + + +class MyChannelServiceAdapter(ChannelServiceAdapter): + pass + + +class TestChannelServiceAdapter: + + @pytest.fixture + def connector_client(self, mocker): + connector_client = mocker.Mock(spec=TeamsConnectorClient) + mocker.patch.object( + TeamsConnectorClient, "__new__", return_value=connector_client + ) + return connector_client + + @pytest.fixture + def user_token_client(self, mocker): + user_token_client = mocker.Mock(spec=UserTokenClient) + mocker.patch.object(UserTokenClient, "__new__", return_value=user_token_client) + return user_token_client + + @pytest.fixture + def connection_manager(self, mocker, user_token_client): + connection_manager = mocker.Mock(spec=Connections) + connection_manager.get_token_provider = mocker.Mock( + return_value=user_token_client + ) + return connection_manager + + @pytest.fixture + def factory(self, connection_manager): + client_factory = RestChannelServiceClientFactory(connection_manager) + return client_factory + + @pytest.fixture + def adapter(self, factory): + return MyChannelServiceAdapter(factory) + + @pytest.mark.asyncio + async def test_create_conversation_basic( + self, mocker, user_token_client, connector_client, adapter + ): + + user_token_client.get_access_token = mocker.AsyncMock( + return_value="user_token_value" + ) + adapter.run_pipeline = mocker.AsyncMock() + + connector_client.conversations = mocker.Mock(spec=ConversationsBase) + connector_client.conversations.create_conversation.return_value = ( + ConversationResourceResponse( + activity_id="activity123", + service_url="https://service.url", + id="conversation123", + ) + ) + + async def callback(context: TurnContext): + return None + + await adapter.create_conversation( + "agent_app_id", + "channel_id", + "service_url", + "audience", + ConversationParameters(), + callback, + ) + + adapter.run_pipeline.assert_awaited_once() + + context_arg, callback_arg = adapter.run_pipeline.call_args[0] + assert callback_arg == callback + assert context_arg.activity.conversation.id == "conversation123" + assert context_arg.activity.channel_id == "channel_id" + assert context_arg.activity.service_url == "service_url" diff --git a/tests/hosting_core/test_rest_channel_service_client_factory.py b/tests/hosting_core/test_rest_channel_service_client_factory.py new file mode 100644 index 00000000..09665916 --- /dev/null +++ b/tests/hosting_core/test_rest_channel_service_client_factory.py @@ -0,0 +1,387 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity import Activity, RoleTypes, ChannelAccount +from microsoft_agents.hosting.core import ( + RestChannelServiceClientFactory, + TurnContext, +) +from microsoft_agents.hosting.core.authorization import ( + AuthenticationConstants, + ClaimsIdentity, + Connections, + AccessTokenProviderBase, + AnonymousTokenProvider, + AgentAuthConfiguration, +) +from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient +from microsoft_agents.hosting.core.connector.client import UserTokenClient + +from tests._common.data import DEFAULT_TEST_VALUES + +DEFAULTS = DEFAULT_TEST_VALUES() + + +class TestRestChannelServiceClientFactory: + + @pytest.fixture + def activity(self): + return Activity( + type="message", + channel_id="msteams", + from_property=ChannelAccount(id="user1", role=RoleTypes.user), + recipient=ChannelAccount(id="bot1", role=RoleTypes.agent), + service_url="https://service.url/", + conversation={"id": "conv1"}, + id="activity1", + text="Hello, World!", + ) + + @pytest.fixture(params=[True, False]) + def context_flag(self, request): + return request.param + + @pytest.fixture + def activity_agentic_user(self): + return Activity( + type="message", + channel_id="msteams", + from_property=ChannelAccount(id="agentic_user", role=RoleTypes.user), + recipient=ChannelAccount( + id="bot1", + agentic_app_id="agentic_app_id", + agentic_user_id="agentic_user_id", + role=RoleTypes.agentic_user, + ), + service_url="https://service.url/", + conversation={"id": "conv1"}, + id="activity_agentic1", + text="Hello, World!", + properties={"agenticRequest": True}, + ) + + @pytest.fixture + def activity_agentic_identity(self): + return Activity( + type="message", + channel_id="msteams", + from_property=ChannelAccount(id="agentic_user", role=RoleTypes.user), + recipient=ChannelAccount( + id="bot1", + agentic_app_id="agentic_app_id", + role=RoleTypes.agentic_identity, + ), + service_url="https://service.url/", + conversation={"id": "conv1"}, + id="activity_agentic1", + text="Hello, World!", + properties={"agenticRequest": True}, + ) + + @pytest.mark.parametrize( + "token_service_endpoint, token_service_audience", + [ + ( + AuthenticationConstants.AGENTS_SDK_OAUTH_URL, + AuthenticationConstants.AGENTS_SDK_SCOPE, + ), + ("https://custom.token.endpoint", "https://custom.token.audience"), + ], + ) + @pytest.mark.asyncio + async def test_create_connector_client_anonymous( + self, + mocker, + activity, + token_service_endpoint, + token_service_audience, + context_flag, + ): + mock_connector_client = mocker.Mock(spec=TeamsConnectorClient) + mocker.patch.object( + TeamsConnectorClient, "__new__", return_value=mock_connector_client + ) + + factory = RestChannelServiceClientFactory( + mocker.Mock(spec=Connections), + token_service_endpoint, + token_service_audience, + ) + + context = mocker.Mock(spec=TurnContext) + context.activity = activity + claims_identity = mocker.Mock(spec=ClaimsIdentity) + scopes = ["scope1"] + audience = "https://service.audience/" + + res = await factory.create_connector_client( + context if context_flag else None, + claims_identity, + DEFAULTS.service_url, + audience, + scopes, + use_anonymous=True, + ) + + # verify + TeamsConnectorClient.__new__.assert_called_once_with( + TeamsConnectorClient, endpoint=DEFAULTS.service_url, token="" + ) + assert res == mock_connector_client + + @pytest.mark.parametrize( + "token_service_endpoint, token_service_audience", + [ + ( + AuthenticationConstants.AGENTS_SDK_OAUTH_URL, + AuthenticationConstants.AGENTS_SDK_SCOPE, + ), + ("https://custom.token.endpoint", "https://custom.token.audience"), + ], + ) + @pytest.mark.asyncio + async def test_create_connector_client_normal_no_scopes( + self, + mocker, + activity, + token_service_endpoint, + token_service_audience, + context_flag, + ): + # setup + mock_connector_client = mocker.Mock(spec=TeamsConnectorClient) + mocker.patch.object( + TeamsConnectorClient, "__new__", return_value=mock_connector_client + ) + + token_provider = mocker.Mock(spec=AccessTokenProviderBase) + token_provider.get_access_token = mocker.AsyncMock(return_value=DEFAULTS.token) + + connection_manager = mocker.Mock(spec=Connections) + connection_manager.get_token_provider = mocker.Mock(return_value=token_provider) + + factory = RestChannelServiceClientFactory( + connection_manager, token_service_endpoint, token_service_audience + ) + + claims_identity = mocker.Mock(spec=ClaimsIdentity) + service_url = DEFAULTS.service_url + audience = "https://service.audience/" + + context = mocker.Mock(spec=TurnContext) + context.activity = activity + + # test + + res = await factory.create_connector_client( + context if context_flag else None, + claims_identity, + service_url, + audience, + None, + ) + + # verify + assert connection_manager.get_token_provider.call_count == 1 + connection_manager.get_token_provider.assert_called_once_with( + claims_identity, service_url + ) + assert token_provider.get_access_token.call_count == 1 + token_provider.get_access_token.assert_called_once_with( + audience, [f"{audience}/.default"] + ) + TeamsConnectorClient.__new__.assert_called_once_with( + TeamsConnectorClient, endpoint=DEFAULTS.service_url, token=DEFAULTS.token + ) + + @pytest.mark.parametrize( + "token_service_endpoint, token_service_audience", + [ + ( + AuthenticationConstants.AGENTS_SDK_OAUTH_URL, + AuthenticationConstants.AGENTS_SDK_SCOPE, + ), + ("https://custom.token.endpoint", "https://custom.token.audience"), + ], + ) + @pytest.mark.asyncio + async def test_create_connector_client_normal( + self, + mocker, + activity, + token_service_endpoint, + token_service_audience, + context_flag, + ): + # setup + mock_connector_client = mocker.Mock(spec=TeamsConnectorClient) + mocker.patch.object( + TeamsConnectorClient, "__new__", return_value=mock_connector_client + ) + + token_provider = mocker.Mock(spec=AccessTokenProviderBase) + token_provider.get_access_token = mocker.AsyncMock(return_value=DEFAULTS.token) + + connection_manager = mocker.Mock(spec=Connections) + connection_manager.get_token_provider = mocker.Mock(return_value=token_provider) + + factory = RestChannelServiceClientFactory( + connection_manager, token_service_endpoint, token_service_audience + ) + + claims_identity = mocker.Mock(spec=ClaimsIdentity) + service_url = DEFAULTS.service_url + audience = "https://service.audience/" + scopes = ["scope1", "scope2"] + + context = mocker.Mock(spec=TurnContext) + context.activity = activity + + # test + + res = await factory.create_connector_client( + context if context_flag else None, + claims_identity, + service_url, + audience, + scopes, + ) + + # verify + assert connection_manager.get_token_provider.call_count == 1 + connection_manager.get_token_provider.assert_called_once_with( + claims_identity, service_url + ) + assert token_provider.get_access_token.call_count == 1 + token_provider.get_access_token.assert_called_once_with(audience, scopes) + TeamsConnectorClient.__new__.assert_called_once_with( + TeamsConnectorClient, endpoint=DEFAULTS.service_url, token=DEFAULTS.token + ) + + @pytest.mark.parametrize("alt_blueprint", [True, False]) + @pytest.mark.asyncio + async def test_create_connector_client_agentic_identity( + self, mocker, activity_agentic_identity, alt_blueprint + ): + # setup + mock_connector_client = mocker.Mock(spec=TeamsConnectorClient) + mocker.patch.object( + TeamsConnectorClient, "__new__", return_value=mock_connector_client + ) + + token_provider = mocker.Mock(spec=AccessTokenProviderBase) + token_provider.get_agentic_instance_token = mocker.AsyncMock( + return_value=(DEFAULTS.token, None) + ) + + connection_manager = mocker.Mock(spec=Connections) + connection_manager.get_token_provider = mocker.Mock(return_value=token_provider) + + auth_config = AgentAuthConfiguration() + if alt_blueprint: + auth_config.ALT_BLUEPRINT_ID = "alt_blueprint_id" + connection_manager.get_connection = mocker.Mock(return_value=token_provider) + token_provider._msal_configuration = auth_config + + factory = RestChannelServiceClientFactory(connection_manager) + + claims_identity = mocker.Mock(spec=ClaimsIdentity) + service_url = DEFAULTS.service_url + audience = "https://service.audience/" + scopes = ["scope1", "scope2"] + + context = mocker.Mock(spec=TurnContext) + context.activity = activity_agentic_identity + + # test + + res = await factory.create_connector_client( + context, + claims_identity, + service_url, + audience, + scopes, + ) + + # verify + assert connection_manager.get_token_provider.call_count == 1 + connection_manager.get_token_provider.assert_called_once_with( + context.identity, service_url + ) + if alt_blueprint: + connection_manager.get_connection.assert_called_once_with( + "alt_blueprint_id" + ) + assert token_provider.get_agentic_instance_token.call_count == 1 + token_provider.get_agentic_instance_token.assert_called_once_with( + "agentic_app_id" + ) + TeamsConnectorClient.__new__.assert_called_once_with( + TeamsConnectorClient, endpoint=DEFAULTS.service_url, token=DEFAULTS.token + ) + + @pytest.mark.parametrize("alt_blueprint", [True, False]) + @pytest.mark.asyncio + async def test_create_connector_client_agentic_user( + self, mocker, activity_agentic_user, alt_blueprint + ): + # setup + mock_connector_client = mocker.Mock(spec=TeamsConnectorClient) + mocker.patch.object( + TeamsConnectorClient, "__new__", return_value=mock_connector_client + ) + + token_provider = mocker.Mock(spec=AccessTokenProviderBase) + token_provider.get_agentic_user_token = mocker.AsyncMock( + return_value=DEFAULTS.token + ) + + connection_manager = mocker.Mock(spec=Connections) + connection_manager.get_token_provider = mocker.Mock(return_value=token_provider) + + auth_config = AgentAuthConfiguration() + if alt_blueprint: + auth_config.ALT_BLUEPRINT_ID = "alt_blueprint_id" + connection_manager.get_connection = mocker.Mock(return_value=token_provider) + token_provider._msal_configuration = auth_config + + factory = RestChannelServiceClientFactory(connection_manager) + + claims_identity = mocker.Mock(spec=ClaimsIdentity) + service_url = DEFAULTS.service_url + audience = "https://service.audience/" + scopes = ["scope1", "scope2"] + + context = mocker.Mock(spec=TurnContext) + context.activity = activity_agentic_user + + # test + + res = await factory.create_connector_client( + context, + claims_identity, + service_url, + audience, + scopes, + ) + + # verify + assert connection_manager.get_token_provider.call_count == 1 + connection_manager.get_token_provider.assert_called_once_with( + context.identity, service_url + ) + if alt_blueprint: + connection_manager.get_connection.assert_called_once_with( + "alt_blueprint_id" + ) + assert token_provider.get_agentic_user_token.call_count == 1 + token_provider.get_agentic_user_token.assert_called_once_with( + "agentic_app_id", + "agentic_user_id", + [AuthenticationConstants.APX_PRODUCTION_SCOPE], + ) + TeamsConnectorClient.__new__.assert_called_once_with( + TeamsConnectorClient, endpoint=DEFAULTS.service_url, token=DEFAULTS.token + )