diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 1f3e86439..187ec74d3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -1264,22 +1264,20 @@ def add_ai_metadata( Adds AI entity to an activity to indicate AI-generated content. Args: - activity: The activity to modify citations: Optional list of citations usage_info: Optional sensitivity usage information """ - if citations: - ai_entity = AIEntity( - id="", - additional_type=["AIGeneratedContent"], - citation=citations, - usage_info=usage_info, - ) + ai_entity = AIEntity( + id="", + additional_type=["AIGeneratedContent"], + citation=citations, + usage_info=usage_info, + ) - if self.entities is None: - self.entities = [] + if self.entities is None: + self.entities = [] - self.entities.append(ai_entity) + self.entities.append(ai_entity) def is_agentic_request(self) -> bool: return self.recipient and self.recipient.role in [ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index b39beea19..d2bdef9b0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -6,6 +6,7 @@ import uuid import asyncio import logging +from collections.abc import Mapping from typing import Optional, Callable, Literal, cast, TYPE_CHECKING from microsoft_agents.activity import ( @@ -499,6 +500,12 @@ def _create_final_message(self, *, include_stream_info: bool) -> Activity: ) if self._should_update_final_activity() and self._stream_id: activity.id = self._stream_id + + if self._enable_feedback_loop and self._is_teams_channel(): + self._add_feedback_loop_to_channel_data(activity) + + self._add_generated_by_ai_metadata(activity) + return activity def _create_stream_stopped_message(self, message: str) -> Activity: @@ -514,6 +521,7 @@ def _create_stream_stopped_message(self, message: str) -> Activity: ) ], ) + self._add_generated_by_ai_metadata(activity) return activity def _create_stream_timed_out_message( @@ -534,12 +542,15 @@ def _create_stream_timed_out_message( stream_info.stream_id = self._stream_id entities.append(stream_info) - return Activity( + activity = Activity( id=self._stream_id, type="message", text=text, entities=entities, ) + if add_stream_final: + self._add_generated_by_ai_metadata(activity) + return activity def _create_stream_timed_out_streaming_update(self) -> Activity: text = ( @@ -615,23 +626,6 @@ async def _send_activity( ) ) - # Add in Powered by AI feature flags - if self._ended: - if self._enable_feedback_loop and self._feedback_loop_type: - # Add feedback loop to streaminfo entity - if streaminfo_entity: - streaminfo_entity.feedback_loop = {"type": self._feedback_loop_type} - else: - # Add feedback loop enabled to streaminfo entity - if streaminfo_entity: - streaminfo_entity.feedback_loop_enabled = self._enable_feedback_loop - # Add in Generated by AI - if self._enable_generated_by_ai_label: - curr_citations = CitationUtil.get_used_citations( - self._message, self._citations - ) - activity.add_ai_metadata(curr_citations, self._sensitivity_label) - try: response = await self._context.send_activity(activity) @@ -840,6 +834,38 @@ def _is_teams_channel(self) -> bool: channel_id = self._context.activity.channel_id return bool(channel_id) and channel_id.channel == Channels.ms_teams + def _add_feedback_loop_to_channel_data(self, activity: Activity) -> None: + channel_data = activity.channel_data + if channel_data is None: + serialized_channel_data = {} + elif isinstance(channel_data, Mapping): + serialized_channel_data = dict(channel_data) + elif callable(model_dump := getattr(channel_data, "model_dump", None)): + serialized_channel_data = model_dump( + mode="json", + by_alias=True, + exclude_none=True, + exclude_unset=True, + ) + else: + logger.warning( + "Unable to add feedback loop to unsupported channel data type: %s", + type(channel_data).__name__, + ) + return + + serialized_channel_data["feedbackLoop"] = { + "type": self._feedback_loop_type or "default" + } + activity.channel_data = serialized_channel_data + + def _add_generated_by_ai_metadata(self, activity: Activity) -> None: + if not self._enable_generated_by_ai_label: + return + + curr_citations = CitationUtil.get_used_citations(self._message, self._citations) + activity.add_ai_metadata(curr_citations, self._sensitivity_label) + def _should_update_final_activity(self) -> bool: if self._is_m365_copilot(): return False diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index c1d37e0f3..4f50f9bcb 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -456,6 +456,16 @@ def test_get_mentions(self): Mention(text="Another mention"), ] + def test_add_ai_metadata_adds_ai_entity_without_citations(self): + activity = Activity(type=ActivityTypes.message) + + activity.add_ai_metadata() + + assert len(activity.entities) == 1 + assert isinstance(activity.entities[0], AIEntity) + assert activity.entities[0].additional_type == ["AIGeneratedContent"] + assert activity.entities[0].citation is None + @pytest.mark.parametrize( "entities, expected", [ diff --git a/tests/hosting_core/app/streaming/test_streaming_response.py b/tests/hosting_core/app/streaming/test_streaming_response.py index 732331bb0..647b4016e 100644 --- a/tests/hosting_core/app/streaming/test_streaming_response.py +++ b/tests/hosting_core/app/streaming/test_streaming_response.py @@ -452,6 +452,31 @@ async def test_timeout_notification_preserves_streamed_text(mocker): assert stopped_activity.entities[0].stream_type == "final" +@pytest.mark.asyncio +async def test_timeout_notification_adds_generated_by_ai_metadata(mocker): + context = _create_turn_context( + mocker, + channel_id=Channels.ms_teams, + return_value=ResourceResponse(id="stream-stop"), + ) + response = StreamingResponse(context) + response._interval = 0 + response.set_generated_by_ai_label(True) + + response.queue_text_chunk("Partial response") + await response.wait_for_queue() + assert await response.send_stream_timed_out_notification("Still working.") + + stopped_activity = context.send_activity.await_args_list[-1].args[0] + ai_entities = [ + entity + for entity in stopped_activity.entities + if "AIGeneratedContent" in (getattr(entity, "additional_type", None) or []) + ] + assert len(ai_entities) == 1 + assert ai_entities[0].citation is None + + @pytest.mark.asyncio async def test_timeout_notification_sends_m365_final_as_new_activity(mocker): context = _create_turn_context( @@ -726,9 +751,10 @@ async def send_activity(_activity): @pytest.mark.asyncio -async def test_feedback_loop_type_added_to_final_streaminfo_entity(mocker): +async def test_feedback_loop_type_added_to_final_teams_channel_data(mocker): context = _create_turn_context( mocker, + channel_id=Channels.ms_teams, delivery_mode=DeliveryModes.stream, return_value=[ResourceResponse(id="stream-4")], ) @@ -744,7 +770,56 @@ async def test_feedback_loop_type_added_to_final_streaminfo_entity(mocker): entity for entity in final_activity.entities if entity.type == "streaminfo" ) - assert stream_info.feedback_loop == {"type": "custom"} + assert stream_info.feedback_loop_enabled is False + assert stream_info.feedback_loop is None + + payload = final_activity.model_dump(by_alias=True, exclude_unset=True, mode="json") + stream_info_payload = next( + entity for entity in payload["entities"] if entity["type"] == "streaminfo" + ) + assert "feedbackLoopEnabled" not in stream_info_payload + assert "feedbackLoop" not in stream_info_payload + assert payload["channelData"] == {"feedbackLoop": {"type": "custom"}} + + +def test_feedback_loop_defaults_to_default_type_and_preserves_channel_data(mocker): + context = _create_turn_context( + mocker, + channel_id=Channels.ms_teams, + ) + response = StreamingResponse(context) + response.set_feedback_loop(True) + activity = Activity( + type="message", + text="feedback", + entities=[], + channel_data={"tenant": {"id": "tenant-id"}}, + ) + + response._add_feedback_loop_to_channel_data(activity) + + assert activity.channel_data == { + "tenant": {"id": "tenant-id"}, + "feedbackLoop": {"type": "default"}, + } + + +@pytest.mark.asyncio +async def test_feedback_loop_is_not_added_to_non_teams_final_message(mocker): + context = _create_turn_context( + mocker, + channel_id=Channels.webchat, + return_value=ResourceResponse(id="webchat-feedback"), + ) + response = StreamingResponse(context) + response.set_feedback_loop(True) + response.set_feedback_loop_type("custom") + + response.queue_text_chunk("feedback") + await response.end_stream() + + final_activity = context.send_activity.await_args_list[-1].args[0] + assert final_activity.channel_data is None @pytest.mark.asyncio @@ -771,6 +846,42 @@ async def test_generated_by_ai_label_adds_ai_entity_on_final_message(mocker): assert len(ai_entities) == 1 +@pytest.mark.asyncio +async def test_generated_by_ai_label_without_citations_is_only_on_final_message( + mocker, +): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + return_value=[ + ResourceResponse(id="stream-6"), + ResourceResponse(id="stream-6"), + ], + ) + response = StreamingResponse(context) + response.set_generated_by_ai_label(True) + + response.queue_text_chunk("Generated response") + await response.wait_for_queue() + + streaming_activity = context.send_activity.await_args_list[0].args[0] + assert not any( + "AIGeneratedContent" in (getattr(entity, "additional_type", None) or []) + for entity in streaming_activity.entities + ) + + await response.end_stream() + + final_activity = context.send_activity.await_args_list[-1].args[0] + ai_entities = [ + entity + for entity in final_activity.entities + if "AIGeneratedContent" in (getattr(entity, "additional_type", None) or []) + ] + assert len(ai_entities) == 1 + assert ai_entities[0].citation is None + + @pytest.mark.asyncio async def test_streaming_operations_with_sleeps_send_informative_and_text_updates( mocker, @@ -1020,6 +1131,7 @@ async def test_feedback_loop_type_without_enable_does_not_emit_feedback_loop_obj assert not streaminfo.feedback_loop assert not streaminfo.feedback_loop_enabled + assert sent.channel_data is None @pytest.mark.asyncio