From 2b31cce8e73214f72ee64986d88c9cc5c8844304 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 28 Sep 2026 11:55:07 -0700 Subject: [PATCH 1/4] Improved deserialization of AI entities --- .../activity/entity/_schema_mixin.py | 57 ++++++++++--- .../activity/entity/ai_entity.py | 1 + tests/activity/entity/test_serialization.py | 80 +++++++++++++++++++ 3 files changed, 126 insertions(+), 12 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py index d96dc2fc6..2d8bc94d7 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py @@ -12,31 +12,64 @@ ) +def _deserialize_at_field( + data: dict[str, Any], model: BaseModel, model_field_name: str, input_field_name: str +) -> None: + """Deserialize a single @-prefixed field from the input data into the model if it exists. + + :param data: The input data dictionary containing potential @-prefixed fields. + :param model: The Pydantic model instance to populate with deserialized data. + :param model_field_name: The name of the field in the model to populate. + :param input_field_name: The name of the field in the input data to read from. + """ + + def validate_schema_model(data: Any, handler: ModelWrapValidatorHandler): """Custom validator to handle the aliases @type, @context, and @id if defined in the destination type.""" model = handler(data) if isinstance(data, dict): - if "@type" in data: - setattr(model, "at_type", data["@type"]) - if "@context" in data: - setattr(model, "at_context", data["@context"]) - if "@id" in data: - setattr(model, "at_id", data["@id"]) + allows_extra = model.model_config.get("extra") == "allow" + for model_field, input_field in [ + ("at_type", "@type"), + ("at_context", "@context"), + ("at_id", "@id"), + ]: + if input_field in data and (hasattr(model, model_field) or allows_extra): + setattr(model, model_field, data[input_field]) return model +def _serialize_at_field( + model: BaseModel, + serialized: dict[str, Any], + model_field_name: str, + output_field_name: str, +) -> None: + """Serialize a non-None schema field using its @-prefixed name.""" + field = type(model).model_fields.get(model_field_name) + if field is None: + return + + alias = field.serialization_alias or field.alias or model_field_name + serialized.pop(alias, None) + + value = getattr(model, model_field_name) + if value is not None: + serialized[output_field_name] = value + + def serialize_schema_model( self, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> dict[str, Any]: """Custom serializer to convert keys to force inclusion of @type, @context, and @id if defined.""" serialized = handler(self) if info.by_alias: - if hasattr(self, "at_type"): - serialized["@type"] = getattr(self, "at_type") - if hasattr(self, "at_context"): - serialized["@context"] = getattr(self, "at_context") - if hasattr(self, "at_id"): - serialized["@id"] = getattr(self, "at_id") + for model_field, out_field in [ + ("at_type", "@type"), + ("at_context", "@context"), + ("at_id", "@id"), + ]: + _serialize_at_field(self, serialized, model_field, out_field) return serialized diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 846f249e4..2cc612ef4 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -96,6 +96,7 @@ class ClientCitation(AgentsModel, _SchemaMixin): """ at_type: Literal["Claim"] = "Claim" + at_id: str | None = None position: int = 0 appearance: ClientCitationAppearance = Field( diff --git a/tests/activity/entity/test_serialization.py b/tests/activity/entity/test_serialization.py index 99ea7ac6b..c64848844 100644 --- a/tests/activity/entity/test_serialization.py +++ b/tests/activity/entity/test_serialization.py @@ -1,5 +1,6 @@ import pytest +from microsoft_agents.activity import Activity from microsoft_agents.activity.entity import ( AIEntity, ClientCitation, @@ -61,6 +62,85 @@ def test_schema_mixin_at_context_serialization(): assert "at_context" not in data +@pytest.mark.parametrize( + "entity_cls", + [ + SensitivityUsageInfo, + ClientCitationAppearance, + SensitivityPattern, + ], +) +def test_schema_mixin_ignores_at_id_when_model_does_not_define_it(entity_cls): + entity = entity_cls.model_validate({"@id": "schema-id"}) + + assert not hasattr(entity, "at_id") + assert "@id" not in entity.model_dump(exclude_unset=True, by_alias=True) + + +def test_client_citation_without_at_id_omits_schema_id(): + data = ClientCitation().model_dump(exclude_unset=True, by_alias=True) + + assert "@id" not in data + assert "atId" not in data + + +def test_client_citation_deserializes_and_serializes_schema_id(): + citation = ClientCitation.model_validate( + { + "@type": "Claim", + "@id": "turn16search0", + "position": 1, + "appearance": { + "@type": "DigitalDocument", + "name": "Example citation", + }, + } + ) + + assert citation.at_id == "turn16search0" + + data = citation.model_dump(exclude_unset=True, by_alias=True) + + assert data["@type"] == "Claim" + assert data["@id"] == "turn16search0" + assert "atType" not in data + assert "atId" not in data + assert data["appearance"]["@type"] == "DigitalDocument" + assert "atType" not in data["appearance"] + + +def test_activity_deserializes_client_citation_schema_id(): + activity = Activity.model_validate( + { + "type": "message", + "entities": [ + { + "type": "https://schema.org/Message", + "@type": "Message", + "@context": "https://schema.org", + "citation": [ + { + "@type": "Claim", + "@id": "turn16search0", + "position": 1, + "appearance": { + "@type": "DigitalDocument", + "name": "Example citation", + }, + } + ], + } + ], + } + ) + + entity = activity.entities[0] + + assert isinstance(entity, AIEntity) + assert entity.citation is not None + assert entity.citation[0].at_id == "turn16search0" + + def test_client_citation_icon_name_matches_teams_docs(): """The icon names must match the predefined values documented for citation.appearance.image.name in the Teams "Add citations" article: From a420eab9b09d7ae6f68fb484f1bed418f652fdae Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 28 Sep 2026 12:17:47 -0700 Subject: [PATCH 2/4] Removing empty helper --- .../activity/entity/_schema_mixin.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py index 2d8bc94d7..57b9c2a5e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py @@ -12,18 +12,6 @@ ) -def _deserialize_at_field( - data: dict[str, Any], model: BaseModel, model_field_name: str, input_field_name: str -) -> None: - """Deserialize a single @-prefixed field from the input data into the model if it exists. - - :param data: The input data dictionary containing potential @-prefixed fields. - :param model: The Pydantic model instance to populate with deserialized data. - :param model_field_name: The name of the field in the model to populate. - :param input_field_name: The name of the field in the input data to read from. - """ - - def validate_schema_model(data: Any, handler: ModelWrapValidatorHandler): """Custom validator to handle the aliases @type, @context, and @id if defined in the destination type.""" model = handler(data) From 9afe3d004a21870030af3574ef11046693419a3d Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 28 Sep 2026 12:24:06 -0700 Subject: [PATCH 3/4] More robust schema validation --- .../activity/entity/_schema_mixin.py | 32 +++++++++++++++---- .../activity/entity/entity.py | 4 +-- tests/activity/entity/test_serialization.py | 13 ++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py index 57b9c2a5e..58ea5b1cc 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py @@ -12,18 +12,38 @@ ) -def validate_schema_model(data: Any, handler: ModelWrapValidatorHandler): +def validate_schema_model( + data: Any, + handler: ModelWrapValidatorHandler, + model_type: type[BaseModel], +): """Custom validator to handle the aliases @type, @context, and @id if defined in the destination type.""" - model = handler(data) if isinstance(data, dict): - allows_extra = model.model_config.get("extra") == "allow" + normalized_data = dict(data) + extra_schema_fields = {} + allows_extra = model_type.model_config.get("extra") == "allow" + for model_field, input_field in [ ("at_type", "@type"), ("at_context", "@context"), ("at_id", "@id"), ]: - if input_field in data and (hasattr(model, model_field) or allows_extra): - setattr(model, model_field, data[input_field]) + if input_field not in data: + continue + + field = model_type.model_fields.get(model_field) + if field is not None: + normalized_data.pop(input_field, None) + normalized_data[model_field] = data[input_field] + elif allows_extra: + extra_schema_fields[model_field] = data[input_field] + + model = handler(normalized_data) + for model_field, value in extra_schema_fields.items(): + setattr(model, model_field, value) + return model + + model = handler(data) return model @@ -67,7 +87,7 @@ class _SchemaMixin(BaseModel): @model_validator(mode="wrap") @classmethod def _validate_model(cls, data: Any, handler: ModelWrapValidatorHandler): - return validate_schema_model(data, handler) + return validate_schema_model(data, handler, cls) @model_serializer(mode="wrap") def _serialize_model( diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index dff08db1a..f6072211f 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -51,8 +51,8 @@ def _validate_model(cls, data: Any, handler: ModelWrapValidatorHandler): if isinstance(data, dict): new_data = {to_snake(k): v for k, v in data.items()} - return validate_schema_model(new_data, handler) - return validate_schema_model(data, handler) + return validate_schema_model(new_data, handler, cls) + return validate_schema_model(data, handler, cls) @model_serializer(mode="wrap") def _serialize_model( diff --git a/tests/activity/entity/test_serialization.py b/tests/activity/entity/test_serialization.py index c64848844..79df61fca 100644 --- a/tests/activity/entity/test_serialization.py +++ b/tests/activity/entity/test_serialization.py @@ -1,4 +1,5 @@ import pytest +from pydantic import ValidationError from microsoft_agents.activity import Activity from microsoft_agents.activity.entity import ( @@ -109,6 +110,18 @@ def test_client_citation_deserializes_and_serializes_schema_id(): assert "atType" not in data["appearance"] +@pytest.mark.parametrize( + "data", + [ + {"@id": []}, + {"@type": "NotClaim"}, + ], +) +def test_client_citation_validates_schema_fields(data): + with pytest.raises(ValidationError): + ClientCitation.model_validate(data) + + def test_activity_deserializes_client_citation_schema_id(): activity = Activity.model_validate( { From a994ad8b3c6d0040b68529a121bafc6509304810 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 28 Sep 2026 12:39:22 -0700 Subject: [PATCH 4/4] Updating changelog --- changelog.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/changelog.md b/changelog.md index 13ce7c86e..71765f086 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,15 @@ +# Microsoft 365 Agents SDK for Python - Release Notes v1.8.0 (Unreleased) + +**Release Date:** Unreleased +**Previous Version:** 1.7.0 (Released 2026-09-17) + +## Bug Fixes + +- **Client Citation Deserialization**: Fixed deserialization of citations containing `@id` values + +--- + + # Microsoft 365 Agents SDK for Python - Release Notes v1.7.0 **Release Date:** 2026-09-17