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 @@ -77,7 +77,7 @@
TranscriptFormatter,
print_activities,
print_conversation,
print_json
print_json,
)

from .scenario_registry import (
Expand Down Expand Up @@ -122,5 +122,5 @@
"print_activities",
"print_conversation",
"print_json",
"SourceScenario"
"SourceScenario",
]
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,18 @@

from microsoft_agents.activity import load_configuration_from_env
from microsoft_agents.hosting.core import (
AgentApplication, Authorization, ChannelServiceAdapter,
Connections, MemoryStorage, Storage, TurnState,
AgentApplication,
Authorization,
ChannelServiceAdapter,
Connections,
MemoryStorage,
Storage,
TurnState,
)
from microsoft_agents.hosting.aiohttp import (
CloudAdapter, start_agent_process, jwt_authorization_middleware,
CloudAdapter,
start_agent_process,
jwt_authorization_middleware,
)
from microsoft_agents.authentication.msal import MsalConnectionManager

Expand All @@ -36,13 +43,14 @@
ScenarioConfig,
)


@dataclass
class AgentEnvironment:
"""Components available when an in-process agent is running.

Provides access to the agent's infrastructure components for
configuration and inspection during tests.

Attributes:
config: SDK configuration dictionary.
agent_application: The running AgentApplication instance.
Expand All @@ -51,31 +59,33 @@ class AgentEnvironment:
storage: State storage instance (typically MemoryStorage).
connections: Connection manager for external services.
"""

config: dict
agent_application: AgentApplication
authorization: Authorization
adapter: ChannelServiceAdapter
storage: Storage
connections: Connections


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

Use this scenario for integration testing where you want to test the
full agent stack without external dependencies. The agent runs within
the test process, allowing direct access to its components.

Example::

async def init_agent(env: AgentEnvironment):
@env.agent_application.activity(ActivityTypes.message)
async def handler(context, state):
await context.send_activity(f"Echo: {context.activity.text}")

scenario = AiohttpScenario(init_agent)
async with scenario.client() as client:
replies = await client.send("Hello!")

:param init_agent: Async function to initialize the agent with handlers.
:param config: Optional scenario configuration.
:param use_jwt_middleware: Whether to use JWT auth middleware.
Expand All @@ -88,10 +98,10 @@ def __init__(
use_jwt_middleware: bool = True,
) -> None:
super().__init__(config)

if not init_agent:
raise ValueError("init_agent must be provided.")

