From 74c410da918aed839822d4998ef2073a5cc85b00 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 21 Jul 2026 22:08:58 -0700 Subject: [PATCH 1/5] TurnContext annotation updates --- .../hosting/core/turn_context.py | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) 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 3bd9a7cd..1ae55940 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 @@ -3,12 +3,11 @@ from __future__ import annotations import re -from typing import Optional +from typing import Optional, Awaitable, Any -from copy import copy, deepcopy +from copy import deepcopy from collections.abc import Callable from datetime import datetime, timezone -from microsoft_agents.activity import TurnContextProtocol from microsoft_agents.activity import ( Activity, ActivityTypes, @@ -16,8 +15,9 @@ InputHints, Mention, ResourceResponse, - DeliveryModes, + TurnContextProtocol, ) +from microsoft_agents.activity._model_utils import pick_model, SkipNone from microsoft_agents.activity.entity.entity_types import EntityTypes from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity import microsoft_agents.hosting.core.telemetry.turn_context.spans as spans @@ -29,6 +29,16 @@ class TurnContext(TurnContextProtocol): _activity: Activity + _on_send_activities: list[ + Callable[[TurnContext, list[Activity], Callable], list[ResourceResponse]] + ] + _on_update_activity: list[ + Callable[[TurnContext, Activity, Callable], ResourceResponse] + ] + _on_delete_activity: list[ + Callable[[TurnContext, ConversationReference, Callable], None] + ] + def __init__( self, adapter_or_context, @@ -37,8 +47,9 @@ def __init__( ): """ Creates a new TurnContext instance. - :param adapter_or_context: - :param request: + :param adapter_or_context: The adapter instance or an existing TurnContext. + :param request: The incoming Activity. + :param identity: The ClaimsIdentity associated with the request. """ if isinstance(adapter_or_context, TurnContext): adapter_or_context.copy_to(self) @@ -48,15 +59,9 @@ def __init__( self._activity = request # exception thrown if None further down self.responses: list[Activity] = [] self._services: dict = {} - self._on_send_activities: Callable[ - ["TurnContext", list[Activity], Callable], list[ResourceResponse] - ] = [] - self._on_update_activity: Callable[ - ["TurnContext", Activity, Callable], ResourceResponse - ] = [] - self._on_delete_activity: Callable[ - ["TurnContext", ConversationReference, Callable], None - ] = [] + self._on_send_activities = [] + self._on_update_activity = [] + self._on_delete_activity = [] self._responded: bool = False self._identity = identity @@ -319,8 +324,8 @@ def on_delete_activity(self, handler) -> "TurnContext": self._on_delete_activity.append(handler) return self - async def _emit(self, plugins, arg, logic): - handlers = copy(plugins) + async def _emit(self, plugins: list[Callable], arg: Any, logic: Awaitable[Any]) -> Any: + handlers = list(plugins) async def emit_next(i: int): context = self @@ -346,13 +351,14 @@ async def send_trace_activity( value_type: str | None = None, label: str | None = None, ) -> ResourceResponse: - trace_activity = Activity( + trace_activity = pick_model( + Activity, type=ActivityTypes.trace, timestamp=datetime.now(timezone.utc), name=name, value=value, - value_type=value_type, - label=label, + value_type=SkipNone(value_type), + label=SkipNone(label), ) return await self.send_activity(trace_activity) From 567fcb81ffd75b8bd5d1d7b4acbaa345afa8c487 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Jul 2026 07:49:06 -0700 Subject: [PATCH 2/5] Formatting --- .../microsoft_agents/hosting/core/turn_context.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 1ae55940..799a5840 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 @@ -324,7 +324,9 @@ def on_delete_activity(self, handler) -> "TurnContext": self._on_delete_activity.append(handler) return self - async def _emit(self, plugins: list[Callable], arg: Any, logic: Awaitable[Any]) -> Any: + async def _emit( + self, plugins: list[Callable], arg: Any, logic: Awaitable[Any] + ) -> Any: handlers = list(plugins) async def emit_next(i: int): @@ -435,10 +437,9 @@ def remove_mention_text(activity: Activity, identifier: str) -> str: @staticmethod def get_mentions(activity: Activity) -> list[Mention]: - result: list[Mention] = [] - if activity.entities is not None: - for entity in activity.entities: - if entity.type.lower() == EntityTypes.MENTION: - result.append(entity) + """Get all mentions from the activity. - return result + :param activity: The activity to get mentions from. + :return: A list of Mention objects. + """ + return activity.get_mentions() From 39c1f257cd473f0134ed5d498ec0ba0f25d05a89 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Jul 2026 08:01:35 -0700 Subject: [PATCH 3/5] Letting pick_model handle generic types with return --- .../microsoft_agents/activity/_model_utils.py | 6 ++- .../hosting/core/turn_context.py | 50 ++++++++++++------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py index b747c8ed..b8ee2966 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py @@ -2,10 +2,12 @@ # Licensed under the MIT License. from abc import ABC -from typing import Any, Callable +from typing import Any, Callable, TypeVar from .agents_model import AgentsModel +AgentsModelT = TypeVar("AgentsModelT", bound=AgentsModel) + class ModelFieldHelper(ABC): """Base class for model field processing prior to initialization of an AgentsModel""" @@ -55,7 +57,7 @@ def pick_model_dict(**kwargs): return model_dict -def pick_model(model_class: type[AgentsModel], **kwargs) -> AgentsModel: +def pick_model(model_class: type[AgentsModelT], **kwargs) -> AgentsModelT: """Picks model fields from the given keyword arguments. Usage: 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 799a5840..4ac3b540 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 @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from __future__ import annotations import re @@ -12,6 +13,7 @@ Activity, ActivityTypes, ConversationReference, + DeliveryModes, InputHints, Mention, ResourceResponse, @@ -22,6 +24,18 @@ from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity import microsoft_agents.hosting.core.telemetry.turn_context.spans as spans +OnSendActivitiesHandler = Callable[ + ["TurnContext", list[Activity], Callable[[], Awaitable[list[ResourceResponse]]]], + Awaitable[list[ResourceResponse]], +] +OnUpdateActivityHandler = Callable[ + ["TurnContext", Activity, Callable[[], Awaitable[ResourceResponse]]], + Awaitable[ResourceResponse], +] +OnDeleteActivityHandler = Callable[ + ["TurnContext", ConversationReference, Callable[[], Awaitable]], Awaitable[None] +] + class TurnContext(TurnContextProtocol): # Same constant as in the BF Adapter, duplicating here to avoid circular dependency @@ -29,15 +43,9 @@ class TurnContext(TurnContextProtocol): _activity: Activity - _on_send_activities: list[ - Callable[[TurnContext, list[Activity], Callable], list[ResourceResponse]] - ] - _on_update_activity: list[ - Callable[[TurnContext, Activity, Callable], ResourceResponse] - ] - _on_delete_activity: list[ - Callable[[TurnContext, ConversationReference, Callable], None] - ] + _on_send_activities: list[OnSendActivitiesHandler] + _on_update_activity: list[OnUpdateActivityHandler] + _on_delete_activity: list[OnDeleteActivityHandler] def __init__( self, @@ -297,28 +305,31 @@ async def delete_activity(self, id_or_reference: str | ConversationReference): self.adapter.delete_activity(self, reference), ) - def on_send_activities(self, handler) -> "TurnContext": + def on_send_activities(self, handler: OnSendActivitiesHandler) -> TurnContext: """ Registers a handler to be notified of and potentially intercept the sending of activities. - :param handler: + :param handler: the handler to register + :type handler: OnSendActivitiesHandler :return: """ self._on_send_activities.append(handler) return self - def on_update_activity(self, handler) -> "TurnContext": + def on_update_activity(self, handler: OnUpdateActivityHandler) -> TurnContext: """ Registers a handler to be notified of and potentially intercept an activity being updated. - :param handler: + :param handler: the handler to register + :type handler: OnUpdateActivityHandler :return: """ self._on_update_activity.append(handler) return self - def on_delete_activity(self, handler) -> "TurnContext": + def on_delete_activity(self, handler: OnDeleteActivityHandler) -> "TurnContext": """ Registers a handler to be notified of and potentially intercept an activity being deleted. - :param handler: + :param handler: the handler to register + :type handler: OnDeleteActivityHandler :return: """ self._on_delete_activity.append(handler) @@ -379,17 +390,20 @@ def apply_conversation_reference( :return: """ activity.channel_id = reference.channel_id - activity.locale = reference.locale + if reference.locale: + activity.locale = reference.locale activity.service_url = reference.service_url activity.conversation = reference.conversation if is_incoming: - activity.from_property = reference.user + if reference.user: + activity.from_property = reference.user activity.recipient = reference.agent if reference.activity_id: activity.id = reference.activity_id else: activity.from_property = reference.agent - activity.recipient = reference.user + if reference.user: + activity.recipient = reference.user if reference.activity_id: activity.reply_to_id = reference.activity_id From 6643752354c98494a7e7bc9821bf9ec7606987d2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Jul 2026 08:45:58 -0700 Subject: [PATCH 4/5] Fixing TurnContext middleware implementation --- .../microsoft_agents/activity/activity.py | 2 +- .../hosting/core/turn_context.py | 64 ++++----- tests/hosting_core/test_turn_context.py | 125 +++++++++++++++++- 3 files changed, 159 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 c3881d30..71dbaa49 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -727,7 +727,7 @@ def get_mentions(self) -> list[Mention]: if not self.entities: return [] raw_mentions = [ - x for x in self.entities if x.type.lower() == EntityTypes.MENTION + x for x in self.entities if x.type.lower() == EntityTypes.MENTION.value ] return Activity._convert_entity_list(raw_mentions, Mention) 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 4ac3b540..5c6463bb 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 @@ -4,7 +4,7 @@ from __future__ import annotations import re -from typing import Optional, Awaitable, Any +from typing import Optional, Awaitable, Any, TypeVar, Generic, Protocol from copy import deepcopy from collections.abc import Callable @@ -33,9 +33,17 @@ Awaitable[ResourceResponse], ] OnDeleteActivityHandler = Callable[ - ["TurnContext", ConversationReference, Callable[[], Awaitable]], Awaitable[None] + ["TurnContext", ConversationReference, Callable[[], Awaitable[None]]], + Awaitable[None], ] +T = TypeVar("T") +_ArgT = TypeVar("_ArgT") + + +class _AsyncFunc(Protocol[T]): + def __call__(self) -> Awaitable[T]: ... + class TurnContext(TurnContextProtocol): # Same constant as in the BF Adapter, duplicating here to avoid circular dependency @@ -248,7 +256,7 @@ def activity_validator(activity: Activity) -> Activity: ] # send activities through adapter - async def logic(): + async def logic() -> list[ResourceResponse]: nonlocal sent_non_trace_activity if self.activity.delivery_mode == DeliveryModes.expect_replies: @@ -272,7 +280,7 @@ async def logic(): self.responded = True return responses - return await self._emit(self._on_send_activities, output, logic()) + return await self._emit(self._on_send_activities, output, logic) async def update_activity(self, activity: Activity): """ @@ -285,7 +293,7 @@ async def update_activity(self, activity: Activity): return await self._emit( self._on_update_activity, TurnContext.apply_conversation_reference(activity, reference), - self.adapter.update_activity(self, activity), + lambda: self.adapter.update_activity(self, activity), ) async def delete_activity(self, id_or_reference: str | ConversationReference): @@ -299,10 +307,11 @@ async def delete_activity(self, id_or_reference: str | ConversationReference): reference.activity_id = id_or_reference else: reference = id_or_reference + return await self._emit( self._on_delete_activity, reference, - self.adapter.delete_activity(self, reference), + lambda: self.adapter.delete_activity(self, reference), ) def on_send_activities(self, handler: OnSendActivitiesHandler) -> TurnContext: @@ -325,7 +334,7 @@ def on_update_activity(self, handler: OnUpdateActivityHandler) -> TurnContext: self._on_update_activity.append(handler) return self - def on_delete_activity(self, handler: OnDeleteActivityHandler) -> "TurnContext": + def on_delete_activity(self, handler: OnDeleteActivityHandler) -> TurnContext: """ Registers a handler to be notified of and potentially intercept an activity being deleted. :param handler: the handler to register @@ -336,26 +345,26 @@ def on_delete_activity(self, handler: OnDeleteActivityHandler) -> "TurnContext": return self async def _emit( - self, plugins: list[Callable], arg: Any, logic: Awaitable[Any] + self, + handlers: list[Callable[[TurnContext, _ArgT, _AsyncFunc[T]], Awaitable[T]]], + arg: _ArgT, + logic: _AsyncFunc[T], ) -> Any: - handlers = list(plugins) - - async def emit_next(i: int): - context = self - try: - if i < len(handlers): + handlers = list(handlers) - async def next_handler(): - await emit_next(i + 1) + async def emit_next(i: int) -> T: + call_next: _AsyncFunc[T] + if i + 1 < len(handlers): + call_next = lambda: emit_next(i + 1) + else: + call_next = logic - await handlers[i](context, arg, next_handler) + return await handlers[i](self, arg, call_next) - except Exception as error: - raise error + if len(handlers) > 0: + return await emit_next(0) - await emit_next(0) - # logic does not use parentheses because it's a coroutine - return await logic + return await logic() async def send_trace_activity( self, @@ -427,19 +436,14 @@ def remove_recipient_mention(activity: Activity) -> str: @staticmethod def remove_mention_text(activity: Activity, identifier: str) -> str: """ - TODO: manual test for this function as it was replaced from manual code to re.escape - - Previously: This was a copy of the re.escape function in Python 3.8. This was done - because the 3.6.x version didn't escape in the same way and handling - agent names with regex characters in it would fail in TurnContext.remove_mention_text - without escaping the text. + Remove a mention matching the given account identifier from activity.text. """ mentions = TurnContext.get_mentions(activity) for mention in mentions: - if mention.additional_properties["mentioned"]["id"] == identifier: + if mention.mentioned.id == identifier: mention_name_match = re.match( r"(.*?)<\/at>", - re.escape(mention.additional_properties.get("text", "")), + re.escape(mention.text or ""), re.IGNORECASE, ) if mention_name_match: diff --git a/tests/hosting_core/test_turn_context.py b/tests/hosting_core/test_turn_context.py index f4532653..6f2a34d5 100644 --- a/tests/hosting_core/test_turn_context.py +++ b/tests/hosting_core/test_turn_context.py @@ -52,6 +52,26 @@ async def delete_activity(self, context, reference): assert reference.activity_id == ACTIVITY.id +class _RecordingAdapter(_SimpleTestingAdapter): + def __init__(self): + self.sent_batches = [] + self.updated_activities = [] + self.deleted_references = [] + + async def send_activities(self, context, activities) -> list[ResourceResponse]: + self.sent_batches.append(activities) + return [ + ResourceResponse(id=f"sent-{index}") for index, _ in enumerate(activities) + ] + + async def update_activity(self, context, activity): + self.updated_activities.append(activity) + return ResourceResponse(id=activity.id) + + async def delete_activity(self, context, reference): + self.deleted_references.append(reference) + + class TestTurnContext: def test_should_create_context_with_request_and_adapter(self): TurnContext(_SimpleTestingAdapter(), ACTIVITY) @@ -217,6 +237,71 @@ async def send_handler(context, activities, next_handler_coroutine): await context.send_activity(ACTIVITY) assert called is True + @pytest.mark.asyncio + async def test_on_send_activities_handler_can_await_next_and_return_result(self): + adapter = _RecordingAdapter() + context = TurnContext(adapter, ACTIVITY) + events = [] + + async def send_handler(context, activities, next_handler): + events.append(("before", len(adapter.sent_batches))) + responses = await next_handler() + events.append(("after", responses[0].id, len(adapter.sent_batches))) + return responses + + context.on_send_activities(send_handler) + + response = await context.send_activity("hello") + + assert response.id == "sent-0" + assert events == [("before", 0), ("after", "sent-0", 1)] + assert context.responded is True + + @pytest.mark.asyncio + async def test_on_send_activities_handlers_unwind_in_reverse_order(self): + adapter = _RecordingAdapter() + context = TurnContext(adapter, ACTIVITY) + events = [] + + async def first_handler(context, activities, next_handler): + events.append("first-before") + responses = await next_handler() + events.append("first-after") + return responses + + async def second_handler(context, activities, next_handler): + events.append("second-before") + responses = await next_handler() + events.append("second-after") + return responses + + context.on_send_activities(first_handler) + context.on_send_activities(second_handler) + + await context.send_activity("hello") + + assert events == [ + "first-before", + "second-before", + "second-after", + "first-after", + ] + + @pytest.mark.asyncio + async def test_on_send_activities_handler_can_short_circuit_adapter_send(self): + adapter = _RecordingAdapter() + context = TurnContext(adapter, ACTIVITY) + + async def send_handler(context, activities, next_handler): + return [ResourceResponse(id="middleware-response")] + + context.on_send_activities(send_handler) + + response = await context.send_activity("hello") + + assert response.id == "middleware-response" + assert adapter.sent_batches == [] + @pytest.mark.asyncio async def test_should_call_on_update_activity_handler_before_update(self): context = TurnContext(_SimpleTestingAdapter(), ACTIVITY) @@ -234,6 +319,44 @@ async def update_handler(context, activity, next_handler_coroutine): await context.update_activity(ACTIVITY) assert called is True + @pytest.mark.asyncio + async def test_on_update_activity_handler_can_await_next_and_return_result(self): + adapter = _RecordingAdapter() + context = TurnContext(adapter, ACTIVITY) + + async def update_handler(context, activity, next_handler): + result = await next_handler() + return ResourceResponse(id=f"wrapped-{result.id}") + + context.on_update_activity(update_handler) + activity = MessageFactory.text("updated") + activity.id = "activity-to-update" + + result = await context.update_activity(activity) + + assert result.id == "wrapped-activity-to-update" + assert adapter.updated_activities[0].id == "activity-to-update" + + @pytest.mark.asyncio + async def test_on_delete_activity_handler_next_invokes_adapter_delete(self): + adapter = _RecordingAdapter() + context = TurnContext(adapter, ACTIVITY) + called_next = False + + async def delete_handler(context, reference, next_handler): + nonlocal called_next + assert adapter.deleted_references == [] + await next_handler() + called_next = True + assert adapter.deleted_references[0].activity_id == ACTIVITY.id + + context.on_delete_activity(delete_handler) + + result = await context.delete_activity(ACTIVITY.id) + + assert result is None + assert called_next is True + @pytest.mark.asyncio async def test_update_activity_should_apply_conversation_reference(self): activity_id = "activity ID" @@ -246,7 +369,7 @@ async def update_handler(context, activity, next_handler_coroutine): assert context is not None assert activity.id == activity_id assert activity.conversation.id == ACTIVITY.conversation.id - await next_handler_coroutine() + return await next_handler_coroutine() context.on_update_activity(update_handler) new_activity = MessageFactory.text("test text") From 45abc4b2aa1f052845e9987d7db3bf69027ab0d5 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Jul 2026 08:54:57 -0700 Subject: [PATCH 5/5] Addressing more PR feedback --- .../hosting/core/turn_context.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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 5c6463bb..edf639f1 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 @@ -4,7 +4,7 @@ from __future__ import annotations import re -from typing import Optional, Awaitable, Any, TypeVar, Generic, Protocol +from typing import Optional, Awaitable, TypeVar, Protocol from copy import deepcopy from collections.abc import Callable @@ -349,7 +349,15 @@ async def _emit( handlers: list[Callable[[TurnContext, _ArgT, _AsyncFunc[T]], Awaitable[T]]], arg: _ArgT, logic: _AsyncFunc[T], - ) -> Any: + ) -> T: + """Emits an event to the registered handlers, allowing them to intercept and modify the behavior of the logic function. + + :param handlers: The list of registered handlers to invoke. + :param arg: The argument to pass to the handlers. + :param logic: The logic function to invoke after all handlers have been called. + :return: The result of the logic function, potentially modified by the handlers. + """ + handlers = list(handlers) async def emit_next(i: int) -> T: @@ -404,15 +412,13 @@ def apply_conversation_reference( activity.service_url = reference.service_url activity.conversation = reference.conversation if is_incoming: - if reference.user: - activity.from_property = reference.user + activity.from_property = reference.user activity.recipient = reference.agent if reference.activity_id: activity.id = reference.activity_id else: activity.from_property = reference.agent - if reference.user: - activity.recipient = reference.user + activity.recipient = reference.user if reference.activity_id: activity.reply_to_id = reference.activity_id @@ -440,7 +446,7 @@ def remove_mention_text(activity: Activity, identifier: str) -> str: """ mentions = TurnContext.get_mentions(activity) for mention in mentions: - if mention.mentioned.id == identifier: + if mention.mentioned and mention.mentioned.id == identifier: mention_name_match = re.match( r"(.*?)<\/at>", re.escape(mention.text or ""),