Skip to content

Commit bf4b362

Browse files
gwharris7Grant Harris
andauthored
fix: add product context fallback for subchanne; (#252)
* add product context fallback * add tests * refactor tests * 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 * fix import --------- Co-authored-by: Grant Harris <grantharris@microsoft.com>
1 parent f037e13 commit bf4b362

2 files changed

Lines changed: 141 additions & 4 deletions

File tree

  • libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers
  • tests/observability/hosting/middleware

libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
3+
import json
34
from collections.abc import Iterator
45
from typing import Any
56

@@ -74,13 +75,35 @@ def get_channel_pairs(activity: Activity) -> Iterator[tuple[str, Any]]:
7475
sub_channel = None
7576

7677
if channel_id is not None:
77-
if isinstance(channel_id, str):
78-
# Direct string value
79-
channel_name = channel_id
80-
elif hasattr(channel_id, "channel"):
78+
# Check for ChannelId object first
79+
if hasattr(channel_id, "channel"):
8180
# ChannelId object
8281
channel_name = channel_id.channel
8382
sub_channel = channel_id.sub_channel
83+
elif isinstance(channel_id, str):
84+
# Direct string value
85+
channel_name = channel_id
86+
87+
# Try to get sub_channel from productContext in channel_data if sub_channel is not set
88+
if not sub_channel and activity.channel_data:
89+
try:
90+
# Convert channel_data to dict if it's a string
91+
if isinstance(activity.channel_data, str):
92+
channel_data_dict = json.loads(activity.channel_data)
93+
elif isinstance(activity.channel_data, dict):
94+
channel_data_dict = activity.channel_data
95+
else:
96+
# Try to convert to dict if it has __dict__
97+
channel_data_dict = getattr(activity.channel_data, "__dict__", {})
98+
99+
# Extract productContext if available
100+
if isinstance(channel_data_dict, dict) and "productContext" in channel_data_dict:
101+
product_context = channel_data_dict["productContext"]
102+
if isinstance(product_context, str):
103+
sub_channel = product_context
104+
except (json.JSONDecodeError, AttributeError, TypeError):
105+
# Silently ignore any parsing errors
106+
pass
84107

85108
# Yield channel name as source name
86109
yield CHANNEL_NAME_KEY, channel_name

tests/observability/hosting/middleware/test_baggage_middleware.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4+
import json
45
from unittest.mock import MagicMock
56

67
import pytest
@@ -9,10 +10,12 @@
910
ActivityEventNames,
1011
ActivityTypes,
1112
ChannelAccount,
13+
ChannelId,
1214
ConversationAccount,
1315
)
1416
from microsoft_agents.hosting.core import TurnContext
1517
from microsoft_agents_a365.observability.core.constants import (
18+
CHANNEL_LINK_KEY,
1619
TENANT_ID_KEY,
1720
USER_ID_KEY,
1821
)
@@ -53,6 +56,31 @@ def _make_turn_context(
5356
return TurnContext(adapter, activity)
5457

5558

59+
def _make_channel_data_turn_context(
60+
channel_id: ChannelId | str = "msteams",
61+
channel_data: object | None = None,
62+
) -> TurnContext:
63+
"""Create a TurnContext with channel_data for testing."""
64+
activity = Activity(
65+
type="message",
66+
text="Hello",
67+
from_property=ChannelAccount(
68+
aad_object_id="caller-id",
69+
name="Caller",
70+
),
71+
recipient=ChannelAccount(
72+
tenant_id="tenant-123",
73+
name="Agent",
74+
),
75+
conversation=ConversationAccount(id="conv-id"),
76+
service_url="https://example.com",
77+
channel_id=channel_id,
78+
channel_data=channel_data,
79+
)
80+
adapter = MagicMock()
81+
return TurnContext(adapter, activity)
82+
83+
5684
@pytest.mark.asyncio
5785
async def test_baggage_middleware_propagates_baggage():
5886
"""BaggageMiddleware should set baggage context for the downstream logic."""
@@ -95,3 +123,89 @@ async def logic():
95123
assert logic_called is True
96124
# Baggage should NOT be set because the middleware skipped it
97125
assert captured_caller_id is None
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_baggage_middleware_extracts_product_context_from_channel_data():
130+
"""BaggageMiddleware should extract productContext from channel_data when sub_channel is not set."""
131+
132+
middleware = BaggageMiddleware()
133+
ctx = _make_channel_data_turn_context(
134+
channel_id=ChannelId(channel="msteams"), # No sub_channel
135+
channel_data={"productContext": "COPILOT"},
136+
)
137+
138+
captured_channel_link = None
139+
140+
async def logic():
141+
nonlocal captured_channel_link
142+
captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY)
143+
144+
await middleware.on_turn(ctx, logic)
145+
146+
assert captured_channel_link == "COPILOT"
147+
148+
149+
@pytest.mark.asyncio
150+
async def test_baggage_middleware_sub_channel_takes_precedence_over_product_context():
151+
"""BaggageMiddleware should use sub_channel when both sub_channel and productContext are present."""
152+
153+
middleware = BaggageMiddleware()
154+
ctx = _make_channel_data_turn_context(
155+
channel_id=ChannelId(channel="msteams", sub_channel="teams-subchannel"),
156+
channel_data={"productContext": "COPILOT"}, # Should be ignored
157+
)
158+
159+
captured_channel_link = None
160+
161+
async def logic():
162+
nonlocal captured_channel_link
163+
captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY)
164+
165+
await middleware.on_turn(ctx, logic)
166+
167+
# sub_channel should take precedence, productContext should be ignored
168+
assert captured_channel_link == "teams-subchannel"
169+
170+
171+
@pytest.mark.asyncio
172+
async def test_baggage_middleware_extracts_product_context_from_json_string_channel_data():
173+
"""BaggageMiddleware should extract productContext from channel_data when it's a JSON string."""
174+
175+
middleware = BaggageMiddleware()
176+
ctx = _make_channel_data_turn_context(
177+
channel_id=ChannelId(channel="msteams"), # No sub_channel
178+
channel_data=json.dumps({"productContext": "COPILOT"}), # JSON string
179+
)
180+
181+
captured_channel_link = None
182+
183+
async def logic():
184+
nonlocal captured_channel_link
185+
captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY)
186+
187+
await middleware.on_turn(ctx, logic)
188+
189+
assert captured_channel_link == "COPILOT"
190+
191+
192+
@pytest.mark.asyncio
193+
async def test_baggage_middleware_handles_invalid_json_channel_data_gracefully():
194+
"""BaggageMiddleware should handle invalid JSON in channel_data gracefully without setting baggage."""
195+
196+
middleware = BaggageMiddleware()
197+
ctx = _make_channel_data_turn_context(
198+
channel_id=ChannelId(channel="msteams"), # No sub_channel
199+
channel_data="not valid json", # Non-JSON string
200+
)
201+
202+
captured_channel_link = None
203+
204+
async def logic():
205+
nonlocal captured_channel_link
206+
captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY)
207+
208+
await middleware.on_turn(ctx, logic)
209+
210+
# Should not set ChannelLink, should fail gracefully
211+
assert captured_channel_link is None

0 commit comments

Comments
 (0)