Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

from copy import deepcopy
from email.mime import base
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import: The 'base' module from email.mime is imported on line 13 but never used. Remove this import.

Suggested change
from email.mime import base

Copilot uses AI. Check for mistakes.
from typing import Generic, TypeVar, cast, Self

from pydantic import BaseModel
Expand All @@ -22,7 +23,7 @@
set_defaults,
flatten,
)
from .utils import flatten_model_data
from .utils import flatten_model_data, rename_from_property

ModelT = TypeVar("ModelT", bound=BaseModel | dict)

Expand Down Expand Up @@ -82,15 +83,17 @@ def with_defaults(self, defaults: dict | None = None, **kwargs) -> ModelTemplate
:return: A new ModelTemplate instance.
"""
new_template = deepcopy(self._defaults)
set_defaults(new_template, defaults, **kwargs)
defaults_copy = deepcopy(defaults) if defaults else {}
rename_from_property(defaults_copy)
set_defaults(new_template, defaults_copy, **kwargs)
return ModelTemplate[ModelT](self._model_class, new_template)

def with_updates(self, updates: dict | None = None, **kwargs) -> ModelTemplate[ModelT]:
"""Create a new ModelTemplate with updated default values."""
new_template = deepcopy(self._defaults)
# Expand the updates first so they merge correctly with nested structure
flat_updates = flatten(updates or {})
flat_kwargs = flatten(kwargs)
flat_updates = flatten_model_data(updates or {})
flat_kwargs = flatten_model_data(kwargs)
deep_update(new_template, flat_updates)
deep_update(new_template, flat_kwargs)
# Pass already-expanded data, avoid re-expansion
Expand Down Expand Up @@ -128,6 +131,7 @@ def __init__(self, defaults: Activity | dict | None = None, **kwargs) -> None:
:param kwargs: Additional default values as keyword arguments.
"""
super().__init__(Activity, defaults, **kwargs)
rename_from_property(self._defaults)

def with_defaults(self, defaults: dict | None = None, **kwargs) -> ActivityTemplate:
"""Create a new ModelTemplate with additional default values.
Expand All @@ -137,16 +141,18 @@ def with_defaults(self, defaults: dict | None = None, **kwargs) -> ActivityTempl
:return: A new ModelTemplate instance.
"""
new_template = deepcopy(self._defaults)
set_defaults(new_template, defaults, **kwargs)
defaults_copy = deepcopy(defaults) if defaults else {}
rename_from_property(defaults_copy)
set_defaults(new_template, defaults_copy, **kwargs)
return ActivityTemplate(new_template)

def with_updates(self, updates: dict | None = None, **kwargs) -> ActivityTemplate:
"""Create a new ModelTemplate with updated default values."""
new_template = deepcopy(self._defaults)
# Expand the updates first so they merge correctly with nested structure
flat_updates = flatten(updates or {})
flat_kwargs = flatten(kwargs)
flat_updates = flatten_model_data(updates or {})
flat_kwargs = flatten_model_data(kwargs)
deep_update(new_template, flat_updates)
deep_update(new_template, flat_kwargs)
# Pass already-expanded data, avoid re-expansion
return ActivityTemplate(new_template)
return ActivityTemplate(new_template)
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@
from pydantic import BaseModel
from .backend import expand, flatten

def rename_from_property(data: dict) -> None:
"""Rename keys starting with 'from.' to 'from_property.' for compatibility."""
mods = {}
for key in data.keys():
if key.startswith("from."):
new_key = key.replace("from.", "from_property.")
mods[key] = new_key
elif key == "from":
new_key = "from_property"
mods[key] = new_key

for old_key, new_key in mods.items():
data[new_key] = data.pop(old_key)

def normalize_model_data(source: BaseModel | dict) -> dict:
"""Normalize a BaseModel or dictionary to an expanded dictionary.

Expand All @@ -25,7 +39,9 @@ def normalize_model_data(source: BaseModel | dict) -> dict:
source = cast(dict, source.model_dump(exclude_unset=True, mode="json"))
return source

return expand(source)
expanded = expand(source)
rename_from_property(expanded)
return expanded

def flatten_model_data(source: BaseModel | dict) -> dict:
"""Flatten model data to a single-level dictionary with dot-notation keys.
Expand All @@ -41,4 +57,6 @@ def flatten_model_data(source: BaseModel | dict) -> dict:
source = cast(dict, source.model_dump(exclude_unset=True, mode="json"))
return flatten(source)

return flatten(source)
flattened = flatten(source)
rename_from_property(flattened)
return flattened
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,88 @@ def test_dot_notation_for_conversation(self):
assert activity.conversation.name == "Test Conv"


