Skip to content
Merged
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 @@ -75,7 +75,6 @@ def _create_scenario() -> AiohttpScenario:
app = AgentApplication[TurnState](
options=ApplicationOptions(
storage=storage,
adapter=adapter,
proactive=ProactiveOptions(),
),
authorization=authorization,
Expand Down
19 changes: 19 additions & 0 deletions dev/microsoft-agents-hosting-testing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ client to interact with the agent. Auth credentials and general SDK config setti

Swap one for the other and your assertions stay the same.

### Existing AgentApplication

Use `AiohttpScenario.from_app(...)` when the `AgentApplication` is already
constructed. Pass the application's existing `CloudAdapter` to preserve its
middleware, host validation, client factories, and error handling:

```python
scenario = AiohttpScenario.from_app(
AGENT_APP,
adapter=ADAPTER,
use_jwt_middleware=False,
)
```

All arguments after `AGENT_APP` are keyword-only. If `adapter` is omitted, the
scenario creates a default `CloudAdapter` from `AGENT_APP.connection_manager`.
The generated adapter does not inherit configuration from another adapter used
to host the application.

## AgentClient

The client you get from a scenario. Send messages, collect replies, make
Expand Down
36 changes: 36 additions & 0 deletions dev/microsoft-agents-hosting-testing/docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,42 @@ async with scenario.run() as factory:
)))
```

Use `from_app(...)` when the `AgentApplication` already exists:

```python
AiohttpScenario.from_app(
app: AgentApplication,
*,
adapter: CloudAdapter | None = None,
config: ScenarioConfig | None = None,
use_jwt_middleware: bool = True,
sdk_config: dict | None = None,
)
```

All arguments after `app` are keyword-only.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `app` | `AgentApplication` | *(required)* | Existing agent application to host |
| `adapter` | `CloudAdapter \| None` | `None` | Adapter used to host the application; when omitted, one is created from `app.connection_manager` |
| `config` | `ScenarioConfig \| None` | `None` | Scenario-level settings (ports, env file, etc.) |
| `use_jwt_middleware` | `bool` | `True` | Enable JWT auth middleware |
| `sdk_config` | `dict \| None` | `None` | SDK configuration used for client authentication |

```python
scenario = AiohttpScenario.from_app(
AGENT_APP,
adapter=ADAPTER,
use_jwt_middleware=False,
)
```

Pass the application's production adapter when the test must preserve adapter
middleware, host validation, client factories, or error handling. The default
adapter only reuses the application's connection manager and does not inherit
configuration from another adapter used to host the application.

### ActivityHandlerScenario

```python
Expand Down
19 changes: 19 additions & 0 deletions dev/microsoft-agents-hosting-testing/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ client to interact with the agent. Auth credentials and general SDK config setti

Swap one for the other and your assertions stay the same.

### Existing AgentApplication

Use `AiohttpScenario.from_app(...)` when the `AgentApplication` is already
constructed. Pass the application's existing `CloudAdapter` to preserve its
middleware, host validation, client factories, and error handling:

```python
scenario = AiohttpScenario.from_app(
AGENT_APP,
adapter=ADAPTER,
use_jwt_middleware=False,
)
```

All arguments after `AGENT_APP` are keyword-only. If `adapter` is omitted, the
scenario creates a default `CloudAdapter` from `AGENT_APP.connection_manager`.
The generated adapter does not inherit configuration from another adapter used
to host the application.

## AgentClient

The client you get from a scenario. Send messages, collect replies, make
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
web server.

Use :meth:`AiohttpScenario.from_app` when a sample or test already defines
module-level AgentApplication components, and use :meth:`AiohttpScenario.create`
when the test wants the scenario to create the standard storage, adapter,
authorization, and connection components before registering handlers.
module-level AgentApplication components. Pass its hosting ``CloudAdapter`` to
preserve production adapter configuration, or omit it to create a default
adapter from the application's connection manager. Use
:meth:`AiohttpScenario.create` when the test wants the scenario to create the
standard storage, adapter, authorization, and connection components before
registering handlers.
"""

from __future__ import annotations
Expand Down Expand Up @@ -56,6 +59,7 @@

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class AgentEnvironment:
"""Components used by an in-process AgentApplication scenario.
Expand All @@ -80,6 +84,7 @@ class AgentEnvironment:
storage: Storage
connections: Connections


class AiohttpScenario(Scenario):
"""Scenario that hosts an AgentApplication in-process using aiohttp.

Expand All @@ -93,11 +98,17 @@ class AiohttpScenario(Scenario):
- :meth:`create` builds a standard environment and then calls a setup
function to register routes/handlers.
- :meth:`from_app` wraps an existing AgentApplication, such as one defined
at module scope in a sample's ``agents.py`` file.
at module scope in a sample's ``agents.py`` file. It accepts the
application's CloudAdapter or creates a default adapter from the
application's connection manager.

Example::

scenario = AiohttpScenario.from_app(AGENT_APP, use_jwt_middleware=False)
scenario = AiohttpScenario.from_app(
AGENT_APP,
adapter=ADAPTER,
use_jwt_middleware=False,
)

async with scenario.client() as client:
await client.send("Hello!", wait=0.2)
Expand Down Expand Up @@ -178,8 +189,10 @@ def _default_env_factory(
factory when no custom environment factory is provided.
"""
env_vars = dotenv_values(config.env_file_path or ".env")
sdk_config = load_configuration_from_env(env_vars) if sdk_config is None else sdk_config

sdk_config = (
load_configuration_from_env(env_vars) if sdk_config is None else sdk_config
)

connection_manager: Connections
if omit_connections:
connection_manager = ConnectionManager(
Expand All @@ -188,7 +201,7 @@ def _default_env_factory(
"SERVICE_CONNECTION": AgentAuthConfiguration(
anonymous_allowed=True,
)
}
},
)
else:
connection_manager = MsalConnectionManager(**sdk_config)
Expand All @@ -197,7 +210,7 @@ def _default_env_factory(
adapter = CloudAdapter(connection_manager=connection_manager)
authorization = Authorization(storage, connection_manager, **sdk_config)
agent_application = AgentApplication[TurnState](
storage=storage, adapter=adapter, authorization=authorization, **sdk_config
storage=storage, authorization=authorization, **sdk_config
)

return AgentEnvironment(
Expand Down Expand Up @@ -250,17 +263,19 @@ def _env_factory() -> AgentEnvironment:
return AiohttpScenario._default_env_factory(
config=config, sdk_config=sdk_config, omit_connections=omit_connections
)

return AiohttpScenario(
setup,
config,
use_jwt_middleware=use_jwt_middleware,
env_factory=_env_factory,
)

@staticmethod
def from_app(
app: AgentApplication,
*,
Comment thread
rodrigobr-msft marked this conversation as resolved.
adapter: CloudAdapter | None = None,
config: ScenarioConfig | None = None,
use_jwt_middleware: bool = True,
sdk_config: dict | None = None,
Expand All @@ -269,10 +284,15 @@ def from_app(

Use this factory for sample-style modules that already define
AgentApplication and supporting components at module scope. The scenario
hosts the provided application through its configured adapter and exposes
the resulting environment for fixtures and inspection.
hosts the provided application through the supplied adapter and exposes
the resulting environment for fixtures and inspection. When ``adapter``
is omitted, a default CloudAdapter is created from the application's
connection manager. Pass the production adapter when tests need its
middleware, host validation, client factories, or error handling.

:param app: AgentApplication instance to host.
:param adapter: Optional CloudAdapter used to host the application.
When omitted, one is created from ``app.connection_manager``.
:param config: Optional scenario configuration.
:param use_jwt_middleware: Whether to enable JWT middleware on the
aiohttp route.
Expand All @@ -286,11 +306,13 @@ def from_app(
if storage is None:
raise AttributeError("AgentApplication storage could not be resolved.")

adapter = adapter or CloudAdapter(connection_manager=app.connection_manager)

env = AgentEnvironment(
config=sdk_config or {},
agent_application=app,
authorization=app.auth,
adapter=app.adapter,
adapter=adapter,
storage=cast(Storage, storage),
connections=app.connection_manager,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config)

AGENT_APP = AgentApplication[TurnState](
storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config
storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config
)


Expand All @@ -55,6 +55,7 @@ async def on_message(context: TurnContext, _):
try:
start_server(
agent_application=AGENT_APP,
adapter=ADAPTER,
auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(),
)
except Exception as error:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@


def start_server(
agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration
agent_application: AgentApplication,
adapter: CloudAdapter,
auth_configuration: AgentAuthConfiguration,
):
async def entry_point(req: Request) -> Response:
agent: AgentApplication = req.app["agent_app"]
Expand All @@ -25,7 +27,7 @@ async def entry_point(req: Request) -> Response:
APP.router.add_get("/api/messages", lambda _: Response(status=200))
APP["agent_configuration"] = auth_configuration
APP["agent_app"] = agent_application
APP["adapter"] = agent_application.adapter
APP["adapter"] = adapter

try:
run_app(APP, host="localhost", port=environ.get("PORT", 3978))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,40 @@

import pytest

from microsoft_agents.hosting.testing.aiohttp_scenario import AiohttpScenario, AgentEnvironment
from microsoft_agents.hosting.aiohttp import CloudAdapter
from microsoft_agents.hosting.core import (
AgentApplication,
AgentAuthConfiguration,
AnonymousTokenProvider,
Authorization,
ConnectionManager,
MemoryStorage,
TurnState,
)
from microsoft_agents.hosting.testing import aiohttp_scenario
from microsoft_agents.hosting.testing.aiohttp_scenario import (
AiohttpScenario,
AgentEnvironment,
)
from microsoft_agents.hosting.testing.core import Scenario, ScenarioConfig


def _create_agent_application():
connections = ConnectionManager(
provider_factory=lambda _: AnonymousTokenProvider(),
connections_configurations={
"SERVICE_CONNECTION": AgentAuthConfiguration(anonymous_allowed=True)
},
)
storage = MemoryStorage()
authorization = Authorization(storage, connections)
app = AgentApplication[TurnState](
storage=storage,
authorization=authorization,
)
return app, connections


# ============================================================================
# AgentEnvironment Tests
# ============================================================================
Expand Down Expand Up @@ -137,6 +168,47 @@ def init_agent(env: AgentEnvironment) -> None:
assert scenario._env is None


# ============================================================================
# AiohttpScenario Existing Application Tests
# ============================================================================


class TestAiohttpScenarioFromApp:
"""Tests for constructing a scenario from an existing application."""

def test_uses_provided_adapter(self):
"""from_app preserves an explicitly supplied CloudAdapter."""
app, connections = _create_agent_application()
adapter = CloudAdapter(connection_manager=connections)

scenario = AiohttpScenario.from_app(app, adapter=adapter)

assert scenario.agent_environment.adapter is adapter

def test_creates_adapter_from_application_connection_manager(self, monkeypatch):
"""from_app creates a default adapter from the application's connections."""
app, connections = _create_agent_application()
created_adapter = object()

def create_adapter(*, connection_manager):
assert connection_manager is connections
return created_adapter

monkeypatch.setattr(aiohttp_scenario, "CloudAdapter", create_adapter)

scenario = AiohttpScenario.from_app(app)

assert scenario.agent_environment.adapter is created_adapter
assert scenario.agent_environment.connections is connections

def test_optional_arguments_are_keyword_only(self):
"""from_app rejects positional optional arguments."""
app, _ = _create_agent_application()

with pytest.raises(TypeError):
AiohttpScenario.from_app(app, ScenarioConfig())


# ============================================================================
# AiohttpScenario Configuration Tests
# ============================================================================
Expand Down
3 changes: 2 additions & 1 deletion libraries/microsoft-agents-hosting-aiohttp/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER)
AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config)

AGENT_APP = AgentApplication[TurnState](
storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config
storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config
)

@AGENT_APP.activity("message")
Expand All @@ -198,6 +198,7 @@ async def on_message(context: TurnContext, state: TurnState):

start_server(
agent_application=AGENT_APP,
adapter=ADAPTER,
auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(),
)
```
Expand Down
Loading
Loading