From ecd35bb4eee3cc9fa0566aacfe0b4a36ce73df96 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Thu, 7 May 2026 14:27:56 -0700 Subject: [PATCH 1/5] add product context fallback --- .../hosting/scope_helpers/utils.py | 31 ++++++- .../middleware/test_baggage_middleware.py | 81 +++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py index 298294c1..32524042 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py @@ -74,13 +74,36 @@ def get_channel_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: sub_channel = None if channel_id is not None: - if isinstance(channel_id, str): - # Direct string value - channel_name = channel_id - elif hasattr(channel_id, "channel"): + # Check for ChannelId object first + if hasattr(channel_id, "channel"): # ChannelId object channel_name = channel_id.channel sub_channel = channel_id.sub_channel + elif isinstance(channel_id, str): + # Direct string value + channel_name = channel_id + + # Try to get sub_channel from productContext in channel_data if sub_channel is not set + if not sub_channel and activity.channel_data: + try: + import json + # Convert channel_data to dict if it's a string + if isinstance(activity.channel_data, str): + channel_data_dict = json.loads(activity.channel_data) + elif isinstance(activity.channel_data, dict): + channel_data_dict = activity.channel_data + else: + # Try to convert to dict if it has __dict__ + channel_data_dict = getattr(activity.channel_data, '__dict__', {}) + + # Extract productContext if available + if isinstance(channel_data_dict, dict) and 'productContext' in channel_data_dict: + product_context = channel_data_dict['productContext'] + if isinstance(product_context, str): + sub_channel = product_context + except (json.JSONDecodeError, AttributeError, TypeError): + # Silently ignore any parsing errors + pass # Yield channel name as source name yield CHANNEL_NAME_KEY, channel_name diff --git a/tests/observability/hosting/middleware/test_baggage_middleware.py b/tests/observability/hosting/middleware/test_baggage_middleware.py index 4263e898..f52f33c7 100644 --- a/tests/observability/hosting/middleware/test_baggage_middleware.py +++ b/tests/observability/hosting/middleware/test_baggage_middleware.py @@ -95,3 +95,84 @@ async def logic(): assert logic_called is True # Baggage should NOT be set because the middleware skipped it assert captured_caller_id is None + + +@pytest.mark.asyncio +async def test_baggage_middleware_extracts_product_context_from_channel_data(): + """BaggageMiddleware should extract productContext from channel_data when sub_channel is not set.""" + from microsoft_agents.activity import ChannelId + from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY + + middleware = BaggageMiddleware() + + # Create activity with ChannelId (no sub_channel) and channel_data with productContext + activity = Activity( + type="message", + text="Hello", + from_property=ChannelAccount( + aad_object_id="caller-id", + name="Caller", + ), + recipient=ChannelAccount( + tenant_id="tenant-123", + name="Agent", + ), + conversation=ConversationAccount(id="conv-id"), + service_url="https://example.com", + channel_id=ChannelId(channel="msteams"), # No sub_channel + channel_data={"productContext": "COPILOT"}, + ) + + adapter = MagicMock() + ctx = TurnContext(adapter, activity) + + captured_channel_link = None + + async def logic(): + nonlocal captured_channel_link + captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY) + + await middleware.on_turn(ctx, logic) + + assert captured_channel_link == "COPILOT" + + +@pytest.mark.asyncio +async def test_baggage_middleware_sub_channel_takes_precedence_over_product_context(): + """BaggageMiddleware should use sub_channel when both sub_channel and productContext are present.""" + from microsoft_agents.activity import ChannelId + from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY + + middleware = BaggageMiddleware() + + # Create activity with BOTH sub_channel and productContext in channel_data + activity = Activity( + type="message", + text="Hello", + from_property=ChannelAccount( + aad_object_id="caller-id", + name="Caller", + ), + recipient=ChannelAccount( + tenant_id="tenant-123", + name="Agent", + ), + conversation=ConversationAccount(id="conv-id"), + service_url="https://example.com", + channel_id=ChannelId(channel="msteams", sub_channel="teams-subchannel"), + channel_data={"productContext": "COPILOT"}, # Should be ignored + ) + + adapter = MagicMock() + ctx = TurnContext(adapter, activity) + + captured_channel_link = None + + async def logic(): + nonlocal captured_channel_link + captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY) + + await middleware.on_turn(ctx, logic) + + # sub_channel should take precedence, productContext should be ignored + assert captured_channel_link == "teams-subchannel" From a0b93829c8d3ef5c0a6c58fb06dfef907d4b396a Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Thu, 7 May 2026 16:06:29 -0700 Subject: [PATCH 2/5] add tests --- .../middleware/test_baggage_middleware.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/observability/hosting/middleware/test_baggage_middleware.py b/tests/observability/hosting/middleware/test_baggage_middleware.py index f52f33c7..d8df92bf 100644 --- a/tests/observability/hosting/middleware/test_baggage_middleware.py +++ b/tests/observability/hosting/middleware/test_baggage_middleware.py @@ -176,3 +176,85 @@ async def logic(): # sub_channel should take precedence, productContext should be ignored assert captured_channel_link == "teams-subchannel" + + +@pytest.mark.asyncio +async def test_baggage_middleware_extracts_product_context_from_json_string_channel_data(): + """BaggageMiddleware should extract productContext from channel_data when it's a JSON string.""" + from microsoft_agents.activity import ChannelId + from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY + import json + + middleware = BaggageMiddleware() + + # Create activity with channel_data as a JSON string (simulating wire format) + activity = Activity( + type="message", + text="Hello", + from_property=ChannelAccount( + aad_object_id="caller-id", + name="Caller", + ), + recipient=ChannelAccount( + tenant_id="tenant-123", + name="Agent", + ), + conversation=ConversationAccount(id="conv-id"), + service_url="https://example.com", + channel_id=ChannelId(channel="msteams"), # No sub_channel + channel_data=json.dumps({"productContext": "COPILOT"}), # JSON string + ) + + adapter = MagicMock() + ctx = TurnContext(adapter, activity) + + captured_channel_link = None + + async def logic(): + nonlocal captured_channel_link + captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY) + + await middleware.on_turn(ctx, logic) + + assert captured_channel_link == "COPILOT" + + +@pytest.mark.asyncio +async def test_baggage_middleware_handles_invalid_json_channel_data_gracefully(): + """BaggageMiddleware should handle invalid JSON in channel_data gracefully without setting baggage.""" + from microsoft_agents.activity import ChannelId + from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY + + middleware = BaggageMiddleware() + + # Create activity with channel_data as an invalid JSON string + activity = Activity( + type="message", + text="Hello", + from_property=ChannelAccount( + aad_object_id="caller-id", + name="Caller", + ), + recipient=ChannelAccount( + tenant_id="tenant-123", + name="Agent", + ), + conversation=ConversationAccount(id="conv-id"), + service_url="https://example.com", + channel_id=ChannelId(channel="msteams"), # No sub_channel + channel_data="not valid json", # Non-JSON string + ) + + adapter = MagicMock() + ctx = TurnContext(adapter, activity) + + captured_channel_link = None + + async def logic(): + nonlocal captured_channel_link + captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY) + + await middleware.on_turn(ctx, logic) + + # Should not set ChannelLink, should fail gracefully + assert captured_channel_link is None From b853e7f49de46116b8ab9a664e2c1dcd8360372a Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Thu, 7 May 2026 16:18:43 -0700 Subject: [PATCH 3/5] refactor tests --- .../middleware/test_baggage_middleware.py | 106 +++++------------- 1 file changed, 30 insertions(+), 76 deletions(-) diff --git a/tests/observability/hosting/middleware/test_baggage_middleware.py b/tests/observability/hosting/middleware/test_baggage_middleware.py index d8df92bf..92a74253 100644 --- a/tests/observability/hosting/middleware/test_baggage_middleware.py +++ b/tests/observability/hosting/middleware/test_baggage_middleware.py @@ -9,6 +9,7 @@ ActivityEventNames, ActivityTypes, ChannelAccount, + ChannelId, ConversationAccount, ) from microsoft_agents.hosting.core import TurnContext @@ -53,6 +54,31 @@ def _make_turn_context( return TurnContext(adapter, activity) +def _make_channel_data_turn_context( + channel_id: ChannelId | str = "msteams", + channel_data: any = None, +) -> TurnContext: + """Create a TurnContext with channel_data for testing.""" + activity = Activity( + type="message", + text="Hello", + from_property=ChannelAccount( + aad_object_id="caller-id", + name="Caller", + ), + recipient=ChannelAccount( + tenant_id="tenant-123", + name="Agent", + ), + conversation=ConversationAccount(id="conv-id"), + service_url="https://example.com", + channel_id=channel_id, + channel_data=channel_data, + ) + adapter = MagicMock() + return TurnContext(adapter, activity) + + @pytest.mark.asyncio async def test_baggage_middleware_propagates_baggage(): """BaggageMiddleware should set baggage context for the downstream logic.""" @@ -100,31 +126,13 @@ async def logic(): @pytest.mark.asyncio async def test_baggage_middleware_extracts_product_context_from_channel_data(): """BaggageMiddleware should extract productContext from channel_data when sub_channel is not set.""" - from microsoft_agents.activity import ChannelId from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY middleware = BaggageMiddleware() - - # Create activity with ChannelId (no sub_channel) and channel_data with productContext - activity = Activity( - type="message", - text="Hello", - from_property=ChannelAccount( - aad_object_id="caller-id", - name="Caller", - ), - recipient=ChannelAccount( - tenant_id="tenant-123", - name="Agent", - ), - conversation=ConversationAccount(id="conv-id"), - service_url="https://example.com", + ctx = _make_channel_data_turn_context( channel_id=ChannelId(channel="msteams"), # No sub_channel channel_data={"productContext": "COPILOT"}, ) - - adapter = MagicMock() - ctx = TurnContext(adapter, activity) captured_channel_link = None @@ -140,31 +148,13 @@ async def logic(): @pytest.mark.asyncio async def test_baggage_middleware_sub_channel_takes_precedence_over_product_context(): """BaggageMiddleware should use sub_channel when both sub_channel and productContext are present.""" - from microsoft_agents.activity import ChannelId from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY middleware = BaggageMiddleware() - - # Create activity with BOTH sub_channel and productContext in channel_data - activity = Activity( - type="message", - text="Hello", - from_property=ChannelAccount( - aad_object_id="caller-id", - name="Caller", - ), - recipient=ChannelAccount( - tenant_id="tenant-123", - name="Agent", - ), - conversation=ConversationAccount(id="conv-id"), - service_url="https://example.com", + ctx = _make_channel_data_turn_context( channel_id=ChannelId(channel="msteams", sub_channel="teams-subchannel"), channel_data={"productContext": "COPILOT"}, # Should be ignored ) - - adapter = MagicMock() - ctx = TurnContext(adapter, activity) captured_channel_link = None @@ -181,32 +171,14 @@ async def logic(): @pytest.mark.asyncio async def test_baggage_middleware_extracts_product_context_from_json_string_channel_data(): """BaggageMiddleware should extract productContext from channel_data when it's a JSON string.""" - from microsoft_agents.activity import ChannelId from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY import json middleware = BaggageMiddleware() - - # Create activity with channel_data as a JSON string (simulating wire format) - activity = Activity( - type="message", - text="Hello", - from_property=ChannelAccount( - aad_object_id="caller-id", - name="Caller", - ), - recipient=ChannelAccount( - tenant_id="tenant-123", - name="Agent", - ), - conversation=ConversationAccount(id="conv-id"), - service_url="https://example.com", + ctx = _make_channel_data_turn_context( channel_id=ChannelId(channel="msteams"), # No sub_channel channel_data=json.dumps({"productContext": "COPILOT"}), # JSON string ) - - adapter = MagicMock() - ctx = TurnContext(adapter, activity) captured_channel_link = None @@ -222,31 +194,13 @@ async def logic(): @pytest.mark.asyncio async def test_baggage_middleware_handles_invalid_json_channel_data_gracefully(): """BaggageMiddleware should handle invalid JSON in channel_data gracefully without setting baggage.""" - from microsoft_agents.activity import ChannelId from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY middleware = BaggageMiddleware() - - # Create activity with channel_data as an invalid JSON string - activity = Activity( - type="message", - text="Hello", - from_property=ChannelAccount( - aad_object_id="caller-id", - name="Caller", - ), - recipient=ChannelAccount( - tenant_id="tenant-123", - name="Agent", - ), - conversation=ConversationAccount(id="conv-id"), - service_url="https://example.com", + ctx = _make_channel_data_turn_context( channel_id=ChannelId(channel="msteams"), # No sub_channel channel_data="not valid json", # Non-JSON string ) - - adapter = MagicMock() - ctx = TurnContext(adapter, activity) captured_channel_link = None From 54c10e8f633f6d16b6a14539459855142c247baf Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Thu, 7 May 2026 17:33:03 -0700 Subject: [PATCH 4/5] fix: address PR review comments - Replace 'any' type annotation with 'object' for channel_data parameter - Move CHANNEL_LINK_KEY import to module level to avoid redefinition - Remove duplicate inline imports of json and CHANNEL_LINK_KEY - Use double quotes for string literals per ruff-format - Remove trailing whitespace from blank lines - Rename test to clarify it only affects channel link, not all baggage --- .../observability/hosting/scope_helpers/utils.py | 9 +++++---- .../hosting/middleware/test_baggage_middleware.py | 9 +++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py index 32524042..c4f45c98 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py @@ -87,6 +87,7 @@ def get_channel_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: if not sub_channel and activity.channel_data: try: import json + # Convert channel_data to dict if it's a string if isinstance(activity.channel_data, str): channel_data_dict = json.loads(activity.channel_data) @@ -94,11 +95,11 @@ def get_channel_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: channel_data_dict = activity.channel_data else: # Try to convert to dict if it has __dict__ - channel_data_dict = getattr(activity.channel_data, '__dict__', {}) - + channel_data_dict = getattr(activity.channel_data, "__dict__", {}) + # Extract productContext if available - if isinstance(channel_data_dict, dict) and 'productContext' in channel_data_dict: - product_context = channel_data_dict['productContext'] + if isinstance(channel_data_dict, dict) and "productContext" in channel_data_dict: + product_context = channel_data_dict["productContext"] if isinstance(product_context, str): sub_channel = product_context except (json.JSONDecodeError, AttributeError, TypeError): diff --git a/tests/observability/hosting/middleware/test_baggage_middleware.py b/tests/observability/hosting/middleware/test_baggage_middleware.py index 92a74253..4c3e2574 100644 --- a/tests/observability/hosting/middleware/test_baggage_middleware.py +++ b/tests/observability/hosting/middleware/test_baggage_middleware.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import json from unittest.mock import MagicMock import pytest @@ -14,6 +15,7 @@ ) from microsoft_agents.hosting.core import TurnContext from microsoft_agents_a365.observability.core.constants import ( + CHANNEL_LINK_KEY, TENANT_ID_KEY, USER_ID_KEY, ) @@ -56,7 +58,7 @@ def _make_turn_context( def _make_channel_data_turn_context( channel_id: ChannelId | str = "msteams", - channel_data: any = None, + channel_data: object | None = None, ) -> TurnContext: """Create a TurnContext with channel_data for testing.""" activity = Activity( @@ -126,7 +128,6 @@ async def logic(): @pytest.mark.asyncio async def test_baggage_middleware_extracts_product_context_from_channel_data(): """BaggageMiddleware should extract productContext from channel_data when sub_channel is not set.""" - from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY middleware = BaggageMiddleware() ctx = _make_channel_data_turn_context( @@ -148,7 +149,6 @@ async def logic(): @pytest.mark.asyncio async def test_baggage_middleware_sub_channel_takes_precedence_over_product_context(): """BaggageMiddleware should use sub_channel when both sub_channel and productContext are present.""" - from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY middleware = BaggageMiddleware() ctx = _make_channel_data_turn_context( @@ -171,8 +171,6 @@ async def logic(): @pytest.mark.asyncio async def test_baggage_middleware_extracts_product_context_from_json_string_channel_data(): """BaggageMiddleware should extract productContext from channel_data when it's a JSON string.""" - from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY - import json middleware = BaggageMiddleware() ctx = _make_channel_data_turn_context( @@ -194,7 +192,6 @@ async def logic(): @pytest.mark.asyncio async def test_baggage_middleware_handles_invalid_json_channel_data_gracefully(): """BaggageMiddleware should handle invalid JSON in channel_data gracefully without setting baggage.""" - from microsoft_agents_a365.observability.core.constants import CHANNEL_LINK_KEY middleware = BaggageMiddleware() ctx = _make_channel_data_turn_context( From bcf4ef94680b091626d2d53915842911f32438f7 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Fri, 8 May 2026 13:25:14 -0700 Subject: [PATCH 5/5] fix import --- .../observability/hosting/scope_helpers/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py index c4f45c98..02aa5e78 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import json from collections.abc import Iterator from typing import Any @@ -86,8 +87,6 @@ def get_channel_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: # Try to get sub_channel from productContext in channel_data if sub_channel is not set if not sub_channel and activity.channel_data: try: - import json - # Convert channel_data to dict if it's a string if isinstance(activity.channel_data, str): channel_data_dict = json.loads(activity.channel_data)