class TestActivityTemplateFromAliases:
"""Tests for ActivityTemplate alias behavior between from and from_property."""

def test_from_dot_notation_defaults_are_normalized(self):
"""Defaults using from.* are normalized to from_property.* internally."""
template = ActivityTemplate(
type=ActivityTypes.message,
**{"from.id": "user123", "from.name": "Alias User"}
)

assert "from.id" not in template._defaults
assert "from.name" not in template._defaults
assert template._defaults["from_property.id"] == "user123"
assert template._defaults["from_property.name"] == "Alias User"

def test_create_accepts_top_level_from_alias_in_defaults(self):
"""Top-level from alias in defaults maps to Activity.from_property."""
template = ActivityTemplate(
type=ActivityTypes.message,
**{"from": {"id": "user123", "name": "Alias User"}}
)

activity = template.create()
assert activity.from_property is not None
assert activity.from_property.id == "user123"
assert activity.from_property.name == "Alias User"

def test_create_original_from_alias_overrides_from_property_default(self):
"""create() accepts from alias and overrides from_property defaults."""
template = ActivityTemplate(
type=ActivityTypes.message,
**{"from_property.id": "default-id", "from_property.name": "Default User"}
)

activity = template.create({"from": {"id": "override-id", "name": "Override User"}})
assert activity.from_property is not None
assert activity.from_property.id == "override-id"
assert activity.from_property.name == "Override User"

def test_create_original_from_property_overrides_from_dot_default(self):
"""create() accepts from_property and overrides defaults authored with from.* alias."""
template = ActivityTemplate(
type=ActivityTypes.message,
**{"from.id": "default-id", "from.name": "Default User"}
)

activity = template.create(
{
"from_property": {
"id": "override-id",
"name": "Override User",
}
}
)
assert activity.from_property is not None
assert activity.from_property.id == "override-id"
assert activity.from_property.name == "Override User"

def test_with_defaults_accepts_from_alias(self):
"""with_defaults() supports from alias and produces from_property on create."""
template = ActivityTemplate(type=ActivityTypes.message).with_defaults(
**{"from.id": "user123", "from.name": "Alias User"}
)

activity = template.create()
assert activity.from_property is not None
assert activity.from_property.id == "user123"
assert activity.from_property.name == "Alias User"

def test_with_updates_accepts_from_alias(self):
"""with_updates() supports from alias and updates existing from_property values."""
template = ActivityTemplate(
type=ActivityTypes.message,
**{"from_property.id": "default-id", "from_property.name": "Default User"}
).with_updates(**{"from.id": "updated-id", "from.name": "Updated User"})

activity = template.create()
assert activity.from_property is not None
assert activity.from_property.id == "updated-id"
assert activity.from_property.name == "Updated User"


class TestActivityTemplateEquality:
"""Tests for ActivityTemplate equality comparison."""

Expand Down
2 changes: 1 addition & 1 deletion dev/tests/scenarios/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
Scenario,
)

from .quickstart import init_app as init_quickstart
from .quickstart import init_agent as init_quickstart

_SCENARIO_INITS = {
"quickstart": init_quickstart,
Expand Down
6 changes: 4 additions & 2 deletions dev/tests/scenarios/quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
TurnState
)

from microsoft_agents.testing import AgentEnvironment
from microsoft_agents.testing import (
AgentEnvironment,
)

async def init_app(env: AgentEnvironment):
async def init_agent(env: AgentEnvironment):
"""Initialize the application for the quickstart sample."""

app: AgentApplication[TurnState] = env.agent_application
Expand Down
Empty file.
137 changes: 137 additions & 0 deletions dev/tests/sdk/observability/test_observability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import pytest

from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader

from ...scenarios import load_scenario

_SCENARIO = load_scenario("quickstart", use_jwt_middleware=False)

@pytest.fixture(scope="module")
def test_telemetry():
"""Set up fresh in-memory exporter for testing."""
exporter = InMemorySpanExporter()
metric_reader = InMemoryMetricReader()

tracer_provider = TracerProvider()
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(tracer_provider)

meter_provider = MeterProvider([metric_reader])

metrics.set_meter_provider(meter_provider)

yield exporter, metric_reader

exporter.clear()
tracer_provider.shutdown()
meter_provider.shutdown()

@pytest.fixture(scope="function")
def test_exporter(test_telemetry):
"""Provide the in-memory span exporter for each test."""
exporter, _ = test_telemetry
return exporter

