diff --git a/dev/integration/tests/agentic/__init__.py b/dev/integration/tests/agentic/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /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 000000000..005c6295d --- /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() 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 ecb9b153b..be36ce811 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 edd17d2e2..1996f67c1 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 f8fa4f0b9..77fa371f1 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 c6446c9c1..4a793b151 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 000000000..f57a7814a --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_base_client.py @@ -0,0 +1,94 @@ +# 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 merges 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: + """ + Merge propagated headers into the request headers. + + 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: + for key, value in propagated_headers.items(): + headers.setdefault(key, value) + logger.debug( + "Applying propagated headers: %s", list(propagated_headers.keys()) + ) + + def _call_with_headers(self, method: Callable, *args, **kwargs): + """ + Call the underlying session method with propagated headers merged. + + :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 a session wrapper that merges propagated headers per request. + + :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 ee1ddefea..31b40754c 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 28562e7ef..1847e660b 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,20 @@ 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) + + @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: """ @@ -81,7 +92,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 +135,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 +149,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 +212,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 +240,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 +287,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 +347,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 +396,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 +434,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 +479,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 +520,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 +562,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 +601,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 +636,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 +685,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 +719,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 +737,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 +754,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 e02cce9eb..74c01afc4 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 fb2d60eaf..fc8bd0003 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 000000000..48265f618 --- /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 000000000..fd638d9a9 --- /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 000000000..10743e7cc --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/header_propagation/header_propagation_context.py @@ -0,0 +1,76 @@ +# 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: + """Context-local registry of :class:`HeaderValueProvider` instances. + + 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]]] = ( + contextvars.ContextVar("header_propagation_providers", default=None) + ) + + @classmethod + def reset(cls) -> None: + """Starts a fresh, empty provider list for the current context. + + 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 context. + + :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 context. + + :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 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 000000000..c7bf4079b --- /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 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 request collects propagated + headers. + + :return: A mapping of header name to header value. + :rtype: dict[str, str] + """ + raise NotImplementedError 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 273b69c23..5bd6495cf 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_core/app/test_agent_application.py b/tests/hosting_core/app/test_agent_application.py index 30c56f731..f456c0e8d 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 # --------------------------------------------------------------------------- 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 000000000..2b3d928ba --- /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": "request-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 2a8db01d3..d70e9cd62 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 8150ad5a0..c5e726f85 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/header_propagation/__init__.py b/tests/hosting_core/header_propagation/__init__.py new file mode 100644 index 000000000..e69de29bb 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 000000000..d2fe95fb9 --- /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() == {}