diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..1c451ef6a --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = + */microsoft-agents-testing/* \ No newline at end of file diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index d9bc01328..c3881d306 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -793,21 +793,14 @@ def __is_activity(self, activity_type: str) -> bool: if self.type is None: return False - type_attribute = f"ActivityTypes.{str(self.type)}".lower() - activity_type = str(activity_type).lower() + type_value = str(getattr(self.type, "value", self.type)).lower() + activity_type_value = str( + getattr(activity_type, "value", activity_type) + ).lower() - result = type_attribute.startswith(activity_type) - - if result: - result = len(type_attribute) == len(activity_type) - - if not result: - result = ( - len(type_attribute) > len(activity_type) - and type_attribute[len(activity_type)] == "/" - ) - - return result + return type_value == activity_type_value or type_value.startswith( + f"{activity_type_value}/" + ) def add_ai_metadata( self, diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py index 4ec1b4a8f..cde7b1009 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py @@ -4,22 +4,18 @@ from __future__ import annotations from uuid import uuid4 as uuid -from typing import Optional -import logging +from typing import Optional, Annotated from pydantic import Field from .channel_account import ChannelAccount from ._channel_id_field_mixin import _ChannelIdFieldMixin -from .channel_id import ChannelId from .conversation_account import ConversationAccount from .agents_model import AgentsModel from ._type_aliases import NonEmptyString from .activity_types import ActivityTypes from .activity_event_names import ActivityEventNames -logger = logging.getLogger(__name__) - class ConversationReference(AgentsModel, _ChannelIdFieldMixin): """An object relating to a particular point in a conversation. @@ -48,7 +44,7 @@ class ConversationReference(AgentsModel, _ChannelIdFieldMixin): # optionals here are due to webchat activity_id: Optional[NonEmptyString] = None user: Optional[ChannelAccount] = None - agent: ChannelAccount = Field(None, alias="bot") + agent: Annotated[ChannelAccount, Field(alias="bot")] = None conversation: ConversationAccount locale: Optional[NonEmptyString] = None service_url: NonEmptyString = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_state.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_state.py index f2a9b6d50..81bfaa756 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_state.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_state.py @@ -3,7 +3,7 @@ import base64 import json -from typing import Optional +from typing import Optional, Annotated from pydantic import Field from .conversation_reference import ConversationReference @@ -29,7 +29,7 @@ class TokenExchangeState(AgentsModel): connection_name: NonEmptyString = None conversation: ConversationReference = None relates_to: Optional[ConversationReference] = None - agent_url: NonEmptyString = Field(None, alias="bot_url") + agent_url: Annotated[NonEmptyString, Field(alias="bot_url")] = None ms_app_id: NonEmptyString = None def get_encoded_state(self) -> str: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/temp_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/temp_state.py index 03e22379c..b396b1bc9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/temp_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/temp_state.py @@ -64,9 +64,13 @@ def delete_value(self, name: str) -> None: del self._state[name] def get_value( - self, name: str, default_value_factory: Optional[Callable[[], T]] = None - ) -> T: - """Gets a value from the state with the given name, using a factory for default values if not found""" + self, + name: str, + default_value_factory: Optional[Callable[[], T]] = None, + *, + target_cls: type[T] | None = None, + ) -> T | None: + """Gets a value from state, using a factory if not found.""" if name not in self._state and default_value_factory is not None: value = default_value_factory() self.set_value(name, value) diff --git a/scripts/coverage.ps1 b/scripts/coverage.ps1 new file mode 100644 index 000000000..dccbac499 --- /dev/null +++ b/scripts/coverage.ps1 @@ -0,0 +1,2 @@ +python -m coverage run --source=microsoft_agents -m pytest +python -m coverage html \ No newline at end of file diff --git a/tests/activity/config/__init__.py b/tests/activity/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/activity/test_load_configuration.py b/tests/activity/config/test_load_configuration.py similarity index 100% rename from tests/activity/test_load_configuration.py rename to tests/activity/config/test_load_configuration.py diff --git a/tests/activity/entity/test_entity.py b/tests/activity/entity/test_entity.py new file mode 100644 index 000000000..361c96463 --- /dev/null +++ b/tests/activity/entity/test_entity.py @@ -0,0 +1,82 @@ +import pytest +from pydantic import ValidationError + +from microsoft_agents.activity.entity import Entity + + +def test_entity_requires_type(): + with pytest.raises(ValidationError): + Entity() + + +def test_entity_additional_properties_returns_extra_fields(): + entity = Entity(type="custom", custom_value="value", count=1) + + assert entity.additional_properties == { + "custom_value": "value", + "count": 1, + } + + +def test_entity_validates_camel_case_extra_fields_as_snake_case(): + entity = Entity.model_validate( + { + "type": "custom", + "customValue": "value", + } + ) + + assert entity.additional_properties == {"custom_value": "value"} + assert entity.model_dump(by_alias=True) == { + "type": "custom", + "customValue": "value", + } + assert entity.model_dump(by_alias=False) == { + "type": "custom", + "custom_value": "value", + } + + +def test_entity_serialization_always_includes_type(): + entity = Entity(type="custom") + + assert entity.model_dump(exclude_unset=True) == {"type": "custom"} + assert entity.model_dump(exclude_none=True) == {"type": "custom"} + + +def test_entity_preserves_schema_at_fields_when_serializing_by_alias(): + entity = Entity.model_validate( + { + "type": "custom", + "@type": "schema-type", + "@context": "https://schema.org", + "@id": "schema-id", + } + ) + + assert entity.additional_properties == { + "@type": "schema-type", + "@context": "https://schema.org", + "@id": "schema-id", + "at_type": "schema-type", + "at_context": "https://schema.org", + "at_id": "schema-id", + } + assert entity.model_dump(by_alias=True) == { + "type": "custom", + "@type": "schema-type", + "@context": "https://schema.org", + "@id": "schema-id", + "atType": "schema-type", + "atContext": "https://schema.org", + "atId": "schema-id", + } + assert entity.model_dump(by_alias=False) == { + "type": "custom", + "@type": "schema-type", + "@context": "https://schema.org", + "@id": "schema-id", + "at_type": "schema-type", + "at_context": "https://schema.org", + "at_id": "schema-id", + } diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index bec57f155..1fecd29db 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -26,6 +26,23 @@ DEFAULTS = DEFAULT_TEST_VALUES() +AS_ACTIVITY_TYPE_CASES = [ + (ActivityTypes.contact_relation_update, "as_contact_relation_update_activity"), + (ActivityTypes.conversation_update, "as_conversation_update_activity"), + (ActivityTypes.end_of_conversation, "as_end_of_conversation_activity"), + (ActivityTypes.event, "as_event_activity"), + (ActivityTypes.handoff, "as_handoff_activity"), + (ActivityTypes.installation_update, "as_installation_update_activity"), + (ActivityTypes.invoke, "as_invoke_activity"), + (ActivityTypes.message, "as_message_activity"), + (ActivityTypes.message_delete, "as_message_delete_activity"), + (ActivityTypes.message_reaction, "as_message_reaction_activity"), + (ActivityTypes.message_update, "as_message_update_activity"), + (ActivityTypes.suggestion, "as_suggestion_activity"), + (ActivityTypes.trace, "as_trace_activity"), + (ActivityTypes.typing, "as_typing_activity"), +] + def helper_validate_recipient_and_from( activity: Activity, create_recipient: bool, create_from: bool @@ -239,6 +256,8 @@ def test_create_trace( @pytest.mark.parametrize( "activity_type, activity_type_name", [ + (ActivityTypes.contact_relation_update, "contact_relation_update"), + (ActivityTypes.conversation_update, "conversation_update"), (ActivityTypes.end_of_conversation, "end_of_conversation"), (ActivityTypes.event, "event"), (ActivityTypes.handoff, "handoff"), @@ -452,6 +471,40 @@ def test_get_product_info_entity_single(self, entities, expected): assert retrieved_product_info == expected +class TestActivityAsTypeHelpers: + @pytest.mark.parametrize( + "activity_type, method_name", + AS_ACTIVITY_TYPE_CASES, + ) + def test_as_activity_type_returns_self_for_matching_type( + self, activity_type, method_name + ): + activity = Activity(type=activity_type) + + assert getattr(activity, method_name)() is activity + + @pytest.mark.parametrize( + "activity_type, method_name", + AS_ACTIVITY_TYPE_CASES, + ) + def test_as_activity_type_returns_none_for_non_matching_type( + self, activity_type, method_name + ): + non_matching_type = ( + ActivityTypes.event + if activity_type != ActivityTypes.event + else ActivityTypes.message + ) + activity = Activity(type=non_matching_type) + + assert getattr(activity, method_name)() is None + + def test_as_activity_type_returns_self_for_slash_qualified_type(self): + activity = Activity(type="event/custom") + + assert activity.as_event_activity() is activity + + class TestActivityAgenticOps: @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) def non_agentic_role(self, request): @@ -521,3 +574,56 @@ def test_get_agentic_user_not_agentic(self, non_agentic_role): ), ) assert activity.get_agentic_user() is None + + def test_get_agentic_tenant_id_from_recipient(self, agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount( + role=agentic_role, + tenant_id="recipient-tenant-id", + ), + conversation=ConversationAccount( + id="conversation-id", + tenant_id="conversation-tenant-id", + ), + ) + + assert activity.get_agentic_tenant_id() == "recipient-tenant-id" + + def test_get_agentic_tenant_id_from_conversation_when_recipient_missing_tenant( + self, agentic_role + ): + activity = Activity( + type="message", + recipient=ChannelAccount(role=agentic_role), + conversation=ConversationAccount( + id="conversation-id", + tenant_id="conversation-tenant-id", + ), + ) + + assert activity.get_agentic_tenant_id() == "conversation-tenant-id" + + def test_get_agentic_tenant_id_not_agentic(self, non_agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount( + role=non_agentic_role, + tenant_id="recipient-tenant-id", + ), + conversation=ConversationAccount( + id="conversation-id", + tenant_id="conversation-tenant-id", + ), + ) + + assert activity.get_agentic_tenant_id() is None + + def test_get_agentic_tenant_id_returns_none_when_no_tenant(self, agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount(role=agentic_role), + conversation=ConversationAccount(id="conversation-id"), + ) + + assert activity.get_agentic_tenant_id() is None diff --git a/tests/activity/test_conversation_reference.py b/tests/activity/test_conversation_reference.py new file mode 100644 index 000000000..bafabc671 --- /dev/null +++ b/tests/activity/test_conversation_reference.py @@ -0,0 +1,58 @@ +from uuid import UUID + +import pytest +from pydantic import ValidationError + +from microsoft_agents.activity import ( + ActivityEventNames, + ActivityTypes, + ChannelAccount, + ConversationAccount, + ConversationReference, +) + + +def _create_conversation_reference(user: ChannelAccount | None = None): + return ConversationReference( + activity_id="activity-123", + channel_id="msteams", + service_url="https://smba.trafficmanager.net/teams/", + conversation=ConversationAccount(id="conversation-123", name="Test Chat"), + user=user if user is not None else ChannelAccount(id="user-123", name="User"), + agent=ChannelAccount(id="agent-123", name="Agent"), + locale="en-US", + ) + + +def test_get_continuation_activity_sets_expected_fields(): + conversation_reference = _create_conversation_reference() + + continuation_activity = conversation_reference.get_continuation_activity() + + UUID(continuation_activity.id) + assert continuation_activity.id != conversation_reference.activity_id + assert continuation_activity.type == ActivityTypes.event + assert continuation_activity.name == ActivityEventNames.continue_conversation + assert continuation_activity.channel_id == conversation_reference.channel_id + assert continuation_activity.service_url == conversation_reference.service_url + assert continuation_activity.conversation == conversation_reference.conversation + assert continuation_activity.recipient == conversation_reference.agent + assert continuation_activity.from_property == conversation_reference.user + assert continuation_activity.relates_to == conversation_reference + + +def test_get_continuation_activity_generates_new_id_each_time(): + conversation_reference = _create_conversation_reference() + + first_activity = conversation_reference.get_continuation_activity() + second_activity = conversation_reference.get_continuation_activity() + + assert first_activity.id != second_activity.id + + +def test_get_continuation_activity_raises_when_user_is_missing(): + conversation_reference = _create_conversation_reference(user=None) + conversation_reference.user = None + + with pytest.raises(ValidationError): + conversation_reference.get_continuation_activity() diff --git a/tests/activity/test_model_utils.py b/tests/activity/test_model_utils.py new file mode 100644 index 000000000..ec0bdec36 --- /dev/null +++ b/tests/activity/test_model_utils.py @@ -0,0 +1,164 @@ +import pytest +from pydantic import ValidationError + +from microsoft_agents.activity import Activity, AgentsModel, ChannelAccount +from microsoft_agents.activity._model_utils import ( + ModelFieldHelper, + SkipIf, + SkipNone, + pick_model, + pick_model_dict, +) + +from tests.activity._common.model_utils import PickField, SkipEmpty, SkipFalse + + +class ExampleModel(AgentsModel): + model_value: str | None = None + count: int | None = None + + +class RenameField(ModelFieldHelper): + def __init__(self, value, target_key: str): + self.value = value + self.target_key = target_key + + def process(self, key: str): + return {self.target_key: self.value} + + +class DropField(ModelFieldHelper): + def process(self, key: str): + return {} + + +def test_model_field_helper_process_must_be_implemented(): + with pytest.raises(NotImplementedError): + ModelFieldHelper().process("field") + + +def test_skip_if_skips_value_when_condition_is_true(): + field = SkipIf("secret", lambda value: value == "secret") + + assert field.process("field") == {} + + +def test_skip_if_includes_value_when_condition_is_false(): + field = SkipIf("", lambda value: value is None) + + assert field.process("field") == {"field": ""} + + +@pytest.mark.parametrize("value", [0, False, "", [], {}]) +def test_skip_none_includes_falsy_values_that_are_not_none(value): + field = SkipNone(value) + + assert field.process("field") == {"field": value} + + +def test_skip_none_skips_none(): + field = SkipNone(None) + + assert field.process("field") == {} + + +@pytest.mark.parametrize("value", [0, None, [], {}, False, ""]) +def test_skip_false_skips_falsy_values(value): + field = SkipFalse(value) + + assert field.process("field") == {} + + +@pytest.mark.parametrize("value", [2, [1, 2, 3], "aha"]) +def test_skip_false_includes_truthy_values(value): + field = SkipFalse(value) + + assert field.process("field") == {"field": value} + + +@pytest.mark.parametrize("value", ["", [], set(), {}, tuple()]) +def test_skip_empty_skips_empty_values(value): + field = SkipEmpty(value) + + assert field.process("field") == {} + + +@pytest.mark.parametrize("value", ["wow", [2], set("a"), {"a": "b"}]) +def test_skip_empty_includes_nonempty_values(value): + field = SkipEmpty(value) + + assert field.process("field") == {"field": value} + + +def test_pick_model_dict_processes_helpers_and_keeps_plain_values(): + result = pick_model_dict( + plain="value", + keep_none=None, + overwrite="initial-value", + skipped=DropField(), + renamed=RenameField("renamed-value", "target"), + optional=SkipNone(None), + present=SkipNone("present-value"), + skipped_condition=SkipIf( + "skipped-value", lambda value: value == "skipped-value" + ), + overwrite_helper=RenameField("overwritten-value", "overwrite"), + ) + + assert result == { + "plain": "value", + "keep_none": None, + "target": "renamed-value", + "present": "present-value", + "overwrite": "overwritten-value", + } + + +def test_pick_model_creates_model_from_processed_fields(): + model = pick_model( + ExampleModel, + model_value=SkipNone("test-value"), + count=SkipNone(None), + ) + + assert model == ExampleModel(model_value="test-value") + assert "model_value" in model.model_fields_set + assert "count" not in model.model_fields_set + + +def test_pick_model_supports_nested_models_and_preserves_unset_fields(): + recipient = ChannelAccount(id="123", name="foo") + + activity = pick_model( + Activity, + type="message", + id=SkipNone(None), + from_property=pick_model( + ChannelAccount, + id=PickField(recipient), + aad_object_id=PickField(recipient), + ), + recipient=pick_model( + ChannelAccount, + id=PickField(recipient), + name=PickField(recipient), + role=PickField(recipient), + ), + text=PickField(recipient, "name"), + ) + + assert activity == Activity( + type="message", + from_property=ChannelAccount(id="123"), + recipient=ChannelAccount(id="123", name="foo"), + text="foo", + ) + assert "id" not in activity.model_fields_set + assert "aad_object_id" not in activity.from_property.model_fields_set + assert "role" not in activity.recipient.model_fields_set + assert "text" in activity.model_fields_set + + +def test_pick_model_uses_model_validation(): + with pytest.raises(ValidationError): + pick_model(ExampleModel, count="not-an-int") diff --git a/tests/activity/test_token_exchange_state.py b/tests/activity/test_token_exchange_state.py new file mode 100644 index 000000000..0a4008c8b --- /dev/null +++ b/tests/activity/test_token_exchange_state.py @@ -0,0 +1,86 @@ +import base64 +import json + +import pytest + +from microsoft_agents.activity import ( + ChannelAccount, + ConversationAccount, + ConversationReference, + TokenExchangeState, +) + + +def _create_conversation_reference( + activity_id: str = "activity-123", + conversation_id: str = "conversation-123", +) -> ConversationReference: + return ConversationReference( + activity_id=activity_id, + channel_id="msteams", + service_url="https://smba.trafficmanager.net/teams/", + conversation=ConversationAccount(id=conversation_id), + user=ChannelAccount(id="user-123", name="User"), + agent=ChannelAccount(id="agent-123", name="Agent"), + ) + + +def _decode_state(encoded_state: str) -> dict: + return json.loads(base64.b64decode(encoded_state).decode("utf-8")) + + +def test_get_encoded_state_returns_base64_encoded_json_with_aliases(): + token_exchange_state = TokenExchangeState( + connection_name="connection-name", + conversation=_create_conversation_reference(), + agent_url="https://agent.example.com/api/messages", + ms_app_id="app-id", + ) + + decoded_state = _decode_state(token_exchange_state.get_encoded_state()) + + assert decoded_state["connectionName"] == "connection-name" + assert decoded_state["bot_url"] == "https://agent.example.com/api/messages" + assert decoded_state["msAppId"] == "app-id" + assert decoded_state["conversation"]["activityId"] == "activity-123" + assert decoded_state["conversation"]["channelId"] == "msteams" + assert ( + decoded_state["conversation"]["serviceUrl"] + == "https://smba.trafficmanager.net/teams/" + ) + assert decoded_state["conversation"]["conversation"]["id"] == "conversation-123" + assert decoded_state["conversation"]["user"]["id"] == "user-123" + assert decoded_state["conversation"]["bot"]["id"] == "agent-123" + assert "relatesTo" not in decoded_state + assert "agentUrl" not in decoded_state + + +def test_get_encoded_state_includes_related_conversation_when_set(): + token_exchange_state = TokenExchangeState( + connection_name="connection-name", + conversation=_create_conversation_reference(), + relates_to=_create_conversation_reference( + activity_id="parent-activity", conversation_id="parent-conversation" + ), + agent_url="https://agent.example.com/api/messages", + ms_app_id="app-id", + ) + + decoded_state = _decode_state(token_exchange_state.get_encoded_state()) + + assert decoded_state["relatesTo"]["activityId"] == "parent-activity" + assert decoded_state["relatesTo"]["conversation"]["id"] == "parent-conversation" + + +def test_token_exchange_state_accepts_bot_url_alias(): + token_exchange_state = TokenExchangeState( + connection_name="connection-name", + conversation=_create_conversation_reference(), + bot_url="https://agent.example.com/api/messages", + ms_app_id="app-id", + ) + + decoded_state = _decode_state(token_exchange_state.get_encoded_state()) + + assert token_exchange_state.agent_url == "https://agent.example.com/api/messages" + assert decoded_state["bot_url"] == "https://agent.example.com/api/messages" diff --git a/tests/activity/test_token_response.py b/tests/activity/test_token_response.py index d35fe5c79..44a612174 100644 --- a/tests/activity/test_token_response.py +++ b/tests/activity/test_token_response.py @@ -1,3 +1,6 @@ +import base64 +import json + import pytest from microsoft_agents.activity import TokenResponse @@ -22,3 +25,50 @@ def test_token_response_bool_op_false(token_response): ) def test_token_response_bool_op_true(token_response): assert bool(token_response) + + +def _create_unsigned_jwt(payload: dict) -> str: + header = {"alg": "none", "typ": "JWT"} + + def encode_segment(value: dict) -> str: + value_json = json.dumps(value, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(value_json).rstrip(b"=").decode() + + return f"{encode_segment(header)}.{encode_segment(payload)}." + + +@pytest.mark.parametrize( + "payload", + [ + {"aud": "api://test-app-id", "appid": "test-app-id"}, + {"aud": "api://test-app-id", "appid": "test-app-id", "ver": "1.0"}, + {"aud": "api://test-app-id", "azp": "test-app-id", "ver": "2.0"}, + ], +) +def test_token_response_is_exchangeable_true(payload): + token_response = TokenResponse(token=_create_unsigned_jwt(payload)) + + assert token_response.is_exchangeable() + + +@pytest.mark.parametrize( + "payload", + [ + {"aud": "api://test-app-id", "appid": "test-app-id", "idtyp": "user"}, + {"aud": "api://test-app-id"}, + {"appid": "test-app-id"}, + {"aud": "api://test-app-id", "appid": "other-app-id", "ver": "1.0"}, + {"aud": "api://test-app-id", "azp": "other-app-id", "ver": "2.0"}, + {"aud": "api://test-app-id", "appid": "test-app-id", "ver": "3.0"}, + ], +) +def test_token_response_is_exchangeable_false(payload): + token_response = TokenResponse(token=_create_unsigned_jwt(payload)) + + assert not token_response.is_exchangeable() + + +def test_token_response_is_exchangeable_false_for_invalid_token(): + token_response = TokenResponse(token="not-a-jwt") + + assert not token_response.is_exchangeable() diff --git a/tests/activity/test_tools.py b/tests/activity/test_tools.py deleted file mode 100644 index e33edf899..000000000 --- a/tests/activity/test_tools.py +++ /dev/null @@ -1,115 +0,0 @@ -import pytest - -from microsoft_agents.activity import Activity, ChannelAccount -from microsoft_agents.activity._model_utils import ( - ModelFieldHelper, - SkipNone, - SkipIf, - pick_model, - pick_model_dict, -) - -from tests.activity._common.model_utils import SkipFalse, SkipEmpty, PickField - - -class TestModelUtils: - def test_skip_if(self): - field = SkipIf("foo", lambda v: v == "foo") - assert field.process("key") == {} - - @pytest.mark.parametrize( - "value, expected", - [ - [None, {}], - [42, {"field": 42}], - ["foo", {"field": "foo"}], - ], - ) - def test_skip_none(self, value, expected): - field = SkipNone(value) - assert field.process("field") == expected - - @pytest.mark.parametrize("value", [0, None, [], {}, False, ""]) - def test_skip_false_with_falsy_value(self, value): - field = SkipFalse(value) - assert field.process("key") == {} - - @pytest.mark.parametrize("value", [2, [1, 2, 3], "aha"]) - def test_skip_false_with_truthy_value(self, value): - field = SkipFalse(value) - assert field.process("key") == {"key": value} - - @pytest.mark.parametrize("value", ["", [], set(), {}, tuple()]) - def test_skip_empty_with_empty_value(self, value): - field = SkipEmpty(value) - assert field.process("key") == {} - - @pytest.mark.parametrize("value", ["wow", [2], set("a"), {"a": "b"}]) - def test_skip_empty_with_nonempty_value(self, value): - field = SkipEmpty(value) - assert field.process("key") == {"key": value} - - def test_pick_model(self, mocker): - recipient = ChannelAccount(id="123", name="foo") - activity = pick_model( - Activity, - type="message", - id=SkipNone(None), - from_property=pick_model( - ChannelAccount, - id=PickField(recipient), - aad_object_id=PickField(recipient), - ), - recipient=pick_model( - ChannelAccount, - id=PickField(recipient), - name=PickField(recipient), - role=PickField(recipient), - ), - text=PickField(recipient, "name"), - ) - expected = Activity( - type="message", - from_property=ChannelAccount(id="123"), - recipient=ChannelAccount(id="123", name="foo"), - text="foo", - ) - - assert activity == expected - assert "id" not in activity.model_fields_set - assert "aad_object_id" not in activity.from_property.model_fields_set - assert "role" not in activity.recipient.model_fields_set - assert "text" in activity.model_fields_set - - def test_pick_model_dict(self): - class Foo(ModelFieldHelper): - def process(self, key): - return {key: "bar"} - - class Bar(ModelFieldHelper): - def process(self, key): - return {"bar": "bar"} - - foo = Foo() - result = foo.process("foo") - assert result == {"foo": "bar"} - - res = pick_model_dict( - a=Foo(), - b=SkipNone("bar"), - c=SkipIf("baz", lambda v: v == "baz"), - d=Foo(), - e=None, - f=42, - bar=7, - g=Bar(), - ) - - assert res == { - "a": "bar", - "b": "bar", - "d": "bar", - "e": None, - "f": 42, - "bar": "bar", - } diff --git a/tests/hosting_core/app/state/__init__.py b/tests/hosting_core/app/state/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/hosting_core/app/state/test_conversation_state.py b/tests/hosting_core/app/state/test_conversation_state.py new file mode 100644 index 000000000..8db0b81e2 --- /dev/null +++ b/tests/hosting_core/app/state/test_conversation_state.py @@ -0,0 +1,73 @@ +import pytest +from types import SimpleNamespace + +from microsoft_agents.activity import Activity, ActivityTypes, ConversationAccount +from microsoft_agents.hosting.core.app.state import ConversationState +from microsoft_agents.hosting.core.storage import MemoryStorage +from microsoft_agents.hosting.core.turn_context import TurnContext + +from tests._common.testing_objects import MockTestingAdapter + + +def _create_context(channel_id="test-channel", conversation_id="conversation-123"): + activity = Activity( + type=ActivityTypes.message, + channel_id=channel_id, + conversation=ConversationAccount(id=conversation_id), + ) + return TurnContext(MockTestingAdapter(), activity) + + +def test_conversation_state_uses_expected_context_service_key(): + state = ConversationState(MemoryStorage()) + + assert state.CONTEXT_SERVICE_KEY == "ConversationState" + + +def test_get_storage_key_uses_channel_and_conversation_id(): + state = ConversationState(MemoryStorage()) + context = _create_context( + channel_id="msteams", + conversation_id="conversation-456", + ) + + assert state.get_storage_key(context) == "msteams/conversations/conversation-456" + + +def test_get_storage_key_raises_when_channel_id_is_missing(): + state = ConversationState(MemoryStorage()) + context = SimpleNamespace( + activity=SimpleNamespace( + channel_id="", + conversation=SimpleNamespace(id="conversation-123"), + ) + ) + + with pytest.raises(ValueError, match="missing channel_id"): + state.get_storage_key(context) + + +def test_get_storage_key_raises_when_conversation_id_is_missing(): + state = ConversationState(MemoryStorage()) + context = SimpleNamespace( + activity=SimpleNamespace( + channel_id="test-channel", + conversation=SimpleNamespace(id=""), + ) + ) + + with pytest.raises(ValueError, match="missing conversation_id"): + state.get_storage_key(context) + + +@pytest.mark.asyncio +async def test_clear_marks_conversation_state_changed(): + state = ConversationState(MemoryStorage()) + context = _create_context() + + await state.load(context) + state.clear(context) + + cached_state = state._cached_state + assert cached_state.state == {} + assert cached_state.is_changed diff --git a/tests/hosting_core/app/test_state.py b/tests/hosting_core/app/state/test_state.py similarity index 84% rename from tests/hosting_core/app/test_state.py rename to tests/hosting_core/app/state/test_state.py index fc5915bbf..923346e43 100644 --- a/tests/hosting_core/app/test_state.py +++ b/tests/hosting_core/app/state/test_state.py @@ -53,7 +53,7 @@ def test_state_initialization(self): assert test_state.__deleted__ == [] def test_state_property_access(self): - """Test that state properties can be accessed using both dict and attribute syntax.""" + """Test state properties can be accessed using dict and attribute syntax.""" test_state = StateForTesting() # Test attribute syntax @@ -63,7 +63,7 @@ def test_state_property_access(self): assert test_state["test_property"] == "default_value" def test_state_property_modification(self): - """Test that state properties can be modified using both dict and attribute syntax.""" + """Test state properties can be modified using dict and attribute syntax.""" test_state = StateForTesting() # Test attribute syntax @@ -88,6 +88,25 @@ def test_state_property_deletion(self): del test_state["test_property"] assert "test_property" not in test_state + def test_state_missing_attribute_raises_attribute_error(self): + """Test missing attribute access raises AttributeError.""" + test_state = StateForTesting() + + with pytest.raises(AttributeError, match="missing"): + test_state.missing + + def test_state_private_attributes_and_callables_are_not_dict_values(self): + """Test private attributes and callables stay as object attributes.""" + test_state = StateForTesting() + + test_state._private_value = "private" + test_state.helper = lambda: "callable" + + assert test_state._private_value == "private" + assert test_state.helper() == "callable" + assert "_private_value" not in test_state + assert "helper" not in test_state + def test_state_deleted_tracking(self): """Test that state tracks deleted properties for storage updates.""" test_state = StateForTesting() @@ -103,6 +122,25 @@ def test_state_deleted_tracking(self): # Check if the nested key is tracked for deletion assert "nested-key" in test_state.__deleted__ + def test_state_deleted_tracking_is_removed_when_deleted_key_is_set_again(self): + """Test deleted tracking is removed if the deleted storage key is set again.""" + test_state = StateForTesting() + + test_state.__deleted__ = ["deleted-key"] + test_state["deleted-key"] = "replacement" + + assert "deleted-key" not in test_state.__deleted__ + assert test_state["deleted-key"] == "replacement" + + def test_state_str_serializes_store_items(self): + """Test string representation uses StoreItem serialization.""" + test_state = StateForTesting() + del test_state.test_property + + result = str(test_state) + + assert "'store_item': {'initial': 'value'}" in result + def test_create_property(self): """Test creating a state property accessor.""" test_state = StateForTesting() diff --git a/tests/hosting_core/app/state/test_temp_state.py b/tests/hosting_core/app/state/test_temp_state.py new file mode 100644 index 000000000..cb7d60d35 --- /dev/null +++ b/tests/hosting_core/app/state/test_temp_state.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass + +import pytest + +from microsoft_agents.hosting.core.app.input_file import InputFile +from microsoft_agents.hosting.core.app.state import TempState + + +@dataclass +class ExampleValue: + value: str + + +def test_temp_state_name_is_scope_name(): + state = TempState() + + assert state.name == "temp" + assert state.SCOPE_NAME == "temp" + + +def test_temp_state_get_value_uses_factory_once_and_caches_value(): + state = TempState() + calls = 0 + + def factory(): + nonlocal calls + calls += 1 + return [] + + first_value = state.get_value("items", factory) + first_value.append("value") + second_value = state.get_value("items", factory) + + assert calls == 1 + assert second_value == ["value"] + assert first_value is second_value + + +def test_temp_state_has_set_and_delete_value(): + state = TempState() + + state.set_value("name", "value") + assert state.has_value("name") + assert state.get_value("name") == "value" + + state.delete_value("name") + assert not state.has_value("name") + assert state.get_value("name") is None + + +def test_temp_state_input_files_uses_dedicated_key(): + state = TempState() + input_files = [ + InputFile( + content=b"hello", + content_type="text/plain", + content_url="https://example.com/file.txt", + ) + ] + + assert state.input_files == [] + + state.input_files = input_files + + assert state.input_files == input_files + assert state.get_value(TempState.INPUT_FILES_KEY) == input_files + + +def test_temp_state_typed_values_use_value_type_as_key(): + state = TempState() + value = ExampleValue("test") + + state.set_typed_value(value) + + assert state.get_typed_value(ExampleValue) is value + + +def test_temp_state_clear_and_delete_state_remove_all_values(): + state = TempState() + state.set_value("name", "value") + + state.clear(None) + + assert not state.has_value("name") + + +@pytest.mark.asyncio +async def test_temp_state_async_methods_are_noops_or_clear_state(): + state = TempState() + state.set_value("name", "value") + + await state.load(None) + await state.save(None) + await state.save_changes(None) + + assert state.has_value("name") + assert state.is_loaded() + + await state.delete_state(None) + + assert not state.has_value("name") diff --git a/tests/hosting_core/app/state/test_turn_state.py b/tests/hosting_core/app/state/test_turn_state.py new file mode 100644 index 000000000..7d9632ae1 --- /dev/null +++ b/tests/hosting_core/app/state/test_turn_state.py @@ -0,0 +1,164 @@ +import pytest + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, +) +from microsoft_agents.hosting.core.app.state import ( + ConversationState, + TempState, + TurnState, +) +from microsoft_agents.hosting.core.state.agent_state import CachedAgentState +from microsoft_agents.hosting.core.state import UserState +from microsoft_agents.hosting.core.storage import MemoryStorage +from microsoft_agents.hosting.core.turn_context import TurnContext + +from tests._common.testing_objects import MockTestingAdapter + + +def _create_context(): + activity = Activity( + type=ActivityTypes.message, + channel_id="test-channel", + conversation=ConversationAccount(id="conversation-123"), + from_property=ChannelAccount(id="user-123"), + ) + return TurnContext(MockTestingAdapter(), activity) + + +def test_turn_state_always_has_temp_scope(): + turn_state = TurnState() + + assert isinstance(turn_state.temp, TempState) + assert turn_state.get_scope_by_name("temp") is turn_state.temp + + +def test_turn_state_with_storage_adds_default_persistent_scopes(): + storage = MemoryStorage() + + turn_state = TurnState.with_storage(storage) + + assert isinstance(turn_state.conversation, ConversationState) + assert isinstance(turn_state.user, UserState) + assert isinstance(turn_state.temp, TempState) + + +def test_turn_state_add_returns_self_and_registers_scope(): + storage = MemoryStorage() + conversation_state = ConversationState(storage) + turn_state = TurnState() + + result = turn_state.add(conversation_state) + + assert result is turn_state + assert turn_state.get_scope(ConversationState) is conversation_state + + +def test_turn_state_add_rejects_none(): + turn_state = TurnState() + + with pytest.raises(ValueError, match="agent_state cannot be None"): + turn_state.add(None) + + +def test_turn_state_get_scope_errors_for_missing_scope(): + turn_state = TurnState() + + with pytest.raises(ValueError, match="ConversationState"): + turn_state.get_scope(ConversationState) + + with pytest.raises(ValueError, match="missing"): + turn_state.get_scope_by_name("missing") + + +def test_turn_state_path_operations_default_to_temp_scope(): + turn_state = TurnState() + + assert not turn_state.has_value("name") + + turn_state.set_value("name", "value") + + assert turn_state.has_value("name") + assert turn_state.get_value("name") == "value" + + turn_state.delete_value("name") + + assert not turn_state.has_value("name") + + +def test_turn_state_path_operations_use_named_scope(): + turn_state = TurnState() + + turn_state.set_value("temp.name", "value") + + assert turn_state.has_value("temp.name") + assert turn_state.get_value("temp.name") == "value" + + +def test_turn_state_get_value_uses_default_factory_for_temp_scope(): + turn_state = TurnState() + + value = turn_state.get_value("items", lambda: []) + value.append("item") + + assert turn_state.get_value("items") == ["item"] + + +def test_turn_state_clear_specific_scope_or_all_scopes(): + turn_state = TurnState() + turn_state.set_value("temp.name", "value") + + turn_state.clear(None, scope="temp") + + assert not turn_state.has_value("temp.name") + + turn_state.set_value("temp.name", "value") + turn_state.clear(None) + + assert not turn_state.has_value("temp.name") + + +@pytest.mark.asyncio +async def test_turn_state_load_initializes_default_scopes_with_storage(): + storage = MemoryStorage() + context = _create_context() + turn_state = TurnState() + + await turn_state.load(context, storage) + + assert isinstance(turn_state.conversation, ConversationState) + assert isinstance(turn_state.user, UserState) + assert isinstance(turn_state.temp, TempState) + assert turn_state.conversation.get_cached_state(context) is not None + assert turn_state.user.get_cached_state(context) is not None + + +@pytest.mark.asyncio +async def test_turn_state_save_persists_loaded_default_scopes(): + storage = MemoryStorage() + context = _create_context() + turn_state = TurnState() + + await turn_state.load(context, storage) + turn_state.conversation.set_value("topic", {"value": "state"}) + await turn_state.save(context, force=True) + + storage_key = turn_state.conversation.get_storage_key(context) + stored_items = await storage.read( + [storage_key], + target_cls=CachedAgentState, + ) + conversation_cache = stored_items[storage_key] + assert conversation_cache.state["topic"] == {"value": "state"} + + +def test_turn_state_scope_path_parser(): + assert TurnState._get_scope_and_path("name") == ("temp", "name") + assert TurnState._get_scope_and_path("scope.name") == ("scope", "name") + assert TurnState._get_scope_and_path("scope.nested.name") == ( + "scope", + "nested.name", + ) diff --git a/tests/hosting_core/telemetry/test_simple_span_wrapper.py b/tests/hosting_core/telemetry/test_simple_span_wrapper.py index 5a57c5a5e..37f83fb06 100644 --- a/tests/hosting_core/telemetry/test_simple_span_wrapper.py +++ b/tests/hosting_core/telemetry/test_simple_span_wrapper.py @@ -1,7 +1,6 @@ import time import pytest -from types import SimpleNamespace from opentelemetry.trace import StatusCode @@ -10,13 +9,6 @@ test_exporter, test_metric_reader, ) -from tests._common.telemetry_utils import ( - find_metric, - sum_counter, - sum_hist_count, -) - -from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.telemetry import ( agents_telemetry, SimpleSpanWrapper, @@ -240,3 +232,23 @@ class CustomError(Exception): with pytest.raises(CustomError, match="custom msg"): with MinimalSpanWrapper("propagate"): raise CustomError("custom msg") + + def test_simple_span_wrapper_can_be_used_directly(self, test_exporter): + """SimpleSpanWrapper itself creates a valid span without subclassing.""" + with SimpleSpanWrapper("direct_simple"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "direct_simple" + + def test_custom_attributes_set_when_span_body_fails(self, test_exporter): + """Attributes returned by _get_attributes are still set on failed spans.""" + with pytest.raises(ValueError, match="boom"): + with MySpanWrapper("failed_with_attrs"): + raise ValueError("boom") + + span = test_exporter.get_finished_spans()[0] + assert span.attributes["custom_attribute"] == "custom_value" + assert span.attributes["callback_called"] is True + assert span.attributes["exception_message"] == "boom"