diff --git a/.azdo/ci-pr.yaml b/.azdo/ci-pr.yaml index e881ce976..e7beb2003 100644 --- a/.azdo/ci-pr.yaml +++ b/.azdo/ci-pr.yaml @@ -88,6 +88,7 @@ steps: python -m pip install ./dist/microsoft_agents_hosting_fastapi*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl + python -m pip install ./dist/microsoft_agents_testing*.whl displayName: 'Install wheels' - script: | diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3c88a7a0c..1f6235fe5 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -77,6 +77,7 @@ jobs: python -m pip install ./dist/microsoft_agents_hosting_fastapi*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl + python -m pip install ./dist/microsoft_agents_testing*.whl - name: Test with pytest run: | pytest -W "ignore:SelectableGroups dict interface is deprecated. Use select.:DeprecationWarning" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_adapter_protocol.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_adapter_protocol.py index 811d694f1..ec747ff44 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_adapter_protocol.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_adapter_protocol.py @@ -4,6 +4,8 @@ from abc import abstractmethod from typing import Protocol, Callable, Awaitable, Optional +from typing_extensions import Self + from .turn_context_protocol import TurnContextProtocol from microsoft_agents.activity import ( Activity, @@ -35,7 +37,7 @@ async def delete_activity( pass @abstractmethod - def use(self, middleware: object) -> "ChannelAdapterProtocol": + def use(self, middleware: object) -> Self: pass @abstractmethod diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_status.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_status.py index 3509715de..b6d9d1fc5 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_status.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_status.py @@ -3,9 +3,6 @@ """Models for token status operations.""" -from typing import Optional -from pydantic import Field - from .agents_model import AgentsModel from ._type_aliases import NonEmptyString @@ -15,18 +12,16 @@ class TokenStatus(AgentsModel): The status of a user token. :param channel_id: The channelId of the token status pertains to. - :type channel_id: str + :type channel_id: str | None :param connection_name: The name of the connection the token status pertains to. - :type connection_name: str + :type connection_name: str | None :param has_token: True if a token is stored for this ConnectionName. - :type has_token: bool + :type has_token: bool | None :param service_provider_display_name: The display name of the service provider for which this Token belongs to. - :type service_provider_display_name: str + :type service_provider_display_name: str | None """ - channel_id: Optional[NonEmptyString] = Field(None, alias="channelId") - connection_name: Optional[NonEmptyString] = Field(None, alias="connectionName") - has_token: Optional[bool] = Field(None, alias="hasToken") - service_provider_display_name: Optional[NonEmptyString] = Field( - None, alias="serviceProviderDisplayName" - ) + channel_id: NonEmptyString | None = None + connection_name: NonEmptyString | None = None + has_token: bool | None = None + service_provider_display_name: NonEmptyString | None = None 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 29e00f7f1..6499eefbe 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 @@ -3,6 +3,8 @@ from __future__ import annotations +from typing_extensions import Self + from abc import ABC, abstractmethod from collections.abc import Callable from typing import Awaitable @@ -78,7 +80,7 @@ async def delete_activity( """ raise NotImplementedError() - def use(self, middleware: Middleware) -> ChannelAdapter: + def use(self, middleware: Middleware) -> Self: """ Registers a middleware handler with the adapter. diff --git a/libraries/microsoft-agents-testing/LICENSE b/libraries/microsoft-agents-testing/LICENSE new file mode 100644 index 000000000..9e841e7a2 --- /dev/null +++ b/libraries/microsoft-agents-testing/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/libraries/microsoft-agents-testing/MANIFEST.in b/libraries/microsoft-agents-testing/MANIFEST.in new file mode 100644 index 000000000..43a71d9ed --- /dev/null +++ b/libraries/microsoft-agents-testing/MANIFEST.in @@ -0,0 +1 @@ +include VERSION.txt \ No newline at end of file diff --git a/libraries/microsoft-agents-testing/microsoft_agents/testing/__init__.py b/libraries/microsoft-agents-testing/microsoft_agents/testing/__init__.py new file mode 100644 index 000000000..c0e46fcc1 --- /dev/null +++ b/libraries/microsoft-agents-testing/microsoft_agents/testing/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .auth import MockUserTokenClient +from .test_adapter import TestAdapter +from .test_flow import TestFlow + +__all__ = ["MockUserTokenClient", "TestAdapter", "TestFlow"] diff --git a/libraries/microsoft-agents-testing/microsoft_agents/testing/_defaults.py b/libraries/microsoft-agents-testing/microsoft_agents/testing/_defaults.py new file mode 100644 index 000000000..740a789a5 --- /dev/null +++ b/libraries/microsoft-agents-testing/microsoft_agents/testing/_defaults.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +_SERVICE_URL = "https://test.com" + +_CONV_ID = "convo1" +_CONV_NAME = "Conversation 1" + +_BOT_ID = "bot" +_BOT_NAME = "Bot" + +_USER_ID = "user1" +_USER_NAME = "User 1" + +_LOCALE = "en-US" diff --git a/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py b/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py new file mode 100644 index 000000000..bf350672d --- /dev/null +++ b/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .mock_user_token_client import MockUserTokenClient + +__all__ = [ + "MockUserTokenClient", +] diff --git a/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_types.py b/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_types.py new file mode 100644 index 000000000..7c16cc1de --- /dev/null +++ b/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_types.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, eq=False) +class UserTokenKey: + """A key that uniquely identifies a user token in the mock client.""" + + connection_name: str + user_id: str + channel_id: str + + def __eq__(self, other: Any) -> bool: + return ( + isinstance(other, UserTokenKey) + and self.connection_name.casefold() == other.connection_name.casefold() + and self.user_id.casefold() == other.user_id.casefold() + and self.channel_id.casefold() == other.channel_id.casefold() + ) + + def __hash__(self) -> int: + return hash( + ( + self.connection_name.casefold(), + self.user_id.casefold(), + self.channel_id.casefold(), + ) + ) + + +@dataclass(frozen=True, eq=False) +class ExchangeableTokenKey(UserTokenKey): + """A key that uniquely identifies an exchangeable token in the mock client.""" + + exchangeable_item: str + + def __eq__(self, other): + return ( + super().__eq__(other) + and isinstance(other, ExchangeableTokenKey) + and self.exchangeable_item.casefold() == other.exchangeable_item.casefold() + ) + + def __hash__(self) -> int: + return hash((super().__hash__(), self.exchangeable_item.casefold())) + + +@dataclass(frozen=True) +class TokenMagicCode: + """A class that represents a magic code for a token.""" + + key: UserTokenKey + magic_code: str + user_token: str diff --git a/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/mock_user_token_client.py b/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/mock_user_token_client.py new file mode 100644 index 000000000..71c1b81cb --- /dev/null +++ b/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/mock_user_token_client.py @@ -0,0 +1,371 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from uuid import uuid4 + +from microsoft_agents.activity import ( + Activity, + ChannelId, + SignInResource, + TokenExchangeRequest, + TokenExchangeResource, + TokenExchangeState, + TokenOrSignInResourceResponse, + TokenResponse, + TokenStatus, +) +from microsoft_agents.hosting.core import UserTokenClientBase + +from ._types import ( + UserTokenKey, + ExchangeableTokenKey, + TokenMagicCode, +) + +_RAISE_EXCEPTION = "_raise_exception" + + +class MockUserTokenClient(UserTokenClientBase): + """In-memory stand-in for ``UserTokenClientBase`` used by ``TestAdapter``. + + The mock stores user tokens, magic-code tokens, and token-exchange results + in dictionaries keyed by connection, channel, and user. It lets tests drive + OAuth prompt and token-exchange flows without calling the Agents token + service. Sign-in resources are synthetic and deterministic enough for unit + tests, but they are not valid service URLs and no network call is made. + """ + + _user_tokens: dict[UserTokenKey, str] + _exchangable_tokens: dict[ExchangeableTokenKey, str] + _magic_codes: list[TokenMagicCode] + + def __init__(self): + """Create an empty in-memory token store.""" + self._user_tokens = {} + self._exchangable_tokens = {} + self._magic_codes = [] + + @property + def agent_sign_in(self): + """Agent sign-in operations are not modeled by this mock client.""" + raise NotImplementedError() + + @property + def user_token(self): + """Nested user-token operations are not exposed by this mock client.""" + raise NotImplementedError() + + def add_user_token( + self, + *, + connection_name: str, + channel_id: str, + user_id: str, + token: str, + magic_code: str | None = None, + ) -> None: + """Add a fake user token that can later be retrieved by OAuth code. + + Without a magic code, the token is returned immediately for the matching + connection, channel, and user. With a magic code, the token is held in a + separate one-time list and is promoted to the normal token store only + when :meth:`get_user_token` is called with the same code. + + :param connection_name: The name of the connection. + :param channel_id: The channel ID. + :param user_id: The user ID. + :param token: The token to be added. + :param magic_code: An optional magic code associated with the token. + """ + key = UserTokenKey( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ) + + if magic_code is None: + self._user_tokens[key] = token + else: + self._magic_codes.append( + TokenMagicCode(key=key, magic_code=magic_code, user_token=token) + ) + + def add_exchangeable_token( + self, + *, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + token: str, + ) -> None: + """Add a fake token-exchange result. + + ``exchangeable_item`` represents either the exchange request token or + URI. A later :meth:`exchange_token` call for the same connection, + channel, user, and item returns ``token``. + """ + + key = ExchangeableTokenKey( + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + exchangeable_item=exchangeable_item, + ) + + self._exchangable_tokens[key] = token + + def raise_on_exchange_request( + self, + *, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + ) -> None: + """Make a matching token-exchange request raise an exception. + + This is a test-only way to simulate token service exchange failures + without a real service. + + :param connection_name: The name of the connection. + :param channel_id: The channel ID. + :param user_id: The user ID. + :param exchangeable_item: The item to be exchanged. + """ + key = ExchangeableTokenKey( + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + exchangeable_item=exchangeable_item, + ) + + self._exchangable_tokens[key] = _RAISE_EXCEPTION + + async def get_user_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + magic_code: str | None, + ) -> TokenResponse: + """Retrieve a fake user token from the in-memory store. + + When ``magic_code`` matches a stored one-time code, the associated token + is moved into the normal token store before lookup. If no token is found, + an empty :class:`TokenResponse` is returned. + + :param user_id: The user ID. + :param connection_name: The name of the connection. + :param channel_id: The channel ID. + :param magic_code: An optional magic code associated with the token. + :return: A TokenResponse containing the token if found, otherwise an empty TokenResponse. + """ + + key = UserTokenKey( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ) + + if magic_code is not None: + index = next( + ( + i + for i, mc in enumerate(self._magic_codes) + if mc.key == key and mc.magic_code == magic_code + ), + None, + ) + if index is not None: + mc = self._magic_codes.pop(index) + self.add_user_token( + connection_name=connection_name, + channel_id=key.channel_id, + user_id=key.user_id, + token=mc.user_token, + ) + + if key in self._user_tokens: + token = self._user_tokens[key] + return TokenResponse( + token=token, + connection_name=connection_name, + ) + return TokenResponse() + + async def get_sign_in_resource( + self, + connection_name: str, + activity: Activity, + final_redirect: str | None = None, + ) -> SignInResource: + """Return a synthetic sign-in resource for tests. + + The returned link and token-exchange resource are fake values derived + from the connection and activity. They are intended only to let tests + assert that a sign-in prompt would be sent. + + :param connection_name: The name of the connection. + :param activity: The activity associated with the sign-in request. + :param final_redirect: An optional final redirect URL. + :return: A SignInResource containing the sign-in URL and other details. + """ + activity_channel_id = activity.channel_id if activity.channel_id else "unknown" + activity_recipient_id = ( + activity.recipient.id if activity.recipient else "unknown" + ) + return SignInResource( + sign_in_link=f"https://fake.com/oauthsignin/{connection_name}/{activity_channel_id}/{activity_recipient_id}", + token_exchange_resource=TokenExchangeResource( + id=uuid4().hex, uri=f"api://{connection_name}/resource" + ), + ) + + async def get_token_or_sign_in_resource( + self, + connection_name: str, + activity: Activity, + code: str | None = None, + final_redirect: str | None = None, + fwd_url: str | None = None, + ) -> TokenOrSignInResourceResponse: + """Return either a stored token or a synthetic sign-in resource. + + This mirrors the token service shortcut used by OAuth prompts: if a + token is already available for the activity's user/channel, return it; + otherwise return a fake sign-in resource. + + :param connection_name: The name of the connection. + :param activity: The activity associated with the request. + :param code: An optional magic code associated with the token. + :param final_redirect: An optional final redirect URL. + :param fwd_url: An optional forward URL. + :return: A TokenOrSignInResourceResponse containing either a token or a sign-in resource. + """ + + if not activity.from_property or not activity.from_property.id: + raise ValueError("Activity must have a valid 'from' property with an 'id'.") + + token_response = await self.get_user_token( + user_id=activity.from_property.id, + connection_name=connection_name, + channel_id=activity.channel_id if activity.channel_id else "unknown", + magic_code=code, + ) + + if token_response: + return TokenOrSignInResourceResponse(token_response=token_response) + + return TokenOrSignInResourceResponse( + sign_in_resource=await self.get_sign_in_resource( + connection_name=connection_name, + activity=activity, + final_redirect=final_redirect, + ) + ) + + async def sign_out_user( + self, user_id: str, connection_name: str, channel_id: str + ) -> None: + """Sign out a user by removing matching tokens from the mock store.""" + keys_copy = list(self._user_tokens.keys()) + for key in keys_copy: + if ( + key.channel_id.casefold() == channel_id.casefold() + and key.user_id.casefold() == user_id.casefold() + and key.connection_name.casefold() == connection_name.casefold() + ): + self._user_tokens.pop(key) + + async def get_token_status( + self, + user_id: str, + channel_id: str, + include: str | None = None, + ) -> list[TokenStatus]: + """Return token status entries for stored tokens. + + The mock reports a token as present when one exists in the in-memory + store for the requested user and channel. ``include`` filters by + connection name. + + :param user_id: The user ID. + :param channel_id: The channel ID. + :param include: An optional comma-separated list of connection names to filter the results. + :return: A list of TokenStatus objects representing the token status for the user. + """ + include_filter = include.split(",") if include else None + return [ + TokenStatus( + connection_name=key.connection_name, + has_token=True, + service_provider_display_name=key.connection_name, + ) + for key in self._user_tokens.keys() + if key.user_id.casefold() == user_id.casefold() + and key.channel_id.casefold() == channel_id.casefold() + and (include_filter is None or key.connection_name in include_filter) + ] + + async def get_aad_tokens( + self, + user_id: str, + connection_name: str, + resource_urls: list[str], + channel_id: str, + ) -> dict[str, TokenResponse]: + """Return fake AAD tokens. + + The Python testing mock currently does not model per-resource AAD token + acquisition, so this returns an empty mapping. + """ + return {} + + async def exchange_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + """Exchange a fake token or URI for a stored token response. + + If :meth:`raise_on_exchange_request` configured the matching item to + fail, this raises an exception. If no matching item is registered, an + empty :class:`TokenResponse` is returned. + + :param user_id: The user ID. + :param connection_name: The name of the connection. + :param channel_id: The channel ID. + :param exchange_request: The token exchange request containing the token or URI to be exchanged. + """ + + exchangeable_value = exchange_request.token or exchange_request.uri + if not exchangeable_value: + raise ValueError( + "Either token or uri must be provided in the exchange request." + ) + + key = ExchangeableTokenKey( + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + exchangeable_item=exchangeable_value, + ) + + if key in self._exchangable_tokens: + token = self._exchangable_tokens[key] + if token == _RAISE_EXCEPTION: + raise Exception("Simulated exception during token exchange.") + + return TokenResponse( + channel_id=channel_id, connection_name=connection_name, token=token + ) + + return TokenResponse() + + async def close(self) -> None: + """Close the mock client. + + The mock owns no network connections or other external resources, so + this is a no-op. + """ + # In this mock implementation, there's nothing to close. + pass diff --git a/libraries/microsoft-agents-testing/microsoft_agents/testing/test_adapter.py b/libraries/microsoft-agents-testing/microsoft_agents/testing/test_adapter.py new file mode 100644 index 000000000..0de71756c --- /dev/null +++ b/libraries/microsoft-agents-testing/microsoft_agents/testing/test_adapter.py @@ -0,0 +1,513 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""In-memory channel adapter for exercising agent turns in tests. + +The objects in this module intentionally model only the parts of a channel that +unit tests usually need: inbound activities are normalized with a test +conversation reference, outbound activities are captured in an in-memory queue, +and OAuth/token operations are backed by a mock user-token client unless a test +provides its own implementation. +""" + +import asyncio + +from typing import Awaitable, Any + +from datetime import datetime, timezone + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ChannelId, + Channels, + ConversationAccount, + ConversationParameters, + ConversationReference, + InvokeResponse, + ResourceResponse, + RoleTypes, +) + +from microsoft_agents.hosting.core import ( + ChannelAdapter, + ClaimsIdentity, + UserTokenClientBase, + TurnContext, +) +from .auth import MockUserTokenClient +from .type_def import AgentCallbackHandler, T + +from . import _defaults as _DEFAULTS + + +class TestAdapter(ChannelAdapter): + """A lightweight adapter for unit-testing agent logic without a real channel. + + ``TestAdapter`` behaves like a channel adapter at the turn-processing + boundary, but it does not call Connector Service, Teams, or any external + token service. Instead, it: + + * fills in missing activity fields from a single in-memory + :class:`ConversationReference`; + * runs the activity through the normal middleware/agent pipeline; + * stores activities sent by the agent in :attr:`activity_queue`; + * exposes a mock :class:`UserTokenClientBase` through ``TurnContext.services`` + so OAuth flows can be tested without a live token service. + + The Python adapter is intentionally smaller than the .NET TestAdapter. It + does not maintain multiple conversations, does not create real proactive + conversations, and does not simulate channel-specific delivery behavior + beyond assigning IDs/timestamps and queueing replies. + """ + + __test__ = False + + claims_identity: ClaimsIdentity + _activity_queue: list[Activity] + _queued_requests: list[asyncio.Future[Activity]] + + def __init__( + self, + *, + channel_id: str | ChannelId | None = None, + conversation: ConversationReference | None = None, + user_token_client: UserTokenClientBase | None = None + ) -> None: + """Create a test adapter with a default or caller-provided conversation. + + :param channel_id: Channel ID to use when creating the default test + conversation. Ignored when ``conversation`` is supplied. + :param conversation: Optional conversation reference used to stamp + inbound activities and create turn contexts. + :param user_token_client: Optional user-token client test double. When + omitted, the adapter uses :class:`MockUserTokenClient`. + """ + + super().__init__() + + channel_id = channel_id or Channels.test + + self._id_counter = 0 + self._user_token_client = user_token_client or MockUserTokenClient() + + if conversation: + self._conversation = conversation + else: + self._conversation = TestAdapter.create_conversation_model( + channel_id=channel_id, + ) + + self._locale = _DEFAULTS._LOCALE + + self._activity_queue = [] + self._queued_requests = [] + self.claims_identity = ClaimsIdentity({}, True) + + @property + def conversation(self) -> ConversationReference: + """Conversation reference used to populate incoming test activities.""" + return self._conversation + + @property + def locale(self) -> str: + """Locale copied onto activities created by :meth:`create_activity`.""" + return self._locale + + @locale.setter + def locale(self, value: str) -> None: + self._locale = value + + @property + def activity_queue(self) -> list[Activity]: + """Activities sent by the agent and captured instead of sent to a channel.""" + return self._activity_queue + + def _gen_id(self) -> str: + """Return the next deterministic activity ID for this adapter instance.""" + self._id_counter += 1 + return str(self._id_counter) + + @staticmethod + def create_conversation_model( + *, + channel_id: str | ChannelId = Channels.test, + conv_id: str = _DEFAULTS._CONV_ID, + conv_name: str = _DEFAULTS._CONV_NAME, + user_id: str = _DEFAULTS._USER_ID, + user_name: str = _DEFAULTS._USER_NAME, + bot_id: str = _DEFAULTS._BOT_ID, + bot_name: str = _DEFAULTS._BOT_NAME, + locale: str = _DEFAULTS._LOCALE + ) -> ConversationReference: + """Create a conversation reference for tests. + + The returned reference uses the testing service URL and caller-provided + user, bot, conversation, locale, and channel values. The adapter uses + this reference to fill activity ``from``, ``recipient``, ``conversation``, + ``service_url``, and ``channel_id`` fields when a test activity omits + them. + """ + return ConversationReference( + channel_id=ChannelId(channel_id), + service_url=_DEFAULTS._SERVICE_URL, + user=ChannelAccount(id=user_id, name=user_name), + agent=ChannelAccount(id=bot_id, name=bot_name), + conversation=ConversationAccount( + is_group=False, id=conv_id, name=conv_name + ), + locale=locale, + ) + + def create_turn_context( + self, activity: Activity, claims_identity: ClaimsIdentity | None = None + ) -> TurnContext: + """Create the turn context used by the test adapter. + + The context uses this adapter, the supplied activity, and either the + supplied identity or :attr:`claims_identity`. It also registers the + adapter's user-token client in ``context.services`` so OAuth-related + code can retrieve a token client without contacting a real service. + + :param activity: Activity for the current test turn. + :param claims_identity: Optional identity for the turn. + :return: A ``TurnContext`` ready to run through middleware and agent + logic. + """ + + context = TurnContext( + self, + activity, + identity=claims_identity or self.claims_identity, + ) + context.services.set(UserTokenClientBase, self._user_token_client) + return context + + async def process_activity( + self, + claims_identity: ClaimsIdentity, + activity: Activity, + callback: AgentCallbackHandler, + ) -> InvokeResponse | None: + """Process an inbound activity through the test pipeline. + + Missing channel-like fields are populated from :attr:`conversation`: + activity type defaults to ``message``, channel ID defaults to the test + conversation channel, ``from`` defaults to the test user, and + ``recipient``, ``conversation``, and ``service_url`` are replaced with + the test conversation values. The adapter assigns an activity ID and + timestamps before invoking middleware and the callback. + + No HTTP request is made and no real channel response is produced; sent + activities are captured by :meth:`send_activities`. + + :param claims_identity: Identity associated with the inbound activity. + :param activity: Activity to deliver to the agent. + :param callback: Turn logic to invoke. + :return: ``None``; invoke response behavior is not simulated here. + """ + + if not activity.type: + activity.type = ActivityTypes.message + + if not activity.channel_id: + activity.channel_id = self.conversation.channel_id + + if ( + not activity.from_property + or not activity.from_property.id + or activity.from_property.role == RoleTypes.agent + ): + if not self._conversation.user: + raise ValueError( + "Activity must have a 'from' property with a valid user ID and role." + ) + activity.from_property = self._conversation.user + + activity.recipient = self._conversation.agent + activity.conversation = self._conversation.conversation + activity.service_url = self._conversation.service_url + activity.id = self._gen_id() + + if not activity.timestamp: + activity.timestamp = datetime.now(timezone.utc) + + if not activity.local_timestamp: + activity.local_timestamp = datetime.now() + + context = self.create_turn_context(activity, claims_identity) + await self.run_pipeline(context, callback) + return None + + async def process_proactive( + self, + claims_identity: ClaimsIdentity, + continuation_activity: Activity, + audience: str, + callback: AgentCallbackHandler, + ): + """Run proactive turn logic against a supplied continuation activity. + + This simplified implementation creates a turn context and runs the + pipeline. It does not create a conversation, validate ``audience``, or + call a channel service. + + :param claims_identity: Identity for the proactive turn. + :param continuation_activity: Activity used to create the turn context. + :param audience: Accepted for interface compatibility; not used. + :param callback: Turn logic to invoke. + """ + context = self.create_turn_context(continuation_activity, claims_identity) + await self.run_pipeline(context, callback) + + async def send_activities( + self, + context: TurnContext, + activities: list[Activity], + ) -> list[ResourceResponse]: + """Capture outgoing activities in the adapter queue. + + Activities sent by the agent are assigned IDs and timestamps when + missing, then appended to :attr:`activity_queue` or delivered to a + pending :meth:`get_next_reply_async` waiter. This replaces sending to a + real channel. + + :param context: Current turn context. + :param activities: Activities sent by the agent. + :return: Resource responses containing the assigned activity IDs. + """ + + if not activities: + raise ValueError("Activities list cannot be empty.") + + responses: list[ResourceResponse] = [] + + for activity in activities: + + if not activity.id: + activity.id = self._gen_id() + + if not activity.timestamp: + activity.timestamp = datetime.now(timezone.utc) + + self._enqueue(activity) + + responses.append(ResourceResponse(id=activity.id)) + + return responses + + async def update_activity( + self, + context: TurnContext, + activity: Activity, + ) -> ResourceResponse: + """Replace a queued activity with the same ID. + + This simulates channel update behavior by editing the in-memory queue. + If no queued activity has the requested ID, an empty + :class:`ResourceResponse` is returned. + """ + + if activity.id: + replies = list(self._activity_queue) + for i, reply in enumerate(replies): + if reply.id == activity.id: + replies[i] = activity + self._activity_queue.clear() + for reply in replies: + self._activity_queue.append(reply) + + return ResourceResponse(id=activity.id) + return ResourceResponse() + + async def delete_activity( + self, + context: TurnContext, + reference: ConversationReference, + ) -> None: + """Remove a queued activity identified by ``reference.activity_id``. + + Deletion is limited to the adapter's in-memory queue and does not call a + channel service. + """ + + if not reference.activity_id: + return + + replies = list(self._activity_queue) + for i, reply in enumerate(replies): + if reply.id == reference.activity_id: + del replies[i] + self._activity_queue.clear() + for reply in replies: + self._activity_queue.append(reply) + return + + async def create_conversation( + self, + agent_app_id: str, + channel_id: str, + service_url: str, + audience: str, + conversation_parameters: ConversationParameters, + callback: AgentCallbackHandler[T], + ) -> Awaitable[Any]: + raise NotImplementedError() + + def get_activity_snapshot(self) -> list[Activity]: + """Return a shallow copy of the currently queued bot replies.""" + return list(self._activity_queue) + + def get_next_reply(self) -> Activity | None: + """Dequeue and return the next captured activity, or ``None`` if empty.""" + if len(self._activity_queue) > 0: + return self._activity_queue.pop(0) + return None + + async def get_next_reply_async(self) -> Activity | None: + """Return the next captured activity from the queue. + + This mirrors the .NET TestAdapter pattern: if no waiter is already + queued, an available reply is dequeued and returned immediately. + Otherwise a future is queued and completed by the next captured reply. + Timeout and cancellation are owned by callers such as ``TestFlow``. + """ + if not self._queued_requests: + result = self.get_next_reply() + if result is not None: + return result + + loop = asyncio.get_running_loop() + future: asyncio.Future[Activity] = loop.create_future() + self._queued_requests.append(future) + return await future + + def create_activity( + self, + text: str, + ) -> Activity: + """Create a message activity from text and the test conversation. + + The returned activity is shaped like a user message in the current test + conversation and is suitable for :meth:`process_activity` or + :meth:`send_text_to_bot`. + """ + return Activity( + type=ActivityTypes.message, + text=text, + locale=self.locale or _DEFAULTS._LOCALE, + recipient=self._conversation.agent, + from_property=self._conversation.user, + conversation=self._conversation.conversation, + service_url=self._conversation.service_url, + id=self._gen_id(), + ) + + async def send_text_to_bot( + self, user_says: str, callback: AgentCallbackHandler + ) -> InvokeResponse | None: + """Send text as a user message through the test adapter. + + This helper creates a message activity using :meth:`create_activity` and + processes it through the normal test pipeline. A claims identity must be + configured on :attr:`claims_identity` before calling this method. + """ + return await self.process_activity( + self.claims_identity, + self.create_activity(user_says), + callback, + ) + + def add_user_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + token: str, + magic_code: str | None = None, + ) -> None: + """Add a fake user token to the adapter's mock token client. + + The token can later be returned by OAuth flows that request the same + connection, channel, and user. If ``magic_code`` is supplied, the token + is returned only when that code is provided. + """ + if isinstance(self._user_token_client, MockUserTokenClient): + self._user_token_client.add_user_token( + connection_name=connection_name, + channel_id=channel_id, + user_id=user_id, + token=token, + magic_code=magic_code, + ) + else: + raise TypeError( + "UserTokenClient is not a MockUserTokenClient. Cannot add user token." + ) + + def add_exchangeable_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + token: str, + ) -> None: + """Add a fake token exchange result to the mock token client. + + OAuth token-exchange tests can provide a token or URI as + ``exchangeable_item`` and receive ``token`` when the same item is + exchanged. + """ + if isinstance(self._user_token_client, MockUserTokenClient): + self._user_token_client.add_exchangeable_token( + connection_name=connection_name, + channel_id=channel_id, + user_id=user_id, + exchangeable_item=exchangeable_item, + token=token, + ) + else: + raise TypeError( + "UserTokenClient is not a MockUserTokenClient. Cannot add exchangeable token." + ) + + def raise_on_exchange_request( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + ) -> None: + """Configure the mock token client to raise during token exchange. + + This is useful for testing error handling paths where the exchangeable + token or URI should fail instead of returning a fake token. + """ + if isinstance(self._user_token_client, MockUserTokenClient): + self._user_token_client.raise_on_exchange_request( + connection_name=connection_name, + channel_id=channel_id, + user_id=user_id, + exchangeable_item=exchangeable_item, + ) + else: + raise TypeError( + "UserTokenClient is not a MockUserTokenClient. Cannot set to raise on exchange request." + ) + + def _enqueue(self, activity: Activity) -> None: + """Queue a captured activity or fulfill the oldest pending waiter. + + This is the in-memory replacement for sending an activity to a channel. + """ + + while len(self._queued_requests) > 0: + + future = self._queued_requests.pop(0) + if not future.done(): + future.set_result(activity) + return + + self._activity_queue.append(activity) diff --git a/libraries/microsoft-agents-testing/microsoft_agents/testing/test_flow.py b/libraries/microsoft-agents-testing/microsoft_agents/testing/test_flow.py new file mode 100644 index 000000000..cd8435351 --- /dev/null +++ b/libraries/microsoft-agents-testing/microsoft_agents/testing/test_flow.py @@ -0,0 +1,326 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +import asyncio +from inspect import isawaitable +from collections.abc import Awaitable, Callable, Coroutine, Iterable +from typing import Any, TypeAlias + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + RoleTypes, +) + +from .test_adapter import TestAdapter +from .type_def import AgentCallbackHandler + +ReplyValidator: TypeAlias = Callable[[Activity], None | Awaitable[None]] + +__test__ = False # for pytest: don't collect this module as a test case + + +class TestFlow: + """Fluent helper for driving a ``TestAdapter`` conversation in tests. + + ``TestFlow`` mirrors the .NET testing pattern: each fluent method returns a + new flow object whose task waits for the previous flow's task before running + its own send or assertion. The adapter and callback are shared across the + chain, while the accumulated task changes at each step. + + The implementation is intentionally queue-oriented. Sends go through + :class:`TestAdapter`; replies are read from the adapter's captured activity + queue in order. This is best suited to deterministic, unit-style + conversation scripts. + """ + + __test__ = False # for pytest: don't collect this class as a test case + + def __init__( + self, + adapter: TestAdapter, + callback: AgentCallbackHandler | None = None, + *, + task: asyncio.Task[None] | None = None, + ) -> None: + """Create a flow for an adapter and optional agent turn callback. + + :param adapter: Test adapter used to process inbound activities and + capture replies. + :param callback: Agent turn callback to invoke for sent activities. + :param task: Accumulated task for internal chaining. + """ + + self._adapter = adapter + self._callback = callback + self._task = task + + async def start_test(self) -> None: + """Await the accumulated flow and surface any exceptions.""" + + if self._task: + await self._task + + async def start_test_async(self) -> None: + """Await the accumulated flow and surface any exceptions. + + This alias matches the .NET method name. + """ + + await self.start_test() + + def send(self, user_input: str | Activity) -> TestFlow: + """Append a user activity to the flow. + + :param user_input: Text to send as a message activity, or a fully formed + activity to process through the adapter. + :return: A new ``TestFlow`` with this send step appended. + """ + + if user_input is None: + raise ValueError("TestFlow.send(): user_input cannot be None.") + + async def step() -> None: + await self._await_previous() + + if isinstance(user_input, str): + if not self._callback: + raise ValueError("TestFlow.send(): callback is required.") + await self._adapter.send_text_to_bot(user_input, self._callback) + return + + if not self._callback: + raise ValueError("TestFlow.send(): callback is required.") + + await self._adapter.process_activity( + self._adapter.claims_identity, + user_input, + self._callback, + ) + + return self._append(step) + + def send_conversation_update( + self, members_added: Iterable[ChannelAccount] | None = None + ) -> TestFlow: + """Append a conversation update activity to the flow. + + :param members_added: Members to include in ``members_added``. When + omitted, the adapter's default test user is added. + :return: A new ``TestFlow`` with this send step appended. + """ + + if members_added is None: + members = [self._adapter.conversation.user] + else: + members = list(members_added) + if len(members) == 0: + raise ValueError( + "TestFlow.send_conversation_update(): members_added cannot be empty." + ) + + async def step() -> None: + await self._await_previous() + + if not self._callback: + raise ValueError( + "TestFlow.send_conversation_update(): callback is required." + ) + + activity = Activity( + type=ActivityTypes.conversation_update, + members_added=members, + ) + await self._adapter.process_activity( + self._adapter.claims_identity, + activity, + self._callback, + ) + + return self._append(step) + + def delay(self, seconds: float) -> TestFlow: + """Append a delay to the flow. + + :param seconds: Number of seconds to wait after previous steps complete. + :return: A new ``TestFlow`` with this delay step appended. + """ + + if seconds < 0: + raise ValueError("TestFlow.delay(): seconds cannot be negative.") + + async def step() -> None: + await self._await_previous() + await asyncio.sleep(seconds) + + return self._append(step) + + def assert_reply( + self, + expected: str | Activity | ReplyValidator, + description: str | None = None, + *, + timeout: float = 3.0, + ) -> TestFlow: + """Append an assertion for the next captured reply. + + :param expected: Expected reply text, expected activity, or a validator + callable. Validators receive the next reply and may raise an + assertion error or return an awaitable. + :param description: Optional failure description. + :param timeout: Seconds to wait for the next reply. + :return: A new ``TestFlow`` with this assertion appended. + """ + + async def step() -> None: + await self._await_previous() + reply = await self._get_next_reply(timeout) + + if reply is None: + raise AssertionError( + description + or f"Expected a reply within {timeout} seconds, but no reply was received." + ) + + if callable(expected): + result = expected(reply) + if isawaitable(result): + await result + return + + if isinstance(expected, str): + if reply.text != expected: + raise AssertionError( + description + or f"Expected reply text '{expected}', received '{reply.text}'." + ) + return + + self._assert_activity(expected, reply, description) + + return self._append(step) + + def assert_reply_contains( + self, + expected: str, + description: str | None = None, + *, + timeout: float = 3.0, + ) -> TestFlow: + """Append an assertion that the next reply contains text. + + :param expected: Text expected to appear in the next reply. + :param description: Optional failure description. + :param timeout: Seconds to wait for the next reply. + :return: A new ``TestFlow`` with this assertion appended. + """ + + async def validate(reply: Activity) -> None: + if expected not in (reply.text or ""): + raise AssertionError( + description + or f"Expected reply text to contain '{expected}', received '{getattr(reply, 'text', None)}'." + ) + + return self.assert_reply(validate, timeout=timeout) + + def assert_typing_indicator(self, *, timeout: float = 3.0) -> TestFlow: + """Append an assertion that the next reply is a typing activity.""" + + async def validate(reply: Activity) -> None: + if reply.type != ActivityTypes.typing: + raise AssertionError( + f"Expected typing activity, received '{getattr(reply, 'type', None)}'." + ) + + return self.assert_reply(validate, timeout=timeout) + + def assert_no_more_replies(self, *, timeout: float = 0.3) -> TestFlow: + """Append an assertion that no reply arrives within ``timeout`` seconds.""" + + async def step() -> None: + await self._await_previous() + reply = await self._get_next_reply(timeout) + if reply is not None: + raise AssertionError(f"Expected no more replies, received {reply!r}.") + + return self._append(step) + + def test( + self, + user_input: str | Activity, + expected: str | Activity | ReplyValidator, + *, + timeout: float = 3.0, + ) -> TestFlow: + """Append a send followed by an expected reply assertion.""" + + return self.send(user_input).assert_reply(expected, timeout=timeout) + + def test_activities(self, activities: Iterable[Activity]) -> TestFlow: + """Append sends and assertions from a mixed activity transcript. + + Activities whose sender role is ``agent`` are treated as expected + replies. All other activities are sent as user input. + """ + + flow: TestFlow = self + for activity in activities: + role = getattr(activity.from_property, "role", None) + if role == RoleTypes.agent: + flow = flow.assert_reply(activity) + else: + flow = flow.send(activity) + return flow + + def _append(self, step: Callable[[], Coroutine[Any, Any, None]]) -> TestFlow: + task = asyncio.create_task(step()) + return TestFlow(self._adapter, self._callback, task=task) + + async def _await_previous(self) -> None: + if self._task: + await self._task + + async def _get_next_reply(self, timeout: float) -> Activity | None: + try: + return await asyncio.wait_for( + self._adapter.get_next_reply_async(), timeout=timeout + ) + except asyncio.TimeoutError: + return None + + @staticmethod + def _assert_activity( + expected: Activity, + actual: Activity, + description: str | None, + ) -> None: + if actual.type != expected.type: + raise AssertionError( + description + or f"Expected reply type '{expected.type}', received '{actual.type}'." + ) + + if expected.text is not None and actual.text != expected.text: + raise AssertionError( + description + or f"Expected reply text '{expected.text}', received '{actual.text}'." + ) + + if expected.input_hint is not None and actual.input_hint != expected.input_hint: + raise AssertionError( + description + or ( + f"Expected reply input_hint '{expected.input_hint}', " + f"received '{actual.input_hint}'." + ) + ) + + if expected.speak is not None and actual.speak != expected.speak: + raise AssertionError( + description + or f"Expected reply speak '{expected.speak}', received '{actual.speak}'." + ) diff --git a/libraries/microsoft-agents-testing/microsoft_agents/testing/type_def.py b/libraries/microsoft-agents-testing/microsoft_agents/testing/type_def.py new file mode 100644 index 000000000..07ad8b856 --- /dev/null +++ b/libraries/microsoft-agents-testing/microsoft_agents/testing/type_def.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Awaitable, Callable, TypeVar + +from microsoft_agents.hosting.core import TurnContext + +T = TypeVar("T") +AgentCallbackHandler = Callable[[TurnContext], Awaitable[T]] diff --git a/libraries/microsoft-agents-testing/pyproject.toml b/libraries/microsoft-agents-testing/pyproject.toml new file mode 100644 index 000000000..907bd508c --- /dev/null +++ b/libraries/microsoft-agents-testing/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-testing" +dynamic = ["version"] +description = "Library for building agent tests using Microsoft Agents SDK" +readme = {file = "readme.md", content-type = "text/markdown"} +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/microsoft/Agents" diff --git a/libraries/microsoft-agents-testing/readme.md b/libraries/microsoft-agents-testing/readme.md new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-testing/setup.py b/libraries/microsoft-agents-testing/setup.py new file mode 100644 index 000000000..53449889f --- /dev/null +++ b/libraries/microsoft-agents-testing/setup.py @@ -0,0 +1,17 @@ +from os import environ, path +from setuptools import setup + +# Try to read from VERSION.txt file first, fall back to environment variable +version_file = path.join(path.dirname(__file__), "VERSION.txt") +if path.exists(version_file): + with open(version_file, "r", encoding="utf-8") as f: + package_version = f.read().strip() +else: + package_version = environ.get("PackageVersion", "0.0.0") + +setup( + version=package_version, + install_requires=[ + f"microsoft-agents-hosting-core=={package_version}", + ], +) diff --git a/scripts/dev_setup.ps1 b/scripts/dev_setup.ps1 index 50a52b915..2b3f8d35d 100644 --- a/scripts/dev_setup.ps1 +++ b/scripts/dev_setup.ps1 @@ -12,6 +12,7 @@ pip install -e ./libraries/microsoft-agents-hosting-teams/ --config-settings edi pip install -e ./libraries/microsoft-agents-hosting-dialogs/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-storage-blob/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-storage-cosmos/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-testing/ --config-settings editable_mode=compat pip install -r dev_dependencies.txt diff --git a/scripts/dev_setup.sh b/scripts/dev_setup.sh index 3f5500323..b013fced4 100644 --- a/scripts/dev_setup.sh +++ b/scripts/dev_setup.sh @@ -12,6 +12,7 @@ pip install -e ./libraries/microsoft-agents-hosting-teams/ --config-settings edi pip install -e ./libraries/microsoft-agents-hosting-dialogs/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-storage-blob/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-storage-cosmos/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-testing/ --config-settings editable_mode=compat pip install -r dev_dependencies.txt diff --git a/tests/_common/obsolete_test_client.py b/tests/_common/obsolete_test_client.py deleted file mode 100644 index d74e201d0..000000000 --- a/tests/_common/obsolete_test_client.py +++ /dev/null @@ -1,372 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from typing import Callable, List, Optional, TypeVar, Union, Any, Dict -from datetime import timedelta -from functools import reduce - -from microsoft_agents.activity import Activity -from microsoft_agents.hosting.core import Connections - -T = TypeVar("T") - - -# This is a currently unused (probably outdated & deleted) test client that -# may be useful to bring up-to-date for future testing scenarios. -class ObsoleteTestClient: - """ - A testing channel that connects directly to an adapter. - - You can use this class to mimic input from a user or a channel to validate - that the agent or adapter responds as expected. - """ - - def __init__(self, adapter, callback=None): - """ - Initializes a new instance of the TestingFlow class. - - Args: - adapter: The test adapter to use. - callback: The agent turn processing logic to test. - """ - self._adapter = adapter - self._callback = callback - self._test_task = asyncio.create_task(asyncio.sleep(0)) - - @classmethod - def _clone(cls, task, client): - new_flow = cls(client._adapter, client._callback) - new_flow._test_task = task - return new_flow - - async def start_test_async(self): - """ - Starts the execution of the test flow. - - Returns: - A Task that runs the exchange between the user and the agent. - """ - return await self._test_task - - def send(self, user_says): - """ - Adds a message activity from the user to the agent. - - Args: - user_says: The text of the message to send or an Activity. - - Returns: - A new TestingFlow object that appends a new message activity from the user to the modeled exchange. - """ - if user_says is None: - raise ValueError("You have to pass a userSays parameter") - - async def new_task(): - await self._test_task - - if isinstance(user_says, str): - await self._adapter.send_text_to_bot_async(user_says, self._callback) - else: - await self._adapter.process_activity_async(user_says, self._callback) - - return TestingFlow._create_from_flow(new_task(), self) - - def send_conversation_update(self): - """ - Creates a conversation update activity and processes the activity. - - Returns: - A new TestingFlow object. - """ - - async def new_task(): - await self._test_task - - cu = Activity.create_conversation_update_activity() - cu.members_added.append(self._adapter.conversation.user) - await self._adapter.process_activity_async(cu, self._callback) - - return TestingFlow._create_from_flow(new_task(), self) - - def delay(self, ms_or_timespan): - """ - Adds a delay in the conversation. - - Args: - ms_or_timespan: The delay length in milliseconds or a timedelta. - - Returns: - A new TestingFlow object that appends a delay to the modeled exchange. - """ - - async def new_task(): - await self._test_task - - if isinstance(ms_or_timespan, timedelta): - delay_seconds = ms_or_timespan.total_seconds() - else: - delay_seconds = ms_or_timespan / 1000.0 - - await asyncio.sleep(delay_seconds) - - return TestingFlow._create_from_flow(new_task(), self) - - def assert_reply(self, expected, description=None, timeout=3000): - """ - Adds an assertion that the turn processing logic responds as expected. - - Args: - expected: The expected text, activity, or validation function to apply to the bot's response. - description: A message to send if the actual response is not as expected. - timeout: The amount of time in milliseconds within which a response is expected. - - Returns: - A new TestingFlow object that appends this assertion to the modeled exchange. - """ - if isinstance(expected, str): - expected_activity = self._adapter.make_activity(expected) - return self._assert_reply_activity( - expected_activity, description or expected, timeout - ) - elif callable(expected): - return self._assert_reply_validate(expected, description, timeout) - else: - return self._assert_reply_activity(expected, description, timeout) - - def _assert_reply_activity( - self, expected_activity, description=None, timeout=3000, equality_comparer=None - ): - """ - Implementation for asserting replies with an expected activity. - """ - - async def validate_activity(reply): - description_text = description or ( - expected_activity.text.strip() - if hasattr(expected_activity, "text") and expected_activity.text - else None - ) - - if expected_activity.type != reply.type: - raise ValueError(f"{description_text}: Type should match") - - if equality_comparer: - if not equality_comparer(expected_activity, reply): - raise ValueError(f"Expected:{expected_activity}\nReceived:{reply}") - else: - expected_text = ( - expected_activity.text.strip() - if hasattr(expected_activity, "text") and expected_activity.text - else "" - ) - actual_text = ( - reply.text.strip() if hasattr(reply, "text") and reply.text else "" - ) - - if expected_text != actual_text: - if description_text: - raise ValueError( - f"{description_text}:\nExpected:{expected_text}\nReceived:{actual_text}" - ) - else: - raise ValueError( - f"Expected:{expected_text}\nReceived:{actual_text}" - ) - - return self._assert_reply_validate(validate_activity, description, timeout) - - def _assert_reply_validate(self, validate_activity, description=None, timeout=3000): - """ - Implementation for asserting replies with a validation function. - """ - - async def new_task(): - await self._test_task - - # If debugger is attached, extend the timeout - if hasattr(sys, "gettrace") and sys.gettrace(): - timeout_ms = sys.maxsize - else: - timeout_ms = timeout - - try: - reply_activity = await asyncio.wait_for( - self._adapter.get_next_reply_async(), timeout=timeout_ms / 1000.0 - ) - - if callable(validate_activity): - if asyncio.iscoroutinefunction(validate_activity): - await validate_activity(reply_activity) - else: - validate_activity(reply_activity) - - except asyncio.TimeoutError: - raise TimeoutError( - f"No reply received within the timeout period of {timeout_ms}ms" - ) - - return TestingFlow._create_from_flow(new_task(), self) - - def assert_reply_contains(self, expected, description=None, timeout=3000): - """ - Adds an assertion that the turn processing logic response contains the expected text. - - Args: - expected: The part of the expected text of a message from the bot. - description: A message to send if the actual response is not as expected. - timeout: The amount of time in milliseconds within which a response is expected. - - Returns: - A new TestingFlow object that appends this assertion to the modeled exchange. - """ - - def validate_contains(reply): - if ( - reply is None - or not hasattr(reply, "text") - or expected not in reply.text - ): - if description is None: - raise ValueError( - f"Expected:{expected}\nReceived:{reply.text if hasattr(reply, 'text') else 'Not a Message Activity'}" - ) - else: - raise ValueError( - f"{description}:\nExpected:{expected}\nReceived:{reply.text if hasattr(reply, 'text') else 'Not a Message Activity'}" - ) - - return self._assert_reply_validate(validate_contains, description, timeout) - - def assert_no_reply(self, description=None, timeout=3000): - """ - Adds an assertion that the turn processing logic finishes responding as expected. - - Args: - description: A message to send if the turn still responds. - timeout: The amount of time in milliseconds within which no response is expected. - - Returns: - A new TestingFlow object that appends this assertion to the modeled exchange. - """ - - async def new_task(): - await self._test_task - - try: - reply_activity = await asyncio.wait_for( - self._adapter.get_next_reply_async(), timeout=timeout / 1000.0 - ) - - if reply_activity is not None: - raise ValueError( - f"{reply_activity} is responded when waiting for no reply:'{description}'" - ) - - except asyncio.TimeoutError: - # Expected behavior - no response within timeout - pass - - return TestingFlow._create_from_flow(new_task(), self) - - def test(self, user_says, expected=None, description=None, timeout=3000): - """ - Shortcut for calling send followed by assert_reply. - - Args: - user_says: The text of the message to send. - expected: The expected response, text, activity, or validation function. - description: A message to send if the actual response is not as expected. - timeout: The amount of time in milliseconds within which a response is expected. - - Returns: - A new TestingFlow object that appends this exchange to the modeled exchange. - """ - if expected is None: - raise ValueError("expected parameter is required") - - return self.send(user_says).assert_reply(expected, description, timeout) - - def test_activities( - self, activities, validate_reply=None, description=None, timeout=3000 - ): - """ - Shortcut for adding an arbitrary exchange between the user and bot. - - Args: - activities: The list of activities to test. - validate_reply: Optional delegate to call to validate responses from the bot. - description: A message to send if the actual response is not as expected. - timeout: The amount of time in milliseconds within which a response is expected. - - Returns: - A new TestingFlow object that appends this exchange to the modeled exchange. - """ - if activities is None: - raise ValueError("activities parameter is required") - - def process_activity(flow, activity): - if self._is_reply(activity): - if validate_reply: - return flow.assert_reply( - lambda actual: validate_reply(activity, actual), - description, - timeout, - ) - else: - return flow.assert_reply(activity, description, timeout) - else: - return flow.send(activity) - - return reduce(process_activity, activities, self) - - def assert_reply_one_of(self, candidates, description=None, timeout=3000): - """ - Adds an assertion that the bot's response is contained within a set of acceptable responses. - - Args: - candidates: The set of acceptable messages. - description: A message to send if the actual response is not as expected. - timeout: The amount of time in milliseconds within which a response is expected. - - Returns: - A new TestingFlow object that appends this assertion to the modeled exchange. - """ - if candidates is None: - raise ValueError("candidates parameter is required") - - def validate_one_of(reply): - if not hasattr(reply, "text"): - raise ValueError(f"Reply does not have text property: {reply}") - - text = reply.text - - for candidate in candidates: - if text == candidate: - return - - message = ( - description - or f"Text \"{text}\" does not match one of candidates: {', '.join(candidates)}" - ) - raise ValueError(message) - - return self._assert_reply_validate(validate_one_of, description, timeout) - - @staticmethod - def _is_reply(activity): - """ - Determines if an activity is a reply from a bot. - - Args: - activity: The activity to check. - - Returns: - True if the activity is from a bot, False otherwise. - """ - return ( - hasattr(activity, "from_property") - and hasattr(activity.from_property, "role") - and activity.from_property.role.lower() == "bot" - ) diff --git a/tests/_common/testing_objects/mocks/mock_user_token_client.py b/tests/_common/testing_objects/mocks/mock_user_token_client.py index 273b69c23..5bd6495cf 100644 --- a/tests/_common/testing_objects/mocks/mock_user_token_client.py +++ b/tests/_common/testing_objects/mocks/mock_user_token_client.py @@ -65,9 +65,7 @@ async def get_token_or_sign_in_resource( state, ) - mock_user_token_client.get_user_token = mocker.AsyncMock( - side_effect=get_user_token - ) + mock_user_token_client.get_user_token = mocker.AsyncMock(side_effect=get_user_token) mock_user_token_client.sign_out_user = mocker.AsyncMock(side_effect=sign_out_user) mock_user_token_client.exchange_token = mocker.AsyncMock(side_effect=exchange_token) mock_user_token_client.get_token_or_sign_in_resource = mocker.AsyncMock( diff --git a/tests/hosting_core/app/_oauth/_common.py b/tests/hosting_core/app/_oauth/_common.py index 95151ed2e..b6653bf7c 100644 --- a/tests/hosting_core/app/_oauth/_common.py +++ b/tests/hosting_core/app/_oauth/_common.py @@ -1,9 +1,9 @@ from microsoft_agents.activity import Activity, ActivityTypes from microsoft_agents.hosting.core import TurnContext, UserTokenClientBase +from microsoft_agents.testing import MockUserTokenClient from tests._common.data import DEFAULT_TEST_VALUES -from tests._common.testing_objects import mock_UserTokenClient DEFAULTS = DEFAULT_TEST_VALUES() @@ -25,7 +25,7 @@ def create_testing_TurnContext( activity=None, ): if not user_token_client: - user_token_client = mock_UserTokenClient(mocker) + user_token_client = MockUserTokenClient() turn_context = mocker.Mock() if not activity: @@ -55,7 +55,7 @@ def create_testing_TurnContext_magic( activity=None, ): if not user_token_client: - user_token_client = mock_UserTokenClient(mocker) + user_token_client = MockUserTokenClient() turn_context = mocker.MagicMock(spec=TurnContext) turn_context.adapter = mocker.Mock() 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 9bc4459d0..a7e805623 100644 --- a/tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py +++ b/tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py @@ -6,6 +6,7 @@ from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager from microsoft_agents.hosting.core import MemoryStorage, UserTokenClientBase +from microsoft_agents.testing import MockUserTokenClient from microsoft_agents.hosting.core.app.oauth import _UserAuthorization, _SignInResponse from microsoft_agents.hosting.core._oauth import ( _FlowStorageClient, @@ -28,7 +29,6 @@ from tests._common.fixtures import FlowStateFixtures from tests._common.testing_objects import ( mock_class_OAuthFlow, - mock_UserTokenClient, ) from tests.hosting_core._common import flow_state_eq @@ -57,7 +57,7 @@ def create_testing_TurnContext( user_token_client=None, ): if not user_token_client: - user_token_client = mock_UserTokenClient(mocker) + user_token_client = MockUserTokenClient() turn_context = mocker.Mock() turn_context.activity.channel_id = channel_id diff --git a/tests/hosting_core/app/_oauth/test_authorization.py b/tests/hosting_core/app/_oauth/test_authorization.py index f5d62a239..13238e8fa 100644 --- a/tests/hosting_core/app/_oauth/test_authorization.py +++ b/tests/hosting_core/app/_oauth/test_authorization.py @@ -40,7 +40,6 @@ from tests._common.fixtures import FlowStateFixtures from tests._common.testing_objects import ( TestingConnectionManager as MockConnectionManager, - mock_UserTokenClient, mock_class_UserAuthorization, mock_class_AgenticUserAuthorization, mock_class_Authorization, @@ -112,7 +111,6 @@ def copy_sign_in_state(state: _SignInState) -> _SignInState: class TestEnv(FlowStateFixtures): def setup_method(self): self.TurnContext = create_testing_TurnContext - self.UserTokenClient = mock_UserTokenClient self.ConnectionManager = lambda mocker: MockConnectionManager() @pytest.fixture diff --git a/tests/hosting_core/app/state/test_conversation_state.py b/tests/hosting_core/app/state/test_conversation_state.py index 8db0b81e2..9de8cf734 100644 --- a/tests/hosting_core/app/state/test_conversation_state.py +++ b/tests/hosting_core/app/state/test_conversation_state.py @@ -5,8 +5,7 @@ from microsoft_agents.hosting.core.app.state import ConversationState from microsoft_agents.hosting.core.storage import MemoryStorage from microsoft_agents.hosting.core.turn_context import TurnContext - -from tests._common.testing_objects import MockTestingAdapter +from microsoft_agents.testing import TestAdapter def _create_context(channel_id="test-channel", conversation_id="conversation-123"): @@ -15,7 +14,7 @@ def _create_context(channel_id="test-channel", conversation_id="conversation-123 channel_id=channel_id, conversation=ConversationAccount(id=conversation_id), ) - return TurnContext(MockTestingAdapter(), activity) + return TurnContext(TestAdapter(), activity) def test_conversation_state_uses_expected_context_service_key(): diff --git a/tests/hosting_core/app/state/test_turn_state.py b/tests/hosting_core/app/state/test_turn_state.py index 7d9632ae1..f09c0c154 100644 --- a/tests/hosting_core/app/state/test_turn_state.py +++ b/tests/hosting_core/app/state/test_turn_state.py @@ -15,8 +15,7 @@ from microsoft_agents.hosting.core.state import UserState from microsoft_agents.hosting.core.storage import MemoryStorage from microsoft_agents.hosting.core.turn_context import TurnContext - -from tests._common.testing_objects import MockTestingAdapter +from microsoft_agents.testing import TestAdapter def _create_context(): @@ -26,7 +25,7 @@ def _create_context(): conversation=ConversationAccount(id="conversation-123"), from_property=ChannelAccount(id="user-123"), ) - return TurnContext(MockTestingAdapter(), activity) + return TurnContext(TestAdapter(), activity) def test_turn_state_always_has_temp_scope(): diff --git a/tests/hosting_core/state/test_agent_state.py b/tests/hosting_core/state/test_agent_state.py index 4e2f7b28f..195e309c3 100644 --- a/tests/hosting_core/state/test_agent_state.py +++ b/tests/hosting_core/state/test_agent_state.py @@ -23,7 +23,8 @@ ChannelAccount, ConversationAccount, ) -from tests._common.testing_objects import MockTestingAdapter, MockTestingCustomState +from microsoft_agents.testing import TestAdapter +from tests._common.testing_objects import MockTestingCustomState class _MockTestDataItem(StoreItem): @@ -55,7 +56,7 @@ def setup_method(self): self.custom_state = MockTestingCustomState(self.storage) # Create a test context - self.adapter = MockTestingAdapter() + self.adapter = TestAdapter() self.activity = Activity( type=ActivityTypes.message, channel_id="test-channel", diff --git a/tests/hosting_dialogs/choices/test_channel.py b/tests/hosting_dialogs/choices/test_channel.py index 74fc849c1..6b597aa26 100644 --- a/tests/hosting_dialogs/choices/test_channel.py +++ b/tests/hosting_dialogs/choices/test_channel.py @@ -14,7 +14,7 @@ ) from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.dialogs.choices import Channel -from tests._common.testing_objects import MockTestingAdapter +from microsoft_agents.testing import TestAdapter class TestChannel: @@ -66,7 +66,7 @@ def test_supports_card_actions_accepts_string_channel_id(self): assert not Channel.supports_card_actions("msteams", 4) def test_should_return_channel_id_from_context_activity(self): - adapter = MockTestingAdapter(channel_id=Channels.facebook) + adapter = TestAdapter(channel_id=Channels.facebook) test_activity = Activity( type=ActivityTypes.message, channel_id=Channels.facebook, @@ -78,7 +78,7 @@ def test_should_return_channel_id_from_context_activity(self): assert Channels.facebook == channel_id def test_should_return_empty_from_context_activity_missing_channel(self): - adapter = MockTestingAdapter() + adapter = TestAdapter() test_activity = Activity( type=ActivityTypes.message, conversation=ConversationAccount(id="test"), diff --git a/tests/hosting_msteams/helpers.py b/tests/hosting_msteams/helpers.py index d82cdf62f..9bcd83c49 100644 --- a/tests/hosting_msteams/helpers.py +++ b/tests/hosting_msteams/helpers.py @@ -7,13 +7,16 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock -from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.activity import Activity, ActivityTypes, ResourceResponse +from microsoft_agents.activity._model_utils import SkipNone, pick_model from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.app import AgentApplication, RouteRank is_supported_version = sys.version_info >= (3, 11) if is_supported_version: + from microsoft_teams.api import ApiClient + from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext @@ -31,6 +34,15 @@ def set(self, key, value): self._state[key] = value +class _FakeAdapter: + def __init__(self): + self.sent_activities = [] + + async def send_activities(self, context, activities): + self.sent_activities.extend(activities) + return [ResourceResponse()] * len(activities) + + def _make_app() -> Any: app = MagicMock(spec=AgentApplication) app._routes = [] @@ -61,42 +73,28 @@ def _make_context( members_added=None, members_removed=None, ) -> TurnContext: - context = MagicMock(spec=TurnContext) - activity = MagicMock(spec=Activity) - activity.type = activity_type - activity.name = name - activity.value = value - activity.service_url = "https://smba.trafficmanager.net/teams/" - activity.channel_id = channel_id - activity.channel_data = channel_data + activity = pick_model( + Activity, + type=activity_type, + name=SkipNone(name), + value=SkipNone(value), + service_url="https://smba.trafficmanager.net/teams/", + channel_id=channel_id, + channel_data=SkipNone(channel_data), + ) activity.members_added = members_added activity.members_removed = members_removed - context.activity = activity - context.turn_state = {} - context.send_activity = AsyncMock() - mock_adapter = MagicMock() - context.adapter = mock_adapter - context._responded = False - context._services = _FakeServiceSet() - context._on_send_activities = [] - context._on_update_activity = [] - context._on_delete_activity = [] - context.identity = MagicMock() - - def _copy_to(target): - target.adapter = mock_adapter - target._activity = activity - target._responded = False - target._services = _FakeServiceSet() - target._on_send_activities = [] - target._on_update_activity = [] - target._on_delete_activity = [] - - context.copy_to.side_effect = _copy_to + context = TurnContext(_FakeAdapter(), activity, MagicMock()) + _cache_teams_api_client(context) + context.send_activity = AsyncMock() return context +def _cache_teams_api_client(context: TurnContext) -> None: + context.services.set(ApiClient, object.__new__(ApiClient)) + + def _make_teams_context() -> "TeamsTurnContext": """Return a MagicMock shaped like a TeamsTurnContext for use in unit tests.""" ctx = MagicMock(spec=TeamsTurnContext) diff --git a/tests/hosting_msteams/test_internal.py b/tests/hosting_msteams/test_internal.py index 1ea3a2f97..a20d3a07e 100644 --- a/tests/hosting_msteams/test_internal.py +++ b/tests/hosting_msteams/test_internal.py @@ -41,7 +41,7 @@ def __init__(self, services): class TestGetTeamsApiClient: def test_returns_cached_api_client(self): - client = ApiClient("https://smba.trafficmanager.net/teams/") + client = object.__new__(ApiClient) ctx = _FakeContext(_FakeServices({ApiClient: client})) assert _get_teams_api_client(ctx) is client diff --git a/tests/hosting_msteams/test_routing_integration.py b/tests/hosting_msteams/test_routing_integration.py index a8fc602c8..f5c2cd5d6 100644 --- a/tests/hosting_msteams/test_routing_integration.py +++ b/tests/hosting_msteams/test_routing_integration.py @@ -26,7 +26,7 @@ ) from tests._common.testing_objects import TestingConnectionManager as _ConnectionManager -from .helpers import is_supported_version +from .helpers import _cache_teams_api_client, is_supported_version pytestmark = pytest.mark.skipif( not is_supported_version, @@ -80,7 +80,9 @@ def _make_activity(**kwargs) -> Activity: def _make_context(activity: Activity) -> TurnContext: - return TurnContext(_StubAdapter(), activity) + context = TurnContext(_StubAdapter(), activity) + _cache_teams_api_client(context) + return context class TestMessageRouteIntegration: diff --git a/tests/testing_package/__init__.py b/tests/testing_package/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/testing_package/test_test_flow.py b/tests/testing_package/test_test_flow.py new file mode 100644 index 000000000..4cf8d3c4e --- /dev/null +++ b/tests/testing_package/test_test_flow.py @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio + +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes, ChannelAccount, RoleTypes +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.testing import TestAdapter, TestFlow + + +@pytest.mark.asyncio +async def test_send_assert_reply_and_assert_no_more_replies(): + adapter = TestAdapter() + + async def callback(context: TurnContext): + await context.send_activity(f"Echo: {context.activity.text}") + + await ( + TestFlow(adapter, callback) + .send("hello") + .assert_reply("Echo: hello") + .assert_no_more_replies(timeout=0.01) + .start_test() + ) + + +@pytest.mark.asyncio +async def test_assert_reply_consumes_replies_in_order(): + adapter = TestAdapter() + + async def callback(context: TurnContext): + await context.send_activity("first") + await context.send_activity("second") + + await ( + TestFlow(adapter, callback) + .send("go") + .assert_reply("first") + .assert_reply("second") + .assert_no_more_replies(timeout=0.01) + .start_test() + ) + + +@pytest.mark.asyncio +async def test_assert_no_more_replies_fails_when_reply_is_queued(): + adapter = TestAdapter() + + async def callback(context: TurnContext): + await context.send_activity("extra") + + flow = TestFlow(adapter, callback).send("go").assert_no_more_replies(timeout=0.01) + + with pytest.raises(AssertionError, match="Expected no more replies"): + await flow.start_test() + + +@pytest.mark.asyncio +async def test_chained_steps_start_as_tasks_but_wait_for_previous_steps(): + adapter = TestAdapter() + events: list[str] = [] + first_started = asyncio.Event() + release_first = asyncio.Event() + + async def callback(context: TurnContext): + events.append(f"callback:{context.activity.text}") + if context.activity.text == "one": + first_started.set() + await release_first.wait() + await context.send_activity(f"reply:{context.activity.text}") + + flow = ( + TestFlow(adapter, callback) + .send("one") + .assert_reply("reply:one") + .send("two") + .assert_reply("reply:two") + ) + + await first_started.wait() + await asyncio.sleep(0) + assert events == ["callback:one"] + + release_first.set() + await flow.start_test() + assert events == ["callback:one", "callback:two"] + + +@pytest.mark.asyncio +async def test_send_conversation_update_uses_default_member(): + adapter = TestAdapter() + + async def callback(context: TurnContext): + assert context.activity.type == ActivityTypes.conversation_update + assert context.activity.members_added == [adapter.conversation.user] + await context.send_activity("welcome") + + await ( + TestFlow(adapter, callback) + .send_conversation_update() + .assert_reply("welcome") + .start_test() + ) + + +@pytest.mark.asyncio +async def test_test_activities_treats_agent_activities_as_expected_replies(): + adapter = TestAdapter() + + async def callback(context: TurnContext): + await context.send_activity(f"Echo: {context.activity.text}") + + transcript = [ + Activity( + type=ActivityTypes.message, + text="hello", + from_property=ChannelAccount(id="user", role=RoleTypes.user), + ), + Activity( + type=ActivityTypes.message, + text="Echo: hello", + from_property=ChannelAccount(id="bot", role=RoleTypes.agent), + ), + ] + + await TestFlow(adapter, callback).test_activities(transcript).start_test() + + +@pytest.mark.asyncio +async def test_get_next_reply_async_returns_queued_reply_or_waits_for_next_reply(): + adapter = TestAdapter() + context = adapter.create_turn_context(adapter.create_activity("inbound")) + + queued = Activity(type=ActivityTypes.message, text="already queued") + await adapter.send_activities(context, [queued]) + + assert await adapter.get_next_reply_async() is queued + + waiter = asyncio.create_task(adapter.get_next_reply_async()) + await asyncio.sleep(0) + assert not waiter.done() + + next_reply = Activity(type=ActivityTypes.message, text="future reply") + await adapter.send_activities(context, [next_reply]) + + assert await asyncio.wait_for(waiter, timeout=0.1) is next_reply