self._init_agent = init_agent
self._use_jwt_middleware = use_jwt_middleware
self._env: AgentEnvironment | None = None
Expand All @@ -100,7 +110,9 @@ def __init__(
def agent_environment(self) -> AgentEnvironment:
"""Get the agent environment (only valid while scenario is running)."""
if not self._env:
raise RuntimeError("Agent environment not available. Is the scenario running?")
raise RuntimeError(
"Agent environment not available. Is the scenario running?"
)
return self._env

async def _init_agent_environment(self) -> dict:
Expand All @@ -112,18 +124,18 @@ async def _init_agent_environment(self) -> dict:

:return: The SDK configuration dictionary.
"""

env_vars = dotenv_values(self._config.env_file_path or ".env")
sdk_config = load_configuration_from_env(env_vars)

storage = MemoryStorage()
connection_manager = MsalConnectionManager(**sdk_config)
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
)

self._env = AgentEnvironment(
config=sdk_config,
agent_application=agent_application,
Expand All @@ -132,10 +144,10 @@ async def _init_agent_environment(self) -> dict:
storage=storage,
connections=connection_manager,
)

await self._init_agent(self._env)
return sdk_config

def _create_application(self) -> Application:
"""Create and configure the aiohttp Application.

Expand All @@ -145,23 +157,27 @@ def _create_application(self) -> Application:
:return: A configured aiohttp Application.
"""
assert self._env is not None

# Create aiohttp app
middlewares = [jwt_authorization_middleware] if self._use_jwt_middleware else []
app = Application(middlewares=middlewares)
adapter = cast(CloudAdapter, self._env.adapter)

async def entry_point(request: Request) -> Response:
return await start_agent_process(
request,
agent_application=self._env.agent_application,
adapter=adapter,
)

app.router.add_post(
"/api/messages",
entry_point,
)

app["agent_configuration"] = self._env.connections.get_default_connection_configuration()
app["agent_configuration"] = (
self._env.connections.get_default_connection_configuration()
)
app["agent_app"] = self._env.agent_application
app["adapter"] = adapter

Expand All @@ -170,26 +186,26 @@ async def entry_point(request: Request) -> Response:
@asynccontextmanager
async def run(self) -> AsyncIterator[ClientFactory]:
"""Start the scenario and yield a client factory."""

sdk_config = await self._init_agent_environment()
app = self._create_application()

# Start response server
callback_server = AiohttpCallbackServer(self._config.callback_server_port)

async with callback_server.listen() as transcript:
async with TestServer(app, port=3978) as server:
agent_endpoint = f"http://127.0.0.1:{server.port}/api/messages"

factory = _AiohttpClientFactory(
agent_endpoint=agent_endpoint,
response_endpoint=callback_server.service_endpoint,
sdk_config=sdk_config,
default_config=self._config.client_config,
transcript=transcript,
)

try:
yield factory
finally:
await factory.cleanup()
await factory.cleanup()
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@

from .main import main

__all__ = [ "main" ]
__all__ = ["main"]
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
init_group,
]

__all__ = ["COMMANDS"]
__all__ = ["COMMANDS"]
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
pass_config,
)


@click.command("env")
@pass_output
@pass_config
Expand All @@ -42,4 +43,4 @@ def env(config: CLIConfig, out: Output):
out.info(f"\tEnvironment file: {config.env_path if config.env_path else 'None'}")
out.info("\tEnvironment variables from file:")
for key in config.env.keys():
out.info(f"\t\t{key}")
out.info(f"\t\t{key}")
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
from . import _show
from . import _help

__all__ = ["env_group"]
__all__ = ["env_group"]
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from microsoft_agents.testing.cli.core import Output, pass_output
from .env_group import env_group


@env_group.command("help")
@pass_output
def help(out: Output):
Expand All @@ -19,7 +20,9 @@ def help(out: Output):

:param out: CLI output helper.
"""
out.info("In the current directory, create a new .env file with the following variables defined:")
out.info(
"In the current directory, create a new .env file with the following variables defined:"
)
out.info("\tCONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=")
out.info("\tCONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=")
out.info("\tCONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=")
out.info("\tCONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=")
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from .env_group import env_group


@env_group.command("show")
@pass_output
@pass_config
Expand All @@ -41,4 +42,4 @@ def show(config: CLIConfig, out: Output):
out.info(f"\tEnvironment file: {config.env_path if config.env_path else 'None'}")
out.info("\tEnvironment variables from file:")
for key in config.env.keys():
out.info(f"\t\t{key}")
out.info(f"\t\t{key}")
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import click


@click.group(name="env")
def env_group():
"""Manage test environments."""
"""Manage test environments."""
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@
def _discover_presets() -> dict:
try:
return {
entry.name: entry
for entry in _PRESETS_ROOT.iterdir()
if entry.is_dir()
entry.name: entry for entry in _PRESETS_ROOT.iterdir() if entry.is_dir()
}
except (FileNotFoundError, NotADirectoryError):
return {}
Expand Down Expand Up @@ -50,7 +48,12 @@ def _copy_traversable(src, dest: Path) -> None:
# ---------------------------------------------------------------------------
@click.command(name="init")
@click.argument("preset", required=False, default=None)
@click.option("--force", is_flag=True, default=False, help="Overwrite any existing files from the preset.")
@click.option(
"--force",
is_flag=True,
default=False,
help="Overwrite any existing files from the preset.",
)
@pass_output
def init_group(out: Output, preset: str, force: bool) -> None:
"""Scaffold a test harness from a preset template.
Expand Down Expand Up @@ -105,6 +108,10 @@ def init_group(out: Output, preset: str, force: bool) -> None:
if item.is_dir() and (cwd / item.name / "env.TEMPLATE").exists()
]
for template in env_templates:
out.info(f"\t{template.parent.name}/env.TEMPLATE → {template.parent.name}/.env")
out.info(
f"\t{template.parent.name}/env.TEMPLATE → {template.parent.name}/.env"
)
out.info("")
out.info("Populate each .env with your app credentials and configuration before running.")
out.info(
"Populate each .env with your app credentials and configuration before running."
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
from . import _post
from . import _run

__all__ = ["scenario_group"]
__all__ = ["scenario_group"]
Loading
Loading