diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 54b49fab0..74b1cf167 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -199,6 +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) @field_validator("entities", mode="before") @classmethod @@ -300,12 +301,13 @@ def apply_conversation_reference( :returns: This activity, updated with the delivery information. .. remarks:: - Call GetConversationReference on an incoming activity to get a conversation reference that you can then use to update an + Call get_conversation_reference on an incoming activity to get a conversation reference that you can then use to update an outgoing activity with the correct delivery information. """ self.channel_id = reference.channel_id 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 @@ -1043,6 +1045,7 @@ def get_conversation_reference( ), locale=self.locale, service_url=self.service_url, + request_id=self.request_id, ), ) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py index 46f8da8c6..dc69d2b6a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py @@ -53,6 +53,7 @@ class ConversationReference(AgentsModel): channel_id: Optional[ChannelId] = None locale: Optional[NonEmptyString] = None service_url: NonEmptyString = None + request_id: str | None = None def get_continuation_activity(self) -> Activity: from .activity import Activity @@ -68,4 +69,5 @@ def get_continuation_activity(self) -> Activity: recipient=self.agent, from_property=SkipNone(self.user), relates_to=self, + request_id=self.request_id, ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_http_adapter_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_http_adapter_base.py index 4b928a8d4..ec8173431 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_http_adapter_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_http_adapter_base.py @@ -4,6 +4,7 @@ """Base HTTP adapter with shared processing logic.""" from abc import ABC +from uuid import uuid4 from http import HTTPStatus from traceback import format_exc @@ -116,6 +117,8 @@ async def process_request( ) activity: Activity = Activity.model_validate(body) + activity.request_id = str(uuid4()) + span.share(activity=activity) # Get claims identity (default to anonymous if not set by middleware) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py index b7895baf2..b6fa4ab98 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py @@ -54,6 +54,7 @@ async def post_activity( channel_id=activity_copy.channel_id, locale=activity_copy.locale, conversation=copy(activity_copy.conversation), + request_id=activity_copy.request_id, ) activity_copy.conversation.id = conversation_id 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 ddbeaea5a..f98d9d3bd 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 @@ -360,6 +360,8 @@ def apply_conversation_reference( activity.locale = reference.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 diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index f073f921c..c1d37e0f3 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -84,6 +84,13 @@ def test_get_conversation_reference(self, activity): assert activity.locale == conversation_reference.locale assert activity.service_url == conversation_reference.service_url + def test_get_conversation_reference_propagates_request_id(self, activity): + activity.request_id = "request-123" + + conversation_reference = activity.get_conversation_reference() + + assert conversation_reference.request_id == "request-123" + def test_get_conversation_reference_force_base_channel(self, activity): activity.channel_id = "msteams:copilot-web" @@ -198,6 +205,25 @@ def test_apply_conversation_reference_is_incoming(self): assert conversation_reference.activity_id == activity.id assert activity.locale == activity_to_send.locale + def test_apply_conversation_reference_propagates_request_id(self): + activity = create_test_activity("en-us") + conversation_reference = ConversationReference( + channel_id="cr_123", + service_url="cr_serviceUrl", + conversation=ConversationAccount(id="cr_456"), + user=ChannelAccount(id="cr_abc"), + agent=ChannelAccount(id="cr_def"), + activity_id="cr_12345", + locale="en-us", + request_id="request-123", + ) + + activity_to_send = activity.apply_conversation_reference( + conversation_reference, is_incoming=True + ) + + assert activity_to_send.request_id == "request-123" + @pytest.mark.parametrize("locale", ["EN-US", "en-uS"]) def test_apply_conversation_reference(self, locale): activity = create_test_activity(locale) diff --git a/tests/activity/test_conversation_reference.py b/tests/activity/test_conversation_reference.py index 70d042227..3c3f04de9 100644 --- a/tests/activity/test_conversation_reference.py +++ b/tests/activity/test_conversation_reference.py @@ -9,7 +9,9 @@ ) -def _create_conversation_reference(user: ChannelAccount | None = None): +def _create_conversation_reference( + user: ChannelAccount | None = None, request_id: str | None = None +): return ConversationReference( activity_id="activity-123", channel_id="msteams", @@ -18,6 +20,7 @@ def _create_conversation_reference(user: ChannelAccount | None = None): user=user if user is not None else ChannelAccount(id="user-123", name="User"), agent=ChannelAccount(id="agent-123", name="Agent"), locale="en-US", + request_id=request_id, ) @@ -47,6 +50,22 @@ def test_get_continuation_activity_generates_new_id_each_time(): assert first_activity.id != second_activity.id +def test_get_continuation_activity_propagates_request_id(): + conversation_reference = _create_conversation_reference(request_id="request-123") + + continuation_activity = conversation_reference.get_continuation_activity() + + assert continuation_activity.request_id == "request-123" + + +def test_get_continuation_activity_request_id_defaults_to_none(): + conversation_reference = _create_conversation_reference() + + continuation_activity = conversation_reference.get_continuation_activity() + + assert continuation_activity.request_id is None + + def test_get_continuation_activity_omits_sender_when_user_is_missing(): conversation_reference = _create_conversation_reference(user=None) conversation_reference.user = None diff --git a/tests/hosting_core/telemetry/test_http_adapter_telemetry.py b/tests/hosting_core/telemetry/test_http_adapter_telemetry.py index 0c07e998f..ac08b12ac 100644 --- a/tests/hosting_core/telemetry/test_http_adapter_telemetry.py +++ b/tests/hosting_core/telemetry/test_http_adapter_telemetry.py @@ -7,10 +7,13 @@ which metrics fire for each code path through process_request. """ +import uuid + import pytest from unittest.mock import AsyncMock, MagicMock from opentelemetry import trace +from microsoft_agents.activity import Activity from microsoft_agents.hosting.core import HttpAdapterBase from microsoft_agents.hosting.core.telemetry.adapter import constants from microsoft_agents.hosting.core.telemetry import attributes diff --git a/tests/hosting_core/test_turn_context.py b/tests/hosting_core/test_turn_context.py index 23433c7ed..6190301b6 100644 --- a/tests/hosting_core/test_turn_context.py +++ b/tests/hosting_core/test_turn_context.py @@ -388,6 +388,7 @@ def test_get_conversation_reference_should_return_valid_reference(self): assert reference.channel_id == ACTIVITY.channel_id assert reference.locale == ACTIVITY.locale assert reference.service_url == ACTIVITY.service_url + assert reference.request_id == ACTIVITY.request_id def test_apply_conversation_reference_should_return_prepare_reply_when_is_incoming_is_false( self, @@ -419,6 +420,42 @@ def test_apply_conversation_reference_when_is_incoming_is_true_should_not_prepar assert reply.service_url == ACTIVITY.service_url assert reply.channel_id == ACTIVITY.channel_id + def test_apply_conversation_reference_propagates_request_id_when_outgoing( + self, + ): + reference = ACTIVITY.get_conversation_reference() + reference.request_id = "request-123" + + reply = TurnContext.apply_conversation_reference( + Activity(type="message", text="reply"), reference + ) + + assert reply.request_id == "request-123" + + def test_apply_conversation_reference_propagates_request_id_when_incoming( + self, + ): + reference = ACTIVITY.get_conversation_reference() + reference.request_id = "request-123" + + reply = TurnContext.apply_conversation_reference( + Activity(type="message", text="reply"), reference, True + ) + + assert reply.request_id == "request-123" + + def test_apply_conversation_reference_clears_request_id_when_reference_has_none( + self, + ): + reference = ACTIVITY.get_conversation_reference() + assert reference.request_id is None + + reply = TurnContext.apply_conversation_reference( + Activity(type="message", text="reply", request_id="stale-id"), reference + ) + + assert reply.request_id is None + @pytest.mark.asyncio async def test_should_get_conversation_reference_using_get_reply_conversation_reference( self,