diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/activity_handler_scenario.py b/dev/microsoft-agents-testing/microsoft_agents/testing/activity_handler_scenario.py index fac3d2b13..3b1c012d0 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/activity_handler_scenario.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/activity_handler_scenario.py @@ -16,7 +16,10 @@ from aiohttp.web import Application, Request, Response, middleware from aiohttp.test_utils import TestServer +from dotenv import dotenv_values +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager from microsoft_agents.hosting.core import ( ActivityHandler, ConversationState, @@ -47,7 +50,7 @@ class ActivityHandlerEnvironment: storage: In-memory state storage shared by all state objects. conversation_state: Conversation-scoped state accessor. user_state: User-scoped state accessor. - adapter: CloudAdapter instance (anonymous auth, no real credentials). + adapter: CloudAdapter instance configured from the scenario environment. handler: The ActivityHandler instance under test. """ @@ -63,8 +66,9 @@ class ActivityHandlerScenario(Scenario): Use this scenario when your agent extends ``ActivityHandler`` rather than ``AgentApplication``. The scenario creates ``MemoryStorage``, - ``ConversationState``, ``UserState``, and a ``CloudAdapter`` (no auth), then - wires them up and hosts the handler on an ephemeral aiohttp test server. + ``ConversationState``, ``UserState``, and a ``CloudAdapter`` backed by the + configured service connection, then wires them up and hosts the handler on + an ephemeral aiohttp test server. Example:: @@ -107,6 +111,9 @@ def environment(self) -> ActivityHandlerEnvironment: async def _setup(self) -> None: """Create storage, state objects, adapter, and handler.""" + env_vars = dotenv_values(self._config.env_file_path or ".env") + sdk_config = load_configuration_from_env(env_vars) + storage = MemoryStorage() conv_state = ConversationState(storage) user_state = UserState(storage) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py index 6100c4c10..400b3286d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py @@ -17,7 +17,7 @@ SignInResource, ) -from ..connector.client import UserTokenClient +from ..connector import UserTokenClientBase from ._flow_state import _FlowState, _FlowStateTag, _FlowErrorTag logger = logging.getLogger(__name__) @@ -47,7 +47,7 @@ class _OAuthFlow: """ def __init__( - self, flow_state: _FlowState, user_token_client: UserTokenClient, **kwargs + self, flow_state: _FlowState, user_token_client: UserTokenClientBase, **kwargs ): """ Arguments: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/__init__.py new file mode 100644 index 000000000..a9481463d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from ._service_set import _ServiceSet + +__all__ = ["_ServiceSet"] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py new file mode 100644 index 000000000..492114af9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from typing import TypeVar, cast, Any + +T = TypeVar("T") + + +class _ServiceSet: + """ + A class that represents a collection of services, allowing for the storage and retrieval of service instances by their type. + """ + + def __init__(self, service_set: _ServiceSet | None = None) -> None: + """ + Initializes a new instance of the _ServiceSet class. + + :param service_set: An optional _ServiceSet instance to copy the state from. + """ + self._state: dict[type, Any] = {} + if service_set is not None: + self._state.update(service_set._state) + + def get(self, key: type[T]) -> T | None: + """ + Gets a value from the state collection. + :param key: + :return: + """ + val = self._state.get(key) + if val is not None: + if not isinstance(val, key): + raise TypeError( + f"Value for key '{key.__name__}' is not of type {key.__name__} (got {type(val).__name__})" + ) + return cast(T, val) + return None + + def has(self, key: type) -> bool: + """ + Checks if a value exists in the state collection. + :param key: Type of the value to check for. + :return: True if the value exists, False otherwise. + """ + return key in self._state + + def set(self, key: type[T], value: T) -> None: + """ + Sets a value in the state collection. + :param key: Type of the value to set. + :param value: The value to set. + """ + self._state[key] = value diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index f42231e9f..50fcaae1c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -29,7 +29,7 @@ from microsoft_agents.hosting.core._oauth._flow_state import _FlowErrorTag from microsoft_agents.hosting.core.card_factory import CardFactory from microsoft_agents.hosting.core.message_factory import MessageFactory -from microsoft_agents.hosting.core.connector.client import UserTokenClient +from microsoft_agents.hosting.core.connector import UserTokenClientBase from microsoft_agents.hosting.core.turn_context import TurnContext from microsoft_agents.hosting.core._oauth import ( _OAuthFlow, @@ -67,13 +67,10 @@ async def _load_flow( context and the specified auth handler. :rtype: tuple[OAuthFlow, FlowStorageClient] """ - user_token_client = cast( - UserTokenClient | None, - context.turn_state.get(context.adapter.USER_TOKEN_CLIENT_KEY), - ) + user_token_client = context.services.get(UserTokenClientBase) if not user_token_client: raise ValueError( - "UserTokenClient is required in TurnState for OAuth flow handling." + "UserTokenClientBase service is not available in the context" ) if ( @@ -86,15 +83,11 @@ async def _load_flow( channel_id = context.activity.channel_id user_id = context.activity.from_property.id - identity = cast( - ClaimsIdentity | None, - context.turn_state.get(context.adapter.AGENT_IDENTITY_KEY), - ) - if not identity or "aud" not in identity.claims: + identity = context.identity + if identity is None: raise ValueError( - "ClaimsIdentity with 'aud' claim is required in TurnState for OAuth flow handling." + "ClaimsIdentity is required on TurnContext for OAuth flow." ) - ms_app_id = identity.claims["aud"] # try to load existing state diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py index 8695b7ab6..29e00f7f1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py @@ -23,12 +23,9 @@ class ChannelAdapter(ABC, ChannelAdapterProtocol): AGENT_IDENTITY_KEY = "AgentIdentity" - OAUTH_SCOPE_KEY = "Microsoft.Agents.Builder.ChannelAdapter.OAuthScope" - INVOKE_RESPONSE_KEY = "ChannelAdapter.InvokeResponse" - CONNECTOR_FACTORY_KEY = "ConnectorFactory" USER_TOKEN_CLIENT_KEY = "UserTokenClient" - AGENT_CALLBACK_HANDLER_KEY = "AgentCallbackHandler" - CHANNEL_SERVICE_FACTORY_KEY = "ChannelServiceClientFactory" + INVOKE_RESPONSE_KEY = "ChannelAdapter.InvokeResponse" + OAUTH_SCOPE_KEY = "Microsoft.Agents.Builder.ChannelAdapter.OAuthScope" on_turn_error: Callable[[TurnContext, Exception], Awaitable] | None = None 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 64e3a704c..f8fa4f0b9 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 @@ -5,7 +5,7 @@ from abc import ABC from http import HTTPStatus -from typing import Awaitable, Callable, cast +from typing import Awaitable, Callable, Optional from uuid import uuid4 from microsoft_agents.activity import ( @@ -27,6 +27,7 @@ from microsoft_agents.hosting.core.connector import ( ConnectorClientBase, ConnectorClient, + UserTokenClientBase, UserTokenClient, ) from microsoft_agents.hosting.core.authorization import ( @@ -85,10 +86,7 @@ async def send_activities( # no-op pass else: - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), - ) + connector_client = context.services.get(ConnectorClientBase) if not connector_client: raise RuntimeError( "Unable to extract ConnectorClient from turn context." @@ -134,10 +132,7 @@ async def update_activity(self, context: TurnContext, activity: Activity): with spans.AdapterUpdateActivity(activity): - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), - ) + connector_client = context.services.get(ConnectorClientBase) if not connector_client: raise RuntimeError( "Unable to extract ConnectorClient from turn context." @@ -166,10 +161,7 @@ async def delete_activity( with spans.AdapterDeleteActivity(context.activity): - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), - ) + connector_client = context.services.get(ConnectorClientBase) if not connector_client: raise RuntimeError( "Unable to extract ConnectorClient from turn context." @@ -263,7 +255,7 @@ async def create_conversation( # pylint: disable=arguments-differ claims_identity.claims[AuthenticationConstants.SERVICE_URL_CLAIM] = service_url # Create the connector client to use for outbound requests. - connector_client: ConnectorClient = ( + connector_client = ( await self._channel_service_client_factory.create_connector_client( None, claims_identity, service_url, audience ) @@ -285,18 +277,23 @@ async def create_conversation( # pylint: disable=arguments-differ context = self._create_turn_context( claims_identity, None, - callback, create_activity, ) - context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client + context.services.set(ConnectorClientBase, connector_client) + context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = ( + connector_client # for back-compat + ) # Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.) - user_token_client: UserTokenClient = ( + user_token_client = ( await self._channel_service_client_factory.create_user_token_client( context, claims_identity ) ) - context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client + context.services.set(UserTokenClientBase, user_token_client) + context.turn_state[self.USER_TOKEN_CLIENT_KEY] = ( + user_token_client # for back-compat + ) # Run the pipeline await self.run_pipeline(context, callback) @@ -316,24 +313,29 @@ async def process_proactive( context = self._create_turn_context( claims_identity, audience, - callback, activity=continuation_activity, ) - user_token_client: UserTokenClient = ( + user_token_client = ( await self._channel_service_client_factory.create_user_token_client( context, claims_identity ) ) - context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client + context.services.set(UserTokenClientBase, user_token_client) + context.turn_state[self.USER_TOKEN_CLIENT_KEY] = ( + user_token_client # for back-compat + ) # Create the connector client to use for outbound requests. - connector_client: ConnectorClient = ( + connector_client = ( await self._channel_service_client_factory.create_connector_client( context, claims_identity, continuation_activity.service_url, audience ) ) - context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client + context.services.set(ConnectorClientBase, connector_client) + context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = ( + connector_client # for back-compat + ) # Run the pipeline await self.run_pipeline(context, callback) @@ -401,20 +403,22 @@ async def process_activity( context = self._create_turn_context( claims_identity, outgoing_audience, - callback, activity=activity, ) # Create a UserTokenClient instance for the OAuth flow. - user_token_client: UserTokenClient = ( + user_token_client = ( await self._channel_service_client_factory.create_user_token_client( context, claims_identity, use_anonymous_auth_callback ) ) - context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client + context.services.set(UserTokenClientBase, user_token_client) + context.turn_state[self.USER_TOKEN_CLIENT_KEY] = ( + user_token_client # for back-compat + ) # Create the connector client to use for outbound requests. - connector_client: ConnectorClient | None = None + connector_client: ConnectorClientBase | None = None if self._resolve_if_connector_client_is_needed(activity): connector_client = ( await self._channel_service_client_factory.create_connector_client( @@ -426,7 +430,10 @@ async def process_activity( use_anonymous_auth_callback, ) ) - context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client + context.services.set(ConnectorClientBase, connector_client) + context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = ( + connector_client # for back-compat + ) await self.run_pipeline(context, callback) @@ -492,19 +499,12 @@ def _create_create_activity( def _create_turn_context( self, claims_identity: ClaimsIdentity, - oauth_scope: str, - callback: Callable[[TurnContext], Awaitable], - activity: Activity | None = None, + oauth_scope: str | None = None, + activity: Optional[Activity] = None, ) -> TurnContext: context = TurnContext(self, activity, claims_identity) - - context.turn_state[self.AGENT_IDENTITY_KEY] = claims_identity - context.turn_state[self.AGENT_CALLBACK_HANDLER_KEY] = callback - context.turn_state[self.CHANNEL_SERVICE_FACTORY_KEY] = ( - self._channel_service_client_factory - ) context.turn_state[self.OAUTH_SCOPE_KEY] = oauth_scope - + context.turn_state[self.AGENT_IDENTITY_KEY] = claims_identity # for back-compat return context def _process_turn_results(self, context: TurnContext) -> InvokeResponse | None: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/__init__.py index 17efa5770..2a910ca71 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/__init__.py @@ -6,6 +6,8 @@ from .client.connector_client import ConnectorClient from .client.user_token_client import UserTokenClient +from .user_token_client_base import UserTokenClientBase + # Teams API from .teams.teams_connector_client import TeamsConnectorClient @@ -20,4 +22,5 @@ "MCSConnectorClient", "ConnectorClientBase", "get_product_info", + "UserTokenClientBase", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/connector_client_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/connector_client_base.py index 545c51772..5daf26a07 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/connector_client_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/connector_client_base.py @@ -2,12 +2,13 @@ # Licensed under the MIT License. from abc import abstractmethod -from typing import Protocol +from typing import Protocol, runtime_checkable from .attachments_base import AttachmentsBase from .conversations_base import ConversationsBase +@runtime_checkable class ConnectorClientBase(Protocol): @property @abstractmethod @@ -23,3 +24,8 @@ def attachments(self) -> AttachmentsBase: @abstractmethod def conversations(self) -> ConversationsBase: pass + + @abstractmethod + async def close(self) -> None: + """Close the client and release any resources.""" + raise NotImplementedError("close method must be implemented by subclasses.") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_client_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_client_base.py index 7c398a58c..020dd1116 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_client_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_client_base.py @@ -2,19 +2,29 @@ # Licensed under the MIT License. from abc import abstractmethod -from typing import Protocol +from typing import Protocol, runtime_checkable from .agent_sign_in_base import AgentSignInBase from .user_token_base import UserTokenBase +@runtime_checkable class UserTokenClientBase(Protocol): + @property - @abstractmethod def agent_sign_in(self) -> AgentSignInBase: - pass + raise NotImplementedError( + "agent_sign_in property must be implemented by subclasses." + ) @property @abstractmethod def user_token(self) -> UserTokenBase: - pass + raise NotImplementedError( + "user_token property must be implemented by subclasses." + ) + + @abstractmethod + async def close(self) -> None: + """Close the client and release any resources.""" + raise NotImplementedError("close method must be implemented by subclasses.") 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 edf639f1b..74ebac636 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 @@ -22,8 +22,11 @@ from microsoft_agents.activity._model_utils import pick_model, SkipNone from microsoft_agents.activity.entity.entity_types import EntityTypes from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity + import microsoft_agents.hosting.core.telemetry.turn_context.spans as spans +from ._utils._service_set import _ServiceSet + OnSendActivitiesHandler = Callable[ ["TurnContext", list[Activity], Callable[[], Awaitable[list[ResourceResponse]]]], Awaitable[list[ResourceResponse]], @@ -74,7 +77,7 @@ def __init__( self.adapter = adapter_or_context self._activity = request # exception thrown if None further down self.responses: list[Activity] = [] - self._services: dict = {} + self._services: _ServiceSet = _ServiceSet() self._on_send_activities = [] self._on_update_activity = [] self._on_delete_activity = [] @@ -151,7 +154,7 @@ def responded(self, value: bool): self._responded = True @property - def services(self): + def services(self) -> _ServiceSet: """ Map of services and other values cached for the lifetime of the turn. :return: @@ -175,36 +178,6 @@ def streaming_response(self): def identity(self) -> Optional[ClaimsIdentity]: return self._identity - def get(self, key: str) -> object: - if not key or not isinstance(key, str): - raise TypeError('"key" must be a valid string.') - try: - return self._services[key] - except KeyError: - raise KeyError("%s not found in TurnContext._services." % key) - - def has(self, key: str) -> bool: - """ - Returns True is set() has been called for a key. The cached value may be of type 'None'. - :param key: - :return: - """ - if key in self._services: - return True - return False - - def set(self, key: str, value: object) -> None: - """ - Caches a value for the lifetime of the current turn. - :param key: - :param value: - :return: - """ - if not key or not isinstance(key, str): - raise KeyError('"key" must be a valid string.') - - self._services[key] = value - async def send_activity( self, activity_or_text: Activity | str, diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_extensions.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_extensions.py index 3b83fe5c2..5b1028109 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_extensions.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_extensions.py @@ -138,26 +138,18 @@ def __is_from_parent_to_skill(turn_context: TurnContext) -> bool: """ Determines if this turn is an incoming request from a parent bot to this skill. """ - claims_identity = turn_context.turn_state.get( - ChannelAdapter.AGENT_IDENTITY_KEY, None - ) - return ( - isinstance(claims_identity, ClaimsIdentity) - and claims_identity.is_agent_claim() - ) + claims_identity = turn_context.identity + return claims_identity is not None and claims_identity.is_agent_claim() @staticmethod async def _send_state_snapshot_trace(dialog_context: DialogContext): """ Helper to send a trace activity with a memory snapshot of the active dialog DC. """ - claims_identity = dialog_context.context.turn_state.get( - ChannelAdapter.AGENT_IDENTITY_KEY, None - ) + claims_identity = dialog_context.context.identity trace_label = ( "Skill State" - if isinstance(claims_identity, ClaimsIdentity) - and claims_identity.is_agent_claim() + if claims_identity is not None and claims_identity.is_agent_claim() else "Bot State" ) # send trace of memory @@ -178,13 +170,8 @@ def __send_eoc_to_parent(turn_context: TurnContext) -> bool: """ Determines whether to send an EndOfConversation to the parent bot. """ - claims_identity = turn_context.turn_state.get( - ChannelAdapter.AGENT_IDENTITY_KEY, None - ) - if ( - isinstance(claims_identity, ClaimsIdentity) - and claims_identity.is_agent_claim() - ): + claims_identity = turn_context.identity + if claims_identity is not None and claims_identity.is_agent_claim(): return True return False diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager.py index 7c2ec7d87..ef5e9f934 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager.py @@ -137,13 +137,8 @@ def is_from_parent_to_skill(turn_context: TurnContext) -> bool: Determines if this turn is a request from a parent bot to this skill. """ - claims_identity = turn_context.turn_state.get( - ChannelAdapter.AGENT_IDENTITY_KEY, None - ) - return ( - isinstance(claims_identity, ClaimsIdentity) - and claims_identity.is_agent_claim() - ) + claims_identity = turn_context.identity + return claims_identity is not None and claims_identity.is_agent_claim() @staticmethod def should_send_end_of_conversation_to_parent( diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py index aade7e978..2a36b8ce3 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py @@ -27,7 +27,7 @@ TurnContext, ChannelAdapter, ClaimsIdentity, - UserTokenClient, + UserTokenClientBase, MemoryStorage, ) from microsoft_agents.hosting.core._oauth import ( @@ -80,8 +80,13 @@ def __init__( self._settings = settings @staticmethod - def _get_user_token_client(context: TurnContext) -> UserTokenClient: - return context.turn_state.get(context.adapter.USER_TOKEN_CLIENT_KEY) + def _get_user_token_client(context: TurnContext) -> UserTokenClientBase: + val = context.services.get(UserTokenClientBase) + if val is None: + raise RuntimeError( + "OAuthPrompt._get_user_token_client(): UserTokenClientBase not found in context.services." + ) + return val def _get_app_id(self, context: TurnContext) -> str: if ( @@ -255,10 +260,7 @@ async def sign_out_user(self, context: TurnContext): @staticmethod def __create_caller_info(context: TurnContext) -> CallerInfo | None: - bot_identity = cast( - ClaimsIdentity | None, - context.turn_state.get(ChannelAdapter.AGENT_IDENTITY_KEY), - ) + bot_identity = context.identity if bot_identity and bot_identity.is_agent_claim(): return CallerInfo( caller_service_url=context.activity.service_url, @@ -288,10 +290,7 @@ async def _send_oauth_card( card_action_type = ActionTypes.signin sign_in_resource = flow_response.sign_in_resource link = sign_in_resource.sign_in_link - bot_identity = cast( - ClaimsIdentity | None, - context.turn_state.get(ChannelAdapter.AGENT_IDENTITY_KEY), - ) + bot_identity = context.identity # use the SignInLink when in speech channel or bot is a skill or # an extra OAuthAppCredentials is being passed in diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py index a19863230..dbbd0c7c8 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py @@ -3,7 +3,7 @@ """Construction and caching of the Teams :class:`ApiClient` for a turn. -The client is cached on the turn state so it is built at most once per turn, and +The client is cached on turn context services so it is built at most once per turn, and is configured with a token factory derived from the turn's identity when one is available. """ @@ -16,8 +16,6 @@ TurnContext, ) -_TEAMS_API_CLIENT_KEY = "TeamsApiClient" - def _get_teams_api_client(context: TurnContext) -> ApiClient: """ @@ -27,7 +25,7 @@ def _get_teams_api_client(context: TurnContext) -> ApiClient: :return: The cached Teams API client. :raises ValueError: If the Teams API client is not found. """ - api_client = context.turn_state.get(_TEAMS_API_CLIENT_KEY) + api_client = context.services.get(ApiClient) if isinstance(api_client, ApiClient): return api_client raise ValueError("Unable to retrieve Teams API client.") @@ -43,7 +41,7 @@ def _set_teams_api_client( :param connection_manager: The connection manager. """ - if _TEAMS_API_CLIENT_KEY in context.turn_state: + if context.services.has(ApiClient): return headers = { @@ -75,4 +73,4 @@ async def token_factory() -> str: options, ) - context.turn_state[_TEAMS_API_CLIENT_KEY] = api_client + context.services.set(ApiClient, api_client) 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 f35963020..a8782f9e6 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 @@ -24,8 +24,6 @@ TextSelector = str | Pattern[str] | None -_SLACK_API_SERVICE_KEY = "microsoft_agents.hosting.slack.SlackApi" - def _matches_text(selector: TextSelector, text: Optional[str]) -> bool: if selector is None: @@ -94,8 +92,8 @@ async def call( """Invoke a Slack Web API method, preferring a per-turn :class:`SlackApi` if one has been cached on ``turn_context.services``.""" api = self._slack_api - if turn_context is not None and turn_context.has(_SLACK_API_SERVICE_KEY): - api = turn_context.get(_SLACK_API_SERVICE_KEY) # type: ignore[assignment] + if turn_context is not None and turn_context.services.has(SlackApi): + api = turn_context.services.get(SlackApi) return await api.call(method, options, token) async def create_stream( @@ -111,8 +109,8 @@ async def create_stream( ) resolved_thread_ts = thread_ts or channel_data.envelope.get("event.ts") api = self._slack_api - if turn_context.has(_SLACK_API_SERVICE_KEY): - api = turn_context.get(_SLACK_API_SERVICE_KEY) # type: ignore[assignment] + if turn_context.services.has(SlackApi): + api = turn_context.services.get(SlackApi) stream = SlackStream( api, channel_data.envelope.get("event.channel"), diff --git a/tests/_common/testing_objects/adapters/mock_testing_adapter.py b/tests/_common/testing_objects/adapters/mock_testing_adapter.py index 4cd38b453..eafc4a490 100644 --- a/tests/_common/testing_objects/adapters/mock_testing_adapter.py +++ b/tests/_common/testing_objects/adapters/mock_testing_adapter.py @@ -20,6 +20,7 @@ InvokeResponse, ) from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter +from microsoft_agents.hosting.core.connector import UserTokenClientBase from microsoft_agents.hosting.core.turn_context import TurnContext from ..testing_user_token_client import TestingUserTokenClient @@ -496,7 +497,7 @@ def create_turn_context( """ turn_context = TurnContext(self, activity) - turn_context.services["UserTokenClient"] = self._user_token_client + turn_context.services.set(UserTokenClientBase, self._user_token_client) turn_context._identity = identity or self.claims_identity return turn_context diff --git a/tests/hosting_core/app/_oauth/_common.py b/tests/hosting_core/app/_oauth/_common.py index 4d3fea34e..95151ed2e 100644 --- a/tests/hosting_core/app/_oauth/_common.py +++ b/tests/hosting_core/app/_oauth/_common.py @@ -1,6 +1,6 @@ from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core import TurnContext, UserTokenClientBase from tests._common.data import DEFAULT_TEST_VALUES from tests._common.testing_objects import mock_UserTokenClient @@ -36,14 +36,14 @@ def create_testing_TurnContext( ) else: turn_context.activity = activity - turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" - turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" agent_identity = mocker.Mock() agent_identity.claims = {"aud": DEFAULTS.ms_app_id} - turn_context.turn_state = { - "__user_token_client": user_token_client, - "__agent_identity_key": agent_identity, - } + turn_context.identity = agent_identity + turn_context.services = mocker.Mock() + turn_context.services.get.side_effect = lambda key: ( + user_token_client if key is UserTokenClientBase else None + ) + turn_context.turn_state = {} return turn_context @@ -66,13 +66,12 @@ def create_testing_TurnContext_magic( turn_context.activity.type = ActivityTypes.message else: turn_context.activity = activity - turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" - turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" agent_identity = mocker.Mock() agent_identity.claims = {"aud": DEFAULTS.ms_app_id} - turn_context.turn_state = mocker.Mock() - turn_context.turn_state = { - "__user_token_client": user_token_client, - "__agent_identity_key": agent_identity, - } + turn_context.identity = agent_identity + turn_context.services = mocker.Mock() + turn_context.services.get.side_effect = lambda key: ( + user_token_client if key is UserTokenClientBase else None + ) + turn_context.turn_state = {} return turn_context diff --git a/tests/hosting_core/app/_oauth/_handlers/test_connector_user_authorization.py b/tests/hosting_core/app/_oauth/_handlers/test_connector_user_authorization.py index 6d9c8c989..f66f46bb8 100644 --- a/tests/hosting_core/app/_oauth/_handlers/test_connector_user_authorization.py +++ b/tests/hosting_core/app/_oauth/_handlers/test_connector_user_authorization.py @@ -60,7 +60,6 @@ def create_testing_TurnContext( turn_context.activity.channel_id = channel_id turn_context.activity.from_property.id = user_id turn_context.activity.type = ActivityTypes.message - turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" # Create identity with security token identity = mocker.Mock() @@ -68,11 +67,7 @@ def create_testing_TurnContext( identity.security_token = security_token turn_context.identity = identity - agent_identity = mocker.Mock() - agent_identity.claims = {"aud": DEFAULTS.ms_app_id} - turn_context.turn_state = { - "__agent_identity_key": agent_identity, - } + turn_context.turn_state = {} return turn_context diff --git a/tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py b/tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py index e11c612b1..9bc4459d0 100644 --- a/tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py +++ b/tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py @@ -5,7 +5,7 @@ from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager -from microsoft_agents.hosting.core import MemoryStorage +from microsoft_agents.hosting.core import MemoryStorage, UserTokenClientBase from microsoft_agents.hosting.core.app.oauth import _UserAuthorization, _SignInResponse from microsoft_agents.hosting.core._oauth import ( _FlowStorageClient, @@ -63,14 +63,14 @@ def create_testing_TurnContext( turn_context.activity.channel_id = channel_id turn_context.activity.from_property.id = user_id turn_context.activity.type = ActivityTypes.message - turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" - turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" agent_identity = mocker.Mock() agent_identity.claims = {"aud": DEFAULTS.ms_app_id} - turn_context.turn_state = { - "__user_token_client": user_token_client, - "__agent_identity_key": agent_identity, - } + turn_context.identity = agent_identity + turn_context.services = mocker.Mock() + turn_context.services.get.side_effect = lambda key: ( + user_token_client if key is UserTokenClientBase else None + ) + turn_context.turn_state = {} return turn_context diff --git a/tests/hosting_core/app/proactive/test_conversation.py b/tests/hosting_core/app/proactive/test_conversation.py index 8677ddf06..d6ca5adf9 100644 --- a/tests/hosting_core/app/proactive/test_conversation.py +++ b/tests/hosting_core/app/proactive/test_conversation.py @@ -86,7 +86,7 @@ def test_from_turn_context_handles_missing_identity(self): ref = _make_reference("ctx-conv") ctx = MagicMock() ctx.activity.get_conversation_reference.return_value = ref - ctx.turn_state = {} + ctx.identity = None conv = Conversation.from_turn_context(ctx) diff --git a/tests/hosting_core/test_channel_service_adapter.py b/tests/hosting_core/test_channel_service_adapter.py index 8a041bd43..aa7267f27 100644 --- a/tests/hosting_core/test_channel_service_adapter.py +++ b/tests/hosting_core/test_channel_service_adapter.py @@ -157,14 +157,8 @@ async def callback(context: TurnContext): assert context_arg.activity.conversation.id == "conversation123" assert context_arg.activity.channel_id == "channel_id" assert context_arg.activity.service_url == service_url - assert ( - context_arg.turn_state[ChannelServiceAdapter.USER_TOKEN_CLIENT_KEY] - is user_token_client - ) - assert ( - ChannelServiceAdapter._AGENT_CONNECTOR_CLIENT_KEY - not in context_arg.turn_state - ) + assert context_arg.services.get(UserTokenClientBase) is user_token_client + assert not context_arg.services.has(ConnectorClientBase) @pytest.mark.asyncio async def test_process_activity_normal_no_service_url( @@ -244,11 +238,5 @@ async def callback(context: TurnContext): assert context_arg.activity.conversation.id == "conversation123" assert context_arg.activity.channel_id == "channel_id" assert context_arg.activity.service_url == "service_url" - assert ( - context_arg.turn_state[ChannelServiceAdapter.USER_TOKEN_CLIENT_KEY] - is user_token_client - ) - assert ( - context_arg.turn_state[ChannelServiceAdapter._AGENT_CONNECTOR_CLIENT_KEY] - is connector_client - ) + assert context_arg.services.get(UserTokenClientBase) is user_token_client + assert context_arg.services.get(ConnectorClientBase) is connector_client diff --git a/tests/hosting_core/test_service_set.py b/tests/hosting_core/test_service_set.py new file mode 100644 index 000000000..6e9278a55 --- /dev/null +++ b/tests/hosting_core/test_service_set.py @@ -0,0 +1,78 @@ +import pytest + +from microsoft_agents.hosting.core._utils._service_set import _ServiceSet + + +class Service: + pass + + +class OtherService: + pass + + +def test_get_returns_none_for_missing_service(): + services = _ServiceSet() + + assert services.get(Service) is None + + +def test_has_returns_false_for_missing_service(): + services = _ServiceSet() + + assert not services.has(Service) + + +def test_set_registers_service_by_type(): + service = Service() + services = _ServiceSet() + + services.set(Service, service) + + assert services.has(Service) + assert services.get(Service) is service + assert not services.has(OtherService) + + +def test_set_overwrites_existing_service_for_type(): + first = Service() + second = Service() + services = _ServiceSet() + + services.set(Service, first) + services.set(Service, second) + + assert services.get(Service) is second + + +def test_copy_constructor_copies_registered_services(): + service = Service() + services = _ServiceSet() + services.set(Service, service) + + copy = _ServiceSet(services) + + assert copy.get(Service) is service + + +def test_copy_constructor_does_not_share_state_dictionary(): + service = Service() + replacement = Service() + services = _ServiceSet() + services.set(Service, service) + copy = _ServiceSet(services) + + services.set(Service, replacement) + + assert copy.get(Service) is service + assert services.get(Service) is replacement + + +def test_get_raises_type_error_when_stored_value_does_not_match_key(): + services = _ServiceSet() + services._state[Service] = OtherService() + + with pytest.raises( + TypeError, + ): + services.get(Service) diff --git a/tests/hosting_dialogs/helpers.py b/tests/hosting_dialogs/helpers.py index b8e93bfcb..87529bf28 100644 --- a/tests/hosting_dialogs/helpers.py +++ b/tests/hosting_dialogs/helpers.py @@ -16,7 +16,7 @@ SignInResource, TokenOrSignInResourceResponse, ) -from microsoft_agents.hosting.core import ChannelAdapter, TurnContext +from microsoft_agents.hosting.core import TurnContext, UserTokenClientBase from microsoft_agents.hosting.core.authorization import ClaimsIdentity from tests._common.testing_objects import MockTestingAdapter @@ -116,6 +116,9 @@ def __init__(self): ) self.agent_sign_in = _MockAgentSignIn() + async def close(self) -> None: + return None + def add_user_token( self, connection_name: str, @@ -210,7 +213,7 @@ class DialogTestAdapter(MockTestingAdapter): """ A test adapter compatible with the botbuilder TestAdapter API. Provides send() and assert_reply() methods for fluent test flows. - Also provides a proper UserTokenClient in turn_state for OAuthPrompt tests. + Also provides a proper UserTokenClient service for OAuthPrompt tests. """ def __init__(self, callback: AgentCallbackHandler = None, **kwargs): @@ -218,7 +221,7 @@ def __init__(self, callback: AgentCallbackHandler = None, **kwargs): self._callback = callback # Dialog-specific token client that implements the user_token API self._dialog_token_client = DialogUserTokenClient() - # OAuthPrompt reads claims["aud"] from the identity in turn_state + # OAuthPrompt reads claims["aud"] from the turn context identity. self.claims_identity = ClaimsIdentity({"aud": "test-app-id"}, True) def add_user_token( @@ -269,17 +272,12 @@ def create_turn_context( self, activity: Activity, identity: ClaimsIdentity = None ) -> TurnContext: """ - Creates a turn context with the dialog token client in turn_state + Creates a turn context with the dialog token client service so OAuthPrompt can find it via _UserTokenAccess. """ turn_context = super().create_turn_context(activity, identity) - turn_context.turn_state[ChannelAdapter.USER_TOKEN_CLIENT_KEY] = ( - self._dialog_token_client - ) - # OAuthPrompt reads claims["aud"] from this identity - turn_context.turn_state[ChannelAdapter.AGENT_IDENTITY_KEY] = ( - identity or self.claims_identity - ) + turn_context.services.set(UserTokenClientBase, self._dialog_token_client) + turn_context._identity = identity or self.claims_identity return turn_context def make_activity(self, text: str = None) -> Activity: diff --git a/tests/hosting_msteams/helpers.py b/tests/hosting_msteams/helpers.py index 0b5dbbff9..d82cdf62f 100644 --- a/tests/hosting_msteams/helpers.py +++ b/tests/hosting_msteams/helpers.py @@ -17,6 +17,20 @@ from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +class _FakeServiceSet: + def __init__(self): + self._state = {} + + def get(self, key): + return self._state.get(key) + + def has(self, key): + return key in self._state + + def set(self, key, value): + self._state[key] = value + + def _make_app() -> Any: app = MagicMock(spec=AgentApplication) app._routes = [] @@ -64,7 +78,7 @@ def _make_context( mock_adapter = MagicMock() context.adapter = mock_adapter context._responded = False - context._services = {} + context._services = _FakeServiceSet() context._on_send_activities = [] context._on_update_activity = [] context._on_delete_activity = [] @@ -74,7 +88,7 @@ def _copy_to(target): target.adapter = mock_adapter target._activity = activity target._responded = False - target._services = {} + target._services = _FakeServiceSet() target._on_send_activities = [] target._on_update_activity = [] target._on_delete_activity = [] diff --git a/tests/hosting_msteams/test_internal.py b/tests/hosting_msteams/test_internal.py index 4b840d993..1ea3a2f97 100644 --- a/tests/hosting_msteams/test_internal.py +++ b/tests/hosting_msteams/test_internal.py @@ -16,7 +16,6 @@ from microsoft_teams.api import ApiClient from microsoft_agents.hosting.msteams._teams_api_client import ( - _TEAMS_API_CLIENT_KEY, _get_teams_api_client, ) from microsoft_agents.hosting.msteams.errors.error_resources import ( @@ -24,27 +23,35 @@ ) +class _FakeServices: + def __init__(self, values=None): + self._values = values or {} + + def get(self, key): + return self._values.get(key) + + class _FakeContext: - """Minimal stand-in exposing only the ``turn_state`` dict the accessor reads.""" + """Minimal stand-in exposing only the ``services`` accessor reads.""" - def __init__(self, turn_state): - self.turn_state = turn_state + def __init__(self, services): + self.services = services class TestGetTeamsApiClient: def test_returns_cached_api_client(self): client = ApiClient("https://smba.trafficmanager.net/teams/") - ctx = _FakeContext({_TEAMS_API_CLIENT_KEY: client}) + ctx = _FakeContext(_FakeServices({ApiClient: client})) assert _get_teams_api_client(ctx) is client def test_raises_when_missing(self): - ctx = _FakeContext({}) + ctx = _FakeContext(_FakeServices()) with pytest.raises(ValueError, match="Teams API client"): _get_teams_api_client(ctx) def test_raises_when_wrong_type(self): - ctx = _FakeContext({_TEAMS_API_CLIENT_KEY: object()}) + ctx = _FakeContext(_FakeServices({ApiClient: object()})) with pytest.raises(ValueError, match="Teams API client"): _get_teams_api_client(ctx) diff --git a/tests/hosting_msteams/test_teams_agent_extension.py b/tests/hosting_msteams/test_teams_agent_extension.py index fac8fae1a..c8da5a822 100644 --- a/tests/hosting_msteams/test_teams_agent_extension.py +++ b/tests/hosting_msteams/test_teams_agent_extension.py @@ -14,12 +14,10 @@ if is_supported_version: from microsoft_agents.activity import Activity, Channels + from microsoft_teams.api import ApiClient from microsoft_teams.api.models import ChannelData from microsoft_agents.hosting.msteams import TeamsAgentExtension - from microsoft_agents.hosting.msteams._teams_api_client import ( - _TEAMS_API_CLIENT_KEY, - ) from microsoft_agents.hosting.msteams.channel import Channel from microsoft_agents.hosting.msteams.config import Config from microsoft_agents.hosting.msteams.file_consent import FileConsent @@ -30,13 +28,24 @@ from microsoft_agents.hosting.msteams.team import Team +class _FakeServiceSet: + def __init__(self): + self._state = {} + + def has(self, key): + return key in self._state + + def set(self, key, value): + self._state[key] = value + + class _FakeContext: """Minimal context stand-in for exercising the before_turn hook directly.""" def __init__(self, activity, identity=None): self.activity = activity self.identity = identity - self.turn_state = {} + self.services = _FakeServiceSet() class TestTeamsAgentExtensionProperties: @@ -99,7 +108,7 @@ async def test_non_teams_channel_is_untouched(self): assert result is True # channel_data left as the raw dict; no Teams API client cached assert activity.channel_data == {"channel": {"id": "c"}} - assert _TEAMS_API_CLIENT_KEY not in ctx.turn_state + assert not ctx.services.has(ApiClient) @pytest.mark.asyncio async def test_teams_channel_deserializes_channel_data(self): @@ -116,7 +125,7 @@ async def test_teams_channel_deserializes_channel_data(self): assert result is True assert isinstance(activity.channel_data, ChannelData) assert activity.channel_data.channel.id == "c1" - assert _TEAMS_API_CLIENT_KEY in ctx.turn_state + assert ctx.services.has(ApiClient) @pytest.mark.asyncio async def test_teams_channel_without_channel_data_sets_none(self): diff --git a/tests/hosting_slack/test_slack_agent_extension.py b/tests/hosting_slack/test_slack_agent_extension.py index 20941277e..13a048bbb 100644 --- a/tests/hosting_slack/test_slack_agent_extension.py +++ b/tests/hosting_slack/test_slack_agent_extension.py @@ -14,6 +14,7 @@ from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.app import AgentApplication, RouteRank from microsoft_agents.hosting.slack import SlackAgentExtension +from microsoft_agents.hosting.slack.api import SlackApi def _make_app() -> MagicMock: @@ -52,7 +53,8 @@ def _make_context( context = MagicMock(spec=TurnContext) context.activity = activity context.send_activity = AsyncMock() - context.has.return_value = False + context.services = MagicMock() + context.services.has.return_value = False return context @@ -157,10 +159,12 @@ async def test_call_prefers_turn_context_service_when_present(self): slack = SlackAgentExtension(app, slack_api=default_api) ctx = _make_context(ActivityTypes.message) - ctx.has.return_value = True - ctx.get.return_value = per_turn_api + ctx.services.has.return_value = True + ctx.services.get.return_value = per_turn_api out = await slack.call(ctx, "auth.test") assert out == "per-turn" + ctx.services.has.assert_called_once_with(SlackApi) + ctx.services.get.assert_called_once_with(SlackApi) per_turn_api.call.assert_awaited_once() default_api.call.assert_not_awaited()