Skip to content
Merged
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[run]
omit =
*/microsoft-agents-testing/*
Comment thread
rodrigobr-msft marked this conversation as resolved.
Comment thread
rodrigobr-msft marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
rodrigobr-msft marked this conversation as resolved.
Comment thread
rodrigobr-msft marked this conversation as resolved.

Comment thread
rodrigobr-msft marked this conversation as resolved.
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}/"
)
Comment on lines +796 to +803

def add_ai_metadata(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Comment thread
rodrigobr-msft marked this conversation as resolved.
locale: Optional[NonEmptyString] = None
service_url: NonEmptyString = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment thread
rodrigobr-msft marked this conversation as resolved.
ms_app_id: NonEmptyString = None
Comment thread
rodrigobr-msft marked this conversation as resolved.

def get_encoded_state(self) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
rodrigobr-msft marked this conversation as resolved.
Comment thread
rodrigobr-msft marked this conversation as resolved.
"""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)
Expand Down
2 changes: 2 additions & 0 deletions scripts/coverage.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
python -m coverage run --source=microsoft_agents -m pytest
python -m coverage html
Empty file.
82 changes: 82 additions & 0 deletions tests/activity/entity/test_entity.py
Original file line number Diff line number Diff line change
@@ -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",
}
106 changes: 106 additions & 0 deletions tests/activity/test_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
58 changes: 58 additions & 0 deletions tests/activity/test_conversation_reference.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading