From bf1274ecee88686582845384f99f5a314173485e Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Fri, 10 Jul 2026 11:06:07 -0700 Subject: [PATCH 01/12] use proper state loading condition --- .../hosting/core/state/agent_state.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py index 8006e024..a777dfe1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py @@ -129,12 +129,27 @@ async def load(self, turn_context: TurnContext, force: bool = False) -> None: """ storage_key = self.get_storage_key(turn_context) - if force or not self._cached_state: + if self._should_load(turn_context, force): items = await self._storage.read([storage_key], target_cls=CachedAgentState) val = items.get(storage_key, CachedAgentState()) self._cached_state = val turn_context.turn_state[self._context_service_key] = val + def _should_load(self, turn_context: TurnContext, force: bool = False) -> bool: + """ + Determines whether the state should be loaded from storage. + + :param turn_context: The context object for this turn + :type turn_context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` + :param force: Optional, true to bypass the cache + :type force: bool + :return: True if the state should be loaded from storage, False otherwise + :rtype: bool + """ + + _cached_state = self.get_cached_state(turn_context) + return force or not _cached_state or not _cached_state.state + async def save(self, turn_context: TurnContext, force: bool = False) -> None: """ Saves the state cached in the current context for this turn. From e06288f1d2f2a1e917f5046667e182b08d66b693 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Fri, 10 Jul 2026 11:56:01 -0700 Subject: [PATCH 02/12] fix hashing test --- tests/hosting_core/state/test_agent_state.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/hosting_core/state/test_agent_state.py b/tests/hosting_core/state/test_agent_state.py index c5b2a971..4e2f7b28 100644 --- a/tests/hosting_core/state/test_agent_state.py +++ b/tests/hosting_core/state/test_agent_state.py @@ -384,6 +384,7 @@ async def test_cached_state_hash_computation(self): # Make a change prop_accessor = self.user_state.create_property("test_prop") await prop_accessor.set(self.context, _MockTestDataItem("test_value")) + cached_state = self.user_state.get_cached_state(self.context) # State should now be changed assert cached_state.is_changed From 2dd6036202feacbd23b6f7cecb7f1c3ebc76531c Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Fri, 10 Jul 2026 15:33:34 -0700 Subject: [PATCH 03/12] fix state logic and tests to reflect AgentState.load() changes --- .../hosting/core/state/agent_state.py | 13 +- .../memory/scopes/test_memory_scopes.py | 4 +- .../hosting_dialogs/test_dialog_extensions.py | 1 + tests/hosting_dialogs/test_dialog_manager.py | 281 ------------------ 4 files changed, 8 insertions(+), 291 deletions(-) delete mode 100644 tests/hosting_dialogs/test_dialog_manager.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py index a777dfe1..bd8febb5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py @@ -147,8 +147,8 @@ def _should_load(self, turn_context: TurnContext, force: bool = False) -> bool: :rtype: bool """ - _cached_state = self.get_cached_state(turn_context) - return force or not _cached_state or not _cached_state.state + self._cached_state = self.get_cached_state(turn_context) + return force or not self._cached_state or not self._cached_state.state async def save(self, turn_context: TurnContext, force: bool = False) -> None: """ @@ -183,6 +183,7 @@ def clear(self, turn_context: TurnContext): cache_value = CachedAgentState() cache_value.hash = "" self._cached_state = cache_value + turn_context.turn_state[self._context_service_key] = self._cached_state async def delete(self, turn_context: TurnContext) -> None: """ @@ -260,9 +261,7 @@ def delete_value(self, property_name: str) -> None: :return: None """ if not property_name: - raise TypeError( - "AgentState.delete_property(): property_name cannot be None." - ) + raise TypeError("AgentState.delete_value(): property_name cannot be None.") if self._cached_state.state.get(property_name): del self._cached_state.state[property_name] @@ -279,9 +278,7 @@ def set_value(self, property_name: str, value: StoreItem) -> None: :return: None """ if not property_name: - raise TypeError( - "AgentState.delete_property(): property_name cannot be None." - ) + raise TypeError("AgentState.set_value(): property_name cannot be None.") self._cached_state.state[property_name] = value diff --git a/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py b/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py index 5eca2f3f..fd0421c0 100644 --- a/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py +++ b/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py @@ -213,7 +213,7 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context adapter = DialogTestAdapter() context = TurnContext(adapter, _begin_message) - context.turn_state["ConversationState"] = conversation_state + await conversation_state.save(context) dialog_context = await dialogs.create_context(context) @@ -285,7 +285,7 @@ async def test_user_memory_scope_should_return_state_once_loaded(self): # Create a DialogState property, DialogSet and register the dialogs. conversation_state = ConversationState(storage) - context.turn_state["ConversationState"] = conversation_state + await conversation_state.save(context) dialog_state = conversation_state.create_property("dialogs") dialogs = DialogSet(dialog_state) dialog = _TestDialog("test", "test message") diff --git a/tests/hosting_dialogs/test_dialog_extensions.py b/tests/hosting_dialogs/test_dialog_extensions.py index 082e49ed..9659882b 100644 --- a/tests/hosting_dialogs/test_dialog_extensions.py +++ b/tests/hosting_dialogs/test_dialog_extensions.py @@ -121,6 +121,7 @@ async def capture_eoc( await DialogExtensions.run_dialog( dialog, context, convo_state.create_property("DialogState") ) + await convo_state.save(context) # TEMP experiment return DialogTestAdapter(logic) diff --git a/tests/hosting_dialogs/test_dialog_manager.py b/tests/hosting_dialogs/test_dialog_manager.py deleted file mode 100644 index 28af29ed..00000000 --- a/tests/hosting_dialogs/test_dialog_manager.py +++ /dev/null @@ -1,281 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -# pylint: disable=pointless-string-statement - -from enum import Enum -from typing import Callable, List, Tuple - -import pytest - -from microsoft_agents.hosting.core import ( - ConversationState, - MemoryStorage, - MessageFactory, - UserState, - TurnContext, -) -from microsoft_agents.hosting.core import ClaimsIdentity -from microsoft_agents.hosting.core.authorization import AuthenticationConstants -from microsoft_agents.hosting.dialogs import ( - ComponentDialog, - Dialog, - DialogContext, - DialogEvents, - DialogInstance, - DialogReason, - TextPrompt, - WaterfallDialog, - DialogManager, - DialogManagerResult, - DialogTurnStatus, - WaterfallStepContext, -) -from microsoft_agents.hosting.dialogs.prompts import PromptOptions -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, - EndOfConversationCodes, - InputHints, - Channels, -) -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class SkillFlowTestCase(str, Enum): - # DialogManager is executing on a root bot with no skills (typical standalone bot). - root_bot_only = "RootBotOnly" - - # DialogManager is executing on a root bot handling replies from a skill. - root_bot_consuming_skill = "RootBotConsumingSkill" - - # DialogManager is executing in a skill that is called from a root and calling another skill. - middle_skill = "MiddleSkill" - - # DialogManager is executing in a skill that is called from a parent (a root or another skill) but doesn't call - # another skill. - leaf_skill = "LeafSkill" - - -class SimpleComponentDialog(ComponentDialog): - # An App ID for a parent bot. - parent_bot_id = "00000000-0000-0000-0000-0000000000PARENT" - - # An App ID for a skill bot. - skill_bot_id = "00000000-0000-0000-0000-00000000000SKILL" - - # Captures an EndOfConversation if it was sent to help with assertions. - eoc_sent: Activity = None - - # Property to capture the DialogManager turn results and do assertions. - dm_turn_result: DialogManagerResult = None - - def __init__( - self, id: str = None, prop: str = None - ): # pylint: disable=unused-argument - super().__init__(id or "SimpleComponentDialog") - self.text_prompt = "TextPrompt" - self.waterfall_dialog = "WaterfallDialog" - self.add_dialog(TextPrompt(self.text_prompt)) - self.add_dialog( - WaterfallDialog( - self.waterfall_dialog, - [ - self.prompt_for_name, - self.final_step, - ], - ) - ) - self.initial_dialog_id = self.waterfall_dialog - self.end_reason = None - - @staticmethod - async def create_test_flow( - dialog: Dialog, - test_case: SkillFlowTestCase = SkillFlowTestCase.root_bot_only, - enabled_trace=False, - ) -> DialogTestAdapter: - conversation_id = "testFlowConversationId" - storage = MemoryStorage() - conversation_state = ConversationState(storage) - user_state = UserState(storage) - - activity = Activity( - type=ActivityTypes.message, - channel_id=Channels.test, - service_url="https://test.com", - from_property=ChannelAccount(id="user1", name="User1"), - recipient=ChannelAccount(id="bot", name="Bot"), - conversation=ConversationAccount( - is_group=False, conversation_type=conversation_id, id=conversation_id - ), - ) - - dialog_manager = DialogManager(dialog) - dialog_manager.user_state = user_state - dialog_manager.conversation_state = conversation_state - - async def logic(context: TurnContext): - if test_case != SkillFlowTestCase.root_bot_only: - # Create a skill ClaimsIdentity and put it in turn_state so isSkillClaim() returns True. - claims_identity = ClaimsIdentity({}, False) - claims_identity.claims["ver"] = ( - "2.0" # AuthenticationConstants.VersionClaim - ) - claims_identity.claims["aud"] = ( - SimpleComponentDialog.skill_bot_id - ) # AuthenticationConstants.AudienceClaim - claims_identity.claims["azp"] = ( - SimpleComponentDialog.parent_bot_id - ) # AuthenticationConstants.AuthorizedParty - context._identity = claims_identity - - # Note: SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY based skill routing - # is not available in the new SDK. Skill flow test cases that require it are skipped. - - async def aux( - turn_context: TurnContext, # pylint: disable=unused-argument - activities: List[Activity], - next: Callable, - ): - for activity in activities: - if activity.type == ActivityTypes.end_of_conversation: - SimpleComponentDialog.eoc_sent = activity - break - - return await next() - - # Interceptor to capture the EoC activity if it was sent so we can assert it in the tests. - context.on_send_activities(aux) - - SimpleComponentDialog.dm_turn_result = await dialog_manager.on_turn(context) - - # Manually save state since AutoSaveStateMiddleware is not available - await conversation_state.save(context) - await user_state.save(context) - - adapter = DialogTestAdapter(logic) - if enabled_trace: - adapter.enable_trace = True - - return adapter - - async def on_end_dialog( - self, context: DialogContext, instance: DialogInstance, reason: DialogReason - ): - self.end_reason = reason - return await super().on_end_dialog(context, instance, reason) - - async def prompt_for_name(self, step: WaterfallStepContext): - return await step.prompt( - self.text_prompt, - PromptOptions( - prompt=MessageFactory.text( - "Hello, what is your name?", None, InputHints.expecting_input - ), - retry_prompt=MessageFactory.text( - "Hello, what is your name again?", None, InputHints.expecting_input - ), - ), - ) - - async def final_step(self, step: WaterfallStepContext): - await step.context.send_activity(f"Hello { step.result }, nice to meet you!") - return await step.end_dialog(step.result) - - -class TestDialogManager: - @pytest.mark.asyncio - async def test_handles_root_bot_only(self): - SimpleComponentDialog.dm_turn_result = None - SimpleComponentDialog.eoc_sent = None - dialog = SimpleComponentDialog() - test_flow = await SimpleComponentDialog.create_test_flow( - dialog, SkillFlowTestCase.root_bot_only - ) - step1 = await test_flow.send("Hi") - step2 = await step1.assert_reply("Hello, what is your name?") - step3 = await step2.send("SomeName") - await step3.assert_reply("Hello SomeName, nice to meet you!") - - assert ( - SimpleComponentDialog.dm_turn_result.turn_result.status - == DialogTurnStatus.Complete - ) - assert dialog.end_reason == DialogReason.EndCalled - assert ( - SimpleComponentDialog.eoc_sent is None - ), "Root bot should not send EndConversation to channel" - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_handles_root_bot_consuming_skill(self): - pass - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_handles_middle_skill(self): - pass - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_handles_leaf_skill(self): - pass - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_skill_handles_eoc_from_parent(self): - pass - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_skill_handles_reprompt_from_parent(self): - pass - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_skill_should_return_empty_on_reprompt_with_no_dialog(self): - pass - - @pytest.mark.asyncio - async def test_trace_bot_state(self): - SimpleComponentDialog.dm_turn_result = None - dialog = SimpleComponentDialog() - - def assert_is_trace(activity, description): # pylint: disable=unused-argument - assert activity.type == ActivityTypes.trace - return True - - def assert_is_trace_and_label(activity, description): - assert_is_trace(activity, description) - assert activity.label == "Bot State" - return True - - test_flow = await SimpleComponentDialog.create_test_flow( - dialog, SkillFlowTestCase.root_bot_only, True - ) - - step1 = await test_flow.send("Hi") - step2 = await step1.assert_reply("Hello, what is your name?") - step3 = await step2.assert_reply(assert_is_trace_and_label) - step4 = await step3.send("SomeName") - step5 = await step4.assert_reply("Hello SomeName, nice to meet you!") - await step5.assert_reply(assert_is_trace_and_label) - - assert ( - SimpleComponentDialog.dm_turn_result.turn_result.status - == DialogTurnStatus.Complete - ) From b11134b2ba61dc729439bfab1a92e7c406e079d2 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Fri, 10 Jul 2026 15:40:06 -0700 Subject: [PATCH 04/12] purge DialogManager for consistency with .net sdk --- .../hosting/dialogs/__init__.py | 4 - .../hosting/dialogs/dialog_manager.py | 189 ------------------ .../hosting/dialogs/dialog_manager_result.py | 17 -- .../hosting/dialogs/dialog_set.py | 7 +- .../readme.md | 1 - 5 files changed, 2 insertions(+), 216 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager.py delete mode 100644 libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager_result.py diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/__init__.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/__init__.py index 0d3485e2..88a35498 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/__init__.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/__init__.py @@ -18,8 +18,6 @@ from .dialog_state import DialogState from .models.dialog_turn_result import DialogTurnResult from .models.dialog_turn_status import DialogTurnStatus -from .dialog_manager import DialogManager -from .dialog_manager_result import DialogManagerResult from .dialog import Dialog from .dialog_component_registration import DialogsComponentRegistration from .persisted_state_keys import PersistedStateKeys @@ -51,8 +49,6 @@ "DialogState", "DialogTurnResult", "DialogTurnStatus", - "DialogManager", - "DialogManagerResult", "Dialog", "DialogsComponentRegistration", "WaterfallDialog", 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 deleted file mode 100644 index c82c7310..00000000 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager.py +++ /dev/null @@ -1,189 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from datetime import datetime, timedelta -from threading import Lock -from typing import cast - -from microsoft_agents.hosting.core import ( - ChannelAdapter, - ConversationState, - UserState, - TurnContext, - ClaimsIdentity, -) - -from .dialog import Dialog -from .dialog_context import DialogContext -from .dialog_extensions import DialogExtensions -from .dialog_set import DialogSet -from .dialog_state import DialogState -from .dialog_manager_result import DialogManagerResult -from .models.dialog_turn_status import DialogTurnStatus -from .models.dialog_turn_result import DialogTurnResult - - -class DialogManager: - """ - Class which runs the dialog system. - """ - - def __init__( - self, - root_dialog: Dialog | None = None, - dialog_state_property: str = "DialogState", - ): - """ - Initializes an instance of the DialogManager class. - :param root_dialog: Root dialog to use. - :param dialog_state_property: Alternate name for the dialog_state property. (Default is "DialogState"). - """ - self.last_access = "_lastAccess" - self._root_dialog_id = "" - self._dialog_state_property = dialog_state_property - self._lock = Lock() - - # Gets or sets root dialog to use to start conversation. - self.root_dialog = root_dialog - - # Gets or sets the ConversationState. - self.conversation_state: ConversationState | None = None - - # Gets or sets the UserState. - self.user_state: UserState | None = None - - # Gets InitialTurnState collection to copy into the TurnState on every turn. - self.initial_turn_state = {} - - # Gets or sets global dialogs that you want to have be callable. - self.dialogs = DialogSet() - - # Gets or sets (optional) number of milliseconds to expire the bot's state after. - self.expire_after: int | None = None - - async def on_turn(self, context: TurnContext) -> DialogManagerResult: - """ - Runs dialog system in the context of a TurnContext. - :param context: turn context. - :return: - """ - # Lazy initialize RootDialog so it can refer to assets like LG function templates - if not self._root_dialog_id: - with self._lock: - if not self._root_dialog_id: - if self.root_dialog is None: - raise ValueError("DialogManager: root_dialog cannot be None.") - self._root_dialog_id = self.root_dialog.id - self.dialogs.add(self.root_dialog) - - # Preload TurnState with DM TurnState. - for key, val in self.initial_turn_state.items(): - context.turn_state[key] = val - - # Register DialogManager with TurnState. - context.turn_state[DialogManager.__name__] = self - - # Resolve ConversationState - conversation_state_name = ConversationState.__name__ - if self.conversation_state is None: - if conversation_state_name not in context.turn_state: - raise Exception( - f"Unable to get an instance of {conversation_state_name} from turn_context. " - f"Please ensure ConversationState is available in turn_state." - ) - self.conversation_state = cast( - ConversationState, context.turn_state[conversation_state_name] - ) - else: - context.turn_state[conversation_state_name] = self.conversation_state - - # Resolve UserState (optional) - user_state_name = UserState.__name__ - if self.user_state is None: - self.user_state = cast( - UserState | None, context.turn_state.get(user_state_name, None) - ) - else: - context.turn_state[user_state_name] = self.user_state - - # Create property accessors - last_access_property = self.conversation_state.create_property(self.last_access) - last_access: datetime = cast( - datetime, await last_access_property.get(context, datetime.now) - ) - - # Check for expired conversation - if self.expire_after is not None and ( - datetime.now() - last_access - ) >= timedelta(milliseconds=float(self.expire_after)): - # Clear conversation state - self.conversation_state.clear(context) - - last_access = datetime.now() - await last_access_property.set(context, last_access) - - # Get dialog stack - dialogs_property = self.conversation_state.create_property( - self._dialog_state_property - ) - dialog_state: DialogState = cast( - DialogState, await dialogs_property.get(context, DialogState) - ) - - # Create DialogContext - dialog_context = DialogContext(self.dialogs, context, dialog_state) - - # Call the common dialog "continue/begin" execution pattern shared with the classic RunAsync extension method - turn_result = ( - await DialogExtensions._internal_run( # pylint: disable=protected-access - context, self._root_dialog_id, dialog_context - ) - ) - - # Save ConversationState changes - await self.conversation_state.save(context, False) - - # Save UserState changes if present - if self.user_state is not None: - await self.user_state.save(context, False) - - return DialogManagerResult(turn_result=turn_result) - - @staticmethod - 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() - ) - - @staticmethod - def should_send_end_of_conversation_to_parent( - context: TurnContext, turn_result: DialogTurnResult - ) -> bool: - """ - Helper to determine if we should send an EndOfConversation to the parent or not. - """ - if not ( - turn_result.status == DialogTurnStatus.Complete - or turn_result.status == DialogTurnStatus.Cancelled - ): - # The dialog is still going, don't return EoC. - return False - - return DialogManager.is_from_parent_to_skill(context) - - @staticmethod - def get_active_dialog_context(dialog_context: DialogContext) -> DialogContext: - """ - Recursively walk up the DC stack to find the active DC. - """ - return DialogExtensions._get_active_dialog_context( # pylint: disable=protected-access - dialog_context - ) diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager_result.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager_result.py deleted file mode 100644 index a651703f..00000000 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager_result.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from dataclasses import dataclass, field - -from microsoft_agents.activity import Activity - -from .models.dialog_turn_result import DialogTurnResult -from .persisted_state import PersistedState - - -@dataclass -class DialogManagerResult: - - turn_result: DialogTurnResult | None = None - activities: list[Activity] = field(default_factory=list) - persisted_state: PersistedState | None = None diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_set.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_set.py index 0fe84723..60bddded 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_set.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_set.py @@ -31,14 +31,11 @@ def __init__(self, dialog_state: StatePropertyAccessor | None = None): raise KeyError except KeyError: raise TypeError("DialogSet(): dialog_state cannot be None.") - # Only ComponentDialog / DialogContainer / DialogManager can initialize with None dialog_state + # Only ComponentDialog / DialogContainer can initialize with None dialog_state from .component_dialog import ComponentDialog - from .dialog_manager import DialogManager from .dialog_container import DialogContainer - if not isinstance( - self_obj, (ComponentDialog, DialogContainer, DialogManager) - ): + if not isinstance(self_obj, (ComponentDialog, DialogContainer)): raise TypeError("DialogSet(): dialog_state cannot be None.") finally: # make sure to clean up the frame at the end to avoid ref cycles diff --git a/libraries/microsoft-agents-hosting-dialogs/readme.md b/libraries/microsoft-agents-hosting-dialogs/readme.md index 98630500..b3240a6a 100644 --- a/libraries/microsoft-agents-hosting-dialogs/readme.md +++ b/libraries/microsoft-agents-hosting-dialogs/readme.md @@ -17,7 +17,6 @@ This library is part of the **Microsoft 365 Agents SDK for Python** - a comprehe - **Prompts**: Built-in prompts for text, numbers, choices, confirmations, dates, attachments, and OAuth - **Choice recognition**: Locale-aware choice matching and recognition - **Dialog memory**: Scoped memory management (conversation, user, dialog, class, settings) -- **DialogManager**: High-level dialog orchestration with state management ## Installation From 869a6b5351488afbc94ddc9f5f8c1eebc799dde0 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Fri, 10 Jul 2026 15:49:36 -0700 Subject: [PATCH 05/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/hosting/core/state/agent_state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py index bd8febb5..ba7f0ba0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py @@ -148,7 +148,7 @@ def _should_load(self, turn_context: TurnContext, force: bool = False) -> bool: """ self._cached_state = self.get_cached_state(turn_context) - return force or not self._cached_state or not self._cached_state.state + return force or self._cached_state is None async def save(self, turn_context: TurnContext, force: bool = False) -> None: """ From f151c9f222602778eb2006507d1b9baabd5795a4 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Fri, 10 Jul 2026 15:54:48 -0700 Subject: [PATCH 06/12] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/hosting/core/state/agent_state.py | 4 ++-- tests/hosting_dialogs/memory/scopes/test_memory_scopes.py | 4 ++-- tests/hosting_dialogs/test_dialog_extensions.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py index ba7f0ba0..e5e948c4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py @@ -261,7 +261,7 @@ def delete_value(self, property_name: str) -> None: :return: None """ if not property_name: - raise TypeError("AgentState.delete_value(): property_name cannot be None.") + raise TypeError("AgentState.delete_value(): property_name cannot be None or empty.") if self._cached_state.state.get(property_name): del self._cached_state.state[property_name] @@ -278,7 +278,7 @@ def set_value(self, property_name: str, value: StoreItem) -> None: :return: None """ if not property_name: - raise TypeError("AgentState.set_value(): property_name cannot be None.") + raise TypeError("AgentState.set_value(): property_name cannot be None or empty.") self._cached_state.state[property_name] = value diff --git a/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py b/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py index fd0421c0..9ece9f1c 100644 --- a/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py +++ b/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py @@ -213,7 +213,7 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context adapter = DialogTestAdapter() context = TurnContext(adapter, _begin_message) - await conversation_state.save(context) + context.turn_state[ConversationState.__name__] = conversation_state dialog_context = await dialogs.create_context(context) @@ -285,7 +285,7 @@ async def test_user_memory_scope_should_return_state_once_loaded(self): # Create a DialogState property, DialogSet and register the dialogs. conversation_state = ConversationState(storage) - await conversation_state.save(context) + context.turn_state[ConversationState.__name__] = conversation_state dialog_state = conversation_state.create_property("dialogs") dialogs = DialogSet(dialog_state) dialog = _TestDialog("test", "test message") diff --git a/tests/hosting_dialogs/test_dialog_extensions.py b/tests/hosting_dialogs/test_dialog_extensions.py index 9659882b..734629a9 100644 --- a/tests/hosting_dialogs/test_dialog_extensions.py +++ b/tests/hosting_dialogs/test_dialog_extensions.py @@ -121,7 +121,7 @@ async def capture_eoc( await DialogExtensions.run_dialog( dialog, context, convo_state.create_property("DialogState") ) - await convo_state.save(context) # TEMP experiment + await convo_state.save(context) return DialogTestAdapter(logic) From d5b0b06e45afc83b47d2f2aadafea3e46c482059 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Fri, 10 Jul 2026 16:00:50 -0700 Subject: [PATCH 07/12] cleanup --- .../microsoft_agents/hosting/core/state/agent_state.py | 8 ++++++-- tests/hosting_dialogs/memory/scopes/test_memory_scopes.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py index e5e948c4..67faa60e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py @@ -261,7 +261,9 @@ def delete_value(self, property_name: str) -> None: :return: None """ if not property_name: - raise TypeError("AgentState.delete_value(): property_name cannot be None or empty.") + raise TypeError( + "AgentState.delete_value(): property_name cannot be None or empty." + ) if self._cached_state.state.get(property_name): del self._cached_state.state[property_name] @@ -278,7 +280,9 @@ def set_value(self, property_name: str, value: StoreItem) -> None: :return: None """ if not property_name: - raise TypeError("AgentState.set_value(): property_name cannot be None or empty.") + raise TypeError( + "AgentState.set_value(): property_name cannot be None or empty." + ) self._cached_state.state[property_name] = value diff --git a/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py b/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py index 9ece9f1c..84db2743 100644 --- a/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py +++ b/tests/hosting_dialogs/memory/scopes/test_memory_scopes.py @@ -213,7 +213,7 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context adapter = DialogTestAdapter() context = TurnContext(adapter, _begin_message) - context.turn_state[ConversationState.__name__] = conversation_state + await conversation_state.load(context) dialog_context = await dialogs.create_context(context) @@ -285,7 +285,7 @@ async def test_user_memory_scope_should_return_state_once_loaded(self): # Create a DialogState property, DialogSet and register the dialogs. conversation_state = ConversationState(storage) - context.turn_state[ConversationState.__name__] = conversation_state + await conversation_state.load(context) dialog_state = conversation_state.create_property("dialogs") dialogs = DialogSet(dialog_state) dialog = _TestDialog("test", "test message") From d6129569b1c1248a1399d5f49c50642115f57909 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Fri, 10 Jul 2026 16:51:08 -0700 Subject: [PATCH 08/12] restore deletions cause copilot got mad --- dev/hosting_dialogs/__init__.py | 0 dev/hosting_dialogs/choices/__init__.py | 0 dev/hosting_dialogs/choices/test_channel.py | 89 ++ dev/hosting_dialogs/choices/test_choice.py | 29 + .../choices/test_choice_factory.py | 237 +++++ .../choices/test_choice_factory_options.py | 32 + .../choices/test_choice_recognizers.py | 198 ++++ .../choices/test_choice_tokenizer.py | 67 ++ dev/hosting_dialogs/helpers.py | 312 ++++++ dev/hosting_dialogs/memory/__init__.py | 0 dev/hosting_dialogs/memory/scopes/__init__.py | 0 .../memory/scopes/test_memory_scopes.py | 684 ++++++++++++ .../memory/scopes/test_settings.py | 14 + .../memory/test_at_path_resolver.py | 44 + dev/hosting_dialogs/test_activity_prompt.py | 286 +++++ dev/hosting_dialogs/test_attachment_prompt.py | 287 +++++ dev/hosting_dialogs/test_choice_prompt.py | 991 ++++++++++++++++++ dev/hosting_dialogs/test_component_dialog.py | 371 +++++++ dev/hosting_dialogs/test_confirm_prompt.py | 493 +++++++++ dev/hosting_dialogs/test_date_time_prompt.py | 88 ++ dev/hosting_dialogs/test_dialog.py | 81 ++ dev/hosting_dialogs/test_dialog_context.py | 293 ++++++ dev/hosting_dialogs/test_dialog_extensions.py | 188 ++++ dev/hosting_dialogs/test_dialog_manager.py | 285 +++++ dev/hosting_dialogs/test_dialog_set.py | 137 +++ dev/hosting_dialogs/test_number_prompt.py | 378 +++++++ dev/hosting_dialogs/test_oauth_prompt.py | 384 +++++++ dev/hosting_dialogs/test_object_path.py | 209 ++++ .../test_prompt_culture_models.py | 76 ++ .../test_prompt_validator_context.py | 103 ++ dev/hosting_dialogs/test_replace_dialog.py | 136 +++ dev/hosting_dialogs/test_text_prompt.py | 195 ++++ dev/hosting_dialogs/test_waterfall.py | 314 ++++++ dev/hosting_dialogs/test_waterfall_dialog.py | 171 +++ .../test_waterfall_step_context.py | 94 ++ .../hosting/dialogs/__init__.py | 4 + .../hosting/dialogs/dialog_manager.py | 189 ++++ .../hosting/dialogs/dialog_manager_result.py | 17 + .../hosting/dialogs/dialog_set.py | 7 +- tests/hosting_dialogs/test_dialog_manager.py | 285 +++++ 40 files changed, 7766 insertions(+), 2 deletions(-) create mode 100644 dev/hosting_dialogs/__init__.py create mode 100644 dev/hosting_dialogs/choices/__init__.py create mode 100644 dev/hosting_dialogs/choices/test_channel.py create mode 100644 dev/hosting_dialogs/choices/test_choice.py create mode 100644 dev/hosting_dialogs/choices/test_choice_factory.py create mode 100644 dev/hosting_dialogs/choices/test_choice_factory_options.py create mode 100644 dev/hosting_dialogs/choices/test_choice_recognizers.py create mode 100644 dev/hosting_dialogs/choices/test_choice_tokenizer.py create mode 100644 dev/hosting_dialogs/helpers.py create mode 100644 dev/hosting_dialogs/memory/__init__.py create mode 100644 dev/hosting_dialogs/memory/scopes/__init__.py create mode 100644 dev/hosting_dialogs/memory/scopes/test_memory_scopes.py create mode 100644 dev/hosting_dialogs/memory/scopes/test_settings.py create mode 100644 dev/hosting_dialogs/memory/test_at_path_resolver.py create mode 100644 dev/hosting_dialogs/test_activity_prompt.py create mode 100644 dev/hosting_dialogs/test_attachment_prompt.py create mode 100644 dev/hosting_dialogs/test_choice_prompt.py create mode 100644 dev/hosting_dialogs/test_component_dialog.py create mode 100644 dev/hosting_dialogs/test_confirm_prompt.py create mode 100644 dev/hosting_dialogs/test_date_time_prompt.py create mode 100644 dev/hosting_dialogs/test_dialog.py create mode 100644 dev/hosting_dialogs/test_dialog_context.py create mode 100644 dev/hosting_dialogs/test_dialog_extensions.py create mode 100644 dev/hosting_dialogs/test_dialog_manager.py create mode 100644 dev/hosting_dialogs/test_dialog_set.py create mode 100644 dev/hosting_dialogs/test_number_prompt.py create mode 100644 dev/hosting_dialogs/test_oauth_prompt.py create mode 100644 dev/hosting_dialogs/test_object_path.py create mode 100644 dev/hosting_dialogs/test_prompt_culture_models.py create mode 100644 dev/hosting_dialogs/test_prompt_validator_context.py create mode 100644 dev/hosting_dialogs/test_replace_dialog.py create mode 100644 dev/hosting_dialogs/test_text_prompt.py create mode 100644 dev/hosting_dialogs/test_waterfall.py create mode 100644 dev/hosting_dialogs/test_waterfall_dialog.py create mode 100644 dev/hosting_dialogs/test_waterfall_step_context.py create mode 100644 libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager.py create mode 100644 libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager_result.py create mode 100644 tests/hosting_dialogs/test_dialog_manager.py diff --git a/dev/hosting_dialogs/__init__.py b/dev/hosting_dialogs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/hosting_dialogs/choices/__init__.py b/dev/hosting_dialogs/choices/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/hosting_dialogs/choices/test_channel.py b/dev/hosting_dialogs/choices/test_channel.py new file mode 100644 index 00000000..74fc849c --- /dev/null +++ b/dev/hosting_dialogs/choices/test_channel.py @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List, Tuple + +import pytest + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + Channels, + ConversationAccount, + ChannelAccount, +) +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.dialogs.choices import Channel +from tests._common.testing_objects import MockTestingAdapter + + +class TestChannel: + def test_supports_suggested_actions(self): + actual = Channel.supports_suggested_actions(Channels.facebook, 5) + assert actual + + def test_supports_suggested_actions_many(self): + supports_suggested_actions_data: List[Tuple[str, int, bool]] = [ + (Channels.line, 13, True), + (Channels.line, 14, False), + (Channels.skype, 10, True), + (Channels.skype, 11, False), + (Channels.kik, 20, True), + (Channels.kik, 21, False), + (Channels.emulator, 100, True), + (Channels.emulator, 101, False), + (Channels.direct_line_speech, 100, True), + ] + + for channel, button_cnt, expected in supports_suggested_actions_data: + actual = Channel.supports_suggested_actions(channel, button_cnt) + assert ( + expected == actual + ), f"channel={channel}, button_cnt={button_cnt}: expected {expected}, got {actual}" + + def test_supports_card_actions_many(self): + supports_card_action_data: List[Tuple[str, int, bool]] = [ + (Channels.line, 99, True), + (Channels.line, 100, False), + (Channels.slack, 100, True), + (Channels.skype, 3, True), + (Channels.skype, 5, False), + (Channels.direct_line_speech, 99, True), + ] + + for channel, button_cnt, expected in supports_card_action_data: + actual = Channel.supports_card_actions(channel, button_cnt) + assert ( + expected == actual + ), f"channel={channel}, button_cnt={button_cnt}: expected {expected}, got {actual}" + + def test_supports_suggested_actions_accepts_string_channel_id(self): + assert Channel.supports_suggested_actions("facebook", 5) + assert not Channel.supports_suggested_actions("facebook", 11) + + def test_supports_card_actions_accepts_string_channel_id(self): + assert Channel.supports_card_actions("msteams", 3) + assert not Channel.supports_card_actions("msteams", 4) + + def test_should_return_channel_id_from_context_activity(self): + adapter = MockTestingAdapter(channel_id=Channels.facebook) + test_activity = Activity( + type=ActivityTypes.message, + channel_id=Channels.facebook, + conversation=ConversationAccount(id="test"), + from_property=ChannelAccount(id="user"), + ) + test_context = TurnContext(adapter, test_activity) + channel_id = Channel.get_channel_id(test_context) + assert Channels.facebook == channel_id + + def test_should_return_empty_from_context_activity_missing_channel(self): + adapter = MockTestingAdapter() + test_activity = Activity( + type=ActivityTypes.message, + conversation=ConversationAccount(id="test"), + from_property=ChannelAccount(id="user"), + ) + test_context = TurnContext(adapter, test_activity) + channel_id = Channel.get_channel_id(test_context) + assert "" == channel_id diff --git a/dev/hosting_dialogs/choices/test_choice.py b/dev/hosting_dialogs/choices/test_choice.py new file mode 100644 index 00000000..4b37a3e6 --- /dev/null +++ b/dev/hosting_dialogs/choices/test_choice.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +import pytest + +from microsoft_agents.hosting.dialogs.choices import Choice +from microsoft_agents.activity import CardAction + + +class TestChoice: + def test_value_round_trips(self) -> None: + choice = Choice() + expected = "any" + choice.value = expected + assert expected is choice.value + + def test_action_round_trips(self) -> None: + choice = Choice() + expected = CardAction(type="imBack", title="Test Action") + choice.action = expected + assert expected is choice.action + + def test_synonyms_round_trips(self) -> None: + choice = Choice() + expected: List[str] = [] + choice.synonyms = expected + assert expected is choice.synonyms diff --git a/dev/hosting_dialogs/choices/test_choice_factory.py b/dev/hosting_dialogs/choices/test_choice_factory.py new file mode 100644 index 00000000..47e143ff --- /dev/null +++ b/dev/hosting_dialogs/choices/test_choice_factory.py @@ -0,0 +1,237 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +import pytest + +from microsoft_agents.hosting.dialogs.choices import ( + Choice, + ChoiceFactory, + ChoiceFactoryOptions, +) +from microsoft_agents.activity import ( + ActionTypes, + Activity, + ActivityTypes, + Attachment, + AttachmentLayoutTypes, + CardAction, + HeroCard, + InputHints, + SuggestedActions, + Channels, +) + + +class TestChoiceFactory: + color_choices: List[Choice] = [Choice("red"), Choice("green"), Choice("blue")] + choices_with_actions: List[Choice] = [ + Choice( + "ImBack", + action=CardAction( + type=ActionTypes.im_back, title="ImBack Action", value="ImBack Value" + ), + ), + Choice( + "MessageBack", + action=CardAction( + type=ActionTypes.message_back, + title="MessageBack Action", + value="MessageBack Value", + ), + ), + Choice( + "PostBack", + action=CardAction( + type=ActionTypes.post_back, + title="PostBack Action", + value="PostBack Value", + ), + ), + ] + + def test_inline_should_render_choices_inline(self): + activity = ChoiceFactory.inline(TestChoiceFactory.color_choices, "select from:") + assert "select from: (1) red, (2) green, or (3) blue" == activity.text + + def test_should_render_choices_as_a_list(self): + activity = ChoiceFactory.list_style( + TestChoiceFactory.color_choices, "select from:" + ) + assert "select from:\n\n 1. red\n 2. green\n 3. blue" == activity.text + + def test_should_render_unincluded_numbers_choices_as_a_list(self): + activity = ChoiceFactory.list_style( + TestChoiceFactory.color_choices, + "select from:", + options=ChoiceFactoryOptions(include_numbers=False), + ) + assert "select from:\n\n - red\n - green\n - blue" == activity.text + + def test_should_render_choices_as_suggested_actions(self): + expected = Activity( + type=ActivityTypes.message, + text="select from:", + input_hint=InputHints.expecting_input, + suggested_actions=SuggestedActions( + actions=[ + CardAction(type=ActionTypes.im_back, value="red", title="red"), + CardAction(type=ActionTypes.im_back, value="green", title="green"), + CardAction(type=ActionTypes.im_back, value="blue", title="blue"), + ] + ), + ) + + activity = ChoiceFactory.suggested_action( + TestChoiceFactory.color_choices, "select from:" + ) + + assert expected == activity + + def test_should_render_choices_as_hero_card(self): + expected = Activity( + type=ActivityTypes.message, + input_hint=InputHints.expecting_input, + attachment_layout=AttachmentLayoutTypes.list, + attachments=[ + Attachment( + content=HeroCard( + text="select from:", + buttons=[ + CardAction( + type=ActionTypes.im_back, value="red", title="red" + ), + CardAction( + type=ActionTypes.im_back, value="green", title="green" + ), + CardAction( + type=ActionTypes.im_back, value="blue", title="blue" + ), + ], + ), + content_type="application/vnd.microsoft.card.hero", + ) + ], + ) + + activity = ChoiceFactory.hero_card( + TestChoiceFactory.color_choices, "select from:" + ) + + assert expected == activity + + def test_should_automatically_choose_render_style_based_on_channel_type(self): + expected = Activity( + type=ActivityTypes.message, + text="select from:", + input_hint=InputHints.expecting_input, + suggested_actions=SuggestedActions( + actions=[ + CardAction(type=ActionTypes.im_back, value="red", title="red"), + CardAction(type=ActionTypes.im_back, value="green", title="green"), + CardAction(type=ActionTypes.im_back, value="blue", title="blue"), + ] + ), + ) + activity = ChoiceFactory.for_channel( + Channels.emulator, TestChoiceFactory.color_choices, "select from:" + ) + + assert expected == activity + + def test_should_choose_correct_styles_for_teams(self): + expected = Activity( + type=ActivityTypes.message, + input_hint=InputHints.expecting_input, + attachment_layout=AttachmentLayoutTypes.list, + attachments=[ + Attachment( + content=HeroCard( + text="select from:", + buttons=[ + CardAction( + type=ActionTypes.im_back, value="red", title="red" + ), + CardAction( + type=ActionTypes.im_back, value="green", title="green" + ), + CardAction( + type=ActionTypes.im_back, value="blue", title="blue" + ), + ], + ), + content_type="application/vnd.microsoft.card.hero", + ) + ], + ) + activity = ChoiceFactory.for_channel( + Channels.ms_teams, TestChoiceFactory.color_choices, "select from:" + ) + assert expected == activity + + def test_should_include_choice_actions_in_suggested_actions(self): + expected = Activity( + type=ActivityTypes.message, + text="select from:", + input_hint=InputHints.expecting_input, + suggested_actions=SuggestedActions( + actions=[ + CardAction( + type=ActionTypes.im_back, + value="ImBack Value", + title="ImBack Action", + ), + CardAction( + type=ActionTypes.message_back, + value="MessageBack Value", + title="MessageBack Action", + ), + CardAction( + type=ActionTypes.post_back, + value="PostBack Value", + title="PostBack Action", + ), + ] + ), + ) + activity = ChoiceFactory.suggested_action( + TestChoiceFactory.choices_with_actions, "select from:" + ) + assert expected == activity + + def test_should_include_choice_actions_in_hero_cards(self): + expected = Activity( + type=ActivityTypes.message, + input_hint=InputHints.expecting_input, + attachment_layout=AttachmentLayoutTypes.list, + attachments=[ + Attachment( + content=HeroCard( + text="select from:", + buttons=[ + CardAction( + type=ActionTypes.im_back, + value="ImBack Value", + title="ImBack Action", + ), + CardAction( + type=ActionTypes.message_back, + value="MessageBack Value", + title="MessageBack Action", + ), + CardAction( + type=ActionTypes.post_back, + value="PostBack Value", + title="PostBack Action", + ), + ], + ), + content_type="application/vnd.microsoft.card.hero", + ) + ], + ) + activity = ChoiceFactory.hero_card( + TestChoiceFactory.choices_with_actions, "select from:" + ) + assert expected == activity diff --git a/dev/hosting_dialogs/choices/test_choice_factory_options.py b/dev/hosting_dialogs/choices/test_choice_factory_options.py new file mode 100644 index 00000000..2e3563a7 --- /dev/null +++ b/dev/hosting_dialogs/choices/test_choice_factory_options.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.hosting.dialogs.choices import ChoiceFactoryOptions + + +class TestChoiceFactoryOptions: + def test_inline_separator_round_trips(self) -> None: + choice_factor_options = ChoiceFactoryOptions() + expected = ", " + choice_factor_options.inline_separator = expected + assert expected == choice_factor_options.inline_separator + + def test_inline_or_round_trips(self) -> None: + choice_factor_options = ChoiceFactoryOptions() + expected = " or " + choice_factor_options.inline_or = expected + assert expected == choice_factor_options.inline_or + + def test_inline_or_more_round_trips(self) -> None: + choice_factor_options = ChoiceFactoryOptions() + expected = ", or " + choice_factor_options.inline_or_more = expected + assert expected == choice_factor_options.inline_or_more + + def test_include_numbers_round_trips(self) -> None: + choice_factor_options = ChoiceFactoryOptions() + expected = True + choice_factor_options.include_numbers = expected + assert expected == choice_factor_options.include_numbers diff --git a/dev/hosting_dialogs/choices/test_choice_recognizers.py b/dev/hosting_dialogs/choices/test_choice_recognizers.py new file mode 100644 index 00000000..edc63cd2 --- /dev/null +++ b/dev/hosting_dialogs/choices/test_choice_recognizers.py @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +import pytest + +from microsoft_agents.hosting.dialogs.choices import ( + ChoiceRecognizers, + Find, + FindValuesOptions, + SortedValue, +) + + +def assert_result(result, start, end, text): + assert ( + result.start == start + ), f"Invalid ModelResult.start of '{result.start}' for '{text}' result." + assert ( + result.end == end + ), f"Invalid ModelResult.end of '{result.end}' for '{text}' result." + assert ( + result.text == text + ), f"Invalid ModelResult.text of '{result.text}' for '{text}' result." + + +def assert_value(result, value, index, score): + assert ( + result.type_name == "value" + ), f"Invalid ModelResult.type_name of '{result.type_name}' for '{value}' value." + assert result.resolution, f"Missing ModelResult.resolution for '{value}' value." + resolution = result.resolution + assert ( + resolution.value == value + ), f"Invalid resolution.value of '{resolution.value}' for '{value}' value." + assert ( + resolution.index == index + ), f"Invalid resolution.index of '{resolution.index}' for '{value}' value." + assert ( + resolution.score == score + ), f"Invalid resolution.score of '{resolution.score}' for '{value}' value." + + +def assert_choice(result, value, index, score, synonym=None): + assert ( + result.type_name == "choice" + ), f"Invalid ModelResult.type_name of '{result.type_name}' for '{value}' choice." + assert result.resolution, f"Missing ModelResult.resolution for '{value}' choice." + resolution = result.resolution + assert ( + resolution.value == value + ), f"Invalid resolution.value of '{resolution.value}' for '{value}' choice." + assert ( + resolution.index == index + ), f"Invalid resolution.index of '{resolution.index}' for '{value}' choice." + assert ( + resolution.score == score + ), f"Invalid resolution.score of '{resolution.score}' for '{value}' choice." + if synonym: + assert ( + resolution.synonym == synonym + ), f"Invalid resolution.synonym of '{resolution.synonym}' for '{value}' choice." + + +_color_choices: List[str] = ["red", "green", "blue"] +_overlapping_choices: List[str] = ["bread", "bread pudding", "pudding"] + +_color_values: List[SortedValue] = [ + SortedValue(value="red", index=0), + SortedValue(value="green", index=1), + SortedValue(value="blue", index=2), +] + +_overlapping_values: List[SortedValue] = [ + SortedValue(value="bread", index=0), + SortedValue(value="bread pudding", index=1), + SortedValue(value="pudding", index=2), +] + +_similar_values: List[SortedValue] = [ + SortedValue(value="option A", index=0), + SortedValue(value="option B", index=1), + SortedValue(value="option C", index=2), +] + + +class TestChoiceRecognizers: + # Find.find_values + + def test_should_find_a_simple_value_in_a_single_word_utterance(self): + found = Find.find_values("red", _color_values) + assert len(found) == 1, f"Invalid token count of '{len(found)}' returned." + assert_result(found[0], 0, 2, "red") + assert_value(found[0], "red", 0, 1.0) + + def test_should_find_a_simple_value_in_an_utterance(self): + found = Find.find_values("the red one please.", _color_values) + assert len(found) == 1, f"Invalid token count of '{len(found)}' returned." + assert_result(found[0], 4, 6, "red") + assert_value(found[0], "red", 0, 1.0) + + def test_should_find_multiple_values_within_an_utterance(self): + found = Find.find_values("the red and blue ones please.", _color_values) + assert len(found) == 2, f"Invalid token count of '{len(found)}' returned." + assert_result(found[0], 4, 6, "red") + assert_value(found[0], "red", 0, 1.0) + assert_value(found[1], "blue", 2, 1.0) + + def test_should_find_multiple_values_that_overlap(self): + found = Find.find_values( + "the bread pudding and bread please.", _overlapping_values + ) + assert len(found) == 2, f"Invalid token count of '{len(found)}' returned." + assert_result(found[0], 4, 16, "bread pudding") + assert_value(found[0], "bread pudding", 1, 1.0) + assert_value(found[1], "bread", 0, 1.0) + + def test_should_correctly_disambiguate_between_similar_values(self): + found = Find.find_values( + "option B", _similar_values, FindValuesOptions(allow_partial_matches=True) + ) + assert len(found) == 1, f"Invalid token count of '{len(found)}' returned." + assert_value(found[0], "option B", 1, 1.0) + + def test_should_find_a_single_choice_in_an_utterance(self): + found = Find.find_choices("the red one please.", _color_choices) + assert len(found) == 1, f"Invalid token count of '{len(found)}' returned." + assert_result(found[0], 4, 6, "red") + assert_choice(found[0], "red", 0, 1.0, "red") + + def test_should_find_multiple_choices_within_an_utterance(self): + found = Find.find_choices("the red and blue ones please.", _color_choices) + assert len(found) == 2, f"Invalid token count of '{len(found)}' returned." + assert_result(found[0], 4, 6, "red") + assert_choice(found[0], "red", 0, 1.0) + assert_choice(found[1], "blue", 2, 1.0) + + def test_should_find_multiple_choices_that_overlap(self): + found = Find.find_choices( + "the bread pudding and bread please.", _overlapping_choices + ) + assert len(found) == 2, f"Invalid token count of '{len(found)}' returned." + assert_result(found[0], 4, 16, "bread pudding") + assert_choice(found[0], "bread pudding", 1, 1.0) + assert_choice(found[1], "bread", 0, 1.0) + + def test_should_accept_null_utterance_in_find_choices(self): + found = Find.find_choices(None, _color_choices) + assert not found + + # ChoiceRecognizers.recognize_choices + + def test_should_find_a_choice_in_an_utterance_by_name(self): + found = ChoiceRecognizers.recognize_choices( + "the red one please.", _color_choices + ) + assert len(found) == 1 + assert_result(found[0], 4, 6, "red") + assert_choice(found[0], "red", 0, 1.0, "red") + + def test_should_find_a_choice_in_an_utterance_by_ordinal_position(self): + found = ChoiceRecognizers.recognize_choices( + "the first one please.", _color_choices + ) + assert len(found) == 1 + assert_result(found[0], 4, 8, "first") + assert_choice(found[0], "red", 0, 1.0) + + def test_should_find_multiple_choices_in_an_utterance_by_ordinal_position(self): + found = ChoiceRecognizers.recognize_choices( + "the first and third one please", _color_choices + ) + assert len(found) == 2 + assert_choice(found[0], "red", 0, 1.0) + assert_choice(found[1], "blue", 2, 1.0) + + def test_should_find_a_choice_in_an_utterance_by_numerical_index_digit(self): + found = ChoiceRecognizers.recognize_choices("1", _color_choices) + assert len(found) == 1 + assert_result(found[0], 0, 0, "1") + assert_choice(found[0], "red", 0, 1.0) + + def test_should_find_a_choice_in_an_utterance_by_numerical_index_text(self): + found = ChoiceRecognizers.recognize_choices("one", _color_choices) + assert len(found) == 1 + assert_result(found[0], 0, 2, "one") + assert_choice(found[0], "red", 0, 1.0) + + def test_should_find_multiple_choices_in_an_utterance_by_numerical_index(self): + found = ChoiceRecognizers.recognize_choices("option one and 3.", _color_choices) + assert len(found) == 2 + assert_choice(found[0], "red", 0, 1.0) + assert_choice(found[1], "blue", 2, 1.0) + + def test_should_accept_null_utterance_in_recognize_choices(self): + found = ChoiceRecognizers.recognize_choices(None, _color_choices) + assert not found diff --git a/dev/hosting_dialogs/choices/test_choice_tokenizer.py b/dev/hosting_dialogs/choices/test_choice_tokenizer.py new file mode 100644 index 00000000..16d9d38d --- /dev/null +++ b/dev/hosting_dialogs/choices/test_choice_tokenizer.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.hosting.dialogs.choices import Tokenizer + + +def _assert_token(token, start, end, text, normalized=None): + assert ( + token.start == start + ), f"Invalid token.start of '{token.start}' for '{text}' token." + assert token.end == end, f"Invalid token.end of '{token.end}' for '{text}' token." + assert ( + token.text == text + ), f"Invalid token.text of '{token.text}' for '{text}' token." + assert token.normalized == ( + normalized or text.lower() + ), f"Invalid token.normalized of '{token.normalized}' for '{text}' token." + + +class TestChoiceTokenizer: + def test_should_break_on_spaces(self): + tokens = Tokenizer.default_tokenizer("how now brown cow") + assert len(tokens) == 4 + _assert_token(tokens[0], 0, 2, "how") + _assert_token(tokens[1], 4, 6, "now") + _assert_token(tokens[2], 8, 12, "brown") + _assert_token(tokens[3], 14, 16, "cow") + + def test_should_break_on_punctuation(self): + tokens = Tokenizer.default_tokenizer("how-now.brown:cow?") + assert len(tokens) == 4 + _assert_token(tokens[0], 0, 2, "how") + _assert_token(tokens[1], 4, 6, "now") + _assert_token(tokens[2], 8, 12, "brown") + _assert_token(tokens[3], 14, 16, "cow") + + def test_should_tokenize_single_character_tokens(self): + tokens = Tokenizer.default_tokenizer("a b c d") + assert len(tokens) == 4 + _assert_token(tokens[0], 0, 0, "a") + _assert_token(tokens[1], 2, 2, "b") + _assert_token(tokens[2], 4, 4, "c") + _assert_token(tokens[3], 6, 6, "d") + + def test_should_return_a_single_token(self): + tokens = Tokenizer.default_tokenizer("food") + assert len(tokens) == 1 + _assert_token(tokens[0], 0, 3, "food") + + def test_should_return_no_tokens(self): + tokens = Tokenizer.default_tokenizer(".?-()") + assert not tokens + + def test_should_return_a_the_normalized_and_original_text_for_a_token(self): + tokens = Tokenizer.default_tokenizer("fOoD") + assert len(tokens) == 1 + _assert_token(tokens[0], 0, 3, "fOoD", "food") + + def test_should_break_on_emojis(self): + tokens = Tokenizer.default_tokenizer("food 💥👍😀") + assert len(tokens) == 4 + _assert_token(tokens[0], 0, 3, "food") + _assert_token(tokens[1], 5, 5, "💥") + _assert_token(tokens[2], 6, 6, "👍") + _assert_token(tokens[3], 7, 7, "😀") diff --git a/dev/hosting_dialogs/helpers.py b/dev/hosting_dialogs/helpers.py new file mode 100644 index 00000000..975ff507 --- /dev/null +++ b/dev/hosting_dialogs/helpers.py @@ -0,0 +1,312 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +Test helpers for dialog tests. Provides a TestAdapter/TestFlow compatibility +layer that wraps MockTestingAdapter with the old botbuilder-style chained +send/assert_reply API. +""" + +from typing import Callable, Union, Awaitable + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + TokenResponse, + SignInResource, + TokenOrSignInResourceResponse, +) +from microsoft_agents.hosting.core import ChannelAdapter, TurnContext +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from tests._common.testing_objects import MockTestingAdapter + +AgentCallbackHandler = Callable[["TurnContext"], Awaitable[None]] + + +class _MockUserToken: + """Mock user_token API for dialog tests.""" + + def __init__(self, store: dict, exchange_store: dict, throw_on_exchange: dict): + self._store = store + self._exchange_store = exchange_store + self._throw_on_exchange = throw_on_exchange + + @staticmethod + def _key(connection_name, channel_id, user_id): + return f"{connection_name}:{channel_id}:{user_id}" + + @staticmethod + def _exchange_key(connection_name, channel_id, user_id, item): + return f"{connection_name}:{channel_id}:{user_id}:{item}" + + async def get_token(self, user_id, connection_name, channel_id, code=None): + key = self._key(connection_name, channel_id, user_id) + entry = self._store.get(key) + if entry: + token, stored_code = entry + if stored_code is None or (code is not None and code == stored_code): + return TokenResponse( + connection_name=connection_name, + token=token, + channel_id=channel_id, + ) + return None + + async def sign_out(self, user_id, connection_name, channel_id): + key = self._key(connection_name, channel_id, user_id) + self._store.pop(key, None) + + async def exchange_token(self, user_id, connection_name, channel_id, body=None): + token = (body or {}).get("token") or (body or {}).get("uri") + key = self._exchange_key(connection_name, channel_id, user_id, token or "") + if key in self._throw_on_exchange: + raise Exception("Token exchange not allowed for this item.") + result = self._exchange_store.get(key) + if result: + return TokenResponse( + connection_name=connection_name, + token=result, + channel_id=channel_id, + ) + return None + + async def _get_token_or_sign_in_resource( + self, user_id, connection_name, channel_id, state, *_ + ): + key = self._key(connection_name, channel_id, user_id) + entry = self._store.get(key) + if entry: + token, stored_code = entry + if stored_code is None: + return TokenOrSignInResourceResponse( + token_response=TokenResponse( + connection_name=connection_name, + token=token, + channel_id=channel_id, + ) + ) + return TokenOrSignInResourceResponse( + sign_in_resource=SignInResource( + sign_in_link=f"https://token.botframework.com/oauthcards?state={state or ''}" + ) + ) + + +class _MockAgentSignIn: + """Mock agent_sign_in API for dialog tests.""" + + async def get_sign_in_resource(self, state=None): + return SignInResource( + sign_in_link=f"https://token.botframework.com/oauthcards?state={state or ''}", + ) + + +class DialogUserTokenClient: + """ + A lightweight UserTokenClient mock for dialog tests. + Implements the user_token and agent_sign_in APIs used by _UserTokenAccess. + """ + + def __init__(self): + self._store = {} + self._exchange_store = {} + self._throw_on_exchange = {} + self.user_token = _MockUserToken( + self._store, self._exchange_store, self._throw_on_exchange + ) + self.agent_sign_in = _MockAgentSignIn() + + def add_user_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + token: str, + magic_code: str = None, + ): + key = f"{connection_name}:{channel_id}:{user_id}" + self._store[key] = (token, magic_code) + + def add_exchangeable_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + token: str, + ): + key = f"{connection_name}:{channel_id}:{user_id}:{exchangeable_item}" + self._exchange_store[key] = token + + def throw_on_exchange_request( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + ): + key = f"{connection_name}:{channel_id}:{user_id}:{exchangeable_item}" + self._throw_on_exchange[key] = True + + +class TestFlow: + """ + Provides a fluent interface for sending messages and asserting replies + in dialog tests. + """ + + def __init__(self, adapter: "DialogTestAdapter", callback: AgentCallbackHandler): + self._adapter = adapter + self._callback = callback + + async def send(self, msg: Union[str, Activity]) -> "TestFlow": + """Send a message or activity to the agent.""" + import asyncio as _asyncio + + # Small delay to ensure time-based timeouts (e.g. OAuthPrompt timeout=1ms) can fire. + await _asyncio.sleep(0.002) + self._adapter.active_queue.clear() + if isinstance(msg, str): + await self._adapter.send_text_to_agent_async(msg, self._callback) + else: + await self._adapter.process_activity_async(msg, self._callback) + return TestFlow(self._adapter, self._callback) + + async def assert_reply( + self, expected: Union[str, Activity, Callable, None] = None + ) -> "TestFlow": + """Assert the next reply matches the expected text, activity, or callable inspector.""" + import inspect + + reply = self._adapter.get_next_reply() + if expected is not None: + if callable(expected) and not isinstance(expected, (str, Activity)): + # Inspector callable: (activity, description) -> bool (sync or async) + result = expected(reply, None) + if inspect.isawaitable(result): + result = await result + assert ( + result is not False + ), f"Inspector returned False for reply: {reply}" + elif isinstance(expected, str): + assert reply is not None, f"Expected reply '{expected}' but got None" + assert ( + reply.text == expected + ), f"Expected reply text '{expected}' but got '{reply.text}'" + elif isinstance(expected, Activity): + assert reply is not None, "Expected a reply but got None" + if expected.text: + assert ( + reply.text == expected.text + ), f"Expected reply text '{expected.text}' but got '{reply.text}'" + if expected.type: + assert ( + reply.type == expected.type + ), f"Expected activity type '{expected.type}' but got '{reply.type}'" + return TestFlow(self._adapter, self._callback) + + +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. + """ + + def __init__(self, callback: AgentCallbackHandler = None, **kwargs): + super().__init__(**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 + self.claims_identity = ClaimsIdentity({"aud": "test-app-id"}, True) + + def add_user_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + token: str, + magic_code: str = None, + ): + """Store a user token for retrieval by OAuthPrompt.""" + self._dialog_token_client.add_user_token( + connection_name, channel_id, user_id, token, magic_code + ) + # Also update the base class token client for compatibility + super().add_user_token(connection_name, channel_id, user_id, token, magic_code) + + def add_exchangeable_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + token: str, + ): + self._dialog_token_client.add_exchangeable_token( + connection_name, channel_id, user_id, exchangeable_item, token + ) + super().add_exchangeable_token( + connection_name, channel_id, user_id, exchangeable_item, token + ) + + def throw_on_exchange_request( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + ): + self._dialog_token_client.throw_on_exchange_request( + connection_name, channel_id, user_id, exchangeable_item + ) + super().throw_on_exchange_request( + connection_name, channel_id, user_id, exchangeable_item + ) + + def create_turn_context( + self, activity: Activity, identity: ClaimsIdentity = None + ) -> TurnContext: + """ + Creates a turn context with the dialog token client in turn_state + 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 + ) + return turn_context + + def make_activity(self, text: str = None) -> Activity: + """ + Creates a message activity without setting locale, so that prompts' + default_locale is used when no locale is present in the activity. + This matches botbuilder TestAdapter behavior. + """ + from microsoft_agents.activity import ActivityTypes + + activity = Activity( + type=ActivityTypes.message, + from_property=self.conversation.user, + recipient=self.conversation.agent, + conversation=self.conversation.conversation, + service_url=self.conversation.service_url, + id=str(self._next_id), + text=text, + ) + self._next_id += 1 + return activity + + async def send(self, msg: Union[str, Activity]) -> TestFlow: + """Send a message or activity and return a TestFlow for assertions.""" + self.active_queue.clear() + if isinstance(msg, str): + await self.send_text_to_agent_async(msg, self._callback) + else: + await self.process_activity_async(msg, self._callback) + return TestFlow(self, self._callback) diff --git a/dev/hosting_dialogs/memory/__init__.py b/dev/hosting_dialogs/memory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/hosting_dialogs/memory/scopes/__init__.py b/dev/hosting_dialogs/memory/scopes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/hosting_dialogs/memory/scopes/test_memory_scopes.py b/dev/hosting_dialogs/memory/scopes/test_memory_scopes.py new file mode 100644 index 00000000..5eca2f3f --- /dev/null +++ b/dev/hosting_dialogs/memory/scopes/test_memory_scopes.py @@ -0,0 +1,684 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# pylint: disable=pointless-string-statement + +from collections import namedtuple + +import pytest + +from microsoft_agents.hosting.core import ( + ConversationState, + MemoryStorage, + TurnContext, + UserState, +) +from microsoft_agents.hosting.dialogs import ( + Dialog, + DialogContext, + DialogContainer, + DialogInstance, + DialogSet, + DialogState, + ObjectPath, +) +from microsoft_agents.hosting.dialogs.memory.scopes import ( + ClassMemoryScope, + ConversationMemoryScope, + DialogContextMemoryScope, + DialogMemoryScope, + UserMemoryScope, + SettingsMemoryScope, + ThisMemoryScope, + TurnMemoryScope, +) +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + Channels, +) +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class _TestDialog(Dialog): + def __init__(self, id: str, message: str): + super().__init__(id) + + def aux_try_get_value(state): # pylint: disable=unused-argument + return "resolved value" + + ExpressionObject = namedtuple("ExpressionObject", "try_get_value") + self.message = message + self.expression = ExpressionObject(aux_try_get_value) + + async def begin_dialog(self, dialog_context: DialogContext, options: object = None): + dialog_context.active_dialog.state["is_dialog"] = True + await dialog_context.context.send_activity(self.message) + return Dialog.end_of_turn + + +class _TestContainer(DialogContainer): + def __init__(self, id: str, child: Dialog = None): + super().__init__(id) + self.child_id = None + if child: + self.dialogs.add(child) + self.child_id = child.id + + async def begin_dialog(self, dialog_context: DialogContext, options: object = None): + state = dialog_context.active_dialog.state + state["is_container"] = True + if self.child_id: + state["dialog"] = DialogState() + child_dc = self.create_child_context(dialog_context) + return await child_dc.begin_dialog(self.child_id, options) + + return Dialog.end_of_turn + + async def continue_dialog(self, dialog_context: DialogContext): + child_dc = self.create_child_context(dialog_context) + if child_dc: + return await child_dc.continue_dialog() + + return Dialog.end_of_turn + + def create_child_context(self, dialog_context: DialogContext): + state = dialog_context.active_dialog.state + if state["dialog"] is not None: + child_dc = DialogContext( + self.dialogs, dialog_context.context, state["dialog"] + ) + child_dc.parent = dialog_context + return child_dc + + return None + + +_begin_message = Activity( + text="begin", + type=ActivityTypes.message, + channel_id=Channels.test, + service_url="https://test.com", + from_property=ChannelAccount(id="user"), + recipient=ChannelAccount(id="bot"), + conversation=ConversationAccount(id="convo1"), +) + + +class TestMemoryScopes: + @pytest.mark.asyncio + async def test_class_memory_scope_should_find_registered_dialog(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = _TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + await dialog_state.set( + context, DialogState(dialog_stack=[DialogInstance(id="test", state={})]) + ) + + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ClassMemoryScope() + memory = scope.get_memory(dialog_context) + assert memory, "memory not returned" + assert memory.message == "test message" + assert memory.expression == "resolved value" + + @pytest.mark.asyncio + async def test_class_memory_scope_should_not_allow_set_memory_call(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = _TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + await dialog_state.set( + context, DialogState(dialog_stack=[DialogInstance(id="test", state={})]) + ) + + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ClassMemoryScope() + with pytest.raises(Exception) as exc_info: + scope.set_memory(dialog_context, {}) + + assert "not supported" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_class_memory_scope_should_not_allow_load_and_save_changes_calls( + self, + ): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = _TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + await dialog_state.set( + context, DialogState(dialog_stack=[DialogInstance(id="test", state={})]) + ) + + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ClassMemoryScope() + await scope.load(dialog_context) + memory = scope.get_memory(dialog_context) + with pytest.raises(AttributeError) as exc_info: + memory.message = "foo" + + assert "can't set attribute" in str(exc_info.value) + await scope.save_changes(dialog_context) + assert dialog.message == "test message" + + def test_class_memory_scope_has_unique_name(self): + """ClassMemoryScope must use 'class', not 'settings', to avoid colliding with SettingsMemoryScope.""" + assert ClassMemoryScope().name == "class" + assert ClassMemoryScope().name != SettingsMemoryScope().name + + @pytest.mark.asyncio + async def test_conversation_memory_scope_should_return_conversation_state(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = _TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + context.turn_state["ConversationState"] = conversation_state + + dialog_context = await dialogs.create_context(context) + + # Initialize conversation state + foo_cls = namedtuple("TestObject", "foo") + conversation_prop = conversation_state.create_property("conversation") + await conversation_prop.set(context, foo_cls(foo="bar")) + await conversation_state.save(context) + + # Run test + scope = ConversationMemoryScope() + memory = scope.get_memory(dialog_context) + assert memory, "memory not returned" + + # TODO: Make get_path_value take conversation.foo + test_obj = ObjectPath.get_path_value(memory, "conversation") + assert test_obj.foo == "bar" + + @pytest.mark.asyncio + async def test_user_memory_scope_should_not_return_state_if_not_loaded(self): + # Initialize user state + storage = MemoryStorage() + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state + foo_cls = namedtuple("TestObject", "foo") + user_prop = user_state.create_property("conversation") + await user_prop.set(context, foo_cls(foo="bar")) + await user_state.save(context) + + # Replace context and user_state with new instances + context = TurnContext(adapter, _begin_message) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state + + # Create a DialogState property, DialogSet and register the dialogs. + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = _TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + dialog_context = await dialogs.create_context(context) + + # Run test + scope = UserMemoryScope() + memory = scope.get_memory(dialog_context) + assert memory is None, "state returned" + + @pytest.mark.asyncio + async def test_user_memory_scope_should_return_state_once_loaded(self): + # Initialize user state + storage = MemoryStorage() + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state + foo_cls = namedtuple("TestObject", "foo") + user_prop = user_state.create_property("conversation") + await user_prop.set(context, foo_cls(foo="bar")) + await user_state.save(context) + + # Replace context and conversation_state with instances + context = TurnContext(adapter, _begin_message) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state + + # Create a DialogState property, DialogSet and register the dialogs. + conversation_state = ConversationState(storage) + context.turn_state["ConversationState"] = conversation_state + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = _TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + dialog_context = await dialogs.create_context(context) + + # Run test + scope = UserMemoryScope() + memory = scope.get_memory(dialog_context) + assert memory is None, "state returned" + + await scope.load(dialog_context) + memory = scope.get_memory(dialog_context) + assert memory is not None, "state not returned" + + # TODO: Make get_path_value take conversation.foo + test_obj = ObjectPath.get_path_value(memory, "conversation") + assert test_obj.foo == "bar" + + @pytest.mark.asyncio + async def test_dialog_memory_scope_should_return_containers_state(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = _TestContainer("container") + dialogs.add(container) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.begin_dialog("container") + memory = scope.get_memory(dialog_context) + assert memory is not None, "state not returned" + assert memory["is_container"] + + @pytest.mark.asyncio + async def test_dialog_memory_scope_should_return_parent_containers_state_for_children( + self, + ): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = _TestContainer("container", _TestDialog("child", "test message")) + dialogs.add(container) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.begin_dialog("container") + child_dc = dialog_context.child + assert child_dc is not None, "No child DC" + memory = scope.get_memory(child_dc) + assert memory is not None, "state not returned" + assert memory["is_container"] + + @pytest.mark.asyncio + async def test_dialog_memory_scope_should_return_childs_state_when_no_parent(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = _TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.begin_dialog("test") + memory = scope.get_memory(dialog_context) + assert memory is not None, "state not returned" + assert memory["is_dialog"] + + @pytest.mark.asyncio + async def test_dialog_memory_scope_should_overwrite_parents_memory(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = _TestContainer("container", _TestDialog("child", "test message")) + dialogs.add(container) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.begin_dialog("container") + child_dc = dialog_context.child + assert child_dc is not None, "No child DC" + + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(child_dc, foo_cls("bar")) + memory = scope.get_memory(child_dc) + assert memory is not None, "state not returned" + assert memory.foo == "bar" + + @pytest.mark.asyncio + async def test_dialog_memory_scope_should_overwrite_active_dialogs_memory(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = _TestContainer("container") + dialogs.add(container) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.begin_dialog("container") + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) + memory = scope.get_memory(dialog_context) + assert memory is not None, "state not returned" + assert memory.foo == "bar" + + @pytest.mark.asyncio + async def test_dialog_memory_scope_should_raise_error_if_set_memory_called_without_memory( + self, + ): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = _TestContainer("container") + dialogs.add(container) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + with pytest.raises(Exception): + scope = DialogMemoryScope() + await dialog_context.begin_dialog("container") + scope.set_memory(dialog_context, None) + + @pytest.mark.asyncio + async def test_dialog_memory_scope_accepts_empty_dict(self): + """set_memory() must accept an empty dict — empty dialog state is valid.""" + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialogs.add(_TestContainer("container")) + + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + await dialog_context.begin_dialog("container") + + scope = DialogMemoryScope() + scope.set_memory(dialog_context, {}) + assert scope.get_memory(dialog_context) == {} + + @pytest.mark.skip(reason="Requires test_settings module not available") + @pytest.mark.asyncio + async def test_settings_memory_scope_should_return_content_of_settings(self): + # pylint: disable=import-outside-toplevel + from tests.hosting_dialogs.memory.scopes.test_settings import DefaultConfig + + # Create a DialogState property, DialogSet and register the dialogs. + conversation_state = ConversationState(MemoryStorage()) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state).add(_TestDialog("test", "test message")) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + settings = DefaultConfig() + dialog_context.context.turn_state["settings"] = settings + + # Run test + scope = SettingsMemoryScope() + memory = scope.get_memory(dialog_context) + assert memory is not None + assert memory.STRING == "test" + assert memory.INT == 3 + assert memory.LIST[0] == "zero" + assert memory.LIST[1] == "one" + assert memory.LIST[2] == "two" + assert memory.LIST[3] == "three" + + @pytest.mark.asyncio + async def test_this_memory_scope_should_return_active_dialogs_state(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = _TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ThisMemoryScope() + await dialog_context.begin_dialog("test") + memory = scope.get_memory(dialog_context) + assert memory is not None, "state not returned" + assert memory["is_dialog"] + + @pytest.mark.asyncio + async def test_this_memory_scope_should_overwrite_active_dialogs_memory(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = _TestContainer("container") + dialogs.add(container) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ThisMemoryScope() + await dialog_context.begin_dialog("container") + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) + memory = scope.get_memory(dialog_context) + assert memory is not None, "state not returned" + assert memory.foo == "bar" + + @pytest.mark.asyncio + async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_memory( + self, + ): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = _TestContainer("container") + dialogs.add(container) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + with pytest.raises(Exception): + scope = ThisMemoryScope() + await dialog_context.begin_dialog("container") + scope.set_memory(dialog_context, None) + + @pytest.mark.asyncio + async def test_this_memory_scope_accepts_empty_dict(self): + """set_memory() must accept an empty dict — empty dialog state is valid.""" + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialogs.add(_TestContainer("container")) + + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + await dialog_context.begin_dialog("container") + + scope = ThisMemoryScope() + scope.set_memory(dialog_context, {}) + assert scope.get_memory(dialog_context) == {} + + @pytest.mark.asyncio + async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_active_dialog( + self, + ): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = _TestContainer("container") + dialogs.add(container) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + with pytest.raises(Exception): + scope = ThisMemoryScope() + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) + + @pytest.mark.asyncio + async def test_turn_memory_scope_should_persist_changes_to_turn_state(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = _TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = TurnMemoryScope() + memory = scope.get_memory(dialog_context) + assert memory is not None, "state not returned" + memory["foo"] = "bar" + memory = scope.get_memory(dialog_context) + assert memory["foo"] == "bar" + + @pytest.mark.asyncio + async def test_turn_memory_scope_should_overwrite_values_in_turn_state(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = _TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = TurnMemoryScope() + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) + memory = scope.get_memory(dialog_context) + assert memory is not None, "state not returned" + assert memory.foo == "bar" + + @pytest.mark.asyncio + async def test_turn_memory_scope_preserves_empty_dict(self): + """An empty dict stored in turn state must not be replaced with a new CaseInsensitiveDict.""" + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialogs.add(_TestDialog("test", "test message")) + + adapter = DialogTestAdapter() + context = TurnContext(adapter, _begin_message) + dialog_context = await dialogs.create_context(context) + + scope = TurnMemoryScope() + empty = {} + scope.set_memory(dialog_context, empty) + + retrieved = scope.get_memory(dialog_context) + assert ( + retrieved is empty + ), "get_memory() must return the stored empty dict, not a new one" + + def test_dialog_context_memory_scope_has_unique_name(self): + """DialogContextMemoryScope must not share its scope name with SettingsMemoryScope.""" + dc_scope = DialogContextMemoryScope() + settings_scope = SettingsMemoryScope() + assert dc_scope.name == "dialogContext" + assert dc_scope.name != settings_scope.name diff --git a/dev/hosting_dialogs/memory/scopes/test_settings.py b/dev/hosting_dialogs/memory/scopes/test_settings.py new file mode 100644 index 00000000..9e21a99a --- /dev/null +++ b/dev/hosting_dialogs/memory/scopes/test_settings.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + + +class DefaultConfig: + """Bot Configuration""" + + STRING = os.environ.get("STRING", "test") + INT = os.environ.get("INT", 3) + LIST = os.environ.get("LIST", ["zero", "one", "two", "three"]) + NOT_TO_BE_OVERRIDDEN = os.environ.get("NOT_TO_BE_OVERRIDDEN", "one") + TO_BE_OVERRIDDEN = os.environ.get("TO_BE_OVERRIDDEN", "one") diff --git a/dev/hosting_dialogs/memory/test_at_path_resolver.py b/dev/hosting_dialogs/memory/test_at_path_resolver.py new file mode 100644 index 00000000..61b307e2 --- /dev/null +++ b/dev/hosting_dialogs/memory/test_at_path_resolver.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.hosting.dialogs.memory.path_resolvers import AtPathResolver + +_PREFIX = "turn.recognized.entities." + + +class TestAtPathResolver: + def setup_method(self): + self.resolver = AtPathResolver() + + def test_simple_entity_no_suffix(self): + """@foo → turn.recognized.entities.foo.first()""" + assert self.resolver.transform_path("@foo") == f"{_PREFIX}foo.first()" + + def test_entity_with_dot_suffix(self): + """@foo.bar → turn.recognized.entities.foo.first().bar""" + assert self.resolver.transform_path("@foo.bar") == f"{_PREFIX}foo.first().bar" + + def test_entity_with_bracket_suffix(self): + """@foo[0] → turn.recognized.entities.foo.first()[0]""" + assert self.resolver.transform_path("@foo[0]") == f"{_PREFIX}foo.first()[0]" + + def test_entity_with_dot_and_bracket_suffix(self): + """@foo.bar[0] — dot comes before bracket, entity name is still just 'foo'""" + assert ( + self.resolver.transform_path("@foo.bar[0]") + == f"{_PREFIX}foo.first().bar[0]" + ) + + def test_non_at_path_is_returned_unchanged(self): + assert self.resolver.transform_path("turn.foo") == "turn.foo" + assert self.resolver.transform_path("user.name") == "user.name" + + def test_empty_path_raises(self): + with pytest.raises(TypeError): + self.resolver.transform_path("") + + def test_at_sign_only_is_returned_unchanged(self): + """A bare '@' has no subsequent path char so the branch is skipped.""" + assert self.resolver.transform_path("@") == "@" diff --git a/dev/hosting_dialogs/test_activity_prompt.py b/dev/hosting_dialogs/test_activity_prompt.py new file mode 100644 index 00000000..705f87c0 --- /dev/null +++ b/dev/hosting_dialogs/test_activity_prompt.py @@ -0,0 +1,286 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.hosting.dialogs.prompts import ( + ActivityPrompt, + PromptOptions, + PromptValidatorContext, +) +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import ( + ConversationState, + MemoryStorage, + TurnContext, + MessageFactory, +) +from tests.hosting_dialogs.helpers import DialogTestAdapter +from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus, DialogReason + + +async def validator(prompt_context: PromptValidatorContext): + assert prompt_context.attempt_count > 0 + + activity = prompt_context.recognized.value + + if activity.type == ActivityTypes.event: + if int(activity.value) == 2: + prompt_context.recognized.value = MessageFactory.text(str(activity.value)) + return True + else: + await prompt_context.context.send_activity( + "Please send an 'event'-type Activity with a value of 2." + ) + + return False + + +class SimpleActivityPrompt(ActivityPrompt): + pass + + +class TestActivityPrompt: + def test_activity_prompt_with_empty_id_should_fail(self): + empty_id = "" + with pytest.raises(TypeError): + SimpleActivityPrompt(empty_id, validator) + + def test_activity_prompt_with_none_id_should_fail(self): + none_id = None + with pytest.raises(TypeError): + SimpleActivityPrompt(none_id, validator) + + def test_activity_prompt_with_none_validator_should_fail(self): + none_validator = None + with pytest.raises(TypeError): + SimpleActivityPrompt("EventActivityPrompt", none_validator) + + @pytest.mark.asyncio + async def test_basic_activity_prompt(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator)) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please send an event." + ) + ) + await dialog_context.prompt("EventActivityPrompt", options) + elif results.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + event_activity = Activity(type=ActivityTypes.event, value=2) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("please send an event.") + step3 = await step2.send(event_activity) + await step3.assert_reply("2") + + @pytest.mark.asyncio + async def test_retry_activity_prompt(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator)) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please send an event." + ) + ) + await dialog_context.prompt("EventActivityPrompt", options) + elif results.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + event_activity = Activity(type=ActivityTypes.event, value=2) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("please send an event.") + step3 = await step2.send("hello again") + step4 = await step3.assert_reply( + "Please send an 'event'-type Activity with a value of 2." + ) + step5 = await step4.send(event_activity) + await step5.assert_reply("2") + + @pytest.mark.asyncio + async def test_activity_prompt_should_return_dialog_end_if_validation_failed(self): + async def aux_validator(prompt_context: PromptValidatorContext): + assert prompt_context, "Validator missing prompt_context" + return False + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(SimpleActivityPrompt("EventActivityPrompt", aux_validator)) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please send an event." + ), + retry_prompt=Activity( + type=ActivityTypes.message, text="event not received." + ), + ) + await dialog_context.prompt("EventActivityPrompt", options) + elif results.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("please send an event.") + step3 = await step2.send("test") + await step3.assert_reply("event not received.") + + @pytest.mark.asyncio + async def test_activity_prompt_resume_dialog_should_return_dialog_end(self): + async def aux_validator(prompt_context: PromptValidatorContext): + assert prompt_context, "Validator missing prompt_context" + return False + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + event_prompt = SimpleActivityPrompt("EventActivityPrompt", aux_validator) + dialogs.add(event_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please send an event." + ) + ) + await dialog_context.prompt("EventActivityPrompt", options) + + second_results = await event_prompt.resume_dialog( + dialog_context, DialogReason.NextCalled + ) + + assert ( + second_results.status == DialogTurnStatus.Waiting + ), "resume_dialog did not returned Dialog.EndOfTurn" + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("please send an event.") + await step2.assert_reply("please send an event.") + + @pytest.mark.asyncio + async def test_activity_prompt_onerror_should_return_dialogcontext(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator)) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please send an event." + ) + ) + + try: + await dialog_context.prompt("EventActivityPrompt", options) + await dialog_context.prompt("Non existent id", options) + except Exception as err: + assert ( + err.data["DialogContext"] + is not None # pylint: disable=no-member + ) + assert ( + err.data["DialogContext"][ # pylint: disable=no-member + "active_dialog" + ] + == "EventActivityPrompt" + ) + else: + raise Exception("Should have thrown an error.") + + elif results.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + await adapter.send("hello") + + @pytest.mark.asyncio + async def test_activity_replace_dialog_onerror_should_return_dialogcontext(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator)) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please send an event." + ) + ) + + try: + await dialog_context.prompt("EventActivityPrompt", options) + await dialog_context.replace_dialog("Non existent id", options) + except Exception as err: + assert ( + err.data["DialogContext"] + is not None # pylint: disable=no-member + ) + else: + raise Exception("Should have thrown an error.") + + elif results.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + await adapter.send("hello") diff --git a/dev/hosting_dialogs/test_attachment_prompt.py b/dev/hosting_dialogs/test_attachment_prompt.py new file mode 100644 index 00000000..6d1e3501 --- /dev/null +++ b/dev/hosting_dialogs/test_attachment_prompt.py @@ -0,0 +1,287 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import copy +import pytest +from microsoft_agents.hosting.dialogs.prompts import ( + AttachmentPrompt, + PromptOptions, + PromptValidatorContext, +) +from microsoft_agents.activity import Activity, ActivityTypes, Attachment, InputHints +from microsoft_agents.hosting.core import ( + TurnContext, + ConversationState, + MemoryStorage, + MessageFactory, +) +from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class TestAttachmentPrompt: + def test_attachment_prompt_with_empty_id_should_fail(self): + with pytest.raises(TypeError): + AttachmentPrompt("") + + def test_attachment_prompt_with_none_id_should_fail(self): + with pytest.raises(TypeError): + AttachmentPrompt(None) + + @pytest.mark.asyncio + async def test_basic_attachment_prompt(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(AttachmentPrompt("AttachmentPrompt")) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please add an attachment." + ) + ) + await dialog_context.prompt("AttachmentPrompt", options) + elif results.status == DialogTurnStatus.Complete: + attachment = results.result[0] + content = MessageFactory.text(attachment.content) + await turn_context.send_activity(content) + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + attachment = Attachment(content="some content", content_type="text/plain") + attachment_activity = Activity( + type=ActivityTypes.message, attachments=[attachment] + ) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("please add an attachment.") + step3 = await step2.send(attachment_activity) + await step3.assert_reply("some content") + + @pytest.mark.asyncio + async def test_attachment_prompt_with_input_hint(self): + prompt_activity = Activity( + type=ActivityTypes.message, + text="please add an attachment.", + input_hint=InputHints.accepting_input, + ) + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(AttachmentPrompt("AttachmentPrompt")) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions(prompt=copy.copy(prompt_activity)) + await dialog_context.prompt("AttachmentPrompt", options) + elif results.status == DialogTurnStatus.Complete: + attachment = results.result[0] + content = MessageFactory.text(attachment.content) + await turn_context.send_activity(content) + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + step1 = await adapter.send("hello") + await step1.assert_reply(prompt_activity) + + @pytest.mark.asyncio + async def test_attachment_prompt_with_validator(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + + async def aux_validator(prompt_context: PromptValidatorContext): + assert prompt_context, "Validator missing prompt_context" + return prompt_context.recognized.succeeded + + dialogs.add(AttachmentPrompt("AttachmentPrompt", aux_validator)) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please add an attachment." + ) + ) + await dialog_context.prompt("AttachmentPrompt", options) + elif results.status == DialogTurnStatus.Complete: + attachment = results.result[0] + content = MessageFactory.text(attachment.content) + await turn_context.send_activity(content) + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + attachment = Attachment(content="some content", content_type="text/plain") + attachment_activity = Activity( + type=ActivityTypes.message, attachments=[attachment] + ) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("please add an attachment.") + step3 = await step2.send(attachment_activity) + await step3.assert_reply("some content") + + @pytest.mark.asyncio + async def test_retry_attachment_prompt(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(AttachmentPrompt("AttachmentPrompt")) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please add an attachment." + ) + ) + await dialog_context.prompt("AttachmentPrompt", options) + elif results.status == DialogTurnStatus.Complete: + attachment = results.result[0] + content = MessageFactory.text(attachment.content) + await turn_context.send_activity(content) + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + attachment = Attachment(content="some content", content_type="text/plain") + attachment_activity = Activity( + type=ActivityTypes.message, attachments=[attachment] + ) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("please add an attachment.") + step3 = await step2.send("hello again") + step4 = await step3.assert_reply("please add an attachment.") + step5 = await step4.send(attachment_activity) + await step5.assert_reply("some content") + + @pytest.mark.asyncio + async def test_attachment_prompt_with_custom_retry(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + + async def aux_validator(prompt_context: PromptValidatorContext): + assert prompt_context, "Validator missing prompt_context" + return prompt_context.recognized.succeeded + + dialogs.add(AttachmentPrompt("AttachmentPrompt", aux_validator)) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please add an attachment." + ), + retry_prompt=Activity( + type=ActivityTypes.message, text="please try again." + ), + ) + await dialog_context.prompt("AttachmentPrompt", options) + elif results.status == DialogTurnStatus.Complete: + attachment = results.result[0] + content = MessageFactory.text(attachment.content) + await turn_context.send_activity(content) + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + attachment = Attachment(content="some content", content_type="text/plain") + attachment_activity = Activity( + type=ActivityTypes.message, attachments=[attachment] + ) + invalid_activity = Activity(type=ActivityTypes.message, text="invalid") + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("please add an attachment.") + step3 = await step2.send(invalid_activity) + step4 = await step3.assert_reply("please try again.") + step5 = await step4.send(attachment_activity) + await step5.assert_reply("some content") + + @pytest.mark.asyncio + async def test_should_send_ignore_retry_prompt_if_validator_replies(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + + async def aux_validator(prompt_context: PromptValidatorContext): + assert prompt_context, "Validator missing prompt_context" + if not prompt_context.recognized.succeeded: + await prompt_context.context.send_activity("Bad input.") + return prompt_context.recognized.succeeded + + dialogs.add(AttachmentPrompt("AttachmentPrompt", aux_validator)) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please add an attachment." + ), + retry_prompt=Activity( + type=ActivityTypes.message, text="please try again." + ), + ) + await dialog_context.prompt("AttachmentPrompt", options) + elif results.status == DialogTurnStatus.Complete: + attachment = results.result[0] + content = MessageFactory.text(attachment.content) + await turn_context.send_activity(content) + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + attachment = Attachment(content="some content", content_type="text/plain") + attachment_activity = Activity( + type=ActivityTypes.message, attachments=[attachment] + ) + invalid_activity = Activity(type=ActivityTypes.message, text="invalid") + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("please add an attachment.") + step3 = await step2.send(invalid_activity) + step4 = await step3.assert_reply("Bad input.") + step5 = await step4.send(attachment_activity) + await step5.assert_reply("some content") + + @pytest.mark.asyncio + async def test_should_not_send_retry_if_not_specified(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(AttachmentPrompt("AttachmentPrompt")) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog("AttachmentPrompt", PromptOptions()) + elif results.status == DialogTurnStatus.Complete: + attachment = results.result[0] + content = MessageFactory.text(attachment.content) + await turn_context.send_activity(content) + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + attachment = Attachment(content="some content", content_type="text/plain") + attachment_activity = Activity( + type=ActivityTypes.message, attachments=[attachment] + ) + + step1 = await adapter.send("hello") + step2 = await step1.send("what?") + step3 = await step2.send(attachment_activity) + await step3.assert_reply("some content") diff --git a/dev/hosting_dialogs/test_choice_prompt.py b/dev/hosting_dialogs/test_choice_prompt.py new file mode 100644 index 00000000..990ab686 --- /dev/null +++ b/dev/hosting_dialogs/test_choice_prompt.py @@ -0,0 +1,991 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +import pytest +from recognizers_text import Culture + +from microsoft_agents.hosting.core import ConversationState, MemoryStorage, TurnContext +from microsoft_agents.hosting.core import CardFactory +from microsoft_agents.hosting.dialogs import ( + DialogSet, + DialogTurnResult, + DialogTurnStatus, + ChoiceRecognizers, + FindChoicesOptions, +) +from microsoft_agents.hosting.dialogs.choices import ( + Choice, + ChoiceFactoryOptions, + ListStyle, +) +from microsoft_agents.hosting.dialogs.prompts import ( + ChoicePrompt, + PromptCultureModel, + PromptOptions, + PromptValidatorContext, +) +from microsoft_agents.activity import Activity, ActivityTypes +from tests.hosting_dialogs.helpers import DialogTestAdapter + +_color_choices: List[Choice] = [ + Choice(value="red"), + Choice(value="green"), + Choice(value="blue"), +] + +_answer_message: Activity = Activity(text="red", type=ActivityTypes.message) +_invalid_message: Activity = Activity(text="purple", type=ActivityTypes.message) + + +class TestChoicePrompt: + def test_choice_prompt_with_empty_id_should_fail(self): + empty_id = "" + + with pytest.raises(TypeError): + ChoicePrompt(empty_id) + + def test_choice_prompt_with_none_id_should_fail(self): + none_id = None + + with pytest.raises(TypeError): + ChoicePrompt(none_id) + + @pytest.mark.asyncio + async def test_should_call_choice_prompt_using_dc_prompt(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + choice_prompt = ChoicePrompt("ChoicePrompt") + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("ChoicePrompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_call_choice_prompt_with_custom_validator(self): + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + return prompt.recognized.succeeded + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt", validator) + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_invalid_message) + step4 = await step3.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step5 = await step4.send(_answer_message) + await step5.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_send_custom_retry_prompt(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + choice_prompt = ChoicePrompt("prompt") + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + retry_prompt=Activity( + type=ActivityTypes.message, + text="Please choose red, blue, or green.", + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_invalid_message) + step4 = await step3.assert_reply( + "Please choose red, blue, or green. (1) red, (2) green, or (3) blue" + ) + step5 = await step4.send(_answer_message) + await step5.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_send_ignore_retry_prompt_if_validator_replies(self): + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt", validator) + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + retry_prompt=Activity( + type=ActivityTypes.message, + text="Please choose red, blue, or green.", + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_invalid_message) + step4 = await step3.assert_reply("Bad input.") + step5 = await step4.send(_answer_message) + await step5.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_use_default_locale_when_rendering_choices(self): + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt( + "prompt", validator, default_locale=Culture.Spanish + ) + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send(Activity(type=ActivityTypes.message, text="Hello")) + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, o (3) blue" + ) + step3 = await step2.send(_invalid_message) + step4 = await step3.assert_reply("Bad input.") + step5 = await step4.send(Activity(type=ActivityTypes.message, text="red")) + await step5.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_use_context_activity_locale_when_rendering_choices(self): + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt", validator) + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send( + Activity(type=ActivityTypes.message, text="Hello", locale=Culture.Spanish) + ) + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, o (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_use_context_activity_locale_over_default_locale_when_rendering_choices( + self, + ): + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt( + "prompt", validator, default_locale=Culture.Spanish + ) + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send( + Activity(type=ActivityTypes.message, text="Hello", locale=Culture.English) + ) + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_default_to_english_locale(self): + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + locales = [None, "", "not-supported"] + + for locale in locales: + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt", validator) + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + # Activity.locale uses NonEmptyString which rejects None/""; use model_construct to bypass + send_activity = Activity.model_construct( + type=ActivityTypes.message, text="Hello", locale=locale or None + ) + step1 = await adapter.send(send_activity) + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_recognize_locale_variations_of_correct_locales(self): + def cap_ending(locale: str) -> str: + return f"{locale.split('-')[0]}-{locale.split('-')[1].upper()}" + + def title_ending(locale: str) -> str: + return locale[:3] + locale[3].upper() + locale[4:] + + def cap_two_letter(locale: str) -> str: + return locale.split("-")[0].upper() + + def lower_two_letter(locale: str) -> str: + return locale.split("-")[0].upper() + + async def exec_test_for_locale(valid_locale: str, locale_variations: List): + # Hold the correct answer from when a valid locale is used + expected_answer = None + + def inspector(activity: Activity, description: str): + nonlocal expected_answer + + assert not description + + if valid_locale == test_locale: + expected_answer = activity.text + else: + # Ensure we're actually testing a variation. + assert activity.locale != valid_locale + + assert activity.text == expected_answer + return True + + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + test_locale = None + for test_locale in locale_variations: + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt", validator) + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, + text="Please choose a color.", + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send( + Activity( + type=ActivityTypes.message, text="Hello", locale=test_locale + ) + ) + await step1.assert_reply(inspector) + + locales = [ + "zh-cn", + "nl-nl", + "en-us", + "fr-fr", + "de-de", + "it-it", + "ja-jp", + "ko-kr", + "pt-br", + "es-es", + "tr-tr", + "de-de", + ] + + locale_tests = [] + for locale in locales: + locale_tests.append( + [ + locale, + cap_ending(locale), + title_ending(locale), + cap_two_letter(locale), + lower_two_letter(locale), + ] + ) + + # Test each valid locale + for locale_test in locale_tests: + await exec_test_for_locale(locale_test[0], locale_test) + + @pytest.mark.asyncio + async def test_should_recognize_and_use_custom_locale_dict(self): + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + culture = PromptCultureModel( + locale="custom-locale", + no_in_language="customNo", + yes_in_language="customYes", + separator="customSeparator", + inline_or="customInlineOr", + inline_or_more="customInlineOrMore", + ) + + custom_dict = { + culture.locale: ChoiceFactoryOptions( + inline_or=culture.inline_or, + inline_or_more=culture.inline_or_more, + inline_separator=culture.separator, + include_numbers=True, + ) + } + + choice_prompt = ChoicePrompt("prompt", validator, choice_defaults=custom_dict) + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send( + Activity(type=ActivityTypes.message, text="Hello", locale=culture.locale) + ) + await step1.assert_reply( + "Please choose a color. (1) redcustomSeparator(2) greencustomInlineOrMore(3) blue" + ) + + @pytest.mark.asyncio + async def test_should_not_render_choices_if_list_style_none_is_specified(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + style=ListStyle.none, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply("Please choose a color.") + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_create_prompt_with_inline_choices_when_specified(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + choice_prompt.style = ListStyle.in_line + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_create_prompt_with_list_choices_when_specified(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + choice_prompt.style = ListStyle.list_style + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color.\n\n 1. red\n 2. green\n 3. blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_create_prompt_with_suggested_action_style_when_specified( + self, + ): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + style=ListStyle.suggested_action, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply("Please choose a color.") + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_create_prompt_with_auto_style_when_specified(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + style=ListStyle.auto, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_recognize_valid_number_choice(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send("1") + await step3.assert_reply("red") + + @pytest.mark.asyncio + async def test_should_display_choices_on_hero_card(self): + size_choices = ["large", "medium", "small"] + + def assert_expected_activity( + activity: Activity, description + ): # pylint: disable=unused-argument + assert len(activity.attachments) == 1 + assert ( + activity.attachments[0].content_type + == CardFactory.content_types.hero_card + ) + assert activity.attachments[0].content.text == "Please choose a size." + return True + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + # Change the ListStyle of the prompt to ListStyle.hero_card. + choice_prompt.style = ListStyle.hero_card + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a size." + ), + choices=size_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply(assert_expected_activity) + step3 = await step2.send("1") + await step3.assert_reply(size_choices[0]) + + @pytest.mark.asyncio + async def test_should_display_choices_on_hero_card_with_additional_attachment(self): + size_choices = ["large", "medium", "small"] + card = CardFactory.adaptive_card( + { + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.2", + "body": [], + } + ) + card_activity = Activity(type=ActivityTypes.message, attachments=[card]) + + def assert_expected_activity( + activity: Activity, description + ): # pylint: disable=unused-argument + assert len(activity.attachments) == 2 + assert ( + activity.attachments[0].content_type + == CardFactory.content_types.adaptive_card + ) + assert ( + activity.attachments[1].content_type + == CardFactory.content_types.hero_card + ) + return True + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + # Change the ListStyle of the prompt to ListStyle.hero_card. + choice_prompt.style = ListStyle.hero_card + dialogs.add(choice_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions(prompt=card_activity, choices=size_choices) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + await step1.assert_reply(assert_expected_activity) + + def test_should_not_find_a_choice_in_an_utterance_by_ordinal(self): + found = ChoiceRecognizers.recognize_choices( + "the first one please", + _color_choices, + FindChoicesOptions(recognize_numbers=False, recognize_ordinals=False), + ) + assert not found + + def test_should_not_find_a_choice_in_an_utterance_by_numerical_index(self): + found = ChoiceRecognizers.recognize_choices( + "one", + _color_choices, + FindChoicesOptions(recognize_numbers=False, recognize_ordinals=False), + ) + assert not found + + @pytest.mark.asyncio + async def test_choice_prompt_with_empty_choices_renders_but_errors_on_response( + self, + ): + """ChoicePrompt with an empty choices list renders the prompt without + error, but raises TypeError when the user responds. + + This is because Find.find_choices() treats an empty list the same as + None and raises TypeError: "Find: choices cannot be None." Always + provide at least one Choice when using ChoicePrompt. + """ + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + dialogs.add(ChoicePrompt("ChoicePrompt")) + + turns = [] + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + try: + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Choose one:"), + choices=[], + ) + await dialog_context.prompt("ChoicePrompt", options) + turns.append("prompted") + else: + turns.append("continued") + except TypeError as exc: + turns.append(f"error:{exc}") + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + # First turn: prompt is sent without error + await adapter.send("hello") + assert turns[-1] == "prompted" + + # Second turn: recognition raises because choices is empty + await adapter.send("red") + assert turns[-1].startswith("error:") diff --git a/dev/hosting_dialogs/test_component_dialog.py b/dev/hosting_dialogs/test_component_dialog.py new file mode 100644 index 00000000..2edc46af --- /dev/null +++ b/dev/hosting_dialogs/test_component_dialog.py @@ -0,0 +1,371 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import ConversationState, MemoryStorage +from microsoft_agents.hosting.dialogs import ( + ComponentDialog, + Dialog, + DialogSet, + DialogTurnResult, + DialogTurnStatus, + WaterfallDialog, + WaterfallStepContext, +) +from microsoft_agents.hosting.dialogs.models.dialog_reason import DialogReason +from microsoft_agents.hosting.dialogs.prompts import NumberPrompt, PromptOptions +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +def _number_prompt_options(text: str) -> PromptOptions: + return PromptOptions(prompt=Activity(type=ActivityTypes.message, text=text)) + + +class TestComponentDialog: + @pytest.mark.asyncio + async def test_begin_dialog_null_dc_raises(self): + dialog = ComponentDialog("dialogId") + with pytest.raises((TypeError, Exception)): + await dialog.begin_dialog(None) + + @pytest.mark.asyncio + async def test_basic_waterfall_with_number_prompt(self): + """A two-step waterfall collects two numbers via NumberPrompt.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + async def step1(step): + return await step.prompt( + "number", _number_prompt_options("Enter a number.") + ) + + async def step2(step): + await step.context.send_activity(f"Thanks for '{int(step.result)}'") + return await step.prompt( + "number", _number_prompt_options("Enter another number.") + ) + + ds.add(WaterfallDialog("test-waterfall", [step1, step2])) + ds.add(NumberPrompt("number", default_locale="en-us")) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("test-waterfall") + elif results.status == DialogTurnStatus.Complete: + await tc.send_activity( + f"Bot received the number '{int(results.result)}'." + ) + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("Enter a number.") + flow = await flow.send("42") + flow = await flow.assert_reply("Thanks for '42'") + flow = await flow.assert_reply("Enter another number.") + flow = await flow.send("64") + await flow.assert_reply("Bot received the number '64'.") + + @pytest.mark.asyncio + async def test_basic_component_dialog(self): + """ComponentDialog encapsulates its own waterfall and prompt.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + class TestComp(ComponentDialog): + def __init__(self): + super().__init__("TestComponentDialog") + + async def step1(step): + return await step.prompt( + "number", _number_prompt_options("Enter a number.") + ) + + async def step2(step): + await step.context.send_activity(f"Thanks for '{int(step.result)}'") + return await step.prompt( + "number", _number_prompt_options("Enter another number.") + ) + + self.add_dialog(WaterfallDialog("test-waterfall", [step1, step2])) + self.add_dialog(NumberPrompt("number", default_locale="en-us")) + + ds.add(TestComp()) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("TestComponentDialog") + elif results.status == DialogTurnStatus.Complete: + await tc.send_activity( + f"Bot received the number '{int(results.result)}'." + ) + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("Enter a number.") + flow = await flow.send("42") + flow = await flow.assert_reply("Thanks for '42'") + flow = await flow.assert_reply("Enter another number.") + flow = await flow.send("64") + await flow.assert_reply("Bot received the number '64'.") + + @pytest.mark.asyncio + async def test_call_dialog_in_parent_component(self): + """A child component can call a dialog registered in its parent.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + child_component = ComponentDialog("childComponent") + + async def child_step1(step): + await step.context.send_activity("Child started.") + return await step.begin_dialog("parentDialog", "test") + + async def child_step2(step): + await step.context.send_activity(f"Child finished. Value: {step.result}") + return await step.end_dialog() + + child_component.add_dialog( + WaterfallDialog("childDialog", [child_step1, child_step2]) + ) + + parent_component = ComponentDialog("parentComponent") + parent_component.add_dialog(child_component) + + async def parent_step(step): + await step.context.send_activity("Parent called.") + return await step.end_dialog(step.options) + + parent_component.add_dialog(WaterfallDialog("parentDialog", [parent_step])) + ds.add(parent_component) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("parentComponent") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("Hi") + flow = await flow.assert_reply("Child started.") + flow = await flow.assert_reply("Parent called.") + await flow.assert_reply("Child finished. Value: test") + + @pytest.mark.asyncio + async def test_call_dialog_defined_in_parent_component(self): + """Child can call parent-registered dialog and receive the return value.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + options = {"value": "test"} + + child_component = ComponentDialog("childComponent") + + async def child_step1(step): + await step.context.send_activity("Child started.") + return await step.begin_dialog("parentDialog", options) + + async def child_step2(step): + assert step.result == "test" + await step.context.send_activity("Child finished.") + return await step.end_dialog() + + child_component.add_dialog( + WaterfallDialog("childDialog", [child_step1, child_step2]) + ) + + parent_component = ComponentDialog("parentComponent") + parent_component.add_dialog(child_component) + + async def parent_step(step): + step_options = step.options + await step.context.send_activity( + f"Parent called with: {step_options['value']}" + ) + return await step.end_dialog(step_options["value"]) + + parent_component.add_dialog(WaterfallDialog("parentDialog", [parent_step])) + ds.add(parent_component) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("parentComponent") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("Hi") + flow = await flow.assert_reply("Child started.") + flow = await flow.assert_reply("Parent called with: test") + await flow.assert_reply("Child finished.") + + @pytest.mark.asyncio + async def test_nested_component_dialog(self): + """Nested ComponentDialogs properly pass control between each other.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + class InnerComp(ComponentDialog): + def __init__(self): + super().__init__("TestComponentDialog") + + async def step1(step): + return await step.prompt( + "number", _number_prompt_options("Enter a number.") + ) + + async def step2(step): + await step.context.send_activity(f"Thanks for '{int(step.result)}'") + return await step.prompt( + "number", _number_prompt_options("Enter another number.") + ) + + self.add_dialog(WaterfallDialog("test-waterfall", [step1, step2])) + self.add_dialog(NumberPrompt("number", default_locale="en-us")) + + class OuterComp(ComponentDialog): + def __init__(self): + super().__init__("TestNestedComponentDialog") + + async def step1(step): + return await step.prompt( + "number", _number_prompt_options("Enter a number.") + ) + + async def step2(step): + await step.context.send_activity(f"Thanks for '{int(step.result)}'") + return await step.prompt( + "number", _number_prompt_options("Enter another number.") + ) + + async def step3(step): + await step.context.send_activity(f"Got '{int(step.result)}'.") + return await step.begin_dialog("TestComponentDialog") + + self.add_dialog( + WaterfallDialog("test-waterfall", [step1, step2, step3]) + ) + self.add_dialog(NumberPrompt("number", default_locale="en-us")) + self.add_dialog(InnerComp()) + + ds.add(OuterComp()) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("TestNestedComponentDialog") + elif results.status == DialogTurnStatus.Complete: + await tc.send_activity( + f"Bot received the number '{int(results.result)}'." + ) + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("Enter a number.") + flow = await flow.send("42") + flow = await flow.assert_reply("Thanks for '42'") + flow = await flow.assert_reply("Enter another number.") + flow = await flow.send("64") + flow = await flow.assert_reply("Got '64'.") + flow = await flow.assert_reply("Enter a number.") + flow = await flow.send("101") + flow = await flow.assert_reply("Thanks for '101'") + flow = await flow.assert_reply("Enter another number.") + flow = await flow.send("5") + await flow.assert_reply("Bot received the number '5'.") + + +class TestComponentDialogOnEndDialogHook: + @pytest.mark.asyncio + async def test_on_end_dialog_called_with_cancel_reason(self): + """on_end_dialog hook receives CancelCalled reason when cancel_all_dialogs() is invoked.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + hook_calls = [] + + class TrackingComp(ComponentDialog): + def __init__(self): + super().__init__("TrackingComp") + + async def waiting_step(step): + return Dialog.end_of_turn + + self.add_dialog(WaterfallDialog("inner-wf", [waiting_step])) + + async def on_end_dialog(self, context, instance, reason): + hook_calls.append(reason) + + ds.add(TrackingComp()) + + turn = [0] + + async def exec(tc): + turn[0] += 1 + dc = await ds.create_context(tc) + if turn[0] == 1: + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("TrackingComp") + else: + # Cancel without continuing so the waterfall doesn't advance + await dc.cancel_all_dialogs() + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + await adapter.send("hi") # starts the dialog, step 0 waits + await adapter.send("cancel") # triggers cancel_all_dialogs directly + + assert DialogReason.CancelCalled in hook_calls + + @pytest.mark.asyncio + async def test_on_end_dialog_called_with_end_reason_on_completion(self): + """on_end_dialog hook receives EndCalled reason when the component finishes normally.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + hook_calls = [] + + class TrackingComp(ComponentDialog): + def __init__(self): + super().__init__("TrackingComp") + + async def ending_step(step): + return await step.end_dialog("done") + + self.add_dialog(WaterfallDialog("inner-wf", [ending_step])) + + async def on_end_dialog(self, context, instance, reason): + hook_calls.append(reason) + + ds.add(TrackingComp()) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("TrackingComp") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + await adapter.send("hi") # starts and immediately completes the component + + assert DialogReason.EndCalled in hook_calls diff --git a/dev/hosting_dialogs/test_confirm_prompt.py b/dev/hosting_dialogs/test_confirm_prompt.py new file mode 100644 index 00000000..b15a2030 --- /dev/null +++ b/dev/hosting_dialogs/test_confirm_prompt.py @@ -0,0 +1,493 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +import pytest + +from microsoft_agents.hosting.core import ( + ConversationState, + MemoryStorage, + TurnContext, + MessageFactory, +) +from microsoft_agents.hosting.dialogs import ( + DialogSet, + DialogTurnResult, + DialogTurnStatus, +) +from microsoft_agents.hosting.dialogs.choices import ( + Choice, + ChoiceFactoryOptions, + ListStyle, +) +from microsoft_agents.hosting.dialogs.prompts import ( + ConfirmPrompt, + PromptCultureModel, + PromptOptions, + PromptValidatorContext, +) +from microsoft_agents.activity import Activity, ActivityTypes +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class TestConfirmPrompt: + def test_confirm_prompt_with_empty_id_should_fail(self): + empty_id = "" + + with pytest.raises(TypeError): + ConfirmPrompt(empty_id) + + def test_confirm_prompt_with_none_id_should_fail(self): + none_id = None + + with pytest.raises(TypeError): + ConfirmPrompt(none_id) + + @pytest.mark.asyncio + async def test_confirm_prompt(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") + dialogs.add(confirm_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Please confirm.") + ) + await dialog_context.prompt("ConfirmPrompt", options) + elif results.status == DialogTurnStatus.Complete: + message_text = "Confirmed" if results.result else "Not confirmed" + await turn_context.send_activity(MessageFactory.text(message_text)) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("Please confirm. (1) Yes or (2) No") + step3 = await step2.send("yes") + await step3.assert_reply("Confirmed") + + @pytest.mark.asyncio + async def test_confirm_prompt_retry(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") + dialogs.add(confirm_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Please confirm."), + retry_prompt=Activity( + type=ActivityTypes.message, + text="Please confirm, say 'yes' or 'no' or something like that.", + ), + ) + await dialog_context.prompt("ConfirmPrompt", options) + elif results.status == DialogTurnStatus.Complete: + message_text = "Confirmed" if results.result else "Not confirmed" + await turn_context.send_activity(MessageFactory.text(message_text)) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("Please confirm. (1) Yes or (2) No") + step3 = await step2.send("lala") + step4 = await step3.assert_reply( + "Please confirm, say 'yes' or 'no' or something like that. (1) Yes or (2) No" + ) + step5 = await step4.send("no") + await step5.assert_reply("Not confirmed") + + @pytest.mark.asyncio + async def test_confirm_prompt_no_options(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") + dialogs.add(confirm_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + await dialog_context.prompt("ConfirmPrompt", PromptOptions()) + elif results.status == DialogTurnStatus.Complete: + message_text = "Confirmed" if results.result else "Not confirmed" + await turn_context.send_activity(MessageFactory.text(message_text)) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply(" (1) Yes or (2) No") + step3 = await step2.send("lala") + step4 = await step3.assert_reply(" (1) Yes or (2) No") + step5 = await step4.send("no") + await step5.assert_reply("Not confirmed") + + @pytest.mark.asyncio + async def test_confirm_prompt_choice_options_numbers(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") + confirm_prompt.choice_options = ChoiceFactoryOptions(include_numbers=True) + confirm_prompt.style = ListStyle.in_line + dialogs.add(confirm_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Please confirm."), + retry_prompt=Activity( + type=ActivityTypes.message, + text="Please confirm, say 'yes' or 'no' or something like that.", + ), + ) + await dialog_context.prompt("ConfirmPrompt", options) + elif results.status == DialogTurnStatus.Complete: + message_text = "Confirmed" if results.result else "Not confirmed" + await turn_context.send_activity(MessageFactory.text(message_text)) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("Please confirm. (1) Yes or (2) No") + step3 = await step2.send("lala") + step4 = await step3.assert_reply( + "Please confirm, say 'yes' or 'no' or something like that. (1) Yes or (2) No" + ) + step5 = await step4.send("2") + await step5.assert_reply("Not confirmed") + + @pytest.mark.asyncio + async def test_confirm_prompt_choice_options_multiple_attempts(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") + confirm_prompt.choice_options = ChoiceFactoryOptions(include_numbers=True) + confirm_prompt.style = ListStyle.in_line + dialogs.add(confirm_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Please confirm."), + retry_prompt=Activity( + type=ActivityTypes.message, + text="Please confirm, say 'yes' or 'no' or something like that.", + ), + ) + await dialog_context.prompt("ConfirmPrompt", options) + elif results.status == DialogTurnStatus.Complete: + message_text = "Confirmed" if results.result else "Not confirmed" + await turn_context.send_activity(MessageFactory.text(message_text)) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("Please confirm. (1) Yes or (2) No") + step3 = await step2.send("lala") + step4 = await step3.assert_reply( + "Please confirm, say 'yes' or 'no' or something like that. (1) Yes or (2) No" + ) + step5 = await step4.send("what") + step6 = await step5.assert_reply( + "Please confirm, say 'yes' or 'no' or something like that. (1) Yes or (2) No" + ) + step7 = await step6.send("2") + await step7.assert_reply("Not confirmed") + + @pytest.mark.asyncio + async def test_confirm_prompt_options_no_numbers(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") + confirm_prompt.choice_options = ChoiceFactoryOptions( + include_numbers=False, inline_separator="~" + ) + dialogs.add(confirm_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Please confirm."), + retry_prompt=Activity( + type=ActivityTypes.message, + text="Please confirm, say 'yes' or 'no' or something like that.", + ), + ) + await dialog_context.prompt("ConfirmPrompt", options) + elif results.status == DialogTurnStatus.Complete: + message_text = "Confirmed" if results.result else "Not confirmed" + await turn_context.send_activity(MessageFactory.text(message_text)) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("Please confirm. Yes or No") + step3 = await step2.send("2") + step4 = await step3.assert_reply( + "Please confirm, say 'yes' or 'no' or something like that. Yes or No" + ) + step5 = await step4.send("no") + await step5.assert_reply("Not confirmed") + + @pytest.mark.asyncio + async def test_confirm_prompt_should_default_to_english_locale(self): + locales = [None, "", "not-supported"] + + for locale in locales: + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + confirm_prompt = ConfirmPrompt("ConfirmPrompt") + confirm_prompt.choice_options = ChoiceFactoryOptions(include_numbers=True) + dialogs.add(confirm_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please confirm." + ), + retry_prompt=Activity( + type=ActivityTypes.message, + text="Please confirm, say 'yes' or 'no' or something like that.", + ), + ) + await dialog_context.prompt("ConfirmPrompt", options) + elif results.status == DialogTurnStatus.Complete: + message_text = "Confirmed" if results.result else "Not confirmed" + await turn_context.send_activity(MessageFactory.text(message_text)) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + # Activity.locale uses NonEmptyString which rejects None/""; use model_construct to bypass + send_activity = Activity.model_construct( + type=ActivityTypes.message, text="Hello", locale=locale or None + ) + step1 = await adapter.send(send_activity) + step2 = await step1.assert_reply("Please confirm. (1) Yes or (2) No") + step3 = await step2.send("lala") + step4 = await step3.assert_reply( + "Please confirm, say 'yes' or 'no' or something like that. (1) Yes or (2) No" + ) + step5 = await step4.send("2") + await step5.assert_reply("Not confirmed") + + @pytest.mark.asyncio + async def test_should_recognize_locale_variations_of_correct_locales(self): + def cap_ending(locale: str) -> str: + return f"{locale.split('-')[0]}-{locale.split('-')[1].upper()}" + + def title_ending(locale: str) -> str: + return locale[:3] + locale[3].upper() + locale[4:] + + def cap_two_letter(locale: str) -> str: + return locale.split("-")[0].upper() + + def lower_two_letter(locale: str) -> str: + return locale.split("-")[0].upper() + + async def exec_test_for_locale(valid_locale: str, locale_variations: List): + # Hold the correct answer from when a valid locale is used + expected_answer = None + + def inspector(activity: Activity, description: str): + nonlocal expected_answer + + assert not description + + if valid_locale == test_locale: + expected_answer = activity.text + else: + # Ensure we're actually testing a variation. + assert activity.locale != valid_locale + + assert activity.text == expected_answer + return True + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please confirm." + ) + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + confirmed = results.result + if confirmed: + await turn_context.send_activity("true") + else: + await turn_context.send_activity("false") + + await convo_state.save(turn_context) + + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + test_locale = None + for test_locale in locale_variations: + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ConfirmPrompt("prompt", validator) + dialogs.add(choice_prompt) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send( + Activity( + type=ActivityTypes.message, text="Hello", locale=test_locale + ) + ) + await step1.assert_reply(inspector) + + locales = [ + "zh-cn", + "nl-nl", + "en-us", + "fr-fr", + "de-de", + "it-it", + "ja-jp", + "ko-kr", + "pt-br", + "es-es", + "tr-tr", + "de-de", + ] + + locale_tests = [] + for locale in locales: + locale_tests.append( + [ + locale, + cap_ending(locale), + title_ending(locale), + cap_two_letter(locale), + lower_two_letter(locale), + ] + ) + + # Test each valid locale + for locale_test in locale_tests: + await exec_test_for_locale(locale_test[0], locale_test) + + @pytest.mark.asyncio + async def test_should_recognize_and_use_custom_locale_dict(self): + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + culture = PromptCultureModel( + locale="custom-locale", + no_in_language="customNo", + yes_in_language="customYes", + separator="customSeparator", + inline_or="customInlineOr", + inline_or_more="customInlineOrMore", + ) + + custom_dict = { + culture.locale: ( + Choice(culture.yes_in_language), + Choice(culture.no_in_language), + ChoiceFactoryOptions( + culture.separator, culture.inline_or, culture.inline_or_more, True + ), + ) + } + + confirm_prompt = ConfirmPrompt("prompt", validator, choice_defaults=custom_dict) + dialogs.add(confirm_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Please confirm.") + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send( + Activity(type=ActivityTypes.message, text="Hello", locale=culture.locale) + ) + await step1.assert_reply( + "Please confirm. (1) customYescustomInlineOr(2) customNo" + ) diff --git a/dev/hosting_dialogs/test_date_time_prompt.py b/dev/hosting_dialogs/test_date_time_prompt.py new file mode 100644 index 00000000..ef2aeb9d --- /dev/null +++ b/dev/hosting_dialogs/test_date_time_prompt.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.hosting.dialogs.prompts import DateTimePrompt, PromptOptions +from microsoft_agents.hosting.core import ( + MessageFactory, + ConversationState, + MemoryStorage, + TurnContext, +) +from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class TestDatetimePrompt: + @pytest.mark.asyncio + async def test_date_time_prompt(self): + # Create new ConversationState with MemoryStorage and register the state as middleware. + conver_state = ConversationState(MemoryStorage()) + + # Create a DialogState property + dialog_state = conver_state.create_property("dialogState") + + # Create new DialogSet. + dialogs = DialogSet(dialog_state) + + # Create and add DateTime prompt to DialogSet. + date_time_prompt = DateTimePrompt("DateTimePrompt") + dialogs.add(date_time_prompt) + + async def exec_test(turn_context: TurnContext) -> None: + prompt_msg = "What date would you like?" + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions(prompt=MessageFactory.text(prompt_msg)) + await dialog_context.begin_dialog("DateTimePrompt", options) + else: + if results.status == DialogTurnStatus.Complete: + resolution = results.result[0] + reply = MessageFactory.text( + f"Timex: '{resolution.timex}' Value: '{resolution.value}'" + ) + await turn_context.send_activity(reply) + await conver_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("What date would you like?") + step3 = await step2.send("5th December 2018 at 9am") + await step3.assert_reply("Timex: '2018-12-05T09' Value: '2018-12-05 09:00:00'") + + @pytest.mark.asyncio + async def test_date_time_prompt_retry_on_invalid_input(self): + """DateTimePrompt sends the retry_prompt when input cannot be recognized, then accepts valid input.""" + conver_state = ConversationState(MemoryStorage()) + dialog_state = conver_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + dialogs.add(DateTimePrompt("DateTimePrompt")) + + async def exec_test(turn_context: TurnContext) -> None: + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=MessageFactory.text("What date?"), + retry_prompt=MessageFactory.text("Not a valid date. Try again."), + ) + await dialog_context.begin_dialog("DateTimePrompt", options) + elif results.status == DialogTurnStatus.Complete: + resolution = results.result[0] + await turn_context.send_activity( + MessageFactory.text(f"Timex: '{resolution.timex}'") + ) + await conver_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("What date?") + step3 = await step2.send("not a date at all xyz") + step4 = await step3.assert_reply("Not a valid date. Try again.") + step5 = await step4.send("5th December 2018") + await step5.assert_reply("Timex: '2018-12-05'") diff --git a/dev/hosting_dialogs/test_dialog.py b/dev/hosting_dialogs/test_dialog.py new file mode 100644 index 00000000..3bf99d2b --- /dev/null +++ b/dev/hosting_dialogs/test_dialog.py @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from microsoft_agents.hosting.dialogs import ( + Dialog, + DialogTurnResult, + DialogTurnStatus, + DialogReason, +) + + +class _ConcreteDialog(Dialog): + """Minimal concrete Dialog for testing base class behavior.""" + + async def begin_dialog(self, dialog_context, options=None): + return DialogTurnResult(DialogTurnStatus.Complete) + + +class TestDialog: + def test_null_id_raises(self): + with pytest.raises((TypeError, Exception)): + _ConcreteDialog(None) + + def test_blank_id_raises(self): + with pytest.raises((TypeError, Exception)): + _ConcreteDialog(" ") + + def test_telemetry_client_defaults_non_none(self): + dialog = _ConcreteDialog("A") + assert dialog.telemetry_client is not None + + def test_get_version_returns_id(self): + dialog = _ConcreteDialog("my-dialog") + assert dialog.get_version() == "my-dialog" + + def test_id_property(self): + dialog = _ConcreteDialog("test-id") + assert dialog.id == "test-id" + + @pytest.mark.asyncio + async def test_continue_dialog_calls_end_dialog(self): + """Default continue_dialog calls end_dialog(None) → Complete.""" + dialog = _ConcreteDialog("A") + dc = MagicMock() + dc.end_dialog = AsyncMock( + return_value=DialogTurnResult(DialogTurnStatus.Complete) + ) + + result = await dialog.continue_dialog(dc) + + assert result.status == DialogTurnStatus.Complete + dc.end_dialog.assert_awaited_once_with(None) + + @pytest.mark.asyncio + async def test_resume_dialog_calls_end_dialog_with_result(self): + """Default resume_dialog calls end_dialog(result) → Complete.""" + dialog = _ConcreteDialog("A") + dc = MagicMock() + dc.end_dialog = AsyncMock( + return_value=DialogTurnResult(DialogTurnStatus.Complete, "done") + ) + + result = await dialog.resume_dialog(dc, DialogReason.BeginCalled, "done") + + assert result.status == DialogTurnStatus.Complete + dc.end_dialog.assert_awaited_once_with("done") + + @pytest.mark.asyncio + async def test_reprompt_dialog_is_noop(self): + """Default reprompt_dialog is a no-op (no exception).""" + dialog = _ConcreteDialog("A") + await dialog.reprompt_dialog(MagicMock(), MagicMock()) + + @pytest.mark.asyncio + async def test_end_dialog_is_noop(self): + """Default end_dialog is a no-op (no exception).""" + dialog = _ConcreteDialog("A") + await dialog.end_dialog(MagicMock(), MagicMock(), DialogReason.EndCalled) diff --git a/dev/hosting_dialogs/test_dialog_context.py b/dev/hosting_dialogs/test_dialog_context.py new file mode 100644 index 00000000..288b7d0a --- /dev/null +++ b/dev/hosting_dialogs/test_dialog_context.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from unittest.mock import MagicMock + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.dialogs import ( + ComponentDialog, + Dialog, + DialogContext, + DialogSet, + DialogState, + DialogTurnStatus, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, +) +from microsoft_agents.hosting.dialogs.models.dialog_instance import DialogInstance +from microsoft_agents.hosting.dialogs.prompts import TextPrompt, PromptOptions +from microsoft_agents.hosting.core import ConversationState, MemoryStorage +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +def _make_dc(): + """Create a minimal DialogContext backed by a MagicMock TurnContext.""" + + class _Stub(ComponentDialog): + def __init__(self): + super().__init__("stub") + + ds = _Stub()._dialogs + mock_tc = MagicMock() + return DialogContext(ds, mock_tc, DialogState()) + + +class TestDialogContext: + def test_null_dialog_set_raises(self): + with pytest.raises(TypeError): + DialogContext(None, MagicMock(), DialogState()) + + def test_null_turn_context_raises(self): + dc = _make_dc() + with pytest.raises(TypeError): + DialogContext(dc.dialogs, None, DialogState()) + + @pytest.mark.asyncio + async def test_begin_dialog_empty_id_raises(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + adapter = DialogTestAdapter() + + async def callback(tc): + dc = await ds.create_context(tc) + with pytest.raises(TypeError): + await dc.begin_dialog("") + + await adapter.send_text_to_agent_async("hi", callback) + + @pytest.mark.asyncio + async def test_prompt_empty_id_raises(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + adapter = DialogTestAdapter() + + async def callback(tc): + dc = await ds.create_context(tc) + with pytest.raises(TypeError): + await dc.prompt("", MagicMock()) + + await adapter.send_text_to_agent_async("hi", callback) + + @pytest.mark.asyncio + async def test_prompt_none_options_raises(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + adapter = DialogTestAdapter() + + async def callback(tc): + dc = await ds.create_context(tc) + with pytest.raises(TypeError): + await dc.prompt("somePrompt", None) + + await adapter.send_text_to_agent_async("hi", callback) + + @pytest.mark.asyncio + async def test_continue_dialog_empty_stack_returns_empty(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + result_holder = {} + adapter = DialogTestAdapter() + + async def callback(tc): + dc = await ds.create_context(tc) + result = await dc.continue_dialog() + result_holder["status"] = result.status + + await adapter.send_text_to_agent_async("hi", callback) + assert result_holder["status"] == DialogTurnStatus.Empty + + def test_active_dialog_none_on_empty_stack(self): + dc = _make_dc() + assert dc.active_dialog is None + + def test_child_none_when_no_active_dialog(self): + dc = _make_dc() + assert dc.child is None + + def test_find_dialog_sync_returns_none_for_unknown(self): + dc = _make_dc() + assert dc.find_dialog_sync("nonexistent") is None + + @pytest.mark.asyncio + async def test_cancel_all_dialogs_empty_stack_returns_empty(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + result_holder = {} + adapter = DialogTestAdapter() + + async def callback(tc): + dc = await ds.create_context(tc) + result = await dc.cancel_all_dialogs() + result_holder["status"] = result.status + + await adapter.send_text_to_agent_async("hi", callback) + assert result_holder["status"] == DialogTurnStatus.Empty + + @pytest.mark.asyncio + async def test_begin_dialog_unknown_id_raises(self): + """begin_dialog raises when the dialog ID is not registered.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + adapter = DialogTestAdapter() + + async def callback(tc): + dc = await ds.create_context(tc) + with pytest.raises(Exception): + await dc.begin_dialog("does-not-exist") + + await adapter.send_text_to_agent_async("hi", callback) + + @pytest.mark.asyncio + async def test_replace_dialog_ends_active_and_starts_new(self): + """replace_dialog pops the active dialog and starts the replacement.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + completed = {} + + async def step1(step): + await step.context.send_activity("step1") + return Dialog.end_of_turn + + async def step2(step): + await step.context.send_activity("replacement") + return await step.end_dialog() + + ds.add(WaterfallDialog("first", [step1])) + ds.add(WaterfallDialog("second", [step2])) + + adapter = DialogTestAdapter() + + async def callback(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("first") + elif results.status == DialogTurnStatus.Waiting: + await dc.replace_dialog("second") + await convo_state.save(tc) + + step = await adapter.send_text_to_agent_async("hi", callback) + step = await adapter.send_text_to_agent_async("next", callback) + completed["done"] = True + assert completed["done"] + + @pytest.mark.asyncio + async def test_find_dialog_returns_none_for_unknown(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + result_holder = {} + adapter = DialogTestAdapter() + + async def callback(tc): + dc = await ds.create_context(tc) + found = await dc.find_dialog("nonexistent") + result_holder["found"] = found + + await adapter.send_text_to_agent_async("hi", callback) + assert result_holder["found"] is None + + @pytest.mark.asyncio + async def test_reprompt_dialog_resends_prompt_activity(self): + """reprompt_dialog re-sends the original prompt when a TextPrompt is waiting.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + ds.add(TextPrompt("text-prompt")) + + async def start_prompt(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please enter text." + ) + ) + await dc.prompt("text-prompt", options) + await convo_state.save(tc) + + async def do_reprompt(tc): + dc = await ds.create_context(tc) + await dc.reprompt_dialog() + await convo_state.save(tc) + + adapter = DialogTestAdapter() + + await adapter.send_text_to_agent_async("hi", start_prompt) + first_prompt = adapter.get_next_reply() + assert first_prompt is not None + assert first_prompt.text == "Please enter text." + + adapter.active_queue.clear() + await adapter.send_text_to_agent_async("anything", do_reprompt) + reprompt_reply = adapter.get_next_reply() + assert reprompt_reply is not None + assert reprompt_reply.text == "Please enter text." + + @pytest.mark.asyncio + async def test_emit_event_bubbles_to_parent_dc(self): + """Events emitted with bubble=True propagate from the inner DC to the parent DC's active dialog.""" + from unittest.mock import MagicMock + + captured_events = [] + + class CapturingDialog(WaterfallDialog): + async def _on_pre_bubble_event(self, dc, event): + captured_events.append(event.name) + return True # mark as handled; stop bubbling + + # ComponentDialog subclasses are the only way to get a no-state DialogSet + class _OuterHolder(ComponentDialog): + def __init__(self): + super().__init__("_outer_holder") + + class _InnerHolder(ComponentDialog): + def __init__(self): + super().__init__("_inner_holder") + + # Outer DC: has CapturingDialog active on the stack + outer_ds = _OuterHolder()._dialogs + outer_ds.add(CapturingDialog("capturing", [])) + outer_state = DialogState() + outer_instance = DialogInstance() + outer_instance.id = "capturing" + outer_instance.state = {} + outer_state.dialog_stack.insert(0, outer_instance) + mock_tc = MagicMock() + outer_dc = DialogContext(outer_ds, mock_tc, outer_state) + + # Inner DC: has a plain WaterfallDialog active; parent is outer_dc + inner_ds = _InnerHolder()._dialogs + inner_ds.add(WaterfallDialog("inner-wf", [])) + inner_state = DialogState() + inner_instance = DialogInstance() + inner_instance.id = "inner-wf" + inner_instance.state = {} + inner_state.dialog_stack.insert(0, inner_instance) + inner_dc = DialogContext(inner_ds, mock_tc, inner_state) + inner_dc.parent = outer_dc + + handled = await inner_dc.emit_event("custom.test.event", "payload", bubble=True) + + assert ( + handled + ), "Event should have been caught by the outer dialog's _on_pre_bubble_event" + assert "custom.test.event" in captured_events diff --git a/dev/hosting_dialogs/test_dialog_extensions.py b/dev/hosting_dialogs/test_dialog_extensions.py new file mode 100644 index 00000000..082e49ed --- /dev/null +++ b/dev/hosting_dialogs/test_dialog_extensions.py @@ -0,0 +1,188 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# pylint: disable=ungrouped-imports +import enum +from typing import List +import uuid + +import pytest + +from microsoft_agents.hosting.core import ClaimsIdentity +from microsoft_agents.hosting.core.authorization import AuthenticationConstants +from microsoft_agents.hosting.core import ( + TurnContext, + MessageFactory, + MemoryStorage, + ConversationState, + UserState, +) +from microsoft_agents.activity import ActivityTypes, Activity, EndOfConversationCodes +from microsoft_agents.hosting.dialogs import ( + ComponentDialog, + TextPrompt, + WaterfallDialog, + DialogInstance, + DialogReason, + WaterfallStepContext, + PromptOptions, + Dialog, + DialogExtensions, + DialogEvents, +) +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class SimpleComponentDialog(ComponentDialog): + def __init__(self): + super().__init__("SimpleComponentDialog") + + self.add_dialog(TextPrompt("TextPrompt")) + self.add_dialog( + WaterfallDialog("WaterfallDialog", [self.prompt_for_name, self.final_step]) + ) + + self.initial_dialog_id = "WaterfallDialog" + self.end_reason = DialogReason.BeginCalled + + async def end_dialog( + self, context: TurnContext, instance: DialogInstance, reason: DialogReason + ) -> None: + self.end_reason = reason + return await super().end_dialog(context, instance, reason) + + async def prompt_for_name(self, step_context: WaterfallStepContext): + return await step_context.prompt( + "TextPrompt", + PromptOptions( + prompt=MessageFactory.text("Hello, what is your name?"), + retry_prompt=MessageFactory.text("Hello, what is your name again?"), + ), + ) + + async def final_step(self, step_context: WaterfallStepContext): + await step_context.context.send_activity( + f"Hello {step_context.result}, nice to meet you!" + ) + return await step_context.end_dialog(step_context.result) + + +class FlowTestCase(enum.Enum): + root_bot_only = 1 + root_bot_consuming_skill = 2 + middle_skill = 3 + leaf_skill = 4 + + +class TestDialogExtensions: + def setup_method(self): + self.eoc_sent: Activity = None + self.skill_bot_id = str(uuid.uuid4()) + self.parent_bot_id = str(uuid.uuid4()) + + def _create_test_flow( + self, dialog: Dialog, test_case: FlowTestCase + ) -> DialogTestAdapter: + """ + Creates a DialogTestAdapter configured for the given test case. + Returns the adapter (which supports send/assert_reply as a TestFlow entry point). + """ + conversation_id = str(uuid.uuid4()) + storage = MemoryStorage() + convo_state = ConversationState(storage) + + eoc_sent_ref = [None] + self.eoc_sent = None + + async def logic(context: TurnContext): + if test_case != FlowTestCase.root_bot_only: + claims_identity = ClaimsIdentity( + { + AuthenticationConstants.VERSION_CLAIM: "2.0", + AuthenticationConstants.AUDIENCE_CLAIM: self.skill_bot_id, + AuthenticationConstants.AUTHORIZED_PARTY: self.parent_bot_id, + }, + True, + ) + context._identity = claims_identity + + async def capture_eoc( + inner_context: TurnContext, activities: List[Activity], next + ): # pylint: disable=unused-argument + for activity in activities: + if activity.type == ActivityTypes.end_of_conversation: + self.eoc_sent = activity + eoc_sent_ref[0] = activity + break + return await next() + + context.on_send_activities(capture_eoc) + + await DialogExtensions.run_dialog( + dialog, context, convo_state.create_property("DialogState") + ) + + return DialogTestAdapter(logic) + + @pytest.mark.asyncio + async def test_handles_root_bot_only(self): + dialog = SimpleComponentDialog() + adapter = self._create_test_flow(dialog, FlowTestCase.root_bot_only) + + step1 = await adapter.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.send("SomeName") + await step3.assert_reply("Hello SomeName, nice to meet you!") + + assert dialog.end_reason == DialogReason.EndCalled + assert ( + self.eoc_sent is None + ), "Root bot should not send EndConversation to channel" + + @pytest.mark.skip( + reason="Requires skill infrastructure (SkillHandler/SkillConversationReference) not available in new SDK" + ) + @pytest.mark.asyncio + async def test_handles_root_bot_consuming_skill(self): + pass + + @pytest.mark.skip( + reason="Requires skill infrastructure (SkillHandler/SkillConversationReference) not available in new SDK" + ) + @pytest.mark.asyncio + async def test_handles_middle_skill(self): + pass + + @pytest.mark.skip( + reason="Requires skill infrastructure (SkillHandler/SkillConversationReference) not available in new SDK" + ) + @pytest.mark.asyncio + async def test_handles_leaf_skill(self): + pass + + @pytest.mark.skip( + reason="Requires skill infrastructure (SkillHandler/SkillConversationReference) not available in new SDK" + ) + @pytest.mark.asyncio + async def test_skill_handles_eoc_from_parent(self): + pass + + @pytest.mark.asyncio + async def test_skill_handles_reprompt_from_parent(self): + """ + Tests that a reprompt event causes the dialog to re-prompt. + This test does not require skill infrastructure. + """ + dialog = SimpleComponentDialog() + adapter = self._create_test_flow(dialog, FlowTestCase.root_bot_only) + + step1 = await adapter.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + await step2.send( + Activity( + type=ActivityTypes.event, + name=DialogEvents.reprompt_dialog, + ) + ) + + assert dialog.end_reason == DialogReason.BeginCalled diff --git a/dev/hosting_dialogs/test_dialog_manager.py b/dev/hosting_dialogs/test_dialog_manager.py new file mode 100644 index 00000000..fa39d2f8 --- /dev/null +++ b/dev/hosting_dialogs/test_dialog_manager.py @@ -0,0 +1,285 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# pylint: disable=pointless-string-statement + +from enum import Enum +from typing import Callable, List, Tuple + +import pytest + +from microsoft_agents.hosting.core import ( + ConversationState, + MemoryStorage, + MessageFactory, + UserState, + TurnContext, +) +from microsoft_agents.hosting.core import ClaimsIdentity +from microsoft_agents.hosting.core.authorization import AuthenticationConstants +from microsoft_agents.hosting.dialogs import ( + ComponentDialog, + Dialog, + DialogContext, + DialogEvents, + DialogInstance, + DialogReason, + TextPrompt, + WaterfallDialog, + DialogManager, + DialogManagerResult, + DialogTurnStatus, + WaterfallStepContext, +) +from microsoft_agents.hosting.dialogs.prompts import PromptOptions +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + EndOfConversationCodes, + InputHints, + Channels, +) +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class SkillFlowTestCase(str, Enum): + # DialogManager is executing on a root bot with no skills (typical standalone bot). + root_bot_only = "RootBotOnly" + + # DialogManager is executing on a root bot handling replies from a skill. + root_bot_consuming_skill = "RootBotConsumingSkill" + + # DialogManager is executing in a skill that is called from a root and calling another skill. + middle_skill = "MiddleSkill" + + # DialogManager is executing in a skill that is called from a parent (a root or another skill) but doesn't call + # another skill. + leaf_skill = "LeafSkill" + + +class SimpleComponentDialog(ComponentDialog): + # An App ID for a parent bot. + parent_bot_id = "00000000-0000-0000-0000-0000000000PARENT" + + # An App ID for a skill bot. + skill_bot_id = "00000000-0000-0000-0000-00000000000SKILL" + + # Captures an EndOfConversation if it was sent to help with assertions. + eoc_sent: Activity = None + + # Property to capture the DialogManager turn results and do assertions. + dm_turn_result: DialogManagerResult = None + + def __init__( + self, id: str = None, prop: str = None + ): # pylint: disable=unused-argument + super().__init__(id or "SimpleComponentDialog") + self.text_prompt = "TextPrompt" + self.waterfall_dialog = "WaterfallDialog" + self.add_dialog(TextPrompt(self.text_prompt)) + self.add_dialog( + WaterfallDialog( + self.waterfall_dialog, + [ + self.prompt_for_name, + self.final_step, + ], + ) + ) + self.initial_dialog_id = self.waterfall_dialog + self.end_reason = None + + @staticmethod + async def create_test_flow( + dialog: Dialog, + test_case: SkillFlowTestCase = SkillFlowTestCase.root_bot_only, + enabled_trace=False, + ) -> DialogTestAdapter: + conversation_id = "testFlowConversationId" + storage = MemoryStorage() + conversation_state = ConversationState(storage) + user_state = UserState(storage) + + activity = Activity( + type=ActivityTypes.message, + channel_id=Channels.test, + service_url="https://test.com", + from_property=ChannelAccount(id="user1", name="User1"), + recipient=ChannelAccount(id="bot", name="Bot"), + conversation=ConversationAccount( + is_group=False, conversation_type=conversation_id, id=conversation_id + ), + ) + + dialog_manager = DialogManager(dialog) + dialog_manager.user_state = user_state + dialog_manager.conversation_state = conversation_state + + async def logic(context: TurnContext): + if test_case != SkillFlowTestCase.root_bot_only: + # Create a skill ClaimsIdentity and put it in turn_state so isSkillClaim() returns True. + claims_identity = ClaimsIdentity({}, False) + claims_identity.claims[ + "ver" + ] = "2.0" # AuthenticationConstants.VersionClaim + claims_identity.claims[ + "aud" + ] = ( + SimpleComponentDialog.skill_bot_id + ) # AuthenticationConstants.AudienceClaim + claims_identity.claims[ + "azp" + ] = ( + SimpleComponentDialog.parent_bot_id + ) # AuthenticationConstants.AuthorizedParty + context._identity = claims_identity + + # Note: SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY based skill routing + # is not available in the new SDK. Skill flow test cases that require it are skipped. + + async def aux( + turn_context: TurnContext, # pylint: disable=unused-argument + activities: List[Activity], + next: Callable, + ): + for activity in activities: + if activity.type == ActivityTypes.end_of_conversation: + SimpleComponentDialog.eoc_sent = activity + break + + return await next() + + # Interceptor to capture the EoC activity if it was sent so we can assert it in the tests. + context.on_send_activities(aux) + + SimpleComponentDialog.dm_turn_result = await dialog_manager.on_turn(context) + + # Manually save state since AutoSaveStateMiddleware is not available + await conversation_state.save(context) + await user_state.save(context) + + adapter = DialogTestAdapter(logic) + if enabled_trace: + adapter.enable_trace = True + + return adapter + + async def on_end_dialog( + self, context: DialogContext, instance: DialogInstance, reason: DialogReason + ): + self.end_reason = reason + return await super().on_end_dialog(context, instance, reason) + + async def prompt_for_name(self, step: WaterfallStepContext): + return await step.prompt( + self.text_prompt, + PromptOptions( + prompt=MessageFactory.text( + "Hello, what is your name?", None, InputHints.expecting_input + ), + retry_prompt=MessageFactory.text( + "Hello, what is your name again?", None, InputHints.expecting_input + ), + ), + ) + + async def final_step(self, step: WaterfallStepContext): + await step.context.send_activity(f"Hello { step.result }, nice to meet you!") + return await step.end_dialog(step.result) + + +class TestDialogManager: + @pytest.mark.asyncio + async def test_handles_root_bot_only(self): + SimpleComponentDialog.dm_turn_result = None + SimpleComponentDialog.eoc_sent = None + dialog = SimpleComponentDialog() + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.root_bot_only + ) + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.send("SomeName") + await step3.assert_reply("Hello SomeName, nice to meet you!") + + assert ( + SimpleComponentDialog.dm_turn_result.turn_result.status + == DialogTurnStatus.Complete + ) + assert dialog.end_reason == DialogReason.EndCalled + assert ( + SimpleComponentDialog.eoc_sent is None + ), "Root bot should not send EndConversation to channel" + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_handles_root_bot_consuming_skill(self): + pass + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_handles_middle_skill(self): + pass + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_handles_leaf_skill(self): + pass + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_skill_handles_eoc_from_parent(self): + pass + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_skill_handles_reprompt_from_parent(self): + pass + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_skill_should_return_empty_on_reprompt_with_no_dialog(self): + pass + + @pytest.mark.asyncio + async def test_trace_bot_state(self): + SimpleComponentDialog.dm_turn_result = None + dialog = SimpleComponentDialog() + + def assert_is_trace(activity, description): # pylint: disable=unused-argument + assert activity.type == ActivityTypes.trace + return True + + def assert_is_trace_and_label(activity, description): + assert_is_trace(activity, description) + assert activity.label == "Bot State" + return True + + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.root_bot_only, True + ) + + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.assert_reply(assert_is_trace_and_label) + step4 = await step3.send("SomeName") + step5 = await step4.assert_reply("Hello SomeName, nice to meet you!") + await step5.assert_reply(assert_is_trace_and_label) + + assert ( + SimpleComponentDialog.dm_turn_result.turn_result.status + == DialogTurnStatus.Complete + ) diff --git a/dev/hosting_dialogs/test_dialog_set.py b/dev/hosting_dialogs/test_dialog_set.py new file mode 100644 index 00000000..ef9fee1e --- /dev/null +++ b/dev/hosting_dialogs/test_dialog_set.py @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from microsoft_agents.hosting.dialogs import DialogSet, ComponentDialog, WaterfallDialog +from microsoft_agents.hosting.core import ConversationState, MemoryStorage +from microsoft_agents.hosting.dialogs._telemetry_client import NullTelemetryClient + + +class MyBotTelemetryClient(NullTelemetryClient): + def __init__(self): + super().__init__() + + +class TestDialogSet: + def test_dialogset_constructor_valid(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state_property = convo_state.create_property("dialogstate") + dialog_set = DialogSet(dialog_state_property) + assert dialog_set is not None + + def test_dialogset_constructor_null_property(self): + with pytest.raises(TypeError): + DialogSet(None) + + def test_dialogset_constructor_null_from_componentdialog(self): + ComponentDialog("MyId") + + def test_dialogset_telemetryset(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state_property = convo_state.create_property("dialogstate") + dialog_set = DialogSet(dialog_state_property) + + dialog_set.add(WaterfallDialog("A")) + dialog_set.add(WaterfallDialog("B")) + + assert isinstance( + dialog_set.find_dialog("A").telemetry_client, NullTelemetryClient + ) + assert isinstance( + dialog_set.find_dialog("B").telemetry_client, NullTelemetryClient + ) + + dialog_set.telemetry_client = MyBotTelemetryClient() + + assert isinstance( + dialog_set.find_dialog("A").telemetry_client, MyBotTelemetryClient + ) + assert isinstance( + dialog_set.find_dialog("B").telemetry_client, MyBotTelemetryClient + ) + + def test_add_duplicate_dialog_id_raises(self): + """Adding a second dialog with the same ID raises TypeError.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state_property = convo_state.create_property("dialogstate") + dialog_set = DialogSet(dialog_state_property) + + dialog_set.add(WaterfallDialog("A")) + with pytest.raises(TypeError): + dialog_set.add(WaterfallDialog("A")) + + def test_add_none_dialog_raises(self): + """Adding None raises TypeError.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state_property = convo_state.create_property("dialogstate") + dialog_set = DialogSet(dialog_state_property) + with pytest.raises(TypeError): + dialog_set.add(None) + + def test_get_version_is_stable(self): + """get_version() returns the same hash on repeated calls.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state_property = convo_state.create_property("dialogstate") + dialog_set = DialogSet(dialog_state_property) + dialog_set.add(WaterfallDialog("A")) + v1 = dialog_set.get_version() + v2 = dialog_set.get_version() + assert v1 == v2 + + def test_dialogset_nulltelemetryset(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state_property = convo_state.create_property("dialogstate") + dialog_set = DialogSet(dialog_state_property) + + dialog_set.add(WaterfallDialog("A")) + dialog_set.add(WaterfallDialog("B")) + + dialog_set.telemetry_client = MyBotTelemetryClient() + dialog_set.telemetry_client = None + + assert not isinstance( + dialog_set.find_dialog("A").telemetry_client, MyBotTelemetryClient + ) + assert not isinstance( + dialog_set.find_dialog("B").telemetry_client, MyBotTelemetryClient + ) + assert isinstance( + dialog_set.find_dialog("A").telemetry_client, NullTelemetryClient + ) + assert isinstance( + dialog_set.find_dialog("B").telemetry_client, NullTelemetryClient + ) + + def test_get_version_is_invalidated_after_add(self): + """get_version() must reflect all dialogs even when add() is called after an earlier get_version().""" + convo_state = ConversationState(MemoryStorage()) + dialog_state_property = convo_state.create_property("dialogstate") + dialog_set = DialogSet(dialog_state_property) + + dialog_set.add(WaterfallDialog("A")) + version_before = dialog_set.get_version() + + dialog_set.add(WaterfallDialog("B")) + version_after = dialog_set.get_version() + + assert version_before != version_after, ( + "get_version() returned the same hash after adding a new dialog; " + "the version cache was not invalidated by add()" + ) + + def test_get_version_is_invalidated_after_telemetry_change(self): + """Changing the telemetry client must invalidate the cached version.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state_property = convo_state.create_property("dialogstate") + dialog_set = DialogSet(dialog_state_property) + dialog_set.add(WaterfallDialog("A")) + + version_before = dialog_set.get_version() + dialog_set.telemetry_client = MyBotTelemetryClient() + version_after = dialog_set.get_version() + + # Versions may or may not differ depending on whether telemetry affects + # individual dialog versions, but the cache must at least be recomputed + # (i.e. get_version() must not return the stale pre-change value from cache). + # We verify this by checking the cache was cleared (recomputed == same value is fine). + assert version_after is not None diff --git a/dev/hosting_dialogs/test_number_prompt.py b/dev/hosting_dialogs/test_number_prompt.py new file mode 100644 index 00000000..06f01827 --- /dev/null +++ b/dev/hosting_dialogs/test_number_prompt.py @@ -0,0 +1,378 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Callable + +import pytest +from recognizers_text import Culture + +from microsoft_agents.hosting.core import ( + TurnContext, + ConversationState, + MemoryStorage, + MessageFactory, +) +from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus, DialogContext +from microsoft_agents.hosting.dialogs.prompts import ( + NumberPrompt, + PromptOptions, + PromptValidatorContext, +) +from microsoft_agents.activity import Activity, ActivityTypes +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class NumberPromptMock(NumberPrompt): + def __init__( + self, + dialog_id: str, + validator: Callable[[PromptValidatorContext], bool] = None, + default_locale=None, + ): + super().__init__(dialog_id, validator, default_locale) + + async def on_prompt_null_context(self, options: PromptOptions): + # Should throw TypeError + await self.on_prompt( + turn_context=None, state=None, options=options, is_retry=False + ) + + async def on_prompt_null_options(self, dialog_context: DialogContext): + # Should throw TypeError + await self.on_prompt( + dialog_context.context, state=None, options=None, is_retry=False + ) + + async def on_recognize_null_context(self): + # Should throw TypeError + await self.on_recognize(turn_context=None, state=None, options=None) + + +class TestNumberPrompt: + def test_empty_id_should_fail(self): + empty_id = "" + with pytest.raises(TypeError): + NumberPrompt(empty_id) + + def test_none_id_should_fail(self): + with pytest.raises(TypeError): + NumberPrompt(dialog_id=None) + + @pytest.mark.asyncio + async def test_with_null_turn_context_should_fail(self): + number_prompt_mock = NumberPromptMock("NumberPromptMock") + + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Please send a number.") + ) + + with pytest.raises(TypeError): + await number_prompt_mock.on_prompt_null_context(options) + + @pytest.mark.asyncio + async def test_on_prompt_with_null_options_fails(self): + conver_state = ConversationState(MemoryStorage()) + dialog_state = conver_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + number_prompt_mock = NumberPromptMock( + dialog_id="NumberPromptMock", validator=None, default_locale=Culture.English + ) + dialogs.add(number_prompt_mock) + + with pytest.raises(TypeError): + await number_prompt_mock.on_recognize_null_context() + + @pytest.mark.asyncio + async def test_number_prompt(self): + # Create new ConversationState with MemoryStorage and register the state as middleware. + conver_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the WaterfallDialog. + dialog_state = conver_state.create_property("dialogState") + + dialogs = DialogSet(dialog_state) + + # Create and add number prompt to DialogSet. + number_prompt = NumberPrompt("NumberPrompt", None, Culture.English) + dialogs.add(number_prompt) + + async def exec_test(turn_context: TurnContext) -> None: + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog( + "NumberPrompt", + PromptOptions( + prompt=MessageFactory.text("Enter quantity of cable") + ), + ) + else: + if results.status == DialogTurnStatus.Complete: + number_result = results.result + await turn_context.send_activity( + MessageFactory.text( + f"You asked me for '{number_result}' meters of cable." + ) + ) + + await conver_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply("Enter quantity of cable") + step3 = await step2.send("Give me twenty meters of cable") + await step3.assert_reply("You asked me for '20' meters of cable.") + + @pytest.mark.asyncio + async def test_number_prompt_retry(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + number_prompt = NumberPrompt( + dialog_id="NumberPrompt", validator=None, default_locale=Culture.English + ) + dialogs.add(number_prompt) + + async def exec_test(turn_context: TurnContext) -> None: + dialog_context: DialogContext = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter a number."), + retry_prompt=Activity( + type=ActivityTypes.message, text="You must enter a number." + ), + ) + await dialog_context.prompt("NumberPrompt", options) + elif results.status == DialogTurnStatus.Complete: + number_result = results.result + await turn_context.send_activity( + MessageFactory.text(f"Bot received the number '{number_result}'.") + ) + + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("Enter a number.") + step3 = await step2.send("hello") + step4 = await step3.assert_reply("You must enter a number.") + step5 = await step4.send("64") + await step5.assert_reply("Bot received the number '64'.") + + @pytest.mark.asyncio + async def test_number_uses_locale_specified_in_constructor(self): + # Create new ConversationState with MemoryStorage and register the state as middleware. + conver_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the WaterfallDialog. + dialog_state = conver_state.create_property("dialogState") + + dialogs = DialogSet(dialog_state) + + # Create and add number prompt to DialogSet. + number_prompt = NumberPrompt( + "NumberPrompt", None, default_locale=Culture.Spanish + ) + dialogs.add(number_prompt) + + async def exec_test(turn_context: TurnContext) -> None: + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog( + "NumberPrompt", + PromptOptions( + prompt=MessageFactory.text( + "How much money is in your gaming account?" + ) + ), + ) + else: + if results.status == DialogTurnStatus.Complete: + number_result = results.result + await turn_context.send_activity( + MessageFactory.text( + f"You say you have ${number_result} in your gaming account." + ) + ) + + await conver_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply("How much money is in your gaming account?") + step3 = await step2.send("I've got $1.200.555,42 in my account.") + await step3.assert_reply("You say you have $1200555.42 in your gaming account.") + + @pytest.mark.asyncio + async def test_number_prompt_validator(self): + # Create new ConversationState with MemoryStorage and register the state as middleware. + conver_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the WaterfallDialog. + dialog_state = conver_state.create_property("dialogState") + + dialogs = DialogSet(dialog_state) + + # Create and add number prompt to DialogSet. + async def validator(prompt_context: PromptValidatorContext): + result = prompt_context.recognized.value + + if 0 < result < 100: + return True + + return False + + number_prompt = NumberPrompt( + "NumberPrompt", validator, default_locale=Culture.English + ) + dialogs.add(number_prompt) + + async def exec_test(turn_context: TurnContext) -> None: + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter a number."), + retry_prompt=Activity( + type=ActivityTypes.message, + text="You must enter a positive number less than 100.", + ), + ) + await dialog_context.prompt("NumberPrompt", options) + + elif results.status == DialogTurnStatus.Complete: + number_result = int(results.result) + await turn_context.send_activity( + MessageFactory.text(f"Bot received the number '{number_result}'.") + ) + + await conver_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("Enter a number.") + step3 = await step2.send("150") + step4 = await step3.assert_reply( + "You must enter a positive number less than 100." + ) + step5 = await step4.send("64") + await step5.assert_reply("Bot received the number '64'.") + + @pytest.mark.asyncio + async def test_float_number_prompt(self): + # Create new ConversationState with MemoryStorage and register the state as middleware. + conver_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the WaterfallDialog. + dialog_state = conver_state.create_property("dialogState") + + dialogs = DialogSet(dialog_state) + + # Create and add number prompt to DialogSet. + number_prompt = NumberPrompt( + "NumberPrompt", validator=None, default_locale=Culture.English + ) + dialogs.add(number_prompt) + + async def exec_test(turn_context: TurnContext) -> None: + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter a number.") + ) + await dialog_context.prompt("NumberPrompt", options) + + elif results.status == DialogTurnStatus.Complete: + number_result = float(results.result) + await turn_context.send_activity( + MessageFactory.text(f"Bot received the number '{number_result}'.") + ) + + await conver_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("Enter a number.") + step3 = await step2.send("3.14") + await step3.assert_reply("Bot received the number '3.14'.") + + @pytest.mark.asyncio + async def test_number_prompt_uses_locale_specified_in_activity(self): + conver_state = ConversationState(MemoryStorage()) + dialog_state = conver_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + number_prompt = NumberPrompt("NumberPrompt", None, None) + dialogs.add(number_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter a number.") + ) + await dialog_context.prompt("NumberPrompt", options) + + elif results.status == DialogTurnStatus.Complete: + number_result = float(results.result) + assert 3.14 == number_result + + await conver_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("Enter a number.") + await step2.send( + Activity(type=ActivityTypes.message, text="3,14", locale=Culture.Spanish) + ) + + @pytest.mark.asyncio + async def test_number_prompt_defaults_to_en_us_culture(self): + conver_state = ConversationState(MemoryStorage()) + dialog_state = conver_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + number_prompt = NumberPrompt("NumberPrompt") + dialogs.add(number_prompt) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter a number.") + ) + await dialog_context.prompt("NumberPrompt", options) + + elif results.status == DialogTurnStatus.Complete: + number_result = float(results.result) + await turn_context.send_activity( + MessageFactory.text(f"Bot received the number '{number_result}'.") + ) + + await conver_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("Enter a number.") + step3 = await step2.send("3.14") + await step3.assert_reply("Bot received the number '3.14'.") diff --git a/dev/hosting_dialogs/test_oauth_prompt.py b/dev/hosting_dialogs/test_oauth_prompt.py new file mode 100644 index 00000000..331abe6a --- /dev/null +++ b/dev/hosting_dialogs/test_oauth_prompt.py @@ -0,0 +1,384 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from microsoft_agents.hosting.dialogs.prompts import OAuthPromptSettings +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + InputHints, + SignInConstants, + TokenResponse, +) +from microsoft_agents.hosting.core import ( + CardFactory, + ConversationState, + MemoryStorage, + TurnContext, +) +from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus +from microsoft_agents.hosting.dialogs.prompts import OAuthPrompt, PromptOptions +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +def create_reply(activity): + return Activity( + type=ActivityTypes.message, + from_property=ChannelAccount( + id=activity.recipient.id, name=activity.recipient.name + ), + recipient=ChannelAccount( + id=activity.from_property.id, name=activity.from_property.name + ), + reply_to_id=activity.id, + service_url=activity.service_url, + channel_id=activity.channel_id, + conversation=ConversationAccount( + is_group=activity.conversation.is_group, + id=activity.conversation.id, + name=activity.conversation.name, + ), + ) + + +class TestOAuthPrompt: + @pytest.mark.skip( + reason="tokens/response event path not supported in new OAuthPrompt internals" + ) + @pytest.mark.asyncio + async def test_should_call_oauth_prompt(self): + connection_name = "myConnection" + token = "abc123" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + dialogs.add( + OAuthPrompt( + "prompt", OAuthPromptSettings(connection_name, "Login", None, 300000) + ) + ) + + async def callback_handler(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.prompt("prompt", PromptOptions()) + elif results.status == DialogTurnStatus.Complete: + if results.result.token: + await turn_context.send_activity("Logged in.") + else: + await turn_context.send_activity("Failed") + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(callback_handler) + + async def inspector(activity: Activity, description: str = None): + assert len(activity.attachments) == 1 + assert ( + activity.attachments[0].content_type + == CardFactory.content_types.oauth_card + ) + + adapter.add_user_token( + connection_name, activity.channel_id, activity.recipient.id, token + ) + + event_activity = create_reply(activity) + event_activity.type = ActivityTypes.event + event_activity.from_property, event_activity.recipient = ( + event_activity.recipient, + event_activity.from_property, + ) + event_activity.name = "tokens/response" + event_activity.value = TokenResponse( + connection_name=connection_name, token=token + ) + + context = adapter.create_turn_context(event_activity) + await callback_handler(context) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply(inspector) + await step2.assert_reply("Logged in.") + + @pytest.mark.asyncio + async def test_should_call_oauth_prompt_with_code(self): + connection_name = "myConnection" + token = "abc123" + magic_code = "888999" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add( + OAuthPrompt( + "prompt", OAuthPromptSettings(connection_name, "Login", None, 300000) + ) + ) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.prompt("prompt", PromptOptions()) + elif results.status == DialogTurnStatus.Complete: + if results.result.token: + await turn_context.send_activity("Logged in.") + else: + await turn_context.send_activity("Failed") + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + def inspector(activity: Activity, description: str = None): + assert len(activity.attachments) == 1 + assert ( + activity.attachments[0].content_type + == CardFactory.content_types.oauth_card + ) + adapter.add_user_token( + connection_name, + activity.channel_id, + activity.recipient.id, + token, + magic_code, + ) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply(inspector) + step3 = await step2.send(magic_code) + await step3.assert_reply("Logged in.") + + @pytest.mark.asyncio + async def test_oauth_prompt_doesnt_detect_code_in_begin_dialog(self): + connection_name = "myConnection" + token = "abc123" + magic_code = "888999" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add( + OAuthPrompt( + "prompt", OAuthPromptSettings(connection_name, "Login", None, 300000) + ) + ) + + async def exec_test(turn_context: TurnContext): + adapter.add_user_token( + connection_name, + turn_context.activity.channel_id, + turn_context.activity.from_property.id, + token, + magic_code, + ) + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + token_result = await dialog_context.prompt("prompt", PromptOptions()) + if isinstance(token_result.result, TokenResponse): + assert False, "Should not have returned token during begin" + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + def inspector(activity: Activity, description: str = None): + assert len(activity.attachments) == 1 + assert ( + activity.attachments[0].content_type + == CardFactory.content_types.oauth_card + ) + + step1 = await adapter.send(magic_code) + await step1.assert_reply(inspector) + + @pytest.mark.asyncio + async def test_should_add_accepting_input_hint_oauth_prompt(self): + connection_name = "myConnection" + called = False + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + dialogs.add( + OAuthPrompt( + "prompt", OAuthPromptSettings(connection_name, "Login", None, 300000) + ) + ) + + async def callback_handler(turn_context: TurnContext): + nonlocal called + dialog_context = await dialogs.create_context(turn_context) + await dialog_context.continue_dialog() + await dialog_context.prompt( + "prompt", + PromptOptions( + prompt=Activity(type=ActivityTypes.message), + retry_prompt=Activity(type=ActivityTypes.message), + ), + ) + assert ( + dialog_context.active_dialog.state["options"].prompt.input_hint + == InputHints.accepting_input + ) + assert ( + dialog_context.active_dialog.state["options"].retry_prompt.input_hint + == InputHints.accepting_input + ) + await convo_state.save(turn_context) + called = True + + adapter = DialogTestAdapter(callback_handler) + await adapter.send("Hello") + assert called + + @pytest.mark.asyncio + async def test_should_end_oauth_prompt_on_invalid_message_when_end_on_invalid_message( + self, + ): + connection_name = "myConnection" + token = "abc123" + magic_code = "888999" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add( + OAuthPrompt( + "prompt", + OAuthPromptSettings(connection_name, "Login", None, 300000, None, True), + ) + ) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.prompt("prompt", PromptOptions()) + elif results.status == DialogTurnStatus.Complete: + if results.result and results.result.token: + await turn_context.send_activity("Failed") + else: + await turn_context.send_activity("Ended") + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + def inspector(activity: Activity, description: str = None): + assert len(activity.attachments) == 1 + assert ( + activity.attachments[0].content_type + == CardFactory.content_types.oauth_card + ) + adapter.add_user_token( + connection_name, + activity.channel_id, + activity.recipient.id, + token, + magic_code, + ) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply(inspector) + step3 = await step2.send("test invalid message") + await step3.assert_reply("Ended") + + @pytest.mark.asyncio + async def test_should_timeout_oauth_prompt_with_message_activity(self): + activity = Activity(type=ActivityTypes.message, text="any") + await self._run_timeout_test(activity) + + @pytest.mark.asyncio + async def test_should_timeout_oauth_prompt_with_token_response_event_activity(self): + activity = Activity( + type=ActivityTypes.event, name=SignInConstants.token_response_event_name + ) + await self._run_timeout_test(activity) + + @pytest.mark.asyncio + async def test_should_timeout_oauth_prompt_with_verify_state_operation_activity( + self, + ): + activity = Activity( + type=ActivityTypes.invoke, name=SignInConstants.verify_state_operation_name + ) + await self._run_timeout_test(activity) + + @pytest.mark.asyncio + async def test_should_not_timeout_oauth_prompt_with_custom_event_activity(self): + activity = Activity(type=ActivityTypes.event, name="custom event name") + await self._run_timeout_test(activity, False, "Ended", "Failed") + + async def _run_timeout_test( + self, + activity: Activity, + should_succeed: bool = True, + token_response: str = "Failed", + no_token_response: str = "Ended", + ): + connection_name = "myConnection" + token = "abc123" + magic_code = "888999" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add( + OAuthPrompt( + "prompt", + OAuthPromptSettings(connection_name, "Login", None, 1), + ) + ) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.prompt("prompt", PromptOptions()) + elif results.status == DialogTurnStatus.Complete or ( + results.status == DialogTurnStatus.Waiting and not should_succeed + ): + if results.result and results.result.token: + await turn_context.send_activity(token_response) + else: + await turn_context.send_activity(no_token_response) + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + def inspector(activity_: Activity, description: str = None): + assert len(activity_.attachments) == 1 + assert ( + activity_.attachments[0].content_type + == CardFactory.content_types.oauth_card + ) + adapter.add_user_token( + connection_name, + activity_.channel_id, + activity_.recipient.id, + token, + magic_code, + ) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply(inspector) + step3 = await step2.send(activity) + await step3.assert_reply(no_token_response) + + +class TestOAuthPromptSettings: + def test_oauth_app_credentials_stored_correctly(self): + """Constructor param oauth_app_credentials must be accessible as oauth_app_credentials.""" + sentinel = object() + settings = OAuthPromptSettings("conn", "title", oauth_app_credentials=sentinel) + assert settings.oauth_app_credentials is sentinel + + def test_oath_typo_alias_reads_same_value(self): + """oath_app_credentials (typo alias) must return the same value as oauth_app_credentials.""" + sentinel = object() + settings = OAuthPromptSettings("conn", "title", oauth_app_credentials=sentinel) + assert settings.oath_app_credentials is sentinel + + def test_oath_typo_alias_setter_writes_to_canonical(self): + """Writing via the typo alias updates oauth_app_credentials.""" + sentinel = object() + settings = OAuthPromptSettings("conn", "title") + settings.oath_app_credentials = sentinel + assert settings.oauth_app_credentials is sentinel diff --git a/dev/hosting_dialogs/test_object_path.py b/dev/hosting_dialogs/test_object_path.py new file mode 100644 index 00000000..db7ff202 --- /dev/null +++ b/dev/hosting_dialogs/test_object_path.py @@ -0,0 +1,209 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from microsoft_agents.hosting.dialogs import ObjectPath + + +class Location: + def __init__(self, lat: float = None, long: float = None): + self.lat = lat + self.long = long + + +class Options: + def __init__( + self, + first_name: str = None, + last_name: str = None, + age: int = None, + boolean: bool = None, + dictionary: dict = None, + location: Location = None, + ): + self.first_name = first_name + self.last_name = last_name + self.age = age + self.boolean = boolean + self.dictionary = dictionary + self.location = location + + +class TestObjectPath: + def test_typed_only_default(self): + default_options = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location(lat=1.2312312, long=3.234234), + ) + overlay = Options() + result = ObjectPath.assign(default_options, overlay) + assert result.last_name == default_options.last_name + assert result.first_name == default_options.first_name + assert result.age == default_options.age + assert result.boolean == default_options.boolean + assert result.location.lat == default_options.location.lat + assert result.location.long == default_options.location.long + + def test_typed_only_overlay(self): + default_options = Options() + overlay = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location(lat=1.2312312, long=3.234234), + ) + result = ObjectPath.assign(default_options, overlay) + assert result.last_name == overlay.last_name + assert result.first_name == overlay.first_name + assert result.age == overlay.age + assert result.boolean == overlay.boolean + assert result.location.lat == overlay.location.lat + assert result.location.long == overlay.location.long + + def test_typed_full_overlay(self): + default_options = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location(lat=1.2312312, long=3.234234), + dictionary={"one": 1, "two": 2}, + ) + overlay = Options( + last_name="Grant", + first_name="Eddit", + age=32, + location=Location(lat=2.2312312, long=2.234234), + dictionary={"one": 99, "three": 3}, + ) + result = ObjectPath.assign(default_options, overlay) + assert result.last_name == overlay.last_name + assert result.first_name == overlay.first_name + assert result.age == overlay.age + assert result.boolean == overlay.boolean + assert result.location.lat == overlay.location.lat + assert result.location.long == overlay.location.long + assert "one" in result.dictionary + assert result.dictionary["one"] == 99 + assert "two" in result.dictionary + assert "three" in result.dictionary + + def test_typed_partial_overlay(self): + default_options = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location(lat=1.2312312, long=3.234234), + ) + overlay = Options(last_name="Grant") + result = ObjectPath.assign(default_options, overlay) + assert result.last_name == overlay.last_name + assert result.first_name == default_options.first_name + assert result.age == default_options.age + assert result.boolean == default_options.boolean + assert result.location.lat == default_options.location.lat + assert result.location.long == default_options.location.long + + def test_typed_no_target(self): + overlay = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location(lat=1.2312312, long=3.234234), + ) + result = ObjectPath.assign(None, overlay) + assert result.last_name == overlay.last_name + assert result.first_name == overlay.first_name + assert result.age == overlay.age + assert result.boolean == overlay.boolean + assert result.location.lat == overlay.location.lat + assert result.location.long == overlay.location.long + + def test_typed_no_overlay(self): + default_options = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location(lat=1.2312312, long=3.234234), + ) + result = ObjectPath.assign(default_options, None) + assert result.last_name == default_options.last_name + assert result.first_name == default_options.first_name + assert result.age == default_options.age + assert result.boolean == default_options.boolean + assert result.location.lat == default_options.location.lat + assert result.location.long == default_options.location.long + + def test_no_target_or_overlay(self): + result = ObjectPath.assign(None, None, Options) + assert result + + def test_dict_partial_overlay(self): + default_options = { + "last_name": "Smith", + "first_name": "Fred", + "age": 22, + "location": Location(lat=1.2312312, long=3.234234), + } + overlay = {"last_name": "Grant"} + result = ObjectPath.assign(default_options, overlay) + assert result["last_name"] == overlay["last_name"] + assert result["first_name"] == default_options["first_name"] + assert result["age"] == default_options["age"] + assert result["location"].lat == default_options["location"].lat + assert result["location"].long == default_options["location"].long + + def test_dict_to_typed_overlay(self): + default_options = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location(lat=1.2312312, long=3.234234), + ) + overlay = {"last_name": "Grant"} + result = ObjectPath.assign(default_options, overlay) + assert result.last_name == overlay["last_name"] + assert result.first_name == default_options.first_name + assert result.age == default_options.age + assert result.boolean == default_options.boolean + assert result.location.lat == default_options.location.lat + assert result.location.long == default_options.location.long + + def test_set_value(self): + test = {} + ObjectPath.set_path_value(test, "x.y.z", 15) + ObjectPath.set_path_value(test, "x.p", "hello") + ObjectPath.set_path_value(test, "foo", {"Bar": 15, "Blat": "yo"}) + ObjectPath.set_path_value(test, "x.a[1]", "yabba") + ObjectPath.set_path_value(test, "x.a[0]", "dabba") + ObjectPath.set_path_value(test, "null", None) + + assert ObjectPath.get_path_value(test, "x.y.z") == 15 + assert ObjectPath.get_path_value(test, "x.p") == "hello" + assert ObjectPath.get_path_value(test, "foo.bar") == 15 + + assert not ObjectPath.try_get_path_value(test, "foo.Blatxxx") + assert ObjectPath.try_get_path_value(test, "x.a[1]") == "yabba" + assert ObjectPath.try_get_path_value(test, "x.a[0]") == "dabba" + + assert not ObjectPath.try_get_path_value(test, "null") + + def test_remove_path_value(self): + test = {} + ObjectPath.set_path_value(test, "x.y.z", 15) + ObjectPath.set_path_value(test, "x.p", "hello") + ObjectPath.set_path_value(test, "foo", {"Bar": 15, "Blat": "yo"}) + ObjectPath.set_path_value(test, "x.a[1]", "yabba") + ObjectPath.set_path_value(test, "x.a[0]", "dabba") + + ObjectPath.remove_path_value(test, "x.y.z") + with pytest.raises(KeyError): + ObjectPath.get_path_value(test, "x.y.z") + + assert ObjectPath.get_path_value(test, "x.y.z", 99) == 99 + + ObjectPath.remove_path_value(test, "x.a[1]") + assert not ObjectPath.try_get_path_value(test, "x.a[1]") + + assert ObjectPath.try_get_path_value(test, "x.a[0]") == "dabba" diff --git a/dev/hosting_dialogs/test_prompt_culture_models.py b/dev/hosting_dialogs/test_prompt_culture_models.py new file mode 100644 index 00000000..138d3a7e --- /dev/null +++ b/dev/hosting_dialogs/test_prompt_culture_models.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from microsoft_agents.hosting.dialogs.prompts.prompt_culture_models import ( + PromptCultureModels, +) + +SUPPORTED_CULTURES = [ + PromptCultureModels.Bulgarian, + PromptCultureModels.Chinese, + PromptCultureModels.Dutch, + PromptCultureModels.English, + PromptCultureModels.French, + PromptCultureModels.German, + PromptCultureModels.Hindi, + PromptCultureModels.Italian, + PromptCultureModels.Japanese, + PromptCultureModels.Korean, + PromptCultureModels.Portuguese, + PromptCultureModels.Spanish, + PromptCultureModels.Swedish, + PromptCultureModels.Turkish, +] + + +def _locale_variations(culture): + """Generate (input_variation, expected_locale) tuples for a culture.""" + locale = culture.locale # e.g. "en-us" + parts = locale.split("-") + prefix = parts[0] + suffix = parts[1] if len(parts) > 1 else "" + return [ + (locale, locale), # exact: "en-us" + (f"{prefix}-{suffix.upper()}", locale), # cap ending: "en-US" + (f"{prefix.capitalize()}-{suffix.capitalize()}", locale), # title: "En-Us" + (prefix.upper(), locale), # all-caps two-letter: "EN" + (prefix, locale), # lowercase two-letter: "en" + ] + + +LOCALE_VARIATIONS = [ + variation + for culture in SUPPORTED_CULTURES + for variation in _locale_variations(culture) +] + + +@pytest.mark.parametrize("locale_variation,expected", LOCALE_VARIATIONS) +def test_map_to_nearest_language(locale_variation, expected): + result = PromptCultureModels.map_to_nearest_language(locale_variation) + assert result == expected + + +def test_null_locale_does_not_raise(): + result = PromptCultureModels.map_to_nearest_language(None) + assert result is None + + +def test_get_supported_cultures_returns_all(): + expected_locales = {c.locale for c in SUPPORTED_CULTURES} + actual_locales = {c.locale for c in PromptCultureModels.get_supported_cultures()} + assert expected_locales == actual_locales + + +def test_supported_cultures_have_required_fields(): + for culture in PromptCultureModels.get_supported_cultures(): + assert culture.locale, f"Culture missing locale" + assert culture.separator, f"Culture {culture.locale} missing separator" + assert culture.inline_or, f"Culture {culture.locale} missing inline_or" + assert ( + culture.yes_in_language + ), f"Culture {culture.locale} missing yes_in_language" + assert ( + culture.no_in_language + ), f"Culture {culture.locale} missing no_in_language" diff --git a/dev/hosting_dialogs/test_prompt_validator_context.py b/dev/hosting_dialogs/test_prompt_validator_context.py new file mode 100644 index 00000000..69a3e695 --- /dev/null +++ b/dev/hosting_dialogs/test_prompt_validator_context.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus +from microsoft_agents.hosting.core import MemoryStorage, ConversationState +from microsoft_agents.hosting.dialogs.prompts import ( + TextPrompt, + PromptOptions, + PromptValidatorContext, +) +from microsoft_agents.activity import Activity, ActivityTypes +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class TestPromptValidatorContext: + @pytest.mark.asyncio + async def test_prompt_validator_context_end(self): + storage = MemoryStorage() + conv = ConversationState(storage) + accessor = conv.create_property("dialogstate") + dialog_set = DialogSet(accessor) + assert dialog_set is not None + + def test_prompt_validator_context_retry_end(self): + storage = MemoryStorage() + conv = ConversationState(storage) + accessor = conv.create_property("dialogstate") + dialog_set = DialogSet(accessor) + assert dialog_set is not None + + @pytest.mark.asyncio + async def test_attempt_count_starts_at_one_on_first_validation(self): + """attempt_count is 1 on the first validator call and increments on each + subsequent user reply (mirrors ActivityPrompt behaviour).""" + observed_attempt_counts = [] + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + async def validator(pc: PromptValidatorContext) -> bool: + observed_attempt_counts.append(pc.attempt_count) + return bool(pc.recognized.value) + + ds.add(TextPrompt("TextPrompt", validator)) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter text."), + retry_prompt=Activity(type=ActivityTypes.message, text="Again."), + ) + await dc.prompt("TextPrompt", options) + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + await flow.assert_reply("Enter text.") + await adapter.send("something") + + assert observed_attempt_counts == [1] + + @pytest.mark.asyncio + async def test_attempt_count_increments_across_retries(self): + """attempt_count increments with each user reply, so retry #1 = 2, retry #2 = 3, etc.""" + observed_attempt_counts = [] + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + async def validator(pc: PromptValidatorContext) -> bool: + observed_attempt_counts.append(pc.attempt_count) + # Only accept the literal word "yes" + return pc.recognized.succeeded and pc.recognized.value == "yes" + + ds.add(TextPrompt("TextPrompt", validator)) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Say yes."), + retry_prompt=Activity( + type=ActivityTypes.message, text="Please say yes." + ), + ) + await dc.prompt("TextPrompt", options) + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("start") + await flow.assert_reply("Say yes.") + await adapter.send("no") # attempt 1 — rejected + await adapter.send("nope") # attempt 2 — rejected + await adapter.send("yes") # attempt 3 — accepted + + assert observed_attempt_counts == [1, 2, 3] diff --git a/dev/hosting_dialogs/test_replace_dialog.py b/dev/hosting_dialogs/test_replace_dialog.py new file mode 100644 index 00000000..86ebb8ec --- /dev/null +++ b/dev/hosting_dialogs/test_replace_dialog.py @@ -0,0 +1,136 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import ConversationState, MemoryStorage, TurnContext +from microsoft_agents.hosting.dialogs import ( + ComponentDialog, + DialogSet, + DialogTurnStatus, + WaterfallDialog, +) +from microsoft_agents.hosting.dialogs.models.dialog_instance import DialogInstance +from microsoft_agents.hosting.dialogs.models.dialog_reason import DialogReason +from microsoft_agents.hosting.dialogs.prompts import TextPrompt, PromptOptions +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +def _text_prompt_options(text: str) -> PromptOptions: + return PromptOptions(prompt=Activity(type=ActivityTypes.message, text=text)) + + +class _WaterfallWithEndDialog(WaterfallDialog): + """WaterfallDialog that announces itself when it ends.""" + + async def end_dialog( + self, context: TurnContext, instance: DialogInstance, reason: DialogReason + ): + await context.send_activity("*** WaterfallDialog End ***") + await super().end_dialog(context, instance, reason) + + +class _SecondDialog(ComponentDialog): + def __init__(self): + super().__init__("SecondDialog") + + async def action_four(step): + return await step.prompt("TextPrompt", _text_prompt_options("prompt four")) + + async def action_five(step): + return await step.prompt("TextPrompt", _text_prompt_options("prompt five")) + + async def last_action(step): + return await step.end_dialog() + + self.add_dialog(TextPrompt("TextPrompt")) + self.add_dialog( + WaterfallDialog("WaterfallDialog", [action_four, action_five, last_action]) + ) + self.initial_dialog_id = "WaterfallDialog" + + +class _FirstDialog(ComponentDialog): + def __init__(self): + super().__init__("FirstDialog") + + async def action_one(step): + return await step.prompt("TextPrompt", _text_prompt_options("prompt one")) + + async def action_two(step): + return await step.prompt("TextPrompt", _text_prompt_options("prompt two")) + + async def replace_action(step): + if step.result == "replace": + return await step.replace_dialog("SecondDialog") + return await step.next(None) + + async def action_three(step): + return await step.prompt("TextPrompt", _text_prompt_options("prompt three")) + + async def last_action(step): + return await step.end_dialog() + + self.add_dialog(TextPrompt("TextPrompt")) + self.add_dialog(_SecondDialog()) + self.add_dialog( + _WaterfallWithEndDialog( + "WaterfallWithEndDialog", + [action_one, action_two, replace_action, action_three, last_action], + ) + ) + self.initial_dialog_id = "WaterfallWithEndDialog" + + +class TestReplaceDialog: + @pytest.mark.asyncio + async def test_replace_dialog_no_branch(self): + """Dialog flows through all three prompts without branching.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + ds.add(_FirstDialog()) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("FirstDialog") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("prompt one") + flow = await flow.send("hello") + flow = await flow.assert_reply("prompt two") + flow = await flow.send("hello") + flow = await flow.assert_reply("prompt three") + flow = await flow.send("hello") + await flow.assert_reply("*** WaterfallDialog End ***") + + @pytest.mark.asyncio + async def test_replace_dialog_branch(self): + """Sending 'replace' causes replace_dialog to switch to SecondDialog.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + ds.add(_FirstDialog()) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("FirstDialog") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("prompt one") + flow = await flow.send("hello") + flow = await flow.assert_reply("prompt two") + flow = await flow.send("replace") + flow = await flow.assert_reply("*** WaterfallDialog End ***") + flow = await flow.assert_reply("prompt four") + flow = await flow.send("hello") + await flow.assert_reply("prompt five") diff --git a/dev/hosting_dialogs/test_text_prompt.py b/dev/hosting_dialogs/test_text_prompt.py new file mode 100644 index 00000000..6103d909 --- /dev/null +++ b/dev/hosting_dialogs/test_text_prompt.py @@ -0,0 +1,195 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import ConversationState, MemoryStorage +from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus +from microsoft_agents.hosting.dialogs.prompts import ( + TextPrompt, + PromptOptions, + PromptValidatorContext, +) +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class TestTextPrompt: + def test_empty_id_raises(self): + with pytest.raises((TypeError, Exception)): + TextPrompt("") + + def test_null_id_raises(self): + with pytest.raises((TypeError, Exception)): + TextPrompt(None) + + @pytest.mark.asyncio + async def test_basic_text_prompt(self): + """TextPrompt echoes user input back after the prompt.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + ds.add(TextPrompt("TextPrompt")) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter some text.") + ) + await dc.prompt("TextPrompt", options) + elif results.status == DialogTurnStatus.Complete: + await tc.send_activity(f"Bot received: {results.result}") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("Enter some text.") + flow = await flow.send("some text") + await flow.assert_reply("Bot received: some text") + + @pytest.mark.asyncio + async def test_text_prompt_with_validator(self): + """A validator can reject short inputs and trigger the retry prompt.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + async def validator(pc: PromptValidatorContext) -> bool: + return pc.recognized.value is not None and len(pc.recognized.value) > 3 + + ds.add(TextPrompt("TextPrompt", validator)) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Enter some text." + ), + retry_prompt=Activity( + type=ActivityTypes.message, text="That's not long enough." + ), + ) + await dc.prompt("TextPrompt", options) + elif results.status == DialogTurnStatus.Complete: + await tc.send_activity(f"Bot received: {results.result}") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("Enter some text.") + flow = await flow.send("hi") # Too short — validator rejects + flow = await flow.assert_reply("That's not long enough.") + flow = await flow.send("hello world") # Long enough + await flow.assert_reply("Bot received: hello world") + + @pytest.mark.asyncio + async def test_text_prompt_retry_on_failed_validation(self): + """Without a retry_prompt the original prompt is re-sent on failure.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + async def validator(pc: PromptValidatorContext) -> bool: + return pc.recognized.value is not None and pc.recognized.value != "bad" + + ds.add(TextPrompt("TextPrompt", validator)) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Enter something." + ), + ) + await dc.prompt("TextPrompt", options) + elif results.status == DialogTurnStatus.Complete: + await tc.send_activity(f"Got: {results.result}") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("Enter something.") + flow = await flow.send("bad") # Rejected by validator + flow = await flow.assert_reply("Enter something.") # Re-prompted + flow = await flow.send("good") + await flow.assert_reply("Got: good") + + @pytest.mark.asyncio + async def test_text_prompt_non_message_activity_does_not_succeed(self): + """A non-message activity (e.g. event) causes recognition to fail. + + The base Prompt.continue_dialog returns end_of_turn immediately for + non-message activities without calling the recognizer or validator. + The dialog remains active (Waiting) and the user is not re-prompted. + """ + from microsoft_agents.activity import ActivityTypes + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + ds.add(TextPrompt("TextPrompt")) + + completed = [] + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter something.") + ) + await dc.prompt("TextPrompt", options) + elif results.status == DialogTurnStatus.Complete: + completed.append(results.result) + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + # Start the prompt + flow = await adapter.send("hello") + await flow.assert_reply("Enter something.") + + # Send a non-message event — dialog must NOT complete + event_activity = Activity(type=ActivityTypes.event, name="custom") + await adapter.process_activity_async(event_activity, exec) + assert len(completed) == 0, "Prompt must not complete on non-message activity" + + @pytest.mark.asyncio + async def test_text_prompt_with_custom_message_validator(self): + """Validator can send its own message and return False.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + async def validator(pc: PromptValidatorContext) -> bool: + if pc.recognized.value and len(pc.recognized.value) > 5: + return True + await pc.context.send_activity("Please enter more than 5 characters.") + return False + + ds.add(TextPrompt("TextPrompt", validator)) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter text."), + ) + await dc.prompt("TextPrompt", options) + elif results.status == DialogTurnStatus.Complete: + await tc.send_activity(f"Done: {results.result}") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("Enter text.") + flow = await flow.send("hi") + flow = await flow.assert_reply("Please enter more than 5 characters.") + flow = await flow.send("hello world") + await flow.assert_reply("Done: hello world") diff --git a/dev/hosting_dialogs/test_waterfall.py b/dev/hosting_dialogs/test_waterfall.py new file mode 100644 index 00000000..c07d1b5a --- /dev/null +++ b/dev/hosting_dialogs/test_waterfall.py @@ -0,0 +1,314 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from recognizers_text import Culture + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import ConversationState, MemoryStorage, TurnContext +from microsoft_agents.hosting.dialogs import ( + ComponentDialog, + Dialog, + DialogSet, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, + DialogTurnStatus, +) +from microsoft_agents.hosting.dialogs.prompts import ( + NumberPrompt, + DateTimePrompt, + PromptOptions, +) +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class TestWaterfallDialog: + def test_waterfall_none_name(self): + with pytest.raises((TypeError, Exception)): + WaterfallDialog(None) + + def test_waterfall_add_none_step(self): + waterfall = WaterfallDialog("test") + with pytest.raises((TypeError, Exception)): + waterfall.add_step(None) + + def test_waterfall_with_set_instead_of_array(self): + with pytest.raises((TypeError, Exception)): + WaterfallDialog("a", {1, 2}) + + @pytest.mark.asyncio + async def test_execute_sequence_waterfall_steps(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + async def step1(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("bot responding.") + return Dialog.end_of_turn + + async def step2(step: WaterfallStepContext) -> DialogTurnResult: + return await step.end_dialog("ending WaterfallDialog.") + + my_dialog = WaterfallDialog("test", [step1, step2]) + dialogs.add(my_dialog) + + async def exec_test(turn_context: TurnContext) -> None: + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog("test") + else: + if results.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + await convo_state.save(turn_context) + + adapter = DialogTestAdapter(exec_test) + + step1_flow = await adapter.send("begin") + step2_flow = await step1_flow.assert_reply("bot responding.") + step3_flow = await step2_flow.send("continue") + await step3_flow.assert_reply("ending WaterfallDialog.") + + def test_waterfall_callback(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + async def step_callback1(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step1") + + async def step_callback2(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step2") + + async def step_callback3(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step3") + + steps = [step_callback1, step_callback2, step_callback3] + dialogs.add(WaterfallDialog("test", steps)) + assert dialogs is not None + assert len(dialogs._dialogs) == 1 # pylint: disable=protected-access + + def test_waterfall_with_class(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + class MyWaterfallDialog(WaterfallDialog): + def __init__(self, dialog_id: str): + async def step1(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step1") + return Dialog.end_of_turn + + async def step2(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step2") + return Dialog.end_of_turn + + super().__init__(dialog_id, [step1, step2]) + + dialogs.add(MyWaterfallDialog("test")) + assert dialogs is not None + assert len(dialogs._dialogs) == 1 # pylint: disable=protected-access + + @pytest.mark.asyncio + async def test_waterfall_step_parent_is_waterfall_parent(self): + """WaterfallStepContext.parent should be the ComponentDialog containing the waterfall.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + + result_holder = {} + + class WaterfallParentDialog(ComponentDialog): + def __init__(self): + super().__init__("waterfall-parent-test-dialog") + + async def step1(step: WaterfallStepContext) -> DialogTurnResult: + # Parent context should have the component dialog as its active dialog + parent_id = ( + step.parent.active_dialog.id + if step.parent and step.parent.active_dialog + else None + ) + result_holder["parent_id"] = parent_id + await step.context.send_activity("verified") + return Dialog.end_of_turn + + self.add_dialog(WaterfallDialog("test", [step1])) + self.initial_dialog_id = "test" + + ds = DialogSet(dialog_state) + ds.add(WaterfallParentDialog()) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("waterfall-parent-test-dialog") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + await flow.assert_reply("verified") + + assert result_holder.get("parent_id") == "waterfall-parent-test-dialog" + + @pytest.mark.asyncio + async def test_waterfall_prompt(self): + """Waterfall with NumberPrompt: invalid inputs trigger retry, valid inputs advance steps.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + + async def step1(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step1") + return await step.prompt( + "number", + PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter a number."), + retry_prompt=Activity( + type=ActivityTypes.message, text="It must be a number" + ), + ), + ) + + async def step2(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity(f"Thanks for '{int(step.result)}'") + await step.context.send_activity("step2") + return await step.prompt( + "number", + PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Enter a number."), + retry_prompt=Activity( + type=ActivityTypes.message, text="It must be a number" + ), + ), + ) + + async def step3(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity(f"Thanks for '{int(step.result)}'") + await step.context.send_activity("step3") + return await step.end_dialog() + + ds = DialogSet(dialog_state) + ds.add(WaterfallDialog("test-waterfall", [step1, step2, step3])) + ds.add(NumberPrompt("number", default_locale=Culture.English)) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("test-waterfall") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("step1") + flow = await flow.assert_reply("Enter a number.") + flow = await flow.send("hello again") + flow = await flow.assert_reply("It must be a number") + flow = await flow.send("42") + flow = await flow.assert_reply("Thanks for '42'") + flow = await flow.assert_reply("step2") + flow = await flow.assert_reply("Enter a number.") + flow = await flow.send("apple") + flow = await flow.assert_reply("It must be a number") + flow = await flow.send("orange") + flow = await flow.assert_reply("It must be a number") + flow = await flow.send("64") + flow = await flow.assert_reply("Thanks for '64'") + await flow.assert_reply("step3") + + @pytest.mark.asyncio + async def test_waterfall_nested(self): + """Nested waterfall dialogs chain correctly when each ends.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + + async def waterfall_a_step1(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step1") + return await step.begin_dialog("test-waterfall-b") + + async def waterfall_a_step2(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step2") + return await step.begin_dialog("test-waterfall-c") + + async def waterfall_b_step1(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step1.1") + return Dialog.end_of_turn + + async def waterfall_b_step2(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step1.2") + return Dialog.end_of_turn + + async def waterfall_c_step1(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step2.1") + return Dialog.end_of_turn + + async def waterfall_c_step2(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step2.2") + return await step.end_dialog() + + ds = DialogSet(dialog_state) + ds.add( + WaterfallDialog("test-waterfall-a", [waterfall_a_step1, waterfall_a_step2]) + ) + ds.add( + WaterfallDialog("test-waterfall-b", [waterfall_b_step1, waterfall_b_step2]) + ) + ds.add( + WaterfallDialog("test-waterfall-c", [waterfall_c_step1, waterfall_c_step2]) + ) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("test-waterfall-a") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("step1") + flow = await flow.assert_reply("step1.1") + flow = await flow.send("hello") + flow = await flow.assert_reply("step1.2") + flow = await flow.send("hello") + flow = await flow.assert_reply("step2") + flow = await flow.assert_reply("step2.1") + flow = await flow.send("hello") + await flow.assert_reply("step2.2") + + @pytest.mark.asyncio + async def test_waterfall_datetime_prompt_first_invalid_then_valid(self): + """DateTimePrompt re-prompts on invalid input and accepts valid date/time.""" + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + + async def step1(step: WaterfallStepContext) -> DialogTurnResult: + return await step.prompt( + "dateTimePrompt", + PromptOptions( + prompt=Activity(type=ActivityTypes.message, text="Provide a date") + ), + ) + + async def step2(step: WaterfallStepContext) -> DialogTurnResult: + assert step.result is not None + return await step.end_dialog() + + ds = DialogSet(dialog_state) + ds.add(DateTimePrompt("dateTimePrompt", default_locale=Culture.English)) + ds.add(WaterfallDialog("test-dateTimePrompt", [step1, step2])) + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog("test-dateTimePrompt") + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + flow = await adapter.send("hello") + flow = await flow.assert_reply("Provide a date") + flow = await flow.send("hello again") + flow = await flow.assert_reply("Provide a date") + await flow.send("Wednesday 4 oclock") diff --git a/dev/hosting_dialogs/test_waterfall_dialog.py b/dev/hosting_dialogs/test_waterfall_dialog.py new file mode 100644 index 00000000..de56539f --- /dev/null +++ b/dev/hosting_dialogs/test_waterfall_dialog.py @@ -0,0 +1,171 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from unittest.mock import MagicMock + +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.hosting.dialogs import ( + ComponentDialog, + Dialog, + DialogContext, + DialogReason, + DialogSet, + DialogState, + DialogTurnResult, + DialogTurnStatus, + WaterfallDialog, +) + + +def _make_dc(activity_type="message"): + """Create a minimal DialogContext with the given activity type.""" + + class _Stub(ComponentDialog): + def __init__(self): + super().__init__("stub") + + ds = _Stub()._dialogs + mock_tc = MagicMock() + mock_tc.activity.type = activity_type + return DialogContext(ds, mock_tc, DialogState()) + + +class TestWaterfallDialogValidation: + @pytest.mark.asyncio + async def test_begin_dialog_null_dc_raises(self): + dialog = WaterfallDialog("A", []) + with pytest.raises((TypeError, Exception)): + await dialog.begin_dialog(None) + + @pytest.mark.asyncio + async def test_continue_dialog_null_dc_raises(self): + dialog = WaterfallDialog("A", []) + with pytest.raises((TypeError, Exception)): + await dialog.continue_dialog(None) + + @pytest.mark.asyncio + async def test_continue_dialog_returns_waiting_for_non_message_activity(self): + """WaterfallDialog.continue_dialog returns end_of_turn for non-message activities.""" + dialog = WaterfallDialog("A", []) + dc = _make_dc(activity_type=ActivityTypes.event) + + result = await dialog.continue_dialog(dc) + + # end_of_turn is DialogTurnResult(Waiting) + assert result.status == DialogTurnStatus.Waiting + + @pytest.mark.asyncio + async def test_resume_dialog_null_dc_raises(self): + dialog = WaterfallDialog("A", []) + with pytest.raises((TypeError, Exception)): + await dialog.resume_dialog(None, DialogReason.BeginCalled, "result") + + @pytest.mark.asyncio + async def test_run_step_null_dc_raises(self): + dialog = WaterfallDialog("A", []) + with pytest.raises((TypeError, Exception)): + await dialog.run_step(None, 0, DialogReason.BeginCalled, None) + + def test_none_name_raises(self): + with pytest.raises((TypeError, Exception)): + WaterfallDialog(None, []) + + def test_add_none_step_raises(self): + dialog = WaterfallDialog("A", []) + with pytest.raises((TypeError, Exception)): + dialog.add_step(None) + + def test_steps_must_be_list(self): + with pytest.raises((TypeError, Exception)): + WaterfallDialog("A", {1, 2}) + + @pytest.mark.asyncio + async def test_empty_steps_completes_immediately(self): + """WaterfallDialog with no steps ends immediately with None result.""" + from microsoft_agents.hosting.core import ConversationState, MemoryStorage + from microsoft_agents.hosting.dialogs import DialogSet + from tests.hosting_dialogs.helpers import DialogTestAdapter + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + dialogs.add(WaterfallDialog("empty-waterfall", [])) + + result_holder = {} + + async def exec(tc): + dc = await dialogs.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + results = await dc.begin_dialog("empty-waterfall") + result_holder["status"] = results.status + result_holder["result"] = results.result + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + await adapter.send("hi") + assert result_holder["status"] == DialogTurnStatus.Complete + assert result_holder["result"] is None + + @pytest.mark.asyncio + async def test_continue_dialog_non_message_returns_waiting(self): + """WaterfallDialog.continue_dialog ignores non-message activities.""" + dialog = WaterfallDialog("A", [lambda step: Dialog.end_of_turn]) + dc = _make_dc(activity_type=ActivityTypes.event) + result = await dialog.continue_dialog(dc) + assert result.status == DialogTurnStatus.Waiting + + +class TestWaterfallStepName: + def test_get_step_name_named_function_uses_qualname(self): + """Named step functions expose their __qualname__ as the step name.""" + + async def my_named_step(step): + return Dialog.end_of_turn + + dialog = WaterfallDialog("A", [my_named_step]) + assert "my_named_step" in dialog.get_step_name(0) + + def test_get_step_name_lambda_uses_fallback_format(self): + """Lambda steps fall back to 'Step{n}of{total}' because their __qualname__ ends in ''.""" + dialog = WaterfallDialog("A", [lambda s: None, lambda s: None]) + assert dialog.get_step_name(0) == "Step1of2" + assert dialog.get_step_name(1) == "Step2of2" + + +class TestWaterfallStepNoneReturn: + @pytest.mark.asyncio + async def test_step_returning_none_raises_type_error(self): + """A step returning None raises a clear TypeError instead of a confusing AttributeError.""" + from microsoft_agents.hosting.core import ConversationState, MemoryStorage + from microsoft_agents.hosting.dialogs import DialogSet + from tests.hosting_dialogs.helpers import DialogTestAdapter + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + ds = DialogSet(dialog_state) + + async def bad_step(step): + return None # programmer forgot to return Dialog.end_of_turn + + ds.add(WaterfallDialog("bad-waterfall", [bad_step])) + + error_holder = {} + + async def exec(tc): + dc = await ds.create_context(tc) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + try: + await dc.begin_dialog("bad-waterfall") + except TypeError as e: + error_holder["error"] = e + await convo_state.save(tc) + + adapter = DialogTestAdapter(exec) + await adapter.send("hi") + + assert "error" in error_holder, "Expected TypeError but none was raised" + error_msg = str(error_holder["error"]).lower() + assert "none" in error_msg or "step" in error_msg diff --git a/dev/hosting_dialogs/test_waterfall_step_context.py b/dev/hosting_dialogs/test_waterfall_step_context.py new file mode 100644 index 00000000..1f040946 --- /dev/null +++ b/dev/hosting_dialogs/test_waterfall_step_context.py @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from microsoft_agents.hosting.dialogs import ( + ComponentDialog, + DialogContext, + DialogReason, + DialogSet, + DialogState, + DialogTurnResult, + DialogTurnStatus, + WaterfallDialog, +) +from microsoft_agents.hosting.dialogs.waterfall_step_context import WaterfallStepContext + + +def _make_step_context(): + """Create a WaterfallStepContext backed by mock objects.""" + + class _Stub(ComponentDialog): + def __init__(self): + super().__init__("stub") + + ds = _Stub()._dialogs + mock_tc = MagicMock() + dc = DialogContext(ds, mock_tc, DialogState()) + + wf_dialog = WaterfallDialog("wf", []) + wf_dialog.resume_dialog = AsyncMock( + return_value=DialogTurnResult(DialogTurnStatus.Complete) + ) + + return WaterfallStepContext(wf_dialog, dc, None, {}, 0, DialogReason.BeginCalled) + + +class TestWaterfallStepContext: + @pytest.mark.asyncio + async def test_next_called_twice_raises(self): + """Calling next() a second time on the same step context must raise.""" + step_ctx = _make_step_context() + + # First call succeeds + await step_ctx.next(None) + + # Second call raises + with pytest.raises(Exception, match="already called"): + await step_ctx.next(None) + + @pytest.mark.asyncio + async def test_next_calls_resume_on_parent_dialog(self): + """next() delegates to the parent WaterfallDialog's resume_dialog.""" + step_ctx = _make_step_context() + await step_ctx.next("my-result") + step_ctx._wf_parent.resume_dialog.assert_awaited_once() + + def test_step_context_properties(self): + """WaterfallStepContext exposes index, options, reason, result, values.""" + step_ctx = _make_step_context() + assert step_ctx.index == 0 + assert step_ctx.options is None + assert step_ctx.reason == DialogReason.BeginCalled + assert step_ctx.result is None + assert step_ctx.values == {} + + def test_step_context_with_result(self): + class _Stub(ComponentDialog): + def __init__(self): + super().__init__("stub") + + ds = _Stub()._dialogs + dc = DialogContext(ds, MagicMock(), DialogState()) + wf = WaterfallDialog("wf", []) + wf.resume_dialog = AsyncMock( + return_value=DialogTurnResult(DialogTurnStatus.Complete) + ) + + step_ctx = WaterfallStepContext( + wf, + dc, + {"key": "val"}, + {"v": 1}, + 2, + DialogReason.NextCalled, + "previous-result", + ) + + assert step_ctx.index == 2 + assert step_ctx.options == {"key": "val"} + assert step_ctx.values == {"v": 1} + assert step_ctx.result == "previous-result" + assert step_ctx.reason == DialogReason.NextCalled diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/__init__.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/__init__.py index 88a35498..0d3485e2 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/__init__.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/__init__.py @@ -18,6 +18,8 @@ from .dialog_state import DialogState from .models.dialog_turn_result import DialogTurnResult from .models.dialog_turn_status import DialogTurnStatus +from .dialog_manager import DialogManager +from .dialog_manager_result import DialogManagerResult from .dialog import Dialog from .dialog_component_registration import DialogsComponentRegistration from .persisted_state_keys import PersistedStateKeys @@ -49,6 +51,8 @@ "DialogState", "DialogTurnResult", "DialogTurnStatus", + "DialogManager", + "DialogManagerResult", "Dialog", "DialogsComponentRegistration", "WaterfallDialog", 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 new file mode 100644 index 00000000..c82c7310 --- /dev/null +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager.py @@ -0,0 +1,189 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime, timedelta +from threading import Lock +from typing import cast + +from microsoft_agents.hosting.core import ( + ChannelAdapter, + ConversationState, + UserState, + TurnContext, + ClaimsIdentity, +) + +from .dialog import Dialog +from .dialog_context import DialogContext +from .dialog_extensions import DialogExtensions +from .dialog_set import DialogSet +from .dialog_state import DialogState +from .dialog_manager_result import DialogManagerResult +from .models.dialog_turn_status import DialogTurnStatus +from .models.dialog_turn_result import DialogTurnResult + + +class DialogManager: + """ + Class which runs the dialog system. + """ + + def __init__( + self, + root_dialog: Dialog | None = None, + dialog_state_property: str = "DialogState", + ): + """ + Initializes an instance of the DialogManager class. + :param root_dialog: Root dialog to use. + :param dialog_state_property: Alternate name for the dialog_state property. (Default is "DialogState"). + """ + self.last_access = "_lastAccess" + self._root_dialog_id = "" + self._dialog_state_property = dialog_state_property + self._lock = Lock() + + # Gets or sets root dialog to use to start conversation. + self.root_dialog = root_dialog + + # Gets or sets the ConversationState. + self.conversation_state: ConversationState | None = None + + # Gets or sets the UserState. + self.user_state: UserState | None = None + + # Gets InitialTurnState collection to copy into the TurnState on every turn. + self.initial_turn_state = {} + + # Gets or sets global dialogs that you want to have be callable. + self.dialogs = DialogSet() + + # Gets or sets (optional) number of milliseconds to expire the bot's state after. + self.expire_after: int | None = None + + async def on_turn(self, context: TurnContext) -> DialogManagerResult: + """ + Runs dialog system in the context of a TurnContext. + :param context: turn context. + :return: + """ + # Lazy initialize RootDialog so it can refer to assets like LG function templates + if not self._root_dialog_id: + with self._lock: + if not self._root_dialog_id: + if self.root_dialog is None: + raise ValueError("DialogManager: root_dialog cannot be None.") + self._root_dialog_id = self.root_dialog.id + self.dialogs.add(self.root_dialog) + + # Preload TurnState with DM TurnState. + for key, val in self.initial_turn_state.items(): + context.turn_state[key] = val + + # Register DialogManager with TurnState. + context.turn_state[DialogManager.__name__] = self + + # Resolve ConversationState + conversation_state_name = ConversationState.__name__ + if self.conversation_state is None: + if conversation_state_name not in context.turn_state: + raise Exception( + f"Unable to get an instance of {conversation_state_name} from turn_context. " + f"Please ensure ConversationState is available in turn_state." + ) + self.conversation_state = cast( + ConversationState, context.turn_state[conversation_state_name] + ) + else: + context.turn_state[conversation_state_name] = self.conversation_state + + # Resolve UserState (optional) + user_state_name = UserState.__name__ + if self.user_state is None: + self.user_state = cast( + UserState | None, context.turn_state.get(user_state_name, None) + ) + else: + context.turn_state[user_state_name] = self.user_state + + # Create property accessors + last_access_property = self.conversation_state.create_property(self.last_access) + last_access: datetime = cast( + datetime, await last_access_property.get(context, datetime.now) + ) + + # Check for expired conversation + if self.expire_after is not None and ( + datetime.now() - last_access + ) >= timedelta(milliseconds=float(self.expire_after)): + # Clear conversation state + self.conversation_state.clear(context) + + last_access = datetime.now() + await last_access_property.set(context, last_access) + + # Get dialog stack + dialogs_property = self.conversation_state.create_property( + self._dialog_state_property + ) + dialog_state: DialogState = cast( + DialogState, await dialogs_property.get(context, DialogState) + ) + + # Create DialogContext + dialog_context = DialogContext(self.dialogs, context, dialog_state) + + # Call the common dialog "continue/begin" execution pattern shared with the classic RunAsync extension method + turn_result = ( + await DialogExtensions._internal_run( # pylint: disable=protected-access + context, self._root_dialog_id, dialog_context + ) + ) + + # Save ConversationState changes + await self.conversation_state.save(context, False) + + # Save UserState changes if present + if self.user_state is not None: + await self.user_state.save(context, False) + + return DialogManagerResult(turn_result=turn_result) + + @staticmethod + 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() + ) + + @staticmethod + def should_send_end_of_conversation_to_parent( + context: TurnContext, turn_result: DialogTurnResult + ) -> bool: + """ + Helper to determine if we should send an EndOfConversation to the parent or not. + """ + if not ( + turn_result.status == DialogTurnStatus.Complete + or turn_result.status == DialogTurnStatus.Cancelled + ): + # The dialog is still going, don't return EoC. + return False + + return DialogManager.is_from_parent_to_skill(context) + + @staticmethod + def get_active_dialog_context(dialog_context: DialogContext) -> DialogContext: + """ + Recursively walk up the DC stack to find the active DC. + """ + return DialogExtensions._get_active_dialog_context( # pylint: disable=protected-access + dialog_context + ) diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager_result.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager_result.py new file mode 100644 index 00000000..a651703f --- /dev/null +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_manager_result.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from dataclasses import dataclass, field + +from microsoft_agents.activity import Activity + +from .models.dialog_turn_result import DialogTurnResult +from .persisted_state import PersistedState + + +@dataclass +class DialogManagerResult: + + turn_result: DialogTurnResult | None = None + activities: list[Activity] = field(default_factory=list) + persisted_state: PersistedState | None = None diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_set.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_set.py index 60bddded..0fe84723 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_set.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/dialog_set.py @@ -31,11 +31,14 @@ def __init__(self, dialog_state: StatePropertyAccessor | None = None): raise KeyError except KeyError: raise TypeError("DialogSet(): dialog_state cannot be None.") - # Only ComponentDialog / DialogContainer can initialize with None dialog_state + # Only ComponentDialog / DialogContainer / DialogManager can initialize with None dialog_state from .component_dialog import ComponentDialog + from .dialog_manager import DialogManager from .dialog_container import DialogContainer - if not isinstance(self_obj, (ComponentDialog, DialogContainer)): + if not isinstance( + self_obj, (ComponentDialog, DialogContainer, DialogManager) + ): raise TypeError("DialogSet(): dialog_state cannot be None.") finally: # make sure to clean up the frame at the end to avoid ref cycles diff --git a/tests/hosting_dialogs/test_dialog_manager.py b/tests/hosting_dialogs/test_dialog_manager.py new file mode 100644 index 00000000..fa39d2f8 --- /dev/null +++ b/tests/hosting_dialogs/test_dialog_manager.py @@ -0,0 +1,285 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# pylint: disable=pointless-string-statement + +from enum import Enum +from typing import Callable, List, Tuple + +import pytest + +from microsoft_agents.hosting.core import ( + ConversationState, + MemoryStorage, + MessageFactory, + UserState, + TurnContext, +) +from microsoft_agents.hosting.core import ClaimsIdentity +from microsoft_agents.hosting.core.authorization import AuthenticationConstants +from microsoft_agents.hosting.dialogs import ( + ComponentDialog, + Dialog, + DialogContext, + DialogEvents, + DialogInstance, + DialogReason, + TextPrompt, + WaterfallDialog, + DialogManager, + DialogManagerResult, + DialogTurnStatus, + WaterfallStepContext, +) +from microsoft_agents.hosting.dialogs.prompts import PromptOptions +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + EndOfConversationCodes, + InputHints, + Channels, +) +from tests.hosting_dialogs.helpers import DialogTestAdapter + + +class SkillFlowTestCase(str, Enum): + # DialogManager is executing on a root bot with no skills (typical standalone bot). + root_bot_only = "RootBotOnly" + + # DialogManager is executing on a root bot handling replies from a skill. + root_bot_consuming_skill = "RootBotConsumingSkill" + + # DialogManager is executing in a skill that is called from a root and calling another skill. + middle_skill = "MiddleSkill" + + # DialogManager is executing in a skill that is called from a parent (a root or another skill) but doesn't call + # another skill. + leaf_skill = "LeafSkill" + + +class SimpleComponentDialog(ComponentDialog): + # An App ID for a parent bot. + parent_bot_id = "00000000-0000-0000-0000-0000000000PARENT" + + # An App ID for a skill bot. + skill_bot_id = "00000000-0000-0000-0000-00000000000SKILL" + + # Captures an EndOfConversation if it was sent to help with assertions. + eoc_sent: Activity = None + + # Property to capture the DialogManager turn results and do assertions. + dm_turn_result: DialogManagerResult = None + + def __init__( + self, id: str = None, prop: str = None + ): # pylint: disable=unused-argument + super().__init__(id or "SimpleComponentDialog") + self.text_prompt = "TextPrompt" + self.waterfall_dialog = "WaterfallDialog" + self.add_dialog(TextPrompt(self.text_prompt)) + self.add_dialog( + WaterfallDialog( + self.waterfall_dialog, + [ + self.prompt_for_name, + self.final_step, + ], + ) + ) + self.initial_dialog_id = self.waterfall_dialog + self.end_reason = None + + @staticmethod + async def create_test_flow( + dialog: Dialog, + test_case: SkillFlowTestCase = SkillFlowTestCase.root_bot_only, + enabled_trace=False, + ) -> DialogTestAdapter: + conversation_id = "testFlowConversationId" + storage = MemoryStorage() + conversation_state = ConversationState(storage) + user_state = UserState(storage) + + activity = Activity( + type=ActivityTypes.message, + channel_id=Channels.test, + service_url="https://test.com", + from_property=ChannelAccount(id="user1", name="User1"), + recipient=ChannelAccount(id="bot", name="Bot"), + conversation=ConversationAccount( + is_group=False, conversation_type=conversation_id, id=conversation_id + ), + ) + + dialog_manager = DialogManager(dialog) + dialog_manager.user_state = user_state + dialog_manager.conversation_state = conversation_state + + async def logic(context: TurnContext): + if test_case != SkillFlowTestCase.root_bot_only: + # Create a skill ClaimsIdentity and put it in turn_state so isSkillClaim() returns True. + claims_identity = ClaimsIdentity({}, False) + claims_identity.claims[ + "ver" + ] = "2.0" # AuthenticationConstants.VersionClaim + claims_identity.claims[ + "aud" + ] = ( + SimpleComponentDialog.skill_bot_id + ) # AuthenticationConstants.AudienceClaim + claims_identity.claims[ + "azp" + ] = ( + SimpleComponentDialog.parent_bot_id + ) # AuthenticationConstants.AuthorizedParty + context._identity = claims_identity + + # Note: SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY based skill routing + # is not available in the new SDK. Skill flow test cases that require it are skipped. + + async def aux( + turn_context: TurnContext, # pylint: disable=unused-argument + activities: List[Activity], + next: Callable, + ): + for activity in activities: + if activity.type == ActivityTypes.end_of_conversation: + SimpleComponentDialog.eoc_sent = activity + break + + return await next() + + # Interceptor to capture the EoC activity if it was sent so we can assert it in the tests. + context.on_send_activities(aux) + + SimpleComponentDialog.dm_turn_result = await dialog_manager.on_turn(context) + + # Manually save state since AutoSaveStateMiddleware is not available + await conversation_state.save(context) + await user_state.save(context) + + adapter = DialogTestAdapter(logic) + if enabled_trace: + adapter.enable_trace = True + + return adapter + + async def on_end_dialog( + self, context: DialogContext, instance: DialogInstance, reason: DialogReason + ): + self.end_reason = reason + return await super().on_end_dialog(context, instance, reason) + + async def prompt_for_name(self, step: WaterfallStepContext): + return await step.prompt( + self.text_prompt, + PromptOptions( + prompt=MessageFactory.text( + "Hello, what is your name?", None, InputHints.expecting_input + ), + retry_prompt=MessageFactory.text( + "Hello, what is your name again?", None, InputHints.expecting_input + ), + ), + ) + + async def final_step(self, step: WaterfallStepContext): + await step.context.send_activity(f"Hello { step.result }, nice to meet you!") + return await step.end_dialog(step.result) + + +class TestDialogManager: + @pytest.mark.asyncio + async def test_handles_root_bot_only(self): + SimpleComponentDialog.dm_turn_result = None + SimpleComponentDialog.eoc_sent = None + dialog = SimpleComponentDialog() + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.root_bot_only + ) + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.send("SomeName") + await step3.assert_reply("Hello SomeName, nice to meet you!") + + assert ( + SimpleComponentDialog.dm_turn_result.turn_result.status + == DialogTurnStatus.Complete + ) + assert dialog.end_reason == DialogReason.EndCalled + assert ( + SimpleComponentDialog.eoc_sent is None + ), "Root bot should not send EndConversation to channel" + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_handles_root_bot_consuming_skill(self): + pass + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_handles_middle_skill(self): + pass + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_handles_leaf_skill(self): + pass + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_skill_handles_eoc_from_parent(self): + pass + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_skill_handles_reprompt_from_parent(self): + pass + + @pytest.mark.skip( + reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" + ) + @pytest.mark.asyncio + async def test_skill_should_return_empty_on_reprompt_with_no_dialog(self): + pass + + @pytest.mark.asyncio + async def test_trace_bot_state(self): + SimpleComponentDialog.dm_turn_result = None + dialog = SimpleComponentDialog() + + def assert_is_trace(activity, description): # pylint: disable=unused-argument + assert activity.type == ActivityTypes.trace + return True + + def assert_is_trace_and_label(activity, description): + assert_is_trace(activity, description) + assert activity.label == "Bot State" + return True + + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.root_bot_only, True + ) + + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.assert_reply(assert_is_trace_and_label) + step4 = await step3.send("SomeName") + step5 = await step4.assert_reply("Hello SomeName, nice to meet you!") + await step5.assert_reply(assert_is_trace_and_label) + + assert ( + SimpleComponentDialog.dm_turn_result.turn_result.status + == DialogTurnStatus.Complete + ) From 008c2b90c18106d9995be793c43e13d649b3860c Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Fri, 10 Jul 2026 17:09:45 -0700 Subject: [PATCH 09/12] make DialogManager compatible with new agent_state.load() --- .../hosting/dialogs/dialog_manager.py | 26 +++---------------- .../readme.md | 1 + 2 files changed, 5 insertions(+), 22 deletions(-) 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 c82c7310..7c2ec7d8 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 @@ -83,28 +83,10 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: # Register DialogManager with TurnState. context.turn_state[DialogManager.__name__] = self - # Resolve ConversationState - conversation_state_name = ConversationState.__name__ - if self.conversation_state is None: - if conversation_state_name not in context.turn_state: - raise Exception( - f"Unable to get an instance of {conversation_state_name} from turn_context. " - f"Please ensure ConversationState is available in turn_state." - ) - self.conversation_state = cast( - ConversationState, context.turn_state[conversation_state_name] - ) - else: - context.turn_state[conversation_state_name] = self.conversation_state - - # Resolve UserState (optional) - user_state_name = UserState.__name__ - if self.user_state is None: - self.user_state = cast( - UserState | None, context.turn_state.get(user_state_name, None) - ) - else: - context.turn_state[user_state_name] = self.user_state + if self.conversation_state is not None: + await self.conversation_state.load(context, False) + if self.user_state is not None: + await self.user_state.load(context, False) # Create property accessors last_access_property = self.conversation_state.create_property(self.last_access) diff --git a/libraries/microsoft-agents-hosting-dialogs/readme.md b/libraries/microsoft-agents-hosting-dialogs/readme.md index b3240a6a..98630500 100644 --- a/libraries/microsoft-agents-hosting-dialogs/readme.md +++ b/libraries/microsoft-agents-hosting-dialogs/readme.md @@ -17,6 +17,7 @@ This library is part of the **Microsoft 365 Agents SDK for Python** - a comprehe - **Prompts**: Built-in prompts for text, numbers, choices, confirmations, dates, attachments, and OAuth - **Choice recognition**: Locale-aware choice matching and recognition - **Dialog memory**: Scoped memory management (conversation, user, dialog, class, settings) +- **DialogManager**: High-level dialog orchestration with state management ## Installation From 757e7b9246852b33773e7e9649218b5475eae491 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Mon, 13 Jul 2026 11:40:35 -0700 Subject: [PATCH 10/12] align get, clear, and get_cached_state to the .net sdk and make their outputs consistent with what the program expects --- .../hosting/core/state/agent_state.py | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py index 67faa60e..7a1f1145 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py @@ -5,6 +5,7 @@ from abc import abstractmethod from copy import deepcopy +import logging from typing import Callable, Type from microsoft_agents.hosting.core.storage import Storage, StoreItem @@ -12,6 +13,8 @@ from .state_property_accessor import StatePropertyAccessor from ..turn_context import TurnContext +logger = logging.getLogger(__name__) + class CachedAgentState(StoreItem): """ @@ -47,6 +50,10 @@ def store_item_to_json(self) -> dict: } return serialized + def clear(self) -> None: + self.state = {} + self.hash = "" + @staticmethod def from_json_to_store_item(json_data: dict) -> StoreItem: return CachedAgentState(json_data) @@ -87,16 +94,23 @@ def __init__(self, storage: Storage, context_service_key: str): self._context_service_key = context_service_key self._cached_state: CachedAgentState | None = None - def get_cached_state(self, turn_context: TurnContext) -> CachedAgentState: + def get_cached_state( + self, turn_context: TurnContext | None = None + ) -> CachedAgentState: """ Gets the cached agent state instance that wraps the raw cached data for this "AgentState" from the turn context. - :param turn_context: The context object for this turn. + :param turn_context: Deprecated. The context object for this turn. This parameter is no + longer required and will be removed in a future release. :type turn_context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` :return: The cached agent state instance. """ - return turn_context.turn_state.get(self._context_service_key) + if turn_context: + logger.warning( + "AgentState.get_cached_state(): turn_context is deprecated and no longer required for get_cached_state(); it will be removed in a future release" + ) + return self._cached_state def create_property(self, name: str) -> StatePropertyAccessor: """ @@ -113,10 +127,20 @@ def create_property(self, name: str) -> StatePropertyAccessor: ) return BotStatePropertyAccessor(self, name) - def get(self, turn_context: TurnContext) -> dict[str, StoreItem]: - cached = self.get_cached_state(turn_context) + def get(self, turn_context: TurnContext | None = None) -> dict[str, StoreItem]: + """ + Gets the raw cached state dictionary for this "AgentState". - return getattr(cached, "state", None) + :param turn_context: Deprecated. The context object for this turn. This parameter is no + longer required and will be removed in a future release. + :type turn_context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` + :return: The cached state dictionary. + """ + if turn_context: + logger.warning( + "AgentState.get(): turn_context is deprecated and no longer required for get(); it will be removed in a future release" + ) + return getattr(self._cached_state, "state", None) async def load(self, turn_context: TurnContext, force: bool = False) -> None: """ @@ -147,7 +171,7 @@ def _should_load(self, turn_context: TurnContext, force: bool = False) -> bool: :rtype: bool """ - self._cached_state = self.get_cached_state(turn_context) + self._cached_state = turn_context.turn_state.get(self._context_service_key) return force or self._cached_state is None async def save(self, turn_context: TurnContext, force: bool = False) -> None: @@ -167,11 +191,12 @@ async def save(self, turn_context: TurnContext, force: bool = False) -> None: await self._storage.write(changes) self._cached_state.hash = self._cached_state.compute_hash() - def clear(self, turn_context: TurnContext): + def clear(self, turn_context: TurnContext | None = None) -> None: """ Clears any state currently stored in this state scope. - :param turn_context: The context object for this turn + :param turn_context: Deprecated. The context object for this turn. This parameter is no + longer required and will be removed in a future release. :type turn_context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` :return: None @@ -180,10 +205,14 @@ def clear(self, turn_context: TurnContext): This function must be called in order for the cleared state to be persisted to the underlying store. """ # Explicitly setting the hash will mean IsChanged is always true. And that will force a Save. - cache_value = CachedAgentState() - cache_value.hash = "" - self._cached_state = cache_value - turn_context.turn_state[self._context_service_key] = self._cached_state + if turn_context: + logger.warning( + "AgentState.clear(): turn_context is deprecated and no longer required for clear(); it will be removed in a future release" + ) + if self._cached_state is None: + logger.warning("AgentState.clear(): No cached state to clear.") + return + self._cached_state.clear() async def delete(self, turn_context: TurnContext) -> None: """ From 8f91a500fca6e5a4696dd2b5af67b91f1ee545f2 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Mon, 13 Jul 2026 14:12:11 -0700 Subject: [PATCH 11/12] fix one last concurrency issue --- .../microsoft_agents/hosting/core/state/agent_state.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py index 7a1f1145..4fc82ef1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py @@ -185,11 +185,14 @@ async def save(self, turn_context: TurnContext, force: bool = False) -> None: :type force: bool """ - if force or (self._cached_state is not None and self._cached_state.is_changed): + # Avoid self._cached_state updating during the save operation + cached_state = self._cached_state + + if force or (cached_state is not None and cached_state.is_changed): storage_key = self.get_storage_key(turn_context) - changes: dict[str, StoreItem] = {storage_key: self._cached_state} + changes: dict[str, StoreItem] = {storage_key: cached_state} await self._storage.write(changes) - self._cached_state.hash = self._cached_state.compute_hash() + cached_state.hash = cached_state.compute_hash() def clear(self, turn_context: TurnContext | None = None) -> None: """ From b56336b766f17994683e4df9c2557b46aa7bdba2 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Mon, 13 Jul 2026 14:33:24 -0700 Subject: [PATCH 12/12] restore deleted deletions --- dev/hosting_dialogs/__init__.py | 0 dev/hosting_dialogs/choices/__init__.py | 0 dev/hosting_dialogs/choices/test_channel.py | 89 -- dev/hosting_dialogs/choices/test_choice.py | 29 - .../choices/test_choice_factory.py | 237 ----- .../choices/test_choice_factory_options.py | 32 - .../choices/test_choice_recognizers.py | 198 ---- .../choices/test_choice_tokenizer.py | 67 -- dev/hosting_dialogs/helpers.py | 312 ------ dev/hosting_dialogs/memory/__init__.py | 0 dev/hosting_dialogs/memory/scopes/__init__.py | 0 .../memory/scopes/test_memory_scopes.py | 684 ------------ .../memory/scopes/test_settings.py | 14 - .../memory/test_at_path_resolver.py | 44 - dev/hosting_dialogs/test_activity_prompt.py | 286 ----- dev/hosting_dialogs/test_attachment_prompt.py | 287 ----- dev/hosting_dialogs/test_choice_prompt.py | 991 ------------------ dev/hosting_dialogs/test_component_dialog.py | 371 ------- dev/hosting_dialogs/test_confirm_prompt.py | 493 --------- dev/hosting_dialogs/test_date_time_prompt.py | 88 -- dev/hosting_dialogs/test_dialog.py | 81 -- dev/hosting_dialogs/test_dialog_context.py | 293 ------ dev/hosting_dialogs/test_dialog_extensions.py | 188 ---- dev/hosting_dialogs/test_dialog_manager.py | 285 ----- dev/hosting_dialogs/test_dialog_set.py | 137 --- dev/hosting_dialogs/test_number_prompt.py | 378 ------- dev/hosting_dialogs/test_oauth_prompt.py | 384 ------- dev/hosting_dialogs/test_object_path.py | 209 ---- .../test_prompt_culture_models.py | 76 -- .../test_prompt_validator_context.py | 103 -- dev/hosting_dialogs/test_replace_dialog.py | 136 --- dev/hosting_dialogs/test_text_prompt.py | 195 ---- dev/hosting_dialogs/test_waterfall.py | 314 ------ dev/hosting_dialogs/test_waterfall_dialog.py | 171 --- .../test_waterfall_step_context.py | 94 -- 35 files changed, 7266 deletions(-) delete mode 100644 dev/hosting_dialogs/__init__.py delete mode 100644 dev/hosting_dialogs/choices/__init__.py delete mode 100644 dev/hosting_dialogs/choices/test_channel.py delete mode 100644 dev/hosting_dialogs/choices/test_choice.py delete mode 100644 dev/hosting_dialogs/choices/test_choice_factory.py delete mode 100644 dev/hosting_dialogs/choices/test_choice_factory_options.py delete mode 100644 dev/hosting_dialogs/choices/test_choice_recognizers.py delete mode 100644 dev/hosting_dialogs/choices/test_choice_tokenizer.py delete mode 100644 dev/hosting_dialogs/helpers.py delete mode 100644 dev/hosting_dialogs/memory/__init__.py delete mode 100644 dev/hosting_dialogs/memory/scopes/__init__.py delete mode 100644 dev/hosting_dialogs/memory/scopes/test_memory_scopes.py delete mode 100644 dev/hosting_dialogs/memory/scopes/test_settings.py delete mode 100644 dev/hosting_dialogs/memory/test_at_path_resolver.py delete mode 100644 dev/hosting_dialogs/test_activity_prompt.py delete mode 100644 dev/hosting_dialogs/test_attachment_prompt.py delete mode 100644 dev/hosting_dialogs/test_choice_prompt.py delete mode 100644 dev/hosting_dialogs/test_component_dialog.py delete mode 100644 dev/hosting_dialogs/test_confirm_prompt.py delete mode 100644 dev/hosting_dialogs/test_date_time_prompt.py delete mode 100644 dev/hosting_dialogs/test_dialog.py delete mode 100644 dev/hosting_dialogs/test_dialog_context.py delete mode 100644 dev/hosting_dialogs/test_dialog_extensions.py delete mode 100644 dev/hosting_dialogs/test_dialog_manager.py delete mode 100644 dev/hosting_dialogs/test_dialog_set.py delete mode 100644 dev/hosting_dialogs/test_number_prompt.py delete mode 100644 dev/hosting_dialogs/test_oauth_prompt.py delete mode 100644 dev/hosting_dialogs/test_object_path.py delete mode 100644 dev/hosting_dialogs/test_prompt_culture_models.py delete mode 100644 dev/hosting_dialogs/test_prompt_validator_context.py delete mode 100644 dev/hosting_dialogs/test_replace_dialog.py delete mode 100644 dev/hosting_dialogs/test_text_prompt.py delete mode 100644 dev/hosting_dialogs/test_waterfall.py delete mode 100644 dev/hosting_dialogs/test_waterfall_dialog.py delete mode 100644 dev/hosting_dialogs/test_waterfall_step_context.py diff --git a/dev/hosting_dialogs/__init__.py b/dev/hosting_dialogs/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/dev/hosting_dialogs/choices/__init__.py b/dev/hosting_dialogs/choices/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/dev/hosting_dialogs/choices/test_channel.py b/dev/hosting_dialogs/choices/test_channel.py deleted file mode 100644 index 74fc849c..00000000 --- a/dev/hosting_dialogs/choices/test_channel.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List, Tuple - -import pytest - -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - Channels, - ConversationAccount, - ChannelAccount, -) -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents.hosting.dialogs.choices import Channel -from tests._common.testing_objects import MockTestingAdapter - - -class TestChannel: - def test_supports_suggested_actions(self): - actual = Channel.supports_suggested_actions(Channels.facebook, 5) - assert actual - - def test_supports_suggested_actions_many(self): - supports_suggested_actions_data: List[Tuple[str, int, bool]] = [ - (Channels.line, 13, True), - (Channels.line, 14, False), - (Channels.skype, 10, True), - (Channels.skype, 11, False), - (Channels.kik, 20, True), - (Channels.kik, 21, False), - (Channels.emulator, 100, True), - (Channels.emulator, 101, False), - (Channels.direct_line_speech, 100, True), - ] - - for channel, button_cnt, expected in supports_suggested_actions_data: - actual = Channel.supports_suggested_actions(channel, button_cnt) - assert ( - expected == actual - ), f"channel={channel}, button_cnt={button_cnt}: expected {expected}, got {actual}" - - def test_supports_card_actions_many(self): - supports_card_action_data: List[Tuple[str, int, bool]] = [ - (Channels.line, 99, True), - (Channels.line, 100, False), - (Channels.slack, 100, True), - (Channels.skype, 3, True), - (Channels.skype, 5, False), - (Channels.direct_line_speech, 99, True), - ] - - for channel, button_cnt, expected in supports_card_action_data: - actual = Channel.supports_card_actions(channel, button_cnt) - assert ( - expected == actual - ), f"channel={channel}, button_cnt={button_cnt}: expected {expected}, got {actual}" - - def test_supports_suggested_actions_accepts_string_channel_id(self): - assert Channel.supports_suggested_actions("facebook", 5) - assert not Channel.supports_suggested_actions("facebook", 11) - - def test_supports_card_actions_accepts_string_channel_id(self): - assert Channel.supports_card_actions("msteams", 3) - assert not Channel.supports_card_actions("msteams", 4) - - def test_should_return_channel_id_from_context_activity(self): - adapter = MockTestingAdapter(channel_id=Channels.facebook) - test_activity = Activity( - type=ActivityTypes.message, - channel_id=Channels.facebook, - conversation=ConversationAccount(id="test"), - from_property=ChannelAccount(id="user"), - ) - test_context = TurnContext(adapter, test_activity) - channel_id = Channel.get_channel_id(test_context) - assert Channels.facebook == channel_id - - def test_should_return_empty_from_context_activity_missing_channel(self): - adapter = MockTestingAdapter() - test_activity = Activity( - type=ActivityTypes.message, - conversation=ConversationAccount(id="test"), - from_property=ChannelAccount(id="user"), - ) - test_context = TurnContext(adapter, test_activity) - channel_id = Channel.get_channel_id(test_context) - assert "" == channel_id diff --git a/dev/hosting_dialogs/choices/test_choice.py b/dev/hosting_dialogs/choices/test_choice.py deleted file mode 100644 index 4b37a3e6..00000000 --- a/dev/hosting_dialogs/choices/test_choice.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - -import pytest - -from microsoft_agents.hosting.dialogs.choices import Choice -from microsoft_agents.activity import CardAction - - -class TestChoice: - def test_value_round_trips(self) -> None: - choice = Choice() - expected = "any" - choice.value = expected - assert expected is choice.value - - def test_action_round_trips(self) -> None: - choice = Choice() - expected = CardAction(type="imBack", title="Test Action") - choice.action = expected - assert expected is choice.action - - def test_synonyms_round_trips(self) -> None: - choice = Choice() - expected: List[str] = [] - choice.synonyms = expected - assert expected is choice.synonyms diff --git a/dev/hosting_dialogs/choices/test_choice_factory.py b/dev/hosting_dialogs/choices/test_choice_factory.py deleted file mode 100644 index 47e143ff..00000000 --- a/dev/hosting_dialogs/choices/test_choice_factory.py +++ /dev/null @@ -1,237 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - -import pytest - -from microsoft_agents.hosting.dialogs.choices import ( - Choice, - ChoiceFactory, - ChoiceFactoryOptions, -) -from microsoft_agents.activity import ( - ActionTypes, - Activity, - ActivityTypes, - Attachment, - AttachmentLayoutTypes, - CardAction, - HeroCard, - InputHints, - SuggestedActions, - Channels, -) - - -class TestChoiceFactory: - color_choices: List[Choice] = [Choice("red"), Choice("green"), Choice("blue")] - choices_with_actions: List[Choice] = [ - Choice( - "ImBack", - action=CardAction( - type=ActionTypes.im_back, title="ImBack Action", value="ImBack Value" - ), - ), - Choice( - "MessageBack", - action=CardAction( - type=ActionTypes.message_back, - title="MessageBack Action", - value="MessageBack Value", - ), - ), - Choice( - "PostBack", - action=CardAction( - type=ActionTypes.post_back, - title="PostBack Action", - value="PostBack Value", - ), - ), - ] - - def test_inline_should_render_choices_inline(self): - activity = ChoiceFactory.inline(TestChoiceFactory.color_choices, "select from:") - assert "select from: (1) red, (2) green, or (3) blue" == activity.text - - def test_should_render_choices_as_a_list(self): - activity = ChoiceFactory.list_style( - TestChoiceFactory.color_choices, "select from:" - ) - assert "select from:\n\n 1. red\n 2. green\n 3. blue" == activity.text - - def test_should_render_unincluded_numbers_choices_as_a_list(self): - activity = ChoiceFactory.list_style( - TestChoiceFactory.color_choices, - "select from:", - options=ChoiceFactoryOptions(include_numbers=False), - ) - assert "select from:\n\n - red\n - green\n - blue" == activity.text - - def test_should_render_choices_as_suggested_actions(self): - expected = Activity( - type=ActivityTypes.message, - text="select from:", - input_hint=InputHints.expecting_input, - suggested_actions=SuggestedActions( - actions=[ - CardAction(type=ActionTypes.im_back, value="red", title="red"), - CardAction(type=ActionTypes.im_back, value="green", title="green"), - CardAction(type=ActionTypes.im_back, value="blue", title="blue"), - ] - ), - ) - - activity = ChoiceFactory.suggested_action( - TestChoiceFactory.color_choices, "select from:" - ) - - assert expected == activity - - def test_should_render_choices_as_hero_card(self): - expected = Activity( - type=ActivityTypes.message, - input_hint=InputHints.expecting_input, - attachment_layout=AttachmentLayoutTypes.list, - attachments=[ - Attachment( - content=HeroCard( - text="select from:", - buttons=[ - CardAction( - type=ActionTypes.im_back, value="red", title="red" - ), - CardAction( - type=ActionTypes.im_back, value="green", title="green" - ), - CardAction( - type=ActionTypes.im_back, value="blue", title="blue" - ), - ], - ), - content_type="application/vnd.microsoft.card.hero", - ) - ], - ) - - activity = ChoiceFactory.hero_card( - TestChoiceFactory.color_choices, "select from:" - ) - - assert expected == activity - - def test_should_automatically_choose_render_style_based_on_channel_type(self): - expected = Activity( - type=ActivityTypes.message, - text="select from:", - input_hint=InputHints.expecting_input, - suggested_actions=SuggestedActions( - actions=[ - CardAction(type=ActionTypes.im_back, value="red", title="red"), - CardAction(type=ActionTypes.im_back, value="green", title="green"), - CardAction(type=ActionTypes.im_back, value="blue", title="blue"), - ] - ), - ) - activity = ChoiceFactory.for_channel( - Channels.emulator, TestChoiceFactory.color_choices, "select from:" - ) - - assert expected == activity - - def test_should_choose_correct_styles_for_teams(self): - expected = Activity( - type=ActivityTypes.message, - input_hint=InputHints.expecting_input, - attachment_layout=AttachmentLayoutTypes.list, - attachments=[ - Attachment( - content=HeroCard( - text="select from:", - buttons=[ - CardAction( - type=ActionTypes.im_back, value="red", title="red" - ), - CardAction( - type=ActionTypes.im_back, value="green", title="green" - ), - CardAction( - type=ActionTypes.im_back, value="blue", title="blue" - ), - ], - ), - content_type="application/vnd.microsoft.card.hero", - ) - ], - ) - activity = ChoiceFactory.for_channel( - Channels.ms_teams, TestChoiceFactory.color_choices, "select from:" - ) - assert expected == activity - - def test_should_include_choice_actions_in_suggested_actions(self): - expected = Activity( - type=ActivityTypes.message, - text="select from:", - input_hint=InputHints.expecting_input, - suggested_actions=SuggestedActions( - actions=[ - CardAction( - type=ActionTypes.im_back, - value="ImBack Value", - title="ImBack Action", - ), - CardAction( - type=ActionTypes.message_back, - value="MessageBack Value", - title="MessageBack Action", - ), - CardAction( - type=ActionTypes.post_back, - value="PostBack Value", - title="PostBack Action", - ), - ] - ), - ) - activity = ChoiceFactory.suggested_action( - TestChoiceFactory.choices_with_actions, "select from:" - ) - assert expected == activity - - def test_should_include_choice_actions_in_hero_cards(self): - expected = Activity( - type=ActivityTypes.message, - input_hint=InputHints.expecting_input, - attachment_layout=AttachmentLayoutTypes.list, - attachments=[ - Attachment( - content=HeroCard( - text="select from:", - buttons=[ - CardAction( - type=ActionTypes.im_back, - value="ImBack Value", - title="ImBack Action", - ), - CardAction( - type=ActionTypes.message_back, - value="MessageBack Value", - title="MessageBack Action", - ), - CardAction( - type=ActionTypes.post_back, - value="PostBack Value", - title="PostBack Action", - ), - ], - ), - content_type="application/vnd.microsoft.card.hero", - ) - ], - ) - activity = ChoiceFactory.hero_card( - TestChoiceFactory.choices_with_actions, "select from:" - ) - assert expected == activity diff --git a/dev/hosting_dialogs/choices/test_choice_factory_options.py b/dev/hosting_dialogs/choices/test_choice_factory_options.py deleted file mode 100644 index 2e3563a7..00000000 --- a/dev/hosting_dialogs/choices/test_choice_factory_options.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest - -from microsoft_agents.hosting.dialogs.choices import ChoiceFactoryOptions - - -class TestChoiceFactoryOptions: - def test_inline_separator_round_trips(self) -> None: - choice_factor_options = ChoiceFactoryOptions() - expected = ", " - choice_factor_options.inline_separator = expected - assert expected == choice_factor_options.inline_separator - - def test_inline_or_round_trips(self) -> None: - choice_factor_options = ChoiceFactoryOptions() - expected = " or " - choice_factor_options.inline_or = expected - assert expected == choice_factor_options.inline_or - - def test_inline_or_more_round_trips(self) -> None: - choice_factor_options = ChoiceFactoryOptions() - expected = ", or " - choice_factor_options.inline_or_more = expected - assert expected == choice_factor_options.inline_or_more - - def test_include_numbers_round_trips(self) -> None: - choice_factor_options = ChoiceFactoryOptions() - expected = True - choice_factor_options.include_numbers = expected - assert expected == choice_factor_options.include_numbers diff --git a/dev/hosting_dialogs/choices/test_choice_recognizers.py b/dev/hosting_dialogs/choices/test_choice_recognizers.py deleted file mode 100644 index edc63cd2..00000000 --- a/dev/hosting_dialogs/choices/test_choice_recognizers.py +++ /dev/null @@ -1,198 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - -import pytest - -from microsoft_agents.hosting.dialogs.choices import ( - ChoiceRecognizers, - Find, - FindValuesOptions, - SortedValue, -) - - -def assert_result(result, start, end, text): - assert ( - result.start == start - ), f"Invalid ModelResult.start of '{result.start}' for '{text}' result." - assert ( - result.end == end - ), f"Invalid ModelResult.end of '{result.end}' for '{text}' result." - assert ( - result.text == text - ), f"Invalid ModelResult.text of '{result.text}' for '{text}' result." - - -def assert_value(result, value, index, score): - assert ( - result.type_name == "value" - ), f"Invalid ModelResult.type_name of '{result.type_name}' for '{value}' value." - assert result.resolution, f"Missing ModelResult.resolution for '{value}' value." - resolution = result.resolution - assert ( - resolution.value == value - ), f"Invalid resolution.value of '{resolution.value}' for '{value}' value." - assert ( - resolution.index == index - ), f"Invalid resolution.index of '{resolution.index}' for '{value}' value." - assert ( - resolution.score == score - ), f"Invalid resolution.score of '{resolution.score}' for '{value}' value." - - -def assert_choice(result, value, index, score, synonym=None): - assert ( - result.type_name == "choice" - ), f"Invalid ModelResult.type_name of '{result.type_name}' for '{value}' choice." - assert result.resolution, f"Missing ModelResult.resolution for '{value}' choice." - resolution = result.resolution - assert ( - resolution.value == value - ), f"Invalid resolution.value of '{resolution.value}' for '{value}' choice." - assert ( - resolution.index == index - ), f"Invalid resolution.index of '{resolution.index}' for '{value}' choice." - assert ( - resolution.score == score - ), f"Invalid resolution.score of '{resolution.score}' for '{value}' choice." - if synonym: - assert ( - resolution.synonym == synonym - ), f"Invalid resolution.synonym of '{resolution.synonym}' for '{value}' choice." - - -_color_choices: List[str] = ["red", "green", "blue"] -_overlapping_choices: List[str] = ["bread", "bread pudding", "pudding"] - -_color_values: List[SortedValue] = [ - SortedValue(value="red", index=0), - SortedValue(value="green", index=1), - SortedValue(value="blue", index=2), -] - -_overlapping_values: List[SortedValue] = [ - SortedValue(value="bread", index=0), - SortedValue(value="bread pudding", index=1), - SortedValue(value="pudding", index=2), -] - -_similar_values: List[SortedValue] = [ - SortedValue(value="option A", index=0), - SortedValue(value="option B", index=1), - SortedValue(value="option C", index=2), -] - - -class TestChoiceRecognizers: - # Find.find_values - - def test_should_find_a_simple_value_in_a_single_word_utterance(self): - found = Find.find_values("red", _color_values) - assert len(found) == 1, f"Invalid token count of '{len(found)}' returned." - assert_result(found[0], 0, 2, "red") - assert_value(found[0], "red", 0, 1.0) - - def test_should_find_a_simple_value_in_an_utterance(self): - found = Find.find_values("the red one please.", _color_values) - assert len(found) == 1, f"Invalid token count of '{len(found)}' returned." - assert_result(found[0], 4, 6, "red") - assert_value(found[0], "red", 0, 1.0) - - def test_should_find_multiple_values_within_an_utterance(self): - found = Find.find_values("the red and blue ones please.", _color_values) - assert len(found) == 2, f"Invalid token count of '{len(found)}' returned." - assert_result(found[0], 4, 6, "red") - assert_value(found[0], "red", 0, 1.0) - assert_value(found[1], "blue", 2, 1.0) - - def test_should_find_multiple_values_that_overlap(self): - found = Find.find_values( - "the bread pudding and bread please.", _overlapping_values - ) - assert len(found) == 2, f"Invalid token count of '{len(found)}' returned." - assert_result(found[0], 4, 16, "bread pudding") - assert_value(found[0], "bread pudding", 1, 1.0) - assert_value(found[1], "bread", 0, 1.0) - - def test_should_correctly_disambiguate_between_similar_values(self): - found = Find.find_values( - "option B", _similar_values, FindValuesOptions(allow_partial_matches=True) - ) - assert len(found) == 1, f"Invalid token count of '{len(found)}' returned." - assert_value(found[0], "option B", 1, 1.0) - - def test_should_find_a_single_choice_in_an_utterance(self): - found = Find.find_choices("the red one please.", _color_choices) - assert len(found) == 1, f"Invalid token count of '{len(found)}' returned." - assert_result(found[0], 4, 6, "red") - assert_choice(found[0], "red", 0, 1.0, "red") - - def test_should_find_multiple_choices_within_an_utterance(self): - found = Find.find_choices("the red and blue ones please.", _color_choices) - assert len(found) == 2, f"Invalid token count of '{len(found)}' returned." - assert_result(found[0], 4, 6, "red") - assert_choice(found[0], "red", 0, 1.0) - assert_choice(found[1], "blue", 2, 1.0) - - def test_should_find_multiple_choices_that_overlap(self): - found = Find.find_choices( - "the bread pudding and bread please.", _overlapping_choices - ) - assert len(found) == 2, f"Invalid token count of '{len(found)}' returned." - assert_result(found[0], 4, 16, "bread pudding") - assert_choice(found[0], "bread pudding", 1, 1.0) - assert_choice(found[1], "bread", 0, 1.0) - - def test_should_accept_null_utterance_in_find_choices(self): - found = Find.find_choices(None, _color_choices) - assert not found - - # ChoiceRecognizers.recognize_choices - - def test_should_find_a_choice_in_an_utterance_by_name(self): - found = ChoiceRecognizers.recognize_choices( - "the red one please.", _color_choices - ) - assert len(found) == 1 - assert_result(found[0], 4, 6, "red") - assert_choice(found[0], "red", 0, 1.0, "red") - - def test_should_find_a_choice_in_an_utterance_by_ordinal_position(self): - found = ChoiceRecognizers.recognize_choices( - "the first one please.", _color_choices - ) - assert len(found) == 1 - assert_result(found[0], 4, 8, "first") - assert_choice(found[0], "red", 0, 1.0) - - def test_should_find_multiple_choices_in_an_utterance_by_ordinal_position(self): - found = ChoiceRecognizers.recognize_choices( - "the first and third one please", _color_choices - ) - assert len(found) == 2 - assert_choice(found[0], "red", 0, 1.0) - assert_choice(found[1], "blue", 2, 1.0) - - def test_should_find_a_choice_in_an_utterance_by_numerical_index_digit(self): - found = ChoiceRecognizers.recognize_choices("1", _color_choices) - assert len(found) == 1 - assert_result(found[0], 0, 0, "1") - assert_choice(found[0], "red", 0, 1.0) - - def test_should_find_a_choice_in_an_utterance_by_numerical_index_text(self): - found = ChoiceRecognizers.recognize_choices("one", _color_choices) - assert len(found) == 1 - assert_result(found[0], 0, 2, "one") - assert_choice(found[0], "red", 0, 1.0) - - def test_should_find_multiple_choices_in_an_utterance_by_numerical_index(self): - found = ChoiceRecognizers.recognize_choices("option one and 3.", _color_choices) - assert len(found) == 2 - assert_choice(found[0], "red", 0, 1.0) - assert_choice(found[1], "blue", 2, 1.0) - - def test_should_accept_null_utterance_in_recognize_choices(self): - found = ChoiceRecognizers.recognize_choices(None, _color_choices) - assert not found diff --git a/dev/hosting_dialogs/choices/test_choice_tokenizer.py b/dev/hosting_dialogs/choices/test_choice_tokenizer.py deleted file mode 100644 index 16d9d38d..00000000 --- a/dev/hosting_dialogs/choices/test_choice_tokenizer.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest - -from microsoft_agents.hosting.dialogs.choices import Tokenizer - - -def _assert_token(token, start, end, text, normalized=None): - assert ( - token.start == start - ), f"Invalid token.start of '{token.start}' for '{text}' token." - assert token.end == end, f"Invalid token.end of '{token.end}' for '{text}' token." - assert ( - token.text == text - ), f"Invalid token.text of '{token.text}' for '{text}' token." - assert token.normalized == ( - normalized or text.lower() - ), f"Invalid token.normalized of '{token.normalized}' for '{text}' token." - - -class TestChoiceTokenizer: - def test_should_break_on_spaces(self): - tokens = Tokenizer.default_tokenizer("how now brown cow") - assert len(tokens) == 4 - _assert_token(tokens[0], 0, 2, "how") - _assert_token(tokens[1], 4, 6, "now") - _assert_token(tokens[2], 8, 12, "brown") - _assert_token(tokens[3], 14, 16, "cow") - - def test_should_break_on_punctuation(self): - tokens = Tokenizer.default_tokenizer("how-now.brown:cow?") - assert len(tokens) == 4 - _assert_token(tokens[0], 0, 2, "how") - _assert_token(tokens[1], 4, 6, "now") - _assert_token(tokens[2], 8, 12, "brown") - _assert_token(tokens[3], 14, 16, "cow") - - def test_should_tokenize_single_character_tokens(self): - tokens = Tokenizer.default_tokenizer("a b c d") - assert len(tokens) == 4 - _assert_token(tokens[0], 0, 0, "a") - _assert_token(tokens[1], 2, 2, "b") - _assert_token(tokens[2], 4, 4, "c") - _assert_token(tokens[3], 6, 6, "d") - - def test_should_return_a_single_token(self): - tokens = Tokenizer.default_tokenizer("food") - assert len(tokens) == 1 - _assert_token(tokens[0], 0, 3, "food") - - def test_should_return_no_tokens(self): - tokens = Tokenizer.default_tokenizer(".?-()") - assert not tokens - - def test_should_return_a_the_normalized_and_original_text_for_a_token(self): - tokens = Tokenizer.default_tokenizer("fOoD") - assert len(tokens) == 1 - _assert_token(tokens[0], 0, 3, "fOoD", "food") - - def test_should_break_on_emojis(self): - tokens = Tokenizer.default_tokenizer("food 💥👍😀") - assert len(tokens) == 4 - _assert_token(tokens[0], 0, 3, "food") - _assert_token(tokens[1], 5, 5, "💥") - _assert_token(tokens[2], 6, 6, "👍") - _assert_token(tokens[3], 7, 7, "😀") diff --git a/dev/hosting_dialogs/helpers.py b/dev/hosting_dialogs/helpers.py deleted file mode 100644 index 975ff507..00000000 --- a/dev/hosting_dialogs/helpers.py +++ /dev/null @@ -1,312 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" -Test helpers for dialog tests. Provides a TestAdapter/TestFlow compatibility -layer that wraps MockTestingAdapter with the old botbuilder-style chained -send/assert_reply API. -""" - -from typing import Callable, Union, Awaitable - -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - TokenResponse, - SignInResource, - TokenOrSignInResourceResponse, -) -from microsoft_agents.hosting.core import ChannelAdapter, TurnContext -from microsoft_agents.hosting.core.authorization import ClaimsIdentity -from tests._common.testing_objects import MockTestingAdapter - -AgentCallbackHandler = Callable[["TurnContext"], Awaitable[None]] - - -class _MockUserToken: - """Mock user_token API for dialog tests.""" - - def __init__(self, store: dict, exchange_store: dict, throw_on_exchange: dict): - self._store = store - self._exchange_store = exchange_store - self._throw_on_exchange = throw_on_exchange - - @staticmethod - def _key(connection_name, channel_id, user_id): - return f"{connection_name}:{channel_id}:{user_id}" - - @staticmethod - def _exchange_key(connection_name, channel_id, user_id, item): - return f"{connection_name}:{channel_id}:{user_id}:{item}" - - async def get_token(self, user_id, connection_name, channel_id, code=None): - key = self._key(connection_name, channel_id, user_id) - entry = self._store.get(key) - if entry: - token, stored_code = entry - if stored_code is None or (code is not None and code == stored_code): - return TokenResponse( - connection_name=connection_name, - token=token, - channel_id=channel_id, - ) - return None - - async def sign_out(self, user_id, connection_name, channel_id): - key = self._key(connection_name, channel_id, user_id) - self._store.pop(key, None) - - async def exchange_token(self, user_id, connection_name, channel_id, body=None): - token = (body or {}).get("token") or (body or {}).get("uri") - key = self._exchange_key(connection_name, channel_id, user_id, token or "") - if key in self._throw_on_exchange: - raise Exception("Token exchange not allowed for this item.") - result = self._exchange_store.get(key) - if result: - return TokenResponse( - connection_name=connection_name, - token=result, - channel_id=channel_id, - ) - return None - - async def _get_token_or_sign_in_resource( - self, user_id, connection_name, channel_id, state, *_ - ): - key = self._key(connection_name, channel_id, user_id) - entry = self._store.get(key) - if entry: - token, stored_code = entry - if stored_code is None: - return TokenOrSignInResourceResponse( - token_response=TokenResponse( - connection_name=connection_name, - token=token, - channel_id=channel_id, - ) - ) - return TokenOrSignInResourceResponse( - sign_in_resource=SignInResource( - sign_in_link=f"https://token.botframework.com/oauthcards?state={state or ''}" - ) - ) - - -class _MockAgentSignIn: - """Mock agent_sign_in API for dialog tests.""" - - async def get_sign_in_resource(self, state=None): - return SignInResource( - sign_in_link=f"https://token.botframework.com/oauthcards?state={state or ''}", - ) - - -class DialogUserTokenClient: - """ - A lightweight UserTokenClient mock for dialog tests. - Implements the user_token and agent_sign_in APIs used by _UserTokenAccess. - """ - - def __init__(self): - self._store = {} - self._exchange_store = {} - self._throw_on_exchange = {} - self.user_token = _MockUserToken( - self._store, self._exchange_store, self._throw_on_exchange - ) - self.agent_sign_in = _MockAgentSignIn() - - def add_user_token( - self, - connection_name: str, - channel_id: str, - user_id: str, - token: str, - magic_code: str = None, - ): - key = f"{connection_name}:{channel_id}:{user_id}" - self._store[key] = (token, magic_code) - - def add_exchangeable_token( - self, - connection_name: str, - channel_id: str, - user_id: str, - exchangeable_item: str, - token: str, - ): - key = f"{connection_name}:{channel_id}:{user_id}:{exchangeable_item}" - self._exchange_store[key] = token - - def throw_on_exchange_request( - self, - connection_name: str, - channel_id: str, - user_id: str, - exchangeable_item: str, - ): - key = f"{connection_name}:{channel_id}:{user_id}:{exchangeable_item}" - self._throw_on_exchange[key] = True - - -class TestFlow: - """ - Provides a fluent interface for sending messages and asserting replies - in dialog tests. - """ - - def __init__(self, adapter: "DialogTestAdapter", callback: AgentCallbackHandler): - self._adapter = adapter - self._callback = callback - - async def send(self, msg: Union[str, Activity]) -> "TestFlow": - """Send a message or activity to the agent.""" - import asyncio as _asyncio - - # Small delay to ensure time-based timeouts (e.g. OAuthPrompt timeout=1ms) can fire. - await _asyncio.sleep(0.002) - self._adapter.active_queue.clear() - if isinstance(msg, str): - await self._adapter.send_text_to_agent_async(msg, self._callback) - else: - await self._adapter.process_activity_async(msg, self._callback) - return TestFlow(self._adapter, self._callback) - - async def assert_reply( - self, expected: Union[str, Activity, Callable, None] = None - ) -> "TestFlow": - """Assert the next reply matches the expected text, activity, or callable inspector.""" - import inspect - - reply = self._adapter.get_next_reply() - if expected is not None: - if callable(expected) and not isinstance(expected, (str, Activity)): - # Inspector callable: (activity, description) -> bool (sync or async) - result = expected(reply, None) - if inspect.isawaitable(result): - result = await result - assert ( - result is not False - ), f"Inspector returned False for reply: {reply}" - elif isinstance(expected, str): - assert reply is not None, f"Expected reply '{expected}' but got None" - assert ( - reply.text == expected - ), f"Expected reply text '{expected}' but got '{reply.text}'" - elif isinstance(expected, Activity): - assert reply is not None, "Expected a reply but got None" - if expected.text: - assert ( - reply.text == expected.text - ), f"Expected reply text '{expected.text}' but got '{reply.text}'" - if expected.type: - assert ( - reply.type == expected.type - ), f"Expected activity type '{expected.type}' but got '{reply.type}'" - return TestFlow(self._adapter, self._callback) - - -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. - """ - - def __init__(self, callback: AgentCallbackHandler = None, **kwargs): - super().__init__(**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 - self.claims_identity = ClaimsIdentity({"aud": "test-app-id"}, True) - - def add_user_token( - self, - connection_name: str, - channel_id: str, - user_id: str, - token: str, - magic_code: str = None, - ): - """Store a user token for retrieval by OAuthPrompt.""" - self._dialog_token_client.add_user_token( - connection_name, channel_id, user_id, token, magic_code - ) - # Also update the base class token client for compatibility - super().add_user_token(connection_name, channel_id, user_id, token, magic_code) - - def add_exchangeable_token( - self, - connection_name: str, - channel_id: str, - user_id: str, - exchangeable_item: str, - token: str, - ): - self._dialog_token_client.add_exchangeable_token( - connection_name, channel_id, user_id, exchangeable_item, token - ) - super().add_exchangeable_token( - connection_name, channel_id, user_id, exchangeable_item, token - ) - - def throw_on_exchange_request( - self, - connection_name: str, - channel_id: str, - user_id: str, - exchangeable_item: str, - ): - self._dialog_token_client.throw_on_exchange_request( - connection_name, channel_id, user_id, exchangeable_item - ) - super().throw_on_exchange_request( - connection_name, channel_id, user_id, exchangeable_item - ) - - def create_turn_context( - self, activity: Activity, identity: ClaimsIdentity = None - ) -> TurnContext: - """ - Creates a turn context with the dialog token client in turn_state - 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 - ) - return turn_context - - def make_activity(self, text: str = None) -> Activity: - """ - Creates a message activity without setting locale, so that prompts' - default_locale is used when no locale is present in the activity. - This matches botbuilder TestAdapter behavior. - """ - from microsoft_agents.activity import ActivityTypes - - activity = Activity( - type=ActivityTypes.message, - from_property=self.conversation.user, - recipient=self.conversation.agent, - conversation=self.conversation.conversation, - service_url=self.conversation.service_url, - id=str(self._next_id), - text=text, - ) - self._next_id += 1 - return activity - - async def send(self, msg: Union[str, Activity]) -> TestFlow: - """Send a message or activity and return a TestFlow for assertions.""" - self.active_queue.clear() - if isinstance(msg, str): - await self.send_text_to_agent_async(msg, self._callback) - else: - await self.process_activity_async(msg, self._callback) - return TestFlow(self, self._callback) diff --git a/dev/hosting_dialogs/memory/__init__.py b/dev/hosting_dialogs/memory/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/dev/hosting_dialogs/memory/scopes/__init__.py b/dev/hosting_dialogs/memory/scopes/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/dev/hosting_dialogs/memory/scopes/test_memory_scopes.py b/dev/hosting_dialogs/memory/scopes/test_memory_scopes.py deleted file mode 100644 index 5eca2f3f..00000000 --- a/dev/hosting_dialogs/memory/scopes/test_memory_scopes.py +++ /dev/null @@ -1,684 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -# pylint: disable=pointless-string-statement - -from collections import namedtuple - -import pytest - -from microsoft_agents.hosting.core import ( - ConversationState, - MemoryStorage, - TurnContext, - UserState, -) -from microsoft_agents.hosting.dialogs import ( - Dialog, - DialogContext, - DialogContainer, - DialogInstance, - DialogSet, - DialogState, - ObjectPath, -) -from microsoft_agents.hosting.dialogs.memory.scopes import ( - ClassMemoryScope, - ConversationMemoryScope, - DialogContextMemoryScope, - DialogMemoryScope, - UserMemoryScope, - SettingsMemoryScope, - ThisMemoryScope, - TurnMemoryScope, -) -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, - Channels, -) -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class _TestDialog(Dialog): - def __init__(self, id: str, message: str): - super().__init__(id) - - def aux_try_get_value(state): # pylint: disable=unused-argument - return "resolved value" - - ExpressionObject = namedtuple("ExpressionObject", "try_get_value") - self.message = message - self.expression = ExpressionObject(aux_try_get_value) - - async def begin_dialog(self, dialog_context: DialogContext, options: object = None): - dialog_context.active_dialog.state["is_dialog"] = True - await dialog_context.context.send_activity(self.message) - return Dialog.end_of_turn - - -class _TestContainer(DialogContainer): - def __init__(self, id: str, child: Dialog = None): - super().__init__(id) - self.child_id = None - if child: - self.dialogs.add(child) - self.child_id = child.id - - async def begin_dialog(self, dialog_context: DialogContext, options: object = None): - state = dialog_context.active_dialog.state - state["is_container"] = True - if self.child_id: - state["dialog"] = DialogState() - child_dc = self.create_child_context(dialog_context) - return await child_dc.begin_dialog(self.child_id, options) - - return Dialog.end_of_turn - - async def continue_dialog(self, dialog_context: DialogContext): - child_dc = self.create_child_context(dialog_context) - if child_dc: - return await child_dc.continue_dialog() - - return Dialog.end_of_turn - - def create_child_context(self, dialog_context: DialogContext): - state = dialog_context.active_dialog.state - if state["dialog"] is not None: - child_dc = DialogContext( - self.dialogs, dialog_context.context, state["dialog"] - ) - child_dc.parent = dialog_context - return child_dc - - return None - - -_begin_message = Activity( - text="begin", - type=ActivityTypes.message, - channel_id=Channels.test, - service_url="https://test.com", - from_property=ChannelAccount(id="user"), - recipient=ChannelAccount(id="bot"), - conversation=ConversationAccount(id="convo1"), -) - - -class TestMemoryScopes: - @pytest.mark.asyncio - async def test_class_memory_scope_should_find_registered_dialog(self): - # Create ConversationState with MemoryStorage and register the state as middleware. - conversation_state = ConversationState(MemoryStorage()) - - # Create a DialogState property, DialogSet and register the dialogs. - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = _TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - await dialog_state.set( - context, DialogState(dialog_stack=[DialogInstance(id="test", state={})]) - ) - - dialog_context = await dialogs.create_context(context) - - # Run test - scope = ClassMemoryScope() - memory = scope.get_memory(dialog_context) - assert memory, "memory not returned" - assert memory.message == "test message" - assert memory.expression == "resolved value" - - @pytest.mark.asyncio - async def test_class_memory_scope_should_not_allow_set_memory_call(self): - # Create ConversationState with MemoryStorage and register the state as middleware. - conversation_state = ConversationState(MemoryStorage()) - - # Create a DialogState property, DialogSet and register the dialogs. - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = _TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - await dialog_state.set( - context, DialogState(dialog_stack=[DialogInstance(id="test", state={})]) - ) - - dialog_context = await dialogs.create_context(context) - - # Run test - scope = ClassMemoryScope() - with pytest.raises(Exception) as exc_info: - scope.set_memory(dialog_context, {}) - - assert "not supported" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_class_memory_scope_should_not_allow_load_and_save_changes_calls( - self, - ): - # Create ConversationState with MemoryStorage and register the state as middleware. - conversation_state = ConversationState(MemoryStorage()) - - # Create a DialogState property, DialogSet and register the dialogs. - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = _TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - await dialog_state.set( - context, DialogState(dialog_stack=[DialogInstance(id="test", state={})]) - ) - - dialog_context = await dialogs.create_context(context) - - # Run test - scope = ClassMemoryScope() - await scope.load(dialog_context) - memory = scope.get_memory(dialog_context) - with pytest.raises(AttributeError) as exc_info: - memory.message = "foo" - - assert "can't set attribute" in str(exc_info.value) - await scope.save_changes(dialog_context) - assert dialog.message == "test message" - - def test_class_memory_scope_has_unique_name(self): - """ClassMemoryScope must use 'class', not 'settings', to avoid colliding with SettingsMemoryScope.""" - assert ClassMemoryScope().name == "class" - assert ClassMemoryScope().name != SettingsMemoryScope().name - - @pytest.mark.asyncio - async def test_conversation_memory_scope_should_return_conversation_state(self): - # Create ConversationState with MemoryStorage and register the state as middleware. - conversation_state = ConversationState(MemoryStorage()) - - # Create a DialogState property, DialogSet and register the dialogs. - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = _TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - context.turn_state["ConversationState"] = conversation_state - - dialog_context = await dialogs.create_context(context) - - # Initialize conversation state - foo_cls = namedtuple("TestObject", "foo") - conversation_prop = conversation_state.create_property("conversation") - await conversation_prop.set(context, foo_cls(foo="bar")) - await conversation_state.save(context) - - # Run test - scope = ConversationMemoryScope() - memory = scope.get_memory(dialog_context) - assert memory, "memory not returned" - - # TODO: Make get_path_value take conversation.foo - test_obj = ObjectPath.get_path_value(memory, "conversation") - assert test_obj.foo == "bar" - - @pytest.mark.asyncio - async def test_user_memory_scope_should_not_return_state_if_not_loaded(self): - # Initialize user state - storage = MemoryStorage() - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - user_state = UserState(storage) - context.turn_state["UserState"] = user_state - foo_cls = namedtuple("TestObject", "foo") - user_prop = user_state.create_property("conversation") - await user_prop.set(context, foo_cls(foo="bar")) - await user_state.save(context) - - # Replace context and user_state with new instances - context = TurnContext(adapter, _begin_message) - user_state = UserState(storage) - context.turn_state["UserState"] = user_state - - # Create a DialogState property, DialogSet and register the dialogs. - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = _TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - dialog_context = await dialogs.create_context(context) - - # Run test - scope = UserMemoryScope() - memory = scope.get_memory(dialog_context) - assert memory is None, "state returned" - - @pytest.mark.asyncio - async def test_user_memory_scope_should_return_state_once_loaded(self): - # Initialize user state - storage = MemoryStorage() - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - user_state = UserState(storage) - context.turn_state["UserState"] = user_state - foo_cls = namedtuple("TestObject", "foo") - user_prop = user_state.create_property("conversation") - await user_prop.set(context, foo_cls(foo="bar")) - await user_state.save(context) - - # Replace context and conversation_state with instances - context = TurnContext(adapter, _begin_message) - user_state = UserState(storage) - context.turn_state["UserState"] = user_state - - # Create a DialogState property, DialogSet and register the dialogs. - conversation_state = ConversationState(storage) - context.turn_state["ConversationState"] = conversation_state - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = _TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - dialog_context = await dialogs.create_context(context) - - # Run test - scope = UserMemoryScope() - memory = scope.get_memory(dialog_context) - assert memory is None, "state returned" - - await scope.load(dialog_context) - memory = scope.get_memory(dialog_context) - assert memory is not None, "state not returned" - - # TODO: Make get_path_value take conversation.foo - test_obj = ObjectPath.get_path_value(memory, "conversation") - assert test_obj.foo == "bar" - - @pytest.mark.asyncio - async def test_dialog_memory_scope_should_return_containers_state(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - container = _TestContainer("container") - dialogs.add(container) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - scope = DialogMemoryScope() - await dialog_context.begin_dialog("container") - memory = scope.get_memory(dialog_context) - assert memory is not None, "state not returned" - assert memory["is_container"] - - @pytest.mark.asyncio - async def test_dialog_memory_scope_should_return_parent_containers_state_for_children( - self, - ): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - container = _TestContainer("container", _TestDialog("child", "test message")) - dialogs.add(container) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - scope = DialogMemoryScope() - await dialog_context.begin_dialog("container") - child_dc = dialog_context.child - assert child_dc is not None, "No child DC" - memory = scope.get_memory(child_dc) - assert memory is not None, "state not returned" - assert memory["is_container"] - - @pytest.mark.asyncio - async def test_dialog_memory_scope_should_return_childs_state_when_no_parent(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = _TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - scope = DialogMemoryScope() - await dialog_context.begin_dialog("test") - memory = scope.get_memory(dialog_context) - assert memory is not None, "state not returned" - assert memory["is_dialog"] - - @pytest.mark.asyncio - async def test_dialog_memory_scope_should_overwrite_parents_memory(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - container = _TestContainer("container", _TestDialog("child", "test message")) - dialogs.add(container) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - scope = DialogMemoryScope() - await dialog_context.begin_dialog("container") - child_dc = dialog_context.child - assert child_dc is not None, "No child DC" - - foo_cls = namedtuple("TestObject", "foo") - scope.set_memory(child_dc, foo_cls("bar")) - memory = scope.get_memory(child_dc) - assert memory is not None, "state not returned" - assert memory.foo == "bar" - - @pytest.mark.asyncio - async def test_dialog_memory_scope_should_overwrite_active_dialogs_memory(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - container = _TestContainer("container") - dialogs.add(container) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - scope = DialogMemoryScope() - await dialog_context.begin_dialog("container") - foo_cls = namedtuple("TestObject", "foo") - scope.set_memory(dialog_context, foo_cls("bar")) - memory = scope.get_memory(dialog_context) - assert memory is not None, "state not returned" - assert memory.foo == "bar" - - @pytest.mark.asyncio - async def test_dialog_memory_scope_should_raise_error_if_set_memory_called_without_memory( - self, - ): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - container = _TestContainer("container") - dialogs.add(container) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - with pytest.raises(Exception): - scope = DialogMemoryScope() - await dialog_context.begin_dialog("container") - scope.set_memory(dialog_context, None) - - @pytest.mark.asyncio - async def test_dialog_memory_scope_accepts_empty_dict(self): - """set_memory() must accept an empty dict — empty dialog state is valid.""" - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialogs.add(_TestContainer("container")) - - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - await dialog_context.begin_dialog("container") - - scope = DialogMemoryScope() - scope.set_memory(dialog_context, {}) - assert scope.get_memory(dialog_context) == {} - - @pytest.mark.skip(reason="Requires test_settings module not available") - @pytest.mark.asyncio - async def test_settings_memory_scope_should_return_content_of_settings(self): - # pylint: disable=import-outside-toplevel - from tests.hosting_dialogs.memory.scopes.test_settings import DefaultConfig - - # Create a DialogState property, DialogSet and register the dialogs. - conversation_state = ConversationState(MemoryStorage()) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state).add(_TestDialog("test", "test message")) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - settings = DefaultConfig() - dialog_context.context.turn_state["settings"] = settings - - # Run test - scope = SettingsMemoryScope() - memory = scope.get_memory(dialog_context) - assert memory is not None - assert memory.STRING == "test" - assert memory.INT == 3 - assert memory.LIST[0] == "zero" - assert memory.LIST[1] == "one" - assert memory.LIST[2] == "two" - assert memory.LIST[3] == "three" - - @pytest.mark.asyncio - async def test_this_memory_scope_should_return_active_dialogs_state(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = _TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - scope = ThisMemoryScope() - await dialog_context.begin_dialog("test") - memory = scope.get_memory(dialog_context) - assert memory is not None, "state not returned" - assert memory["is_dialog"] - - @pytest.mark.asyncio - async def test_this_memory_scope_should_overwrite_active_dialogs_memory(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - container = _TestContainer("container") - dialogs.add(container) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - scope = ThisMemoryScope() - await dialog_context.begin_dialog("container") - foo_cls = namedtuple("TestObject", "foo") - scope.set_memory(dialog_context, foo_cls("bar")) - memory = scope.get_memory(dialog_context) - assert memory is not None, "state not returned" - assert memory.foo == "bar" - - @pytest.mark.asyncio - async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_memory( - self, - ): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - container = _TestContainer("container") - dialogs.add(container) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - with pytest.raises(Exception): - scope = ThisMemoryScope() - await dialog_context.begin_dialog("container") - scope.set_memory(dialog_context, None) - - @pytest.mark.asyncio - async def test_this_memory_scope_accepts_empty_dict(self): - """set_memory() must accept an empty dict — empty dialog state is valid.""" - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialogs.add(_TestContainer("container")) - - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - await dialog_context.begin_dialog("container") - - scope = ThisMemoryScope() - scope.set_memory(dialog_context, {}) - assert scope.get_memory(dialog_context) == {} - - @pytest.mark.asyncio - async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_active_dialog( - self, - ): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - container = _TestContainer("container") - dialogs.add(container) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - with pytest.raises(Exception): - scope = ThisMemoryScope() - foo_cls = namedtuple("TestObject", "foo") - scope.set_memory(dialog_context, foo_cls("bar")) - - @pytest.mark.asyncio - async def test_turn_memory_scope_should_persist_changes_to_turn_state(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = _TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - scope = TurnMemoryScope() - memory = scope.get_memory(dialog_context) - assert memory is not None, "state not returned" - memory["foo"] = "bar" - memory = scope.get_memory(dialog_context) - assert memory["foo"] == "bar" - - @pytest.mark.asyncio - async def test_turn_memory_scope_should_overwrite_values_in_turn_state(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = _TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - scope = TurnMemoryScope() - foo_cls = namedtuple("TestObject", "foo") - scope.set_memory(dialog_context, foo_cls("bar")) - memory = scope.get_memory(dialog_context) - assert memory is not None, "state not returned" - assert memory.foo == "bar" - - @pytest.mark.asyncio - async def test_turn_memory_scope_preserves_empty_dict(self): - """An empty dict stored in turn state must not be replaced with a new CaseInsensitiveDict.""" - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialogs.add(_TestDialog("test", "test message")) - - adapter = DialogTestAdapter() - context = TurnContext(adapter, _begin_message) - dialog_context = await dialogs.create_context(context) - - scope = TurnMemoryScope() - empty = {} - scope.set_memory(dialog_context, empty) - - retrieved = scope.get_memory(dialog_context) - assert ( - retrieved is empty - ), "get_memory() must return the stored empty dict, not a new one" - - def test_dialog_context_memory_scope_has_unique_name(self): - """DialogContextMemoryScope must not share its scope name with SettingsMemoryScope.""" - dc_scope = DialogContextMemoryScope() - settings_scope = SettingsMemoryScope() - assert dc_scope.name == "dialogContext" - assert dc_scope.name != settings_scope.name diff --git a/dev/hosting_dialogs/memory/scopes/test_settings.py b/dev/hosting_dialogs/memory/scopes/test_settings.py deleted file mode 100644 index 9e21a99a..00000000 --- a/dev/hosting_dialogs/memory/scopes/test_settings.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os - - -class DefaultConfig: - """Bot Configuration""" - - STRING = os.environ.get("STRING", "test") - INT = os.environ.get("INT", 3) - LIST = os.environ.get("LIST", ["zero", "one", "two", "three"]) - NOT_TO_BE_OVERRIDDEN = os.environ.get("NOT_TO_BE_OVERRIDDEN", "one") - TO_BE_OVERRIDDEN = os.environ.get("TO_BE_OVERRIDDEN", "one") diff --git a/dev/hosting_dialogs/memory/test_at_path_resolver.py b/dev/hosting_dialogs/memory/test_at_path_resolver.py deleted file mode 100644 index 61b307e2..00000000 --- a/dev/hosting_dialogs/memory/test_at_path_resolver.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest - -from microsoft_agents.hosting.dialogs.memory.path_resolvers import AtPathResolver - -_PREFIX = "turn.recognized.entities." - - -class TestAtPathResolver: - def setup_method(self): - self.resolver = AtPathResolver() - - def test_simple_entity_no_suffix(self): - """@foo → turn.recognized.entities.foo.first()""" - assert self.resolver.transform_path("@foo") == f"{_PREFIX}foo.first()" - - def test_entity_with_dot_suffix(self): - """@foo.bar → turn.recognized.entities.foo.first().bar""" - assert self.resolver.transform_path("@foo.bar") == f"{_PREFIX}foo.first().bar" - - def test_entity_with_bracket_suffix(self): - """@foo[0] → turn.recognized.entities.foo.first()[0]""" - assert self.resolver.transform_path("@foo[0]") == f"{_PREFIX}foo.first()[0]" - - def test_entity_with_dot_and_bracket_suffix(self): - """@foo.bar[0] — dot comes before bracket, entity name is still just 'foo'""" - assert ( - self.resolver.transform_path("@foo.bar[0]") - == f"{_PREFIX}foo.first().bar[0]" - ) - - def test_non_at_path_is_returned_unchanged(self): - assert self.resolver.transform_path("turn.foo") == "turn.foo" - assert self.resolver.transform_path("user.name") == "user.name" - - def test_empty_path_raises(self): - with pytest.raises(TypeError): - self.resolver.transform_path("") - - def test_at_sign_only_is_returned_unchanged(self): - """A bare '@' has no subsequent path char so the branch is skipped.""" - assert self.resolver.transform_path("@") == "@" diff --git a/dev/hosting_dialogs/test_activity_prompt.py b/dev/hosting_dialogs/test_activity_prompt.py deleted file mode 100644 index 705f87c0..00000000 --- a/dev/hosting_dialogs/test_activity_prompt.py +++ /dev/null @@ -1,286 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest - -from microsoft_agents.hosting.dialogs.prompts import ( - ActivityPrompt, - PromptOptions, - PromptValidatorContext, -) -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import ( - ConversationState, - MemoryStorage, - TurnContext, - MessageFactory, -) -from tests.hosting_dialogs.helpers import DialogTestAdapter -from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus, DialogReason - - -async def validator(prompt_context: PromptValidatorContext): - assert prompt_context.attempt_count > 0 - - activity = prompt_context.recognized.value - - if activity.type == ActivityTypes.event: - if int(activity.value) == 2: - prompt_context.recognized.value = MessageFactory.text(str(activity.value)) - return True - else: - await prompt_context.context.send_activity( - "Please send an 'event'-type Activity with a value of 2." - ) - - return False - - -class SimpleActivityPrompt(ActivityPrompt): - pass - - -class TestActivityPrompt: - def test_activity_prompt_with_empty_id_should_fail(self): - empty_id = "" - with pytest.raises(TypeError): - SimpleActivityPrompt(empty_id, validator) - - def test_activity_prompt_with_none_id_should_fail(self): - none_id = None - with pytest.raises(TypeError): - SimpleActivityPrompt(none_id, validator) - - def test_activity_prompt_with_none_validator_should_fail(self): - none_validator = None - with pytest.raises(TypeError): - SimpleActivityPrompt("EventActivityPrompt", none_validator) - - @pytest.mark.asyncio - async def test_basic_activity_prompt(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator)) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please send an event." - ) - ) - await dialog_context.prompt("EventActivityPrompt", options) - elif results.status == DialogTurnStatus.Complete: - await turn_context.send_activity(results.result) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - event_activity = Activity(type=ActivityTypes.event, value=2) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("please send an event.") - step3 = await step2.send(event_activity) - await step3.assert_reply("2") - - @pytest.mark.asyncio - async def test_retry_activity_prompt(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator)) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please send an event." - ) - ) - await dialog_context.prompt("EventActivityPrompt", options) - elif results.status == DialogTurnStatus.Complete: - await turn_context.send_activity(results.result) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - event_activity = Activity(type=ActivityTypes.event, value=2) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("please send an event.") - step3 = await step2.send("hello again") - step4 = await step3.assert_reply( - "Please send an 'event'-type Activity with a value of 2." - ) - step5 = await step4.send(event_activity) - await step5.assert_reply("2") - - @pytest.mark.asyncio - async def test_activity_prompt_should_return_dialog_end_if_validation_failed(self): - async def aux_validator(prompt_context: PromptValidatorContext): - assert prompt_context, "Validator missing prompt_context" - return False - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add(SimpleActivityPrompt("EventActivityPrompt", aux_validator)) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please send an event." - ), - retry_prompt=Activity( - type=ActivityTypes.message, text="event not received." - ), - ) - await dialog_context.prompt("EventActivityPrompt", options) - elif results.status == DialogTurnStatus.Complete: - await turn_context.send_activity(results.result) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("please send an event.") - step3 = await step2.send("test") - await step3.assert_reply("event not received.") - - @pytest.mark.asyncio - async def test_activity_prompt_resume_dialog_should_return_dialog_end(self): - async def aux_validator(prompt_context: PromptValidatorContext): - assert prompt_context, "Validator missing prompt_context" - return False - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - event_prompt = SimpleActivityPrompt("EventActivityPrompt", aux_validator) - dialogs.add(event_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please send an event." - ) - ) - await dialog_context.prompt("EventActivityPrompt", options) - - second_results = await event_prompt.resume_dialog( - dialog_context, DialogReason.NextCalled - ) - - assert ( - second_results.status == DialogTurnStatus.Waiting - ), "resume_dialog did not returned Dialog.EndOfTurn" - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("please send an event.") - await step2.assert_reply("please send an event.") - - @pytest.mark.asyncio - async def test_activity_prompt_onerror_should_return_dialogcontext(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator)) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please send an event." - ) - ) - - try: - await dialog_context.prompt("EventActivityPrompt", options) - await dialog_context.prompt("Non existent id", options) - except Exception as err: - assert ( - err.data["DialogContext"] - is not None # pylint: disable=no-member - ) - assert ( - err.data["DialogContext"][ # pylint: disable=no-member - "active_dialog" - ] - == "EventActivityPrompt" - ) - else: - raise Exception("Should have thrown an error.") - - elif results.status == DialogTurnStatus.Complete: - await turn_context.send_activity(results.result) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - await adapter.send("hello") - - @pytest.mark.asyncio - async def test_activity_replace_dialog_onerror_should_return_dialogcontext(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator)) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please send an event." - ) - ) - - try: - await dialog_context.prompt("EventActivityPrompt", options) - await dialog_context.replace_dialog("Non existent id", options) - except Exception as err: - assert ( - err.data["DialogContext"] - is not None # pylint: disable=no-member - ) - else: - raise Exception("Should have thrown an error.") - - elif results.status == DialogTurnStatus.Complete: - await turn_context.send_activity(results.result) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - await adapter.send("hello") diff --git a/dev/hosting_dialogs/test_attachment_prompt.py b/dev/hosting_dialogs/test_attachment_prompt.py deleted file mode 100644 index 6d1e3501..00000000 --- a/dev/hosting_dialogs/test_attachment_prompt.py +++ /dev/null @@ -1,287 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import copy -import pytest -from microsoft_agents.hosting.dialogs.prompts import ( - AttachmentPrompt, - PromptOptions, - PromptValidatorContext, -) -from microsoft_agents.activity import Activity, ActivityTypes, Attachment, InputHints -from microsoft_agents.hosting.core import ( - TurnContext, - ConversationState, - MemoryStorage, - MessageFactory, -) -from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class TestAttachmentPrompt: - def test_attachment_prompt_with_empty_id_should_fail(self): - with pytest.raises(TypeError): - AttachmentPrompt("") - - def test_attachment_prompt_with_none_id_should_fail(self): - with pytest.raises(TypeError): - AttachmentPrompt(None) - - @pytest.mark.asyncio - async def test_basic_attachment_prompt(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add(AttachmentPrompt("AttachmentPrompt")) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please add an attachment." - ) - ) - await dialog_context.prompt("AttachmentPrompt", options) - elif results.status == DialogTurnStatus.Complete: - attachment = results.result[0] - content = MessageFactory.text(attachment.content) - await turn_context.send_activity(content) - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - attachment = Attachment(content="some content", content_type="text/plain") - attachment_activity = Activity( - type=ActivityTypes.message, attachments=[attachment] - ) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("please add an attachment.") - step3 = await step2.send(attachment_activity) - await step3.assert_reply("some content") - - @pytest.mark.asyncio - async def test_attachment_prompt_with_input_hint(self): - prompt_activity = Activity( - type=ActivityTypes.message, - text="please add an attachment.", - input_hint=InputHints.accepting_input, - ) - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add(AttachmentPrompt("AttachmentPrompt")) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions(prompt=copy.copy(prompt_activity)) - await dialog_context.prompt("AttachmentPrompt", options) - elif results.status == DialogTurnStatus.Complete: - attachment = results.result[0] - content = MessageFactory.text(attachment.content) - await turn_context.send_activity(content) - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - step1 = await adapter.send("hello") - await step1.assert_reply(prompt_activity) - - @pytest.mark.asyncio - async def test_attachment_prompt_with_validator(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - - async def aux_validator(prompt_context: PromptValidatorContext): - assert prompt_context, "Validator missing prompt_context" - return prompt_context.recognized.succeeded - - dialogs.add(AttachmentPrompt("AttachmentPrompt", aux_validator)) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please add an attachment." - ) - ) - await dialog_context.prompt("AttachmentPrompt", options) - elif results.status == DialogTurnStatus.Complete: - attachment = results.result[0] - content = MessageFactory.text(attachment.content) - await turn_context.send_activity(content) - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - attachment = Attachment(content="some content", content_type="text/plain") - attachment_activity = Activity( - type=ActivityTypes.message, attachments=[attachment] - ) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("please add an attachment.") - step3 = await step2.send(attachment_activity) - await step3.assert_reply("some content") - - @pytest.mark.asyncio - async def test_retry_attachment_prompt(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add(AttachmentPrompt("AttachmentPrompt")) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please add an attachment." - ) - ) - await dialog_context.prompt("AttachmentPrompt", options) - elif results.status == DialogTurnStatus.Complete: - attachment = results.result[0] - content = MessageFactory.text(attachment.content) - await turn_context.send_activity(content) - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - attachment = Attachment(content="some content", content_type="text/plain") - attachment_activity = Activity( - type=ActivityTypes.message, attachments=[attachment] - ) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("please add an attachment.") - step3 = await step2.send("hello again") - step4 = await step3.assert_reply("please add an attachment.") - step5 = await step4.send(attachment_activity) - await step5.assert_reply("some content") - - @pytest.mark.asyncio - async def test_attachment_prompt_with_custom_retry(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - - async def aux_validator(prompt_context: PromptValidatorContext): - assert prompt_context, "Validator missing prompt_context" - return prompt_context.recognized.succeeded - - dialogs.add(AttachmentPrompt("AttachmentPrompt", aux_validator)) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please add an attachment." - ), - retry_prompt=Activity( - type=ActivityTypes.message, text="please try again." - ), - ) - await dialog_context.prompt("AttachmentPrompt", options) - elif results.status == DialogTurnStatus.Complete: - attachment = results.result[0] - content = MessageFactory.text(attachment.content) - await turn_context.send_activity(content) - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - attachment = Attachment(content="some content", content_type="text/plain") - attachment_activity = Activity( - type=ActivityTypes.message, attachments=[attachment] - ) - invalid_activity = Activity(type=ActivityTypes.message, text="invalid") - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("please add an attachment.") - step3 = await step2.send(invalid_activity) - step4 = await step3.assert_reply("please try again.") - step5 = await step4.send(attachment_activity) - await step5.assert_reply("some content") - - @pytest.mark.asyncio - async def test_should_send_ignore_retry_prompt_if_validator_replies(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - - async def aux_validator(prompt_context: PromptValidatorContext): - assert prompt_context, "Validator missing prompt_context" - if not prompt_context.recognized.succeeded: - await prompt_context.context.send_activity("Bad input.") - return prompt_context.recognized.succeeded - - dialogs.add(AttachmentPrompt("AttachmentPrompt", aux_validator)) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="please add an attachment." - ), - retry_prompt=Activity( - type=ActivityTypes.message, text="please try again." - ), - ) - await dialog_context.prompt("AttachmentPrompt", options) - elif results.status == DialogTurnStatus.Complete: - attachment = results.result[0] - content = MessageFactory.text(attachment.content) - await turn_context.send_activity(content) - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - attachment = Attachment(content="some content", content_type="text/plain") - attachment_activity = Activity( - type=ActivityTypes.message, attachments=[attachment] - ) - invalid_activity = Activity(type=ActivityTypes.message, text="invalid") - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("please add an attachment.") - step3 = await step2.send(invalid_activity) - step4 = await step3.assert_reply("Bad input.") - step5 = await step4.send(attachment_activity) - await step5.assert_reply("some content") - - @pytest.mark.asyncio - async def test_should_not_send_retry_if_not_specified(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add(AttachmentPrompt("AttachmentPrompt")) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog("AttachmentPrompt", PromptOptions()) - elif results.status == DialogTurnStatus.Complete: - attachment = results.result[0] - content = MessageFactory.text(attachment.content) - await turn_context.send_activity(content) - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - attachment = Attachment(content="some content", content_type="text/plain") - attachment_activity = Activity( - type=ActivityTypes.message, attachments=[attachment] - ) - - step1 = await adapter.send("hello") - step2 = await step1.send("what?") - step3 = await step2.send(attachment_activity) - await step3.assert_reply("some content") diff --git a/dev/hosting_dialogs/test_choice_prompt.py b/dev/hosting_dialogs/test_choice_prompt.py deleted file mode 100644 index 990ab686..00000000 --- a/dev/hosting_dialogs/test_choice_prompt.py +++ /dev/null @@ -1,991 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - -import pytest -from recognizers_text import Culture - -from microsoft_agents.hosting.core import ConversationState, MemoryStorage, TurnContext -from microsoft_agents.hosting.core import CardFactory -from microsoft_agents.hosting.dialogs import ( - DialogSet, - DialogTurnResult, - DialogTurnStatus, - ChoiceRecognizers, - FindChoicesOptions, -) -from microsoft_agents.hosting.dialogs.choices import ( - Choice, - ChoiceFactoryOptions, - ListStyle, -) -from microsoft_agents.hosting.dialogs.prompts import ( - ChoicePrompt, - PromptCultureModel, - PromptOptions, - PromptValidatorContext, -) -from microsoft_agents.activity import Activity, ActivityTypes -from tests.hosting_dialogs.helpers import DialogTestAdapter - -_color_choices: List[Choice] = [ - Choice(value="red"), - Choice(value="green"), - Choice(value="blue"), -] - -_answer_message: Activity = Activity(text="red", type=ActivityTypes.message) -_invalid_message: Activity = Activity(text="purple", type=ActivityTypes.message) - - -class TestChoicePrompt: - def test_choice_prompt_with_empty_id_should_fail(self): - empty_id = "" - - with pytest.raises(TypeError): - ChoicePrompt(empty_id) - - def test_choice_prompt_with_none_id_should_fail(self): - none_id = None - - with pytest.raises(TypeError): - ChoicePrompt(none_id) - - @pytest.mark.asyncio - async def test_should_call_choice_prompt_using_dc_prompt(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - choice_prompt = ChoicePrompt("ChoicePrompt") - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("ChoicePrompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_call_choice_prompt_with_custom_validator(self): - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - return prompt.recognized.succeeded - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt", validator) - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_invalid_message) - step4 = await step3.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step5 = await step4.send(_answer_message) - await step5.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_send_custom_retry_prompt(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - choice_prompt = ChoicePrompt("prompt") - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - retry_prompt=Activity( - type=ActivityTypes.message, - text="Please choose red, blue, or green.", - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_invalid_message) - step4 = await step3.assert_reply( - "Please choose red, blue, or green. (1) red, (2) green, or (3) blue" - ) - step5 = await step4.send(_answer_message) - await step5.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_send_ignore_retry_prompt_if_validator_replies(self): - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt", validator) - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - retry_prompt=Activity( - type=ActivityTypes.message, - text="Please choose red, blue, or green.", - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_invalid_message) - step4 = await step3.assert_reply("Bad input.") - step5 = await step4.send(_answer_message) - await step5.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_use_default_locale_when_rendering_choices(self): - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt( - "prompt", validator, default_locale=Culture.Spanish - ) - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send(Activity(type=ActivityTypes.message, text="Hello")) - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, o (3) blue" - ) - step3 = await step2.send(_invalid_message) - step4 = await step3.assert_reply("Bad input.") - step5 = await step4.send(Activity(type=ActivityTypes.message, text="red")) - await step5.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_use_context_activity_locale_when_rendering_choices(self): - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt", validator) - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send( - Activity(type=ActivityTypes.message, text="Hello", locale=Culture.Spanish) - ) - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, o (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_use_context_activity_locale_over_default_locale_when_rendering_choices( - self, - ): - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt( - "prompt", validator, default_locale=Culture.Spanish - ) - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send( - Activity(type=ActivityTypes.message, text="Hello", locale=Culture.English) - ) - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_default_to_english_locale(self): - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - locales = [None, "", "not-supported"] - - for locale in locales: - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt", validator) - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - # Activity.locale uses NonEmptyString which rejects None/""; use model_construct to bypass - send_activity = Activity.model_construct( - type=ActivityTypes.message, text="Hello", locale=locale or None - ) - step1 = await adapter.send(send_activity) - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_recognize_locale_variations_of_correct_locales(self): - def cap_ending(locale: str) -> str: - return f"{locale.split('-')[0]}-{locale.split('-')[1].upper()}" - - def title_ending(locale: str) -> str: - return locale[:3] + locale[3].upper() + locale[4:] - - def cap_two_letter(locale: str) -> str: - return locale.split("-")[0].upper() - - def lower_two_letter(locale: str) -> str: - return locale.split("-")[0].upper() - - async def exec_test_for_locale(valid_locale: str, locale_variations: List): - # Hold the correct answer from when a valid locale is used - expected_answer = None - - def inspector(activity: Activity, description: str): - nonlocal expected_answer - - assert not description - - if valid_locale == test_locale: - expected_answer = activity.text - else: - # Ensure we're actually testing a variation. - assert activity.locale != valid_locale - - assert activity.text == expected_answer - return True - - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - test_locale = None - for test_locale in locale_variations: - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt", validator) - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, - text="Please choose a color.", - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send( - Activity( - type=ActivityTypes.message, text="Hello", locale=test_locale - ) - ) - await step1.assert_reply(inspector) - - locales = [ - "zh-cn", - "nl-nl", - "en-us", - "fr-fr", - "de-de", - "it-it", - "ja-jp", - "ko-kr", - "pt-br", - "es-es", - "tr-tr", - "de-de", - ] - - locale_tests = [] - for locale in locales: - locale_tests.append( - [ - locale, - cap_ending(locale), - title_ending(locale), - cap_two_letter(locale), - lower_two_letter(locale), - ] - ) - - # Test each valid locale - for locale_test in locale_tests: - await exec_test_for_locale(locale_test[0], locale_test) - - @pytest.mark.asyncio - async def test_should_recognize_and_use_custom_locale_dict(self): - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - culture = PromptCultureModel( - locale="custom-locale", - no_in_language="customNo", - yes_in_language="customYes", - separator="customSeparator", - inline_or="customInlineOr", - inline_or_more="customInlineOrMore", - ) - - custom_dict = { - culture.locale: ChoiceFactoryOptions( - inline_or=culture.inline_or, - inline_or_more=culture.inline_or_more, - inline_separator=culture.separator, - include_numbers=True, - ) - } - - choice_prompt = ChoicePrompt("prompt", validator, choice_defaults=custom_dict) - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send( - Activity(type=ActivityTypes.message, text="Hello", locale=culture.locale) - ) - await step1.assert_reply( - "Please choose a color. (1) redcustomSeparator(2) greencustomInlineOrMore(3) blue" - ) - - @pytest.mark.asyncio - async def test_should_not_render_choices_if_list_style_none_is_specified(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - style=ListStyle.none, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply("Please choose a color.") - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_create_prompt_with_inline_choices_when_specified(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - choice_prompt.style = ListStyle.in_line - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_create_prompt_with_list_choices_when_specified(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - choice_prompt.style = ListStyle.list_style - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color.\n\n 1. red\n 2. green\n 3. blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_create_prompt_with_suggested_action_style_when_specified( - self, - ): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - style=ListStyle.suggested_action, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply("Please choose a color.") - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_create_prompt_with_auto_style_when_specified(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - style=ListStyle.auto, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_recognize_valid_number_choice(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send("1") - await step3.assert_reply("red") - - @pytest.mark.asyncio - async def test_should_display_choices_on_hero_card(self): - size_choices = ["large", "medium", "small"] - - def assert_expected_activity( - activity: Activity, description - ): # pylint: disable=unused-argument - assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.hero_card - ) - assert activity.attachments[0].content.text == "Please choose a size." - return True - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - # Change the ListStyle of the prompt to ListStyle.hero_card. - choice_prompt.style = ListStyle.hero_card - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a size." - ), - choices=size_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply(assert_expected_activity) - step3 = await step2.send("1") - await step3.assert_reply(size_choices[0]) - - @pytest.mark.asyncio - async def test_should_display_choices_on_hero_card_with_additional_attachment(self): - size_choices = ["large", "medium", "small"] - card = CardFactory.adaptive_card( - { - "type": "AdaptiveCard", - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "version": "1.2", - "body": [], - } - ) - card_activity = Activity(type=ActivityTypes.message, attachments=[card]) - - def assert_expected_activity( - activity: Activity, description - ): # pylint: disable=unused-argument - assert len(activity.attachments) == 2 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.adaptive_card - ) - assert ( - activity.attachments[1].content_type - == CardFactory.content_types.hero_card - ) - return True - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - # Change the ListStyle of the prompt to ListStyle.hero_card. - choice_prompt.style = ListStyle.hero_card - dialogs.add(choice_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions(prompt=card_activity, choices=size_choices) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - await step1.assert_reply(assert_expected_activity) - - def test_should_not_find_a_choice_in_an_utterance_by_ordinal(self): - found = ChoiceRecognizers.recognize_choices( - "the first one please", - _color_choices, - FindChoicesOptions(recognize_numbers=False, recognize_ordinals=False), - ) - assert not found - - def test_should_not_find_a_choice_in_an_utterance_by_numerical_index(self): - found = ChoiceRecognizers.recognize_choices( - "one", - _color_choices, - FindChoicesOptions(recognize_numbers=False, recognize_ordinals=False), - ) - assert not found - - @pytest.mark.asyncio - async def test_choice_prompt_with_empty_choices_renders_but_errors_on_response( - self, - ): - """ChoicePrompt with an empty choices list renders the prompt without - error, but raises TypeError when the user responds. - - This is because Find.find_choices() treats an empty list the same as - None and raises TypeError: "Find: choices cannot be None." Always - provide at least one Choice when using ChoicePrompt. - """ - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - dialogs.add(ChoicePrompt("ChoicePrompt")) - - turns = [] - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - try: - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Choose one:"), - choices=[], - ) - await dialog_context.prompt("ChoicePrompt", options) - turns.append("prompted") - else: - turns.append("continued") - except TypeError as exc: - turns.append(f"error:{exc}") - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - # First turn: prompt is sent without error - await adapter.send("hello") - assert turns[-1] == "prompted" - - # Second turn: recognition raises because choices is empty - await adapter.send("red") - assert turns[-1].startswith("error:") diff --git a/dev/hosting_dialogs/test_component_dialog.py b/dev/hosting_dialogs/test_component_dialog.py deleted file mode 100644 index 2edc46af..00000000 --- a/dev/hosting_dialogs/test_component_dialog.py +++ /dev/null @@ -1,371 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest - -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import ConversationState, MemoryStorage -from microsoft_agents.hosting.dialogs import ( - ComponentDialog, - Dialog, - DialogSet, - DialogTurnResult, - DialogTurnStatus, - WaterfallDialog, - WaterfallStepContext, -) -from microsoft_agents.hosting.dialogs.models.dialog_reason import DialogReason -from microsoft_agents.hosting.dialogs.prompts import NumberPrompt, PromptOptions -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -def _number_prompt_options(text: str) -> PromptOptions: - return PromptOptions(prompt=Activity(type=ActivityTypes.message, text=text)) - - -class TestComponentDialog: - @pytest.mark.asyncio - async def test_begin_dialog_null_dc_raises(self): - dialog = ComponentDialog("dialogId") - with pytest.raises((TypeError, Exception)): - await dialog.begin_dialog(None) - - @pytest.mark.asyncio - async def test_basic_waterfall_with_number_prompt(self): - """A two-step waterfall collects two numbers via NumberPrompt.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - async def step1(step): - return await step.prompt( - "number", _number_prompt_options("Enter a number.") - ) - - async def step2(step): - await step.context.send_activity(f"Thanks for '{int(step.result)}'") - return await step.prompt( - "number", _number_prompt_options("Enter another number.") - ) - - ds.add(WaterfallDialog("test-waterfall", [step1, step2])) - ds.add(NumberPrompt("number", default_locale="en-us")) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("test-waterfall") - elif results.status == DialogTurnStatus.Complete: - await tc.send_activity( - f"Bot received the number '{int(results.result)}'." - ) - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("Enter a number.") - flow = await flow.send("42") - flow = await flow.assert_reply("Thanks for '42'") - flow = await flow.assert_reply("Enter another number.") - flow = await flow.send("64") - await flow.assert_reply("Bot received the number '64'.") - - @pytest.mark.asyncio - async def test_basic_component_dialog(self): - """ComponentDialog encapsulates its own waterfall and prompt.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - class TestComp(ComponentDialog): - def __init__(self): - super().__init__("TestComponentDialog") - - async def step1(step): - return await step.prompt( - "number", _number_prompt_options("Enter a number.") - ) - - async def step2(step): - await step.context.send_activity(f"Thanks for '{int(step.result)}'") - return await step.prompt( - "number", _number_prompt_options("Enter another number.") - ) - - self.add_dialog(WaterfallDialog("test-waterfall", [step1, step2])) - self.add_dialog(NumberPrompt("number", default_locale="en-us")) - - ds.add(TestComp()) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("TestComponentDialog") - elif results.status == DialogTurnStatus.Complete: - await tc.send_activity( - f"Bot received the number '{int(results.result)}'." - ) - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("Enter a number.") - flow = await flow.send("42") - flow = await flow.assert_reply("Thanks for '42'") - flow = await flow.assert_reply("Enter another number.") - flow = await flow.send("64") - await flow.assert_reply("Bot received the number '64'.") - - @pytest.mark.asyncio - async def test_call_dialog_in_parent_component(self): - """A child component can call a dialog registered in its parent.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - child_component = ComponentDialog("childComponent") - - async def child_step1(step): - await step.context.send_activity("Child started.") - return await step.begin_dialog("parentDialog", "test") - - async def child_step2(step): - await step.context.send_activity(f"Child finished. Value: {step.result}") - return await step.end_dialog() - - child_component.add_dialog( - WaterfallDialog("childDialog", [child_step1, child_step2]) - ) - - parent_component = ComponentDialog("parentComponent") - parent_component.add_dialog(child_component) - - async def parent_step(step): - await step.context.send_activity("Parent called.") - return await step.end_dialog(step.options) - - parent_component.add_dialog(WaterfallDialog("parentDialog", [parent_step])) - ds.add(parent_component) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("parentComponent") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("Hi") - flow = await flow.assert_reply("Child started.") - flow = await flow.assert_reply("Parent called.") - await flow.assert_reply("Child finished. Value: test") - - @pytest.mark.asyncio - async def test_call_dialog_defined_in_parent_component(self): - """Child can call parent-registered dialog and receive the return value.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - options = {"value": "test"} - - child_component = ComponentDialog("childComponent") - - async def child_step1(step): - await step.context.send_activity("Child started.") - return await step.begin_dialog("parentDialog", options) - - async def child_step2(step): - assert step.result == "test" - await step.context.send_activity("Child finished.") - return await step.end_dialog() - - child_component.add_dialog( - WaterfallDialog("childDialog", [child_step1, child_step2]) - ) - - parent_component = ComponentDialog("parentComponent") - parent_component.add_dialog(child_component) - - async def parent_step(step): - step_options = step.options - await step.context.send_activity( - f"Parent called with: {step_options['value']}" - ) - return await step.end_dialog(step_options["value"]) - - parent_component.add_dialog(WaterfallDialog("parentDialog", [parent_step])) - ds.add(parent_component) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("parentComponent") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("Hi") - flow = await flow.assert_reply("Child started.") - flow = await flow.assert_reply("Parent called with: test") - await flow.assert_reply("Child finished.") - - @pytest.mark.asyncio - async def test_nested_component_dialog(self): - """Nested ComponentDialogs properly pass control between each other.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - class InnerComp(ComponentDialog): - def __init__(self): - super().__init__("TestComponentDialog") - - async def step1(step): - return await step.prompt( - "number", _number_prompt_options("Enter a number.") - ) - - async def step2(step): - await step.context.send_activity(f"Thanks for '{int(step.result)}'") - return await step.prompt( - "number", _number_prompt_options("Enter another number.") - ) - - self.add_dialog(WaterfallDialog("test-waterfall", [step1, step2])) - self.add_dialog(NumberPrompt("number", default_locale="en-us")) - - class OuterComp(ComponentDialog): - def __init__(self): - super().__init__("TestNestedComponentDialog") - - async def step1(step): - return await step.prompt( - "number", _number_prompt_options("Enter a number.") - ) - - async def step2(step): - await step.context.send_activity(f"Thanks for '{int(step.result)}'") - return await step.prompt( - "number", _number_prompt_options("Enter another number.") - ) - - async def step3(step): - await step.context.send_activity(f"Got '{int(step.result)}'.") - return await step.begin_dialog("TestComponentDialog") - - self.add_dialog( - WaterfallDialog("test-waterfall", [step1, step2, step3]) - ) - self.add_dialog(NumberPrompt("number", default_locale="en-us")) - self.add_dialog(InnerComp()) - - ds.add(OuterComp()) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("TestNestedComponentDialog") - elif results.status == DialogTurnStatus.Complete: - await tc.send_activity( - f"Bot received the number '{int(results.result)}'." - ) - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("Enter a number.") - flow = await flow.send("42") - flow = await flow.assert_reply("Thanks for '42'") - flow = await flow.assert_reply("Enter another number.") - flow = await flow.send("64") - flow = await flow.assert_reply("Got '64'.") - flow = await flow.assert_reply("Enter a number.") - flow = await flow.send("101") - flow = await flow.assert_reply("Thanks for '101'") - flow = await flow.assert_reply("Enter another number.") - flow = await flow.send("5") - await flow.assert_reply("Bot received the number '5'.") - - -class TestComponentDialogOnEndDialogHook: - @pytest.mark.asyncio - async def test_on_end_dialog_called_with_cancel_reason(self): - """on_end_dialog hook receives CancelCalled reason when cancel_all_dialogs() is invoked.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - hook_calls = [] - - class TrackingComp(ComponentDialog): - def __init__(self): - super().__init__("TrackingComp") - - async def waiting_step(step): - return Dialog.end_of_turn - - self.add_dialog(WaterfallDialog("inner-wf", [waiting_step])) - - async def on_end_dialog(self, context, instance, reason): - hook_calls.append(reason) - - ds.add(TrackingComp()) - - turn = [0] - - async def exec(tc): - turn[0] += 1 - dc = await ds.create_context(tc) - if turn[0] == 1: - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("TrackingComp") - else: - # Cancel without continuing so the waterfall doesn't advance - await dc.cancel_all_dialogs() - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - await adapter.send("hi") # starts the dialog, step 0 waits - await adapter.send("cancel") # triggers cancel_all_dialogs directly - - assert DialogReason.CancelCalled in hook_calls - - @pytest.mark.asyncio - async def test_on_end_dialog_called_with_end_reason_on_completion(self): - """on_end_dialog hook receives EndCalled reason when the component finishes normally.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - hook_calls = [] - - class TrackingComp(ComponentDialog): - def __init__(self): - super().__init__("TrackingComp") - - async def ending_step(step): - return await step.end_dialog("done") - - self.add_dialog(WaterfallDialog("inner-wf", [ending_step])) - - async def on_end_dialog(self, context, instance, reason): - hook_calls.append(reason) - - ds.add(TrackingComp()) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("TrackingComp") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - await adapter.send("hi") # starts and immediately completes the component - - assert DialogReason.EndCalled in hook_calls diff --git a/dev/hosting_dialogs/test_confirm_prompt.py b/dev/hosting_dialogs/test_confirm_prompt.py deleted file mode 100644 index b15a2030..00000000 --- a/dev/hosting_dialogs/test_confirm_prompt.py +++ /dev/null @@ -1,493 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - -import pytest - -from microsoft_agents.hosting.core import ( - ConversationState, - MemoryStorage, - TurnContext, - MessageFactory, -) -from microsoft_agents.hosting.dialogs import ( - DialogSet, - DialogTurnResult, - DialogTurnStatus, -) -from microsoft_agents.hosting.dialogs.choices import ( - Choice, - ChoiceFactoryOptions, - ListStyle, -) -from microsoft_agents.hosting.dialogs.prompts import ( - ConfirmPrompt, - PromptCultureModel, - PromptOptions, - PromptValidatorContext, -) -from microsoft_agents.activity import Activity, ActivityTypes -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class TestConfirmPrompt: - def test_confirm_prompt_with_empty_id_should_fail(self): - empty_id = "" - - with pytest.raises(TypeError): - ConfirmPrompt(empty_id) - - def test_confirm_prompt_with_none_id_should_fail(self): - none_id = None - - with pytest.raises(TypeError): - ConfirmPrompt(none_id) - - @pytest.mark.asyncio - async def test_confirm_prompt(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") - dialogs.add(confirm_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Please confirm.") - ) - await dialog_context.prompt("ConfirmPrompt", options) - elif results.status == DialogTurnStatus.Complete: - message_text = "Confirmed" if results.result else "Not confirmed" - await turn_context.send_activity(MessageFactory.text(message_text)) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("Please confirm. (1) Yes or (2) No") - step3 = await step2.send("yes") - await step3.assert_reply("Confirmed") - - @pytest.mark.asyncio - async def test_confirm_prompt_retry(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") - dialogs.add(confirm_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Please confirm."), - retry_prompt=Activity( - type=ActivityTypes.message, - text="Please confirm, say 'yes' or 'no' or something like that.", - ), - ) - await dialog_context.prompt("ConfirmPrompt", options) - elif results.status == DialogTurnStatus.Complete: - message_text = "Confirmed" if results.result else "Not confirmed" - await turn_context.send_activity(MessageFactory.text(message_text)) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("Please confirm. (1) Yes or (2) No") - step3 = await step2.send("lala") - step4 = await step3.assert_reply( - "Please confirm, say 'yes' or 'no' or something like that. (1) Yes or (2) No" - ) - step5 = await step4.send("no") - await step5.assert_reply("Not confirmed") - - @pytest.mark.asyncio - async def test_confirm_prompt_no_options(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") - dialogs.add(confirm_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - await dialog_context.prompt("ConfirmPrompt", PromptOptions()) - elif results.status == DialogTurnStatus.Complete: - message_text = "Confirmed" if results.result else "Not confirmed" - await turn_context.send_activity(MessageFactory.text(message_text)) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply(" (1) Yes or (2) No") - step3 = await step2.send("lala") - step4 = await step3.assert_reply(" (1) Yes or (2) No") - step5 = await step4.send("no") - await step5.assert_reply("Not confirmed") - - @pytest.mark.asyncio - async def test_confirm_prompt_choice_options_numbers(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") - confirm_prompt.choice_options = ChoiceFactoryOptions(include_numbers=True) - confirm_prompt.style = ListStyle.in_line - dialogs.add(confirm_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Please confirm."), - retry_prompt=Activity( - type=ActivityTypes.message, - text="Please confirm, say 'yes' or 'no' or something like that.", - ), - ) - await dialog_context.prompt("ConfirmPrompt", options) - elif results.status == DialogTurnStatus.Complete: - message_text = "Confirmed" if results.result else "Not confirmed" - await turn_context.send_activity(MessageFactory.text(message_text)) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("Please confirm. (1) Yes or (2) No") - step3 = await step2.send("lala") - step4 = await step3.assert_reply( - "Please confirm, say 'yes' or 'no' or something like that. (1) Yes or (2) No" - ) - step5 = await step4.send("2") - await step5.assert_reply("Not confirmed") - - @pytest.mark.asyncio - async def test_confirm_prompt_choice_options_multiple_attempts(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") - confirm_prompt.choice_options = ChoiceFactoryOptions(include_numbers=True) - confirm_prompt.style = ListStyle.in_line - dialogs.add(confirm_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Please confirm."), - retry_prompt=Activity( - type=ActivityTypes.message, - text="Please confirm, say 'yes' or 'no' or something like that.", - ), - ) - await dialog_context.prompt("ConfirmPrompt", options) - elif results.status == DialogTurnStatus.Complete: - message_text = "Confirmed" if results.result else "Not confirmed" - await turn_context.send_activity(MessageFactory.text(message_text)) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("Please confirm. (1) Yes or (2) No") - step3 = await step2.send("lala") - step4 = await step3.assert_reply( - "Please confirm, say 'yes' or 'no' or something like that. (1) Yes or (2) No" - ) - step5 = await step4.send("what") - step6 = await step5.assert_reply( - "Please confirm, say 'yes' or 'no' or something like that. (1) Yes or (2) No" - ) - step7 = await step6.send("2") - await step7.assert_reply("Not confirmed") - - @pytest.mark.asyncio - async def test_confirm_prompt_options_no_numbers(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - confirm_prompt = ConfirmPrompt("ConfirmPrompt", default_locale="English") - confirm_prompt.choice_options = ChoiceFactoryOptions( - include_numbers=False, inline_separator="~" - ) - dialogs.add(confirm_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Please confirm."), - retry_prompt=Activity( - type=ActivityTypes.message, - text="Please confirm, say 'yes' or 'no' or something like that.", - ), - ) - await dialog_context.prompt("ConfirmPrompt", options) - elif results.status == DialogTurnStatus.Complete: - message_text = "Confirmed" if results.result else "Not confirmed" - await turn_context.send_activity(MessageFactory.text(message_text)) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("Please confirm. Yes or No") - step3 = await step2.send("2") - step4 = await step3.assert_reply( - "Please confirm, say 'yes' or 'no' or something like that. Yes or No" - ) - step5 = await step4.send("no") - await step5.assert_reply("Not confirmed") - - @pytest.mark.asyncio - async def test_confirm_prompt_should_default_to_english_locale(self): - locales = [None, "", "not-supported"] - - for locale in locales: - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - confirm_prompt = ConfirmPrompt("ConfirmPrompt") - confirm_prompt.choice_options = ChoiceFactoryOptions(include_numbers=True) - dialogs.add(confirm_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please confirm." - ), - retry_prompt=Activity( - type=ActivityTypes.message, - text="Please confirm, say 'yes' or 'no' or something like that.", - ), - ) - await dialog_context.prompt("ConfirmPrompt", options) - elif results.status == DialogTurnStatus.Complete: - message_text = "Confirmed" if results.result else "Not confirmed" - await turn_context.send_activity(MessageFactory.text(message_text)) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - # Activity.locale uses NonEmptyString which rejects None/""; use model_construct to bypass - send_activity = Activity.model_construct( - type=ActivityTypes.message, text="Hello", locale=locale or None - ) - step1 = await adapter.send(send_activity) - step2 = await step1.assert_reply("Please confirm. (1) Yes or (2) No") - step3 = await step2.send("lala") - step4 = await step3.assert_reply( - "Please confirm, say 'yes' or 'no' or something like that. (1) Yes or (2) No" - ) - step5 = await step4.send("2") - await step5.assert_reply("Not confirmed") - - @pytest.mark.asyncio - async def test_should_recognize_locale_variations_of_correct_locales(self): - def cap_ending(locale: str) -> str: - return f"{locale.split('-')[0]}-{locale.split('-')[1].upper()}" - - def title_ending(locale: str) -> str: - return locale[:3] + locale[3].upper() + locale[4:] - - def cap_two_letter(locale: str) -> str: - return locale.split("-")[0].upper() - - def lower_two_letter(locale: str) -> str: - return locale.split("-")[0].upper() - - async def exec_test_for_locale(valid_locale: str, locale_variations: List): - # Hold the correct answer from when a valid locale is used - expected_answer = None - - def inspector(activity: Activity, description: str): - nonlocal expected_answer - - assert not description - - if valid_locale == test_locale: - expected_answer = activity.text - else: - # Ensure we're actually testing a variation. - assert activity.locale != valid_locale - - assert activity.text == expected_answer - return True - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please confirm." - ) - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - confirmed = results.result - if confirmed: - await turn_context.send_activity("true") - else: - await turn_context.send_activity("false") - - await convo_state.save(turn_context) - - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - test_locale = None - for test_locale in locale_variations: - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ConfirmPrompt("prompt", validator) - dialogs.add(choice_prompt) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send( - Activity( - type=ActivityTypes.message, text="Hello", locale=test_locale - ) - ) - await step1.assert_reply(inspector) - - locales = [ - "zh-cn", - "nl-nl", - "en-us", - "fr-fr", - "de-de", - "it-it", - "ja-jp", - "ko-kr", - "pt-br", - "es-es", - "tr-tr", - "de-de", - ] - - locale_tests = [] - for locale in locales: - locale_tests.append( - [ - locale, - cap_ending(locale), - title_ending(locale), - cap_two_letter(locale), - lower_two_letter(locale), - ] - ) - - # Test each valid locale - for locale_test in locale_tests: - await exec_test_for_locale(locale_test[0], locale_test) - - @pytest.mark.asyncio - async def test_should_recognize_and_use_custom_locale_dict(self): - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - culture = PromptCultureModel( - locale="custom-locale", - no_in_language="customNo", - yes_in_language="customYes", - separator="customSeparator", - inline_or="customInlineOr", - inline_or_more="customInlineOrMore", - ) - - custom_dict = { - culture.locale: ( - Choice(culture.yes_in_language), - Choice(culture.no_in_language), - ChoiceFactoryOptions( - culture.separator, culture.inline_or, culture.inline_or_more, True - ), - ) - } - - confirm_prompt = ConfirmPrompt("prompt", validator, choice_defaults=custom_dict) - dialogs.add(confirm_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Please confirm.") - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send( - Activity(type=ActivityTypes.message, text="Hello", locale=culture.locale) - ) - await step1.assert_reply( - "Please confirm. (1) customYescustomInlineOr(2) customNo" - ) diff --git a/dev/hosting_dialogs/test_date_time_prompt.py b/dev/hosting_dialogs/test_date_time_prompt.py deleted file mode 100644 index ef2aeb9d..00000000 --- a/dev/hosting_dialogs/test_date_time_prompt.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest - -from microsoft_agents.hosting.dialogs.prompts import DateTimePrompt, PromptOptions -from microsoft_agents.hosting.core import ( - MessageFactory, - ConversationState, - MemoryStorage, - TurnContext, -) -from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class TestDatetimePrompt: - @pytest.mark.asyncio - async def test_date_time_prompt(self): - # Create new ConversationState with MemoryStorage and register the state as middleware. - conver_state = ConversationState(MemoryStorage()) - - # Create a DialogState property - dialog_state = conver_state.create_property("dialogState") - - # Create new DialogSet. - dialogs = DialogSet(dialog_state) - - # Create and add DateTime prompt to DialogSet. - date_time_prompt = DateTimePrompt("DateTimePrompt") - dialogs.add(date_time_prompt) - - async def exec_test(turn_context: TurnContext) -> None: - prompt_msg = "What date would you like?" - dialog_context = await dialogs.create_context(turn_context) - - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions(prompt=MessageFactory.text(prompt_msg)) - await dialog_context.begin_dialog("DateTimePrompt", options) - else: - if results.status == DialogTurnStatus.Complete: - resolution = results.result[0] - reply = MessageFactory.text( - f"Timex: '{resolution.timex}' Value: '{resolution.value}'" - ) - await turn_context.send_activity(reply) - await conver_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("What date would you like?") - step3 = await step2.send("5th December 2018 at 9am") - await step3.assert_reply("Timex: '2018-12-05T09' Value: '2018-12-05 09:00:00'") - - @pytest.mark.asyncio - async def test_date_time_prompt_retry_on_invalid_input(self): - """DateTimePrompt sends the retry_prompt when input cannot be recognized, then accepts valid input.""" - conver_state = ConversationState(MemoryStorage()) - dialog_state = conver_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - dialogs.add(DateTimePrompt("DateTimePrompt")) - - async def exec_test(turn_context: TurnContext) -> None: - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=MessageFactory.text("What date?"), - retry_prompt=MessageFactory.text("Not a valid date. Try again."), - ) - await dialog_context.begin_dialog("DateTimePrompt", options) - elif results.status == DialogTurnStatus.Complete: - resolution = results.result[0] - await turn_context.send_activity( - MessageFactory.text(f"Timex: '{resolution.timex}'") - ) - await conver_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("What date?") - step3 = await step2.send("not a date at all xyz") - step4 = await step3.assert_reply("Not a valid date. Try again.") - step5 = await step4.send("5th December 2018") - await step5.assert_reply("Timex: '2018-12-05'") diff --git a/dev/hosting_dialogs/test_dialog.py b/dev/hosting_dialogs/test_dialog.py deleted file mode 100644 index 3bf99d2b..00000000 --- a/dev/hosting_dialogs/test_dialog.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest -from unittest.mock import AsyncMock, MagicMock - -from microsoft_agents.hosting.dialogs import ( - Dialog, - DialogTurnResult, - DialogTurnStatus, - DialogReason, -) - - -class _ConcreteDialog(Dialog): - """Minimal concrete Dialog for testing base class behavior.""" - - async def begin_dialog(self, dialog_context, options=None): - return DialogTurnResult(DialogTurnStatus.Complete) - - -class TestDialog: - def test_null_id_raises(self): - with pytest.raises((TypeError, Exception)): - _ConcreteDialog(None) - - def test_blank_id_raises(self): - with pytest.raises((TypeError, Exception)): - _ConcreteDialog(" ") - - def test_telemetry_client_defaults_non_none(self): - dialog = _ConcreteDialog("A") - assert dialog.telemetry_client is not None - - def test_get_version_returns_id(self): - dialog = _ConcreteDialog("my-dialog") - assert dialog.get_version() == "my-dialog" - - def test_id_property(self): - dialog = _ConcreteDialog("test-id") - assert dialog.id == "test-id" - - @pytest.mark.asyncio - async def test_continue_dialog_calls_end_dialog(self): - """Default continue_dialog calls end_dialog(None) → Complete.""" - dialog = _ConcreteDialog("A") - dc = MagicMock() - dc.end_dialog = AsyncMock( - return_value=DialogTurnResult(DialogTurnStatus.Complete) - ) - - result = await dialog.continue_dialog(dc) - - assert result.status == DialogTurnStatus.Complete - dc.end_dialog.assert_awaited_once_with(None) - - @pytest.mark.asyncio - async def test_resume_dialog_calls_end_dialog_with_result(self): - """Default resume_dialog calls end_dialog(result) → Complete.""" - dialog = _ConcreteDialog("A") - dc = MagicMock() - dc.end_dialog = AsyncMock( - return_value=DialogTurnResult(DialogTurnStatus.Complete, "done") - ) - - result = await dialog.resume_dialog(dc, DialogReason.BeginCalled, "done") - - assert result.status == DialogTurnStatus.Complete - dc.end_dialog.assert_awaited_once_with("done") - - @pytest.mark.asyncio - async def test_reprompt_dialog_is_noop(self): - """Default reprompt_dialog is a no-op (no exception).""" - dialog = _ConcreteDialog("A") - await dialog.reprompt_dialog(MagicMock(), MagicMock()) - - @pytest.mark.asyncio - async def test_end_dialog_is_noop(self): - """Default end_dialog is a no-op (no exception).""" - dialog = _ConcreteDialog("A") - await dialog.end_dialog(MagicMock(), MagicMock(), DialogReason.EndCalled) diff --git a/dev/hosting_dialogs/test_dialog_context.py b/dev/hosting_dialogs/test_dialog_context.py deleted file mode 100644 index 288b7d0a..00000000 --- a/dev/hosting_dialogs/test_dialog_context.py +++ /dev/null @@ -1,293 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest -from unittest.mock import MagicMock - -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.dialogs import ( - ComponentDialog, - Dialog, - DialogContext, - DialogSet, - DialogState, - DialogTurnStatus, - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, -) -from microsoft_agents.hosting.dialogs.models.dialog_instance import DialogInstance -from microsoft_agents.hosting.dialogs.prompts import TextPrompt, PromptOptions -from microsoft_agents.hosting.core import ConversationState, MemoryStorage -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -def _make_dc(): - """Create a minimal DialogContext backed by a MagicMock TurnContext.""" - - class _Stub(ComponentDialog): - def __init__(self): - super().__init__("stub") - - ds = _Stub()._dialogs - mock_tc = MagicMock() - return DialogContext(ds, mock_tc, DialogState()) - - -class TestDialogContext: - def test_null_dialog_set_raises(self): - with pytest.raises(TypeError): - DialogContext(None, MagicMock(), DialogState()) - - def test_null_turn_context_raises(self): - dc = _make_dc() - with pytest.raises(TypeError): - DialogContext(dc.dialogs, None, DialogState()) - - @pytest.mark.asyncio - async def test_begin_dialog_empty_id_raises(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - adapter = DialogTestAdapter() - - async def callback(tc): - dc = await ds.create_context(tc) - with pytest.raises(TypeError): - await dc.begin_dialog("") - - await adapter.send_text_to_agent_async("hi", callback) - - @pytest.mark.asyncio - async def test_prompt_empty_id_raises(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - adapter = DialogTestAdapter() - - async def callback(tc): - dc = await ds.create_context(tc) - with pytest.raises(TypeError): - await dc.prompt("", MagicMock()) - - await adapter.send_text_to_agent_async("hi", callback) - - @pytest.mark.asyncio - async def test_prompt_none_options_raises(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - adapter = DialogTestAdapter() - - async def callback(tc): - dc = await ds.create_context(tc) - with pytest.raises(TypeError): - await dc.prompt("somePrompt", None) - - await adapter.send_text_to_agent_async("hi", callback) - - @pytest.mark.asyncio - async def test_continue_dialog_empty_stack_returns_empty(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - result_holder = {} - adapter = DialogTestAdapter() - - async def callback(tc): - dc = await ds.create_context(tc) - result = await dc.continue_dialog() - result_holder["status"] = result.status - - await adapter.send_text_to_agent_async("hi", callback) - assert result_holder["status"] == DialogTurnStatus.Empty - - def test_active_dialog_none_on_empty_stack(self): - dc = _make_dc() - assert dc.active_dialog is None - - def test_child_none_when_no_active_dialog(self): - dc = _make_dc() - assert dc.child is None - - def test_find_dialog_sync_returns_none_for_unknown(self): - dc = _make_dc() - assert dc.find_dialog_sync("nonexistent") is None - - @pytest.mark.asyncio - async def test_cancel_all_dialogs_empty_stack_returns_empty(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - result_holder = {} - adapter = DialogTestAdapter() - - async def callback(tc): - dc = await ds.create_context(tc) - result = await dc.cancel_all_dialogs() - result_holder["status"] = result.status - - await adapter.send_text_to_agent_async("hi", callback) - assert result_holder["status"] == DialogTurnStatus.Empty - - @pytest.mark.asyncio - async def test_begin_dialog_unknown_id_raises(self): - """begin_dialog raises when the dialog ID is not registered.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - adapter = DialogTestAdapter() - - async def callback(tc): - dc = await ds.create_context(tc) - with pytest.raises(Exception): - await dc.begin_dialog("does-not-exist") - - await adapter.send_text_to_agent_async("hi", callback) - - @pytest.mark.asyncio - async def test_replace_dialog_ends_active_and_starts_new(self): - """replace_dialog pops the active dialog and starts the replacement.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - completed = {} - - async def step1(step): - await step.context.send_activity("step1") - return Dialog.end_of_turn - - async def step2(step): - await step.context.send_activity("replacement") - return await step.end_dialog() - - ds.add(WaterfallDialog("first", [step1])) - ds.add(WaterfallDialog("second", [step2])) - - adapter = DialogTestAdapter() - - async def callback(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("first") - elif results.status == DialogTurnStatus.Waiting: - await dc.replace_dialog("second") - await convo_state.save(tc) - - step = await adapter.send_text_to_agent_async("hi", callback) - step = await adapter.send_text_to_agent_async("next", callback) - completed["done"] = True - assert completed["done"] - - @pytest.mark.asyncio - async def test_find_dialog_returns_none_for_unknown(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - result_holder = {} - adapter = DialogTestAdapter() - - async def callback(tc): - dc = await ds.create_context(tc) - found = await dc.find_dialog("nonexistent") - result_holder["found"] = found - - await adapter.send_text_to_agent_async("hi", callback) - assert result_holder["found"] is None - - @pytest.mark.asyncio - async def test_reprompt_dialog_resends_prompt_activity(self): - """reprompt_dialog re-sends the original prompt when a TextPrompt is waiting.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - ds.add(TextPrompt("text-prompt")) - - async def start_prompt(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please enter text." - ) - ) - await dc.prompt("text-prompt", options) - await convo_state.save(tc) - - async def do_reprompt(tc): - dc = await ds.create_context(tc) - await dc.reprompt_dialog() - await convo_state.save(tc) - - adapter = DialogTestAdapter() - - await adapter.send_text_to_agent_async("hi", start_prompt) - first_prompt = adapter.get_next_reply() - assert first_prompt is not None - assert first_prompt.text == "Please enter text." - - adapter.active_queue.clear() - await adapter.send_text_to_agent_async("anything", do_reprompt) - reprompt_reply = adapter.get_next_reply() - assert reprompt_reply is not None - assert reprompt_reply.text == "Please enter text." - - @pytest.mark.asyncio - async def test_emit_event_bubbles_to_parent_dc(self): - """Events emitted with bubble=True propagate from the inner DC to the parent DC's active dialog.""" - from unittest.mock import MagicMock - - captured_events = [] - - class CapturingDialog(WaterfallDialog): - async def _on_pre_bubble_event(self, dc, event): - captured_events.append(event.name) - return True # mark as handled; stop bubbling - - # ComponentDialog subclasses are the only way to get a no-state DialogSet - class _OuterHolder(ComponentDialog): - def __init__(self): - super().__init__("_outer_holder") - - class _InnerHolder(ComponentDialog): - def __init__(self): - super().__init__("_inner_holder") - - # Outer DC: has CapturingDialog active on the stack - outer_ds = _OuterHolder()._dialogs - outer_ds.add(CapturingDialog("capturing", [])) - outer_state = DialogState() - outer_instance = DialogInstance() - outer_instance.id = "capturing" - outer_instance.state = {} - outer_state.dialog_stack.insert(0, outer_instance) - mock_tc = MagicMock() - outer_dc = DialogContext(outer_ds, mock_tc, outer_state) - - # Inner DC: has a plain WaterfallDialog active; parent is outer_dc - inner_ds = _InnerHolder()._dialogs - inner_ds.add(WaterfallDialog("inner-wf", [])) - inner_state = DialogState() - inner_instance = DialogInstance() - inner_instance.id = "inner-wf" - inner_instance.state = {} - inner_state.dialog_stack.insert(0, inner_instance) - inner_dc = DialogContext(inner_ds, mock_tc, inner_state) - inner_dc.parent = outer_dc - - handled = await inner_dc.emit_event("custom.test.event", "payload", bubble=True) - - assert ( - handled - ), "Event should have been caught by the outer dialog's _on_pre_bubble_event" - assert "custom.test.event" in captured_events diff --git a/dev/hosting_dialogs/test_dialog_extensions.py b/dev/hosting_dialogs/test_dialog_extensions.py deleted file mode 100644 index 082e49ed..00000000 --- a/dev/hosting_dialogs/test_dialog_extensions.py +++ /dev/null @@ -1,188 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -# pylint: disable=ungrouped-imports -import enum -from typing import List -import uuid - -import pytest - -from microsoft_agents.hosting.core import ClaimsIdentity -from microsoft_agents.hosting.core.authorization import AuthenticationConstants -from microsoft_agents.hosting.core import ( - TurnContext, - MessageFactory, - MemoryStorage, - ConversationState, - UserState, -) -from microsoft_agents.activity import ActivityTypes, Activity, EndOfConversationCodes -from microsoft_agents.hosting.dialogs import ( - ComponentDialog, - TextPrompt, - WaterfallDialog, - DialogInstance, - DialogReason, - WaterfallStepContext, - PromptOptions, - Dialog, - DialogExtensions, - DialogEvents, -) -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class SimpleComponentDialog(ComponentDialog): - def __init__(self): - super().__init__("SimpleComponentDialog") - - self.add_dialog(TextPrompt("TextPrompt")) - self.add_dialog( - WaterfallDialog("WaterfallDialog", [self.prompt_for_name, self.final_step]) - ) - - self.initial_dialog_id = "WaterfallDialog" - self.end_reason = DialogReason.BeginCalled - - async def end_dialog( - self, context: TurnContext, instance: DialogInstance, reason: DialogReason - ) -> None: - self.end_reason = reason - return await super().end_dialog(context, instance, reason) - - async def prompt_for_name(self, step_context: WaterfallStepContext): - return await step_context.prompt( - "TextPrompt", - PromptOptions( - prompt=MessageFactory.text("Hello, what is your name?"), - retry_prompt=MessageFactory.text("Hello, what is your name again?"), - ), - ) - - async def final_step(self, step_context: WaterfallStepContext): - await step_context.context.send_activity( - f"Hello {step_context.result}, nice to meet you!" - ) - return await step_context.end_dialog(step_context.result) - - -class FlowTestCase(enum.Enum): - root_bot_only = 1 - root_bot_consuming_skill = 2 - middle_skill = 3 - leaf_skill = 4 - - -class TestDialogExtensions: - def setup_method(self): - self.eoc_sent: Activity = None - self.skill_bot_id = str(uuid.uuid4()) - self.parent_bot_id = str(uuid.uuid4()) - - def _create_test_flow( - self, dialog: Dialog, test_case: FlowTestCase - ) -> DialogTestAdapter: - """ - Creates a DialogTestAdapter configured for the given test case. - Returns the adapter (which supports send/assert_reply as a TestFlow entry point). - """ - conversation_id = str(uuid.uuid4()) - storage = MemoryStorage() - convo_state = ConversationState(storage) - - eoc_sent_ref = [None] - self.eoc_sent = None - - async def logic(context: TurnContext): - if test_case != FlowTestCase.root_bot_only: - claims_identity = ClaimsIdentity( - { - AuthenticationConstants.VERSION_CLAIM: "2.0", - AuthenticationConstants.AUDIENCE_CLAIM: self.skill_bot_id, - AuthenticationConstants.AUTHORIZED_PARTY: self.parent_bot_id, - }, - True, - ) - context._identity = claims_identity - - async def capture_eoc( - inner_context: TurnContext, activities: List[Activity], next - ): # pylint: disable=unused-argument - for activity in activities: - if activity.type == ActivityTypes.end_of_conversation: - self.eoc_sent = activity - eoc_sent_ref[0] = activity - break - return await next() - - context.on_send_activities(capture_eoc) - - await DialogExtensions.run_dialog( - dialog, context, convo_state.create_property("DialogState") - ) - - return DialogTestAdapter(logic) - - @pytest.mark.asyncio - async def test_handles_root_bot_only(self): - dialog = SimpleComponentDialog() - adapter = self._create_test_flow(dialog, FlowTestCase.root_bot_only) - - step1 = await adapter.send("Hi") - step2 = await step1.assert_reply("Hello, what is your name?") - step3 = await step2.send("SomeName") - await step3.assert_reply("Hello SomeName, nice to meet you!") - - assert dialog.end_reason == DialogReason.EndCalled - assert ( - self.eoc_sent is None - ), "Root bot should not send EndConversation to channel" - - @pytest.mark.skip( - reason="Requires skill infrastructure (SkillHandler/SkillConversationReference) not available in new SDK" - ) - @pytest.mark.asyncio - async def test_handles_root_bot_consuming_skill(self): - pass - - @pytest.mark.skip( - reason="Requires skill infrastructure (SkillHandler/SkillConversationReference) not available in new SDK" - ) - @pytest.mark.asyncio - async def test_handles_middle_skill(self): - pass - - @pytest.mark.skip( - reason="Requires skill infrastructure (SkillHandler/SkillConversationReference) not available in new SDK" - ) - @pytest.mark.asyncio - async def test_handles_leaf_skill(self): - pass - - @pytest.mark.skip( - reason="Requires skill infrastructure (SkillHandler/SkillConversationReference) not available in new SDK" - ) - @pytest.mark.asyncio - async def test_skill_handles_eoc_from_parent(self): - pass - - @pytest.mark.asyncio - async def test_skill_handles_reprompt_from_parent(self): - """ - Tests that a reprompt event causes the dialog to re-prompt. - This test does not require skill infrastructure. - """ - dialog = SimpleComponentDialog() - adapter = self._create_test_flow(dialog, FlowTestCase.root_bot_only) - - step1 = await adapter.send("Hi") - step2 = await step1.assert_reply("Hello, what is your name?") - await step2.send( - Activity( - type=ActivityTypes.event, - name=DialogEvents.reprompt_dialog, - ) - ) - - assert dialog.end_reason == DialogReason.BeginCalled diff --git a/dev/hosting_dialogs/test_dialog_manager.py b/dev/hosting_dialogs/test_dialog_manager.py deleted file mode 100644 index fa39d2f8..00000000 --- a/dev/hosting_dialogs/test_dialog_manager.py +++ /dev/null @@ -1,285 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -# pylint: disable=pointless-string-statement - -from enum import Enum -from typing import Callable, List, Tuple - -import pytest - -from microsoft_agents.hosting.core import ( - ConversationState, - MemoryStorage, - MessageFactory, - UserState, - TurnContext, -) -from microsoft_agents.hosting.core import ClaimsIdentity -from microsoft_agents.hosting.core.authorization import AuthenticationConstants -from microsoft_agents.hosting.dialogs import ( - ComponentDialog, - Dialog, - DialogContext, - DialogEvents, - DialogInstance, - DialogReason, - TextPrompt, - WaterfallDialog, - DialogManager, - DialogManagerResult, - DialogTurnStatus, - WaterfallStepContext, -) -from microsoft_agents.hosting.dialogs.prompts import PromptOptions -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, - EndOfConversationCodes, - InputHints, - Channels, -) -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class SkillFlowTestCase(str, Enum): - # DialogManager is executing on a root bot with no skills (typical standalone bot). - root_bot_only = "RootBotOnly" - - # DialogManager is executing on a root bot handling replies from a skill. - root_bot_consuming_skill = "RootBotConsumingSkill" - - # DialogManager is executing in a skill that is called from a root and calling another skill. - middle_skill = "MiddleSkill" - - # DialogManager is executing in a skill that is called from a parent (a root or another skill) but doesn't call - # another skill. - leaf_skill = "LeafSkill" - - -class SimpleComponentDialog(ComponentDialog): - # An App ID for a parent bot. - parent_bot_id = "00000000-0000-0000-0000-0000000000PARENT" - - # An App ID for a skill bot. - skill_bot_id = "00000000-0000-0000-0000-00000000000SKILL" - - # Captures an EndOfConversation if it was sent to help with assertions. - eoc_sent: Activity = None - - # Property to capture the DialogManager turn results and do assertions. - dm_turn_result: DialogManagerResult = None - - def __init__( - self, id: str = None, prop: str = None - ): # pylint: disable=unused-argument - super().__init__(id or "SimpleComponentDialog") - self.text_prompt = "TextPrompt" - self.waterfall_dialog = "WaterfallDialog" - self.add_dialog(TextPrompt(self.text_prompt)) - self.add_dialog( - WaterfallDialog( - self.waterfall_dialog, - [ - self.prompt_for_name, - self.final_step, - ], - ) - ) - self.initial_dialog_id = self.waterfall_dialog - self.end_reason = None - - @staticmethod - async def create_test_flow( - dialog: Dialog, - test_case: SkillFlowTestCase = SkillFlowTestCase.root_bot_only, - enabled_trace=False, - ) -> DialogTestAdapter: - conversation_id = "testFlowConversationId" - storage = MemoryStorage() - conversation_state = ConversationState(storage) - user_state = UserState(storage) - - activity = Activity( - type=ActivityTypes.message, - channel_id=Channels.test, - service_url="https://test.com", - from_property=ChannelAccount(id="user1", name="User1"), - recipient=ChannelAccount(id="bot", name="Bot"), - conversation=ConversationAccount( - is_group=False, conversation_type=conversation_id, id=conversation_id - ), - ) - - dialog_manager = DialogManager(dialog) - dialog_manager.user_state = user_state - dialog_manager.conversation_state = conversation_state - - async def logic(context: TurnContext): - if test_case != SkillFlowTestCase.root_bot_only: - # Create a skill ClaimsIdentity and put it in turn_state so isSkillClaim() returns True. - claims_identity = ClaimsIdentity({}, False) - claims_identity.claims[ - "ver" - ] = "2.0" # AuthenticationConstants.VersionClaim - claims_identity.claims[ - "aud" - ] = ( - SimpleComponentDialog.skill_bot_id - ) # AuthenticationConstants.AudienceClaim - claims_identity.claims[ - "azp" - ] = ( - SimpleComponentDialog.parent_bot_id - ) # AuthenticationConstants.AuthorizedParty - context._identity = claims_identity - - # Note: SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY based skill routing - # is not available in the new SDK. Skill flow test cases that require it are skipped. - - async def aux( - turn_context: TurnContext, # pylint: disable=unused-argument - activities: List[Activity], - next: Callable, - ): - for activity in activities: - if activity.type == ActivityTypes.end_of_conversation: - SimpleComponentDialog.eoc_sent = activity - break - - return await next() - - # Interceptor to capture the EoC activity if it was sent so we can assert it in the tests. - context.on_send_activities(aux) - - SimpleComponentDialog.dm_turn_result = await dialog_manager.on_turn(context) - - # Manually save state since AutoSaveStateMiddleware is not available - await conversation_state.save(context) - await user_state.save(context) - - adapter = DialogTestAdapter(logic) - if enabled_trace: - adapter.enable_trace = True - - return adapter - - async def on_end_dialog( - self, context: DialogContext, instance: DialogInstance, reason: DialogReason - ): - self.end_reason = reason - return await super().on_end_dialog(context, instance, reason) - - async def prompt_for_name(self, step: WaterfallStepContext): - return await step.prompt( - self.text_prompt, - PromptOptions( - prompt=MessageFactory.text( - "Hello, what is your name?", None, InputHints.expecting_input - ), - retry_prompt=MessageFactory.text( - "Hello, what is your name again?", None, InputHints.expecting_input - ), - ), - ) - - async def final_step(self, step: WaterfallStepContext): - await step.context.send_activity(f"Hello { step.result }, nice to meet you!") - return await step.end_dialog(step.result) - - -class TestDialogManager: - @pytest.mark.asyncio - async def test_handles_root_bot_only(self): - SimpleComponentDialog.dm_turn_result = None - SimpleComponentDialog.eoc_sent = None - dialog = SimpleComponentDialog() - test_flow = await SimpleComponentDialog.create_test_flow( - dialog, SkillFlowTestCase.root_bot_only - ) - step1 = await test_flow.send("Hi") - step2 = await step1.assert_reply("Hello, what is your name?") - step3 = await step2.send("SomeName") - await step3.assert_reply("Hello SomeName, nice to meet you!") - - assert ( - SimpleComponentDialog.dm_turn_result.turn_result.status - == DialogTurnStatus.Complete - ) - assert dialog.end_reason == DialogReason.EndCalled - assert ( - SimpleComponentDialog.eoc_sent is None - ), "Root bot should not send EndConversation to channel" - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_handles_root_bot_consuming_skill(self): - pass - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_handles_middle_skill(self): - pass - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_handles_leaf_skill(self): - pass - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_skill_handles_eoc_from_parent(self): - pass - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_skill_handles_reprompt_from_parent(self): - pass - - @pytest.mark.skip( - reason="Requires SkillHandler/SkillConversationReference skill infrastructure not available in new SDK" - ) - @pytest.mark.asyncio - async def test_skill_should_return_empty_on_reprompt_with_no_dialog(self): - pass - - @pytest.mark.asyncio - async def test_trace_bot_state(self): - SimpleComponentDialog.dm_turn_result = None - dialog = SimpleComponentDialog() - - def assert_is_trace(activity, description): # pylint: disable=unused-argument - assert activity.type == ActivityTypes.trace - return True - - def assert_is_trace_and_label(activity, description): - assert_is_trace(activity, description) - assert activity.label == "Bot State" - return True - - test_flow = await SimpleComponentDialog.create_test_flow( - dialog, SkillFlowTestCase.root_bot_only, True - ) - - step1 = await test_flow.send("Hi") - step2 = await step1.assert_reply("Hello, what is your name?") - step3 = await step2.assert_reply(assert_is_trace_and_label) - step4 = await step3.send("SomeName") - step5 = await step4.assert_reply("Hello SomeName, nice to meet you!") - await step5.assert_reply(assert_is_trace_and_label) - - assert ( - SimpleComponentDialog.dm_turn_result.turn_result.status - == DialogTurnStatus.Complete - ) diff --git a/dev/hosting_dialogs/test_dialog_set.py b/dev/hosting_dialogs/test_dialog_set.py deleted file mode 100644 index ef9fee1e..00000000 --- a/dev/hosting_dialogs/test_dialog_set.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest -from microsoft_agents.hosting.dialogs import DialogSet, ComponentDialog, WaterfallDialog -from microsoft_agents.hosting.core import ConversationState, MemoryStorage -from microsoft_agents.hosting.dialogs._telemetry_client import NullTelemetryClient - - -class MyBotTelemetryClient(NullTelemetryClient): - def __init__(self): - super().__init__() - - -class TestDialogSet: - def test_dialogset_constructor_valid(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state_property = convo_state.create_property("dialogstate") - dialog_set = DialogSet(dialog_state_property) - assert dialog_set is not None - - def test_dialogset_constructor_null_property(self): - with pytest.raises(TypeError): - DialogSet(None) - - def test_dialogset_constructor_null_from_componentdialog(self): - ComponentDialog("MyId") - - def test_dialogset_telemetryset(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state_property = convo_state.create_property("dialogstate") - dialog_set = DialogSet(dialog_state_property) - - dialog_set.add(WaterfallDialog("A")) - dialog_set.add(WaterfallDialog("B")) - - assert isinstance( - dialog_set.find_dialog("A").telemetry_client, NullTelemetryClient - ) - assert isinstance( - dialog_set.find_dialog("B").telemetry_client, NullTelemetryClient - ) - - dialog_set.telemetry_client = MyBotTelemetryClient() - - assert isinstance( - dialog_set.find_dialog("A").telemetry_client, MyBotTelemetryClient - ) - assert isinstance( - dialog_set.find_dialog("B").telemetry_client, MyBotTelemetryClient - ) - - def test_add_duplicate_dialog_id_raises(self): - """Adding a second dialog with the same ID raises TypeError.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state_property = convo_state.create_property("dialogstate") - dialog_set = DialogSet(dialog_state_property) - - dialog_set.add(WaterfallDialog("A")) - with pytest.raises(TypeError): - dialog_set.add(WaterfallDialog("A")) - - def test_add_none_dialog_raises(self): - """Adding None raises TypeError.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state_property = convo_state.create_property("dialogstate") - dialog_set = DialogSet(dialog_state_property) - with pytest.raises(TypeError): - dialog_set.add(None) - - def test_get_version_is_stable(self): - """get_version() returns the same hash on repeated calls.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state_property = convo_state.create_property("dialogstate") - dialog_set = DialogSet(dialog_state_property) - dialog_set.add(WaterfallDialog("A")) - v1 = dialog_set.get_version() - v2 = dialog_set.get_version() - assert v1 == v2 - - def test_dialogset_nulltelemetryset(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state_property = convo_state.create_property("dialogstate") - dialog_set = DialogSet(dialog_state_property) - - dialog_set.add(WaterfallDialog("A")) - dialog_set.add(WaterfallDialog("B")) - - dialog_set.telemetry_client = MyBotTelemetryClient() - dialog_set.telemetry_client = None - - assert not isinstance( - dialog_set.find_dialog("A").telemetry_client, MyBotTelemetryClient - ) - assert not isinstance( - dialog_set.find_dialog("B").telemetry_client, MyBotTelemetryClient - ) - assert isinstance( - dialog_set.find_dialog("A").telemetry_client, NullTelemetryClient - ) - assert isinstance( - dialog_set.find_dialog("B").telemetry_client, NullTelemetryClient - ) - - def test_get_version_is_invalidated_after_add(self): - """get_version() must reflect all dialogs even when add() is called after an earlier get_version().""" - convo_state = ConversationState(MemoryStorage()) - dialog_state_property = convo_state.create_property("dialogstate") - dialog_set = DialogSet(dialog_state_property) - - dialog_set.add(WaterfallDialog("A")) - version_before = dialog_set.get_version() - - dialog_set.add(WaterfallDialog("B")) - version_after = dialog_set.get_version() - - assert version_before != version_after, ( - "get_version() returned the same hash after adding a new dialog; " - "the version cache was not invalidated by add()" - ) - - def test_get_version_is_invalidated_after_telemetry_change(self): - """Changing the telemetry client must invalidate the cached version.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state_property = convo_state.create_property("dialogstate") - dialog_set = DialogSet(dialog_state_property) - dialog_set.add(WaterfallDialog("A")) - - version_before = dialog_set.get_version() - dialog_set.telemetry_client = MyBotTelemetryClient() - version_after = dialog_set.get_version() - - # Versions may or may not differ depending on whether telemetry affects - # individual dialog versions, but the cache must at least be recomputed - # (i.e. get_version() must not return the stale pre-change value from cache). - # We verify this by checking the cache was cleared (recomputed == same value is fine). - assert version_after is not None diff --git a/dev/hosting_dialogs/test_number_prompt.py b/dev/hosting_dialogs/test_number_prompt.py deleted file mode 100644 index 06f01827..00000000 --- a/dev/hosting_dialogs/test_number_prompt.py +++ /dev/null @@ -1,378 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -from typing import Callable - -import pytest -from recognizers_text import Culture - -from microsoft_agents.hosting.core import ( - TurnContext, - ConversationState, - MemoryStorage, - MessageFactory, -) -from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus, DialogContext -from microsoft_agents.hosting.dialogs.prompts import ( - NumberPrompt, - PromptOptions, - PromptValidatorContext, -) -from microsoft_agents.activity import Activity, ActivityTypes -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class NumberPromptMock(NumberPrompt): - def __init__( - self, - dialog_id: str, - validator: Callable[[PromptValidatorContext], bool] = None, - default_locale=None, - ): - super().__init__(dialog_id, validator, default_locale) - - async def on_prompt_null_context(self, options: PromptOptions): - # Should throw TypeError - await self.on_prompt( - turn_context=None, state=None, options=options, is_retry=False - ) - - async def on_prompt_null_options(self, dialog_context: DialogContext): - # Should throw TypeError - await self.on_prompt( - dialog_context.context, state=None, options=None, is_retry=False - ) - - async def on_recognize_null_context(self): - # Should throw TypeError - await self.on_recognize(turn_context=None, state=None, options=None) - - -class TestNumberPrompt: - def test_empty_id_should_fail(self): - empty_id = "" - with pytest.raises(TypeError): - NumberPrompt(empty_id) - - def test_none_id_should_fail(self): - with pytest.raises(TypeError): - NumberPrompt(dialog_id=None) - - @pytest.mark.asyncio - async def test_with_null_turn_context_should_fail(self): - number_prompt_mock = NumberPromptMock("NumberPromptMock") - - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Please send a number.") - ) - - with pytest.raises(TypeError): - await number_prompt_mock.on_prompt_null_context(options) - - @pytest.mark.asyncio - async def test_on_prompt_with_null_options_fails(self): - conver_state = ConversationState(MemoryStorage()) - dialog_state = conver_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - number_prompt_mock = NumberPromptMock( - dialog_id="NumberPromptMock", validator=None, default_locale=Culture.English - ) - dialogs.add(number_prompt_mock) - - with pytest.raises(TypeError): - await number_prompt_mock.on_recognize_null_context() - - @pytest.mark.asyncio - async def test_number_prompt(self): - # Create new ConversationState with MemoryStorage and register the state as middleware. - conver_state = ConversationState(MemoryStorage()) - - # Create a DialogState property, DialogSet and register the WaterfallDialog. - dialog_state = conver_state.create_property("dialogState") - - dialogs = DialogSet(dialog_state) - - # Create and add number prompt to DialogSet. - number_prompt = NumberPrompt("NumberPrompt", None, Culture.English) - dialogs.add(number_prompt) - - async def exec_test(turn_context: TurnContext) -> None: - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog( - "NumberPrompt", - PromptOptions( - prompt=MessageFactory.text("Enter quantity of cable") - ), - ) - else: - if results.status == DialogTurnStatus.Complete: - number_result = results.result - await turn_context.send_activity( - MessageFactory.text( - f"You asked me for '{number_result}' meters of cable." - ) - ) - - await conver_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply("Enter quantity of cable") - step3 = await step2.send("Give me twenty meters of cable") - await step3.assert_reply("You asked me for '20' meters of cable.") - - @pytest.mark.asyncio - async def test_number_prompt_retry(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - number_prompt = NumberPrompt( - dialog_id="NumberPrompt", validator=None, default_locale=Culture.English - ) - dialogs.add(number_prompt) - - async def exec_test(turn_context: TurnContext) -> None: - dialog_context: DialogContext = await dialogs.create_context(turn_context) - - results = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter a number."), - retry_prompt=Activity( - type=ActivityTypes.message, text="You must enter a number." - ), - ) - await dialog_context.prompt("NumberPrompt", options) - elif results.status == DialogTurnStatus.Complete: - number_result = results.result - await turn_context.send_activity( - MessageFactory.text(f"Bot received the number '{number_result}'.") - ) - - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("Enter a number.") - step3 = await step2.send("hello") - step4 = await step3.assert_reply("You must enter a number.") - step5 = await step4.send("64") - await step5.assert_reply("Bot received the number '64'.") - - @pytest.mark.asyncio - async def test_number_uses_locale_specified_in_constructor(self): - # Create new ConversationState with MemoryStorage and register the state as middleware. - conver_state = ConversationState(MemoryStorage()) - - # Create a DialogState property, DialogSet and register the WaterfallDialog. - dialog_state = conver_state.create_property("dialogState") - - dialogs = DialogSet(dialog_state) - - # Create and add number prompt to DialogSet. - number_prompt = NumberPrompt( - "NumberPrompt", None, default_locale=Culture.Spanish - ) - dialogs.add(number_prompt) - - async def exec_test(turn_context: TurnContext) -> None: - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog( - "NumberPrompt", - PromptOptions( - prompt=MessageFactory.text( - "How much money is in your gaming account?" - ) - ), - ) - else: - if results.status == DialogTurnStatus.Complete: - number_result = results.result - await turn_context.send_activity( - MessageFactory.text( - f"You say you have ${number_result} in your gaming account." - ) - ) - - await conver_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply("How much money is in your gaming account?") - step3 = await step2.send("I've got $1.200.555,42 in my account.") - await step3.assert_reply("You say you have $1200555.42 in your gaming account.") - - @pytest.mark.asyncio - async def test_number_prompt_validator(self): - # Create new ConversationState with MemoryStorage and register the state as middleware. - conver_state = ConversationState(MemoryStorage()) - - # Create a DialogState property, DialogSet and register the WaterfallDialog. - dialog_state = conver_state.create_property("dialogState") - - dialogs = DialogSet(dialog_state) - - # Create and add number prompt to DialogSet. - async def validator(prompt_context: PromptValidatorContext): - result = prompt_context.recognized.value - - if 0 < result < 100: - return True - - return False - - number_prompt = NumberPrompt( - "NumberPrompt", validator, default_locale=Culture.English - ) - dialogs.add(number_prompt) - - async def exec_test(turn_context: TurnContext) -> None: - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter a number."), - retry_prompt=Activity( - type=ActivityTypes.message, - text="You must enter a positive number less than 100.", - ), - ) - await dialog_context.prompt("NumberPrompt", options) - - elif results.status == DialogTurnStatus.Complete: - number_result = int(results.result) - await turn_context.send_activity( - MessageFactory.text(f"Bot received the number '{number_result}'.") - ) - - await conver_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("Enter a number.") - step3 = await step2.send("150") - step4 = await step3.assert_reply( - "You must enter a positive number less than 100." - ) - step5 = await step4.send("64") - await step5.assert_reply("Bot received the number '64'.") - - @pytest.mark.asyncio - async def test_float_number_prompt(self): - # Create new ConversationState with MemoryStorage and register the state as middleware. - conver_state = ConversationState(MemoryStorage()) - - # Create a DialogState property, DialogSet and register the WaterfallDialog. - dialog_state = conver_state.create_property("dialogState") - - dialogs = DialogSet(dialog_state) - - # Create and add number prompt to DialogSet. - number_prompt = NumberPrompt( - "NumberPrompt", validator=None, default_locale=Culture.English - ) - dialogs.add(number_prompt) - - async def exec_test(turn_context: TurnContext) -> None: - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter a number.") - ) - await dialog_context.prompt("NumberPrompt", options) - - elif results.status == DialogTurnStatus.Complete: - number_result = float(results.result) - await turn_context.send_activity( - MessageFactory.text(f"Bot received the number '{number_result}'.") - ) - - await conver_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("Enter a number.") - step3 = await step2.send("3.14") - await step3.assert_reply("Bot received the number '3.14'.") - - @pytest.mark.asyncio - async def test_number_prompt_uses_locale_specified_in_activity(self): - conver_state = ConversationState(MemoryStorage()) - dialog_state = conver_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - number_prompt = NumberPrompt("NumberPrompt", None, None) - dialogs.add(number_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter a number.") - ) - await dialog_context.prompt("NumberPrompt", options) - - elif results.status == DialogTurnStatus.Complete: - number_result = float(results.result) - assert 3.14 == number_result - - await conver_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("Enter a number.") - await step2.send( - Activity(type=ActivityTypes.message, text="3,14", locale=Culture.Spanish) - ) - - @pytest.mark.asyncio - async def test_number_prompt_defaults_to_en_us_culture(self): - conver_state = ConversationState(MemoryStorage()) - dialog_state = conver_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - number_prompt = NumberPrompt("NumberPrompt") - dialogs.add(number_prompt) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter a number.") - ) - await dialog_context.prompt("NumberPrompt", options) - - elif results.status == DialogTurnStatus.Complete: - number_result = float(results.result) - await turn_context.send_activity( - MessageFactory.text(f"Bot received the number '{number_result}'.") - ) - - await conver_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply("Enter a number.") - step3 = await step2.send("3.14") - await step3.assert_reply("Bot received the number '3.14'.") diff --git a/dev/hosting_dialogs/test_oauth_prompt.py b/dev/hosting_dialogs/test_oauth_prompt.py deleted file mode 100644 index 331abe6a..00000000 --- a/dev/hosting_dialogs/test_oauth_prompt.py +++ /dev/null @@ -1,384 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest -from microsoft_agents.hosting.dialogs.prompts import OAuthPromptSettings -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, - InputHints, - SignInConstants, - TokenResponse, -) -from microsoft_agents.hosting.core import ( - CardFactory, - ConversationState, - MemoryStorage, - TurnContext, -) -from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus -from microsoft_agents.hosting.dialogs.prompts import OAuthPrompt, PromptOptions -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -def create_reply(activity): - return Activity( - type=ActivityTypes.message, - from_property=ChannelAccount( - id=activity.recipient.id, name=activity.recipient.name - ), - recipient=ChannelAccount( - id=activity.from_property.id, name=activity.from_property.name - ), - reply_to_id=activity.id, - service_url=activity.service_url, - channel_id=activity.channel_id, - conversation=ConversationAccount( - is_group=activity.conversation.is_group, - id=activity.conversation.id, - name=activity.conversation.name, - ), - ) - - -class TestOAuthPrompt: - @pytest.mark.skip( - reason="tokens/response event path not supported in new OAuthPrompt internals" - ) - @pytest.mark.asyncio - async def test_should_call_oauth_prompt(self): - connection_name = "myConnection" - token = "abc123" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - dialogs.add( - OAuthPrompt( - "prompt", OAuthPromptSettings(connection_name, "Login", None, 300000) - ) - ) - - async def callback_handler(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.prompt("prompt", PromptOptions()) - elif results.status == DialogTurnStatus.Complete: - if results.result.token: - await turn_context.send_activity("Logged in.") - else: - await turn_context.send_activity("Failed") - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(callback_handler) - - async def inspector(activity: Activity, description: str = None): - assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.oauth_card - ) - - adapter.add_user_token( - connection_name, activity.channel_id, activity.recipient.id, token - ) - - event_activity = create_reply(activity) - event_activity.type = ActivityTypes.event - event_activity.from_property, event_activity.recipient = ( - event_activity.recipient, - event_activity.from_property, - ) - event_activity.name = "tokens/response" - event_activity.value = TokenResponse( - connection_name=connection_name, token=token - ) - - context = adapter.create_turn_context(event_activity) - await callback_handler(context) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply(inspector) - await step2.assert_reply("Logged in.") - - @pytest.mark.asyncio - async def test_should_call_oauth_prompt_with_code(self): - connection_name = "myConnection" - token = "abc123" - magic_code = "888999" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add( - OAuthPrompt( - "prompt", OAuthPromptSettings(connection_name, "Login", None, 300000) - ) - ) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.prompt("prompt", PromptOptions()) - elif results.status == DialogTurnStatus.Complete: - if results.result.token: - await turn_context.send_activity("Logged in.") - else: - await turn_context.send_activity("Failed") - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - def inspector(activity: Activity, description: str = None): - assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.oauth_card - ) - adapter.add_user_token( - connection_name, - activity.channel_id, - activity.recipient.id, - token, - magic_code, - ) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply(inspector) - step3 = await step2.send(magic_code) - await step3.assert_reply("Logged in.") - - @pytest.mark.asyncio - async def test_oauth_prompt_doesnt_detect_code_in_begin_dialog(self): - connection_name = "myConnection" - token = "abc123" - magic_code = "888999" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add( - OAuthPrompt( - "prompt", OAuthPromptSettings(connection_name, "Login", None, 300000) - ) - ) - - async def exec_test(turn_context: TurnContext): - adapter.add_user_token( - connection_name, - turn_context.activity.channel_id, - turn_context.activity.from_property.id, - token, - magic_code, - ) - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - token_result = await dialog_context.prompt("prompt", PromptOptions()) - if isinstance(token_result.result, TokenResponse): - assert False, "Should not have returned token during begin" - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - def inspector(activity: Activity, description: str = None): - assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.oauth_card - ) - - step1 = await adapter.send(magic_code) - await step1.assert_reply(inspector) - - @pytest.mark.asyncio - async def test_should_add_accepting_input_hint_oauth_prompt(self): - connection_name = "myConnection" - called = False - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - dialogs.add( - OAuthPrompt( - "prompt", OAuthPromptSettings(connection_name, "Login", None, 300000) - ) - ) - - async def callback_handler(turn_context: TurnContext): - nonlocal called - dialog_context = await dialogs.create_context(turn_context) - await dialog_context.continue_dialog() - await dialog_context.prompt( - "prompt", - PromptOptions( - prompt=Activity(type=ActivityTypes.message), - retry_prompt=Activity(type=ActivityTypes.message), - ), - ) - assert ( - dialog_context.active_dialog.state["options"].prompt.input_hint - == InputHints.accepting_input - ) - assert ( - dialog_context.active_dialog.state["options"].retry_prompt.input_hint - == InputHints.accepting_input - ) - await convo_state.save(turn_context) - called = True - - adapter = DialogTestAdapter(callback_handler) - await adapter.send("Hello") - assert called - - @pytest.mark.asyncio - async def test_should_end_oauth_prompt_on_invalid_message_when_end_on_invalid_message( - self, - ): - connection_name = "myConnection" - token = "abc123" - magic_code = "888999" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add( - OAuthPrompt( - "prompt", - OAuthPromptSettings(connection_name, "Login", None, 300000, None, True), - ) - ) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.prompt("prompt", PromptOptions()) - elif results.status == DialogTurnStatus.Complete: - if results.result and results.result.token: - await turn_context.send_activity("Failed") - else: - await turn_context.send_activity("Ended") - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - def inspector(activity: Activity, description: str = None): - assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.oauth_card - ) - adapter.add_user_token( - connection_name, - activity.channel_id, - activity.recipient.id, - token, - magic_code, - ) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply(inspector) - step3 = await step2.send("test invalid message") - await step3.assert_reply("Ended") - - @pytest.mark.asyncio - async def test_should_timeout_oauth_prompt_with_message_activity(self): - activity = Activity(type=ActivityTypes.message, text="any") - await self._run_timeout_test(activity) - - @pytest.mark.asyncio - async def test_should_timeout_oauth_prompt_with_token_response_event_activity(self): - activity = Activity( - type=ActivityTypes.event, name=SignInConstants.token_response_event_name - ) - await self._run_timeout_test(activity) - - @pytest.mark.asyncio - async def test_should_timeout_oauth_prompt_with_verify_state_operation_activity( - self, - ): - activity = Activity( - type=ActivityTypes.invoke, name=SignInConstants.verify_state_operation_name - ) - await self._run_timeout_test(activity) - - @pytest.mark.asyncio - async def test_should_not_timeout_oauth_prompt_with_custom_event_activity(self): - activity = Activity(type=ActivityTypes.event, name="custom event name") - await self._run_timeout_test(activity, False, "Ended", "Failed") - - async def _run_timeout_test( - self, - activity: Activity, - should_succeed: bool = True, - token_response: str = "Failed", - no_token_response: str = "Ended", - ): - connection_name = "myConnection" - token = "abc123" - magic_code = "888999" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialog_state") - dialogs = DialogSet(dialog_state) - dialogs.add( - OAuthPrompt( - "prompt", - OAuthPromptSettings(connection_name, "Login", None, 1), - ) - ) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.prompt("prompt", PromptOptions()) - elif results.status == DialogTurnStatus.Complete or ( - results.status == DialogTurnStatus.Waiting and not should_succeed - ): - if results.result and results.result.token: - await turn_context.send_activity(token_response) - else: - await turn_context.send_activity(no_token_response) - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - def inspector(activity_: Activity, description: str = None): - assert len(activity_.attachments) == 1 - assert ( - activity_.attachments[0].content_type - == CardFactory.content_types.oauth_card - ) - adapter.add_user_token( - connection_name, - activity_.channel_id, - activity_.recipient.id, - token, - magic_code, - ) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply(inspector) - step3 = await step2.send(activity) - await step3.assert_reply(no_token_response) - - -class TestOAuthPromptSettings: - def test_oauth_app_credentials_stored_correctly(self): - """Constructor param oauth_app_credentials must be accessible as oauth_app_credentials.""" - sentinel = object() - settings = OAuthPromptSettings("conn", "title", oauth_app_credentials=sentinel) - assert settings.oauth_app_credentials is sentinel - - def test_oath_typo_alias_reads_same_value(self): - """oath_app_credentials (typo alias) must return the same value as oauth_app_credentials.""" - sentinel = object() - settings = OAuthPromptSettings("conn", "title", oauth_app_credentials=sentinel) - assert settings.oath_app_credentials is sentinel - - def test_oath_typo_alias_setter_writes_to_canonical(self): - """Writing via the typo alias updates oauth_app_credentials.""" - sentinel = object() - settings = OAuthPromptSettings("conn", "title") - settings.oath_app_credentials = sentinel - assert settings.oauth_app_credentials is sentinel diff --git a/dev/hosting_dialogs/test_object_path.py b/dev/hosting_dialogs/test_object_path.py deleted file mode 100644 index db7ff202..00000000 --- a/dev/hosting_dialogs/test_object_path.py +++ /dev/null @@ -1,209 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest -from microsoft_agents.hosting.dialogs import ObjectPath - - -class Location: - def __init__(self, lat: float = None, long: float = None): - self.lat = lat - self.long = long - - -class Options: - def __init__( - self, - first_name: str = None, - last_name: str = None, - age: int = None, - boolean: bool = None, - dictionary: dict = None, - location: Location = None, - ): - self.first_name = first_name - self.last_name = last_name - self.age = age - self.boolean = boolean - self.dictionary = dictionary - self.location = location - - -class TestObjectPath: - def test_typed_only_default(self): - default_options = Options( - last_name="Smith", - first_name="Fred", - age=22, - location=Location(lat=1.2312312, long=3.234234), - ) - overlay = Options() - result = ObjectPath.assign(default_options, overlay) - assert result.last_name == default_options.last_name - assert result.first_name == default_options.first_name - assert result.age == default_options.age - assert result.boolean == default_options.boolean - assert result.location.lat == default_options.location.lat - assert result.location.long == default_options.location.long - - def test_typed_only_overlay(self): - default_options = Options() - overlay = Options( - last_name="Smith", - first_name="Fred", - age=22, - location=Location(lat=1.2312312, long=3.234234), - ) - result = ObjectPath.assign(default_options, overlay) - assert result.last_name == overlay.last_name - assert result.first_name == overlay.first_name - assert result.age == overlay.age - assert result.boolean == overlay.boolean - assert result.location.lat == overlay.location.lat - assert result.location.long == overlay.location.long - - def test_typed_full_overlay(self): - default_options = Options( - last_name="Smith", - first_name="Fred", - age=22, - location=Location(lat=1.2312312, long=3.234234), - dictionary={"one": 1, "two": 2}, - ) - overlay = Options( - last_name="Grant", - first_name="Eddit", - age=32, - location=Location(lat=2.2312312, long=2.234234), - dictionary={"one": 99, "three": 3}, - ) - result = ObjectPath.assign(default_options, overlay) - assert result.last_name == overlay.last_name - assert result.first_name == overlay.first_name - assert result.age == overlay.age - assert result.boolean == overlay.boolean - assert result.location.lat == overlay.location.lat - assert result.location.long == overlay.location.long - assert "one" in result.dictionary - assert result.dictionary["one"] == 99 - assert "two" in result.dictionary - assert "three" in result.dictionary - - def test_typed_partial_overlay(self): - default_options = Options( - last_name="Smith", - first_name="Fred", - age=22, - location=Location(lat=1.2312312, long=3.234234), - ) - overlay = Options(last_name="Grant") - result = ObjectPath.assign(default_options, overlay) - assert result.last_name == overlay.last_name - assert result.first_name == default_options.first_name - assert result.age == default_options.age - assert result.boolean == default_options.boolean - assert result.location.lat == default_options.location.lat - assert result.location.long == default_options.location.long - - def test_typed_no_target(self): - overlay = Options( - last_name="Smith", - first_name="Fred", - age=22, - location=Location(lat=1.2312312, long=3.234234), - ) - result = ObjectPath.assign(None, overlay) - assert result.last_name == overlay.last_name - assert result.first_name == overlay.first_name - assert result.age == overlay.age - assert result.boolean == overlay.boolean - assert result.location.lat == overlay.location.lat - assert result.location.long == overlay.location.long - - def test_typed_no_overlay(self): - default_options = Options( - last_name="Smith", - first_name="Fred", - age=22, - location=Location(lat=1.2312312, long=3.234234), - ) - result = ObjectPath.assign(default_options, None) - assert result.last_name == default_options.last_name - assert result.first_name == default_options.first_name - assert result.age == default_options.age - assert result.boolean == default_options.boolean - assert result.location.lat == default_options.location.lat - assert result.location.long == default_options.location.long - - def test_no_target_or_overlay(self): - result = ObjectPath.assign(None, None, Options) - assert result - - def test_dict_partial_overlay(self): - default_options = { - "last_name": "Smith", - "first_name": "Fred", - "age": 22, - "location": Location(lat=1.2312312, long=3.234234), - } - overlay = {"last_name": "Grant"} - result = ObjectPath.assign(default_options, overlay) - assert result["last_name"] == overlay["last_name"] - assert result["first_name"] == default_options["first_name"] - assert result["age"] == default_options["age"] - assert result["location"].lat == default_options["location"].lat - assert result["location"].long == default_options["location"].long - - def test_dict_to_typed_overlay(self): - default_options = Options( - last_name="Smith", - first_name="Fred", - age=22, - location=Location(lat=1.2312312, long=3.234234), - ) - overlay = {"last_name": "Grant"} - result = ObjectPath.assign(default_options, overlay) - assert result.last_name == overlay["last_name"] - assert result.first_name == default_options.first_name - assert result.age == default_options.age - assert result.boolean == default_options.boolean - assert result.location.lat == default_options.location.lat - assert result.location.long == default_options.location.long - - def test_set_value(self): - test = {} - ObjectPath.set_path_value(test, "x.y.z", 15) - ObjectPath.set_path_value(test, "x.p", "hello") - ObjectPath.set_path_value(test, "foo", {"Bar": 15, "Blat": "yo"}) - ObjectPath.set_path_value(test, "x.a[1]", "yabba") - ObjectPath.set_path_value(test, "x.a[0]", "dabba") - ObjectPath.set_path_value(test, "null", None) - - assert ObjectPath.get_path_value(test, "x.y.z") == 15 - assert ObjectPath.get_path_value(test, "x.p") == "hello" - assert ObjectPath.get_path_value(test, "foo.bar") == 15 - - assert not ObjectPath.try_get_path_value(test, "foo.Blatxxx") - assert ObjectPath.try_get_path_value(test, "x.a[1]") == "yabba" - assert ObjectPath.try_get_path_value(test, "x.a[0]") == "dabba" - - assert not ObjectPath.try_get_path_value(test, "null") - - def test_remove_path_value(self): - test = {} - ObjectPath.set_path_value(test, "x.y.z", 15) - ObjectPath.set_path_value(test, "x.p", "hello") - ObjectPath.set_path_value(test, "foo", {"Bar": 15, "Blat": "yo"}) - ObjectPath.set_path_value(test, "x.a[1]", "yabba") - ObjectPath.set_path_value(test, "x.a[0]", "dabba") - - ObjectPath.remove_path_value(test, "x.y.z") - with pytest.raises(KeyError): - ObjectPath.get_path_value(test, "x.y.z") - - assert ObjectPath.get_path_value(test, "x.y.z", 99) == 99 - - ObjectPath.remove_path_value(test, "x.a[1]") - assert not ObjectPath.try_get_path_value(test, "x.a[1]") - - assert ObjectPath.try_get_path_value(test, "x.a[0]") == "dabba" diff --git a/dev/hosting_dialogs/test_prompt_culture_models.py b/dev/hosting_dialogs/test_prompt_culture_models.py deleted file mode 100644 index 138d3a7e..00000000 --- a/dev/hosting_dialogs/test_prompt_culture_models.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest -from microsoft_agents.hosting.dialogs.prompts.prompt_culture_models import ( - PromptCultureModels, -) - -SUPPORTED_CULTURES = [ - PromptCultureModels.Bulgarian, - PromptCultureModels.Chinese, - PromptCultureModels.Dutch, - PromptCultureModels.English, - PromptCultureModels.French, - PromptCultureModels.German, - PromptCultureModels.Hindi, - PromptCultureModels.Italian, - PromptCultureModels.Japanese, - PromptCultureModels.Korean, - PromptCultureModels.Portuguese, - PromptCultureModels.Spanish, - PromptCultureModels.Swedish, - PromptCultureModels.Turkish, -] - - -def _locale_variations(culture): - """Generate (input_variation, expected_locale) tuples for a culture.""" - locale = culture.locale # e.g. "en-us" - parts = locale.split("-") - prefix = parts[0] - suffix = parts[1] if len(parts) > 1 else "" - return [ - (locale, locale), # exact: "en-us" - (f"{prefix}-{suffix.upper()}", locale), # cap ending: "en-US" - (f"{prefix.capitalize()}-{suffix.capitalize()}", locale), # title: "En-Us" - (prefix.upper(), locale), # all-caps two-letter: "EN" - (prefix, locale), # lowercase two-letter: "en" - ] - - -LOCALE_VARIATIONS = [ - variation - for culture in SUPPORTED_CULTURES - for variation in _locale_variations(culture) -] - - -@pytest.mark.parametrize("locale_variation,expected", LOCALE_VARIATIONS) -def test_map_to_nearest_language(locale_variation, expected): - result = PromptCultureModels.map_to_nearest_language(locale_variation) - assert result == expected - - -def test_null_locale_does_not_raise(): - result = PromptCultureModels.map_to_nearest_language(None) - assert result is None - - -def test_get_supported_cultures_returns_all(): - expected_locales = {c.locale for c in SUPPORTED_CULTURES} - actual_locales = {c.locale for c in PromptCultureModels.get_supported_cultures()} - assert expected_locales == actual_locales - - -def test_supported_cultures_have_required_fields(): - for culture in PromptCultureModels.get_supported_cultures(): - assert culture.locale, f"Culture missing locale" - assert culture.separator, f"Culture {culture.locale} missing separator" - assert culture.inline_or, f"Culture {culture.locale} missing inline_or" - assert ( - culture.yes_in_language - ), f"Culture {culture.locale} missing yes_in_language" - assert ( - culture.no_in_language - ), f"Culture {culture.locale} missing no_in_language" diff --git a/dev/hosting_dialogs/test_prompt_validator_context.py b/dev/hosting_dialogs/test_prompt_validator_context.py deleted file mode 100644 index 69a3e695..00000000 --- a/dev/hosting_dialogs/test_prompt_validator_context.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest - -from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus -from microsoft_agents.hosting.core import MemoryStorage, ConversationState -from microsoft_agents.hosting.dialogs.prompts import ( - TextPrompt, - PromptOptions, - PromptValidatorContext, -) -from microsoft_agents.activity import Activity, ActivityTypes -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class TestPromptValidatorContext: - @pytest.mark.asyncio - async def test_prompt_validator_context_end(self): - storage = MemoryStorage() - conv = ConversationState(storage) - accessor = conv.create_property("dialogstate") - dialog_set = DialogSet(accessor) - assert dialog_set is not None - - def test_prompt_validator_context_retry_end(self): - storage = MemoryStorage() - conv = ConversationState(storage) - accessor = conv.create_property("dialogstate") - dialog_set = DialogSet(accessor) - assert dialog_set is not None - - @pytest.mark.asyncio - async def test_attempt_count_starts_at_one_on_first_validation(self): - """attempt_count is 1 on the first validator call and increments on each - subsequent user reply (mirrors ActivityPrompt behaviour).""" - observed_attempt_counts = [] - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - async def validator(pc: PromptValidatorContext) -> bool: - observed_attempt_counts.append(pc.attempt_count) - return bool(pc.recognized.value) - - ds.add(TextPrompt("TextPrompt", validator)) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter text."), - retry_prompt=Activity(type=ActivityTypes.message, text="Again."), - ) - await dc.prompt("TextPrompt", options) - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - await flow.assert_reply("Enter text.") - await adapter.send("something") - - assert observed_attempt_counts == [1] - - @pytest.mark.asyncio - async def test_attempt_count_increments_across_retries(self): - """attempt_count increments with each user reply, so retry #1 = 2, retry #2 = 3, etc.""" - observed_attempt_counts = [] - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - async def validator(pc: PromptValidatorContext) -> bool: - observed_attempt_counts.append(pc.attempt_count) - # Only accept the literal word "yes" - return pc.recognized.succeeded and pc.recognized.value == "yes" - - ds.add(TextPrompt("TextPrompt", validator)) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Say yes."), - retry_prompt=Activity( - type=ActivityTypes.message, text="Please say yes." - ), - ) - await dc.prompt("TextPrompt", options) - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("start") - await flow.assert_reply("Say yes.") - await adapter.send("no") # attempt 1 — rejected - await adapter.send("nope") # attempt 2 — rejected - await adapter.send("yes") # attempt 3 — accepted - - assert observed_attempt_counts == [1, 2, 3] diff --git a/dev/hosting_dialogs/test_replace_dialog.py b/dev/hosting_dialogs/test_replace_dialog.py deleted file mode 100644 index 86ebb8ec..00000000 --- a/dev/hosting_dialogs/test_replace_dialog.py +++ /dev/null @@ -1,136 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest - -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import ConversationState, MemoryStorage, TurnContext -from microsoft_agents.hosting.dialogs import ( - ComponentDialog, - DialogSet, - DialogTurnStatus, - WaterfallDialog, -) -from microsoft_agents.hosting.dialogs.models.dialog_instance import DialogInstance -from microsoft_agents.hosting.dialogs.models.dialog_reason import DialogReason -from microsoft_agents.hosting.dialogs.prompts import TextPrompt, PromptOptions -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -def _text_prompt_options(text: str) -> PromptOptions: - return PromptOptions(prompt=Activity(type=ActivityTypes.message, text=text)) - - -class _WaterfallWithEndDialog(WaterfallDialog): - """WaterfallDialog that announces itself when it ends.""" - - async def end_dialog( - self, context: TurnContext, instance: DialogInstance, reason: DialogReason - ): - await context.send_activity("*** WaterfallDialog End ***") - await super().end_dialog(context, instance, reason) - - -class _SecondDialog(ComponentDialog): - def __init__(self): - super().__init__("SecondDialog") - - async def action_four(step): - return await step.prompt("TextPrompt", _text_prompt_options("prompt four")) - - async def action_five(step): - return await step.prompt("TextPrompt", _text_prompt_options("prompt five")) - - async def last_action(step): - return await step.end_dialog() - - self.add_dialog(TextPrompt("TextPrompt")) - self.add_dialog( - WaterfallDialog("WaterfallDialog", [action_four, action_five, last_action]) - ) - self.initial_dialog_id = "WaterfallDialog" - - -class _FirstDialog(ComponentDialog): - def __init__(self): - super().__init__("FirstDialog") - - async def action_one(step): - return await step.prompt("TextPrompt", _text_prompt_options("prompt one")) - - async def action_two(step): - return await step.prompt("TextPrompt", _text_prompt_options("prompt two")) - - async def replace_action(step): - if step.result == "replace": - return await step.replace_dialog("SecondDialog") - return await step.next(None) - - async def action_three(step): - return await step.prompt("TextPrompt", _text_prompt_options("prompt three")) - - async def last_action(step): - return await step.end_dialog() - - self.add_dialog(TextPrompt("TextPrompt")) - self.add_dialog(_SecondDialog()) - self.add_dialog( - _WaterfallWithEndDialog( - "WaterfallWithEndDialog", - [action_one, action_two, replace_action, action_three, last_action], - ) - ) - self.initial_dialog_id = "WaterfallWithEndDialog" - - -class TestReplaceDialog: - @pytest.mark.asyncio - async def test_replace_dialog_no_branch(self): - """Dialog flows through all three prompts without branching.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - ds.add(_FirstDialog()) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("FirstDialog") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("prompt one") - flow = await flow.send("hello") - flow = await flow.assert_reply("prompt two") - flow = await flow.send("hello") - flow = await flow.assert_reply("prompt three") - flow = await flow.send("hello") - await flow.assert_reply("*** WaterfallDialog End ***") - - @pytest.mark.asyncio - async def test_replace_dialog_branch(self): - """Sending 'replace' causes replace_dialog to switch to SecondDialog.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - ds.add(_FirstDialog()) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("FirstDialog") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("prompt one") - flow = await flow.send("hello") - flow = await flow.assert_reply("prompt two") - flow = await flow.send("replace") - flow = await flow.assert_reply("*** WaterfallDialog End ***") - flow = await flow.assert_reply("prompt four") - flow = await flow.send("hello") - await flow.assert_reply("prompt five") diff --git a/dev/hosting_dialogs/test_text_prompt.py b/dev/hosting_dialogs/test_text_prompt.py deleted file mode 100644 index 6103d909..00000000 --- a/dev/hosting_dialogs/test_text_prompt.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest - -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import ConversationState, MemoryStorage -from microsoft_agents.hosting.dialogs import DialogSet, DialogTurnStatus -from microsoft_agents.hosting.dialogs.prompts import ( - TextPrompt, - PromptOptions, - PromptValidatorContext, -) -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class TestTextPrompt: - def test_empty_id_raises(self): - with pytest.raises((TypeError, Exception)): - TextPrompt("") - - def test_null_id_raises(self): - with pytest.raises((TypeError, Exception)): - TextPrompt(None) - - @pytest.mark.asyncio - async def test_basic_text_prompt(self): - """TextPrompt echoes user input back after the prompt.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - ds.add(TextPrompt("TextPrompt")) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter some text.") - ) - await dc.prompt("TextPrompt", options) - elif results.status == DialogTurnStatus.Complete: - await tc.send_activity(f"Bot received: {results.result}") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("Enter some text.") - flow = await flow.send("some text") - await flow.assert_reply("Bot received: some text") - - @pytest.mark.asyncio - async def test_text_prompt_with_validator(self): - """A validator can reject short inputs and trigger the retry prompt.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - async def validator(pc: PromptValidatorContext) -> bool: - return pc.recognized.value is not None and len(pc.recognized.value) > 3 - - ds.add(TextPrompt("TextPrompt", validator)) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Enter some text." - ), - retry_prompt=Activity( - type=ActivityTypes.message, text="That's not long enough." - ), - ) - await dc.prompt("TextPrompt", options) - elif results.status == DialogTurnStatus.Complete: - await tc.send_activity(f"Bot received: {results.result}") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("Enter some text.") - flow = await flow.send("hi") # Too short — validator rejects - flow = await flow.assert_reply("That's not long enough.") - flow = await flow.send("hello world") # Long enough - await flow.assert_reply("Bot received: hello world") - - @pytest.mark.asyncio - async def test_text_prompt_retry_on_failed_validation(self): - """Without a retry_prompt the original prompt is re-sent on failure.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - async def validator(pc: PromptValidatorContext) -> bool: - return pc.recognized.value is not None and pc.recognized.value != "bad" - - ds.add(TextPrompt("TextPrompt", validator)) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Enter something." - ), - ) - await dc.prompt("TextPrompt", options) - elif results.status == DialogTurnStatus.Complete: - await tc.send_activity(f"Got: {results.result}") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("Enter something.") - flow = await flow.send("bad") # Rejected by validator - flow = await flow.assert_reply("Enter something.") # Re-prompted - flow = await flow.send("good") - await flow.assert_reply("Got: good") - - @pytest.mark.asyncio - async def test_text_prompt_non_message_activity_does_not_succeed(self): - """A non-message activity (e.g. event) causes recognition to fail. - - The base Prompt.continue_dialog returns end_of_turn immediately for - non-message activities without calling the recognizer or validator. - The dialog remains active (Waiting) and the user is not re-prompted. - """ - from microsoft_agents.activity import ActivityTypes - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - ds.add(TextPrompt("TextPrompt")) - - completed = [] - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter something.") - ) - await dc.prompt("TextPrompt", options) - elif results.status == DialogTurnStatus.Complete: - completed.append(results.result) - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - # Start the prompt - flow = await adapter.send("hello") - await flow.assert_reply("Enter something.") - - # Send a non-message event — dialog must NOT complete - event_activity = Activity(type=ActivityTypes.event, name="custom") - await adapter.process_activity_async(event_activity, exec) - assert len(completed) == 0, "Prompt must not complete on non-message activity" - - @pytest.mark.asyncio - async def test_text_prompt_with_custom_message_validator(self): - """Validator can send its own message and return False.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - async def validator(pc: PromptValidatorContext) -> bool: - if pc.recognized.value and len(pc.recognized.value) > 5: - return True - await pc.context.send_activity("Please enter more than 5 characters.") - return False - - ds.add(TextPrompt("TextPrompt", validator)) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter text."), - ) - await dc.prompt("TextPrompt", options) - elif results.status == DialogTurnStatus.Complete: - await tc.send_activity(f"Done: {results.result}") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("Enter text.") - flow = await flow.send("hi") - flow = await flow.assert_reply("Please enter more than 5 characters.") - flow = await flow.send("hello world") - await flow.assert_reply("Done: hello world") diff --git a/dev/hosting_dialogs/test_waterfall.py b/dev/hosting_dialogs/test_waterfall.py deleted file mode 100644 index c07d1b5a..00000000 --- a/dev/hosting_dialogs/test_waterfall.py +++ /dev/null @@ -1,314 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest -from recognizers_text import Culture - -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import ConversationState, MemoryStorage, TurnContext -from microsoft_agents.hosting.dialogs import ( - ComponentDialog, - Dialog, - DialogSet, - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, - DialogTurnStatus, -) -from microsoft_agents.hosting.dialogs.prompts import ( - NumberPrompt, - DateTimePrompt, - PromptOptions, -) -from tests.hosting_dialogs.helpers import DialogTestAdapter - - -class TestWaterfallDialog: - def test_waterfall_none_name(self): - with pytest.raises((TypeError, Exception)): - WaterfallDialog(None) - - def test_waterfall_add_none_step(self): - waterfall = WaterfallDialog("test") - with pytest.raises((TypeError, Exception)): - waterfall.add_step(None) - - def test_waterfall_with_set_instead_of_array(self): - with pytest.raises((TypeError, Exception)): - WaterfallDialog("a", {1, 2}) - - @pytest.mark.asyncio - async def test_execute_sequence_waterfall_steps(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - async def step1(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("bot responding.") - return Dialog.end_of_turn - - async def step2(step: WaterfallStepContext) -> DialogTurnResult: - return await step.end_dialog("ending WaterfallDialog.") - - my_dialog = WaterfallDialog("test", [step1, step2]) - dialogs.add(my_dialog) - - async def exec_test(turn_context: TurnContext) -> None: - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog("test") - else: - if results.status == DialogTurnStatus.Complete: - await turn_context.send_activity(results.result) - await convo_state.save(turn_context) - - adapter = DialogTestAdapter(exec_test) - - step1_flow = await adapter.send("begin") - step2_flow = await step1_flow.assert_reply("bot responding.") - step3_flow = await step2_flow.send("continue") - await step3_flow.assert_reply("ending WaterfallDialog.") - - def test_waterfall_callback(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - async def step_callback1(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step1") - - async def step_callback2(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step2") - - async def step_callback3(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step3") - - steps = [step_callback1, step_callback2, step_callback3] - dialogs.add(WaterfallDialog("test", steps)) - assert dialogs is not None - assert len(dialogs._dialogs) == 1 # pylint: disable=protected-access - - def test_waterfall_with_class(self): - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - class MyWaterfallDialog(WaterfallDialog): - def __init__(self, dialog_id: str): - async def step1(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step1") - return Dialog.end_of_turn - - async def step2(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step2") - return Dialog.end_of_turn - - super().__init__(dialog_id, [step1, step2]) - - dialogs.add(MyWaterfallDialog("test")) - assert dialogs is not None - assert len(dialogs._dialogs) == 1 # pylint: disable=protected-access - - @pytest.mark.asyncio - async def test_waterfall_step_parent_is_waterfall_parent(self): - """WaterfallStepContext.parent should be the ComponentDialog containing the waterfall.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - - result_holder = {} - - class WaterfallParentDialog(ComponentDialog): - def __init__(self): - super().__init__("waterfall-parent-test-dialog") - - async def step1(step: WaterfallStepContext) -> DialogTurnResult: - # Parent context should have the component dialog as its active dialog - parent_id = ( - step.parent.active_dialog.id - if step.parent and step.parent.active_dialog - else None - ) - result_holder["parent_id"] = parent_id - await step.context.send_activity("verified") - return Dialog.end_of_turn - - self.add_dialog(WaterfallDialog("test", [step1])) - self.initial_dialog_id = "test" - - ds = DialogSet(dialog_state) - ds.add(WaterfallParentDialog()) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("waterfall-parent-test-dialog") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - await flow.assert_reply("verified") - - assert result_holder.get("parent_id") == "waterfall-parent-test-dialog" - - @pytest.mark.asyncio - async def test_waterfall_prompt(self): - """Waterfall with NumberPrompt: invalid inputs trigger retry, valid inputs advance steps.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - - async def step1(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step1") - return await step.prompt( - "number", - PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter a number."), - retry_prompt=Activity( - type=ActivityTypes.message, text="It must be a number" - ), - ), - ) - - async def step2(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity(f"Thanks for '{int(step.result)}'") - await step.context.send_activity("step2") - return await step.prompt( - "number", - PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Enter a number."), - retry_prompt=Activity( - type=ActivityTypes.message, text="It must be a number" - ), - ), - ) - - async def step3(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity(f"Thanks for '{int(step.result)}'") - await step.context.send_activity("step3") - return await step.end_dialog() - - ds = DialogSet(dialog_state) - ds.add(WaterfallDialog("test-waterfall", [step1, step2, step3])) - ds.add(NumberPrompt("number", default_locale=Culture.English)) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("test-waterfall") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("step1") - flow = await flow.assert_reply("Enter a number.") - flow = await flow.send("hello again") - flow = await flow.assert_reply("It must be a number") - flow = await flow.send("42") - flow = await flow.assert_reply("Thanks for '42'") - flow = await flow.assert_reply("step2") - flow = await flow.assert_reply("Enter a number.") - flow = await flow.send("apple") - flow = await flow.assert_reply("It must be a number") - flow = await flow.send("orange") - flow = await flow.assert_reply("It must be a number") - flow = await flow.send("64") - flow = await flow.assert_reply("Thanks for '64'") - await flow.assert_reply("step3") - - @pytest.mark.asyncio - async def test_waterfall_nested(self): - """Nested waterfall dialogs chain correctly when each ends.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - - async def waterfall_a_step1(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step1") - return await step.begin_dialog("test-waterfall-b") - - async def waterfall_a_step2(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step2") - return await step.begin_dialog("test-waterfall-c") - - async def waterfall_b_step1(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step1.1") - return Dialog.end_of_turn - - async def waterfall_b_step2(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step1.2") - return Dialog.end_of_turn - - async def waterfall_c_step1(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step2.1") - return Dialog.end_of_turn - - async def waterfall_c_step2(step: WaterfallStepContext) -> DialogTurnResult: - await step.context.send_activity("step2.2") - return await step.end_dialog() - - ds = DialogSet(dialog_state) - ds.add( - WaterfallDialog("test-waterfall-a", [waterfall_a_step1, waterfall_a_step2]) - ) - ds.add( - WaterfallDialog("test-waterfall-b", [waterfall_b_step1, waterfall_b_step2]) - ) - ds.add( - WaterfallDialog("test-waterfall-c", [waterfall_c_step1, waterfall_c_step2]) - ) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("test-waterfall-a") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("step1") - flow = await flow.assert_reply("step1.1") - flow = await flow.send("hello") - flow = await flow.assert_reply("step1.2") - flow = await flow.send("hello") - flow = await flow.assert_reply("step2") - flow = await flow.assert_reply("step2.1") - flow = await flow.send("hello") - await flow.assert_reply("step2.2") - - @pytest.mark.asyncio - async def test_waterfall_datetime_prompt_first_invalid_then_valid(self): - """DateTimePrompt re-prompts on invalid input and accepts valid date/time.""" - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - - async def step1(step: WaterfallStepContext) -> DialogTurnResult: - return await step.prompt( - "dateTimePrompt", - PromptOptions( - prompt=Activity(type=ActivityTypes.message, text="Provide a date") - ), - ) - - async def step2(step: WaterfallStepContext) -> DialogTurnResult: - assert step.result is not None - return await step.end_dialog() - - ds = DialogSet(dialog_state) - ds.add(DateTimePrompt("dateTimePrompt", default_locale=Culture.English)) - ds.add(WaterfallDialog("test-dateTimePrompt", [step1, step2])) - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog("test-dateTimePrompt") - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - flow = await adapter.send("hello") - flow = await flow.assert_reply("Provide a date") - flow = await flow.send("hello again") - flow = await flow.assert_reply("Provide a date") - await flow.send("Wednesday 4 oclock") diff --git a/dev/hosting_dialogs/test_waterfall_dialog.py b/dev/hosting_dialogs/test_waterfall_dialog.py deleted file mode 100644 index de56539f..00000000 --- a/dev/hosting_dialogs/test_waterfall_dialog.py +++ /dev/null @@ -1,171 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest -from unittest.mock import MagicMock - -from microsoft_agents.activity import ActivityTypes -from microsoft_agents.hosting.dialogs import ( - ComponentDialog, - Dialog, - DialogContext, - DialogReason, - DialogSet, - DialogState, - DialogTurnResult, - DialogTurnStatus, - WaterfallDialog, -) - - -def _make_dc(activity_type="message"): - """Create a minimal DialogContext with the given activity type.""" - - class _Stub(ComponentDialog): - def __init__(self): - super().__init__("stub") - - ds = _Stub()._dialogs - mock_tc = MagicMock() - mock_tc.activity.type = activity_type - return DialogContext(ds, mock_tc, DialogState()) - - -class TestWaterfallDialogValidation: - @pytest.mark.asyncio - async def test_begin_dialog_null_dc_raises(self): - dialog = WaterfallDialog("A", []) - with pytest.raises((TypeError, Exception)): - await dialog.begin_dialog(None) - - @pytest.mark.asyncio - async def test_continue_dialog_null_dc_raises(self): - dialog = WaterfallDialog("A", []) - with pytest.raises((TypeError, Exception)): - await dialog.continue_dialog(None) - - @pytest.mark.asyncio - async def test_continue_dialog_returns_waiting_for_non_message_activity(self): - """WaterfallDialog.continue_dialog returns end_of_turn for non-message activities.""" - dialog = WaterfallDialog("A", []) - dc = _make_dc(activity_type=ActivityTypes.event) - - result = await dialog.continue_dialog(dc) - - # end_of_turn is DialogTurnResult(Waiting) - assert result.status == DialogTurnStatus.Waiting - - @pytest.mark.asyncio - async def test_resume_dialog_null_dc_raises(self): - dialog = WaterfallDialog("A", []) - with pytest.raises((TypeError, Exception)): - await dialog.resume_dialog(None, DialogReason.BeginCalled, "result") - - @pytest.mark.asyncio - async def test_run_step_null_dc_raises(self): - dialog = WaterfallDialog("A", []) - with pytest.raises((TypeError, Exception)): - await dialog.run_step(None, 0, DialogReason.BeginCalled, None) - - def test_none_name_raises(self): - with pytest.raises((TypeError, Exception)): - WaterfallDialog(None, []) - - def test_add_none_step_raises(self): - dialog = WaterfallDialog("A", []) - with pytest.raises((TypeError, Exception)): - dialog.add_step(None) - - def test_steps_must_be_list(self): - with pytest.raises((TypeError, Exception)): - WaterfallDialog("A", {1, 2}) - - @pytest.mark.asyncio - async def test_empty_steps_completes_immediately(self): - """WaterfallDialog with no steps ends immediately with None result.""" - from microsoft_agents.hosting.core import ConversationState, MemoryStorage - from microsoft_agents.hosting.dialogs import DialogSet - from tests.hosting_dialogs.helpers import DialogTestAdapter - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - dialogs.add(WaterfallDialog("empty-waterfall", [])) - - result_holder = {} - - async def exec(tc): - dc = await dialogs.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - results = await dc.begin_dialog("empty-waterfall") - result_holder["status"] = results.status - result_holder["result"] = results.result - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - await adapter.send("hi") - assert result_holder["status"] == DialogTurnStatus.Complete - assert result_holder["result"] is None - - @pytest.mark.asyncio - async def test_continue_dialog_non_message_returns_waiting(self): - """WaterfallDialog.continue_dialog ignores non-message activities.""" - dialog = WaterfallDialog("A", [lambda step: Dialog.end_of_turn]) - dc = _make_dc(activity_type=ActivityTypes.event) - result = await dialog.continue_dialog(dc) - assert result.status == DialogTurnStatus.Waiting - - -class TestWaterfallStepName: - def test_get_step_name_named_function_uses_qualname(self): - """Named step functions expose their __qualname__ as the step name.""" - - async def my_named_step(step): - return Dialog.end_of_turn - - dialog = WaterfallDialog("A", [my_named_step]) - assert "my_named_step" in dialog.get_step_name(0) - - def test_get_step_name_lambda_uses_fallback_format(self): - """Lambda steps fall back to 'Step{n}of{total}' because their __qualname__ ends in ''.""" - dialog = WaterfallDialog("A", [lambda s: None, lambda s: None]) - assert dialog.get_step_name(0) == "Step1of2" - assert dialog.get_step_name(1) == "Step2of2" - - -class TestWaterfallStepNoneReturn: - @pytest.mark.asyncio - async def test_step_returning_none_raises_type_error(self): - """A step returning None raises a clear TypeError instead of a confusing AttributeError.""" - from microsoft_agents.hosting.core import ConversationState, MemoryStorage - from microsoft_agents.hosting.dialogs import DialogSet - from tests.hosting_dialogs.helpers import DialogTestAdapter - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - ds = DialogSet(dialog_state) - - async def bad_step(step): - return None # programmer forgot to return Dialog.end_of_turn - - ds.add(WaterfallDialog("bad-waterfall", [bad_step])) - - error_holder = {} - - async def exec(tc): - dc = await ds.create_context(tc) - results = await dc.continue_dialog() - if results.status == DialogTurnStatus.Empty: - try: - await dc.begin_dialog("bad-waterfall") - except TypeError as e: - error_holder["error"] = e - await convo_state.save(tc) - - adapter = DialogTestAdapter(exec) - await adapter.send("hi") - - assert "error" in error_holder, "Expected TypeError but none was raised" - error_msg = str(error_holder["error"]).lower() - assert "none" in error_msg or "step" in error_msg diff --git a/dev/hosting_dialogs/test_waterfall_step_context.py b/dev/hosting_dialogs/test_waterfall_step_context.py deleted file mode 100644 index 1f040946..00000000 --- a/dev/hosting_dialogs/test_waterfall_step_context.py +++ /dev/null @@ -1,94 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import pytest -from unittest.mock import AsyncMock, MagicMock - -from microsoft_agents.hosting.dialogs import ( - ComponentDialog, - DialogContext, - DialogReason, - DialogSet, - DialogState, - DialogTurnResult, - DialogTurnStatus, - WaterfallDialog, -) -from microsoft_agents.hosting.dialogs.waterfall_step_context import WaterfallStepContext - - -def _make_step_context(): - """Create a WaterfallStepContext backed by mock objects.""" - - class _Stub(ComponentDialog): - def __init__(self): - super().__init__("stub") - - ds = _Stub()._dialogs - mock_tc = MagicMock() - dc = DialogContext(ds, mock_tc, DialogState()) - - wf_dialog = WaterfallDialog("wf", []) - wf_dialog.resume_dialog = AsyncMock( - return_value=DialogTurnResult(DialogTurnStatus.Complete) - ) - - return WaterfallStepContext(wf_dialog, dc, None, {}, 0, DialogReason.BeginCalled) - - -class TestWaterfallStepContext: - @pytest.mark.asyncio - async def test_next_called_twice_raises(self): - """Calling next() a second time on the same step context must raise.""" - step_ctx = _make_step_context() - - # First call succeeds - await step_ctx.next(None) - - # Second call raises - with pytest.raises(Exception, match="already called"): - await step_ctx.next(None) - - @pytest.mark.asyncio - async def test_next_calls_resume_on_parent_dialog(self): - """next() delegates to the parent WaterfallDialog's resume_dialog.""" - step_ctx = _make_step_context() - await step_ctx.next("my-result") - step_ctx._wf_parent.resume_dialog.assert_awaited_once() - - def test_step_context_properties(self): - """WaterfallStepContext exposes index, options, reason, result, values.""" - step_ctx = _make_step_context() - assert step_ctx.index == 0 - assert step_ctx.options is None - assert step_ctx.reason == DialogReason.BeginCalled - assert step_ctx.result is None - assert step_ctx.values == {} - - def test_step_context_with_result(self): - class _Stub(ComponentDialog): - def __init__(self): - super().__init__("stub") - - ds = _Stub()._dialogs - dc = DialogContext(ds, MagicMock(), DialogState()) - wf = WaterfallDialog("wf", []) - wf.resume_dialog = AsyncMock( - return_value=DialogTurnResult(DialogTurnStatus.Complete) - ) - - step_ctx = WaterfallStepContext( - wf, - dc, - {"key": "val"}, - {"v": 1}, - 2, - DialogReason.NextCalled, - "previous-result", - ) - - assert step_ctx.index == 2 - assert step_ctx.options == {"key": "val"} - assert step_ctx.values == {"v": 1} - assert step_ctx.result == "previous-result" - assert step_ctx.reason == DialogReason.NextCalled