From b1dd055f8e7332405b58cc7fcc9717993ed275b3 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 30 Sep 2026 11:30:08 -0700 Subject: [PATCH 1/9] Small send_targeted_activity refactor --- .../microsoft_agents/activity/activity.py | 58 ++++++++++++---- .../hosting/msteams/teams_turn_context.py | 67 +++++++++++++------ 2 files changed, 93 insertions(+), 32 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 74b1cf167..90857e05a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -199,7 +199,7 @@ class Activity(AgentsModel): text_highlights: list[TextHighlight] = None semantic_action: SemanticAction = None caller_id: NonEmptyString = None - request_id: str | None = Field(None, exclude=True) + request_id: Annotated[str | None, Field(exclude=True)] = None @field_validator("entities", mode="before") @classmethod @@ -554,6 +554,19 @@ def with_suggested_actions(self, suggested_actions: SuggestedActions) -> Self: self.suggested_actions = suggested_actions return self + def with_recipient(self, recipient: str | ChannelAccount) -> Self: + """ + Sets the recipient of the activity. + + :param recipient: The recipient of the activity. Can be an id or a ChannelAccount instance. + :returns: This activity, to allow for method chaining. + """ + if isinstance(recipient, str): + self.recipient = ChannelAccount(id=recipient, role=RoleTypes.user) + else: + self.recipient = recipient + return self + def add_text(self, text: str) -> Self: """ Appends text to the existing text content of the activity. @@ -1079,15 +1092,37 @@ def _convert_entity_list( entities.append(Activity._convert_entity(e, entity_cls)) return entities - def get_product_info_entity(self) -> Optional[ProductInfo]: + def _get_entity_by_type(self, entity_type: str) -> Entity | None: + """ + Internal method to get the first entity of a specific type from the activity's entities. + + :param entity_type: The type of the entity to retrieve. This will be converted to lowercase for comparison. + :return: The first entity of the specified type, or None if not found. + """ if not self.entities: return None - target = EntityTypes.PRODUCT_INFO.lower() - # validated entities can be Entity, and that prevents us from - # making assumptions about the casing of the 'type' attribute - raw_product_info = next( - filter(lambda e: e.type.lower() == target, self.entities), None - ) + target = entity_type.lower() + return next((e for e in self.entities if e.type.lower() == target), None) + + def _get_entities_by_type(self, entity_type: str) -> list[Entity]: + """ + Internal method to get all entities of a specific type from the activity's entities. + + :param entity_type: The type of the entities to retrieve. This will be converted to lowercase for comparison. + :return: A list of entities of the specified type. Returns an empty list if none are found. + """ + if not self.entities: + return [] + target = entity_type.lower() + return [e for e in self.entities if e.type.lower() == target] + + def get_product_info_entity(self) -> Optional[ProductInfo]: + """ + Get the product info entity from the activity's entities. + + :return: The product info entity, or None if not found. + """ + raw_product_info = self._get_entity_by_type(EntityTypes.PRODUCT_INFO) if raw_product_info is None: return None return Activity._convert_entity(raw_product_info, ProductInfo) @@ -1102,12 +1137,9 @@ def get_mentions(self) -> list[Mention]: This method is defined on the :class:`microsoft_agents.activity.Activity` class, but is only intended for use with a message activity, where the activity Activity.Type is set to ActivityTypes.Message. """ - if not self.entities: + raw_mentions = self._get_entities_by_type(EntityTypes.MENTION.value) + if not raw_mentions: return [] - raw_mentions = [ - x for x in self.entities if x.type.lower() == EntityTypes.MENTION.value - ] - return Activity._convert_entity_list(raw_mentions, Mention) def get_reply_conversation_reference( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 81509842e..cf0a0b367 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -7,6 +7,7 @@ from typing import cast +from microsoft_agents.activity.entity.entity import Entity from msgraph import GraphServiceClient from microsoft_teams.api import ApiClient @@ -15,7 +16,11 @@ Activity, ActivityTreatment, ActivityTreatmentTypes, + ActivityTypes, + ChannelAccount, + EntityTypes, ResourceResponse, + RoleTypes, ) from microsoft_agents.hosting.core import ( AgentApplication, @@ -99,41 +104,65 @@ def api_client(self) -> ApiClient: return _get_teams_api_client(self) @staticmethod - def _make_targeted_activity(activity: Activity) -> None: + def _make_targeted_activity( + activity: Activity, recipient: str | ChannelAccount + ) -> None: """ Make an activity targeted. :param activity: The activity to make targeted. + :param recipient: The recipient to target the activity to. Can be a string or a ChannelAccount instance. :return: None """ + + if isinstance(recipient, str): + recipient = ChannelAccount(id=recipient, role=RoleTypes.user) + activity.entities = activity.entities or [] - activity.entities.append( - ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED) - ) - async def send_targeted_activity(self, activity: Activity) -> ResourceResponse: + found_targeted_entity: bool = False + + if activity.entities is not None: + # try to remove all targeted entities but keep the first one + def _keep(entity: Entity) -> bool: + nonlocal found_targeted_entity + entity_type = entity.type.lower() + if entity_type == EntityTypes.ACTIVITY_TREATMENT.value.lower(): + treatment = getattr(entity, "treatment", None) + if treatment == ActivityTreatmentTypes.TARGETED: + if found_targeted_entity: + return False + found_targeted_entity = True + return True + + # https://stackoverflow.com/questions/1207406/how-to-remove-items-from-a-list-while-iterating + # entity lists probably won't get too big, but heck + activity.entities[:] = [ + entity for entity in activity.entities if _keep(entity) + ] + + if not found_targeted_entity: + activity.entities.append( + ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED) + ) + + async def send_targeted_activity( + self, + activity: str | Activity, + recipient: str | ChannelAccount, + ) -> ResourceResponse: """ Send a targeted activity. :param activity: The activity to send. + :param recipient: The recipient to target the activity to. Can be a string or a ChannelAccount instance. :return: The resource response. """ - TeamsTurnContext._make_targeted_activity(activity) + if isinstance(activity, str): + activity = Activity(type=ActivityTypes.message, text=activity) + TeamsTurnContext._make_targeted_activity(activity, recipient) return await self.send_activity(activity) - async def send_targeted_activities( - self, activities: list[Activity] - ) -> list[ResourceResponse]: - """ - Send a list of targeted activities. - - :param activities: The list of activities to send. - :return: A list of resource responses. - """ - for activity in activities: - TeamsTurnContext._make_targeted_activity(activity) - return await self.send_activities(activities) - def get_graph_client( self, handler_name: str | None = None, From a5317bbe57aca6c52682a000a459a69c8b52a175 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 30 Sep 2026 11:40:15 -0700 Subject: [PATCH 2/9] Setting the recipient --- .../hosting/msteams/teams_turn_context.py | 1 + .../test_teams_turn_context.py | 105 +++++++++++++----- 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index cf0a0b367..202a35486 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -118,6 +118,7 @@ def _make_targeted_activity( if isinstance(recipient, str): recipient = ChannelAccount(id=recipient, role=RoleTypes.user) + activity.recipient = recipient activity.entities = activity.entities or [] found_targeted_entity: bool = False diff --git a/tests/hosting_msteams/test_teams_turn_context.py b/tests/hosting_msteams/test_teams_turn_context.py index 73df57206..1a1dc2298 100644 --- a/tests/hosting_msteams/test_teams_turn_context.py +++ b/tests/hosting_msteams/test_teams_turn_context.py @@ -3,6 +3,8 @@ """Tests for TeamsTurnContext helpers that can be exercised without a live adapter.""" +from unittest.mock import AsyncMock + import pytest from .helpers import is_supported_version @@ -15,40 +17,85 @@ if is_supported_version: from microsoft_agents.activity import ( Activity, + ActivityTreatment, ActivityTreatmentTypes, + ActivityTypes, + ChannelAccount, Entity, + ResourceResponse, + RoleTypes, ) from microsoft_agents.hosting.msteams import TeamsTurnContext -class TestMakeTargetedActivity: - """``_make_targeted_activity`` mutates the supplied activity in place (returns - None) by appending a TARGETED activity-treatment entity.""" - - def test_appends_targeted_treatment_when_no_entities(self): - activity = Activity(type="message") - result = TeamsTurnContext._make_targeted_activity(activity) - assert result is None - assert len(activity.entities) == 1 - assert activity.entities[0].treatment == ActivityTreatmentTypes.TARGETED - - def test_preserves_existing_entities(self): - activity = Activity(type="message", entities=[Entity(type="mention")]) - TeamsTurnContext._make_targeted_activity(activity) - assert len(activity.entities) == 2 - assert activity.entities[0].type == "mention" - assert activity.entities[1].treatment == ActivityTreatmentTypes.TARGETED - - def test_each_call_appends_another_treatment(self): - # The helper is not idempotent: repeated calls accumulate treatments - # (see BUGS.md #4). This pins the current behaviour. - activity = Activity(type="message") - TeamsTurnContext._make_targeted_activity(activity) - TeamsTurnContext._make_targeted_activity(activity) - treatments = [ - e - for e in activity.entities - if getattr(e, "treatment", None) == ActivityTreatmentTypes.TARGETED +class TestSendTargetedActivity: + @staticmethod + def _make_context(response: ResourceResponse) -> TeamsTurnContext: + context = object.__new__(TeamsTurnContext) + context.send_activity = AsyncMock(return_value=response) + return context + + @staticmethod + def _targeted_treatments(activity: Activity) -> list[Entity]: + return [ + entity + for entity in activity.entities or [] + if getattr(entity, "treatment", None) + == ActivityTreatmentTypes.TARGETED ] - assert len(treatments) == 2 + + @pytest.mark.asyncio + async def test_sends_string_as_targeted_message_to_recipient_id(self): + response = ResourceResponse(id="activity-id") + context = self._make_context(response) + + result = await context.send_targeted_activity("hello", "user-id") + + assert result is response + context.send_activity.assert_awaited_once() + activity = context.send_activity.await_args.args[0] + assert activity.type == ActivityTypes.message + assert activity.text == "hello" + assert activity.recipient == ChannelAccount( + id="user-id", role=RoleTypes.user + ) + assert len(self._targeted_treatments(activity)) == 1 + + @pytest.mark.asyncio + async def test_sends_activity_to_channel_account_without_losing_entities(self): + response = ResourceResponse(id="activity-id") + context = self._make_context(response) + recipient = ChannelAccount(id="user-id", name="User") + mention = Entity(type="mention") + activity = Activity( + type=ActivityTypes.message, + text="hello", + entities=[mention], + ) + + result = await context.send_targeted_activity(activity, recipient) + + assert result is response + context.send_activity.assert_awaited_once_with(activity) + assert activity.recipient == recipient + assert mention in activity.entities + assert len(self._targeted_treatments(activity)) == 1 + + @pytest.mark.asyncio + async def test_keeps_only_one_targeted_treatment(self): + context = self._make_context(ResourceResponse()) + recipient = ChannelAccount(id="user-id") + activity = Activity( + type=ActivityTypes.message, + entities=[ + ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED), + Entity(type="mention"), + ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED), + ], + ) + + await context.send_targeted_activity(activity, recipient) + + assert Entity(type="mention") in activity.entities + assert len(self._targeted_treatments(activity)) == 1 From 6489c9eb893a36a9ec82e327a494b67e5bb50540 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 30 Sep 2026 11:44:16 -0700 Subject: [PATCH 3/9] Updating sample --- .../hosting_msteams/conversation-agent/README.md | 5 +++-- .../conversation-agent/src/agent.py | 15 +++++---------- tests/hosting_msteams/test_teams_turn_context.py | 7 ++----- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/test_samples/hosting_msteams/conversation-agent/README.md b/test_samples/hosting_msteams/conversation-agent/README.md index 00948565f..41a2429ef 100644 --- a/test_samples/hosting_msteams/conversation-agent/README.md +++ b/test_samples/hosting_msteams/conversation-agent/README.md @@ -26,10 +26,11 @@ Message commands (driven by the welcome card buttons, all `@teams.message(...)`) | `mentionme` | Replies with an Adaptive Card that @-mentions the caller. | | `atmention` | Replies with a text message that @-mentions the caller. | | `messageall` | Proactively sends a 1:1 greeting to every team member. | -| `targeted` | Sends a 1:1 message to every member of the conversation. | +| `targeted` | Sends a private targeted activity to every member of the conversation. | Member lookups use `teams.get_teams_api_client(context).conversations.members`, -and proactive messages use `adapter.create_conversation(...)`. +targeted messages use `context.send_targeted_activity(...)`, and proactive +messages use `adapter.create_conversation(...)`. ## Running diff --git a/test_samples/hosting_msteams/conversation-agent/src/agent.py b/test_samples/hosting_msteams/conversation-agent/src/agent.py index f6b7bbd9c..e5e4dbec2 100644 --- a/test_samples/hosting_msteams/conversation-agent/src/agent.py +++ b/test_samples/hosting_msteams/conversation-agent/src/agent.py @@ -275,9 +275,7 @@ async def on_team_renamed( @teams.message("targeted") async def on_targeted(context: TeamsTurnContext, state: TurnState) -> None: - """Send a 1:1 message to every member of the current conversation.""" - app_id = _app_id(context) - audience = _audience(context) + """Send a private targeted message to every member of the conversation.""" continuation_token: Optional[str] = None while True: paged = await teams.get_teams_api_client( @@ -286,13 +284,10 @@ async def on_targeted(context: TeamsTurnContext, state: TurnState) -> None: 100, continuation_token ) for member in paged.members or []: - - async def _send(ctx: TurnContext, _name=member.name) -> None: - await ctx.send_activity( - f"{_name}, this is a **targeted message** — only you can see this." - ) - - await _create_one_on_one(context, app_id, audience, member, _send) + await context.send_targeted_activity( + f"{member.name}, this is a **targeted message** — only you can see this.", + member.id, + ) continuation_token = paged.continuation_token if not continuation_token: diff --git a/tests/hosting_msteams/test_teams_turn_context.py b/tests/hosting_msteams/test_teams_turn_context.py index 1a1dc2298..4295e436f 100644 --- a/tests/hosting_msteams/test_teams_turn_context.py +++ b/tests/hosting_msteams/test_teams_turn_context.py @@ -41,8 +41,7 @@ def _targeted_treatments(activity: Activity) -> list[Entity]: return [ entity for entity in activity.entities or [] - if getattr(entity, "treatment", None) - == ActivityTreatmentTypes.TARGETED + if getattr(entity, "treatment", None) == ActivityTreatmentTypes.TARGETED ] @pytest.mark.asyncio @@ -57,9 +56,7 @@ async def test_sends_string_as_targeted_message_to_recipient_id(self): activity = context.send_activity.await_args.args[0] assert activity.type == ActivityTypes.message assert activity.text == "hello" - assert activity.recipient == ChannelAccount( - id="user-id", role=RoleTypes.user - ) + assert activity.recipient == ChannelAccount(id="user-id", role=RoleTypes.user) assert len(self._targeted_treatments(activity)) == 1 @pytest.mark.asyncio From 2d768f1e6a9b372aa6777d4f6fc719a5b61bea94 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 30 Sep 2026 14:41:55 -0700 Subject: [PATCH 4/9] Fixing issues with quoted replies --- .../microsoft_agents/activity/activity.py | 41 ++++++- .../hosting/msteams/_utils.py | 40 +++++++ .../hosting/msteams/models/__init__.py | 7 ++ .../hosting/msteams/models/quoted_reply.py | 24 +++++ .../msteams/models/targeted_message_info.py | 12 +++ .../hosting/msteams/teams_activity.py | 56 +++++++++- .../hosting/msteams/teams_turn_context.py | 101 ++++++++++-------- .../conversation-agent/src/agent.py | 17 ++- tests/hosting_msteams/test_teams_activity.py | 44 ++++++++ .../test_teams_turn_context.py | 46 +++++++- 10 files changed, 340 insertions(+), 48 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/__init__.py create mode 100644 libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/quoted_reply.py create mode 100644 libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/targeted_message_info.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 90857e05a..f0db9257d 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -176,7 +176,7 @@ class Activity(AgentsModel): topic_name: NonEmptyString = None history_disclosed: bool = None locale: NonEmptyString = None - text: str = None + text: str | None = None speak: str = None input_hint: NonEmptyString = None summary: NonEmptyString = None @@ -567,6 +567,45 @@ def with_recipient(self, recipient: str | ChannelAccount) -> Self: self.recipient = recipient return self + def with_targeted_recipient(self, recipient: str | ChannelAccount) -> Self: + """ + Make an activity targeted. + + :param recipient: The recipient to target the activity to. Can be a string or a ChannelAccount instance. + :return: This activity, to allow for method chaining. + """ + + if isinstance(recipient, str): + recipient = ChannelAccount(id=recipient, role=RoleTypes.user) + + self.recipient = recipient + self.entities = self.entities or [] + + found_targeted_entity: bool = False + + if self.entities is not None: + # try to remove all targeted entities but keep the first one + def _keep(entity: Entity) -> bool: + nonlocal found_targeted_entity + entity_type = entity.type.lower() + if entity_type == EntityTypes.ACTIVITY_TREATMENT.value.lower(): + treatment = getattr(entity, "treatment", None) + if treatment == ActivityTreatmentTypes.TARGETED: + if found_targeted_entity: + return False + found_targeted_entity = True + return True + + # https://stackoverflow.com/questions/1207406/how-to-remove-items-from-a-list-while-iterating + # entity lists probably won't get too big, but heck + self.entities[:] = [entity for entity in self.entities if _keep(entity)] + + if not found_targeted_entity: + self.entities.append( + ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED) + ) + return self + def add_text(self, text: str) -> Self: """ Appends text to the existing text content of the activity. diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py index 570e1889e..db7365f39 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -14,10 +14,12 @@ from microsoft_agents.activity import ( Activity, ActivityTypes, + Entity, InvokeResponse, ) from microsoft_agents.hosting.core import TurnContext +from .models import TargetedMessageInfo from .type_defs import CommandSelector @@ -146,3 +148,41 @@ async def _send_invoke_response(context: TurnContext, body: Any = None) -> None: value=InvokeResponse(status=int(HTTPStatus.OK), body=serialized_body), ) ) + + +def _apply_prompt_preview_normalizer(activity: Activity, message_id: str) -> None: + """Normalize the prompt preview for the given activity. + + :param activity: The activity to normalize. + :param message_id: The message ID to associate with the targeted message info. + :return: None + """ + pattern = re.compile(r"") + target_type = "quotedreply" + if activity.entities: + + def _keep(entity: Entity) -> bool: + return entity.type.lower() != target_type + + activity.entities[:] = [entity for entity in activity.entities if _keep(entity)] + + if activity.text: + text_without_placeholder = pattern.sub("", activity.text) + if len(text_without_placeholder) != len(activity.text): + activity.text = text_without_placeholder.strip() + + if not activity._get_entities_by_type("targetedmessageinfo"): + activity.entities = activity.entities or [] + activity.entities.append(TargetedMessageInfo(message_id=message_id)) + + +def _is_recipient_targeted(activity: Activity) -> bool: + """Check if the recipient is targeted in the given activity. + + :param activity: The activity to check. + :return: True if the recipient is targeted, False otherwise. + """ + recipient = activity.recipient + if recipient is None: + return False + return recipient and getattr(recipient, "isTargeted", None) == True diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/__init__.py new file mode 100644 index 000000000..8d3b58390 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .quoted_reply import QuotedReply, QuotedReplyData +from .targeted_message_info import TargetedMessageInfo + +__all__ = ["QuotedReply", "QuotedReplyData", "TargetedMessageInfo"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/quoted_reply.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/quoted_reply.py new file mode 100644 index 000000000..f22417a1c --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/quoted_reply.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Literal +from microsoft_agents.activity import AgentsModel, Entity + + +class QuotedReplyData(AgentsModel): + """Represents a quoted reply in a Teams activity.""" + + message_id: str + sender_id: str | None = None + sender_name: str | None = None + preview: str | None = None + time: str | None = None + is_reply_deleted: bool | None = None + validated_message_reference: bool | None = None + + +class QuotedReply(Entity): + """Contains the metadata for a quoted Teams message.""" + + type: Literal["quotedReply"] = "quotedReply" + quoted_reply: QuotedReplyData | None = None diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/targeted_message_info.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/targeted_message_info.py new file mode 100644 index 000000000..8bf4b4aec --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/models/targeted_message_info.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Literal +from microsoft_agents.activity import Entity + + +class TargetedMessageInfo(Entity): + """Identifies the inbound targeted message associated with a Prompt Preview response.""" + + type: Literal["targetedMessageInfo"] = "targetedMessageInfo" + message_id: str diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py index fb47071df..b54d59aab 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py @@ -3,7 +3,10 @@ """Teams-aware :class:`Activity` subclass exposing Teams channel data helpers.""" +import html + from typing import Literal +from typing_extensions import Self from microsoft_teams.api.models import ( ChannelData, @@ -15,7 +18,8 @@ from microsoft_agents.activity import Activity -from ._utils import _try_get_channel_data +from .models import QuotedReply, QuotedReplyData, TargetedMessageInfo +from ._utils import _try_get_channel_data, _is_recipient_targeted class TeamsActivity(Activity): @@ -121,3 +125,53 @@ def enable_feedback_loop( feedback_loop=FeedbackLoop(type=feedback_loop_type) ) return True + + def is_recipient_targeted(self) -> bool: + """Check if the recipient is targeted in the activity. + + :return: True if the recipient is targeted, False otherwise. + """ + return _is_recipient_targeted(self) + + def get_targeted_message_info(self) -> TargetedMessageInfo | None: + """Get the targeted message information from the activity. + + :return: The targeted message information if available, None otherwise. + """ + res = self._get_entity_by_type("targetedMessageInfo") + if res: + return self._convert_entity(res, entity_cls=TargetedMessageInfo) + return None + + def get_quoted_messages(self) -> list[QuotedReply]: + """Get the quoted messages from the activity. + + :return: A list of quoted messages. + """ + return self._convert_entity_list( + self._get_entities_by_type("quotedReply"), entity_cls=QuotedReply + ) + + def add_quoted_reply(self, message_id: str, text: str | None = None) -> Self: + """Add a quoted reply to the activity. + + :param message_id: The ID of the message being quoted. + :param text: The text of the quoted reply. + :return: The updated activity instance. + """ + self.entities = self.entities or [] + self.entities.append( + QuotedReply( + type="quotedReply", + quoted_reply=QuotedReplyData( + message_id=message_id, + ), + ) + ) + + placeholder = f'' + self.text = f"{self.text or ""}{placeholder}" + if text is not None: + self.text = f"{self.text} {text}" + + return self diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 202a35486..9f9334f96 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -7,20 +7,16 @@ from typing import cast -from microsoft_agents.activity.entity.entity import Entity from msgraph import GraphServiceClient from microsoft_teams.api import ApiClient from microsoft_agents.activity import ( Activity, - ActivityTreatment, - ActivityTreatmentTypes, ActivityTypes, ChannelAccount, - EntityTypes, + InputHints, ResourceResponse, - RoleTypes, ) from microsoft_agents.hosting.core import ( AgentApplication, @@ -35,6 +31,7 @@ ) from ._teams_api_client import _get_teams_api_client, _set_teams_api_client from .teams_activity import TeamsActivity +from ._utils import _apply_prompt_preview_normalizer, _is_recipient_targeted class TeamsTurnContext(TurnContext): @@ -103,49 +100,67 @@ def api_client(self) -> ApiClient: """Get the API client for the Teams turn context.""" return _get_teams_api_client(self) - @staticmethod - def _make_targeted_activity( - activity: Activity, recipient: str | ChannelAccount - ) -> None: + def _apply_prompt_preview(self, activity: Activity) -> None: """ - Make an activity targeted. + Apply the prompt preview to the given activity. - :param activity: The activity to make targeted. - :param recipient: The recipient to target the activity to. Can be a string or a ChannelAccount instance. + :param activity: The activity to apply the prompt preview to. :return: None """ + if ( + activity.type == ActivityTypes.message + and _is_recipient_targeted(self.activity) + and self.activity.id + ): + _apply_prompt_preview_normalizer(activity, self.activity.id) + + async def send_activity( + self, + activity_or_text: Activity | str, + speak: str | None = None, + input_hint: str | None = None, + ) -> ResourceResponse: + """Send an activity after applying the prompt preview. - if isinstance(recipient, str): - recipient = ChannelAccount(id=recipient, role=RoleTypes.user) - - activity.recipient = recipient - activity.entities = activity.entities or [] - - found_targeted_entity: bool = False - - if activity.entities is not None: - # try to remove all targeted entities but keep the first one - def _keep(entity: Entity) -> bool: - nonlocal found_targeted_entity - entity_type = entity.type.lower() - if entity_type == EntityTypes.ACTIVITY_TREATMENT.value.lower(): - treatment = getattr(entity, "treatment", None) - if treatment == ActivityTreatmentTypes.TARGETED: - if found_targeted_entity: - return False - found_targeted_entity = True - return True - - # https://stackoverflow.com/questions/1207406/how-to-remove-items-from-a-list-while-iterating - # entity lists probably won't get too big, but heck - activity.entities[:] = [ - entity for entity in activity.entities if _keep(entity) - ] - - if not found_targeted_entity: - activity.entities.append( - ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED) + :param activity_or_text: The activity or text to send. + :param speak: Optional speech text for the activity. + :param input_hint: Optional input hint for the activity. + :return: The resource response for the sent activity. + """ + + if isinstance(activity_or_text, str): + activity_or_text = Activity( + type=ActivityTypes.message, + text=activity_or_text, + input_hint=input_hint or InputHints.accepting_input, ) + if speak: + activity_or_text.speak = speak + + self._apply_prompt_preview( + activity_or_text + if isinstance(activity_or_text, Activity) + else Activity(type=ActivityTypes.message, text=activity_or_text) + ) + + return await TurnContext.send_activity(self._original, activity_or_text) + + async def send_activities( + self, activities: list[Activity] + ) -> list[ResourceResponse]: + """Send multiple activities after applying the prompt preview to each. + + :param activities: A list of activities to send. + :return: A list of resource responses for the sent activities. + """ + + for activity in activities: + self._apply_prompt_preview(activity) + + return await TurnContext.send_activities( + self._original, + activities, + ) async def send_targeted_activity( self, @@ -161,7 +176,7 @@ async def send_targeted_activity( """ if isinstance(activity, str): activity = Activity(type=ActivityTypes.message, text=activity) - TeamsTurnContext._make_targeted_activity(activity, recipient) + activity.with_targeted_recipient(recipient) return await self.send_activity(activity) def get_graph_client( diff --git a/test_samples/hosting_msteams/conversation-agent/src/agent.py b/test_samples/hosting_msteams/conversation-agent/src/agent.py index e5e4dbec2..a5ff6b2cc 100644 --- a/test_samples/hosting_msteams/conversation-agent/src/agent.py +++ b/test_samples/hosting_msteams/conversation-agent/src/agent.py @@ -24,6 +24,7 @@ ) from microsoft_agents.activity import ( + ActivityTypes, ActionTypes, CardAction, ChannelAccount, @@ -48,7 +49,7 @@ ConsoleTranscriptLogger, TranscriptLoggerMiddleware, ) -from microsoft_agents.hosting.msteams import TeamsAgentExtension +from microsoft_agents.hosting.msteams import TeamsAgentExtension, TeamsActivity from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext logger = logging.getLogger(__name__) @@ -272,6 +273,20 @@ async def on_team_renamed( # ── Message commands ───────────────────────────────────────────────────────── +@teams.message("quotedreply") +async def on_quoted_reply(context: TeamsTurnContext, state: TurnState) -> None: + """Handle a quoted reply message.""" + message_id = context.activity.id + if not message_id: + raise ValueError("Message ID is required for a quoted reply.") + + reply = TeamsActivity( + type=ActivityTypes.message, + text="" + ) + reply.add_quoted_reply(message_id, "This response includes a quoted reply to your message.") + + await context.send_activity(reply) @teams.message("targeted") async def on_targeted(context: TeamsTurnContext, state: TurnState) -> None: diff --git a/tests/hosting_msteams/test_teams_activity.py b/tests/hosting_msteams/test_teams_activity.py index 0a535c272..1fbb3137b 100644 --- a/tests/hosting_msteams/test_teams_activity.py +++ b/tests/hosting_msteams/test_teams_activity.py @@ -17,6 +17,7 @@ ) if is_supported_version: + from microsoft_agents.activity import ActivityTypes, ChannelAccount from microsoft_teams.api.models import ( ChannelData, ChannelInfo, @@ -27,6 +28,7 @@ from microsoft_teams.api.models.channel_data.settings import ChannelDataSettings from microsoft_agents.hosting.msteams import TeamsActivity + from microsoft_agents.hosting.msteams.models import QuotedReplyData def _activity(channel_data=None) -> "TeamsActivity": @@ -161,3 +163,45 @@ def test_uses_supplied_feedback_loop_type(self): activity = _activity() assert activity.enable_feedback_loop("custom") is True assert activity.channel_data.feedback_loop.type == "custom" + + +class TestPromptPreview: + + def test_is_recipient_targeted_reads_wire_property(self): + recipient = ChannelAccount.model_validate({"id": "user-id", "isTargeted": True}) + activity = TeamsActivity( + type=ActivityTypes.message, + recipient=recipient, + ) + + assert activity.is_recipient_targeted() is True + + def test_add_quoted_reply_adds_escaped_self_closing_placeholder(self): + activity = TeamsActivity(type=ActivityTypes.message, text="") + + activity.add_quoted_reply('message&"id', "response") + + quoted_reply = activity.get_quoted_messages()[0] + assert quoted_reply.quoted_reply.message_id == 'message&"id' + assert activity.text == ' response' + + def test_add_quoted_reply_without_existing_or_appended_text(self): + activity = TeamsActivity(type=ActivityTypes.message) + + activity.add_quoted_reply("message-id") + + assert activity.text == '' + + def test_quoted_reply_validated_reference_round_trips_wire_name(self): + data = QuotedReplyData.model_validate( + { + "messageId": "message-id", + "validatedMessageReference": True, + } + ) + + assert data.validated_message_reference is True + assert data.model_dump(by_alias=True, exclude_none=True) == { + "messageId": "message-id", + "validatedMessageReference": True, + } diff --git a/tests/hosting_msteams/test_teams_turn_context.py b/tests/hosting_msteams/test_teams_turn_context.py index 4295e436f..146c0b2ad 100644 --- a/tests/hosting_msteams/test_teams_turn_context.py +++ b/tests/hosting_msteams/test_teams_turn_context.py @@ -3,11 +3,13 @@ """Tests for TeamsTurnContext helpers that can be exercised without a live adapter.""" +from __future__ import annotations + from unittest.mock import AsyncMock import pytest -from .helpers import is_supported_version +from .helpers import _make_context, is_supported_version pytestmark = pytest.mark.skipif( not is_supported_version, @@ -21,12 +23,14 @@ ActivityTreatmentTypes, ActivityTypes, ChannelAccount, + ConversationAccount, Entity, ResourceResponse, RoleTypes, ) - from microsoft_agents.hosting.msteams import TeamsTurnContext + from microsoft_agents.hosting.msteams import TeamsActivity, TeamsTurnContext + from microsoft_agents.hosting.msteams.models import QuotedReply, QuotedReplyData class TestSendTargetedActivity: @@ -96,3 +100,41 @@ async def test_keeps_only_one_targeted_treatment(self): assert Entity(type="mention") in activity.entities assert len(self._targeted_treatments(activity)) == 1 + + +class TestPromptPreview: + @pytest.mark.asyncio + async def test_send_activity_uses_targeted_inbound_message_metadata(self): + original = _make_context(ActivityTypes.message) + original.activity.id = "inbound-message" + original.activity.conversation = ConversationAccount(id="conversation-id") + original.activity.recipient = ChannelAccount.model_validate( + {"id": "agent-id", "isTargeted": True} + ) + + context = object.__new__(TeamsTurnContext) + context._original = original + context._teams_activity = original.activity + + response = TeamsActivity( + type=ActivityTypes.message, + text=' response', + entities=[ + QuotedReply(quoted_reply=QuotedReplyData(message_id="quoted-message")) + ], + ) + + await context.send_activity(response) + + sent = original.adapter.sent_activities[0] + assert sent.text == "response" + assert not [ + entity for entity in sent.entities if entity.type.lower() == "quotedreply" + ] + targeted_message_info = [ + entity + for entity in sent.entities + if entity.type.lower() == "targetedmessageinfo" + ] + assert len(targeted_message_info) == 1 + assert targeted_message_info[0].message_id == "inbound-message" From ceca04612e5702d6f5e5c1b5f440b350c7696c53 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 30 Sep 2026 14:55:20 -0700 Subject: [PATCH 5/9] Targeted handling in ConnectorClient --- .../microsoft_agents/activity/activity.py | 12 ++- .../hosting/core/_oauth/_oauth_flow.py | 2 +- .../core/connector/client/connector_client.py | 14 +++ .../hosting/msteams/teams_activity.py | 2 +- tests/activity/test_activity_builders.py | 49 ++++++++- .../connector/test_connector_client.py | 93 ++++++++++++++++ tests/hosting_msteams/test_teams_activity.py | 42 +++++++- .../test_teams_turn_context.py | 101 ++++++++++++++++-- 8 files changed, 298 insertions(+), 17 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index f0db9257d..645d231d3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -725,7 +725,7 @@ def remove_mention_text(self, identifier: NonEmptyString | None) -> str: appears in Activity.text. """ if not identifier: - return self.text + return self.text or "" for mention in self.get_mentions(): if not mention.mentioned or mention.mentioned.id != identifier: @@ -740,7 +740,7 @@ def remove_mention_text(self, identifier: NonEmptyString | None) -> str: pattern, "", self.text or "", flags=re.IGNORECASE ).strip() - return self.text + return self.text or "" def is_targeted_activity(self) -> bool: """ @@ -751,11 +751,13 @@ def is_targeted_activity(self) -> bool: if not self.entities: return False + target_type = EntityTypes.ACTIVITY_TREATMENT.lower() + for entity in self.entities: if ( - entity.type == EntityTypes.ACTIVITY_TREATMENT - and isinstance(entity, ActivityTreatment) - and entity.treatment == ActivityTreatmentTypes.TARGETED + entity.type.lower() == target_type + and getattr(entity, "treatment", None) + == ActivityTreatmentTypes.TARGETED ): return True diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py index 27ae4a961..b23fe9d1c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py @@ -208,7 +208,7 @@ async def _continue_from_message( self, activity: Activity ) -> tuple[TokenResponse, _FlowErrorTag]: """Handles the continuation of the flow from a message activity.""" - magic_code: str = activity.text + magic_code: str = activity.text or "" if magic_code and magic_code.isdigit() and len(magic_code) == 6: token_response: TokenResponse = await self.get_user_token(magic_code) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 484077553..78b420f30 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -276,8 +276,15 @@ async def reply_to_activity( body.type, ) + params = ( + {"isTargetedActivity": "true"} + if body.channel_id == Channels.ms_teams and body.is_targeted_activity() + else None + ) + async with self._wrapped_client().post( url, + params=params, json=body.model_dump( by_alias=True, exclude_unset=True, exclude_none=True, mode="json" ), @@ -337,8 +344,15 @@ async def send_to_conversation( body.type, ) + params = ( + {"isTargetedActivity": "true"} + if body.channel_id == Channels.ms_teams and body.is_targeted_activity() + else None + ) + async with self._wrapped_client().post( url, + params=params, json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), ) as response: span.share(http_method="POST", status_code=response.status) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py index b54d59aab..5c16c7fc1 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py @@ -170,7 +170,7 @@ def add_quoted_reply(self, message_id: str, text: str | None = None) -> Self: ) placeholder = f'' - self.text = f"{self.text or ""}{placeholder}" + self.text = f"{self.text or ''}{placeholder}" if text is not None: self.text = f"{self.text} {text}" diff --git a/tests/activity/test_activity_builders.py b/tests/activity/test_activity_builders.py index 11a5cdbd2..09560fa52 100644 --- a/tests/activity/test_activity_builders.py +++ b/tests/activity/test_activity_builders.py @@ -6,13 +6,17 @@ from microsoft_agents.activity import ( Activity, - AttachmentLayoutTypes, + ActivityTreatment, + ActivityTreatmentTypes, Attachment, + AttachmentLayoutTypes, ChannelAccount, DeliveryModes, Entity, + EntityTypes, InputHints, Mention, + RoleTypes, SuggestedActions, TextFormatTypes, ) @@ -168,6 +172,49 @@ def test_remove_recipient_mention(self): class TestActivityTargeting: + def test_with_recipient_uses_string_id(self): + activity = Activity.create_message_activity().with_recipient("u1") + + assert activity.recipient == ChannelAccount( + id="u1", + role=RoleTypes.user, + ) + + def test_with_recipient_uses_channel_account(self): + recipient = ChannelAccount(id="u1", name="User One") + + activity = Activity.create_message_activity().with_recipient(recipient) + + assert activity.recipient is recipient + + def test_with_targeted_recipient_sets_recipient_and_treatment(self): + activity = Activity.create_message_activity().with_targeted_recipient("u1") + + assert activity.recipient == ChannelAccount( + id="u1", + role=RoleTypes.user, + ) + assert activity.is_targeted_activity() is True + + def test_with_targeted_recipient_preserves_entities_and_removes_duplicates(self): + mention = Entity(type=EntityTypes.MENTION) + activity = Activity.create_message_activity() + activity.entities = [ + ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED), + mention, + ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED), + ] + + activity.with_targeted_recipient(ChannelAccount(id="u1")) + + assert mention in activity.entities + treatments = [ + entity + for entity in activity.entities + if getattr(entity, "treatment", None) == ActivityTreatmentTypes.TARGETED + ] + assert len(treatments) == 1 + def test_make_targeted_activity_sets_treatment(self): recipient = ChannelAccount(id="bot", name="Bot") activity = Activity.create_message_activity() diff --git a/tests/hosting_core/connector/test_connector_client.py b/tests/hosting_core/connector/test_connector_client.py index 013b3d59d..982b2fb22 100644 --- a/tests/hosting_core/connector/test_connector_client.py +++ b/tests/hosting_core/connector/test_connector_client.py @@ -97,6 +97,50 @@ async def handler(request): finally: await server.close() + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("channel_id", "targeted", "expected_query"), + [ + (Channels.ms_teams, True, {"isTargetedActivity": "true"}), + (Channels.ms_teams, False, {}), + ("webchat", True, {}), + ], + ) + async def test_send_to_conversation_sets_targeted_query_for_teams( + self, + channel_id, + targeted, + expected_query, + ): + captured_query = None + + async def handler(request): + nonlocal captured_query + captured_query = dict(request.query) + return web.Response(status=200, text="") + + routes = [web.post("/v3/conversations/{conversation_id}/activities", handler)] + app = _create_app(routes) + activity = Activity( + type="message", + text="Hello, world!", + channel_id=channel_id, + recipient=ChannelAccount(id="user-id"), + ) + if targeted: + activity.make_targeted_activity() + + server = TestServer(app) + await server.start_server() + try: + async with ClientSession(base_url=server.make_url("/")) as session: + ops = ConversationsOperations(session) + await ops.send_to_conversation("conv-1", activity) + finally: + await server.close() + + assert captured_query == expected_query + @pytest.mark.asyncio async def test_send_to_conversation_error_includes_response_body(self, activity): """Should preserve channel error details used by streaming recovery.""" @@ -569,6 +613,55 @@ async def handler(request): finally: await server.close() + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("channel_id", "targeted", "expected_query"), + [ + (Channels.ms_teams, True, {"isTargetedActivity": "true"}), + (Channels.ms_teams, False, {}), + ("webchat", True, {}), + ], + ) + async def test_reply_to_activity_sets_targeted_query_for_teams( + self, + channel_id, + targeted, + expected_query, + ): + captured_query = None + + async def handler(request): + nonlocal captured_query + captured_query = dict(request.query) + return web.Response(status=200, text="") + + routes = [ + web.post( + "/v3/conversations/{conversation_id}/activities/{activity_id}", + handler, + ) + ] + app = _create_app(routes) + activity = Activity( + type="message", + text="Hello, world!", + channel_id=channel_id, + recipient=ChannelAccount(id="user-id"), + ) + if targeted: + activity.make_targeted_activity() + + server = TestServer(app) + await server.start_server() + try: + async with ClientSession(base_url=server.make_url("/")) as session: + ops = ConversationsOperations(session) + await ops.reply_to_activity("conv-1", "act-1", activity) + finally: + await server.close() + + assert captured_query == expected_query + class TestNormalizeConversationId: """Tests for ConversationsOperations._normalize_conversation_id and _should_sanitize_conversation_id.""" diff --git a/tests/hosting_msteams/test_teams_activity.py b/tests/hosting_msteams/test_teams_activity.py index 1fbb3137b..1cb23e3c1 100644 --- a/tests/hosting_msteams/test_teams_activity.py +++ b/tests/hosting_msteams/test_teams_activity.py @@ -17,7 +17,7 @@ ) if is_supported_version: - from microsoft_agents.activity import ActivityTypes, ChannelAccount + from microsoft_agents.activity import ActivityTypes, ChannelAccount, Entity from microsoft_teams.api.models import ( ChannelData, ChannelInfo, @@ -176,6 +176,46 @@ def test_is_recipient_targeted_reads_wire_property(self): assert activity.is_recipient_targeted() is True + def test_is_recipient_targeted_returns_false_without_wire_property(self): + activity = TeamsActivity( + type=ActivityTypes.message, + recipient=ChannelAccount(id="user-id"), + ) + + assert activity.is_recipient_targeted() is False + + def test_get_targeted_message_info_converts_wire_entity(self): + activity = TeamsActivity( + type=ActivityTypes.message, + entities=[ + Entity( + type="targetedMessageInfo", + messageId="inbound-message", + ) + ], + ) + + result = activity.get_targeted_message_info() + + assert result is not None + assert result.message_id == "inbound-message" + + def test_get_quoted_messages_converts_wire_entity(self): + activity = TeamsActivity( + type=ActivityTypes.message, + entities=[ + Entity( + type="quotedReply", + quotedReply={"messageId": "quoted-message"}, + ) + ], + ) + + result = activity.get_quoted_messages() + + assert len(result) == 1 + assert result[0].quoted_reply.message_id == "quoted-message" + def test_add_quoted_reply_adds_escaped_self_closing_placeholder(self): activity = TeamsActivity(type=ActivityTypes.message, text="") diff --git a/tests/hosting_msteams/test_teams_turn_context.py b/tests/hosting_msteams/test_teams_turn_context.py index 146c0b2ad..f04aa5984 100644 --- a/tests/hosting_msteams/test_teams_turn_context.py +++ b/tests/hosting_msteams/test_teams_turn_context.py @@ -30,7 +30,11 @@ ) from microsoft_agents.hosting.msteams import TeamsActivity, TeamsTurnContext - from microsoft_agents.hosting.msteams.models import QuotedReply, QuotedReplyData + from microsoft_agents.hosting.msteams.models import ( + QuotedReply, + QuotedReplyData, + TargetedMessageInfo, + ) class TestSendTargetedActivity: @@ -103,18 +107,28 @@ async def test_keeps_only_one_targeted_treatment(self): class TestPromptPreview: - @pytest.mark.asyncio - async def test_send_activity_uses_targeted_inbound_message_metadata(self): + @staticmethod + def _make_real_context( + *, + targeted: bool, + activity_id: str = "inbound-message", + ) -> tuple[TeamsTurnContext, object]: original = _make_context(ActivityTypes.message) - original.activity.id = "inbound-message" + original.activity.id = activity_id original.activity.conversation = ConversationAccount(id="conversation-id") - original.activity.recipient = ChannelAccount.model_validate( - {"id": "agent-id", "isTargeted": True} - ) + recipient_data = {"id": "agent-id"} + if targeted: + recipient_data["isTargeted"] = True + original.activity.recipient = ChannelAccount.model_validate(recipient_data) context = object.__new__(TeamsTurnContext) context._original = original context._teams_activity = original.activity + return context, original.adapter + + @pytest.mark.asyncio + async def test_send_activity_uses_targeted_inbound_message_metadata(self): + context, adapter = self._make_real_context(targeted=True) response = TeamsActivity( type=ActivityTypes.message, @@ -126,7 +140,7 @@ async def test_send_activity_uses_targeted_inbound_message_metadata(self): await context.send_activity(response) - sent = original.adapter.sent_activities[0] + sent = adapter.sent_activities[0] assert sent.text == "response" assert not [ entity for entity in sent.entities if entity.type.lower() == "quotedreply" @@ -138,3 +152,74 @@ async def test_send_activity_uses_targeted_inbound_message_metadata(self): ] assert len(targeted_message_info) == 1 assert targeted_message_info[0].message_id == "inbound-message" + + @pytest.mark.asyncio + async def test_send_string_adds_prompt_preview_for_targeted_inbound(self): + context, adapter = self._make_real_context(targeted=True) + + await context.send_activity("response") + + sent = adapter.sent_activities[0] + targeted_message_info = [ + entity + for entity in sent.entities + if entity.type.lower() == "targetedmessageinfo" + ] + assert len(targeted_message_info) == 1 + assert targeted_message_info[0].message_id == "inbound-message" + + @pytest.mark.asyncio + async def test_send_activity_does_not_add_prompt_preview_for_regular_inbound(self): + context, adapter = self._make_real_context(targeted=False) + + await context.send_activity( + Activity(type=ActivityTypes.message, text="response") + ) + + sent = adapter.sent_activities[0] + assert not [ + entity + for entity in sent.entities or [] + if entity.type.lower() == "targetedmessageinfo" + ] + + @pytest.mark.asyncio + async def test_send_activities_adds_prompt_preview_to_each_message(self): + context, adapter = self._make_real_context(targeted=True) + + await context.send_activities( + [ + Activity(type=ActivityTypes.message, text="first"), + Activity(type=ActivityTypes.message, text="second"), + ] + ) + + assert len(adapter.sent_activities) == 2 + for sent in adapter.sent_activities: + targeted_message_info = [ + entity + for entity in sent.entities + if entity.type.lower() == "targetedmessageinfo" + ] + assert len(targeted_message_info) == 1 + assert targeted_message_info[0].message_id == "inbound-message" + + @pytest.mark.asyncio + async def test_send_activity_preserves_explicit_prompt_preview_metadata(self): + context, adapter = self._make_real_context(targeted=True) + response = Activity( + type=ActivityTypes.message, + text="response", + entities=[TargetedMessageInfo(message_id="explicit-message")], + ) + + await context.send_activity(response) + + sent = adapter.sent_activities[0] + targeted_message_info = [ + entity + for entity in sent.entities + if entity.type.lower() == "targetedmessageinfo" + ] + assert len(targeted_message_info) == 1 + assert targeted_message_info[0].message_id == "explicit-message" From 882794810a8cb263f1cecba42364128da5d1e36f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 30 Sep 2026 15:00:08 -0700 Subject: [PATCH 6/9] Fixing boolean condition --- .../microsoft_agents/hosting/msteams/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py index db7365f39..ac2df9a09 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -185,4 +185,4 @@ def _is_recipient_targeted(activity: Activity) -> bool: recipient = activity.recipient if recipient is None: return False - return recipient and getattr(recipient, "isTargeted", None) == True + return recipient and getattr(recipient, "isTargeted", None) is True From 5cbc19b8218a79a29cc575a3fa7f58db58102c14 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 1 Oct 2026 11:21:39 -0700 Subject: [PATCH 7/9] ADdressing PR feedback --- changelog.md | 10 +++++ .../microsoft_agents/activity/activity.py | 2 +- .../hosting/core/turn_context.py | 10 +++-- .../hosting/msteams/teams_turn_context.py | 15 ++++++- .../readme.md | 40 +++++++++++++++++-- .../conversation-agent/README.md | 7 +++- tests/hosting_core/test_turn_context.py | 36 +++++++++++++++++ .../test_teams_turn_context.py | 40 +++++++++++++++++++ 8 files changed, 150 insertions(+), 10 deletions(-) diff --git a/changelog.md b/changelog.md index 71765f086..803d21ee9 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,16 @@ **Release Date:** Unreleased **Previous Version:** 1.7.0 (Released 2026-09-17) +## Major Features & Enhancements + +- **Teams Quoted Replies**: Added `TeamsActivity.add_quoted_reply()` for creating quoted replies and `get_quoted_messages()` for reading quoted-message metadata, with typed `QuotedReply` and `QuotedReplyData` models +- **Targeted Activity Sending**: Improved `TeamsTurnContext.send_targeted_activity()` to accept text or an activity plus a recipient ID or `ChannelAccount`, preserve the intended recipient when applying conversation references, and send the Teams `isTargetedActivity=true` connector parameter +- **Targeted Reply Prompt Previews**: Added automatic prompt-preview metadata when replying to inbound targeted activities, along with helpers for detecting targeted recipients and reading `TargetedMessageInfo` + +## Developer Experience + +- **Teams Conversation Agent Sample**: Expanded the sample and package documentation with targeted activity and quoted reply examples + ## Bug Fixes - **Client Citation Deserialization**: Fixed deserialization of citations containing `@id` values diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 645d231d3..d9dc40a4e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -166,7 +166,7 @@ class Activity(AgentsModel): service_url: NonEmptyString = None from_property: Annotated[ChannelAccount, Field(alias="from")] = None conversation: ConversationAccount = None - recipient: ChannelAccount = None + recipient: ChannelAccount | None = None text_format: NonEmptyString = None attachment_layout: NonEmptyString = None members_added: list[ChannelAccount] = None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index f98d9d3bd..f357769a4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -356,8 +356,7 @@ def apply_conversation_reference( :return: """ activity.channel_id = reference.channel_id - if reference.locale: - activity.locale = reference.locale + activity.locale = reference.locale or activity.locale activity.service_url = reference.service_url activity.conversation = reference.conversation activity.request_id = reference.request_id @@ -369,7 +368,12 @@ def apply_conversation_reference( activity.id = reference.activity_id else: activity.from_property = reference.agent - activity.recipient = reference.user + # Targeted activities should have the recipient set to the intended user + # instead of the incoming Activity's sender. This allows for propery routing + # of the outgoing activity to the user even if the incoming activity was sent + # to a different users (e.g. in group chat scenarios) + if not activity.is_targeted_activity() or activity.recipient is None: + activity.recipient = reference.user if reference.activity_id: activity.reply_to_id = reference.activity_id diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 9f9334f96..385c2c7dc 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -5,6 +5,7 @@ from __future__ import annotations +import warnings from typing import cast from msgraph import GraphServiceClient @@ -165,7 +166,7 @@ async def send_activities( async def send_targeted_activity( self, activity: str | Activity, - recipient: str | ChannelAccount, + recipient: str | ChannelAccount | None = None, ) -> ResourceResponse: """ Send a targeted activity. @@ -174,6 +175,18 @@ async def send_targeted_activity( :param recipient: The recipient to target the activity to. Can be a string or a ChannelAccount instance. :return: The resource response. """ + if recipient is None: + warnings.warn( + "Using an empty recipient is deprecated and will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) + if isinstance(activity, str) or not activity.recipient: + raise ValueError( + "Cannot infer the recipient from the passed-in activity." + ) + recipient = activity.recipient + if isinstance(activity, str): activity = Activity(type=ActivityTypes.message, text=activity) activity.with_targeted_recipient(recipient) diff --git a/libraries/microsoft-agents-hosting-msteams/readme.md b/libraries/microsoft-agents-hosting-msteams/readme.md index b9581bbbc..7d21b1cf1 100644 --- a/libraries/microsoft-agents-hosting-msteams/readme.md +++ b/libraries/microsoft-agents-hosting-msteams/readme.md @@ -230,6 +230,10 @@ context.activity.notify_user(alert_in_meeting=True) # Attach a feedback loop to a message being sent (Copilot scenarios) context.activity.enable_feedback_loop() + +# Inspect quoted messages or targeted-message metadata +quoted_messages = context.activity.get_quoted_messages() +targeted_message = context.activity.get_targeted_message_info() ``` #### `context.api_client` → `ApiClient` @@ -238,13 +242,43 @@ Direct access to the Teams REST API client, pre-authenticated for the current tu #### Sending targeted activities -`TeamsTurnContext` adds two methods for sending activities that target a specific user in a meeting: +Use `TeamsTurnContext.send_targeted_activity()` to send a private activity to a +specific user in a meeting or group conversation. Pass either the recipient ID +or a `ChannelAccount`: + +```python +await context.send_targeted_activity( + "Only you can see this message.", + recipient.id, +) +``` + +Replies to inbound targeted activities automatically include the prompt-preview +metadata required by Teams. + +#### Sending quoted replies + +Use `TeamsActivity.add_quoted_reply()` to quote an existing message: ```python -await context.send_targeted_activity(activity) -await context.send_targeted_activities([activity1, activity2]) +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.hosting.msteams import TeamsActivity + +message_id = context.activity.id +if not message_id: + raise ValueError("Message ID is required for a quoted reply.") + +reply = TeamsActivity(type=ActivityTypes.message) +reply.add_quoted_reply( + message_id, + "This response includes a quote of your message.", +) +await context.send_activity(reply) ``` +Use `context.activity.get_quoted_messages()` to inspect quoted messages on an +incoming activity. + ### Messaging Extensions ```python diff --git a/test_samples/hosting_msteams/conversation-agent/README.md b/test_samples/hosting_msteams/conversation-agent/README.md index 41a2429ef..af4095dd4 100644 --- a/test_samples/hosting_msteams/conversation-agent/README.md +++ b/test_samples/hosting_msteams/conversation-agent/README.md @@ -15,7 +15,8 @@ Conversation-update and lifecycle events: | channel created / renamed / deleted | `@teams.channels.created` / `.renamed` / `.deleted` | | team renamed | `@teams.teams.renamed` | -Message commands (driven by the welcome card buttons, all `@teams.message(...)`): +Message commands (all registered with `@teams.message(...)`; most are also +available from the welcome card): | Command | Behaviour | |---------|-----------| @@ -27,10 +28,12 @@ Message commands (driven by the welcome card buttons, all `@teams.message(...)`) | `atmention` | Replies with a text message that @-mentions the caller. | | `messageall` | Proactively sends a 1:1 greeting to every team member. | | `targeted` | Sends a private targeted activity to every member of the conversation. | +| `quotedreply` | Replies with a quote of the triggering message. | Member lookups use `teams.get_teams_api_client(context).conversations.members`, targeted messages use `context.send_targeted_activity(...)`, and proactive -messages use `adapter.create_conversation(...)`. +messages use `adapter.create_conversation(...)`. Quoted replies are built with +`TeamsActivity.add_quoted_reply(...)`. ## Running diff --git a/tests/hosting_core/test_turn_context.py b/tests/hosting_core/test_turn_context.py index 6190301b6..651946e7a 100644 --- a/tests/hosting_core/test_turn_context.py +++ b/tests/hosting_core/test_turn_context.py @@ -405,6 +405,42 @@ def test_apply_conversation_reference_should_return_prepare_reply_when_is_incomi assert reply.service_url == ACTIVITY.service_url assert reply.channel_id == ACTIVITY.channel_id + def test_apply_conversation_reference_preserves_activity_locale_when_reference_has_none( + self, + ): + reference = ACTIVITY.get_conversation_reference() + reference.locale = None + + reply = TurnContext.apply_conversation_reference( + Activity(type="message", text="reply", locale="fr-FR"), reference + ) + + assert reply.locale == "fr-FR" + + def test_apply_conversation_reference_preserves_targeted_recipient(self): + reference = ACTIVITY.get_conversation_reference() + targeted_recipient = ChannelAccount(id="target-user", name="Target User") + reply = Activity(type="message", text="reply").with_targeted_recipient( + targeted_recipient + ) + + TurnContext.apply_conversation_reference(reply, reference) + + assert reply.recipient == targeted_recipient + + def test_apply_conversation_reference_sets_recipient_when_targeted_recipient_is_missing( + self, + ): + reference = ACTIVITY.get_conversation_reference() + reply = Activity(type="message", text="reply").with_targeted_recipient( + "target-user" + ) + reply.recipient = None + + TurnContext.apply_conversation_reference(reply, reference) + + assert reply.recipient == reference.user + def test_apply_conversation_reference_when_is_incoming_is_true_should_not_prepare_a_reply( self, ): diff --git a/tests/hosting_msteams/test_teams_turn_context.py b/tests/hosting_msteams/test_teams_turn_context.py index f04aa5984..df1efdbb7 100644 --- a/tests/hosting_msteams/test_teams_turn_context.py +++ b/tests/hosting_msteams/test_teams_turn_context.py @@ -105,6 +105,46 @@ async def test_keeps_only_one_targeted_treatment(self): assert Entity(type="mention") in activity.entities assert len(self._targeted_treatments(activity)) == 1 + @pytest.mark.asyncio + async def test_deprecated_empty_recipient_uses_activity_recipient(self): + response = ResourceResponse(id="activity-id") + context = self._make_context(response) + recipient = ChannelAccount(id="user-id", name="User") + activity = Activity( + type=ActivityTypes.message, + text="hello", + recipient=recipient, + ) + + with pytest.warns( + DeprecationWarning, + match="Using an empty recipient is deprecated", + ): + result = await context.send_targeted_activity(activity) + + assert result is response + context.send_activity.assert_awaited_once_with(activity) + assert activity.recipient == recipient + assert len(self._targeted_treatments(activity)) == 1 + + @pytest.mark.asyncio + async def test_deprecated_empty_recipient_fails_when_activity_has_no_recipient( + self, + ): + context = self._make_context(ResourceResponse()) + activity = Activity(type=ActivityTypes.message, text="hello") + + with pytest.warns( + DeprecationWarning, + match="Using an empty recipient is deprecated", + ), pytest.raises( + ValueError, + match="Cannot infer the recipient from the passed-in activity", + ): + await context.send_targeted_activity(activity) + + context.send_activity.assert_not_awaited() + class TestPromptPreview: @staticmethod From bf7ef5b3f6380a829422e5fa48f4c81ad25be814 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 1 Oct 2026 11:58:44 -0700 Subject: [PATCH 8/9] Addressing more PR feedback --- .../microsoft_agents/activity/activity.py | 19 ++++++++------- .../core/connector/client/connector_client.py | 6 +++-- .../hosting/core/turn_context.py | 24 +------------------ 3 files changed, 15 insertions(+), 34 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index d9dc40a4e..1f3e86439 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -290,7 +290,7 @@ def _serialize_sub_channel_data( def apply_conversation_reference( self, reference: ConversationReference, is_incoming: bool = False - ): + ) -> Self: """ Updates this activity with the delivery information from an existing ConversationReference. @@ -305,24 +305,25 @@ def apply_conversation_reference( outgoing activity with the correct delivery information. """ self.channel_id = reference.channel_id + self.locale = reference.locale or self.locale self.service_url = reference.service_url self.conversation = reference.conversation self.request_id = reference.request_id - if reference.locale is not None: - self.locale = reference.locale - if is_incoming: self.from_property = reference.user self.recipient = reference.agent - - if reference.activity_id is not None: + if reference.activity_id: self.id = reference.activity_id else: self.from_property = reference.agent - self.recipient = reference.user - - if reference.activity_id is not None: + # Targeted activities should have the recipient set to the intended user + # instead of the incoming activity's sender. This allows for proper routing + # of the outgoing activity to the user even if the incoming activity was sent + # to a different user (e.g. in group chat scenarios) + if not self.is_targeted_activity() or self.recipient is None: + self.recipient = reference.user + if reference.activity_id: self.reply_to_id = reference.activity_id return self diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 78b420f30..9d20b2a2c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -278,7 +278,8 @@ async def reply_to_activity( params = ( {"isTargetedActivity": "true"} - if body.channel_id == Channels.ms_teams and body.is_targeted_activity() + if ChannelId.get_channel(body.channel_id) == Channels.ms_teams + and body.is_targeted_activity() else None ) @@ -346,7 +347,8 @@ async def send_to_conversation( params = ( {"isTargetedActivity": "true"} - if body.channel_id == Channels.ms_teams and body.is_targeted_activity() + if ChannelId.get_channel(body.channel_id) == Channels.ms_teams + and body.is_targeted_activity() else None ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index f357769a4..07d5a0d72 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -355,29 +355,7 @@ def apply_conversation_reference( :param is_incoming: :return: """ - activity.channel_id = reference.channel_id - activity.locale = reference.locale or activity.locale - activity.service_url = reference.service_url - activity.conversation = reference.conversation - activity.request_id = reference.request_id - - if is_incoming: - activity.from_property = reference.user - activity.recipient = reference.agent - if reference.activity_id: - activity.id = reference.activity_id - else: - activity.from_property = reference.agent - # Targeted activities should have the recipient set to the intended user - # instead of the incoming Activity's sender. This allows for propery routing - # of the outgoing activity to the user even if the incoming activity was sent - # to a different users (e.g. in group chat scenarios) - if not activity.is_targeted_activity() or activity.recipient is None: - activity.recipient = reference.user - if reference.activity_id: - activity.reply_to_id = reference.activity_id - - return activity + return activity.apply_conversation_reference(reference, is_incoming) @staticmethod def get_reply_conversation_reference( From f5328301264e250c960fae9dae6c265645530142 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 1 Oct 2026 13:13:52 -0700 Subject: [PATCH 9/9] Minor improvement to readability --- .../microsoft_agents/hosting/msteams/teams_turn_context.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 385c2c7dc..e90c900bc 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -138,12 +138,7 @@ async def send_activity( if speak: activity_or_text.speak = speak - self._apply_prompt_preview( - activity_or_text - if isinstance(activity_or_text, Activity) - else Activity(type=ActivityTypes.message, text=activity_or_text) - ) - + self._apply_prompt_preview(activity_or_text) return await TurnContext.send_activity(self._original, activity_or_text) async def send_activities(