@pytest.fixture(scope="function")
def test_metric_reader(test_telemetry):
"""Provide the in-memory metric reader for each test."""
_, metric_reader = test_telemetry
return metric_reader

@pytest.fixture(autouse=True, scope="function")
def clear(test_exporter, test_metric_reader):
"""Clear spans before each test to ensure test isolation."""
test_exporter.clear()
test_metric_reader.force_flush()

@pytest.mark.asyncio
@pytest.mark.agent_test(_SCENARIO)
async def test_basic(test_exporter, agent_client):
"""Test that spans are created for a simple scenario."""

await agent_client.send_expect_replies("Hello!")

Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing whitespace: Line 58 contains trailing whitespace. Remove it for code cleanliness.

Suggested change

Copilot uses AI. Check for mistakes.
spans = test_exporter.get_finished_spans()

# We should have a span for the overall turn
assert any(
span.name == "agent turn"
for span in spans
)
turn_span = next(span for span in spans if span.name == "agent turn")
assert (
"activity.type" in turn_span.attributes and
"agent.is_agentic" in turn_span.attributes and
"from.id" in turn_span.attributes and
"recipient.id" in turn_span.attributes and
"conversation.id" in turn_span.attributes and
"channel_id" in turn_span.attributes and
"message.text.length" in turn_span.attributes
)
assert turn_span.attributes["activity.type"] == "message"
assert turn_span.attributes["agent.is_agentic"] == False
assert turn_span.attributes["message.text.length"] == len("Hello!")

# adapter processing is a key part of the turn, so we should have a span for it
assert any(
span.name == "adapter process"
for span in spans
)

# storage is read when accessing conversation state
assert any(
span.name == "storage read"
for span in spans
)

assert len(spans) >= 3

@pytest.mark.asyncio
@pytest.mark.agent_test(_SCENARIO)
async def test_multiple_users(test_exporter, agent_client):
"""Test that spans are created correctly for multiple users."""

activity1 = agent_client.template.create({
"from.id": "user1",
"text": "Hello from user 1"
})

activity2 = agent_client.template.create({
"from.id": "user2",
"text": "Hello from user 2"
})

await agent_client.send_expect_replies(activity1)
await agent_client.send_expect_replies(activity2)

spans = test_exporter.get_finished_spans()

def assert_span_for_user(user_id: str):
assert any(
span.name == "agent turn" and span.attributes.get("from.id") == user_id
for span in spans
)

assert_span_for_user("user1")
assert_span_for_user("user2")

assert len([ span if span.name == "agent turn" else None for span in spans ]) == 2
assert len([ span if span.name == "adapter process" else None for span in spans ]) == 2
Comment on lines +123 to +124
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inefficient list comprehension: Lines 123-124 use list comprehensions that include all spans (with None for non-matching ones) instead of filtering. This is inefficient and incorrect. Replace with: [span for span in spans if span.name == "agent turn"] and similarly for "adapter process".

Suggested change
assert len([ span if span.name == "agent turn" else None for span in spans ]) == 2
assert len([ span if span.name == "adapter process" else None for span in spans ]) == 2
assert len([span for span in spans if span.name == "agent turn"]) == 2
assert len([span for span in spans if span.name == "adapter process"]) == 2

Copilot uses AI. Check for mistakes.

@pytest.mark.asyncio
@pytest.mark.agent_test(_SCENARIO)
async def test_metrics(test_metric_reader, agent_client):
"""Test that metrics are recorded for a simple scenario."""

await agent_client.send_expect_replies("Hello!")

metrics_data = test_metric_reader.get_metrics_data()

metrics = metrics_data.resource_metrics

assert len(metrics) > 0
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
HttpResponse,
)
from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase
from microsoft_agents.hosting.core.observability import agent_telemetry

from .agent_http_adapter import AgentHttpAdapter

Expand Down Expand Up @@ -69,14 +70,16 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]:
Returns:
aiohttp Response object.
"""
# Adapt request to protocol
adapted_request = AiohttpRequestAdapter(request)

# Process using base implementation
http_response: HttpResponse = await self.process_request(adapted_request, agent)
with agent_telemetry.adapter_process_operation():
# Adapt request to protocol
adapted_request = AiohttpRequestAdapter(request)

# Convert HttpResponse to aiohttp Response
return self._to_aiohttp_response(http_response)
# Process using base implementation
http_response: HttpResponse = await self.process_request(adapted_request, agent)

# Convert HttpResponse to aiohttp Response
return self._to_aiohttp_response(http_response)

@staticmethod
def _to_aiohttp_response(http_response: HttpResponse) -> Response:
Expand Down
Loading
Loading