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 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..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,31 +12,72 @@ ) -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): - 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"]) + 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 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 +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 @@ -46,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/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/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 99ea7ac6b..79df61fca 100644 --- a/tests/activity/entity/test_serialization.py +++ b/tests/activity/entity/test_serialization.py @@ -1,5 +1,7 @@ import pytest +from pydantic import ValidationError +from microsoft_agents.activity import Activity from microsoft_agents.activity.entity import ( AIEntity, ClientCitation, @@ -61,6 +63,97 @@ 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"] + + +@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( + { + "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: