From 1a14df4073cfe72a6bd4ffe106e34d34e960f1c3 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 28 Jul 2026 09:28:42 -0700 Subject: [PATCH 1/8] Lazy creation of Teams ApiClient --- .../hosting/msteams/_teams_api_client.py | 22 +++-------- .../hosting/msteams/teams_agent_extension.py | 11 +++--- .../hosting/msteams/teams_turn_context.py | 7 +++- .../mocks/mock_user_token_client.py | 4 +- tests/hosting_msteams/test_internal.py | 38 ------------------- .../test_teams_agent_extension.py | 1 - .../test_teams_turn_context.py | 29 ++++++++++++++ 7 files changed, 45 insertions(+), 67 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py index dbbd0c7c..f022fa8c 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py @@ -17,23 +17,9 @@ ) -def _get_teams_api_client(context: TurnContext) -> ApiClient: - """ - Get the cached Teams API client from the context. - - :param context: The turn context. - :return: The cached Teams API client. - :raises ValueError: If the Teams API client is not found. - """ - api_client = context.services.get(ApiClient) - if isinstance(api_client, ApiClient): - return api_client - raise ValueError("Unable to retrieve Teams API client.") - - def _set_teams_api_client( context: TurnContext, connection_manager: Connections -) -> None: +) -> ApiClient: """ Set the Teams API client in the context if it is not already set. @@ -41,8 +27,9 @@ def _set_teams_api_client( :param connection_manager: The connection manager. """ - if context.services.has(ApiClient): - return + api_client = context.services.get(ApiClient) + if api_client is not None: + return api_client headers = { "Accept": "application/json", @@ -74,3 +61,4 @@ async def token_factory() -> str: ) context.services.set(ApiClient, api_client) + return api_client diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 3070843b..620ce313 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -57,10 +57,7 @@ _common_get_app_graph_client_for_connection, ) -from ._teams_api_client import ( - _get_teams_api_client, - _set_teams_api_client, -) +from ._teams_api_client import _set_teams_api_client from ._utils import _try_get_channel_data from .teams_activity import TeamsActivity @@ -124,7 +121,6 @@ def _configure_app(self): async def on_before_turn(context: TurnContext, state: StateT) -> bool: if context.activity.channel_id == Channels.ms_teams: - _set_teams_api_client(context, self._app.connection_manager) # caches the deserialized version of ChannelData context.activity.channel_data = _try_get_channel_data(context.activity) return True @@ -301,7 +297,10 @@ def get_teams_api_client(self, context: TurnContext) -> ApiClient: :return: The Teams API client. """ - return _get_teams_api_client(context) + api_client = context.services.get(ApiClient) + if not api_client: + return _set_teams_api_client(context, self._app.connection_manager) + return api_client def get_graph_client( 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 81509842..beef6051 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 @@ -28,7 +28,7 @@ _common_get_app_graph_client, _common_get_app_graph_client_for_connection, ) -from ._teams_api_client import _get_teams_api_client, _set_teams_api_client +from ._teams_api_client import _set_teams_api_client from .teams_activity import TeamsActivity @@ -96,7 +96,10 @@ def activity(self) -> TeamsActivity: @property def api_client(self) -> ApiClient: """Get the API client for the Teams turn context.""" - return _get_teams_api_client(self) + api_client = self._services.get(ApiClient) + if not api_client: + return _set_teams_api_client(self, self._app.connection_manager) + return api_client @staticmethod def _make_targeted_activity(activity: Activity) -> None: diff --git a/tests/_common/testing_objects/mocks/mock_user_token_client.py b/tests/_common/testing_objects/mocks/mock_user_token_client.py index 273b69c2..5bd6495c 100644 --- a/tests/_common/testing_objects/mocks/mock_user_token_client.py +++ b/tests/_common/testing_objects/mocks/mock_user_token_client.py @@ -65,9 +65,7 @@ async def get_token_or_sign_in_resource( state, ) - mock_user_token_client.get_user_token = mocker.AsyncMock( - side_effect=get_user_token - ) + mock_user_token_client.get_user_token = mocker.AsyncMock(side_effect=get_user_token) mock_user_token_client.sign_out_user = mocker.AsyncMock(side_effect=sign_out_user) mock_user_token_client.exchange_token = mocker.AsyncMock(side_effect=exchange_token) mock_user_token_client.get_token_or_sign_in_resource = mocker.AsyncMock( diff --git a/tests/hosting_msteams/test_internal.py b/tests/hosting_msteams/test_internal.py index 1ea3a2f9..3a8201fe 100644 --- a/tests/hosting_msteams/test_internal.py +++ b/tests/hosting_msteams/test_internal.py @@ -13,49 +13,11 @@ ) if is_supported_version: - from microsoft_teams.api import ApiClient - - from microsoft_agents.hosting.msteams._teams_api_client import ( - _get_teams_api_client, - ) from microsoft_agents.hosting.msteams.errors.error_resources import ( TeamsErrorResources, ) -class _FakeServices: - def __init__(self, values=None): - self._values = values or {} - - def get(self, key): - return self._values.get(key) - - -class _FakeContext: - """Minimal stand-in exposing only the ``services`` accessor reads.""" - - def __init__(self, services): - self.services = services - - -class TestGetTeamsApiClient: - - def test_returns_cached_api_client(self): - client = ApiClient("https://smba.trafficmanager.net/teams/") - ctx = _FakeContext(_FakeServices({ApiClient: client})) - assert _get_teams_api_client(ctx) is client - - def test_raises_when_missing(self): - ctx = _FakeContext(_FakeServices()) - with pytest.raises(ValueError, match="Teams API client"): - _get_teams_api_client(ctx) - - def test_raises_when_wrong_type(self): - ctx = _FakeContext(_FakeServices({ApiClient: object()})) - with pytest.raises(ValueError, match="Teams API client"): - _get_teams_api_client(ctx) - - class TestTeamsErrorResources: def _error_messages(self): diff --git a/tests/hosting_msteams/test_teams_agent_extension.py b/tests/hosting_msteams/test_teams_agent_extension.py index c8da5a82..74cb2982 100644 --- a/tests/hosting_msteams/test_teams_agent_extension.py +++ b/tests/hosting_msteams/test_teams_agent_extension.py @@ -125,7 +125,6 @@ async def test_teams_channel_deserializes_channel_data(self): assert result is True assert isinstance(activity.channel_data, ChannelData) assert activity.channel_data.channel.id == "c1" - assert ctx.services.has(ApiClient) @pytest.mark.asyncio async def test_teams_channel_without_channel_data_sets_none(self): diff --git a/tests/hosting_msteams/test_teams_turn_context.py b/tests/hosting_msteams/test_teams_turn_context.py index 73df5720..a2e6009f 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 types import SimpleNamespace + import pytest from .helpers import is_supported_version @@ -17,11 +19,19 @@ Activity, ActivityTreatmentTypes, Entity, + ResourceResponse, ) + from microsoft_teams.api import ApiClient + from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.msteams import TeamsTurnContext +class _StubAdapter: + async def send_activities(self, context, activities): + return [ResourceResponse()] * len(activities) + + class TestMakeTargetedActivity: """``_make_targeted_activity`` mutates the supplied activity in place (returns None) by appending a TARGETED activity-treatment entity.""" @@ -52,3 +62,22 @@ def test_each_call_appends_another_treatment(self): if getattr(e, "treatment", None) == ActivityTreatmentTypes.TARGETED ] assert len(treatments) == 2 + + +class TestTeamsApiClient: + + def test_api_client_returns_cached_client(self): + activity = Activity( + type="message", + channel_id="msteams", + service_url="https://smba.trafficmanager.net/teams/", + ) + context = TurnContext(_StubAdapter(), activity) + client = object.__new__(ApiClient) + context.services.set(ApiClient, client) + + teams_context = TeamsTurnContext( + context, SimpleNamespace(connection_manager=object()) + ) + + assert teams_context.api_client is client From 7595580cb0d03625d34ac470d12c7efd4e6aa9b9 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 28 Jul 2026 13:10:53 -0700 Subject: [PATCH 2/8] Adding _BaseClient to handle header propagation across all rest clients --- .../microsoft_agents/hosting/core/__init__.py | 10 ++ .../hosting/core/app/agent_application.py | 21 ++- .../hosting/core/channel_service_adapter.py | 4 +- .../hosting/core/connector/client/__init__.py | 3 + .../core/connector/client/_base_client.py | 90 ++++++++++++ .../core/connector/client/agent_sign_in.py | 21 ++- .../core/connector/client/connector_client.py | 72 +++++++--- .../core/connector/client/user_token.py | 28 ++-- .../connector/mcs/mcs_connector_client.py | 7 +- .../core/header_propagation/__init__.py | 13 ++ .../agentic_header_provider.py | 76 ++++++++++ .../header_propagation_context.py | 77 ++++++++++ .../header_value_provider.py | 25 ++++ .../header_propagation/__init__.py | 0 .../test_agentic_header_provider.py | 131 ++++++++++++++++++ .../test_channel_service_adapter.py | 32 ++++- 16 files changed, 564 insertions(+), 46 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/agentic_header_provider.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_propagation_context.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_value_provider.py create mode 100644 tests/hosting_core/header_propagation/__init__.py create mode 100644 tests/hosting_core/header_propagation/test_agentic_header_provider.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py index ecb9b153..be36ce81 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py @@ -89,6 +89,13 @@ get_product_info, ) +# Header propagation +from .header_propagation import ( + HeaderValueProvider, + AgenticHeaderProvider, + HeaderPropagationContext, +) + # State management from .state.agent_state import AgentState from .state.state_property_accessor import StatePropertyAccessor @@ -168,6 +175,9 @@ "TeamsConnectorClient", "ConnectorClientBase", "get_product_info", + "HeaderValueProvider", + "AgenticHeaderProvider", + "HeaderPropagationContext", "AgentState", "StatePropertyAccessor", "UserState", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index edd17d2e..1996f67c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -36,6 +36,7 @@ from ..agent import Agent from ..authorization import Connections +from ..header_propagation import AgenticHeaderProvider, HeaderPropagationContext from .app_error import ApplicationError from .app_options import ApplicationOptions @@ -108,6 +109,14 @@ def __init__( self._internal_before_turn = [] self._internal_after_turn = [] + # Human-friendly agent name surfaced on outgoing agentic headers. + # Falls back to the application class name when not explicitly provided. + raw_agent_name = kwargs.get("agent_name") or type(self).__name__ + sanitized_agent_name = re.sub( + r"[^A-Za-z0-9 ._-]", "", str(raw_agent_name) + ).strip() + self._agent_name = sanitized_agent_name or type(self).__name__ + configuration = kwargs if not options: @@ -184,7 +193,8 @@ def __init__( auth_options = { key: value for key, value in configuration.items() - if key not in ["storage", "connection_manager", "handlers"] + if key + not in ["storage", "connection_manager", "handlers", "agent_name"] } self._auth = Authorization( storage=self._storage, @@ -810,6 +820,15 @@ async def on_turn(self, context: TurnContext): async def _on_turn(self, context: TurnContext): try: + # Register Activity-derived header provider for agentic requests so + # that agent identity headers are propagated on outgoing requests + # made while processing this turn. + HeaderPropagationContext.reset() + if context.activity and context.activity.is_agentic_request(): + HeaderPropagationContext.register( + AgenticHeaderProvider(context.activity, self._agent_name) + ) + with spans.AppOnTurn(context) as on_turn_span: use_typing = ( self._options.start_typing_timer diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index f8fa4f0b..77fa371f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -5,7 +5,7 @@ from abc import ABC from http import HTTPStatus -from typing import Awaitable, Callable, Optional +from typing import Awaitable, Callable, Optional, cast from uuid import uuid4 from microsoft_agents.activity import ( @@ -26,9 +26,7 @@ ) from microsoft_agents.hosting.core.connector import ( ConnectorClientBase, - ConnectorClient, UserTokenClientBase, - UserTokenClient, ) from microsoft_agents.hosting.core.authorization import ( AuthenticationConstants, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/__init__.py index c6446c9c..4a793b15 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .connector_client import ConnectorClient from .user_token_client import UserTokenClient diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py new file mode 100644 index 00000000..15fb5d54 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging +from typing import Any, Callable + +from aiohttp import ClientSession + +from ...header_propagation import HeaderPropagationContext + +logger = logging.getLogger(__name__) + + +class _ClientSessionWrapper: + """ClientSession wrapper that applies propagated headers per request.""" + + def __init__(self, session: ClientSession): + self._session = session + + def __getattr__(self, name: str) -> Any: + return getattr(self._session, name) + + def _separate_headers(self, **kwargs) -> tuple[dict, dict]: + """ + Separate headers from other keyword arguments. + + :param kwargs: Keyword arguments that may contain headers. + :return: A tuple containing the headers and the remaining keyword arguments. + """ + headers = dict(kwargs.get("headers") or {}) + kwargs_without_headers = {k: v for k, v in kwargs.items() if k != "headers"} + return headers, kwargs_without_headers + + def _apply_headers(self, headers: dict) -> None: + """ + Apply propagated headers to the request headers. + + :param headers: Headers to apply. + """ + propagated_headers = HeaderPropagationContext.collect_headers() + if propagated_headers: + headers.update(propagated_headers) + logger.debug( + "Applying propagated headers: %s", list(propagated_headers.keys()) + ) + + def _call_with_headers(self, method: Callable, *args, **kwargs): + """ + Call the specified method on the underlying session with headers applied. + + :param method: The HTTP method to call. + :param args: Positional arguments for the method. + :param kwargs: Keyword arguments for the method. + :return: The result of the method call. + """ + headers, kwargs_without_headers = self._separate_headers(**kwargs) + self._apply_headers(headers) + return method(*args, headers=headers, **kwargs_without_headers) + + def request(self, *args, **kwargs): + return self._call_with_headers(self._session.request, *args, **kwargs) + + def get(self, *args, **kwargs): + return self._call_with_headers(self._session.get, *args, **kwargs) + + def post(self, *args, **kwargs): + return self._call_with_headers(self._session.post, *args, **kwargs) + + def put(self, *args, **kwargs): + return self._call_with_headers(self._session.put, *args, **kwargs) + + def delete(self, *args, **kwargs): + return self._call_with_headers(self._session.delete, *args, **kwargs) + + def patch(self, *args, **kwargs): + return self._call_with_headers(self._session.patch, *args, **kwargs) + + +class _BaseClient: + + def __init__(self, client: ClientSession): + self._client = client + + def _wrapped_client(self) -> _ClientSessionWrapper: + """ + Returns the wrapped client session. + + :return: The wrapped ClientSession. + """ + return _ClientSessionWrapper(self._client) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/agent_sign_in.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/agent_sign_in.py index ee1ddefe..31b40754 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/agent_sign_in.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/agent_sign_in.py @@ -7,15 +7,26 @@ from microsoft_agents.activity import SignInResource from ..telemetry import user_token_client_spans as spans from ..agent_sign_in_base import AgentSignInBase +from ._base_client import _BaseClient logger = logging.getLogger(__name__) -class AgentSignIn(AgentSignInBase): +class AgentSignIn(AgentSignInBase, _BaseClient): """Implementation of agent sign-in operations.""" def __init__(self, client: ClientSession): - self.client = client + _BaseClient.__init__(self, client) + + @property + def client(self) -> ClientSession: + """Get the underlying aiohttp ClientSession.""" + return self._client + + @client.setter + def client(self, value: ClientSession): + """Set the underlying aiohttp ClientSession.""" + self._client = value async def get_sign_in_url( self, @@ -45,7 +56,8 @@ async def get_sign_in_url( "AgentSignIn.get_sign_in_url(): Getting sign-in URL with params: %s", params, ) - async with self.client.get( + + async with self._wrapped_client().get( "api/agentsignin/getSignInUrl", params=params ) as response: if response.status >= 300: @@ -83,7 +95,8 @@ async def get_sign_in_resource( "AgentSignIn.get_sign_in_resource(): Getting sign-in resource with params: %s", params, ) - async with self.client.get( + + async with self._wrapped_client().get( "api/botsignin/getSignInResource", params=params ) as response: span.share(http_method="GET", status_code=response.status) 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 28562e7e..0080c4d6 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 @@ -26,6 +26,7 @@ from ..conversations_base import ConversationsBase from ..get_product_info import get_product_info from ..telemetry import connector_spans as spans +from ._base_client import _BaseClient logger = logging.getLogger(__name__) @@ -61,10 +62,11 @@ def normalize_outgoing_activity(data: Any) -> Any: return data -class AttachmentsOperations(AttachmentsBase): +class AttachmentsOperations(AttachmentsBase, _BaseClient): def __init__(self, client: ClientSession): - self.client = client + _BaseClient.__init__(self, client) + self.client = self._client async def get_attachment_info(self, attachment_id: str) -> AttachmentInfo: """ @@ -81,7 +83,8 @@ async def get_attachment_info(self, attachment_id: str) -> AttachmentInfo: url = f"v3/attachments/{attachment_id}" logger.info("Getting attachment info for ID: %s", attachment_id) - async with self.client.get(url) as response: + + async with self._wrapped_client().get(url) as response: span.share(http_method="GET", status_code=response.status) if response.status >= 300: @@ -123,7 +126,8 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: logger.info( "Getting attachment for ID: %s, View ID: %s", attachment_id, view_id ) - async with self.client.get(url) as response: + + async with self._wrapped_client().get(url) as response: span.share(http_method="GET", status_code=response.status) if response.status >= 300: @@ -136,12 +140,22 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: return BytesIO(data) -class ConversationsOperations(ConversationsBase): +class ConversationsOperations(ConversationsBase, _BaseClient): def __init__(self, client: ClientSession, **kwargs): - self.client = client + _BaseClient.__init__(self, client) self._max_conversation_id_length = kwargs.get("max_conversation_id_length", 150) + @property + def client(self) -> ClientSession: + """Get the underlying aiohttp ClientSession.""" + return self._client + + @client.setter + def client(self, value: ClientSession): + """Set the underlying aiohttp ClientSession.""" + self._client = value + def _normalize_conversation_id( self, conversation_id: str, activity: Optional[Activity] = None ) -> str: @@ -189,7 +203,10 @@ async def get_conversations( logger.info( "Getting conversations with continuation token: %s", continuation_token ) - async with self.client.get("v3/conversations", params=params) as response: + + async with self._wrapped_client().get( + "v3/conversations", params=params + ) as response: span.share(http_method="GET", status_code=response.status) if response.status >= 300: @@ -214,7 +231,8 @@ async def create_conversation( """ with spans.ConnectorCreateConversation() as span: logger.info("Creating a new conversation") - async with self.client.post( + + async with self._wrapped_client().post( "v3/conversations", json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), ) as response: @@ -260,7 +278,7 @@ async def reply_to_activity( body.type, ) - async with self.client.post( + async with self._wrapped_client().post( url, json=body.model_dump( by_alias=True, exclude_unset=True, exclude_none=True, mode="json" @@ -320,7 +338,8 @@ async def send_to_conversation( conversation_id, body.type, ) - async with self.client.post( + + async with self._wrapped_client().post( url, json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), ) as response: @@ -368,7 +387,8 @@ async def update_activity( conversation_id, body.type, ) - async with self.client.put( + + async with self._wrapped_client().put( url, json=body.model_dump(by_alias=True, exclude_unset=True), ) as response: @@ -405,7 +425,8 @@ async def delete_activity(self, conversation_id: str, activity_id: str) -> None: activity_id, conversation_id, ) - async with self.client.delete(url) as response: + + async with self._wrapped_client().delete(url) as response: span.share(http_method="DELETE", status_code=response.status) if response.status >= 300: @@ -449,7 +470,10 @@ async def upload_attachment( conversation_id, body.name, ) - async with self.client.post(url, json=attachment_dict) as response: + + async with self._wrapped_client().post( + url, json=attachment_dict + ) as response: span.share(http_method="POST", status_code=response.status) if response.status >= 300: @@ -487,7 +511,8 @@ async def get_conversation_members( logger.info( "Getting conversation members for conversation: %s", conversation_id ) - async with self.client.get(url) as response: + + async with self._wrapped_client().get(url) as response: span.share(http_method="GET", status_code=response.status) if response.status >= 300: @@ -528,7 +553,8 @@ async def get_conversation_member( member_id, conversation_id, ) - async with self.client.get(url) as response: + + async with self._wrapped_client().get(url) as response: span.share(http_method="GET", status_code=response.status) if response.status >= 300: @@ -566,7 +592,8 @@ async def delete_conversation_member( member_id, conversation_id, ) - async with self.client.delete(url) as response: + + async with self._wrapped_client().delete(url) as response: if response.status >= 300: logger.error( "Error deleting conversation member: %s", @@ -600,7 +627,8 @@ async def get_activity_members( conversation_id, activity_id, ) - async with self.client.get(url) as response: + + async with self._wrapped_client().get(url) as response: if response.status >= 300: logger.error( "Error getting activity members: %s", @@ -648,7 +676,8 @@ async def get_conversation_paged_members( page_size, continuation_token, ) - async with self.client.get(url, params=params) as response: + + async with self._wrapped_client().get(url, params=params) as response: if response.status >= 300: logger.error( "Error getting conversation paged members: %s", @@ -681,7 +710,7 @@ async def send_conversation_history( url = f"v3/conversations/{conversation_id}/activities/history" logger.info("Sending conversation history to conversation: %s", conversation_id) - async with self.client.post(url, json=body) as response: + async with self._wrapped_client().post(url, json=body) as response: if response.status >= 300: logger.error( "Error sending conversation history: %s", @@ -699,7 +728,9 @@ class ConnectorClient(ConnectorClientBase): ConnectorClient is a client for interacting with the Microsoft M365 Agents SDK Connector API. """ - def __init__(self, endpoint: str, token: str, *, session: ClientSession = None): + def __init__( + self, endpoint: str, token: str, *, session: ClientSession | None = None + ): """ Initialize a new instance of ConnectorClient. @@ -714,6 +745,7 @@ def __init__(self, endpoint: str, token: str, *, session: ClientSession = None): "Content-Type": "application/json", "User-Agent": get_product_info(), } + # Create session with the base URL session = session or ClientSession( base_url=endpoint, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token.py index e02cce9e..74c01afc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token.py @@ -13,15 +13,27 @@ ) from ..telemetry import user_token_client_spans as spans from ..user_token_base import UserTokenBase +from ._base_client import _BaseClient logger = logging.getLogger(__name__) -class UserToken(UserTokenBase): +class UserToken(UserTokenBase, _BaseClient): """Implementation of user token operations.""" def __init__(self, client: ClientSession): - self.client = client + _BaseClient.__init__(self, client) + self.client = self._client + + @property + def client(self) -> ClientSession: + """Get the underlying aiohttp ClientSession.""" + return self._client + + @client.setter + def client(self, value: ClientSession): + """Set the underlying aiohttp ClientSession.""" + self._client = value async def get_token( self, @@ -50,7 +62,7 @@ async def get_token( logger.info( "UserToken.get_token(): Getting token with params: %s", safe_params ) - async with self.client.get( + async with self._wrapped_client().get( "api/usertoken/GetToken", params=params ) as response: span.share(http_method="GET", status_code=response.status) @@ -90,7 +102,7 @@ async def _get_token_or_sign_in_resource( } logger.info("Getting token or sign-in resource with params: %s", params) - async with self.client.get( + async with self._wrapped_client().get( "/api/usertoken/GetTokenOrSignInResource", params=params ) as response: span.share(http_method="GET", status_code=response.status) @@ -124,7 +136,7 @@ async def get_aad_tokens( params["channelId"] = channel_id logger.info("Getting AAD tokens with params: %s and body: %s", params, body) - async with self.client.post( + async with self._wrapped_client().post( "api/usertoken/GetAadTokens", params=params, json=body ) as response: span.share(http_method="POST", status_code=response.status) @@ -157,7 +169,7 @@ async def sign_out( params["channelId"] = channel_id logger.info("Signing out user %s with params: %s", user_id, params) - async with self.client.delete( + async with self._wrapped_client().delete( "api/usertoken/SignOut", params=params ) as response: span.share(http_method="DELETE", status_code=response.status) @@ -187,7 +199,7 @@ async def get_token_status( logger.info( "Getting token status for user %s with params: %s", user_id, params ) - async with self.client.get( + async with self._wrapped_client().get( "api/usertoken/GetTokenStatus", params=params ) as response: span.share(http_method="GET", status_code=response.status) @@ -224,7 +236,7 @@ async def exchange_token( params, list(body.keys()) if isinstance(body, dict) else None, ) - async with self.client.post( + async with self._wrapped_client().post( "api/usertoken/exchange", params=params, json=body ) as response: span.share(http_method="POST", status_code=response.status) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/mcs/mcs_connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/mcs/mcs_connector_client.py index fb2d60ea..fc8bd000 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/mcs/mcs_connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/mcs/mcs_connector_client.py @@ -11,11 +11,12 @@ from ..connector_client_base import ConnectorClientBase from ..attachments_base import AttachmentsBase from ..conversations_base import ConversationsBase +from ..client._base_client import _BaseClient logger = logging.getLogger(__name__) -class MCSConversations(ConversationsBase): +class MCSConversations(ConversationsBase, _BaseClient): """ Conversations implementation for Microsoft Copilot Studio Connector. @@ -23,7 +24,7 @@ class MCSConversations(ConversationsBase): """ def __init__(self, client: ClientSession, endpoint: str): - self._client = client + _BaseClient.__init__(self, client) self._endpoint = endpoint async def send_to_conversation( @@ -48,7 +49,7 @@ async def send_to_conversation( activity.type, ) - async with self._client.post( + async with self._wrapped_client().post( self._endpoint, json=activity.model_dump(by_alias=True, exclude_unset=True, mode="json"), headers={"Accept": "application/json", "Content-Type": "application/json"}, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/__init__.py new file mode 100644 index 00000000..48265f61 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .header_value_provider import HeaderValueProvider +from .agentic_header_provider import AgenticHeaderProvider, AGENT_REGISTRAR +from .header_propagation_context import HeaderPropagationContext + +__all__ = [ + "HeaderValueProvider", + "AgenticHeaderProvider", + "AGENT_REGISTRAR", + "HeaderPropagationContext", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/agentic_header_provider.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/agentic_header_provider.py new file mode 100644 index 00000000..fd638d9a --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/agentic_header_provider.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +import logging +from typing import Optional + +from microsoft_agents.activity import Activity + +from .header_value_provider import HeaderValueProvider + +logger = logging.getLogger(__name__) + +# Source of truth for the agent ID. Placeholder value per the v0.2 header spec. +AGENT_REGISTRAR = "A365" + + +class AgenticHeaderProvider(HeaderValueProvider): + """Provides agent identity headers derived from the incoming Activity. + + The headers are only emitted when the Activity represents an agentic request + (i.e. ``recipient.role`` is ``agenticUser`` or ``agenticAppInstance``). For + non-agentic requests an empty mapping is returned so that no agent identity + metadata leaks onto regular outgoing requests. + + The emitted headers are: + + * ``AgentRegistrar`` - source of truth for the agent ID (``A365``). + * ``AgentID`` - the unique identifier registered in Entra, taken from the + recipient's ``agentic_app_id``. + * ``AgentName`` - the human-friendly agent name. + * ``Agent-Referrer`` - the originator identifier, taken from the incoming + channel ID. + """ + + def __init__(self, activity: Activity, agent_name: Optional[str] = None) -> None: + """Initializes a new instance of :class:`AgenticHeaderProvider`. + + :param activity: The incoming activity to derive header values from. + :type activity: :class:`microsoft_agents.activity.Activity` + :param agent_name: The human-friendly agent name (typically the + application class name). + :type agent_name: Optional[str] + """ + if activity is None: + raise ValueError("activity is required") + self._activity = activity + self._agent_name = agent_name or "" + + def get_headers(self) -> dict[str, str]: + """Returns the agent identity headers for agentic requests. + + :return: The agent identity headers, or an empty mapping when the + activity is not an agentic request. + :rtype: dict[str, str] + """ + if not self._activity.is_agentic_request(): + return {} + + def _safe_header_value(value: object) -> str: + text = "" if value is None else str(value) + # Prevent CRLF/header injection and normalize whitespace. + return text.replace("\r", "").replace("\n", "").strip() + + recipient = self._activity.recipient + headers = { + "AgentRegistrar": AGENT_REGISTRAR, + "AgentID": _safe_header_value( + recipient.agentic_app_id if recipient else None + ), + "AgentName": _safe_header_value(self._agent_name), + "Agent-Referrer": _safe_header_value(self._activity.channel_id), + } + logger.debug("Resolved agentic headers: %s", list(headers.keys())) + return headers diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_propagation_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_propagation_context.py new file mode 100644 index 00000000..99917880 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_propagation_context.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +import contextvars +import logging +from typing import Optional + +from .header_value_provider import HeaderValueProvider + +logger = logging.getLogger(__name__) + + +class HeaderPropagationContext: + """Per-turn registry of :class:`HeaderValueProvider` instances whose headers + are applied to outgoing connector clients. + + The registry is backed by a :class:`contextvars.ContextVar`, so providers + registered while a turn is being processed are visible to connector clients + created within that same asynchronous flow without leaking across concurrent + turns running in separate tasks. + """ + + _providers: contextvars.ContextVar[Optional[list[HeaderValueProvider]]] = ( + contextvars.ContextVar("header_propagation_providers", default=None) + ) + + @classmethod + def reset(cls) -> None: + """Starts a fresh, empty provider list for the current turn. + + Call this at the start of a turn before registering providers to avoid + carrying providers over from a previous turn that shared the same + context. + """ + cls._providers.set([]) + + @classmethod + def register(cls, provider: HeaderValueProvider) -> None: + """Registers a provider for the current turn. + + :param provider: The provider to register. + :type provider: :class:`HeaderValueProvider` + """ + providers = cls._providers.get() + if providers is None: + providers = [] + cls._providers.set(providers) + providers.append(provider) + + @classmethod + def providers(cls) -> list[HeaderValueProvider]: + """Returns the providers registered for the current turn. + + :return: A copy of the registered providers. + :rtype: list[:class:`HeaderValueProvider`] + """ + return list(cls._providers.get() or []) + + @classmethod + def collect_headers(cls) -> dict[str, str]: + """Collects and merges the headers produced by all registered providers. + + :return: The merged headers to apply to outgoing requests. + :rtype: dict[str, str] + """ + headers: dict[str, str] = {} + for provider in cls._providers.get() or []: + try: + headers.update(provider.get_headers()) + except Exception: # pragma: no cover - defensive + logger.exception( + "Header provider %s failed to produce headers", + type(provider).__name__, + ) + return headers diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_value_provider.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_value_provider.py new file mode 100644 index 00000000..816d3850 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_value_provider.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + + +class HeaderValueProvider(ABC): + """Provides dynamically resolved headers to inject on outgoing HTTP requests. + + Implementations are registered per-turn via + :class:`microsoft_agents.hosting.core.header_propagation.HeaderPropagationContext` + and are queried each time an outgoing connector client is built. + """ + + @abstractmethod + def get_headers(self) -> dict[str, str]: + """Returns the headers to inject on outgoing requests. + + Called each time an outgoing connector client collects propagated + headers. + + :return: A mapping of header name to header value. + :rtype: dict[str, str] + """ + raise NotImplementedError diff --git a/tests/hosting_core/header_propagation/__init__.py b/tests/hosting_core/header_propagation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/header_propagation/test_agentic_header_provider.py b/tests/hosting_core/header_propagation/test_agentic_header_provider.py new file mode 100644 index 00000000..d2fe95fb --- /dev/null +++ b/tests/hosting_core/header_propagation/test_agentic_header_provider.py @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for AgenticHeaderProvider and HeaderPropagationContext.""" + +import pytest + +from microsoft_agents.activity import Activity, RoleTypes +from microsoft_agents.activity.channel_account import ChannelAccount +from microsoft_agents.hosting.core.header_propagation import ( + AgenticHeaderProvider, + HeaderPropagationContext, +) + + +def _agentic_activity( + role=RoleTypes.agentic_user, + agentic_app_id="Entra:test-guid-1234", + channel_id="msteams", +) -> Activity: + return Activity( + type="message", + recipient=ChannelAccount(role=role, agentic_app_id=agentic_app_id), + channel_id=channel_id, + ) + + +class TestAgenticHeaderProvider: + def test_agentic_request_emits_all_headers(self): + provider = AgenticHeaderProvider(_agentic_activity(), "MyTestAgent") + + headers = provider.get_headers() + + assert headers == { + "AgentRegistrar": "A365", + "AgentID": "Entra:test-guid-1234", + "AgentName": "MyTestAgent", + "Agent-Referrer": "msteams", + } + + def test_agentic_identity_role_emits_headers(self): + provider = AgenticHeaderProvider( + _agentic_activity( + role=RoleTypes.agentic_identity, + agentic_app_id="Entra:identity-guid", + channel_id="webchat", + ), + "IdentityAgent", + ) + + headers = provider.get_headers() + + assert headers["AgentRegistrar"] == "A365" + assert headers["AgentID"] == "Entra:identity-guid" + assert headers["AgentName"] == "IdentityAgent" + assert headers["Agent-Referrer"] == "webchat" + + def test_sub_channel_is_preserved_in_referrer(self): + provider = AgenticHeaderProvider( + _agentic_activity(channel_id="msteams:Copilot"), "TestAgent" + ) + + assert provider.get_headers()["Agent-Referrer"] == "msteams:Copilot" + + def test_non_agentic_request_emits_no_headers(self): + activity = Activity( + type="message", + recipient=ChannelAccount(role=RoleTypes.user), + channel_id="msteams", + ) + + assert AgenticHeaderProvider(activity, "MyAgent").get_headers() == {} + + def test_missing_role_emits_no_headers(self): + activity = Activity( + type="message", + recipient=ChannelAccount(), + channel_id="msteams", + ) + + assert AgenticHeaderProvider(activity, "MyAgent").get_headers() == {} + + def test_missing_agentic_app_id_yields_empty_string(self): + provider = AgenticHeaderProvider( + _agentic_activity(agentic_app_id=None), "MyAgent" + ) + + assert provider.get_headers()["AgentID"] == "" + + def test_none_activity_raises(self): + with pytest.raises(ValueError): + AgenticHeaderProvider(None, "MyAgent") + + +class TestHeaderPropagationContext: + def setup_method(self): + HeaderPropagationContext.reset() + + def test_collect_headers_applies_provider_headers(self): + activity = _agentic_activity( + agentic_app_id="Entra:app-id-123", channel_id="msteams:Copilot" + ) + HeaderPropagationContext.register(AgenticHeaderProvider(activity, "TestAgent")) + + headers = HeaderPropagationContext.collect_headers() + + assert headers == { + "AgentRegistrar": "A365", + "AgentID": "Entra:app-id-123", + "AgentName": "TestAgent", + "Agent-Referrer": "msteams:Copilot", + } + + def test_collect_headers_non_agentic_adds_nothing(self): + activity = Activity( + type="message", + recipient=ChannelAccount(role=RoleTypes.user), + channel_id="msteams", + ) + HeaderPropagationContext.register(AgenticHeaderProvider(activity, "TestAgent")) + + assert HeaderPropagationContext.collect_headers() == {} + + def test_reset_clears_registered_providers(self): + HeaderPropagationContext.register( + AgenticHeaderProvider(_agentic_activity(), "TestAgent") + ) + HeaderPropagationContext.reset() + + assert HeaderPropagationContext.providers() == [] + assert HeaderPropagationContext.collect_headers() == {} diff --git a/tests/hosting_core/test_channel_service_adapter.py b/tests/hosting_core/test_channel_service_adapter.py index aa7267f2..a8899bdf 100644 --- a/tests/hosting_core/test_channel_service_adapter.py +++ b/tests/hosting_core/test_channel_service_adapter.py @@ -164,10 +164,19 @@ async def callback(context: TurnContext): async def test_process_activity_normal_no_service_url( self, mocker, user_token_client, adapter ): + """With lazy ConnectorClient creation the missing-service_url error is + deferred until the client is first requested, not raised during + process_activity itself.""" user_token_client.get_access_token = mocker.AsyncMock( return_value="user_token_value" ) - adapter.run_pipeline = mocker.AsyncMock() + + captured_context = [] + + async def capturing_pipeline(context, callback): + captured_context.append(context) + + adapter.run_pipeline = capturing_pipeline async def callback(context: TurnContext): return None @@ -187,12 +196,21 @@ async def callback(context: TurnContext): is_authenticated=True, ) - with pytest.raises(Exception) as exc_info: - await adapter.process_activity( - claims_identity, - activity, - callback, - ) + # process_activity now succeeds; the error is deferred to client creation. + await adapter.process_activity( + claims_identity, + activity, + callback, + ) + + assert len(captured_context) == 1 + context = captured_context[0] + assert ChannelServiceAdapter._CONNECTOR_CLIENT_FACTORY_KEY in context.turn_state + assert ChannelServiceAdapter._AGENT_CONNECTOR_CLIENT_KEY not in context.turn_state + + # Requesting the connector client triggers the deferred validation error. + with pytest.raises(Exception): + await adapter._get_or_create_connector_client(context) @pytest.mark.asyncio async def test_process_proactive( From 42713d52a33d2063c5e2600f89740e92e29ab0f3 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 28 Jul 2026 13:16:56 -0700 Subject: [PATCH 3/8] Enhancing test coverage --- .../hosting/msteams/_teams_api_client.py | 22 +++- .../hosting/msteams/teams_agent_extension.py | 11 +- .../hosting/msteams/teams_turn_context.py | 7 +- .../connector/test_base_client.py | 111 ++++++++++++++++++ .../connector/test_connector_client.py | 53 +++++++++ .../connector/test_user_token_client.py | 52 +++++++- .../test_channel_service_adapter.py | 32 ++--- tests/hosting_msteams/test_internal.py | 38 ++++++ .../test_teams_agent_extension.py | 1 + .../test_teams_turn_context.py | 29 ----- 10 files changed, 286 insertions(+), 70 deletions(-) create mode 100644 tests/hosting_core/connector/test_base_client.py diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py index f022fa8c..dbbd0c7c 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py @@ -17,9 +17,23 @@ ) +def _get_teams_api_client(context: TurnContext) -> ApiClient: + """ + Get the cached Teams API client from the context. + + :param context: The turn context. + :return: The cached Teams API client. + :raises ValueError: If the Teams API client is not found. + """ + api_client = context.services.get(ApiClient) + if isinstance(api_client, ApiClient): + return api_client + raise ValueError("Unable to retrieve Teams API client.") + + def _set_teams_api_client( context: TurnContext, connection_manager: Connections -) -> ApiClient: +) -> None: """ Set the Teams API client in the context if it is not already set. @@ -27,9 +41,8 @@ def _set_teams_api_client( :param connection_manager: The connection manager. """ - api_client = context.services.get(ApiClient) - if api_client is not None: - return api_client + if context.services.has(ApiClient): + return headers = { "Accept": "application/json", @@ -61,4 +74,3 @@ async def token_factory() -> str: ) context.services.set(ApiClient, api_client) - return api_client diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 620ce313..3070843b 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -57,7 +57,10 @@ _common_get_app_graph_client_for_connection, ) -from ._teams_api_client import _set_teams_api_client +from ._teams_api_client import ( + _get_teams_api_client, + _set_teams_api_client, +) from ._utils import _try_get_channel_data from .teams_activity import TeamsActivity @@ -121,6 +124,7 @@ def _configure_app(self): async def on_before_turn(context: TurnContext, state: StateT) -> bool: if context.activity.channel_id == Channels.ms_teams: + _set_teams_api_client(context, self._app.connection_manager) # caches the deserialized version of ChannelData context.activity.channel_data = _try_get_channel_data(context.activity) return True @@ -297,10 +301,7 @@ def get_teams_api_client(self, context: TurnContext) -> ApiClient: :return: The Teams API client. """ - api_client = context.services.get(ApiClient) - if not api_client: - return _set_teams_api_client(context, self._app.connection_manager) - return api_client + return _get_teams_api_client(context) def get_graph_client( 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 beef6051..81509842 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 @@ -28,7 +28,7 @@ _common_get_app_graph_client, _common_get_app_graph_client_for_connection, ) -from ._teams_api_client import _set_teams_api_client +from ._teams_api_client import _get_teams_api_client, _set_teams_api_client from .teams_activity import TeamsActivity @@ -96,10 +96,7 @@ def activity(self) -> TeamsActivity: @property def api_client(self) -> ApiClient: """Get the API client for the Teams turn context.""" - api_client = self._services.get(ApiClient) - if not api_client: - return _set_teams_api_client(self, self._app.connection_manager) - return api_client + return _get_teams_api_client(self) @staticmethod def _make_targeted_activity(activity: Activity) -> None: diff --git a/tests/hosting_core/connector/test_base_client.py b/tests/hosting_core/connector/test_base_client.py new file mode 100644 index 00000000..417c4cb6 --- /dev/null +++ b/tests/hosting_core/connector/test_base_client.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# pylint: disable=missing-class-docstring,missing-function-docstring,protected-access,too-few-public-methods + +"""Tests for connector base client header propagation.""" + +from typing import cast + +import pytest +from aiohttp import ClientSession + +from microsoft_agents.hosting.core.connector.client._base_client import ( + _BaseClient, + _ClientSessionWrapper, +) +from microsoft_agents.hosting.core.header_propagation import ( + HeaderPropagationContext, + HeaderValueProvider, +) + + +class _HeaderProvider(HeaderValueProvider): + def __init__(self, headers: dict[str, str]): + self.headers = headers + + def get_headers(self) -> dict[str, str]: + return dict(self.headers) + + +class _FakeSession: + def __init__(self): + self._base_url = "https://example.test/" + self.marker = "fake-session" + self.calls = [] + + def get(self, *args, **kwargs): + self.calls.append(("GET", args, kwargs)) + return "get-result" + + def post(self, *args, **kwargs): + self.calls.append(("POST", args, kwargs)) + return "post-result" + + +@pytest.fixture(autouse=True) +def reset_header_propagation_context(): + HeaderPropagationContext.reset() + yield + HeaderPropagationContext.reset() + + +class TestClientSessionWrapper: + def test_merges_propagated_headers_with_request_headers(self): + fake_session = _FakeSession() + wrapper = _ClientSessionWrapper(cast(ClientSession, fake_session)) + HeaderPropagationContext.register( + _HeaderProvider( + { + "X-Propagated": "propagated-value", + "X-Override": "propagated-value", + } + ) + ) + + result = wrapper.get( + "v3/conversations", + headers={ + "X-Request": "request-value", + "X-Override": "request-value", + }, + ) + + assert result == "get-result" + _, args, kwargs = fake_session.calls[0] + assert args == ("v3/conversations",) + assert kwargs["headers"] == { + "X-Request": "request-value", + "X-Override": "propagated-value", + "X-Propagated": "propagated-value", + } + + def test_collects_headers_for_each_request(self): + fake_session = _FakeSession() + wrapper = _ClientSessionWrapper(cast(ClientSession, fake_session)) + provider = _HeaderProvider({"X-Turn": "first"}) + HeaderPropagationContext.register(provider) + + wrapper.get("first") + provider.headers = {"X-Turn": "second"} + wrapper.post("second") + + assert fake_session.calls[0][2]["headers"]["X-Turn"] == "first" + assert fake_session.calls[1][2]["headers"]["X-Turn"] == "second" + + def test_delegates_unknown_attributes_to_wrapped_session(self): + wrapper = _ClientSessionWrapper(cast(ClientSession, _FakeSession())) + + assert wrapper.marker == "fake-session" + + +class TestBaseClient: + def test_wrapped_client_returns_header_propagating_wrapper(self): + fake_session = _FakeSession() + client = _BaseClient(cast(ClientSession, fake_session)) + HeaderPropagationContext.register(_HeaderProvider({"X-Propagated": "value"})) + + result = getattr(client, "_wrapped_client")().get("path") + + assert result == "get-result" + assert fake_session.calls[0][2]["headers"] == {"X-Propagated": "value"} diff --git a/tests/hosting_core/connector/test_connector_client.py b/tests/hosting_core/connector/test_connector_client.py index 2a8db01d..d70e9cd6 100644 --- a/tests/hosting_core/connector/test_connector_client.py +++ b/tests/hosting_core/connector/test_connector_client.py @@ -12,8 +12,10 @@ from microsoft_agents.activity import Activity, Channels, ResourceResponse, RoleTypes from microsoft_agents.activity.channel_account import ChannelAccount from microsoft_agents.hosting.core.connector.client.connector_client import ( + ConnectorClient, ConversationsOperations, ) +from microsoft_agents.hosting.core.header_propagation import HeaderPropagationContext def _create_app(routes): @@ -23,6 +25,21 @@ def _create_app(routes): return app +class _HeaderProvider: + def __init__(self, headers: dict[str, str]): + self.headers = headers + + def get_headers(self) -> dict[str, str]: + return dict(self.headers) + + +@pytest.fixture(autouse=True) +def reset_header_propagation_context(): + HeaderPropagationContext.reset() + yield + HeaderPropagationContext.reset() + + class TestSendToConversation: """Tests for ConversationsOperations.send_to_conversation.""" @@ -75,6 +92,42 @@ async def handler(request): await server.close() +class TestConnectorClientHeaderPropagation: + """Tests propagated headers through a full ConnectorClient operation.""" + + @pytest.mark.asyncio + async def test_send_to_conversation_uses_headers_registered_after_client_creation( + self, + ): + captured = {} + + async def handler(request): + captured["headers"] = request.headers + return web.json_response({"id": "activity-id-123"}) + + app = _create_app( + [web.post("/v3/conversations/{conversation_id}/activities", handler)] + ) + server = TestServer(app) + await server.start_server() + + client = ConnectorClient(str(server.make_url("/")), token="") + try: + HeaderPropagationContext.register( + _HeaderProvider({"X-Agentic-Test": "propagated"}) + ) + + result = await client.conversations.send_to_conversation( + "conv-1", Activity(type="message", text="hello") + ) + + assert result.id == "activity-id-123" + assert captured["headers"]["X-Agentic-Test"] == "propagated" + finally: + await client.close() + await server.close() + + class TestReplyToActivity: """Tests for ConversationsOperations.reply_to_activity.""" diff --git a/tests/hosting_core/connector/test_user_token_client.py b/tests/hosting_core/connector/test_user_token_client.py index 8150ad5a..c5e726f8 100644 --- a/tests/hosting_core/connector/test_user_token_client.py +++ b/tests/hosting_core/connector/test_user_token_client.py @@ -7,7 +7,26 @@ from aiohttp import ClientSession, web from aiohttp.test_utils import TestServer -from microsoft_agents.hosting.core.connector.client.user_token_client import UserToken +from microsoft_agents.hosting.core.connector.client.user_token_client import ( + UserToken, + UserTokenClient, +) +from microsoft_agents.hosting.core.header_propagation import HeaderPropagationContext + + +class _HeaderProvider: + def __init__(self, headers: dict[str, str]): + self.headers = headers + + def get_headers(self) -> dict[str, str]: + return dict(self.headers) + + +@pytest.fixture(autouse=True) +def reset_header_propagation_context(): + HeaderPropagationContext.reset() + yield + HeaderPropagationContext.reset() class TestUserTokenBaseChannel: @@ -92,3 +111,34 @@ async def handler(request): await server.close() assert captured == [None, None, None, None] + + +class TestUserTokenClientHeaderPropagation: + """Tests propagated headers through UserTokenClient operations.""" + + @pytest.mark.asyncio + async def test_get_user_token_uses_headers_registered_after_client_creation(self): + captured = {} + + async def handler(request): + captured["headers"] = request.headers + return web.json_response({"token": "token"}) + + app = web.Application() + app.router.add_get("/api/usertoken/GetToken", handler) + server = TestServer(app) + await server.start_server() + + client = UserTokenClient(str(server.make_url("/")), token="", app_id="app-id") + try: + HeaderPropagationContext.register( + _HeaderProvider({"X-Agentic-Test": "propagated"}) + ) + + result = await client.get_user_token("user", "connection", "msteams") + + assert result.token == "token" + assert captured["headers"]["X-Agentic-Test"] == "propagated" + finally: + await client.close() + await server.close() diff --git a/tests/hosting_core/test_channel_service_adapter.py b/tests/hosting_core/test_channel_service_adapter.py index a8899bdf..aa7267f2 100644 --- a/tests/hosting_core/test_channel_service_adapter.py +++ b/tests/hosting_core/test_channel_service_adapter.py @@ -164,19 +164,10 @@ async def callback(context: TurnContext): async def test_process_activity_normal_no_service_url( self, mocker, user_token_client, adapter ): - """With lazy ConnectorClient creation the missing-service_url error is - deferred until the client is first requested, not raised during - process_activity itself.""" user_token_client.get_access_token = mocker.AsyncMock( return_value="user_token_value" ) - - captured_context = [] - - async def capturing_pipeline(context, callback): - captured_context.append(context) - - adapter.run_pipeline = capturing_pipeline + adapter.run_pipeline = mocker.AsyncMock() async def callback(context: TurnContext): return None @@ -196,21 +187,12 @@ async def callback(context: TurnContext): is_authenticated=True, ) - # process_activity now succeeds; the error is deferred to client creation. - await adapter.process_activity( - claims_identity, - activity, - callback, - ) - - assert len(captured_context) == 1 - context = captured_context[0] - assert ChannelServiceAdapter._CONNECTOR_CLIENT_FACTORY_KEY in context.turn_state - assert ChannelServiceAdapter._AGENT_CONNECTOR_CLIENT_KEY not in context.turn_state - - # Requesting the connector client triggers the deferred validation error. - with pytest.raises(Exception): - await adapter._get_or_create_connector_client(context) + with pytest.raises(Exception) as exc_info: + await adapter.process_activity( + claims_identity, + activity, + callback, + ) @pytest.mark.asyncio async def test_process_proactive( diff --git a/tests/hosting_msteams/test_internal.py b/tests/hosting_msteams/test_internal.py index 3a8201fe..1ea3a2f9 100644 --- a/tests/hosting_msteams/test_internal.py +++ b/tests/hosting_msteams/test_internal.py @@ -13,11 +13,49 @@ ) if is_supported_version: + from microsoft_teams.api import ApiClient + + from microsoft_agents.hosting.msteams._teams_api_client import ( + _get_teams_api_client, + ) from microsoft_agents.hosting.msteams.errors.error_resources import ( TeamsErrorResources, ) +class _FakeServices: + def __init__(self, values=None): + self._values = values or {} + + def get(self, key): + return self._values.get(key) + + +class _FakeContext: + """Minimal stand-in exposing only the ``services`` accessor reads.""" + + def __init__(self, services): + self.services = services + + +class TestGetTeamsApiClient: + + def test_returns_cached_api_client(self): + client = ApiClient("https://smba.trafficmanager.net/teams/") + ctx = _FakeContext(_FakeServices({ApiClient: client})) + assert _get_teams_api_client(ctx) is client + + def test_raises_when_missing(self): + ctx = _FakeContext(_FakeServices()) + with pytest.raises(ValueError, match="Teams API client"): + _get_teams_api_client(ctx) + + def test_raises_when_wrong_type(self): + ctx = _FakeContext(_FakeServices({ApiClient: object()})) + with pytest.raises(ValueError, match="Teams API client"): + _get_teams_api_client(ctx) + + class TestTeamsErrorResources: def _error_messages(self): diff --git a/tests/hosting_msteams/test_teams_agent_extension.py b/tests/hosting_msteams/test_teams_agent_extension.py index 74cb2982..c8da5a82 100644 --- a/tests/hosting_msteams/test_teams_agent_extension.py +++ b/tests/hosting_msteams/test_teams_agent_extension.py @@ -125,6 +125,7 @@ async def test_teams_channel_deserializes_channel_data(self): assert result is True assert isinstance(activity.channel_data, ChannelData) assert activity.channel_data.channel.id == "c1" + assert ctx.services.has(ApiClient) @pytest.mark.asyncio async def test_teams_channel_without_channel_data_sets_none(self): diff --git a/tests/hosting_msteams/test_teams_turn_context.py b/tests/hosting_msteams/test_teams_turn_context.py index a2e6009f..73df5720 100644 --- a/tests/hosting_msteams/test_teams_turn_context.py +++ b/tests/hosting_msteams/test_teams_turn_context.py @@ -3,8 +3,6 @@ """Tests for TeamsTurnContext helpers that can be exercised without a live adapter.""" -from types import SimpleNamespace - import pytest from .helpers import is_supported_version @@ -19,19 +17,11 @@ Activity, ActivityTreatmentTypes, Entity, - ResourceResponse, ) - from microsoft_teams.api import ApiClient - from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.msteams import TeamsTurnContext -class _StubAdapter: - async def send_activities(self, context, activities): - return [ResourceResponse()] * len(activities) - - class TestMakeTargetedActivity: """``_make_targeted_activity`` mutates the supplied activity in place (returns None) by appending a TARGETED activity-treatment entity.""" @@ -62,22 +52,3 @@ def test_each_call_appends_another_treatment(self): if getattr(e, "treatment", None) == ActivityTreatmentTypes.TARGETED ] assert len(treatments) == 2 - - -class TestTeamsApiClient: - - def test_api_client_returns_cached_client(self): - activity = Activity( - type="message", - channel_id="msteams", - service_url="https://smba.trafficmanager.net/teams/", - ) - context = TurnContext(_StubAdapter(), activity) - client = object.__new__(ApiClient) - context.services.set(ApiClient, client) - - teams_context = TeamsTurnContext( - context, SimpleNamespace(connection_manager=object()) - ) - - assert teams_context.api_client is client From fc951fb6199bbae7cb9e67670f12b62c2888f1e9 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 28 Jul 2026 13:30:34 -0700 Subject: [PATCH 4/8] Adding integration tests --- dev/integration/tests/agentic/__init__.py | 2 + .../test_agentic_header_propagation.py | 157 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 dev/integration/tests/agentic/__init__.py create mode 100644 dev/integration/tests/agentic/test_agentic_header_propagation.py diff --git a/dev/integration/tests/agentic/__init__.py b/dev/integration/tests/agentic/__init__.py new file mode 100644 index 00000000..5b7f7a92 --- /dev/null +++ b/dev/integration/tests/agentic/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/dev/integration/tests/agentic/test_agentic_header_propagation.py b/dev/integration/tests/agentic/test_agentic_header_propagation.py new file mode 100644 index 00000000..005c6295 --- /dev/null +++ b/dev/integration/tests/agentic/test_agentic_header_propagation.py @@ -0,0 +1,157 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Integration tests for agentic header propagation.""" + +import asyncio + +import pytest +from aiohttp import ClientSession, web +from aiohttp.test_utils import TestServer + +from microsoft_agents.activity import Activity, ActivityTypes, RoleTypes +from microsoft_agents.hosting.aiohttp import CloudAdapter, start_agent_process +from microsoft_agents.hosting.core import ( + AgentApplication, + AgentAuthConfiguration, + ApplicationOptions, + Authorization, + ConnectorClientBase, + MemoryStorage, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.authorization import ClaimsIdentity + + +class _FakeTokenProvider: + def __init__(self): + self._configuration = AgentAuthConfiguration() + + @property + def configuration(self) -> AgentAuthConfiguration: + return self._configuration + + async def get_access_token( + self, resource_url: str, scopes: list[str], force_refresh: bool = False + ) -> str: + return "test-access-token" + + async def get_agentic_user_token( + self, + tenant_id: str, + agent_app_instance_id: str, + agentic_user_id: str, + scopes: list[str], + ) -> str: + return "test-agentic-user-token" + + +class _FakeConnections: + def __init__(self): + self._provider = _FakeTokenProvider() + + def get_connection(self, connection_name: str): + return self._provider + + def get_default_connection(self): + return self._provider + + def get_token_provider(self, claims_identity: ClaimsIdentity, service_url: str): + return self._provider + + def get_token_provider_from_activity( + self, claims_identity: ClaimsIdentity, activity: Activity + ): + return self._provider + + def get_default_connection_configuration(self) -> AgentAuthConfiguration: + return self._provider.configuration + + +@pytest.mark.asyncio +async def test_agentic_turn_propagates_headers_on_connector_client_request(): + captured_headers = {} + callback_received = asyncio.Event() + + async def callback_handler(request: web.Request) -> web.Response: + captured_headers.update(dict(request.headers)) + callback_received.set() + return web.json_response({"id": "connector-response-id"}) + + callback_app = web.Application() + callback_app.router.add_post("/v3/conversations/{tail:.*}", callback_handler) + callback_server = TestServer(callback_app) + await callback_server.start_server() + + connection_manager = _FakeConnections() + storage = MemoryStorage() + adapter = CloudAdapter(connection_manager=connection_manager) + agent_application = AgentApplication[TurnState]( + options=ApplicationOptions( + storage=storage, + start_typing_timer=False, + remove_recipient_mention=False, + ), + authorization=Authorization(storage, connection_manager), + agent_name="Agentic Header Test Agent", + ) + + @agent_application.activity(ActivityTypes.message) + async def on_message(context: TurnContext, state: TurnState) -> None: + connector_client = context.services.get(ConnectorClientBase) + assert connector_client is not None + + await connector_client.conversations.send_to_conversation( + context.activity.conversation.id, + Activity(type=ActivityTypes.message, text="connector client response"), + ) + + agent_app = web.Application() + + async def messages(request: web.Request) -> web.Response: + return await start_agent_process( + request, + agent_application=agent_application, + adapter=adapter, + ) + + agent_app.router.add_post("/api/messages", messages) + agent_server = TestServer(agent_app) + await agent_server.start_server() + + try: + activity = Activity( + type=ActivityTypes.message, + text="send with connector client", + channel_id="msteams:Copilot", + service_url=str(callback_server.make_url("/")), + conversation={"id": "conversation-id"}, + from_property={"id": "user-id", "role": RoleTypes.user}, + recipient={ + "id": "agent-id", + "role": RoleTypes.agentic_user, + "agentic_app_id": "Entra:agentic-app-id", + "agentic_user_id": "agentic-user-id", + "tenant_id": "tenant-id", + }, + ) + + async with ClientSession() as session: + async with session.post( + agent_server.make_url("/api/messages"), + json=activity.model_dump( + by_alias=True, exclude_unset=True, exclude_none=True, mode="json" + ), + ) as response: + assert response.status == 202 + + await asyncio.wait_for(callback_received.wait(), timeout=5) + + assert captured_headers["AgentRegistrar"] == "A365" + assert captured_headers["AgentID"] == "Entra:agentic-app-id" + assert captured_headers["AgentName"] == "Agentic Header Test Agent" + assert captured_headers["Agent-Referrer"] == "msteams:Copilot" + finally: + await agent_server.close() + await callback_server.close() From 1e2999379977c3667028f6d71ca64e083bacbf30 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 28 Jul 2026 13:30:45 -0700 Subject: [PATCH 5/8] Adding agent application test --- .../app/test_agent_application.py | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/tests/hosting_core/app/test_agent_application.py b/tests/hosting_core/app/test_agent_application.py index 30c56f73..f456c0e8 100644 --- a/tests/hosting_core/app/test_agent_application.py +++ b/tests/hosting_core/app/test_agent_application.py @@ -8,7 +8,7 @@ import pytest -from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.activity import Activity, ActivityTypes, RoleTypes from microsoft_agents.hosting.core import MemoryStorage from microsoft_agents.hosting.core.app import ( AgentApplication, @@ -17,6 +17,7 @@ ) from microsoft_agents.hosting.core.app.app_error import ApplicationError from microsoft_agents.hosting.core.app.oauth import Authorization +from microsoft_agents.hosting.core.header_propagation import HeaderPropagationContext from tests._common.testing_objects import TestingConnectionManager as _ConnectionManager @@ -147,6 +148,115 @@ async def test_on_turn_no_typing_when_start_typing_timer_false(): assert len(context._on_send_handlers) == 0 +@pytest.mark.asyncio +async def test_on_turn_registers_agentic_headers_for_agentic_activity(): + """Agentic activities should expose Activity-derived headers during turn handling.""" + app = AgentApplication[TurnState]( + options=ApplicationOptions( + storage=MemoryStorage(), + start_typing_timer=False, + remove_recipient_mention=False, + ), + authorization=make_auth(), + agent_name="My Agent!", + ) + context = StubTurnContext( + Activity( + type=ActivityTypes.event, + channel_id="msteams:Copilot", + conversation={"id": "conv1"}, + from_property={"id": "user1"}, + recipient={ + "role": RoleTypes.agentic_user, + "agentic_app_id": "Entra:app-id-123", + }, + service_url="https://test", + ) + ) + captured_headers = {} + + async def capture_headers(*_): + captured_headers.update(HeaderPropagationContext.collect_headers()) + + HeaderPropagationContext.reset() + try: + with patch.object(app, "_remove_mentions"), patch.object( + app, "_initialize_state", new_callable=AsyncMock, return_value=TurnState() + ), patch.object( + app, + "_run_before_turn_middleware", + new_callable=AsyncMock, + return_value=True, + ), patch.object( + app, "_handle_file_downloads", new_callable=AsyncMock + ), patch.object( + app, "_on_activity", new_callable=AsyncMock, side_effect=capture_headers + ), patch.object( + app, "_run_after_turn_middleware", new_callable=AsyncMock, return_value=True + ): + await app._on_turn(context) + finally: + HeaderPropagationContext.reset() + + assert captured_headers == { + "AgentRegistrar": "A365", + "AgentID": "Entra:app-id-123", + "AgentName": "My Agent", + "Agent-Referrer": "msteams:Copilot", + } + + +@pytest.mark.asyncio +async def test_on_turn_does_not_register_agentic_headers_for_non_agentic_activity(): + """Non-agentic activities should not expose agentic headers during turn handling.""" + app = AgentApplication[TurnState]( + options=ApplicationOptions( + storage=MemoryStorage(), + start_typing_timer=False, + remove_recipient_mention=False, + ), + authorization=make_auth(), + agent_name="My Agent", + ) + context = StubTurnContext( + Activity( + type=ActivityTypes.event, + channel_id="msteams", + conversation={"id": "conv1"}, + from_property={"id": "user1"}, + recipient={"role": RoleTypes.user}, + service_url="https://test", + ) + ) + captured_headers = {"stale": "value"} + + async def capture_headers(*_): + captured_headers.clear() + captured_headers.update(HeaderPropagationContext.collect_headers()) + + HeaderPropagationContext.reset() + try: + with patch.object(app, "_remove_mentions"), patch.object( + app, "_initialize_state", new_callable=AsyncMock, return_value=TurnState() + ), patch.object( + app, + "_run_before_turn_middleware", + new_callable=AsyncMock, + return_value=True, + ), patch.object( + app, "_handle_file_downloads", new_callable=AsyncMock + ), patch.object( + app, "_on_activity", new_callable=AsyncMock, side_effect=capture_headers + ), patch.object( + app, "_run_after_turn_middleware", new_callable=AsyncMock, return_value=True + ): + await app._on_turn(context) + finally: + HeaderPropagationContext.reset() + + assert captured_headers == {} + + # --------------------------------------------------------------------------- # AgentApplication.__init__ guard: connection_manager required # --------------------------------------------------------------------------- From 4f791336cce5c9b8a470aecfb787a6690d9fbda3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Tue, 28 Jul 2026 13:31:37 -0700 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../hosting/core/connector/client/_base_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py index 15fb5d54..c43b5db5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py @@ -39,7 +39,8 @@ def _apply_headers(self, headers: dict) -> None: """ propagated_headers = HeaderPropagationContext.collect_headers() if propagated_headers: - headers.update(propagated_headers) + for name, value in propagated_headers.items(): + headers.setdefault(name, value) logger.debug( "Applying propagated headers: %s", list(propagated_headers.keys()) ) From e25ea79f0aefd154d79588d7c54e0ae9bc6f584f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 28 Jul 2026 14:09:42 -0700 Subject: [PATCH 7/8] Another commit --- .../hosting/core/connector/client/_base_client.py | 3 ++- tests/hosting_core/connector/test_base_client.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py index 15fb5d54..970e5669 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py @@ -39,7 +39,8 @@ def _apply_headers(self, headers: dict) -> None: """ propagated_headers = HeaderPropagationContext.collect_headers() if propagated_headers: - headers.update(propagated_headers) + for key, value in propagated_headers.items(): + headers.setdefault(key, value) logger.debug( "Applying propagated headers: %s", list(propagated_headers.keys()) ) diff --git a/tests/hosting_core/connector/test_base_client.py b/tests/hosting_core/connector/test_base_client.py index 417c4cb6..2b3d928b 100644 --- a/tests/hosting_core/connector/test_base_client.py +++ b/tests/hosting_core/connector/test_base_client.py @@ -76,7 +76,7 @@ def test_merges_propagated_headers_with_request_headers(self): assert args == ("v3/conversations",) assert kwargs["headers"] == { "X-Request": "request-value", - "X-Override": "propagated-value", + "X-Override": "request-value", "X-Propagated": "propagated-value", } From ef13c62149a5b7064fe87222902994845b95f967 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 28 Jul 2026 14:18:03 -0700 Subject: [PATCH 8/8] Updating docstring --- .../core/connector/client/_base_client.py | 13 +++++++---- .../core/connector/client/connector_client.py | 11 ++++++++- .../header_propagation_context.py | 23 +++++++++---------- .../header_value_provider.py | 4 ++-- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py index 970e5669..f57a7814 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py @@ -12,7 +12,7 @@ class _ClientSessionWrapper: - """ClientSession wrapper that applies propagated headers per request.""" + """ClientSession wrapper that merges propagated headers per request.""" def __init__(self, session: ClientSession): self._session = session @@ -33,9 +33,12 @@ def _separate_headers(self, **kwargs) -> tuple[dict, dict]: def _apply_headers(self, headers: dict) -> None: """ - Apply propagated headers to the request headers. + Merge propagated headers into the request headers. - :param headers: Headers to apply. + Explicit request headers take precedence over propagated values with the + same name. + + :param headers: Mutable request headers to augment. """ propagated_headers = HeaderPropagationContext.collect_headers() if propagated_headers: @@ -47,7 +50,7 @@ def _apply_headers(self, headers: dict) -> None: def _call_with_headers(self, method: Callable, *args, **kwargs): """ - Call the specified method on the underlying session with headers applied. + Call the underlying session method with propagated headers merged. :param method: The HTTP method to call. :param args: Positional arguments for the method. @@ -84,7 +87,7 @@ def __init__(self, client: ClientSession): def _wrapped_client(self) -> _ClientSessionWrapper: """ - Returns the wrapped client session. + Returns a session wrapper that merges propagated headers per request. :return: The wrapped ClientSession. """ 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 0080c4d6..1847e660 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 @@ -66,7 +66,16 @@ class AttachmentsOperations(AttachmentsBase, _BaseClient): def __init__(self, client: ClientSession): _BaseClient.__init__(self, client) - self.client = self._client + + @property + def client(self) -> ClientSession: + """Get the underlying aiohttp ClientSession.""" + return self._client + + @client.setter + def client(self, value: ClientSession): + """Set the underlying aiohttp ClientSession.""" + self._client = value async def get_attachment_info(self, attachment_id: str) -> AttachmentInfo: """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_propagation_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_propagation_context.py index 99917880..10743e7c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_propagation_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_propagation_context.py @@ -13,13 +13,12 @@ class HeaderPropagationContext: - """Per-turn registry of :class:`HeaderValueProvider` instances whose headers - are applied to outgoing connector clients. + """Context-local registry of :class:`HeaderValueProvider` instances. - The registry is backed by a :class:`contextvars.ContextVar`, so providers - registered while a turn is being processed are visible to connector clients - created within that same asynchronous flow without leaking across concurrent - turns running in separate tasks. + The registry is backed by a :class:`contextvars.ContextVar`. Providers + registered while a turn is being processed are visible to outgoing connector + requests made in that same asynchronous context, including requests made by + connector clients that were created before the providers were registered. """ _providers: contextvars.ContextVar[Optional[list[HeaderValueProvider]]] = ( @@ -28,17 +27,17 @@ class HeaderPropagationContext: @classmethod def reset(cls) -> None: - """Starts a fresh, empty provider list for the current turn. + """Starts a fresh, empty provider list for the current context. - Call this at the start of a turn before registering providers to avoid - carrying providers over from a previous turn that shared the same + Call this before registering providers for a turn to avoid carrying + providers over from a previous turn that shared the same asynchronous context. """ cls._providers.set([]) @classmethod def register(cls, provider: HeaderValueProvider) -> None: - """Registers a provider for the current turn. + """Registers a provider for the current context. :param provider: The provider to register. :type provider: :class:`HeaderValueProvider` @@ -51,7 +50,7 @@ def register(cls, provider: HeaderValueProvider) -> None: @classmethod def providers(cls) -> list[HeaderValueProvider]: - """Returns the providers registered for the current turn. + """Returns the providers registered for the current context. :return: A copy of the registered providers. :rtype: list[:class:`HeaderValueProvider`] @@ -60,7 +59,7 @@ def providers(cls) -> list[HeaderValueProvider]: @classmethod def collect_headers(cls) -> dict[str, str]: - """Collects and merges the headers produced by all registered providers. + """Collects and merges the headers produced by registered providers. :return: The merged headers to apply to outgoing requests. :rtype: dict[str, str] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_value_provider.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_value_provider.py index 816d3850..c7bf4079 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_value_provider.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_value_provider.py @@ -9,14 +9,14 @@ class HeaderValueProvider(ABC): Implementations are registered per-turn via :class:`microsoft_agents.hosting.core.header_propagation.HeaderPropagationContext` - and are queried each time an outgoing connector client is built. + and are queried each time an outgoing connector request is prepared. """ @abstractmethod def get_headers(self) -> dict[str, str]: """Returns the headers to inject on outgoing requests. - Called each time an outgoing connector client collects propagated + Called each time an outgoing connector request collects propagated headers. :return: A mapping of header name to header value.