From f2333eeb4b8b71f974182ce6c741584deabd0e62 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 21 Jul 2026 21:38:44 -0700 Subject: [PATCH 1/9] Removing mixin --- .../microsoft_agents/activity/__init__.py | 2 - .../activity/_channel_id_field_mixin.py | 95 ---------------- .../microsoft_agents/activity/activity.py | 16 +-- .../microsoft_agents/activity/channel_id.py | 105 +++++++++++------- .../activity/conversation_reference.py | 5 +- .../hosting/core/channel_service_adapter.py | 3 +- .../connector/client/user_token_client.py | 21 +--- .../pydantic/test_channel_id_field_mixin.py | 82 -------------- tests/activity/test_channel_id.py | 5 + 9 files changed, 86 insertions(+), 248 deletions(-) delete mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/_channel_id_field_mixin.py delete mode 100644 tests/activity/pydantic/test_channel_id_field_mixin.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index 8a4b57e4e..730b8754b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -20,7 +20,6 @@ from .card_image import CardImage from .channels import Channels from .channel_account import ChannelAccount -from ._channel_id_field_mixin import _ChannelIdFieldMixin from .channel_id import ChannelId from .conversation_account import ConversationAccount from .conversation_members import ConversationMembers @@ -126,7 +125,6 @@ "Channels", "ChannelAccount", "ChannelId", - "_ChannelIdFieldMixin", "ConversationAccount", "ConversationMembers", "ConversationParameters", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_channel_id_field_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_channel_id_field_mixin.py deleted file mode 100644 index 38101b87a..000000000 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/_channel_id_field_mixin.py +++ /dev/null @@ -1,95 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from __future__ import annotations - -import logging -from typing import Optional, Any - -from pydantic import ( - ModelWrapValidatorHandler, - SerializerFunctionWrapHandler, - computed_field, - model_validator, - model_serializer, -) - -from microsoft_agents.activity.errors import activity_errors - -from .channel_id import ChannelId - -logger = logging.getLogger(__name__) - - -# can be generalized in the future, if needed -class _ChannelIdFieldMixin: - """A mixin to add a computed field channel_id of type ChannelId to a Pydantic model.""" - - _channel_id: Optional[ChannelId] = None - - # required to define the setter below - @computed_field(return_type=Optional[ChannelId], alias="channelId") - @property - def channel_id(self) -> Optional[ChannelId]: - """Gets the _channel_id field""" - return self._channel_id - - # necessary for backward compatibility - # previously, channel_id was directly assigned with strings - @channel_id.setter - def channel_id(self, value: Any): - """Sets the channel_id after validating it as a ChannelId model.""" - if isinstance(value, ChannelId): - self._channel_id = value - elif isinstance(value, str): - self._channel_id = ChannelId(value) - else: - raise ValueError(activity_errors.InvalidChannelIdType.format(type(value))) - - def _set_validated_channel_id(self, data: Any) -> None: - """Sets the channel_id after validating it as a ChannelId model.""" - if "channelId" in data: - self.channel_id = data["channelId"] - elif "channel_id" in data: - self.channel_id = data["channel_id"] - - @model_validator(mode="wrap") - @classmethod - def _validate_channel_id( - cls, data: Any, handler: ModelWrapValidatorHandler - ) -> _ChannelIdFieldMixin: - """Validate the _channel_id field after model initialization. - - :return: The model instance itself. - """ - try: - model = handler(data) - model._set_validated_channel_id(data) - return model - except Exception: - logging.error("Model %s failed to validate with data %s", cls, data) - raise - - def _remove_serialized_unset_channel_id( - self, serialized: dict[str, object] - ) -> None: - """Remove the _channel_id field if it is not set.""" - if not self._channel_id: - if "channelId" in serialized: - del serialized["channelId"] - elif "channel_id" in serialized: - del serialized["channel_id"] - - @model_serializer(mode="wrap") - def _serialize_channel_id( - self, handler: SerializerFunctionWrapHandler - ) -> dict[str, object]: - """Serialize the model using Pydantic's standard serialization. - - :param handler: The serialization handler provided by Pydantic. - :return: A dictionary representing the serialized model. - """ - serialized = handler(self) - if self: # serialization can be called with None - self._remove_serialized_unset_channel_id(serialized) - return serialized diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index c3881d306..7c7dbd0f9 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -16,7 +16,6 @@ model_validator, SerializerFunctionWrapHandler, ModelWrapValidatorHandler, - computed_field, ValidationError, ) @@ -41,7 +40,6 @@ from .semantic_action import SemanticAction from .agents_model import AgentsModel from .role_types import RoleTypes -from ._channel_id_field_mixin import _ChannelIdFieldMixin from .channel_id import ChannelId from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString @@ -53,7 +51,7 @@ # TODO: A2A Agent 2 is responding with None as id, had to mark it as optional (investigate) -class Activity(AgentsModel, _ChannelIdFieldMixin): +class Activity(AgentsModel): """An Activity is the basic communication type for the protocol. :param type: Contains the activity type. Possible values include: @@ -155,6 +153,7 @@ class Activity(AgentsModel, _ChannelIdFieldMixin): """ type: NonEmptyString + channel_id: Optional[ChannelId] = None id: Optional[NonEmptyString] = None timestamp: datetime = None local_timestamp: datetime = None @@ -211,10 +210,6 @@ def _validate_channel_id( # run Pydantic's standard validation first activity = handler(data) - # needed to assign to a computed field - # needed because we override the mixin validator - activity._set_validated_channel_id(data) - # sync sub_channel with productInfo entity product_info = activity.get_product_info_entity() if product_info and activity.channel_id: @@ -280,9 +275,6 @@ def _serialize_sub_channel_data( if not serialized["entities"]: # after removal above, list may be empty del serialized["entities"] - # necessary due to computed_field serialization - self._remove_serialized_unset_channel_id(serialized) - return serialized def apply_conversation_reference( @@ -662,8 +654,8 @@ def get_conversation_reference( agent=copy(self.recipient), conversation=copy(self.conversation), channel_id=( - self.channel_id.split(":", 1)[0] - if force_base_channel and self.channel_id is not None + ChannelId.get_channel(self.channel_id) + if force_base_channel else self.channel_id ), locale=self.locale, diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py index e8192d6c9..fee772186 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py @@ -14,35 +14,28 @@ class ChannelId(str): """A ChannelId represents a channel and optional sub-channel in the format 'channel:sub_channel'.""" + _channel: str + _sub_channel: str | None + def __init__( self, - value: Optional[str] = None, + value: str | None = None, *, - channel: Optional[str] = None, - sub_channel: Optional[str] = None, + channel: str | None = None, + sub_channel: str | None = None, ) -> None: - """Initialize a ChannelId instance. + """Accept the public constructor signature after __new__ initializes the instance. - :param value: The full channel ID string in the format 'channel:sub_channel'. Must be provided if channel is not provided. - :param channel: The main channel string. Must be provided if value is not provided. - :param sub_channel: The sub-channel string. - :raises ValueError: If the input parameters are invalid. value and channel cannot both be provided. + ChannelId subclasses str, an immutable type, so the string value and derived + channel parts must be assigned in __new__ when the instance is created. """ - super().__init__() - if not channel: - split = self.strip().split(":", 1) - self._channel = split[0].strip() - self._sub_channel = split[1].strip() if len(split) == 2 else None - else: - self._channel = channel - self._sub_channel = sub_channel def __new__( cls, - value: Optional[str] = None, + value: str | None = None, *, - channel: Optional[str] = None, - sub_channel: Optional[str] = None, + channel: str | None = None, + sub_channel: str | None = None, ) -> ChannelId: """Create a new ChannelId instance. @@ -52,30 +45,47 @@ def __new__( :return: A new ChannelId instance. :raises ValueError: If the input parameters are invalid. value and channel cannot both be provided. """ + if isinstance(value, cls) and channel is None and sub_channel is None: + return value + + value, channel, sub_channel = cls._normalize(value, channel, sub_channel) + + instance = str.__new__(cls, value) + instance._channel = channel + instance._sub_channel = sub_channel + return instance + + @staticmethod + def _normalize( + value: Optional[str], + channel: Optional[str], + sub_channel: Optional[str], + ) -> tuple[str, str, Optional[str]]: + """Normalize constructor arguments into string, channel, and sub-channel.""" if isinstance(value, str): if channel or sub_channel: raise ValueError(str(activity_errors.ChannelIdValueConflict)) value = value.strip() - if value: - return str.__new__(cls, value) - raise TypeError(str(activity_errors.ChannelIdValueMustBeNonEmpty)) - else: - if ( - not isinstance(channel, str) - or len(channel.strip()) == 0 - or ":" in channel - ): - raise TypeError( - "channel must be a non empty string, and must not contain the ':' character" - ) - if sub_channel is not None and (not isinstance(sub_channel, str)): - raise TypeError("sub_channel must be a string if provided") - channel = channel.strip() - sub_channel = sub_channel.strip() if sub_channel else None - if sub_channel: - return str.__new__(cls, f"{channel}:{sub_channel}") - return str.__new__(cls, channel) + if not value: + raise TypeError(str(activity_errors.ChannelIdValueMustBeNonEmpty)) + + split = value.split(":", 1) + channel = split[0].strip() + sub_channel = split[1].strip() if len(split) == 2 else None + return value, channel, sub_channel + + if not isinstance(channel, str) or len(channel.strip()) == 0 or ":" in channel: + raise TypeError( + "channel must be a non empty string, and must not contain the ':' character" + ) + if sub_channel is not None and (not isinstance(sub_channel, str)): + raise TypeError("sub_channel must be a string if provided") + channel = channel.strip() + sub_channel = sub_channel.strip() if sub_channel else None + if sub_channel: + return f"{channel}:{sub_channel}", channel, sub_channel + return channel, channel, None @property def channel(self) -> str: @@ -83,7 +93,7 @@ def channel(self) -> str: return self._channel # type: ignore[return-value] @property - def sub_channel(self) -> Optional[str]: + def sub_channel(self) -> str | None: """The sub-channel, e.g. 'work' in 'email:work'. May be None.""" return self._sub_channel @@ -93,3 +103,20 @@ def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> CoreSchema: return core_schema.no_info_after_validator_function(cls, handler(str)) + + @staticmethod + def get_sub_channel(channel_id: str | ChannelId | None) -> str | None: + """Return the sub-channel from a ChannelId or string.""" + if not channel_id or not channel_id.strip(): + return None + if isinstance(channel_id, ChannelId): + return channel_id.sub_channel + return channel_id.split(":", 1)[1].strip() if ":" in channel_id else None + + @staticmethod + def get_channel(channel_id: str | ChannelId | None) -> str | None: + """Return the Bot Framework channel without an optional sub-channel.""" + if not channel_id or not channel_id.strip(): + return channel_id + parsed = channel_id.split(":", 1)[0].strip() if ":" in channel_id else None + return parsed or channel_id \ No newline at end of file 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 cde7b1009..d2bce1811 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py @@ -9,7 +9,7 @@ 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 @@ -17,7 +17,7 @@ from .activity_event_names import ActivityEventNames -class ConversationReference(AgentsModel, _ChannelIdFieldMixin): +class ConversationReference(AgentsModel): """An object relating to a particular point in a conversation. :param activity_id: (Optional) ID of the activity to refer to @@ -46,6 +46,7 @@ class ConversationReference(AgentsModel, _ChannelIdFieldMixin): user: Optional[ChannelAccount] = None agent: Annotated[ChannelAccount, Field(alias="bot")] = None conversation: ConversationAccount + channel_id: Optional[ChannelId] = None locale: Optional[NonEmptyString] = None service_url: NonEmptyString = None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 752ae078c..1cda31b52 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -15,6 +15,7 @@ ActivityTypes, CallerIdConstants, Channels, + ChannelId, ConversationAccount, ConversationReference, ConversationResourceResponse, @@ -488,7 +489,7 @@ def _create_create_activity( # Create a conversation update activity to represent the result. activity = Activity.create_event_activity() activity.name = ActivityEventNames.create_conversation - activity.channel_id = channel_id + activity.channel_id = ChannelId(channel_id) activity.service_url = service_url activity.id = create_conversation_result.activity_id or str(uuid4()) activity.conversation = ConversationAccount( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py index 289af112f..da3f5df4f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py @@ -113,15 +113,6 @@ class UserToken(UserTokenBase): def __init__(self, client: ClientSession): self.client = client - @staticmethod - def _base_channel_id(channel_id: Optional[str]) -> Optional[str]: - """Return the Bot Framework channel without an optional sub-channel.""" - if not channel_id or not channel_id.strip(): - return channel_id - - base_channel_id = ChannelId(channel_id).channel - return base_channel_id or channel_id - async def get_token( self, user_id: str, @@ -130,7 +121,7 @@ async def get_token( code: Optional[str] = None, ) -> TokenResponse: - channel_id = self._base_channel_id(channel_id) + channel_id = ChannelId.get_channel(channel_id) with spans.GetUserToken( connection_name=connection_name, user_id=user_id, channel_id=channel_id @@ -167,7 +158,7 @@ async def _get_token_or_sign_in_resource( ) -> TokenOrSignInResourceResponse: """Get token or sign-in resource for a user.""" - channel_id = self._base_channel_id(channel_id) + channel_id = ChannelId.get_channel(channel_id) with spans.GetTokenOrSignInResource( connection_name=connection_name, user_id=user_id, channel_id=channel_id @@ -206,7 +197,7 @@ async def get_aad_tokens( ) -> dict[str, TokenResponse]: """Get AAD tokens for a user.""" - channel_id = self._base_channel_id(channel_id) + channel_id = ChannelId.get_channel(channel_id) with spans.GetAadTokens( connection_name=connection_name, user_id=user_id, channel_id=channel_id @@ -237,7 +228,7 @@ async def sign_out( ) -> None: """Sign out user from a connection.""" - channel_id = self._base_channel_id(channel_id) + channel_id = ChannelId.get_channel(channel_id) with spans.SignOut( user_id=user_id, connection_name=connection_name, channel_id=channel_id @@ -267,7 +258,7 @@ async def get_token_status( ) -> list[TokenStatus]: """Get token status for a user.""" - channel_id = self._base_channel_id(channel_id) + channel_id = ChannelId.get_channel(channel_id) with spans.GetTokenStatus(user_id=user_id, channel_id=channel_id) as span: params = {"userId": user_id} @@ -301,7 +292,7 @@ async def exchange_token( ) -> TokenResponse: """Exchange token for a user.""" - channel_id = self._base_channel_id(channel_id) + channel_id = ChannelId.get_channel(channel_id) with spans.ExchangeToken( connection_name=connection_name, user_id=user_id, channel_id=channel_id diff --git a/tests/activity/pydantic/test_channel_id_field_mixin.py b/tests/activity/pydantic/test_channel_id_field_mixin.py deleted file mode 100644 index ab51b0569..000000000 --- a/tests/activity/pydantic/test_channel_id_field_mixin.py +++ /dev/null @@ -1,82 +0,0 @@ -import pytest - -from typing import Optional -from pydantic import BaseModel, ValidationError - -from microsoft_agents.activity import ChannelId, _ChannelIdFieldMixin - - -class DummyModel(BaseModel, _ChannelIdFieldMixin): ... - - -def channel_id_eq(a: Optional[ChannelId], b: Optional[ChannelId]) -> bool: - return a.channel == b.channel and a.sub_channel == b.sub_channel and a == b - - -class TestChannelIdFieldMixin: - - def test_validation_basic(self): - model = DummyModel(channel_id="email:support") - assert channel_id_eq(model.channel_id, ChannelId("email:support")) - model = DummyModel(channel_id="email") - assert channel_id_eq(model.channel_id, ChannelId("email")) - model = DummyModel(channel_id="channel:sub_channel:extra") - assert channel_id_eq(model.channel_id, ChannelId("channel:sub_channel:extra")) - - def test_validation_from_channel_id(self): - model = DummyModel(channel_id=ChannelId("email:support")) - assert channel_id_eq(model.channel_id, ChannelId("email:support")) - - def test_validation_dict(self): - model = DummyModel.model_validate({"channelId": "email:support"}) - assert channel_id_eq(model.channel_id, ChannelId("email:support")) - - def test_validation_dict_camel_case(self): - model = DummyModel.model_validate({"channel_id": "email:support"}) - assert channel_id_eq(model.channel_id, ChannelId("email:support")) - - def test_validation_none(self): - model = DummyModel.model_validate({}) - assert model.channel_id is None - - def test_validation_invalid_type(self): - with pytest.raises(ValidationError): - DummyModel.model_validate({"channelId": 123}) - with pytest.raises(ValidationError): - DummyModel.model_validate({"channel_id": 123}) - with pytest.raises(ValidationError): - DummyModel.model_validate({"channelId": None}) - with pytest.raises(ValidationError): - DummyModel(channel_id=123) - - def test_serialize(self): - model = DummyModel(channel_id="email:support") - assert model.model_dump() == {"channel_id": "email:support"} - assert model.model_dump_json() == '{"channel_id":"email:support"}' - assert model.model_dump(by_alias=True) == {"channelId": "email:support"} - assert model.model_dump_json(by_alias=True) == '{"channelId":"email:support"}' - assert model.model_dump(exclude_unset=True) == {"channel_id": "email:support"} - - def test_serialize_none(self): - model = DummyModel() - assert model.model_dump() == {} - assert model.model_dump_json() == "{}" - assert model.model_dump(by_alias=True) == {} - assert model.model_dump_json(by_alias=True) == "{}" - assert model.model_dump(exclude_unset=True) == {} - - def test_set(self): - model = DummyModel() - assert model.channel_id is None - model.channel_id = "email:support" - assert channel_id_eq(model.channel_id, ChannelId("email:support")) - model.channel_id = "a:b:c" - assert channel_id_eq(model.channel_id, ChannelId("a:b:c")) - model.channel_id = ChannelId("email") - assert channel_id_eq(model.channel_id, ChannelId("email")) - with pytest.raises(Exception): - model.channel_id = 123 - with pytest.raises(Exception): - model.channel_id = "" - with pytest.raises(Exception): - model.channel_id = None diff --git a/tests/activity/test_channel_id.py b/tests/activity/test_channel_id.py index 5c9ce25cf..292d5a6ff 100644 --- a/tests/activity/test_channel_id.py +++ b/tests/activity/test_channel_id.py @@ -24,6 +24,11 @@ def test_init_multiple_colons(self): assert ChannelId("email:support:extra").channel == "email" assert ChannelId("email:support:extra").sub_channel == "support:extra" + def test_init_from_channel_id_reuses_instance(self): + channel_id = ChannelId("email:support") + + assert ChannelId(channel_id) is channel_id + def test_init_multiple_args(self): with pytest.raises(ValueError): ChannelId("email:support", channel="a", sub_channel="b") From ddddcf3601b49d53dc4ba093c7f0b077a2a4d9cd Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 21 Jul 2026 21:42:03 -0700 Subject: [PATCH 2/9] Fixing tests --- tests/activity/pydantic/test_activity_io.py | 41 --------------------- 1 file changed, 41 deletions(-) diff --git a/tests/activity/pydantic/test_activity_io.py b/tests/activity/pydantic/test_activity_io.py index f13f0ee14..dfcb9fcc5 100644 --- a/tests/activity/pydantic/test_activity_io.py +++ b/tests/activity/pydantic/test_activity_io.py @@ -19,38 +19,6 @@ def test_serialize_basic(self): ) assert activity_copy == activity - @pytest.mark.parametrize( - "data, expected", - [ - ( - "msteams:subchannel", - ChannelId(channel="msteams", sub_channel="subchannel"), - ), - ("msteams/subchannel", ChannelId(channel="msteams/subchannel")), - ("channel:sub", ChannelId(channel="channel", sub_channel="sub")), - ( - ChannelId(channel="msteams", sub_channel="subchannel"), - ChannelId(channel="msteams", sub_channel="subchannel"), - ), - (ChannelId(channel="msteams"), ChannelId(channel="msteams")), - ], - ) - def test_channel_id_setter_validation(self, data, expected): - activity = Activity(type="message") - activity.channel_id = data - - assert activity.channel_id == expected - assert isinstance(activity.channel_id, ChannelId) - if not isinstance(data, dict): - assert activity.channel_id == data - - def test_channel_id_setter_validation_error(self): - activity = Activity(type="message") - with pytest.raises(Exception): - activity.channel_id = {} - with pytest.raises(Exception): - activity.channel_id = 123 - def test_channel_id_validate_without_product_info(self): data = {"type": "message", "channel_id": "msteams:subchannel"} activity = Activity(**data) @@ -124,15 +92,6 @@ def test_channel_id_sub_channel_conflict_on_validation(self): entities=[Entity(type="some_type"), ProductInfo(id="sub_channel")], ) - def test_channel_id_unset_becomes_set_at_init(self): - activity = Activity(type="message") - activity.channel_id = "channel:sub_channel" - data = activity.model_dump(mode="json", exclude_unset=True, by_alias=True) - assert data["channelId"] == "channel" - assert data["entities"] == [ - {"type": EntityTypes.PRODUCT_INFO.value, "id": "sub_channel"} - ] - def test_channel_id_unset_at_init_not_included(self): activity = Activity(type="message") data = activity.model_dump(mode="json", exclude_unset=True, by_alias=True) From f5353930fbca0ff51d6be2bf475a29e0a51fbd28 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 21 Jul 2026 21:42:27 -0700 Subject: [PATCH 3/9] Another commit --- .../microsoft_agents/activity/channel_id.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py index fee772186..d5ebb9e77 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py @@ -119,4 +119,4 @@ def get_channel(channel_id: str | ChannelId | None) -> str | None: if not channel_id or not channel_id.strip(): return channel_id parsed = channel_id.split(":", 1)[0].strip() if ":" in channel_id else None - return parsed or channel_id \ No newline at end of file + return parsed or channel_id From 1882363ca36e87fdea50f6647f1a55a05a782814 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Jul 2026 09:24:04 -0700 Subject: [PATCH 4/9] Removing old tests --- .../connector/test_user_token_client.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/hosting_core/connector/test_user_token_client.py b/tests/hosting_core/connector/test_user_token_client.py index d030e03d3..8150ad5a0 100644 --- a/tests/hosting_core/connector/test_user_token_client.py +++ b/tests/hosting_core/connector/test_user_token_client.py @@ -92,17 +92,3 @@ async def handler(request): await server.close() assert captured == [None, None, None, None] - - @pytest.mark.parametrize( - ("channel_id", "expected"), - [ - ("msteams", "msteams"), - ("msteams:COPILOT", "msteams"), - ("msteams:", "msteams"), - (":COPILOT", ":COPILOT"), - (" ", " "), - (None, None), - ], - ) - def test_base_channel_id(self, channel_id, expected): - assert UserToken._base_channel_id(channel_id) == expected From 764d58d506cf15908f30e972f2f647daa0918b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Wed, 22 Jul 2026 09:46:30 -0700 Subject: [PATCH 5/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/activity/test_channel_id.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/activity/test_channel_id.py b/tests/activity/test_channel_id.py index 292d5a6ff..5a269c9a9 100644 --- a/tests/activity/test_channel_id.py +++ b/tests/activity/test_channel_id.py @@ -29,6 +29,17 @@ def test_init_from_channel_id_reuses_instance(self): assert ChannelId(channel_id) is channel_id + def test_get_channel_strips_and_drops_sub_channel(self): + assert ChannelId.get_channel(" msteams ") == "msteams" + assert ChannelId.get_channel("msteams:sub") == "msteams" + assert ChannelId.get_channel("msteams:") == "msteams" + assert ChannelId.get_channel(None) is None + + def test_get_sub_channel_empty_is_none(self): + assert ChannelId.get_sub_channel("msteams:sub") == "sub" + assert ChannelId.get_sub_channel("msteams:") is None + assert ChannelId.get_sub_channel(" msteams: sub ") == "sub" + assert ChannelId.get_sub_channel(None) is None def test_init_multiple_args(self): with pytest.raises(ValueError): ChannelId("email:support", channel="a", sub_channel="b") From 0a6ad1a272e534bcce37de5029d7ac96bef6bc3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Wed, 22 Jul 2026 09:46:49 -0700 Subject: [PATCH 6/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/activity/channel_id.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py index d5ebb9e77..b57615913 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py @@ -111,7 +111,9 @@ def get_sub_channel(channel_id: str | ChannelId | None) -> str | None: return None if isinstance(channel_id, ChannelId): return channel_id.sub_channel - return channel_id.split(":", 1)[1].strip() if ":" in channel_id else None + value = channel_id.strip() + sub = value.split(":", 1)[1].strip() if ":" in value else None + return sub or None @staticmethod def get_channel(channel_id: str | ChannelId | None) -> str | None: From 73610274929d3b27df0e9fc38c46a670520fd1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Wed, 22 Jul 2026 09:47:16 -0700 Subject: [PATCH 7/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/activity/channel_id.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py index b57615913..32af6dccd 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py @@ -72,7 +72,7 @@ def _normalize( split = value.split(":", 1) channel = split[0].strip() - sub_channel = split[1].strip() if len(split) == 2 else None + sub_channel = (split[1].strip() or None) if len(split) == 2 else None return value, channel, sub_channel if not isinstance(channel, str) or len(channel.strip()) == 0 or ":" in channel: From 9acc5f537c6c826d483199db82c027bed2183b81 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Jul 2026 09:50:01 -0700 Subject: [PATCH 8/9] Fixing get_channel edge case --- .../microsoft_agents/activity/channel_id.py | 3 +++ tests/activity/test_channel_id.py | 1 + 2 files changed, 4 insertions(+) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py index 32af6dccd..c9e1f239c 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py @@ -120,5 +120,8 @@ def get_channel(channel_id: str | ChannelId | None) -> str | None: """Return the Bot Framework channel without an optional sub-channel.""" if not channel_id or not channel_id.strip(): return channel_id + if isinstance(channel_id, ChannelId): + return channel_id.channel + channel_id = channel_id.strip() parsed = channel_id.split(":", 1)[0].strip() if ":" in channel_id else None return parsed or channel_id diff --git a/tests/activity/test_channel_id.py b/tests/activity/test_channel_id.py index 5a269c9a9..78adb52a9 100644 --- a/tests/activity/test_channel_id.py +++ b/tests/activity/test_channel_id.py @@ -40,6 +40,7 @@ def test_get_sub_channel_empty_is_none(self): assert ChannelId.get_sub_channel("msteams:") is None assert ChannelId.get_sub_channel(" msteams: sub ") == "sub" assert ChannelId.get_sub_channel(None) is None + def test_init_multiple_args(self): with pytest.raises(ValueError): ChannelId("email:support", channel="a", sub_channel="b") From db0a51cd2512823b9947cbc13c0c53285388c8b1 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Jul 2026 09:58:04 -0700 Subject: [PATCH 9/9] Preventing empty base channel --- .../microsoft_agents/activity/channel_id.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py index c9e1f239c..77b246bff 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py @@ -72,6 +72,8 @@ def _normalize( split = value.split(":", 1) channel = split[0].strip() + if not channel: + raise ValueError(str(activity_errors.ChannelIdValueMustBeNonEmpty)) sub_channel = (split[1].strip() or None) if len(split) == 2 else None return value, channel, sub_channel