From 6fabffd4d1aafa1052ec25c99df6a1502c8c72e4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Jul 2026 14:04:45 -0700 Subject: [PATCH 1/5] Polish --- .../microsoft_agents/testing/cli/commands/scenario/_post.py | 2 +- .../testing/core/fluent/backend/model_predicate.py | 2 +- .../microsoft_agents/testing/core/fluent/expect.py | 4 ++-- .../microsoft_agents/testing/core/type_defs.py | 3 +++ 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_post.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_post.py index 51e1e183..ee5c15bd 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_post.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_post.py @@ -22,7 +22,7 @@ @with_scenario @click.option("--message", "-m", required=False, help="Text message to send to the agent.") @click.option( - "--json-file", + "--json_file", "-j", "json_file", required=False, diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py index 469555b0..5d7b85aa 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py @@ -55,7 +55,7 @@ def _truthy( if predicate_paths: return all(bool(self._get_path(result, path)) for path in predicate_paths) - res: Sequence[bool] = [] + res: list[bool] = [] if isinstance(result, dict): iterable = result.values() diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py index 03f954ee..c1cc7aa3 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import Callable, Iterable, Self, TypeVar, Generic, Sequence +from typing import Callable, Self, TypeVar, Generic, Sequence from pydantic import BaseModel @@ -51,7 +51,7 @@ class ExpectBase(Generic[ModelT]): def __init__(self, items: Sequence[ModelT]) -> None: """Initialize Expect with a collection of items. - :param items: An iterable of dicts or BaseModel instances. + :param items: A Sequence of dicts or BaseModel instances. """ self._items = list(items) self._describer = Describe() diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/type_defs.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/type_defs.py index 5f0393d3..ec3541ec 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/type_defs.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/type_defs.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from microsoft_agents.activity import Activity from .fluent import ExpectBase, SelectBase From a43d25e58667dbacf75a5a4b6e373fcf90e8b602 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Jul 2026 14:05:26 -0700 Subject: [PATCH 2/5] Formatting --- .../microsoft_agents/testing/__init__.py | 4 +- .../testing/aiohttp_scenario.py | 66 ++-- .../microsoft_agents/testing/cli/__init__.py | 2 +- .../testing/cli/commands/__init__.py | 2 +- .../testing/cli/commands/env.py | 3 +- .../cli/commands/environment/__init__.py | 2 +- .../testing/cli/commands/environment/_help.py | 7 +- .../testing/cli/commands/environment/_show.py | 3 +- .../cli/commands/environment/env_group.py | 3 +- .../testing/cli/commands/init.py | 19 +- .../testing/cli/commands/scenario/__init__.py | 2 +- .../testing/cli/commands/scenario/_chat.py | 55 ++- .../testing/cli/commands/scenario/_list.py | 6 +- .../testing/cli/commands/scenario/_load.py | 87 +++-- .../testing/cli/commands/scenario/_post.py | 21 +- .../testing/cli/commands/scenario/_run.py | 16 +- .../testing/cli/commands/scenario/_utils.py | 40 +- .../cli/commands/scenario/scenario_group.py | 3 +- .../testing/cli/core/__init__.py | 2 +- .../testing/cli/core/cli_config.py | 44 ++- .../testing/cli/core/decorators.py | 33 +- .../testing/cli/core/output.py | 31 +- .../testing/cli/core/utils.py | 13 +- .../microsoft_agents/testing/cli/main.py | 24 +- .../testing/cli/scenarios/__init__.py | 8 +- .../testing/cli/scenarios/auth_scenario.py | 42 ++- .../testing/cli/scenarios/basic_scenario.py | 4 +- .../microsoft_agents/testing/core/__init__.py | 2 +- .../testing/core/_aiohttp_client_factory.py | 20 +- .../testing/core/agent_client.py | 124 ++++--- .../microsoft_agents/testing/core/config.py | 27 +- .../testing/core/external_scenario.py | 18 +- .../testing/core/fluent/__init__.py | 2 +- .../testing/core/fluent/backend/__init__.py | 2 +- .../testing/core/fluent/backend/describe.py | 41 ++- .../core/fluent/backend/model_predicate.py | 38 +- .../testing/core/fluent/backend/quantifier.py | 14 +- .../testing/core/fluent/backend/transform.py | 58 +-- .../core/fluent/backend/types/__init__.py | 2 +- .../core/fluent/backend/types/readonly.py | 14 +- .../core/fluent/backend/types/safe_object.py | 22 +- .../core/fluent/backend/types/unset.py | 25 +- .../testing/core/fluent/backend/utils.py | 30 +- .../testing/core/fluent/expect.py | 51 +-- .../testing/core/fluent/model_template.py | 57 +-- .../testing/core/fluent/select.py | 53 +-- .../testing/core/fluent/utils.py | 9 +- .../microsoft_agents/testing/core/scenario.py | 22 +- .../testing/core/transport/__init__.py | 4 +- .../core/transport/aiohttp_callback_server.py | 29 +- .../testing/core/transport/aiohttp_sender.py | 30 +- .../testing/core/transport/callback_server.py | 9 +- .../testing/core/transport/sender.py | 10 +- .../core/transport/transcript/__init__.py | 2 +- .../core/transport/transcript/exchange.py | 51 +-- .../core/transport/transcript/transcript.py | 18 +- .../testing/core/type_defs.py | 6 + .../microsoft_agents/testing/core/utils.py | 12 +- .../testing/formatting/__init__.py | 10 +- .../activity_transcript_formatter.py | 9 +- .../conversation_transcript_formatter.py | 36 +- .../formatting/json_transcript_formatter.py | 7 +- .../testing/formatting/print.py | 7 +- .../formatting/transcript_formatter.py | 3 +- .../testing/formatting/utils.py | 6 +- .../presets/basic/e2e-tests/tests/__init__.py | 2 +- .../basic/e2e-tests/tests/test_my_agent.py | 3 +- .../localhost/e2e-tests/tests/__init__.py | 2 +- .../e2e-tests/tests/test_my_agent.py | 3 +- .../microsoft_agents/testing/pytest_plugin.py | 15 +- .../testing/scenario_registry.py | 76 ++-- .../testing/source_scenario.py | 14 +- .../microsoft_agents/testing/utils/poll.py | 9 +- .../microsoft_agents/testing/utils/send.py | 23 +- .../tests/cli/test_cli_integration.py | 74 ++-- .../tests/cli/test_output.py | 78 ++-- .../core/fluent/backend/test_describe.py | 126 ++++--- .../fluent/backend/test_model_predicate.py | 62 ++-- .../core/fluent/backend/test_transform.py | 14 +- .../fluent/backend/types/test_readonly.py | 36 +- .../core/fluent/backend/types/test_unset.py | 2 +- .../tests/core/fluent/test_model_template.py | 68 ++-- .../tests/core/fluent/test_select.py | 1 + .../tests/core/test_agent_client.py | 197 +++++----- .../tests/core/test_aiohttp_client_factory.py | 133 +++---- .../tests/core/test_config.py | 119 +++--- .../tests/core/test_external_scenario.py | 345 +++++++++++------- .../tests/core/test_integration.py | 327 +++++++++-------- .../tests/core/test_type_defs.py | 1 - .../transport/test_aiohttp_callback_server.py | 68 ++-- .../core/transport/test_aiohttp_sender.py | 145 ++++---- .../transport/transcript/test_exchange.py | 164 ++++----- .../transport/transcript/test_transcript.py | 98 ++--- dev/microsoft-agents-testing/tests/manual.py | 6 +- .../tests/test_aiohttp_scenario.py | 4 +- .../test_aiohttp_scenario_integration.py | 14 +- .../tests/test_pytest_plugin.py | 21 +- .../tests/test_scenario_registry.py | 82 ++--- .../tests/test_scenario_registry_plugin.py | 2 +- .../tests/test_source_scenario.py | 69 ++-- .../tests/test_transcript_formatter.py | 36 +- .../tests/utils/test_poll.py | 4 +- .../tests/utils/test_pred.py | 14 +- 103 files changed, 2164 insertions(+), 1715 deletions(-) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py index fbcb8ee3..fc3fc677 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py @@ -77,7 +77,7 @@ TranscriptFormatter, print_activities, print_conversation, - print_json + print_json, ) from .scenario_registry import ( @@ -122,5 +122,5 @@ "print_activities", "print_conversation", "print_json", - "SourceScenario" + "SourceScenario", ] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py b/dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py index 8fb57d7c..b5d35e63 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py @@ -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 @@ -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. @@ -51,6 +59,7 @@ class AgentEnvironment: storage: State storage instance (typically MemoryStorage). connections: Connection manager for external services. """ + config: dict agent_application: AgentApplication authorization: Authorization @@ -58,24 +67,25 @@ class AgentEnvironment: 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. @@ -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 @@ -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: @@ -112,10 +124,10 @@ 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) @@ -123,7 +135,7 @@ async def _init_agent_environment(self) -> dict: agent_application = AgentApplication[TurnState]( storage=storage, adapter=adapter, authorization=authorization, **sdk_config ) - + self._env = AgentEnvironment( config=sdk_config, agent_application=agent_application, @@ -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. @@ -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 @@ -170,17 +186,17 @@ 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, @@ -188,8 +204,8 @@ async def run(self) -> AsyncIterator[ClientFactory]: default_config=self._config.client_config, transcript=transcript, ) - + try: yield factory finally: - await factory.cleanup() \ No newline at end of file + await factory.cleanup() diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py index 9a9db654..98eb03fa 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py @@ -15,4 +15,4 @@ from .main import main -__all__ = [ "main" ] \ No newline at end of file +__all__ = ["main"] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py index 6dadd233..7134294d 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py @@ -21,4 +21,4 @@ init_group, ] -__all__ = ["COMMANDS"] \ No newline at end of file +__all__ = ["COMMANDS"] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py index 194763f0..65d2090b 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py @@ -21,6 +21,7 @@ pass_config, ) + @click.command("env") @pass_output @pass_config @@ -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}") \ No newline at end of file + out.info(f"\t\t{key}") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/__init__.py index 98d6d7ee..6cd123a4 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/__init__.py @@ -7,4 +7,4 @@ from . import _show from . import _help -__all__ = ["env_group"] \ No newline at end of file +__all__ = ["env_group"] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/_help.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/_help.py index 7fe8bae3..9f224ea2 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/_help.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/_help.py @@ -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): @@ -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=") \ No newline at end of file + out.info("\tCONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/_show.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/_show.py index e3525ea0..a9f4cad4 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/_show.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/_show.py @@ -20,6 +20,7 @@ from .env_group import env_group + @env_group.command("show") @pass_output @pass_config @@ -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}") \ No newline at end of file + out.info(f"\t\t{key}") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/env_group.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/env_group.py index 1c7c4d83..a353352d 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/env_group.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/environment/env_group.py @@ -8,6 +8,7 @@ import click + @click.group(name="env") def env_group(): - """Manage test environments.""" \ No newline at end of file + """Manage test environments.""" diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/init.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/init.py index 35edc542..bceff889 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/init.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/init.py @@ -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 {} @@ -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. @@ -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." + ) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/__init__.py index d41dbb88..7493d7f6 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/__init__.py @@ -10,4 +10,4 @@ from . import _post from . import _run -__all__ = ["scenario_group"] \ No newline at end of file +__all__ = ["scenario_group"] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_chat.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_chat.py index 7ab371da..ea4ea426 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_chat.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_chat.py @@ -13,16 +13,17 @@ from .scenario_group import scenario_group + @scenario_group.command("chat") @async_command @pass_output @with_scenario async def chat(out: Output, scenario: Scenario) -> None: """Interactive chat with an agent. - + Starts a REPL-style conversation where you can send messages and see the agent's responses in real-time. - + Examples: \b @@ -35,41 +36,53 @@ async def chat(out: Output, scenario: Scenario) -> None: """ # Print welcome banner out.newline() - click.secho("╔══════════════════════════════════════════════════════════════╗", fg="cyan") - click.secho("║ 🤖 Agent Chat Interface 🤖 ║", fg="cyan") - click.secho("╚══════════════════════════════════════════════════════════════╝", fg="cyan") + click.secho( + "╔══════════════════════════════════════════════════════════════╗", fg="cyan" + ) + click.secho( + "║ 🤖 Agent Chat Interface 🤖 ║", fg="cyan" + ) + click.secho( + "╚══════════════════════════════════════════════════════════════╝", fg="cyan" + ) out.newline() - click.secho(" Type your message and press Enter to chat with the agent.", fg="white", dim=True) - click.secho(" Type '/exit' or '/quit' to end the conversation.", fg="white", dim=True) + click.secho( + " Type your message and press Enter to chat with the agent.", + fg="white", + dim=True, + ) + click.secho( + " Type '/exit' or '/quit' to end the conversation.", fg="white", dim=True + ) click.secho(" ─" * 32, fg="cyan", dim=True) out.newline() async with scenario.client() as client: message_count = 0 - + while True: # User input prompt with styling click.secho("You: ", fg="green", bold=True, nl=False) user_input = click.prompt("", prompt_suffix="") - + if user_input.lower() in ("/exit", "/quit"): break - + if not user_input.strip(): click.secho(" (empty message, skipping...)", fg="yellow", dim=True) continue - + message_count += 1 - + # Show thinking indicator click.secho(" ⏳ Agent is thinking...", fg="cyan", dim=True) - + try: replies = await client.send_expect_replies(user_input) - + # Clear the "thinking" line by moving up (optional, works in most terminals) click.echo("\033[A\033[K", nl=False) # Move up and clear line - + if replies: for reply in replies: if reply.type == "message" and reply.text: @@ -80,19 +93,21 @@ async def chat(out: Output, scenario: Scenario) -> None: pass else: # Show other activity types in debug style - click.secho(f" [activity: {reply.type}]", fg="magenta", dim=True) + click.secho( + f" [activity: {reply.type}]", fg="magenta", dim=True + ) else: click.secho(" (no response from agent)", fg="yellow", dim=True) - + except Exception as e: click.secho(f" ❌ Error: {e}", fg="red") - + out.newline() - + # Print exit summary out.newline() click.secho(" ─" * 32, fg="cyan", dim=True) click.secho(f" 📊 Session Summary: {message_count} messages exchanged", fg="cyan") out.newline() out.success("Chat session ended. Goodbye!") - out.newline() \ No newline at end of file + out.newline() diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_list.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_list.py index 3788e7cf..73056f5a 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_list.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_list.py @@ -3,14 +3,12 @@ import click -from microsoft_agents.testing.cli.core import ( - pass_output, - Output -) +from microsoft_agents.testing.cli.core import pass_output, Output from microsoft_agents.testing.scenario_registry import scenario_registry from .scenario_group import scenario_group + @scenario_group.command("list") @click.argument("pattern", default="*") @pass_output diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_load.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_load.py index e2ebadf4..58b75f16 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_load.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_load.py @@ -12,16 +12,14 @@ async_command, pass_output, Output, - with_scenario -) -from microsoft_agents.testing.core import ( - Exchange, - Scenario + with_scenario, ) +from microsoft_agents.testing.core import Exchange, Scenario from .scenario_group import scenario_group from ._utils import load_activity, console_histogram + @dataclass class RunResult: """Represents the result of a load test run.""" @@ -30,7 +28,10 @@ class RunResult: error: bool error_message: str | None -async def run_load_test(scenario: Scenario, activity: Activity, num: int, timeout: float) -> list[RunResult | None]: + +async def run_load_test( + scenario: Scenario, activity: Activity, num: int, timeout: float +) -> list[RunResult | None]: """Run a load test with the given scenario, activity, and parameters.""" results: list[RunResult | None] = [None] * num @@ -51,27 +52,24 @@ async def send_activity(run_id: int): results[run_id] = RunResult( latency=exchange.latency, error=exchange.is_error, - error_message=exchange.error + error_message=exchange.error, ) except asyncio.TimeoutError: results[run_id] = RunResult( latency=timedelta(milliseconds=timeout), error=True, - error_message="Request timed out" + error_message="Request timed out", ) except Exception as e: results[run_id] = RunResult( - latency=None, - error=True, - error_message=str(e) + latency=None, error=True, error_message=str(e) ) - await asyncio.gather( - *[send_activity(i) for i in range(num)] - ) + await asyncio.gather(*[send_activity(i) for i in range(num)]) return results + def show_results(results: list[RunResult | None], out: Output) -> None: """Display the results of a load test.""" @@ -89,7 +87,7 @@ def show_results(results: list[RunResult | None], out: Output) -> None: latencies.append(result.latency) else: latencies.append(result.latency) - + out.info(f"Completed {len(results) - len(error_ids) - len(missing_ids)} requests.") if error_ids: out.info(f"Failed {len(error_ids)} requests.") @@ -97,31 +95,58 @@ def show_results(results: list[RunResult | None], out: Output) -> None: out.info(f"Missing {len(missing_ids)} requests.") if latencies: - out.info(f"Average latency: {sum(latencies, timedelta()).total_seconds() / len(latencies) * 1000:.2f} ms") + out.info( + f"Average latency: {sum(latencies, timedelta()).total_seconds() / len(latencies) * 1000:.2f} ms" + ) out.info(f"Minimum latency: {min(latencies).total_seconds() * 1000:.2f} ms") out.info(f"Maximum latency: {max(latencies).total_seconds() * 1000:.2f} ms") - out.info(f"90th percentile latency: {sorted(latencies)[int(len(latencies) * 0.9)].total_seconds() * 1000:.2f} ms") - + out.info( + f"90th percentile latency: {sorted(latencies)[int(len(latencies) * 0.9)].total_seconds() * 1000:.2f} ms" + ) + out.newline() out.info("Latency distribution (ms):") console_histogram( - [ latency.total_seconds() * 1000 for latency in latencies ], - out, - bins=10 + [latency.total_seconds() * 1000 for latency in latencies], out, bins=10 ) - @scenario_group.command("load") @async_command @pass_output @with_scenario -@click.option("--message", "-m", required=False, help="Text message to send to the agent.") -@click.option("--json_file", "-j", required=False, type=click.File("rb"), help="JSON activity to send to the agent.") -@click.option("--num", "-n", required=True, type=int, help="Number of concurrent requests to make.") -@click.option("--timeout", "-t", default=5000, help="Milliseconds to wait for a response before timing out.") -async def load(out: Output, scenario: Scenario, message: str | None, json_file, num: int, timeout: float) -> None: +@click.option( + "--message", "-m", required=False, help="Text message to send to the agent." +) +@click.option( + "--json_file", + "-j", + required=False, + type=click.File("rb"), + help="JSON activity to send to the agent.", +) +@click.option( + "--num", + "-n", + required=True, + type=int, + help="Number of concurrent requests to make.", +) +@click.option( + "--timeout", + "-t", + default=5000, + help="Milliseconds to wait for a response before timing out.", +) +async def load( + out: Output, + scenario: Scenario, + message: str | None, + json_file, + num: int, + timeout: float, +) -> None: """Run a concurrent load test against an agent and report latency statistics. Sends the same message or activity to the agent ``--num`` times concurrently @@ -136,9 +161,11 @@ async def load(out: Output, scenario: Scenario, message: str | None, json_file, :param num: Number of concurrent requests to send. :param timeout: Milliseconds to wait per request before treating it as a timeout error. """ - + activity = load_activity(message, json_file, out) - results: list[RunResult | None] = await run_load_test(scenario, activity, num, timeout) + results: list[RunResult | None] = await run_load_test( + scenario, activity, num, timeout + ) - show_results(results, out) \ No newline at end of file + show_results(results, out) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_post.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_post.py index ee5c15bd..28143934 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_post.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_post.py @@ -7,7 +7,7 @@ async_command, pass_output, Output, - with_scenario + with_scenario, ) from microsoft_agents.testing.core import Scenario from microsoft_agents.testing.formatting import ActivityTranscriptFormatter @@ -20,7 +20,9 @@ @async_command @pass_output @with_scenario -@click.option("--message", "-m", required=False, help="Text message to send to the agent.") +@click.option( + "--message", "-m", required=False, help="Text message to send to the agent." +) @click.option( "--json_file", "-j", @@ -36,7 +38,9 @@ type=int, help="Milliseconds to wait for a response before timing out.", ) -async def post(out: Output, scenario: Scenario, message: str | None, json_file, timeout: int) -> None: +async def post( + out: Output, scenario: Scenario, message: str | None, json_file, timeout: int +) -> None: """Send a single message or activity to an agent and display the transcript. Provide either a text message as an argument or a JSON activity file via --json-file. @@ -47,19 +51,16 @@ async def post(out: Output, scenario: Scenario, message: str | None, json_file, :param json_file: File handle for a JSON activity payload. :param timeout: Milliseconds to wait for a response before timing out. """ - + activity = load_activity(message, json_file, out) - + async with scenario.client() as client: - await client.send(activity, wait=timeout/1000) + await client.send(activity, wait=timeout / 1000) transcript = client.transcript text = ActivityTranscriptFormatter( - model_dump_args={ - "exclude_unset": True, - "exclude_none": True - } + model_dump_args={"exclude_unset": True, "exclude_none": True} ).format(transcript) out.info("Transcript of the conversation:") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_run.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_run.py index 9f00a103..5e9403f6 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_run.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_run.py @@ -10,13 +10,11 @@ Output, with_scenario, ) -from microsoft_agents.testing.core import ( - Scenario, - ExternalScenario -) +from microsoft_agents.testing.core import Scenario, ExternalScenario from .scenario_group import scenario_group + @scenario_group.command("run") @async_command @pass_output @@ -31,9 +29,11 @@ async def run(out: Output, scenario: Scenario) -> None: :param scenario: The resolved Scenario instance. """ if isinstance(scenario, ExternalScenario): - out.error("Running an ExternalScenario is not supported in this command. Please use specific commands designed for interaction, such as 'chat' or 'post'.") + out.error( + "Running an ExternalScenario is not supported in this command. Please use specific commands designed for interaction, such as 'chat' or 'post'." + ) raise click.Abort() - + try: async with scenario.run(): out.newline() @@ -44,6 +44,6 @@ async def run(out: Output, scenario: Scenario) -> None: await asyncio.Event().wait() except asyncio.CancelledError: pass - + out.newline() - out.success("Scenario stopped.") \ No newline at end of file + out.success("Scenario stopped.") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_utils.py index ca6413ba..f9301a6c 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/_utils.py @@ -5,6 +5,7 @@ from microsoft_agents.testing.cli.core import Output from microsoft_agents.testing.core import ActivityTemplate + def load_activity(message: str | None, json_file, out: Output) -> Activity: """Load an activity from a message or JSON file.""" @@ -12,24 +13,27 @@ def load_activity(message: str | None, json_file, out: Output) -> Activity: out.error("Either --message or --json-file must be provided.", exit=True) if message and json_file: - out.error("Cannot provide both --message and --json-file. Please choose one.", exit=True) + out.error( + "Cannot provide both --message and --json-file. Please choose one.", + exit=True, + ) activity: Activity - template = ActivityTemplate().with_defaults({ - "type": "message", - "channel_id": "test", - "conversation.id": "test-conversation", - "locale": "en-US", - "from.id": "user-id", - "from.name": "User", - "recipient.id": "agent-id", - "recipient.name": "Agent", - }) + template = ActivityTemplate().with_defaults( + { + "type": "message", + "channel_id": "test", + "conversation.id": "test-conversation", + "locale": "en-US", + "from.id": "user-id", + "from.name": "User", + "recipient.id": "agent-id", + "recipient.name": "Agent", + } + ) if message: assert isinstance(message, str) - activity = template.create({ - "text": message - }) + activity = template.create({"text": message}) else: data = json.load(json_file) activity = template.create(data) @@ -37,10 +41,10 @@ def load_activity(message: str | None, json_file, out: Output) -> Activity: return activity -def console_histogram(data, out: Output, bins: int = 10, char: str = '█'): +def console_histogram(data, out: Output, bins: int = 10, char: str = "█"): """ Prints a text-based histogram in the console. - + :param data: List of numeric values :param bins: Number of bins to group data :param char: Character used for bars @@ -48,7 +52,7 @@ def console_histogram(data, out: Output, bins: int = 10, char: str = '█'): if not data: out.error("No data provided.", exit=True) return - + # Validate numeric data try: data = [float(x) for x in data] @@ -78,4 +82,4 @@ def console_histogram(data, out: Output, bins: int = 10, char: str = '█'): bar = char * math.ceil((count / max_count) * 50) # scale to max 50 chars bin_start = min_val + i * bin_width bin_end = bin_start + bin_width - out.info(f"{bin_start:>7.2f} - {bin_end:>7.2f} | {bar} ({count})") \ No newline at end of file + out.info(f"{bin_start:>7.2f} - {bin_end:>7.2f} | {bar} ({count})") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/scenario_group.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/scenario_group.py index fa60d05a..10441790 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/scenario_group.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario/scenario_group.py @@ -9,6 +9,7 @@ import click + @click.group(name="scenario") def scenario_group(): - """Manage test scenarios.""" \ No newline at end of file + """Manage test scenarios.""" diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py index 03664527..8391dbbd 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py @@ -21,4 +21,4 @@ "pass_config", "pass_output", "with_scenario", -] \ No newline at end of file +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py index d65297fb..7ef4d93e 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py @@ -27,28 +27,29 @@ def load_environment( Both values are empty/blank strings if the file does not exist. """ path = Path(env_path) if env_path else Path(".env") - + if not path.exists(): return {}, "" - + resolved_path = str(path.resolve()) - + env = dotenv_values(str(resolved_path)) return env, resolved_path + def _upper(d: dict) -> dict: """Convert all keys in the dictionary to uppercase.""" - return { key.upper(): value for key, value in d.items() } + return {key.upper(): value for key, value in d.items()} class CLIConfig: """Configuration manager for the CLI. - + Loads and manages configuration from environment files and process environment variables, providing access to authentication credentials and service URLs. - + Attributes: env_path: Path to the loaded .env file, if any. env: Dictionary of loaded environment variables. @@ -76,13 +77,16 @@ def __init__(self, env_path: str | None, connection: str) -> None: self._agent_url: str | None = None self._service_url: str | None = None - self._load(self._env, { - f"CONNECTIONS__{self._connection}__SETTINGS__CLIENTID": "_app_id", - f"CONNECTIONS__{self._connection}__SETTINGS__CLIENTSECRET": "_app_secret", - f"CONNECTIONS__{self._connection}__SETTINGS__TENANTID": "_tenant_id", - "AGENT_URL": "_agent_url", - "SERVICE_URL": "_service_url", - }) + self._load( + self._env, + { + f"CONNECTIONS__{self._connection}__SETTINGS__CLIENTID": "_app_id", + f"CONNECTIONS__{self._connection}__SETTINGS__CLIENTSECRET": "_app_secret", + f"CONNECTIONS__{self._connection}__SETTINGS__TENANTID": "_tenant_id", + "AGENT_URL": "_agent_url", + "SERVICE_URL": "_service_url", + }, + ) @property def env_path(self) -> str | None: @@ -93,32 +97,32 @@ def env_path(self) -> str | None: def env(self) -> dict: """The loaded environment variables.""" return self._env - + @property def app_id(self) -> str | None: """The application (client) ID.""" return self._app_id - + @property def app_secret(self) -> str | None: """The application (client) secret.""" return self._app_secret - + @property def tenant_id(self) -> str | None: """The tenant ID.""" return self._tenant_id - + @property def agent_url(self) -> str | None: """The agent service URL.""" return self._agent_url - + @property def service_url(self) -> str | None: """The service URL.""" return self._service_url - + def _load(self, source_dict: dict, key_attr_map: dict) -> None: """Load configuration values from a source dictionary into instance attributes. @@ -128,4 +132,4 @@ def _load(self, source_dict: dict, key_attr_map: dict) -> None: for key, attr_name in key_attr_map.items(): if key in source_dict: value = source_dict[key] - setattr(self, attr_name, value) \ No newline at end of file + setattr(self, attr_name, value) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py index 719c1f7c..bd003a74 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py @@ -15,6 +15,7 @@ from .utils import _resolve_scenario + def pass_config(func: Callable) -> Callable: """Decorator that injects CLIConfig from the click context. @@ -23,6 +24,7 @@ def pass_config(func: Callable) -> Callable: :param func: The function to decorate. :return: The wrapped function. """ + @click.pass_context @wraps(func) def wrapper(ctx: click.Context, *args: Any, **kwargs: Any) -> Any: @@ -30,8 +32,10 @@ def wrapper(ctx: click.Context, *args: Any, **kwargs: Any) -> Any: if config is None: raise RuntimeError("CLIConfig not found in context") return func(config=config, *args, **kwargs) + return wrapper + def pass_output(func: Callable) -> Callable: """Decorator that injects the Output helper from the click context. @@ -40,6 +44,7 @@ def pass_output(func: Callable) -> Callable: :param func: The function to decorate. :return: The wrapped function. """ + @click.pass_context @wraps(func) def wrapper(ctx: click.Context, *args: Any, **kwargs: Any) -> Any: @@ -47,23 +52,27 @@ def wrapper(ctx: click.Context, *args: Any, **kwargs: Any) -> Any: if out is None: raise RuntimeError("Output not found in context") return func(out=out, *args, **kwargs) + return wrapper + def async_command(func: Callable) -> Callable: """Decorator to run an async function as a click command. - + Example: @click.command() @async_command async def my_command(): await some_async_operation() """ - + @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: return asyncio.run(func(*args, **kwargs)) + return wrapper + def with_scenario(func: Callable) -> Callable: """Decorator for commands that can interact with agents via scenarios. @@ -96,15 +105,17 @@ async def chat(scenario: Scenario) -> None: Example: ``agt scenario chat --agent agt.basic`` """ - + @click.option( - "--url", "-u", + "--url", + "-u", "agent_url", default=None, help="URL of the external agent to connect to.", ) @click.option( - "--agent", "-a", + "--agent", + "-a", "agent_name", default=None, help="Name of the agent to use.", @@ -128,19 +139,19 @@ def wrapper( # Get config and output directly from context config = ctx.obj.get("config") out = ctx.obj.get("out") - + if config is None: raise RuntimeError("CLIConfig not found in context") if out is None: raise RuntimeError("Output not found in context") - + if agent_url and agent_name: out.error("Only one of --url or --agent can be specified.", exit=True) elif not agent_url and not agent_name: out.error("Either --url or --agent must be specified.", exit=True) agent_name_or_url = agent_url or agent_name - + # Determine which scenario to use based on CLI arguments scenario = _resolve_scenario( agent_name_or_url=agent_name_or_url, @@ -156,10 +167,10 @@ def wrapper( config=config, out=out, ) - + if not scenario: out.error("Failed to locate the scenario. Please check your options.") raise click.Abort() return func(scenario=scenario, *args, **kwargs) - - return wrapper \ No newline at end of file + + return wrapper diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py index d649a59a..a001cf35 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py @@ -13,19 +13,19 @@ class Output: """Helper class for consistent CLI output formatting. - + Provides styled output methods and table formatting utilities. - + Example: >>> out = Output() >>> out.success("Operation completed!") >>> out.error("Something went wrong") >>> out.table(headers=["Name", "Value"], rows=[["foo", "bar"]]) """ - + def __init__(self, verbose: bool = False): """Initialize the output helper. - + Args: verbose: Whether to show verbose output. """ @@ -70,13 +70,13 @@ def key_value(self, key: str, value: Any) -> None: click.echo(f" {click.style(key + ':', bold=True)} {value}") def table( - self, - headers: list[str], + self, + headers: list[str], rows: list[list[Any]], col_widths: Optional[list[int]] = None, ) -> None: """Display a simple ASCII table. - + Args: headers: Column header names. rows: List of row data (each row is a list of values). @@ -88,17 +88,15 @@ def table( for row in rows: for i, cell in enumerate(row): col_widths[i] = max(col_widths[i], len(str(cell))) - + # Add padding col_widths = [w + 2 for w in col_widths] - + # Header row - header_row = "".join( - str(h).ljust(col_widths[i]) for i, h in enumerate(headers) - ) + header_row = "".join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers)) click.secho(header_row, bold=True) click.echo("-" * sum(col_widths)) - + # Data rows for row in rows: row_str = "".join( @@ -109,11 +107,14 @@ def table( def json(self, data: Any) -> None: """Display data as formatted JSON.""" import json + click.echo(json.dumps(data, indent=2, default=str)) def activity(self, activity: Activity) -> None: """Display an activity object as formatted JSON.""" - self.json(activity.model_dump_json(exclude_unset=True, exclude_none=True, indent=2)) + self.json( + activity.model_dump_json(exclude_unset=True, exclude_none=True, indent=2) + ) def divider(self) -> None: """Display a horizontal divider.""" @@ -122,7 +123,7 @@ def divider(self) -> None: def prompt(self) -> str: """Prompt the user for input.""" return click.prompt(">> ") - + @contextmanager def text_loading(self, message: str) -> Iterator[None]: """Context manager for displaying a loading message.""" diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py index 07ced771..02dbdd1c 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py @@ -19,6 +19,7 @@ from .cli_config import CLIConfig from .output import Output + def _resolve_scenario( agent_name_or_url: str | None, module_path: str | None, @@ -37,7 +38,7 @@ def _resolve_scenario( :param out: Output helper for debug messages. :return: A resolved Scenario, or None if resolution fails. """ - + scenario_config = ScenarioConfig( env_file_path=config.env_path, ) @@ -46,17 +47,19 @@ def _resolve_scenario( # BUG: Only URLs starting with "https://" are detected as external # endpoints. Plain "http://" URLs (e.g., http://localhost:3978/...) # fall through to the registry lookup and will fail to resolve. - if agent_name_or_url.startswith("https://") or agent_name_or_url.startswith("http://"): + if agent_name_or_url.startswith("https://") or agent_name_or_url.startswith( + "http://" + ): out.debug(f"Using external agent at: {agent_name_or_url}") - return ExternalScenario(agent_name_or_url, config=scenario_config) + return ExternalScenario(agent_name_or_url, config=scenario_config) else: if module_path: load_scenarios(module_path) out.debug(f"Scenarios loaded from module: {module_path}") - + try: return scenario_registry.get(agent_name_or_url) except KeyError as e: return None - return None \ No newline at end of file + return None diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/main.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/main.py index 51e54b90..5e8ec104 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/main.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/main.py @@ -22,31 +22,36 @@ # Register built-in CLI scenarios under the "agt." namespace for scenario in SCENARIOS: scenario_name, scenario_obj, scenario_desc = scenario - scenario_registry.register(f"agt.{scenario_name}", scenario_obj, description=scenario_desc) + scenario_registry.register( + f"agt.{scenario_name}", scenario_obj, description=scenario_desc + ) @click.group() @click.option( - "--env", "-e", + "--env", + "-e", "env_path", - default=".env", + default=".env", help="Path to environment file.", type=click.Path(), ) @click.option( - "--connection", "-c", - default="SERVICE_CONNECTION", + "--connection", + "-c", + default="SERVICE_CONNECTION", help="Named connection to use for auth credentials.", ) @click.option( - "--verbose", "-v", + "--verbose", + "-v", is_flag=True, help="Enable verbose output.", ) @click.pass_context def cli(ctx: click.Context, env_path: str, connection: str, verbose: bool) -> None: """Microsoft Agents Testing CLI. - + A command-line tool for testing and interacting with M365 Agents. """ ctx.ensure_object(dict) @@ -59,12 +64,13 @@ def cli(ctx: click.Context, env_path: str, connection: str, verbose: bool) -> No if env_path != ".env" and config.env_path is None: out.error("Specified environment file not found.") raise click.Abort() - + out.debug(f"Using environment file: {config.env_path}") - + ctx.obj["config"] = config ctx.obj["out"] = out + # Register all commands with the CLI group for command in COMMANDS: cli.add_command(command) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py index 2e6a4de1..8b449835 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py @@ -12,5 +12,9 @@ SCENARIOS = [ ["auth", auth_scenario, "Authentication testing scenario with dynamic auth routes"], ["basic", basic_scenario, "Basic message handling scenario"], - ["basic_no_auth", basic_scenario_no_auth, "Basic message handling scenario without JWT authentication"], -] \ No newline at end of file + [ + "basic_no_auth", + basic_scenario_no_auth, + "Basic message handling scenario without JWT authentication", + ], +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py index 1c7276d9..8954c7af 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py @@ -18,6 +18,7 @@ AiohttpScenario, ) + def create_auth_route(auth_handler_id: str, agent: AgentApplication): """Create a dynamic message handler for testing an auth flow. @@ -32,15 +33,22 @@ def create_auth_route(auth_handler_id: str, agent: AgentApplication): async def dynamic_function(context: TurnContext, state: TurnState): token_response = await agent.auth.get_token(context, auth_handler_id) try: - decoded_token = jwt.decode(token_response.token, options={"verify_signature": False}) + decoded_token = jwt.decode( + token_response.token, options={"verify_signature": False} + ) except Exception as e: decoded_token = f"Error decoding token: {e}" - await context.send_activity(f"Hello from {auth_handler_id}! Token: {token_response}\n\nDecoded: {decoded_token}") + await context.send_activity( + f"Hello from {auth_handler_id}! Token: {token_response}\n\nDecoded: {decoded_token}" + ) dynamic_function.__name__ = f"auth_route_{auth_handler_id}".lower() - click.echo(f"Creating route: {dynamic_function.__name__} for handler {auth_handler_id}") + click.echo( + f"Creating route: {dynamic_function.__name__} for handler {auth_handler_id}" + ) return dynamic_function + def sign_out_route(auth_handler_id: str, agent: AgentApplication): """Create a dynamic handler for signing out of an auth flow. @@ -54,9 +62,12 @@ async def dynamic_function(context: TurnContext, state: TurnState): await context.send_activity(f"You have been signed out from {auth_handler_id}.") dynamic_function.__name__ = f"sign_out_route_{auth_handler_id}".lower() - click.echo(f"Creating sign-out route: {dynamic_function.__name__} for handler {auth_handler_id}") + click.echo( + f"Creating sign-out route: {dynamic_function.__name__} for handler {auth_handler_id}" + ) return dynamic_function + async def auth_scenario_init(env: AgentEnvironment): """Initialize the authentication testing agent. @@ -76,8 +87,12 @@ async def auth_scenario_init(env: AgentEnvironment): # Authorization and will break if the attribute is renamed. if auth._handlers: - click.echo("To test authentication flows, send a message with the name of the auth handler (all lowercase) you want to test. For example, if you have a handler named 'Graph', send 'Graph' to test it.") - click.echo("To sign out, send '/signout {handlername}'. For example, '/signout Graph' to sign out of the Graph handler.") + click.echo( + "To test authentication flows, send a message with the name of the auth handler (all lowercase) you want to test. For example, if you have a handler named 'Graph', send 'Graph' to test it." + ) + click.echo( + "To sign out, send '/signout {handlername}'. For example, '/signout Graph' to sign out of the Graph handler." + ) click.echo("\n") for authorization_handler in auth._handlers.values(): @@ -86,15 +101,22 @@ async def auth_scenario_init(env: AgentEnvironment): auth_handler.name.lower(), auth_handlers=[auth_handler.name], )(create_auth_route(auth_handler.name, app)) - app.message(f"/signout {auth_handler.name.lower()}")(sign_out_route(auth_handler.name, app)) + app.message(f"/signout {auth_handler.name.lower()}")( + sign_out_route(auth_handler.name, app) + ) else: - click.echo("No auth handlers found in the agent application. Please add auth handlers to test authentication flows.") + click.echo( + "No auth handlers found in the agent application. Please add auth handlers to test authentication flows." + ) async def handle_message(context: TurnContext, state: TurnState): """Default message handler for unrecognized input.""" - await context.send_activity("Hello from the auth testing sample! Enter the name of an auth handler to test it.") + await context.send_activity( + "Hello from the auth testing sample! Enter the name of an auth handler to test it." + ) app.activity(ActivityTypes.message)(handle_message) + # Pre-built scenario instance for CLI registration -auth_scenario = AiohttpScenario(auth_scenario_init) \ No newline at end of file +auth_scenario = AiohttpScenario(auth_scenario_init) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py index 46ac30c4..e18aab7e 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py @@ -12,6 +12,7 @@ AgentEnvironment, ) + async def basic_scenario_init(env: AgentEnvironment): """Initialize the basic echo agent. @@ -28,6 +29,7 @@ async def handler(context: TurnContext, state: TurnState): """Echo handler: replies with the user's message.""" await context.send_activity("Echo: " + context.activity.text) + # Pre-built scenario instances for CLI registration basic_scenario = AiohttpScenario(basic_scenario_init) -basic_scenario_no_auth = AiohttpScenario(basic_scenario_init, use_jwt_middleware=False) \ No newline at end of file +basic_scenario_no_auth = AiohttpScenario(basic_scenario_init, use_jwt_middleware=False) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py index 0bfff9b8..2c90bed7 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py @@ -84,4 +84,4 @@ "sdk_config_connection", "generate_token", "generate_token_from_config", -] \ No newline at end of file +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py index db2276cf..f8397dcc 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py @@ -21,15 +21,15 @@ class _AiohttpClientFactory: """Internal factory for creating AgentClient instances using aiohttp. - + This factory manages HTTP session lifecycle and handles authentication token generation. It is used internally by scenario implementations. - + Note: This is an internal class. Use Scenario.run() or Scenario.client() instead of instantiating this directly. """ - + def __init__( self, agent_endpoint: str, @@ -46,14 +46,14 @@ def __init__( self._default_config = default_config or ClientConfig() self._transcript = transcript self._sessions: list[ClientSession] = [] # track for cleanup - + async def __call__(self, config: ClientConfig | None = None) -> AgentClient: """Create a new client with the given configuration.""" config = config or self._default_config - + # Build headers headers = {"Content-Type": "application/json", **config.headers} - + # Handle auth if config.auth_token: headers["Authorization"] = f"Bearer {config.auth_token}" @@ -64,21 +64,21 @@ async def __call__(self, config: ClientConfig | None = None) -> AgentClient: headers["Authorization"] = f"Bearer {token}" except Exception: pass # No auth available - + # Create session session = ClientSession(headers=headers) self._sessions.append(session) - + # Build activity template with user identity template = config.activity_template or self._default_template template = template.with_updates( service_url=self._response_endpoint, ) - + # Create sender and client sender = AiohttpSender(self._agent_endpoint, session) return AgentClient(sender, self._transcript, template=template) - + async def cleanup(self): """Close all HTTP sessions created by this factory. diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py index e3e30987..3574844b 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py @@ -23,11 +23,7 @@ Expect, Select, ) -from .transport import ( - Transcript, - Exchange, - Sender -) +from .transport import Transcript, Exchange, Sender from .type_defs import ( ActivityExpect, ActivitySelect, @@ -51,52 +47,54 @@ class AgentClient: """Client for sending activities to an agent and collecting responses. - + AgentClient provides a high-level API for: - Sending messages and activities to an agent - Collecting and inspecting response activities - Making fluent assertions on responses using Expect/Select - Managing conversation transcripts - + Example:: - + async with scenario.client() as client: # Send a message and get replies replies = await client.send("Hello!") - + # Assert on responses client.expect().that_for_any(text="~Hello") - + # Access full transcript for exchange in client.ex_history(): print(exchange.request.text) """ - + def __init__( self, sender: Sender, transcript: Transcript | None = None, - template: ActivityTemplate | None = None + template: ActivityTemplate | None = None, ) -> None: """Initializes the AgentClient with a sender, transcript, and optional activity template. - + :param sender: The Sender to send activities. :param transcript: The Transcript to collect exchanges. :param activity_template: Optional ActivityTemplate for creating activities. """ - + self._sender = sender transcript = transcript if transcript is not None else Transcript() self._transcript = transcript - self._template = (template or ActivityTemplate()).with_defaults(_DEFAULT_ACTIVITY_FIELDS) - + self._template = (template or ActivityTemplate()).with_defaults( + _DEFAULT_ACTIVITY_FIELDS + ) + @property def template(self) -> ActivityTemplate: """Gets the current ActivityTemplate.""" return self._template - + @template.setter def template(self, template: ActivityTemplate) -> None: """Sets a new ActivityTemplate.""" @@ -106,7 +104,7 @@ def template(self, template: ActivityTemplate) -> None: def transcript(self) -> Transcript: """Get the Transcript associated with this AgentClient.""" return self._transcript - + ### ### Transcript collection/manipulation ### @@ -122,7 +120,7 @@ def _ex_collect(self, history: bool = True) -> list[Exchange]: return self._transcript.get_root().history() else: return self._transcript.history() - + def _collect(self, history: bool = True) -> list[Activity]: """Collect response activities from the transcript. @@ -132,34 +130,34 @@ def _collect(self, history: bool = True) -> list[Activity]: """ ex = self._ex_collect(history) return activities_from_ex(ex) - + def ex_recent(self) -> list[Exchange]: """Gets the most recent exchanges from the transcript.""" return self._ex_collect() - + def recent(self) -> list[Activity]: """Gets the most recent activities from the transcript.""" return self._collect() - + def ex_history(self) -> list[Exchange]: """Gets the full exchange history from the transcript.""" return self._ex_collect(history=True) - + def history(self) -> list[Activity]: """Gets the full activity history from the transcript.""" return self._collect(history=True) - + def clear(self) -> None: """Clears the transcript.""" self._transcript.clear() - + ### ### Utilities ### def ex_select(self, history: bool = False) -> ExchangeSelect: """Create a Select instance for filtering exchanges. - + :param history: If True, includes full history; otherwise, recent only. :return: A Select instance for fluent filtering. """ @@ -167,15 +165,15 @@ def ex_select(self, history: bool = False) -> ExchangeSelect: def select(self, history: bool = False) -> ActivitySelect: """Create a Select instance for filtering activities. - + :param history: If True, includes full history; otherwise, recent only. :return: A Select instance for fluent filtering. """ return ActivitySelect(self._collect(history=history)) - + def ex_expect(self, history: bool = False) -> ExchangeExpect: """Create an Expect instance for asserting on exchanges. - + :param history: If True, includes full history; otherwise, recent only. :return: An Expect instance for fluent assertions. """ @@ -183,16 +181,16 @@ def ex_expect(self, history: bool = False) -> ExchangeExpect: def expect(self, history: bool = False) -> ActivityExpect: """Create an Expect instance for asserting on activities. - + :param history: If True, includes full history; otherwise, recent only. :return: An Expect instance for fluent assertions. """ return ActivityExpect(self._collect(history=history)) - + ### ### Sending API ### - + def _build_activity(self, base: Activity | str) -> Activity: """Build an activity from a string or Activity, applying the template. @@ -202,7 +200,7 @@ def _build_activity(self, base: Activity | str) -> Activity: if isinstance(base, str): base = Activity(type=ActivityTypes.message, text=base) return self._template.create(base) - + async def ex_send( self, activity_or_text: Activity | str, @@ -211,7 +209,7 @@ async def ex_send( **kwargs, ) -> list[Exchange]: """Sends an activity and collects responses. - + :param activity_or_text: An Activity or string to send. :param wait: Time in seconds to wait for additional responses after sending. :param kwargs: Additional arguments to pass to the sender. @@ -220,8 +218,9 @@ async def ex_send( activity = self._build_activity(activity_or_text) - - exchange = await self._sender.send(activity, transcript=self._transcript, **kwargs) + exchange = await self._sender.send( + activity, transcript=self._transcript, **kwargs + ) # Clamp negative wait values to zero, then sleep if positive if max(0.0, wait) != 0.0: @@ -229,7 +228,7 @@ async def ex_send( return self.ex_recent() return [exchange] - + async def send( self, activity_or_text: Activity | str, @@ -238,7 +237,7 @@ async def send( **kwargs, ) -> list[Activity]: """Sends an activity and collects reply activities. - + :param activity_or_text: An Activity or string to send. :param wait: Time in seconds to wait for additional responses after sending. :param kwargs: Additional arguments to pass to the sender. @@ -247,14 +246,14 @@ async def send( return activities_from_ex( await self.ex_send(activity_or_text, wait=wait, **kwargs) ) - + async def ex_send_expect_replies( self, activity_or_text: Activity | str, **kwargs, ) -> list[Exchange]: """Sends an activity with expect_replies delivery mode and collects replies. - + :param activity_or_text: An Activity or string to send. :param kwargs: Additional arguments to pass to the sender. :return: A list of reply Activities. @@ -262,14 +261,14 @@ async def ex_send_expect_replies( activity = self._build_activity(activity_or_text) activity.delivery_mode = DeliveryModes.expect_replies return await self.ex_send(activity, wait=0.0, **kwargs) - + async def send_expect_replies( self, activity_or_text: Activity | str, **kwargs, ) -> list[Activity]: """Sends an activity with expect_replies delivery mode and collects replies. - + :param activity_or_text: An Activity or string to send. :param kwargs: Additional arguments to pass to the sender. :return: A list of reply Activities. @@ -277,14 +276,14 @@ async def send_expect_replies( return activities_from_ex( await self.ex_send_expect_replies(activity_or_text, **kwargs) ) - + async def ex_send_stream( self, activity_or_text: Activity | str, **kwargs, ) -> list[Exchange]: """Sends an activity with stream delivery mode and collects replies. - + :param activity_or_text: An Activity or string to send. :param kwargs: Additional arguments to pass to the sender. :return: A list of reply Activities. @@ -292,29 +291,27 @@ async def ex_send_stream( activity = self._build_activity(activity_or_text) activity.delivery_mode = DeliveryModes.stream return await self.ex_send(activity, wait=1.0, **kwargs) - + async def send_stream( self, activity_or_text: Activity | str, **kwargs, ) -> list[Activity]: """Sends an activity with stream delivery mode and collects replies. - + :param activity_or_text: An Activity or string to send. :param kwargs: Additional arguments to pass to the sender. :return: A list of reply Activities. - """ - return activities_from_ex( - await self.ex_send_stream(activity_or_text, **kwargs) - ) - + """ + return activities_from_ex(await self.ex_send_stream(activity_or_text, **kwargs)) + async def ex_invoke( - self, + self, activity: Activity, **kwargs, ) -> Exchange: """Sends an invoke activity and returns the InvokeResponse. - + :param activity: The invoke Activity to send. :param kwargs: Additional arguments to pass to the sender. :return: The InvokeResponse received. @@ -322,32 +319,34 @@ async def ex_invoke( activity = self._build_activity(activity) if activity.type != ActivityTypes.invoke: raise ValueError("AgentClient.invoke(): Activity type must be 'invoke'") - - exchange = await self._sender.send(activity, transcript=self._transcript, **kwargs) - + + exchange = await self._sender.send( + activity, transcript=self._transcript, **kwargs + ) + if not exchange.invoke_response: # in order to not violate the contract, # we raise the exception if there is no InvokeResponse if not exchange.error: raise RuntimeError("AgentClient.invoke(): No InvokeResponse received") raise Exception(exchange.error) - + return exchange - + async def invoke( self, activity: Activity, **kwargs, ) -> InvokeResponse | None: """Sends an invoke activity and returns the InvokeResponse. - + :param activity: The invoke Activity to send. :param kwargs: Additional arguments to pass to the sender. :return: The InvokeResponse received. """ exchange = await self.ex_invoke(activity, **kwargs) return exchange.invoke_response - + def child(self) -> AgentClient: """Create a child AgentClient with a child transcript. @@ -357,6 +356,5 @@ def child(self) -> AgentClient: :return: A new AgentClient with a child Transcript. """ return AgentClient( - self._sender, - transcript=self._transcript.child(), - template=self._template) \ No newline at end of file + self._sender, transcript=self._transcript.child(), template=self._template + ) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/config.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/config.py index 8a99af5b..3c46e357 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/config.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/config.py @@ -16,24 +16,24 @@ @dataclass class ClientConfig: """Configuration for creating an AgentClient. - + This immutable configuration class uses a builder pattern - each `with_*` method returns a new instance with the updated value. - + Example:: - + config = ClientConfig() .with_auth_token("my-token") .with_headers(X_Custom="value") """ - + # HTTP configuration headers: dict[str, str] = field(default_factory=dict) auth_token: str | None = None - + # Activity defaults activity_template: ActivityTemplate | None = None - + def with_headers(self, **headers: str) -> ClientConfig: """Return a new config with additional headers merged into existing ones. @@ -46,7 +46,7 @@ def with_headers(self, **headers: str) -> ClientConfig: auth_token=self.auth_token, activity_template=self.activity_template, ) - + def with_auth_token(self, token: str) -> ClientConfig: """Return a new config with a specific auth token. @@ -57,9 +57,8 @@ def with_auth_token(self, token: str) -> ClientConfig: headers=self.headers, auth_token=token, activity_template=self.activity_template, - ) - + def with_template(self, template: ActivityTemplate) -> ClientConfig: """Return a new config with a specific activity template. @@ -71,19 +70,21 @@ def with_template(self, template: ActivityTemplate) -> ClientConfig: auth_token=self.auth_token, activity_template=template, ) - + + @dataclass class ScenarioConfig: """Configuration for agent test scenarios. - + Controls scenario-level settings such as environment file location, callback server port, and default client configuration. - + Attributes: env_file_path: Path to a .env file for loading environment variables. callback_server_port: Port for the callback server to receive agent responses. client_config: Default ClientConfig for clients created in this scenario. """ + env_file_path: str | None = None callback_server_port: int = 9378 - client_config: ClientConfig = field(default_factory=ClientConfig) \ No newline at end of file + client_config: ClientConfig = field(default_factory=ClientConfig) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py index 5df3c8de..f20c8147 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py @@ -22,23 +22,23 @@ class ExternalScenario(Scenario): """Scenario for testing an externally-hosted agent. - + Use this scenario when testing against an agent that is already running, either locally on a different port or deployed to a remote environment. - + The scenario sets up a callback server to receive agent responses and handles authentication using credentials from the environment. - + Example:: - + scenario = ExternalScenario("http://localhost:3978/api/messages") async with scenario.client() as client: replies = await client.send("Hello!") - + :param agent_url: The URL of the agent's message endpoint. :param config: Optional scenario configuration. """ - + def __init__(self, endpoint: str, config: ScenarioConfig | None = None) -> None: super().__init__(config) if not endpoint: @@ -53,7 +53,7 @@ async def run(self) -> AsyncIterator[ClientFactory]: env_vars = dotenv_values(self._config.env_file_path or ".env") sdk_config = load_configuration_from_env(env_vars) - + callback_server = AiohttpCallbackServer(self._config.callback_server_port) async with callback_server.listen() as transcript: @@ -66,8 +66,8 @@ async def run(self) -> AsyncIterator[ClientFactory]: default_config=self._config.client_config, transcript=transcript, ) - + try: yield factory finally: - await factory.cleanup() \ No newline at end of file + await factory.cleanup() diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py index 012d48bd..e5c6e2e5 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py @@ -62,4 +62,4 @@ "set_defaults", "normalize_model_data", "Unset", -] \ No newline at end of file +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py index f5f61323..39d9297c 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py @@ -50,4 +50,4 @@ "ModelTransform", "ModelPredicateResult", "Unset", -] \ No newline at end of file +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py index 48269a7c..003d47a3 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py @@ -40,7 +40,9 @@ def _indices_summary(self, results: list[bool], matched: bool = True) -> str: return "none" if len(indices) <= 5: return f"[{', '.join(str(i) for i in indices)}]" - return f"[{', '.join(str(i) for i in indices[:5])}, ... +{len(indices) - 5} more]" + return ( + f"[{', '.join(str(i) for i in indices[:5])}, ... +{len(indices) - 5} more]" + ) def _describe_for_any(self, mpr: ModelPredicateResult, passed: bool) -> str: """Describe result for 'any' quantifier.""" @@ -83,24 +85,28 @@ def _describe_for_n(self, mpr: ModelPredicateResult, passed: bool, n: int) -> st """Describe result for 'exactly n' quantifier.""" true_count = sum(1 for r in mpr.result_bools if r) if passed: - return f"✓ Exactly {n} items matched. {self._count_summary(mpr.result_bools)}." + return ( + f"✓ Exactly {n} items matched. {self._count_summary(mpr.result_bools)}." + ) else: return f"✗ Expected exactly {n} items to match, but {true_count} matched. {self._count_summary(mpr.result_bools)}." - def _describe_default(self, mpr: ModelPredicateResult, passed: bool, quantifier_name: str) -> str: + def _describe_default( + self, mpr: ModelPredicateResult, passed: bool, quantifier_name: str + ) -> str: """Describe result for unknown/custom quantifiers.""" status = "✓ Passed" if passed else "✗ Failed" return f"{status} for quantifier '{quantifier_name}'. {self._count_summary(mpr.result_bools)}." def describe(self, mpr: ModelPredicateResult, quantifier: Quantifier) -> str: """Generate a human-readable description of the predicate evaluation result. - + :param mpr: The ModelPredicateResult containing evaluation results. :param quantifier: The quantifier function used for evaluation. :return: A descriptive string explaining the result. """ passed = quantifier(mpr.result_bools) - quantifier_name = getattr(quantifier, '__name__', str(quantifier)) + quantifier_name = getattr(quantifier, "__name__", str(quantifier)) if quantifier is for_any: return self._describe_for_any(mpr, passed) @@ -115,12 +121,14 @@ def describe(self, mpr: ModelPredicateResult, quantifier: Quantifier) -> str: def describe_failures(self, mpr: ModelPredicateResult) -> list[str]: """Generate detailed descriptions for each failed item. - + :param mpr: The ModelPredicateResult containing evaluation results. :return: A list of failure descriptions, one per failed item. """ failures = [] - for i, (result_bool, result_dict) in enumerate(zip(mpr.result_bools, mpr.result_dicts)): + for i, (result_bool, result_dict) in enumerate( + zip(mpr.result_bools, mpr.result_dicts) + ): if not result_bool: failed_keys = [k for k, v in flatten(result_dict).items() if not v] if failed_keys: @@ -129,14 +137,14 @@ def describe_failures(self, mpr: ModelPredicateResult) -> list[str]: item_source = mpr.source[i] if i < len(mpr.source) else {} for key in failed_keys: func = mpr.dict_transform.get(key) - + # Get actual value from source actual_value = self._get_nested_value(item_source, key) - + if func and callable(func): # Try to get the expected value from lambda defaults (_v=val) expected_value = self._get_expected_value(func) - + try: source_code = inspect.getsource(func) if expected_value is not None: @@ -172,20 +180,23 @@ def describe_failures(self, mpr: ModelPredicateResult) -> list[str]: f" source: \n" f" actual: {actual_value!r}" ) - failures.append(f"Item {i}: failed on keys {failed_keys}\n" + "\n".join(key_details)) + failures.append( + f"Item {i}: failed on keys {failed_keys}\n" + + "\n".join(key_details) + ) else: failures.append(f"Item {i}: failed") return failures def _get_expected_value(self, func: Callable) -> Any: """Extract the expected value (_v) from a lambda's defaults. - + :param func: The callable function to inspect. :return: The expected value if found, None otherwise. """ try: # Check function defaults for _v parameter - if hasattr(func, '__defaults__') and func.__defaults__: + if hasattr(func, "__defaults__") and func.__defaults__: # The _v=val pattern stores val in __defaults__ return func.__defaults__[0] except (AttributeError, IndexError): @@ -194,7 +205,7 @@ def _get_expected_value(self, func: Callable) -> Any: def _get_nested_value(self, source: dict | list, key: str) -> Any: """Get a nested value from source using dot-notation key. - + :param source: The source dictionary or list. :param key: The dot-notation key (e.g., 'user.profile.name'). :return: The value at the key path, or '' if not found. @@ -202,7 +213,7 @@ def _get_nested_value(self, source: dict | list, key: str) -> Any: if isinstance(source, list): # For lists, we can't use dot notation directly return source - + keys = key.split(".") current = source for k in keys: diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py index 5d7b85aa..fc4f19ae 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py @@ -16,13 +16,14 @@ from .transform import DictionaryTransform, ModelTransform + @dataclass class ModelPredicateResult: """Result of evaluating a predicate against a list of models. - + Contains the source data, the transformation applied, and per-item boolean results indicating which items matched the predicate. - + Attributes: source: The original list of dictionaries that were evaluated. dict_transform: The transformation mapping that was applied. @@ -35,10 +36,18 @@ class ModelPredicateResult: result_bools: list[bool] result_dicts: list[dict] - def __init__(self, source: Sequence[dict | BaseModel], dict_transform: dict, result_dicts: list[dict]) -> None: + def __init__( + self, + source: Sequence[dict | BaseModel], + dict_transform: dict, + result_dicts: list[dict], + ) -> None: if isinstance(source, Sequence) and source and isinstance(source[0], BaseModel): source = cast(Sequence[BaseModel], source) - self.source = cast(Sequence[dict], [s.model_dump(exclude_unset=True, mode="json") for s in source]) + self.source = cast( + Sequence[dict], + [s.model_dump(exclude_unset=True, mode="json") for s in source], + ) else: self.source = cast(Sequence[dict], source) self.dict_transform = dict_transform @@ -82,9 +91,10 @@ def _get_path(self, result: dict | Sequence, path: str) -> Any: return current + class ModelPredicate: """Evaluates predicates against models to produce boolean results. - + Wraps a DictionaryTransform to evaluate it against one or more models, producing a ModelPredicateResult with per-item match information. """ @@ -92,8 +102,10 @@ class ModelPredicate: def __init__(self, dict_transform: DictionaryTransform) -> None: self._dt = dict_transform self._transform = ModelTransform(dict_transform) - - def eval(self, source: dict | BaseModel | Sequence[BaseModel | dict]) -> ModelPredicateResult: + + def eval( + self, source: dict | BaseModel | Sequence[BaseModel | dict] + ) -> ModelPredicateResult: """Evaluate the predicate against one or more models. :param source: A single model or a list of models to evaluate. @@ -103,9 +115,11 @@ def eval(self, source: dict | BaseModel | Sequence[BaseModel | dict]) -> ModelP source = cast(Sequence[dict] | Sequence[BaseModel], [source]) res = self._transform.eval(source) return ModelPredicateResult(source, self._dt.map, res) - + @staticmethod - def from_args(arg: dict | Callable | None | ModelPredicate, **kwargs) -> ModelPredicate: + def from_args( + arg: dict | Callable | None | ModelPredicate, **kwargs + ) -> ModelPredicate: """Create a ModelPredicate from flexible argument types. Accepts an existing ModelPredicate, a dictionary, a callable, @@ -117,7 +131,5 @@ def from_args(arg: dict | Callable | None | ModelPredicate, **kwargs) -> ModelPr """ if isinstance(arg, ModelPredicate): return arg - - return ModelPredicate( - DictionaryTransform.from_args(arg, **kwargs) - ) \ No newline at end of file + + return ModelPredicate(DictionaryTransform.from_args(arg, **kwargs)) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py index b20dd059..da8577f4 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py @@ -12,14 +12,14 @@ class Quantifier(Protocol): """Protocol for quantifier functions. - + A quantifier takes a list of boolean results and returns whether the overall assertion passes based on its logic (all, any, none, etc.). """ - + @staticmethod - def __call__(items: list[bool]) -> bool: - ... + def __call__(items: list[bool]) -> bool: ... + def for_all(items: list[bool]) -> bool: """Return True if all items are True.""" @@ -43,10 +43,12 @@ def for_one(items: list[bool]) -> bool: def for_n(n: int) -> Quantifier: """Return a quantifier that passes if exactly n items are True. - + :param n: The exact number of True values required. :return: A quantifier function. """ + def _for_n(items: list[bool]) -> bool: return sum(1 for item in items if item) == n - return _for_n \ No newline at end of file + + return _for_n diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py index fd31084d..d50b166a 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py @@ -19,21 +19,22 @@ T = TypeVar("T") + class DictionaryTransform: """Transform that applies callable predicates to dictionary values. - + Supports dot-notation keys for nested access (e.g., 'user.profile.name'). String values starting with '~' are converted to substring match predicates. - + Example:: - + dt = DictionaryTransform({"type": "message", "text": "~hello"}) result = dt.eval({"type": "message", "text": "hello world"}) # result == {"type": True, "text": True} """ - DT_ROOT_CALLABLE_KEY = '__DT_ROOT_CALLABLE_KEY' - + DT_ROOT_CALLABLE_KEY = "__DT_ROOT_CALLABLE_KEY" + def __init__(self, arg: dict | Callable | None, **kwargs) -> None: if not isinstance(arg, (dict, Callable)) and arg is not None: @@ -66,7 +67,7 @@ def __init__(self, arg: dict | Callable | None, **kwargs) -> None: @property def map(self) -> dict[str, Callable[..., Any]]: - return self._map + return self._map @staticmethod def _get(actual: dict, key: str) -> Any: @@ -77,11 +78,11 @@ def _get(actual: dict, key: str) -> Any: return resolve(current) def _invoke( - self, - actual: dict, - key: str, - func: Callable[..., T], - ) -> T: + self, + actual: dict, + key: str, + func: Callable[..., T], + ) -> T: """Invoke a predicate function with the resolved value for a key. Uses introspection to determine whether the function expects @@ -94,7 +95,7 @@ def _invoke( """ args = {} - + sig = inspect.getfullargspec(func) func_args = sig.args @@ -104,10 +105,10 @@ def _invoke( args["x"] = self._get(actual, key) elif "value" in func_args: args["value"] = self._get(actual, key) - + return func(**args) - - def eval(self, actual: dict, root_callable_arg: Any=None) -> dict: + + def eval(self, actual: dict, root_callable_arg: Any = None) -> dict: """Evaluate all predicate functions against the given dictionary. Each key in the transform map is resolved from ``actual`` using @@ -119,7 +120,7 @@ def eval(self, actual: dict, root_callable_arg: Any=None) -> dict: :param root_callable_arg: Optional object passed as the value for the root-level callable key. :return: A dictionary mapping each key to its predicate result. - """ + """ result = {} # Create a wrapper dict to avoid modifying the original object @@ -128,9 +129,9 @@ def eval(self, actual: dict, root_callable_arg: Any=None) -> dict: eval_context = dict(actual) else: eval_context = {} - + if root_callable_arg is not None: - eval_context[DictionaryTransform.DT_ROOT_CALLABLE_KEY] = root_callable_arg + eval_context[DictionaryTransform.DT_ROOT_CALLABLE_KEY] = root_callable_arg else: eval_context[DictionaryTransform.DT_ROOT_CALLABLE_KEY] = actual for key, func in self._map.items(): @@ -141,9 +142,11 @@ def eval(self, actual: dict, root_callable_arg: Any=None) -> dict: return expand(result) @staticmethod - def from_args(arg: dict | DictionaryTransform | Callable | Any, **kwargs) -> DictionaryTransform: + def from_args( + arg: dict | DictionaryTransform | Callable | Any, **kwargs + ) -> DictionaryTransform: """Creates a DictionaryTransform from arbitrary arguments. - + :param args: Positional arguments to create the predicate from. :param kwargs: Keyword arguments to create the predicate from. :return: A DictionaryTransform instance. @@ -151,13 +154,16 @@ def from_args(arg: dict | DictionaryTransform | Callable | Any, **kwargs) -> Dic if isinstance(arg, DictionaryTransform) and not kwargs: return arg elif isinstance(arg, DictionaryTransform): - raise NotImplementedError("Merging DictionaryTransform instance with keyword arguments is not implemented.") + raise NotImplementedError( + "Merging DictionaryTransform instance with keyword arguments is not implemented." + ) else: return DictionaryTransform(arg, **kwargs) + class ModelTransform: """Apply a DictionaryTransform to BaseModel or dict instances. - + Handles conversion of Pydantic models to dictionaries before applying the underlying DictionaryTransform. """ @@ -169,7 +175,9 @@ def __init__(self, dict_transform: DictionaryTransform) -> None: def eval(self, source: dict | BaseModel) -> dict: ... @overload def eval(self, source: Sequence[dict | BaseModel]) -> list[dict]: ... - def eval(self, source: dict | BaseModel | Sequence[dict | BaseModel]) -> list[dict] | dict: + def eval( + self, source: dict | BaseModel | Sequence[dict | BaseModel] + ) -> list[dict] | dict: """Evaluate the underlying DictionaryTransform against one or more models. Pydantic models are dumped to dictionaries before evaluation. @@ -194,5 +202,5 @@ def eval(self, source: dict | BaseModel | Sequence[dict | BaseModel]) -> list[di results = [] for i, item in enumerate(items): results.append(self._dict_transform.eval(item, source[i])) - - return results \ No newline at end of file + + return results diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py index 5dc782be..16803ac9 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py @@ -15,4 +15,4 @@ "resolve", "parent", "Unset", -] \ No newline at end of file +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py index d9940d01..de8eff03 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py @@ -12,22 +12,24 @@ class Readonly: """Mixin that makes all attributes and items read-only. - + Any attempt to set or delete attributes/items will raise AttributeError. """ - + def __setattr__(self, name: str, value: Any): """Prevent setting attributes on the readonly object.""" raise AttributeError(f"Cannot set attribute '{name}' on {type(self).__name__}") def __delattr__(self, name: str): """Prevent deleting attributes on the readonly object.""" - raise AttributeError(f"Cannot delete attribute '{name}' on {type(self).__name__}") - + raise AttributeError( + f"Cannot delete attribute '{name}' on {type(self).__name__}" + ) + def __setitem__(self, key: str, value: Any): """Prevent setting items on the readonly object.""" raise AttributeError(f"Cannot set item '{key}' on {type(self).__name__}") - + def __delitem__(self, key: str): """Prevent deleting items on the readonly object.""" - raise AttributeError(f"Cannot delete item '{key}' on {type(self).__name__}") \ No newline at end of file + raise AttributeError(f"Cannot delete item '{key}' on {type(self).__name__}") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py index 321933d0..9459eed4 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py @@ -18,6 +18,7 @@ T = TypeVar("T") P = TypeVar("P") + @overload def resolve(obj: SafeObject[T]) -> T: ... @overload @@ -28,13 +29,15 @@ def resolve(obj: SafeObject[T] | P) -> T | P: return object.__getattribute__(obj, "__value__") return obj + def parent(obj: SafeObject[T]) -> SafeObject | None: """Get the parent SafeObject of the given SafeObject, or None if there is no parent.""" return object.__getattribute__(obj, "__parent__") + class SafeObject(Generic[T], Readonly): """A wrapper that provides safe access to object attributes and items. - + SafeObject allows accessing nested attributes and items without raising exceptions for missing keys. Instead, it returns Unset for missing values, enabling safe chained access like `obj.user.profile.name` even when @@ -43,7 +46,7 @@ class SafeObject(Generic[T], Readonly): def __init__(self, value: Any, parent_object: SafeObject | None = None): """Initialize a SafeObject with a value and an optional parent SafeObject. - + :param value: The value to wrap. :param parent: The parent SafeObject, if any. """ @@ -60,10 +63,9 @@ def __init__(self, value: Any, parent_object: SafeObject | None = None): parent_object = None object.__setattr__(self, "__parent__", parent_object) - def __new__(cls, value: Any, parent_object: SafeObject | None = None): """Create a new SafeObject or return the value directly if it's already a SafeObject. - + :param value: The value to wrap. :param parent: The parent SafeObject, if any. @@ -75,7 +77,7 @@ def __new__(cls, value: Any, parent_object: SafeObject | None = None): def __getattr__(self, name: str) -> Any: """Get an attribute of the wrapped object safely. - + :param name: The name of the attribute to access. :return: The attribute value wrapped in a SafeObject. """ @@ -86,7 +88,7 @@ def __getattr__(self, name: str) -> Any: return cls(value.get(name, Unset), self) attr = getattr(value, name, Unset) return cls(attr, self) - + def __getitem__(self, key) -> Any: """Get an item of the wrapped object safely. @@ -112,13 +114,13 @@ def __getitem__(self, key) -> Any: def __str__(self) -> str: """Get the string representation of the wrapped object.""" return str(resolve(self)) - + def __repr__(self) -> str: """Get the detailed string representation of the SafeObject.""" value = resolve(self) cls = object.__getattribute__(self, "__class__") return f"{cls.__name__}({value!r})" - + def __eq__(self, other) -> bool: """Check if the wrapped object is equal to another object.""" value = resolve(self) @@ -126,7 +128,7 @@ def __eq__(self, other) -> bool: if isinstance(other, SafeObject): other_value = resolve(other) return value == other_value - + def __call__(self, *args, **kwargs) -> Any: """Call the wrapped object if it is callable.""" value = resolve(self) @@ -134,4 +136,4 @@ def __call__(self, *args, **kwargs) -> Any: result = value(*args, **kwargs) cls = object.__getattribute__(self, "__class__") return cls(result, self) - raise TypeError(f"'{type(value).__name__}' object is not callable") \ No newline at end of file + raise TypeError(f"'{type(value).__name__}' object is not callable") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py index 322b24d4..529900a7 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py @@ -14,43 +14,44 @@ class _Unset(Readonly): """Singleton representing an unset/missing value. - + All attribute access, item access, and method calls return the Unset instance itself, allowing safe chained access on potentially missing data. - + Note: The class is instantiated as a singleton at module load time. """ - + def get(self, *args, **kwargs): """Returns the singleton instance when accessed as a method.""" return self - + def __getattr__(self, name, *args, **kwargs): """Returns the singleton instance when accessed as an attribute.""" return self - + def __getitem__(self, key, *args, **kwargs): """Returns the singleton instance when accessed as an item.""" return self - + def __bool__(self): """Returns False when converted to a boolean.""" return False - + def __repr__(self): """Returns 'Unset' when represented.""" return "Unset" - + def __str__(self): """Returns 'Unset' when converted to a string.""" return repr(self) - + def __contains__(self, item): """Returns False for any containment check.""" return False - + def __iter__(self): """Returns an empty iterator to prevent iteration hangs.""" return iter([]) - -Unset = _Unset() \ No newline at end of file + + +Unset = _Unset() diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py index 436ac2d7..710190f3 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py @@ -9,6 +9,7 @@ from copy import deepcopy + def flatten(data: dict, parent_key: str = "", level_sep: str = ".") -> dict: """Flatten a nested dictionary into a single-level dictionary. Nested keys are concatenated using the specified level separator. @@ -25,16 +26,19 @@ def flatten(data: dict, parent_key: str = "", level_sep: str = ".") -> dict: new_key = f"{parent_key}{level_sep}{key}" if parent_key else key if isinstance(value, dict): - items.extend(flatten(value, parent_key=new_key, level_sep=level_sep).items()) + items.extend( + flatten(value, parent_key=new_key, level_sep=level_sep).items() + ) else: items.append((new_key, value)) return dict(items) + def expand(data: dict, level_sep: str = ".") -> dict: """Expand a (partially) flattened dictionary into a nested dictionary. Keys with dots (.) are treated as paths representing nested dictionaries. - + :param data: The (partially) flattened dictionary to expand. :return: The expanded nested dictionary. """ @@ -52,9 +56,13 @@ def expand(data: dict, level_sep: str = ".") -> dict: path = key[index + 1 :] if root in new_data and not isinstance(new_data[root], (dict, list)): - raise RuntimeError(f"Conflicting key found during expansion: {root} and {key}") + raise RuntimeError( + f"Conflicting key found during expansion: {root} and {key}" + ) elif root in new_data and path in new_data[root]: - raise RuntimeError(f"Conflicting key found during expansion: {root}.{path} and {key}") + raise RuntimeError( + f"Conflicting key found during expansion: {root}.{path} and {key}" + ) if root not in new_data: new_data[root] = {} @@ -64,7 +72,9 @@ def expand(data: dict, level_sep: str = ".") -> dict: else: root = key if root in new_data: - raise RuntimeError(f"Conflicting key found during expansion: {root} and {key}") + raise RuntimeError( + f"Conflicting key found during expansion: {root} and {key}" + ) new_data[root] = value @@ -74,8 +84,8 @@ def expand(data: dict, level_sep: str = ".") -> dict: return new_data -def _merge(original: dict, other: dict, overwrite_leaves: bool = True) -> None: +def _merge(original: dict, other: dict, overwrite_leaves: bool = True) -> None: """Merge two dictionaries recursively. :param original: The first dictionary. @@ -93,8 +103,8 @@ def _merge(original: dict, other: dict, overwrite_leaves: bool = True) -> None: # since they're not both dicts, just overwrite original[key] = other[key] -def _resolve_kwargs(data: dict | None = None, **kwargs) -> dict: +def _resolve_kwargs(data: dict | None = None, **kwargs) -> dict: """Combine a dictionary and keyword arguments into a single dictionary. The new dictionary is created by deep copying the input dictionary (if provided) @@ -110,8 +120,8 @@ def _resolve_kwargs(data: dict | None = None, **kwargs) -> dict: _merge(new_data, kdict, overwrite_leaves=True) return new_data -def _resolve_kwargs_expanded(data: dict | None = None, **kwargs) -> dict: +def _resolve_kwargs_expanded(data: dict | None = None, **kwargs) -> dict: """Combine a dictionary and keyword arguments into a single dictionary. The new dictionary is created by deep copying the input dictionary (if provided) @@ -127,6 +137,7 @@ def _resolve_kwargs_expanded(data: dict | None = None, **kwargs) -> dict: _merge(new_data, kdict, overwrite_leaves=True) return new_data + def deep_update(original: dict, updates: dict | None = None, **kwargs) -> None: """Update a dictionary with new values. @@ -139,6 +150,7 @@ def deep_update(original: dict, updates: dict | None = None, **kwargs) -> None: updates = _resolve_kwargs(updates, **kwargs) _merge(original, updates, overwrite_leaves=True) + def set_defaults(original: dict, defaults: dict | None = None, **kwargs) -> None: """Set default values in a dictionary. @@ -148,4 +160,4 @@ def set_defaults(original: dict, defaults: dict | None = None, **kwargs) -> None :return: None """ defaults = _resolve_kwargs(defaults, **kwargs) - _merge(original, defaults, overwrite_leaves=False) \ No newline at end of file + _merge(original, defaults, overwrite_leaves=False) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py index c1cc7aa3..815453f4 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py @@ -30,7 +30,7 @@ class ExpectBase(Generic[ModelT]): """ Assertion class that raises on failure. - + Extends Check with throwing assertion methods. Use Select to filter items before passing to Expect. @@ -50,7 +50,7 @@ class ExpectBase(Generic[ModelT]): def __init__(self, items: Sequence[ModelT]) -> None: """Initialize Expect with a collection of items. - + :param items: A Sequence of dicts or BaseModel instances. """ self._items = list(items) @@ -62,7 +62,7 @@ def __init__(self, items: Sequence[ModelT]) -> None: def that(self, _assert: dict | Callable | None = None, **kwargs) -> Self: """Assert that ALL items match criteria. - + :param _assert: A dict of field checks or a callable predicate. :param kwargs: Additional field checks. :raises AssertionError: If not all items match. @@ -72,7 +72,7 @@ def that(self, _assert: dict | Callable | None = None, **kwargs) -> Self: def that_for_any(self, _assert: dict | Callable | None = None, **kwargs) -> Self: """Assert that ANY item matches criteria. - + :param _assert: A dict of field checks or a callable predicate. :param kwargs: Additional field checks. :raises AssertionError: If no items match. @@ -82,7 +82,7 @@ def that_for_any(self, _assert: dict | Callable | None = None, **kwargs) -> Self def that_for_all(self, _assert: dict | Callable | None = None, **kwargs) -> Self: """Assert that ALL items match criteria. - + :param _assert: A dict of field checks or a callable predicate. :param kwargs: Additional field checks. :raises AssertionError: If not all items match. @@ -92,7 +92,7 @@ def that_for_all(self, _assert: dict | Callable | None = None, **kwargs) -> Self def that_for_none(self, _assert: dict | Callable | None = None, **kwargs) -> Self: """Assert that NO items match criteria. - + :param _assert: A dict of field checks or a callable predicate. :param kwargs: Additional field checks. :raises AssertionError: If any items match. @@ -102,7 +102,7 @@ def that_for_none(self, _assert: dict | Callable | None = None, **kwargs) -> Sel def that_for_one(self, _assert: dict | Callable | None = None, **kwargs) -> Self: """Assert that EXACTLY ONE item matches criteria. - + :param _assert: A dict of field checks or a callable predicate. :param kwargs: Additional field checks. :raises AssertionError: If not exactly one item matches. @@ -110,9 +110,11 @@ def that_for_one(self, _assert: dict | Callable | None = None, **kwargs) -> Self """ return self._assert_with(for_one, _assert, **kwargs) - def that_for_exactly(self, n: int, _assert: dict | Callable | None = None, **kwargs) -> Self: + def that_for_exactly( + self, n: int, _assert: dict | Callable | None = None, **kwargs + ) -> Self: """Assert that EXACTLY N items match criteria. - + :param n: The exact number of items that should match. :param _assert: A dict of field checks or a callable predicate. :param kwargs: Additional field checks. @@ -122,13 +124,10 @@ def that_for_exactly(self, n: int, _assert: dict | Callable | None = None, **kwa return self._assert_with(for_n(n), _assert, **kwargs) def _assert_with( - self, - quantifier: Quantifier, - _assert: dict | Callable | None = None, - **kwargs + self, quantifier: Quantifier, _assert: dict | Callable | None = None, **kwargs ) -> Self: """Internal: assert items match criteria using the given quantifier. - + :param quantifier: The quantifier to use for evaluation. :param _assert: A dict of field checks or a callable predicate. :param kwargs: Additional field checks. @@ -142,8 +141,10 @@ def _assert_with( if not passed: description = self._describer.describe(result, quantifier) failures = self._describer.describe_failures(result) - failure_details = "\n ".join(failures) if failures else "No details available." - + failure_details = ( + "\n ".join(failures) if failures else "No details available." + ) + raise AssertionError( f"Expectation failed:\n" f" {description}\n" @@ -158,7 +159,7 @@ def _assert_with( def is_empty(self) -> Self: """Assert that no items exist. - + :raises AssertionError: If there are any items. :return: Self for chaining. """ @@ -168,26 +169,30 @@ def is_empty(self) -> Self: def is_not_empty(self) -> Self: """Assert that some items exist. - + :raises AssertionError: If there are no items. :return: Self for chaining. """ if len(self._items) == 0: raise AssertionError("Expected some items, found none.") return self - + def has_count(self, expected_count: int) -> Self: """Assert that the number of items matches the expected count. - + :param expected_count: The expected number of items. :raises AssertionError: If the count does not match. :return: Self for chaining. """ actual_count = len(self._items) if actual_count != expected_count: - raise AssertionError(f"Expected {expected_count} items, found {actual_count}.") + raise AssertionError( + f"Expected {expected_count} items, found {actual_count}." + ) return self - + + class Expect(ExpectBase[dict | BaseModel]): """Concrete Expect class for use with Select and other collections.""" - pass \ No newline at end of file + + pass diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py index af22430b..348df10c 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py @@ -27,20 +27,23 @@ ModelT = TypeVar("ModelT", bound=BaseModel | dict) + class ModelTemplate(Generic[ModelT]): """A template for creating BaseModel instances with default values. - + Templates provide a way to define reusable defaults for model creation. Supports dot-notation keys for nested field access (e.g., 'from.id'). - + Example:: - + template = ActivityTemplate(type="message", **{"from.name": "Test User"}) activity = template.create(Activity(text="Hello")) # activity.type == "message", activity.from_.name == "Test User" """ - def __init__(self, model_class: type[ModelT], defaults: ModelT | dict | None = None, **kwargs) -> None: + def __init__( + self, model_class: type[ModelT], defaults: ModelT | dict | None = None, **kwargs + ) -> None: """Initialize the ModelTemplate with default values. Keys with dots (.) are treated as paths representing nested dictionaries. @@ -49,7 +52,7 @@ def __init__(self, model_class: type[ModelT], defaults: ModelT | dict | None = N :param defaults: A dictionary or BaseModel containing default values. :param kwargs: Additional default values as keyword arguments. """ - + self._model_class: type[ModelT] = model_class defaults = defaults or {} @@ -61,7 +64,7 @@ def __init__(self, model_class: type[ModelT], defaults: ModelT | dict | None = N def create(self, original: BaseModel | dict | None = None) -> ModelT: """Create a new BaseModel instance based on the template. - + :param original: An optional BaseModel or dictionary to override default values. :param kwargs: Additional values to override defaults. :return: A new BaseModel instance. @@ -74,10 +77,12 @@ def create(self, original: BaseModel | dict | None = None) -> ModelT: if issubclass(self._model_class, BaseModel): return self._model_class.model_validate(data) return cast(ModelT, data) - - def with_defaults(self, defaults: dict | None = None, **kwargs) -> ModelTemplate[ModelT]: + + def with_defaults( + self, defaults: dict | None = None, **kwargs + ) -> ModelTemplate[ModelT]: """Create a new ModelTemplate with additional default values. - + :param defaults: An optional dictionary of default values. :param kwargs: Additional default values as keyword arguments. :return: A new ModelTemplate instance. @@ -87,8 +92,10 @@ def with_defaults(self, defaults: dict | None = None, **kwargs) -> ModelTemplate 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]: + + 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 @@ -99,43 +106,45 @@ def with_updates(self, updates: dict | None = None, **kwargs) -> ModelTemplate[M # Pass already-expanded data, avoid re-expansion result = ModelTemplate[ModelT](self._model_class, new_template) return result - + def __eq__(self, other: object) -> bool: """Check equality between two ModelTemplate instances.""" if not isinstance(other, ModelTemplate): return False - return self._defaults == other._defaults and \ - self._model_class == other._model_class - + return ( + self._defaults == other._defaults + and self._model_class == other._model_class + ) + class ActivityTemplate(ModelTemplate[Activity]): """A template for creating Activity instances with default values. - + Specialized template for the Activity model, commonly used to set consistent conversation context, user identity, and channel information across multiple test activities. - + Example:: - + template = ActivityTemplate( channel_id="test", **{"from.id": "user-1", "conversation.id": "conv-1"} ) activity = template.create("Hello!") # Creates message activity """ - + def __init__(self, defaults: Activity | dict | None = None, **kwargs) -> None: """Initialize the ActivityTemplate with default values. - + :param defaults: A dictionary or Activity containing default values. :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. - + :param defaults: An optional dictionary of default values. :param kwargs: Additional default values as keyword arguments. :return: A new ModelTemplate instance. @@ -145,7 +154,7 @@ def with_defaults(self, defaults: dict | None = None, **kwargs) -> ActivityTempl 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) @@ -155,4 +164,4 @@ def with_updates(self, updates: dict | None = None, **kwargs) -> ActivityTemplat deep_update(new_template, flat_updates) deep_update(new_template, flat_kwargs) # Pass already-expanded data, avoid re-expansion - return ActivityTemplate(new_template) \ No newline at end of file + return ActivityTemplate(new_template) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py index 1b73be5b..d49c1669 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py @@ -18,6 +18,7 @@ ModelT = TypeVar("ModelT", bound=dict | BaseModel) + class SelectBase(Generic[ModelT]): """ Unified selection and assertion for models. @@ -43,9 +44,9 @@ class SelectBase(Generic[ModelT]): """ def __init__( - self, - items: Sequence[ModelT], - ) -> None: + self, + items: Sequence[ModelT], + ) -> None: self._items = list(items) def expect(self) -> Expect: @@ -60,15 +61,19 @@ def _child(self, items: Sequence[ModelT]) -> Self: ### Selectors ### - def _where(self, _filter: dict | Callable | None = None, _reverse: bool=False, **kwargs) -> Self: + def _where( + self, _filter: dict | Callable | None = None, _reverse: bool = False, **kwargs + ) -> Self: """Filter items by criteria. Chainable.""" mp = ModelPredicate.from_args(_filter, **kwargs) mpr = mp.eval(self._items) results = mpr.result_bools - + mapping = zip(self._items, results) - filtered_items = [item for item, keep in mapping if keep != _reverse] # keep if not _reverse else not keep + filtered_items = [ + item for item, keep in mapping if keep != _reverse + ] # keep if not _reverse else not keep return self._child(filtered_items) @@ -84,12 +89,14 @@ def where(self, _filter: dict | Callable | None = None, **kwargs) -> Self: def where_not(self, _filter: dict | Callable | None = None, **kwargs) -> Self: """Exclude items by criteria. Chainable.""" return self._where(_filter, _reverse=True, **kwargs) - - def order_by(self, key: str | Callable | None, reverse: bool = False, **kwargs) -> Self: + + def order_by( + self, key: str | Callable | None, reverse: bool = False, **kwargs + ) -> Self: """Order items by a specific key or callable. Chainable.""" dt = DictionaryTransform.from_args(key, **kwargs) - + return self._child( list( sorted( @@ -99,36 +106,36 @@ def order_by(self, key: str | Callable | None, reverse: bool = False, **kwargs) ) ) ) - + def merge(self, other: Self) -> Self: """Merge with another Select's items.""" l = self._items + other._items return self._child(l) - + def _bool_list(self) -> list[bool]: """Return a list of True values matching the number of selected items.""" - return [ True for _ in self._items ] - + return [True for _ in self._items] + def first(self, n: int = 1) -> Self: """Select the first n items.""" return self._child(self._items[:n]) - + def last(self, n: int = 1) -> Self: """Select the last n items.""" return self._child(self._items[-n:]) - + def at(self, n: int) -> Self: """Set selector to 'exactly n'.""" - return self._child(self._items[n:n+1]) - + return self._child(self._items[n : n + 1]) + def sample(self, n: int) -> Self: """Randomly sample n items.""" if n < 0: raise ValueError("Sample size n must be non-negative.") - + n = min(n, len(self._items)) return self._child(random.sample(self._items, n)) - + ### ### TERMINAL OPERATIONS ### @@ -136,15 +143,17 @@ def sample(self, n: int) -> Self: def get(self) -> list[dict | BaseModel]: """Get the selected items as a list.""" return self._items - + def count(self) -> int: """Get the count of selected items.""" return len(self._items) - + def empty(self) -> bool: """Check if no items are in the current selection.""" return len(self._items) == 0 + class Select(SelectBase[dict | BaseModel]): """Select class for filtering and asserting on model collections.""" - pass \ No newline at end of file + + pass diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py index aab30872..5ecbd785 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py @@ -11,6 +11,7 @@ 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 = {} @@ -25,6 +26,7 @@ def rename_from_property(data: dict) -> None: 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. @@ -38,11 +40,12 @@ def normalize_model_data(source: BaseModel | dict) -> dict: if isinstance(source, BaseModel): source = cast(dict, source.model_dump(exclude_unset=True, mode="json")) return 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. @@ -56,7 +59,7 @@ def flatten_model_data(source: BaseModel | dict) -> dict: if isinstance(source, BaseModel): source = cast(dict, source.model_dump(exclude_unset=True, mode="json")) return flatten(source) - + flattened = flatten(source) rename_from_property(flattened) - return flattened \ No newline at end of file + return flattened diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py index 53c7ca5b..65276567 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py @@ -20,15 +20,15 @@ class ClientFactory(Protocol): """Protocol for creating AgentClient instances within a running scenario. - + Implementations of this protocol are yielded by Scenario.run() and allow creating multiple clients with different configurations during a single test scenario. """ - + async def __call__(self, config: ClientConfig | None = None) -> AgentClient: """Create a new client with the given configuration. - + :param config: Optional client configuration. If None, uses defaults. :return: A configured AgentClient instance. """ @@ -37,22 +37,22 @@ async def __call__(self, config: ClientConfig | None = None) -> AgentClient: class Scenario(ABC): """Base class for agent test scenarios. - + A Scenario manages the lifecycle of testing infrastructure (servers, connections, etc.) and provides a factory for creating test clients. - + Subclasses implement specific hosting strategies: - ExternalScenario: Tests against an externally-hosted agent - AiohttpScenario: Hosts the agent in-process for integration testing """ - + def __init__(self, config: ScenarioConfig | None = None) -> None: self._config = config or ScenarioConfig() @abstractmethod def run(self) -> AsyncContextManager[ClientFactory]: """Start the scenario infrastructure and yield a client factory. - + Usage: async with scenario.run() as factory: client = await factory() @@ -61,11 +61,13 @@ def run(self) -> AsyncContextManager[ClientFactory]: ClientConfig().with_user("user-2", "Second User") ) """ - + # Convenience method for simple single-client usage @asynccontextmanager - async def client(self, config: ClientConfig | None = None) -> AsyncIterator[AgentClient]: + async def client( + self, config: ClientConfig | None = None + ) -> AsyncIterator[AgentClient]: """Convenience: start scenario and yield a single client.""" async with self.run() as factory: client = await factory(config) - yield client \ No newline at end of file + yield client diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py index c3cfa344..1bdfa933 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py @@ -27,5 +27,5 @@ "CallbackServer", "Sender", "Transcript", - "Exchange" -] \ No newline at end of file + "Exchange", +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py index f3345922..59377879 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py @@ -22,12 +22,12 @@ class AiohttpCallbackServer(CallbackServer): """CallbackServer implementation using aiohttp TestServer. - + Starts a local HTTP server that agents can post responses to. Use as an async context manager via the `listen()` method. - + Example:: - + server = AiohttpCallbackServer(port=9378) async with server.listen() as transcript: # Send activities to agent with service_url = server.service_endpoint @@ -44,7 +44,7 @@ def __init__(self, port: int = 9378): self._app: Application = Application() self._app.router.add_post("/v3/conversations/{path:.*}", self._handle_request) - + self._transcript: Transcript | None = None @property @@ -53,7 +53,9 @@ def service_endpoint(self) -> str: return f"http://localhost:{self._port}/v3/conversations/" @asynccontextmanager - async def listen(self, transcript: Transcript | None = None) -> AsyncIterator[Transcript]: + async def listen( + self, transcript: Transcript | None = None + ) -> AsyncIterator[Transcript]: """Starts the callback server and yields a Transcript. :param transcript: An optional Transcript to collect incoming Activities. @@ -64,7 +66,7 @@ async def listen(self, transcript: Transcript | None = None) -> AsyncIterator[Tr if self._transcript is not None: raise RuntimeError("Response server is already listening for responses.") - + if transcript is not None: self._transcript = transcript else: @@ -74,10 +76,10 @@ async def listen(self, transcript: Transcript | None = None) -> AsyncIterator[Tr yield self._transcript self._transcript = None - + async def _handle_request(self, request: Request) -> Response: """Handles incoming POST requests and collects Activities. - + :param request: The incoming HTTP request. :return: An HTTP response indicating success or failure. :rtype: Response @@ -106,12 +108,9 @@ async def _handle_request(self, request: Request) -> Response: except Exception as e: if not Exchange.is_allowed_exception(e): raise e - + exchange = Exchange(error=str(e), response_at=response_at) - response = Response( - status=500, - text=str(e) - ) - + response = Response(status=500, text=str(e)) + self._transcript.record(exchange) - return response \ No newline at end of file + return response diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py index f2527f2f..41165139 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py @@ -20,11 +20,11 @@ class AiohttpSender(Sender): """Sender implementation using aiohttp ClientSession. - + Posts activities to the agent's /api/messages endpoint and captures the response in an Exchange object. """ - + def __init__(self, endpoint, session: ClientSession): self._endpoint = endpoint self._session = session @@ -33,15 +33,17 @@ def __init__(self, endpoint, session: ClientSession): def endpoint(self) -> str: return self._endpoint - async def send(self, activity: Activity, transcript: Transcript | None = None, **kwargs) -> Exchange: + async def send( + self, activity: Activity, transcript: Transcript | None = None, **kwargs + ) -> Exchange: """Send an activity and return the Exchange containing the response. - + :param activity: The Activity to send. :param transcript: Optional Transcript to record the exchange. :param timeout: Optional timeout for the request. :return: An Exchange object containing the response. """ - + exchange: Exchange response_or_exception = None request_at = datetime.now(timezone.utc) @@ -51,13 +53,15 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * json=activity.model_dump( by_alias=True, exclude_unset=True, exclude_none=True, mode="json" ), - **kwargs + **kwargs, ) as response: response_at = datetime.now(timezone.utc) response_or_exception = response if response.status >= 300: - raise ClientError(f"Received non-success status code: {response.status}") + raise ClientError( + f"Received non-success status code: {response.status}" + ) exchange = await Exchange.from_request( request_activity=activity, @@ -65,11 +69,11 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * request_at=request_at, response_at=response_at, status=response.status, - **kwargs + **kwargs, ) - + except ClientError as e: - + if response_or_exception is not None: raise # If we got a response but it was an error status, re-raise the exception @@ -81,9 +85,9 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * response_or_exception=response_or_exception, request_at=request_at, response_at=response_at, - **kwargs + **kwargs, ) - + if transcript is not None: transcript.record(exchange) - return exchange \ No newline at end of file + return exchange diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py index 5fbc3ac0..795224ad 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py @@ -16,14 +16,16 @@ class CallbackServer(ABC): """Abstract server that receives Activities sent by agents. - + Implementations start an HTTP server that agents can post responses to, collecting them into a Transcript for later assertion. """ - + @abstractmethod @asynccontextmanager - async def listen(self, transcript: Transcript | None = None) -> AsyncIterator[Transcript]: + async def listen( + self, transcript: Transcript | None = None + ) -> AsyncIterator[Transcript]: """Starts the response server and yields a Transcript. :param transcript: An optional Transcript to collect incoming Activities. @@ -33,4 +35,3 @@ async def listen(self, transcript: Transcript | None = None) -> AsyncIterator[Tr :raises: RuntimeError if the server is already listening. """ ... - \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py index 97aa0cd1..420af459 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py @@ -20,18 +20,20 @@ class Sender(ABC): """Abstract client for sending activities to an agent endpoint. - + Implementations handle the HTTP communication and response parsing, returning Exchange objects that capture the full request-response cycle. """ @abstractmethod - async def send(self, activity: Activity, transcript: Transcript | None = None, **kwargs) -> Exchange: + async def send( + self, activity: Activity, transcript: Transcript | None = None, **kwargs + ) -> Exchange: """Send an activity and return the Exchange containing the response. - + :param activity: The Activity to send. :param transcript: Optional Transcript to record the exchange. :param timeout: Optional timeout for the request. :return: An Exchange object containing the response. """ - ... \ No newline at end of file + ... diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py index 1059c8ef..091277b5 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py @@ -13,4 +13,4 @@ __all__ = [ "Exchange", "Transcript", -] \ No newline at end of file +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py index a5e01525..976b74d6 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py @@ -26,30 +26,33 @@ # supported Response types, currently only aiohttp.ClientResponse ResponseT = TypeVar("ResponseT", bound=aiohttp.ClientResponse) + def _load_activity(activity_dict: dict) -> Activity: new_activity_dict = { key: value for key, value in activity_dict.items() if value != "" } return Activity(**new_activity_dict) + class Exchange(BaseModel): """A complete send-receive exchange with an agent. - + Captures the outgoing activity, the HTTP response, and any activities received (inline replies or async callbacks). """ + # The activity that was sent request: Activity | None = None request_at: datetime | None = None - + # HTTP response metadata status_code: int | None = None body: str | None = None invoke_response: InvokeResponse | None = None - + # Error message if the request failed error: str | None = None - + # Activities received (from expect_replies or callbacks) responses: list[Activity] = Field(default_factory=list) response_at: datetime | None = None @@ -61,7 +64,7 @@ def is_error(self) -> bool: :return: True if the exchange has an error, False otherwise. """ return self.error is not None - + @property def latency(self) -> timedelta | None: """Calculate the time delta between request and response. @@ -71,7 +74,7 @@ def latency(self) -> timedelta | None: if self.request_at is not None and self.response_at is not None: return self.response_at - self.request_at return None - + @property def latency_ms(self) -> float | None: """Calculate the latency in milliseconds. @@ -82,11 +85,11 @@ def latency_ms(self) -> float | None: if delta is not None: return delta.total_seconds() * 1000.0 return None - + def __repr__(self) -> str: req_type = self.request.type if self.request else "None" return f"Exchange(request={req_type}, status={self.status_code}, responses={len(self.responses)})" - + @staticmethod def is_allowed_exception(exception: Exception) -> bool: """Check if an exception is a recoverable transport error. @@ -97,14 +100,16 @@ def is_allowed_exception(exception: Exception) -> bool: :param exception: The exception to check. :return: True if the exception is a known recoverable error. """ - return isinstance(exception, (aiohttp.ClientTimeout, aiohttp.ClientConnectionError)) - + return isinstance( + exception, (aiohttp.ClientTimeout, aiohttp.ClientConnectionError) + ) + @staticmethod async def from_request( request_activity: Activity, response_or_exception: Exception | ResponseT, status: int | None = None, - **kwargs + **kwargs, ) -> Exchange: """Create an Exchange from a request activity and its outcome. @@ -120,11 +125,11 @@ async def from_request( :return: A populated Exchange instance. :raises: Re-raises exceptions that are not in the allowed list. """ - + if isinstance(response_or_exception, Exception): if not Exchange.is_allowed_exception(response_or_exception): raise response_or_exception - + return Exchange( request=request_activity, error=str(response_or_exception), @@ -136,11 +141,11 @@ async def from_request( request=request_activity, error=text or str(status), status_code=status, - **kwargs + **kwargs, ) - + if isinstance(response_or_exception, aiohttp.ClientResponse): - + response = cast(aiohttp.ClientResponse, response_or_exception) body: str | None = None @@ -152,12 +157,14 @@ async def from_request( body = await response.text() activity_list = json.loads(body)["activities"] - activities = [ _load_activity(activity) for activity in activity_list ] + activities = [_load_activity(activity) for activity in activity_list] elif request_activity.type == ActivityTypes.invoke: body = await response.text() body_json = json.loads(body) if body.strip() else None - invoke_response = InvokeResponse.model_validate({"status": response.status, "body": body_json}) + invoke_response = InvokeResponse.model_validate( + {"status": response.status, "body": body_json} + ) elif request_activity.delivery_mode == DeliveryModes.stream: # Parse Server-Sent Events (SSE) stream for activity events @@ -179,8 +186,10 @@ async def from_request( body=body, responses=activities, invoke_response=invoke_response, - **kwargs + **kwargs, ) - + else: - raise ValueError("response_or_exception must be an Exception or aiohttp.ClientResponse") \ No newline at end of file + raise ValueError( + "response_or_exception must be an Exception or aiohttp.ClientResponse" + ) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py index 0ce407a3..2abb011a 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py @@ -15,7 +15,7 @@ class Transcript: """A hierarchical transcript of exchanges with an agent. - + Transcripts support parent-child relationships, allowing exchanges to be recorded at multiple levels. Exchanges propagate up to parents and down to children, enabling both isolated and shared views. @@ -29,14 +29,14 @@ def __init__(self, parent: Transcript | None = None): def _add(self, exchange: Exchange) -> None: """Add an exchange to the transcript without propagating. - + :param exchange: The exchange to add. """ self._history.append(exchange) def _propagate_up(self, exchange: Exchange) -> None: """Begin propagating an exchange up to the parent transcript. - + :param exchange: The exchange to propagate. """ if self._parent is not None: @@ -45,7 +45,7 @@ def _propagate_up(self, exchange: Exchange) -> None: def _propagate_down(self, exchange: Exchange) -> None: """Begin propagating an exchange down to the child transcripts. - + :param exchange: The exchange to propagate. """ for child in self._children: @@ -59,7 +59,7 @@ def clear(self) -> None: def history(self) -> list[Exchange]: """Get the full history of exchanges.""" return list(self._history) - + def get_root(self) -> Transcript: """Get the root transcript.""" if self._parent is None: @@ -71,17 +71,17 @@ def record(self, exchange: Exchange) -> None: self._add(exchange) self._propagate_up(exchange) self._propagate_down(exchange) - + def child(self) -> Transcript: """Create a child transcript.""" c = Transcript(parent=self) self._children.append(c) return c - + def __len__(self) -> int: """Get the number of exchanges in the transcript.""" return len(self._history) - + def __iter__(self) -> Iterator[Exchange]: """Iterate over the exchanges in the transcript.""" - return iter(self._history) \ No newline at end of file + return iter(self._history) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/type_defs.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/type_defs.py index ec3541ec..aae39e6c 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/type_defs.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/type_defs.py @@ -6,14 +6,19 @@ from .fluent import ExpectBase, SelectBase from .transport import Exchange + class ActivityExpect(ExpectBase[Activity]): """Expect class specifically for asserting on activity collections.""" + pass + class ExchangeExpect(ExpectBase[Exchange]): """Expect class specifically for asserting on Exchange model collections.""" + pass + class ActivitySelect(SelectBase[Activity]): """Select class specifically for filtering and asserting on activity collections.""" @@ -21,6 +26,7 @@ def expect(self) -> ActivityExpect: """Get an ActivityExpect instance for assertions on the current selection.""" return ActivityExpect(self._items) + class ExchangeSelect(SelectBase[Exchange]): """Select class specifically for filtering and asserting on Exchange model collections.""" diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py index 62c0fe62..ebdd5fb7 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py @@ -14,6 +14,7 @@ from .transport import Exchange + def activities_from_ex(exchanges: list[Exchange]) -> list[Activity]: """Extract all response activities from a list of exchanges. @@ -28,6 +29,7 @@ def activities_from_ex(exchanges: list[Exchange]) -> list[Activity]: activities.extend(exchange.responses) return activities + def sdk_config_connection( sdk_config: dict, connection_name: str = "SERVICE_CONNECTION" ) -> AgentAuthConfiguration: @@ -75,7 +77,9 @@ def generate_token(app_id: str, app_secret: str, tenant_id: str) -> str: return res.json().get("access_token") -def generate_token_from_config(sdk_config: dict, connection_name: str = "SERVICE_CONNECTION") -> str: +def generate_token_from_config( + sdk_config: dict, connection_name: str = "SERVICE_CONNECTION" +) -> str: """Generates a token using a provided config object. :param sdk_config: Configuration dictionary containing connection settings. @@ -83,7 +87,9 @@ def generate_token_from_config(sdk_config: dict, connection_name: str = "SERVICE :return: Generated access token as a string. """ - settings: AgentAuthConfiguration = sdk_config_connection(sdk_config, connection_name) + settings: AgentAuthConfiguration = sdk_config_connection( + sdk_config, connection_name + ) client_id = settings.CLIENT_ID client_secret = settings.CLIENT_SECRET @@ -91,4 +97,4 @@ def generate_token_from_config(sdk_config: dict, connection_name: str = "SERVICE if not client_id or not client_secret or not tenant_id: raise ValueError("Incorrect configuration provided for token generation.") - return generate_token(client_id, client_secret, tenant_id) \ No newline at end of file + return generate_token(client_id, client_secret, tenant_id) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/__init__.py index cff7c62e..841dc8ab 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/__init__.py @@ -4,11 +4,7 @@ from .activity_transcript_formatter import ActivityTranscriptFormatter from .conversation_transcript_formatter import ConversationTranscriptFormatter from .json_transcript_formatter import JsonTranscriptFormatter -from .print import ( - print_json, - print_conversation, - print_activities -) +from .print import print_json, print_conversation, print_activities from .transcript_formatter import BaseTranscriptFormatter, TranscriptFormatter __all__ = [ @@ -19,5 +15,5 @@ "print_json", "print_conversation", "print_activities", - "TranscriptFormatter" -] \ No newline at end of file + "TranscriptFormatter", +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/activity_transcript_formatter.py b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/activity_transcript_formatter.py index 510dc7af..ce85b84f 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/activity_transcript_formatter.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/activity_transcript_formatter.py @@ -7,6 +7,7 @@ from microsoft_agents.activity import Activity from microsoft_agents.testing.core import Transcript + class ActivityTranscriptFormatter(BaseTranscriptFormatter): """Formats a transcript as a flat JSON array of Activity objects. @@ -34,6 +35,8 @@ def format(self, transcript: Transcript) -> str: activities.append(exchange.request) if exchange.responses: activities.extend(exchange.responses) - - parts = [activity.model_dump_json(**self._model_dump_args) for activity in activities] - return "[" + ",".join(parts) + "]" \ No newline at end of file + + parts = [ + activity.model_dump_json(**self._model_dump_args) for activity in activities + ] + return "[" + ",".join(parts) + "]" diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/conversation_transcript_formatter.py b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/conversation_transcript_formatter.py index cbb08a76..0adf4700 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/conversation_transcript_formatter.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/conversation_transcript_formatter.py @@ -8,10 +8,8 @@ from .utils import _format_timestamp from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.testing.core import ( - Transcript, - Exchange -) +from microsoft_agents.testing.core import Transcript, Exchange + @dataclass class _ActivityObservation: @@ -27,6 +25,7 @@ def is_error(self) -> bool: """Check if the observation represents an error.""" return self.error is not None + class ConversationTranscriptFormatter(BaseTranscriptFormatter): """Formats a transcript as a human-readable conversation string. @@ -38,25 +37,30 @@ class ConversationTranscriptFormatter(BaseTranscriptFormatter): Lines are sorted by activity timestamp. """ - def _get_exchange_observations(self, exchange: Exchange) -> list[_ActivityObservation]: + def _get_exchange_observations( + self, exchange: Exchange + ) -> list[_ActivityObservation]: """Return observations for a single exchange, preserving request/response order.""" obs = [] if exchange.request: assert exchange.request_at is not None - obs.append(_ActivityObservation( - activity=exchange.request, - at=exchange.request_at, - error=exchange.error - )) + obs.append( + _ActivityObservation( + activity=exchange.request, + at=exchange.request_at, + error=exchange.error, + ) + ) if exchange.responses: assert exchange.response_at is not None for response in exchange.responses: - obs.append(_ActivityObservation( - activity=response, - at=response.timestamp or exchange.response_at - )) + obs.append( + _ActivityObservation( + activity=response, at=response.timestamp or exchange.response_at + ) + ) return obs - + def _get_observations(self, transcript: Transcript) -> list[_ActivityObservation]: """Collect and sort all observations across the full transcript by timestamp.""" obs = [] @@ -92,4 +96,4 @@ def _format_observation(self, observation: _ActivityObservation) -> str: def format(self, transcript: Transcript) -> str: """Format a transcript as a conversation.""" observations = self._get_observations(transcript) - return "\n".join(self._format_observation(obs) for obs in observations) \ No newline at end of file + return "\n".join(self._format_observation(obs) for obs in observations) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/json_transcript_formatter.py b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/json_transcript_formatter.py index 73761cb3..c5464fc4 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/json_transcript_formatter.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/json_transcript_formatter.py @@ -6,6 +6,7 @@ from microsoft_agents.testing.core import Transcript + class JsonTranscriptFormatter(BaseTranscriptFormatter): """Formats a transcript as a JSON array of Exchange objects. @@ -25,5 +26,7 @@ def format(self, transcript: Transcript) -> str: """Return a JSON array string of all exchanges in chronological order.""" exchanges = sorted(transcript.history(), key=_exchange_sort_key) - parts = [exchange.model_dump_json(**self._model_dump_args) for exchange in exchanges] - return "[" + ",".join(parts) + "]" \ No newline at end of file + parts = [ + exchange.model_dump_json(**self._model_dump_args) for exchange in exchanges + ] + return "[" + ",".join(parts) + "]" diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/print.py b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/print.py index 8e6f7847..128d03f4 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/print.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/print.py @@ -6,7 +6,8 @@ from .activity_transcript_formatter import ActivityTranscriptFormatter from .conversation_transcript_formatter import ConversationTranscriptFormatter from .json_transcript_formatter import JsonTranscriptFormatter - + + def print_json(transcript: Transcript) -> None: """Print transcript as JSON. @@ -17,6 +18,7 @@ def print_json(transcript: Transcript) -> None: """ print(JsonTranscriptFormatter()(transcript)) + def print_conversation(transcript: Transcript) -> None: """Print transcript as a conversation. @@ -27,6 +29,7 @@ def print_conversation(transcript: Transcript) -> None: """ print(ConversationTranscriptFormatter()(transcript)) + def print_activities(transcript: Transcript) -> None: """Print transcript with all activity details. @@ -35,4 +38,4 @@ def print_activities(transcript: Transcript) -> None: Args: transcript: The transcript to print. """ - print(ActivityTranscriptFormatter()(transcript)) \ No newline at end of file + print(ActivityTranscriptFormatter()(transcript)) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/transcript_formatter.py b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/transcript_formatter.py index 6128a6d2..a0f4e784 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/transcript_formatter.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/transcript_formatter.py @@ -5,6 +5,7 @@ from microsoft_agents.testing.core import Transcript + class TranscriptFormatter(Protocol): """Protocol for transcript formatters.""" @@ -24,4 +25,4 @@ def __call__(self, transcript: Transcript) -> str: return self.format(transcript) def format(self, transcript: Transcript) -> str: - raise NotImplementedError \ No newline at end of file + raise NotImplementedError diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/utils.py index 7255a26c..c29cfc54 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/formatting/utils.py @@ -5,9 +5,10 @@ from microsoft_agents.testing.core import Exchange + def _exchange_sort_key(exchange: Exchange) -> tuple: """Sort key for exchanges by request timestamp. - + Returns a tuple to handle naive vs aware datetime comparisons. """ dt = exchange.request_at @@ -20,8 +21,9 @@ def _exchange_sort_key(exchange: Exchange) -> tuple: naive_dt = dt.replace(tzinfo=None) if dt.tzinfo else dt return (naive_dt,) + def _format_timestamp(dt: datetime | None) -> str: """Format a datetime for display.""" if dt is None: return "??:??.???" - return dt.strftime("%H:%M:%S.%f")[:-3] \ No newline at end of file + return dt.strftime("%H:%M:%S.%f")[:-3] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/presets/basic/e2e-tests/tests/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/presets/basic/e2e-tests/tests/__init__.py index 11703591..c4242611 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/presets/basic/e2e-tests/tests/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/presets/basic/e2e-tests/tests/__init__.py @@ -1,3 +1,3 @@ from microsoft_agents.testing import scenario_registry -scenario_registry.load_json("config.json") \ No newline at end of file +scenario_registry.load_json("config.json") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/presets/basic/e2e-tests/tests/test_my_agent.py b/dev/microsoft-agents-testing/microsoft_agents/testing/presets/basic/e2e-tests/tests/test_my_agent.py index 14d56fd3..8c97d62c 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/presets/basic/e2e-tests/tests/test_my_agent.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/presets/basic/e2e-tests/tests/test_my_agent.py @@ -2,10 +2,11 @@ from microsoft_agents.testing import AgentClient + @pytest.mark.agent_test("my_agent") async def test_my_agent(agent_client: AgentClient): await agent_client.send("Hello, World!", wait=5.0) # assert that the agent replied with a message Activity - agent_client.expect().that_for_any(type="message") \ No newline at end of file + agent_client.expect().that_for_any(type="message") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/presets/localhost/e2e-tests/tests/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/presets/localhost/e2e-tests/tests/__init__.py index 11703591..c4242611 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/presets/localhost/e2e-tests/tests/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/presets/localhost/e2e-tests/tests/__init__.py @@ -1,3 +1,3 @@ from microsoft_agents.testing import scenario_registry -scenario_registry.load_json("config.json") \ No newline at end of file +scenario_registry.load_json("config.json") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/presets/localhost/e2e-tests/tests/test_my_agent.py b/dev/microsoft-agents-testing/microsoft_agents/testing/presets/localhost/e2e-tests/tests/test_my_agent.py index 14d56fd3..8c97d62c 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/presets/localhost/e2e-tests/tests/test_my_agent.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/presets/localhost/e2e-tests/tests/test_my_agent.py @@ -2,10 +2,11 @@ from microsoft_agents.testing import AgentClient + @pytest.mark.agent_test("my_agent") async def test_my_agent(agent_client: AgentClient): await agent_client.send("Hello, World!", wait=5.0) # assert that the agent replied with a message Activity - agent_client.expect().that_for_any(type="message") \ No newline at end of file + agent_client.expect().that_for_any(type="message") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py b/dev/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py index 44df4f1a..5301a77f 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py @@ -41,7 +41,6 @@ async def test_something(conv): from .aiohttp_scenario import AgentEnvironment from .scenario_registry import resolve_scenario - # Store the scenario per test item _SCENARIO_KEY = "_agent_test_scenario" @@ -89,11 +88,12 @@ def pytest_runtest_setup(item: pytest.Item) -> None: # Fixtures # ============================================================================= + @pytest.fixture async def agent_client(request: pytest.FixtureRequest): """ Provides an AgentClient for communicating with the agent under test. - + Only available when the test is decorated with @pytest.mark.agent_test. """ scenario: Scenario | str | None = getattr(request.node, _SCENARIO_KEY, None) @@ -102,25 +102,26 @@ async def agent_client(request: pytest.FixtureRequest): else: pytest.skip("agent_client fixture requires @pytest.mark.agent_test marker") return - + async with scenario.client() as client: yield client # After test completes, attach conversation to the test item # This makes it available to pytest's reporting hooks request.node._agent_client_transcript = client.transcript + @pytest.fixture def agent_environment(request: pytest.FixtureRequest) -> AgentEnvironment: """ Provides access to the AgentEnvironment (only for in-process scenarios). - + Only available when using AiohttpScenario or similar in-process scenarios. """ scenario: Scenario | None = getattr(request.node, _SCENARIO_KEY, None) - + if scenario is None: pytest.skip("agent_environment fixture requires @pytest.mark.agent_test marker") - + if not hasattr(scenario, "agent_environment"): pytest.skip( "agent_environment fixture is only available for in-process scenarios " @@ -157,4 +158,4 @@ def adapter(agent_environment: AgentEnvironment) -> ChannelServiceAdapter: @pytest.fixture def connection_manager(agent_environment: AgentEnvironment) -> Connections: """Provides the Connections (connection manager) instance from the test scenario.""" - return agent_environment.connections \ No newline at end of file + return agent_environment.connections diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py b/dev/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py index d0876ebe..63e3e3e0 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py @@ -8,17 +8,17 @@ Example: from microsoft_agents.testing import scenario_registry, ExternalScenario - + # Register scenarios scenario_registry.register( "prod.echo", ExternalScenario(url="https://prod.example.com/api/messages"), description="Production echo agent", ) - + # Retrieve by name scenario = scenario_registry.get("prod.echo") - + # List scenarios in a namespace prod_scenarios = scenario_registry.discover("prod") """ @@ -36,6 +36,7 @@ from .core import Scenario, ExternalScenario from .source_scenario import SourceScenario + @dataclass(frozen=True) class ScenarioEntry: """Metadata for a registered scenario. @@ -63,16 +64,17 @@ def namespace(self) -> str: return "" return self.name[:index] + class ScenarioRegistry: """Global registry for named test scenarios. - + Scenarios are registered by name and can be organized into namespaces using dot notation (e.g., "prod.echo", "staging.echo"). """ - + def __init__(self) -> None: self._entries: dict[str, ScenarioEntry] = {} - + def register( self, name: str, @@ -81,16 +83,16 @@ def register( description: str = "", ) -> None: """Register a scenario by name. - + Args: name: Unique name for the scenario. Use dot notation for namespacing (e.g., "prod.echo", "local.multi-turn"). scenario: The Scenario instance to register. description: Optional human-readable description. - + Raises: ValueError: If a scenario with this name is already registered. - + Example: scenario_registry.register( "prod.echo", @@ -102,7 +104,7 @@ def register( raise ValueError(f"Scenario '{name}' is already registered") if not isinstance(scenario, Scenario): raise TypeError("scenario must be an instance of Scenario") - + self._entries[name] = ScenarioEntry( name=name, scenario=scenario, @@ -132,7 +134,9 @@ def load_json(self, file_path: str) -> None: else: if not script: - raise ValueError("A 'script' field is required for source scenarios") + raise ValueError( + "A 'script' field is required for source scenarios" + ) path = (Path(file_path).resolve().parent / path_str).resolve() if not path.exists(): @@ -149,44 +153,44 @@ def get_entry(self, name: str) -> ScenarioEntry: available = ", ".join(sorted(self._entries.keys())) or "(none)" raise KeyError(f"Scenario '{name}' not found. Available: {available}") return self._entries[name] - + def get(self, name: str) -> Scenario: """Get a scenario by name. - + Args: name: The registered name of the scenario. - + Returns: The registered Scenario instance. - + Raises: KeyError: If no scenario is registered with this name. - + Example: scenario = scenario_registry.get("prod.echo") async with scenario.client() as client: replies = await client.send_expect_replies("Hello") """ return self.get_entry(name).scenario - + def discover(self, pattern: str = "*") -> dict[str, ScenarioEntry]: """Discover scenarios matching a pattern. - + Args: pattern: Glob-style pattern to match scenario names. Use "*" for all scenarios, "prod.*" for a namespace, or "*.echo" for all echo scenarios across namespaces. - + Returns: Dictionary of matching scenario names to their entries. - + Example: # All scenarios all_scenarios = scenario_registry.discover() - + # All in 'prod' namespace prod_scenarios = scenario_registry.discover("prod.*") - + # All echo scenarios echo_scenarios = scenario_registry.discover("*.echo") """ @@ -195,23 +199,24 @@ def discover(self, pattern: str = "*") -> dict[str, ScenarioEntry]: for name, entry in self._entries.items() if fnmatch(name, pattern) } - + def __iter__(self) -> Iterator[ScenarioEntry]: """Iterate over registered scenario entries.""" return iter(self._entries.values()) - + def __contains__(self, name: str) -> bool: """Check if a scenario is registered.""" return name in self._entries - + def __len__(self) -> int: """Get the number of registered scenarios.""" return len(self._entries) - + def clear(self) -> None: """Remove all registered scenarios. Primarily for testing.""" self._entries.clear() + # Global singleton instance scenario_registry = ScenarioRegistry() @@ -225,25 +230,25 @@ def _import_modules(module_path: str) -> None: :param module_path: Python module path or file path to import. :raises FileNotFoundError: If a file path is provided and does not exist. """ - + if module_path.endswith(".py") or "/" in module_path or "\\" in module_path: # File path - load as module path = Path(module_path).resolve() if not path.exists(): raise FileNotFoundError(f"Scenario file not found: {path}") - + # Add parent to sys.path temporarily parent = str(path.parent) if parent not in sys.path: sys.path.insert(0, parent) - + module_name = path.stem importlib.import_module(module_name) sys.path = [p for p in sys.path if p != parent] else: # Module path - import directly importlib.import_module(module_path) - + def load_scenarios(module_path: str) -> int: """Load scenarios from the specified module or file path. @@ -264,11 +269,12 @@ def load_scenarios(module_path: str) -> int: return after_count - before_count -def resolve_scenario(scenario_or_str: Scenario | str ) -> Scenario: + +def resolve_scenario(scenario_or_str: Scenario | str) -> Scenario: """Resolve a scenario from a Scenario instance or a registered name. - + If a string is provided, looks up the scenario in the registry. - + :param scenario_or_str: A Scenario instance or a string key for lookup. :return: The resolved Scenario instance. :raises ValueError: If the string key is not found in the registry. @@ -276,7 +282,9 @@ def resolve_scenario(scenario_or_str: Scenario | str ) -> Scenario: if isinstance(scenario_or_str, Scenario): return scenario_or_str elif isinstance(scenario_or_str, str): - if scenario_or_str.startswith("http://") or scenario_or_str.startswith("https://"): + if scenario_or_str.startswith("http://") or scenario_or_str.startswith( + "https://" + ): # If it's a URL, create an ExternalScenario on the fly return ExternalScenario(scenario_or_str) else: diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py b/dev/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py index 1b807a14..8bdcde73 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py @@ -20,6 +20,7 @@ from .constants import DEFAULT_LOCAL_AGENT_ENDPOINT + def _terminate_tree(process: subprocess.Popen, timeout: float = 5.0) -> None: """Terminate `process` and all of its descendants. @@ -57,7 +58,7 @@ def __init__( agent_path: str | Path, script: str, delay: float = 0.0, - config: ScenarioConfig | None = None + config: ScenarioConfig | None = None, ) -> None: super().__init__(DEFAULT_LOCAL_AGENT_ENDPOINT, config) self._agent_path = Path(agent_path) @@ -74,7 +75,14 @@ async def _run_script(self) -> AsyncIterator[None]: raise FileNotFoundError("Could not find pwsh or powershell in PATH") process = subprocess.Popen( - [runner, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", self._script], + [ + runner, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + self._script, + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=agent_path, @@ -105,4 +113,4 @@ async def run(self) -> AsyncIterator[ClientFactory]: """Start callback server and yield a client factory.""" async with self._run_script(): async with super().run() as factory: - yield factory \ No newline at end of file + yield factory diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/poll.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/poll.py index 6b521ede..d2f7bd0e 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/poll.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/poll.py @@ -5,13 +5,16 @@ from typing import Callable -async def poll(condition: Callable[[], bool], timeout: float, interval: float = 0.1) -> None: + +async def poll( + condition: Callable[[], bool], timeout: float, interval: float = 0.1 +) -> None: """Polls a callable function until it returns or a timeout is reached.""" if interval < 0: raise ValueError("Interval must be a non-negative number.") if timeout < interval: - raise ValueError("Timeout must be greater than or equal to interval.") + raise ValueError("Timeout must be greater than or equal to interval.") loop = asyncio.get_running_loop() start = loop.time() @@ -19,4 +22,4 @@ async def poll(condition: Callable[[], bool], timeout: float, interval: float = if condition(): return await asyncio.sleep(interval) - raise TimeoutError("Polling timed out") \ No newline at end of file + raise TimeoutError("Polling timed out") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/send.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/send.py index a8d0f4e3..1aff1d3a 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/send.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/send.py @@ -14,6 +14,7 @@ ) from microsoft_agents.testing.core.utils import activities_from_ex + def _create_activity(payload: str | dict | Activity) -> Activity: """Create an Activity from various payload types. @@ -30,26 +31,27 @@ def _create_activity(payload: str | dict | Activity) -> Activity: else: raise TypeError("Unsupported payload type") + async def ex_send( payload: str | dict | Activity, url: str, listen_duration: float = 1.0, ) -> list[Exchange]: """Send an activity to an agent and return the exchanges. - + A convenience function for quick agent interactions without setting up a full scenario. Creates an ExternalScenario internally. - + :param payload: The activity payload (string message, dict, or Activity). :param url: The URL of the agent's message endpoint. :param listen_duration: Seconds to wait for async responses. :return: List of Exchange objects containing responses. - + Example:: - + exchanges = await ex_send("Hello!", "http://localhost:3978/api/messages") """ - + scenario = ExternalScenario(url) activity = _create_activity(payload) @@ -57,24 +59,25 @@ async def ex_send( async with scenario.client() as client: exchanges = await client.ex_send(activity, wait=listen_duration) return exchanges - + + async def send( payload: str | dict | Activity, url: str, listen_duration: float = 1.0, ) -> list[Activity]: """Send an activity to an agent and return response activities. - + A convenience function that returns just the response Activity objects, without the full Exchange metadata. - + :param payload: The activity payload (string message, dict, or Activity). :param url: The URL of the agent's message endpoint. :param listen_duration: Seconds to wait for async responses. :return: List of response Activity objects. - + Example:: - + replies = await send("Hello!", "http://localhost:3978/api/messages") for reply in replies: print(reply.text) diff --git a/dev/microsoft-agents-testing/tests/cli/test_cli_integration.py b/dev/microsoft-agents-testing/tests/cli/test_cli_integration.py index a38ad415..75b70099 100644 --- a/dev/microsoft-agents-testing/tests/cli/test_cli_integration.py +++ b/dev/microsoft-agents-testing/tests/cli/test_cli_integration.py @@ -88,7 +88,7 @@ # class TestChatCommandBehavior: # """ # Integration tests simulating chat command behavior. - + # These tests use real agents to verify the chat functionality works # correctly - sending messages and receiving responses. # """ @@ -97,7 +97,7 @@ # async def test_chat_single_message_exchange(self, agent_client): # """Verify single message exchange like chat command does.""" # await agent_client.send("Hello agent!", wait=0.2) - + # # Verify the agent responded # agent_client.expect().that_for_any(text="Echo: Hello agent!") @@ -107,7 +107,7 @@ # await agent_client.send("First message", wait=0.1) # await agent_client.send("Second message", wait=0.1) # await agent_client.send("Third message", wait=0.2) - + # # All messages should have been echoed # agent_client.expect().that_for_any(text="Echo: First message") # agent_client.expect().that_for_any(text="Echo: Second message") @@ -119,11 +119,11 @@ # await agent_client.send("Message 1", wait=0.1) # await agent_client.send("Message 2", wait=0.1) # await agent_client.send("Message 3", wait=0.2) - + # # Transcript should have all exchanges # transcript = agent_client.transcript # assert transcript is not None - + # # Should have at least 3 exchanges (one per message) # history = transcript.history() # assert len(history) >= 3 @@ -137,14 +137,14 @@ # async def test_greeting_agent_responds_to_hello(self, agent_client): # """Greeting agent responds with personalized greeting.""" # await agent_client.send("hello Alice", wait=0.2) - + # agent_client.expect().that_for_any(text="Hello, Alice! Nice to meet you.") # @pytest.mark.asyncio # async def test_greeting_agent_prompts_for_hello(self, agent_client): # """Greeting agent prompts user if they don't say hello.""" # await agent_client.send("something else", wait=0.2) - + # agent_client.expect().that_for_any(text="Say 'hello ' to get a greeting!") @@ -156,7 +156,7 @@ # async def test_receives_all_responses(self, agent_client): # """Verify all multiple responses from agent are received.""" # await agent_client.send("Do something", wait=0.3) - + # # All three responses should come through # agent_client.expect().that_for_any(text="Processing your request...") # agent_client.expect().that_for_any(text="Still working on it...") @@ -172,7 +172,7 @@ # class TestPostCommandBehavior: # """ # Integration tests simulating post command behavior. - + # Tests sending payloads to agents like the post command does. # """ @@ -180,7 +180,7 @@ # async def test_post_simple_text_message(self, agent_client): # """Verify posting a simple text message works like --message option.""" # await agent_client.send("Simple message", wait=0.2) - + # agent_client.expect().that_for_any(text="Echo: Simple message") # @pytest.mark.asyncio @@ -190,9 +190,9 @@ # type=ActivityTypes.message, # text="Custom payload message", # ) - + # await agent_client.send(activity, wait=0.2) - + # agent_client.expect().that_for_any(text="Echo: Custom payload message") # @pytest.mark.asyncio @@ -200,7 +200,7 @@ # """Verify multiple posts work in sequence.""" # await agent_client.send("First payload", wait=0.1) # await agent_client.send("Second payload", wait=0.2) - + # agent_client.expect().that_for_any(text="Echo: First payload") # agent_client.expect().that_for_any(text="Echo: Second payload") @@ -241,9 +241,9 @@ # def test_validate_with_complete_config(self, tmp_path: Path): # """Validate command succeeds with complete configuration.""" # from microsoft_agents.testing.cli.main import cli - + # runner = CliRunner() - + # # Create a complete env file # env_file = tmp_path / ".env" # env_file.write_text(""" @@ -253,9 +253,9 @@ # CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=test-secret # CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=test-tenant # """) - + # result = runner.invoke(cli, ["--env", str(env_file), "validate"]) - + # assert result.exit_code == 0 # assert "Configuration Validation" in result.output # assert "All configuration checks passed" in result.output @@ -263,32 +263,32 @@ # def test_validate_shows_missing_values(self, tmp_path: Path): # """Validate command shows warnings for missing config values.""" # from microsoft_agents.testing.cli.main import cli - + # runner = CliRunner() - + # # Create a partial env file # env_file = tmp_path / ".env" # env_file.write_text("AGENT_URL=http://localhost:3978") - + # result = runner.invoke(cli, ["--env", str(env_file), "validate"]) - + # assert result.exit_code == 0 # assert "Not configured" in result.output # def test_validate_masks_credentials(self, tmp_path: Path): # """Validate command masks sensitive credentials in output.""" # from microsoft_agents.testing.cli.main import cli - + # runner = CliRunner() - + # env_file = tmp_path / ".env" # env_file.write_text(""" # CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=abcdefghijklmnop # CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=super-secret-password # """) - + # result = runner.invoke(cli, ["--env", str(env_file), "validate"]) - + # # App ID should be partially masked # assert "abcdefgh..." in result.output # # Full values should NOT appear @@ -309,10 +309,10 @@ # def test_post_shows_usage_help(self): # """Post command displays usage information.""" # from microsoft_agents.testing.cli.main import cli - + # runner = CliRunner() # result = runner.invoke(cli, ["post", "--help"]) - + # assert result.exit_code == 0 # assert "Send a payload to an agent" in result.output # assert "--message" in result.output @@ -321,13 +321,13 @@ # def test_post_requires_payload_or_message(self, tmp_path: Path): # """Post command requires either payload file or --message.""" # from microsoft_agents.testing.cli.main import cli - + # runner = CliRunner() - + # with runner.isolated_filesystem(temp_dir=tmp_path): # Path(".env").write_text("AGENT_URL=http://localhost:3978") # result = runner.invoke(cli, ["post"]) - + # # Should error about missing payload # assert "No payload specified" in result.output or result.exit_code != 0 @@ -343,23 +343,23 @@ # def test_run_shows_help(self): # """Run command displays help information.""" # from microsoft_agents.testing.cli.main import cli - + # runner = CliRunner() # result = runner.invoke(cli, ["run", "--help"]) - + # assert result.exit_code == 0 # assert "--scenario" in result.output # def test_run_rejects_invalid_scenario(self, tmp_path: Path): # """Run command rejects invalid scenario names.""" # from microsoft_agents.testing.cli.main import cli - + # runner = CliRunner() - + # with runner.isolated_filesystem(temp_dir=tmp_path): # Path(".env").write_text("AGENT_URL=http://localhost:3978") # result = runner.invoke(cli, ["run", "--scenario", "nonexistent"]) - + # # Should abort with error about invalid scenario # assert result.exit_code != 0 or "Invalid" in result.output or "Aborted" in result.output @@ -375,9 +375,9 @@ # def test_chat_shows_help(self): # """Chat command displays help information.""" # from microsoft_agents.testing.cli.main import cli - + # runner = CliRunner() # result = runner.invoke(cli, ["chat", "--help"]) - + # assert result.exit_code == 0 # assert "--url" in result.output diff --git a/dev/microsoft-agents-testing/tests/cli/test_output.py b/dev/microsoft-agents-testing/tests/cli/test_output.py index 693f96eb..a3e7fda5 100644 --- a/dev/microsoft-agents-testing/tests/cli/test_output.py +++ b/dev/microsoft-agents-testing/tests/cli/test_output.py @@ -17,28 +17,28 @@ # def test_success_outputs_green_message_with_checkmark(self): # """success() outputs message with green styling and checkmark.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() # out.success("Operation completed") - + # result = runner.invoke(cmd) - + # assert "✓ Operation completed" in result.output # assert result.exit_code == 0 # def test_error_outputs_red_message_with_x(self): # """error() outputs message with red styling and x mark.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() # out.error("Something failed") - + # result = runner.invoke(cmd) - + # # Error outputs to stderr, but CliRunner captures both # assert "✗ Something failed" in result.output # assert result.exit_code == 0 @@ -46,40 +46,40 @@ # def test_warning_outputs_yellow_message_with_warning_symbol(self): # """warning() outputs message with warning symbol.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() # out.warning("Be careful") - + # result = runner.invoke(cmd) - + # assert "⚠ Be careful" in result.output # def test_info_outputs_indented_message(self): # """info() outputs message with indentation.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() # out.info("Some information") - + # result = runner.invoke(cmd) - + # assert " Some information" in result.output # def test_header_outputs_bold_text_with_underline(self): # """header() outputs text with underline.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() # out.header("My Section") - + # result = runner.invoke(cmd) - + # assert "My Section" in result.output # assert "----------" in result.output # Underline same length as header @@ -90,29 +90,29 @@ # def test_debug_hidden_when_verbose_false(self): # """debug() messages are hidden when verbose is False.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output(verbose=False) # out.debug("Debug message") # out.info("Normal message") - + # result = runner.invoke(cmd) - + # assert "Debug message" not in result.output # assert "Normal message" in result.output # def test_debug_shown_when_verbose_true(self): # """debug() messages are shown when verbose is True.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output(verbose=True) # out.debug("Debug message") - + # result = runner.invoke(cmd) - + # assert "[debug] Debug message" in result.output @@ -122,7 +122,7 @@ # def test_table_displays_headers_and_rows(self): # """table() displays headers and data rows.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() @@ -133,9 +133,9 @@ # ["baz", "qux"], # ] # ) - + # result = runner.invoke(cmd) - + # assert "Name" in result.output # assert "Value" in result.output # assert "foo" in result.output @@ -146,7 +146,7 @@ # def test_table_handles_empty_rows(self): # """table() handles empty row list gracefully.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() @@ -154,9 +154,9 @@ # headers=["Col1", "Col2"], # rows=[] # ) - + # result = runner.invoke(cmd) - + # # Should still show headers # assert "Col1" in result.output # assert "Col2" in result.output @@ -169,14 +169,14 @@ # def test_key_value_displays_formatted_pair(self): # """key_value() displays key and value with formatting.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() # out.key_value("Agent URL", "http://localhost:3978") - + # result = runner.invoke(cmd) - + # assert "Agent URL:" in result.output # assert "http://localhost:3978" in result.output @@ -187,31 +187,31 @@ # def test_newline_adds_blank_line(self): # """newline() adds blank lines to output.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() # out.info("Line 1") # out.newline() # out.info("Line 2") - + # result = runner.invoke(cmd) # lines = result.output.split('\n') - + # # Should have a blank line between the two info lines # assert len([l for l in lines if l.strip() == ""]) >= 1 # def test_divider_outputs_horizontal_line(self): # """divider() outputs a horizontal line of dashes.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() # out.divider() - + # result = runner.invoke(cmd) - + # assert "-" * 80 in result.output @@ -221,14 +221,14 @@ # def test_json_outputs_formatted_json(self): # """json() outputs data as formatted JSON.""" # runner = CliRunner() - + # @click.command() # def cmd(): # out = Output() # out.json({"key": "value", "nested": {"inner": 42}}) - + # result = runner.invoke(cmd) - + # assert '"key": "value"' in result.output # assert '"nested"' in result.output # assert '"inner": 42' in result.output diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py b/dev/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py index 76082e3a..00b35f78 100644 --- a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py +++ b/dev/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py @@ -6,7 +6,9 @@ import pytest from microsoft_agents.testing.core.fluent.backend.describe import Describe -from microsoft_agents.testing.core.fluent.backend.model_predicate import ModelPredicateResult +from microsoft_agents.testing.core.fluent.backend.model_predicate import ( + ModelPredicateResult, +) from microsoft_agents.testing.core.fluent.backend.quantifier import ( for_all, for_any, @@ -338,28 +340,36 @@ class TestIntegration: def test_full_workflow_passing(self): """Full workflow with passing results.""" describe = Describe() - mpr = ModelPredicateResult({}, {}, [ - {"name": True, "value": True}, - {"name": True, "value": True}, - ]) - + mpr = ModelPredicateResult( + {}, + {}, + [ + {"name": True, "value": True}, + {"name": True, "value": True}, + ], + ) + description = describe.describe(mpr, for_all) failures = describe.describe_failures(mpr) - + assert "✓" in description assert failures == [] def test_full_workflow_failing(self): """Full workflow with failing results.""" describe = Describe() - mpr = ModelPredicateResult({}, {}, [ - {"name": True, "value": True}, - {"name": False, "value": True}, - ]) - + mpr = ModelPredicateResult( + {}, + {}, + [ + {"name": True, "value": True}, + {"name": False, "value": True}, + ], + ) + description = describe.describe(mpr, for_all) failures = describe.describe_failures(mpr) - + assert "✗" in description assert len(failures) == 1 assert "name" in failures[0] @@ -367,12 +377,16 @@ def test_full_workflow_failing(self): def test_complex_nested_failures(self): """Complex nested structure failure descriptions.""" describe = Describe() - mpr = ModelPredicateResult({}, {}, [ - {"user": {"profile": {"name": False, "active": True}}}, - ]) - + mpr = ModelPredicateResult( + {}, + {}, + [ + {"user": {"profile": {"name": False, "active": True}}}, + ], + ) + failures = describe.describe_failures(mpr) - + assert len(failures) == 1 assert "user.profile.name" in failures[0] @@ -383,16 +397,16 @@ class TestDescribeFailuresWithFunctionSource: def test_describe_failures_includes_function_source(self): """describe_failures includes function source for failed keys.""" describe = Describe() - + def check_positive(x): return x > 0 - + source = [{"value": -5}] dict_transform = {"value": check_positive} mpr = ModelPredicateResult(source, dict_transform, [{"value": False}]) - + result = describe.describe_failures(mpr) - + assert len(result) == 1 assert "value" in result[0] assert "check_positive" in result[0] @@ -403,13 +417,13 @@ def check_positive(x): def test_describe_failures_includes_lambda_source(self): """describe_failures includes lambda source for failed keys.""" describe = Describe() - + source = [{"count": 5}] dict_transform = {"count": lambda x: x >= 10} mpr = ModelPredicateResult(source, dict_transform, [{"count": False}]) - + result = describe.describe_failures(mpr) - + assert len(result) == 1 assert "count" in result[0] assert "lambda" in result[0] @@ -419,19 +433,21 @@ def test_describe_failures_includes_lambda_source(self): def test_describe_failures_multiple_keys_with_sources(self): """describe_failures includes sources for multiple failed keys.""" describe = Describe() - + def is_active(x): return x is True - + source = [{"name": "wrong", "active": False}] dict_transform = { "name": lambda x: x == "test", "active": is_active, } - mpr = ModelPredicateResult(source, dict_transform, [{"name": False, "active": False}]) - + mpr = ModelPredicateResult( + source, dict_transform, [{"name": False, "active": False}] + ) + result = describe.describe_failures(mpr) - + assert len(result) == 1 assert "name" in result[0] assert "active" in result[0] @@ -441,14 +457,14 @@ def is_active(x): def test_describe_failures_handles_missing_function(self): """describe_failures handles keys without functions gracefully.""" describe = Describe() - + # Create a mpr where dict_transform doesn't have the key source = [{"missing": "value"}] dict_transform = {} mpr = ModelPredicateResult(source, dict_transform, [{"missing": False}]) - + result = describe.describe_failures(mpr) - + assert len(result) == 1 assert "missing" in result[0] assert "" in result[0] @@ -456,14 +472,14 @@ def test_describe_failures_handles_missing_function(self): def test_describe_failures_handles_non_callable(self): """describe_failures handles non-callable values gracefully.""" describe = Describe() - + # Manually set a non-callable in dict_transform source = [{"key": "actual_value"}] dict_transform = {"key": "not_callable"} mpr = ModelPredicateResult(source, dict_transform, [{"key": False}]) - + result = describe.describe_failures(mpr) - + assert len(result) == 1 assert "key" in result[0] assert "" in result[0] @@ -471,16 +487,18 @@ def test_describe_failures_handles_non_callable(self): def test_describe_failures_with_nested_keys_and_sources(self): """describe_failures includes sources for nested failed keys.""" describe = Describe() - + def check_name(x): return x == "expected" - + source = [{"user": {"profile": {"name": "actual_name"}}}] dict_transform = {"user.profile.name": check_name} - mpr = ModelPredicateResult(source, dict_transform, [{"user": {"profile": {"name": False}}}]) - + mpr = ModelPredicateResult( + source, dict_transform, [{"user": {"profile": {"name": False}}}] + ) + result = describe.describe_failures(mpr) - + assert len(result) == 1 assert "user.profile.name" in result[0] assert "check_name" in result[0] @@ -489,30 +507,30 @@ def check_name(x): def test_describe_failures_no_failures_with_dict_transform(self): """describe_failures returns empty list when all pass, even with dict_transform.""" describe = Describe() - + source = [{"value": 10}] dict_transform = {"value": lambda x: x > 0} mpr = ModelPredicateResult(source, dict_transform, [{"value": True}]) - + result = describe.describe_failures(mpr) - + assert result == [] def test_describe_failures_formats_multiline_function(self): """describe_failures handles multiline function definitions.""" describe = Describe() - + def complex_check(x): if x is None: return False return x > 0 - + source = [{"value": -1}] dict_transform = {"value": complex_check} mpr = ModelPredicateResult(source, dict_transform, [{"value": False}]) - + result = describe.describe_failures(mpr) - + assert len(result) == 1 assert "complex_check" in result[0] # The source should be included even for multiline functions @@ -521,15 +539,15 @@ def complex_check(x): def test_describe_failures_shows_expected_value(self): """describe_failures shows expected value from lambda defaults.""" describe = Describe() - + # DictionaryTransform creates lambdas like: lambda x, _v=val: x == _v expected_val = "expected_value" source = [{"key": "actual_value"}] dict_transform = {"key": lambda x, _v=expected_val: x == _v} mpr = ModelPredicateResult(source, dict_transform, [{"key": False}]) - + result = describe.describe_failures(mpr) - + assert len(result) == 1 assert "expected:" in result[0] assert "expected_value" in result[0] @@ -539,13 +557,13 @@ def test_describe_failures_shows_expected_value(self): def test_describe_failures_shows_expected_and_actual_for_numeric(self): """describe_failures shows expected and actual numeric values.""" describe = Describe() - + source = [{"count": 5}] dict_transform = {"count": lambda x, _v=10: x == _v} mpr = ModelPredicateResult(source, dict_transform, [{"count": False}]) - + result = describe.describe_failures(mpr) - + assert len(result) == 1 assert "expected:" in result[0] assert "10" in result[0] diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py b/dev/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py index 56cdfe35..da620b2c 100644 --- a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py +++ b/dev/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py @@ -19,11 +19,11 @@ for_n, ) - # ============================================================================ # ModelPredicateResult Tests # ============================================================================ + class TestModelPredicateResult: """Tests for the ModelPredicateResult class.""" @@ -50,11 +50,15 @@ def test_init_with_mixed_values(self): def test_init_with_multiple_dicts(self): """Initializing with multiple dicts produces multiple bools.""" - result = ModelPredicateResult({}, {}, [ - {"a": True}, - {"a": False}, - {"a": True, "b": True}, - ]) + result = ModelPredicateResult( + {}, + {}, + [ + {"a": True}, + {"a": False}, + {"a": True, "b": True}, + ], + ) assert result.result_bools == [True, False, True] def test_init_with_nested_dict_all_true(self): @@ -95,7 +99,9 @@ class TestModelPredicateResultDictTransform: def test_dict_transform_stores_transform_map(self): """dict_transform stores the transform map from DictionaryTransform.""" dict_transform = {"name": lambda x: x == "test", "value": lambda x: x > 0} - result = ModelPredicateResult({}, dict_transform, [{"name": True, "value": True}]) + result = ModelPredicateResult( + {}, dict_transform, [{"name": True, "value": True}] + ) assert result.dict_transform == dict_transform def test_dict_transform_is_accessible(self): @@ -115,7 +121,9 @@ def test_dict_transform_with_nested_keys(self): """dict_transform stores flattened keys.""" func = lambda x: x == "value" dict_transform = {"user.profile.name": func} - result = ModelPredicateResult({}, dict_transform, [{"user": {"profile": {"name": True}}}]) + result = ModelPredicateResult( + {}, dict_transform, [{"user": {"profile": {"name": True}}}] + ) assert "user.profile.name" in result.dict_transform assert result.dict_transform["user.profile.name"] is func @@ -123,7 +131,7 @@ def test_dict_transform_from_model_predicate(self): """ModelPredicate.eval stores dict_transform in result.""" predicate = ModelPredicate.from_args({"name": "test", "value": lambda x: x > 0}) result = predicate.eval({"name": "test", "value": 10}) - + assert "name" in result.dict_transform assert "value" in result.dict_transform assert callable(result.dict_transform["name"]) @@ -131,12 +139,13 @@ def test_dict_transform_from_model_predicate(self): def test_dict_transform_preserves_callables(self): """dict_transform preserves original callable functions.""" + def custom_check(x): return x == "expected" - + dict_transform = {"key": custom_check} result = ModelPredicateResult([], dict_transform, [{"key": True}]) - + assert result.dict_transform["key"] is custom_check assert result.dict_transform["key"]("expected") is True assert result.dict_transform["key"]("other") is False @@ -154,11 +163,11 @@ def test_source_stores_source_list(self): def test_source_from_pydantic_models(self): """source converts Pydantic models to dicts.""" from pydantic import BaseModel - + class TestModel(BaseModel): name: str value: int - + models = [TestModel(name="test", value=42)] result = ModelPredicateResult(models, {}, [{"name": True}]) assert result.source == [{"name": "test", "value": 42}] @@ -168,7 +177,7 @@ def test_source_from_model_predicate(self): predicate = ModelPredicate.from_args({"name": "test"}) source = {"name": "test", "value": 10} result = predicate.eval(source) - + assert result.source == [source] def test_source_multiple_items(self): @@ -183,6 +192,7 @@ def test_source_multiple_items(self): # Sample Models for Testing # ============================================================================ + class SampleModel(BaseModel): """A sample Pydantic model for testing.""" @@ -194,7 +204,7 @@ class SampleModel(BaseModel): class NestedModel(BaseModel): """A Pydantic model with nested structure.""" - + outer: dict @@ -202,6 +212,7 @@ class NestedModel(BaseModel): # ModelPredicate Initialization Tests # ============================================================================ + class TestModelPredicateInit: """Tests for ModelPredicate initialization.""" @@ -222,6 +233,7 @@ def test_init_creates_model_transform(self): # ModelPredicate.eval Tests with Dicts # ============================================================================ + class TestModelPredicateEvalWithDicts: """Tests for ModelPredicate.eval with dictionary sources.""" @@ -273,6 +285,7 @@ def test_eval_multiple_predicates_partial_match(self): # ModelPredicate.eval Tests with Pydantic Models # ============================================================================ + class TestModelPredicateEvalWithPydanticModels: """Tests for ModelPredicate.eval with Pydantic model sources.""" @@ -315,6 +328,7 @@ def test_eval_pydantic_model_with_nested_dict(self): # ModelPredicate.eval Tests with Callables # ============================================================================ + class TestModelPredicateEvalWithCallables: """Tests for ModelPredicate.eval with callable predicates.""" @@ -368,9 +382,7 @@ def test_eval_with_root_callable_returning_empty_list(self): def test_eval_with_mixed_value_and_callable(self): """eval works with mixed value and callable predicates.""" - predicate = ModelPredicate.from_args( - {"name": "test", "value": lambda x: x > 0} - ) + predicate = ModelPredicate.from_args({"name": "test", "value": lambda x: x > 0}) result = predicate.eval({"name": "test", "value": 10}) assert result.result_bools == [True] @@ -379,6 +391,7 @@ def test_eval_with_mixed_value_and_callable(self): # ModelPredicate.from_args Tests # ============================================================================ + class TestModelPredicateFromArgs: """Tests for the ModelPredicate.from_args factory method.""" @@ -428,6 +441,7 @@ def test_from_args_kwargs_override_dict(self): # ModelPredicate with Quantifiers Tests # ============================================================================ + class TestModelPredicateWithQuantifiers: """Tests demonstrating ModelPredicate results with quantifiers.""" @@ -502,22 +516,19 @@ def test_for_n_wrong_count(self): # Nested Predicate Tests # ============================================================================ + class TestNestedPredicates: """Tests for nested predicate evaluation.""" def test_nested_dict_predicate(self): """Nested dict predicates are evaluated correctly.""" - predicate = ModelPredicate.from_args( - {"user": {"name": "test", "active": True}} - ) + predicate = ModelPredicate.from_args({"user": {"name": "test", "active": True}}) result = predicate.eval({"user": {"name": "test", "active": True}}) assert result.result_bools == [True] def test_nested_dict_predicate_not_matching(self): """Nested dict predicate returns False when not matching.""" - predicate = ModelPredicate.from_args( - {"user": {"name": "test"}} - ) + predicate = ModelPredicate.from_args({"user": {"name": "test"}}) result = predicate.eval({"user": {"name": "other"}}) assert result.result_bools == [False] @@ -538,6 +549,7 @@ def test_dotted_kwargs_with_callable(self): # Edge Cases Tests # ============================================================================ + class TestEdgeCases: """Tests for edge cases and special scenarios.""" @@ -576,4 +588,4 @@ def test_predicate_with_empty_string(self): """Predicate correctly matches empty string.""" predicate = ModelPredicate.from_args({"text": ""}) result = predicate.eval({"text": ""}) - assert result.result_bools == [True] \ No newline at end of file + assert result.result_bools == [True] diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py b/dev/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py index 17b855ae..a58e2bf6 100644 --- a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py +++ b/dev/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py @@ -85,9 +85,10 @@ def test_map_property_contains_callables(self): def test_map_property_preserves_custom_callables(self): """map property preserves custom callable functions.""" + def custom_func(x): return x > 0 - + transform = DictionaryTransform({"check": custom_func}) assert transform.map["check"] is custom_func @@ -298,6 +299,7 @@ def test_from_args_with_kwargs(self): class SampleModel(BaseModel): """A sample Pydantic model for testing.""" + name: str value: int nested: dict | None = None @@ -366,10 +368,12 @@ def test_equality_predicate_generation(self): def test_mixed_predicates(self): """Mixed value and callable predicates work together.""" - transform = DictionaryTransform({ - "name": "test", - "value": lambda x: x > 0, - }) + transform = DictionaryTransform( + { + "name": "test", + "value": lambda x: x > 0, + } + ) actual = {"name": "test", "value": 10} result = transform.eval(actual) assert result == {"name": True, "value": True} diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py b/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py index 096361fc..c1e2a2f2 100644 --- a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py +++ b/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py @@ -10,7 +10,7 @@ class ReadonlySubclass(Readonly): """A test subclass that uses the Readonly mixin.""" - + def __init__(self): # Use object.__setattr__ to bypass the readonly protection during init object.__setattr__(self, "initial_value", 42) @@ -23,84 +23,84 @@ class TestReadonly: def test_setattr_raises_attribute_error(self): """Setting an attribute should raise AttributeError.""" obj = ReadonlySubclass() - + with pytest.raises(AttributeError) as exc_info: obj.new_attribute = "value" - + assert "Cannot set attribute 'new_attribute'" in str(exc_info.value) assert "ReadonlySubclass" in str(exc_info.value) def test_setattr_raises_for_existing_attribute(self): """Setting an existing attribute should also raise AttributeError.""" obj = ReadonlySubclass() - + with pytest.raises(AttributeError) as exc_info: obj.initial_value = 100 - + assert "Cannot set attribute 'initial_value'" in str(exc_info.value) def test_delattr_raises_attribute_error(self): """Deleting an attribute should raise AttributeError.""" obj = ReadonlySubclass() - + with pytest.raises(AttributeError) as exc_info: del obj.initial_value - + assert "Cannot delete attribute 'initial_value'" in str(exc_info.value) assert "ReadonlySubclass" in str(exc_info.value) def test_delattr_raises_for_nonexistent_attribute(self): """Deleting a non-existent attribute should also raise AttributeError.""" obj = ReadonlySubclass() - + with pytest.raises(AttributeError) as exc_info: del obj.nonexistent - + assert "Cannot delete attribute 'nonexistent'" in str(exc_info.value) def test_setitem_raises_attribute_error(self): """Setting an item should raise AttributeError.""" obj = ReadonlySubclass() - + with pytest.raises(AttributeError) as exc_info: obj["key"] = "new_value" - + assert "Cannot set item 'key'" in str(exc_info.value) assert "ReadonlySubclass" in str(exc_info.value) def test_delitem_raises_attribute_error(self): """Deleting an item should raise AttributeError.""" obj = ReadonlySubclass() - + with pytest.raises(AttributeError) as exc_info: del obj["key"] - + assert "Cannot delete item 'key'" in str(exc_info.value) assert "ReadonlySubclass" in str(exc_info.value) def test_getattr_still_works(self): """Getting attributes should still work normally.""" obj = ReadonlySubclass() - + assert obj.initial_value == 42 def test_object_setattr_bypasses_protection(self): """Using object.__setattr__ should bypass the protection.""" obj = ReadonlySubclass() - + # This is the escape hatch for initialization object.__setattr__(obj, "new_attr", "bypassed") - + assert obj.new_attr == "bypassed" def test_multiple_readonly_instances_are_independent(self): """Multiple Readonly instances should be independent.""" obj1 = ReadonlySubclass() obj2 = ReadonlySubclass() - + # Modify obj1 via escape hatch object.__setattr__(obj1, "initial_value", 100) - + # obj2 should be unaffected assert obj1.initial_value == 100 assert obj2.initial_value == 42 diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py b/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py index 0dc8c878..0f2a1d4e 100644 --- a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py +++ b/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py @@ -78,7 +78,7 @@ def test_unset_in_if_statement(self): result = "truthy" else: result = "falsy" - + assert result == "falsy" def test_unset_identity(self): diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py b/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py index a378987c..f16f181e 100644 --- a/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py +++ b/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py @@ -6,8 +6,16 @@ import pytest from pydantic import BaseModel -from microsoft_agents.activity import Activity, ActivityTypes, ChannelAccount, ConversationAccount -from microsoft_agents.testing.core.fluent.model_template import ModelTemplate, ActivityTemplate +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, +) +from microsoft_agents.testing.core.fluent.model_template import ( + ModelTemplate, + ActivityTemplate, +) class SimpleModel(BaseModel): @@ -129,7 +137,9 @@ def test_create_with_nested_dict(self): def test_create_with_nested_defaults(self): """create() merges nested defaults correctly.""" - template = ModelTemplate(NestedModel, title="Default", **{"metadata.key1": "v1"}) + template = ModelTemplate( + NestedModel, title="Default", **{"metadata.key1": "v1"} + ) model = template.create({"metadata": {"key2": "v2"}}) # Original overwrites defaults since it's a complete dictionary assert model.title == "Default" @@ -204,7 +214,7 @@ def test_create_multiple_independent_models(self): template = ModelTemplate(SimpleModel, name="default", value=42) model1 = template.create({"name": "one"}) model2 = template.create({"name": "two"}) - + assert model1.name == "one" assert model2.name == "two" assert model1 is not model2 @@ -213,12 +223,13 @@ def test_template_unchanged_after_create(self): """Template defaults are unchanged after create().""" template = ModelTemplate(SimpleModel, name="default", value=42) template.create({"name": "custom"}) - + # Create another to verify defaults model = template.create() assert model.name == "default" assert model.value == 42 + class TestActivityTemplateInit: """Tests for ActivityTemplate initialization.""" @@ -343,7 +354,7 @@ def test_create_with_from_property(self): """ActivityTemplate handles from_property correctly.""" template = ActivityTemplate( type=ActivityTypes.message, - from_property={"id": "user123", "name": "Test User"} + from_property={"id": "user123", "name": "Test User"}, ) activity = template.create() assert activity.from_property is not None @@ -354,7 +365,7 @@ def test_create_with_conversation(self): """ActivityTemplate handles conversation property correctly.""" template = ActivityTemplate( type=ActivityTypes.message, - conversation={"id": "conv123", "name": "Test Conversation"} + conversation={"id": "conv123", "name": "Test Conversation"}, ) activity = template.create() assert activity.conversation is not None @@ -364,8 +375,7 @@ def test_create_with_conversation(self): def test_create_with_recipient(self): """ActivityTemplate handles recipient property correctly.""" template = ActivityTemplate( - type=ActivityTypes.message, - recipient={"id": "bot123", "name": "Test Bot"} + type=ActivityTypes.message, recipient={"id": "bot123", "name": "Test Bot"} ) activity = template.create() assert activity.recipient is not None @@ -376,8 +386,7 @@ def test_create_with_channel_account_model(self): """ActivityTemplate handles ChannelAccount model correctly.""" channel_account = ChannelAccount(id="user123", name="Test User") template = ActivityTemplate( - type=ActivityTypes.message, - from_property=channel_account + type=ActivityTypes.message, from_property=channel_account ) activity = template.create() assert activity.from_property.id == "user123" @@ -387,8 +396,7 @@ def test_create_with_conversation_account_model(self): """ActivityTemplate handles ConversationAccount model correctly.""" conversation = ConversationAccount(id="conv123", name="Test Conversation") template = ActivityTemplate( - type=ActivityTypes.message, - conversation=conversation + type=ActivityTypes.message, conversation=conversation ) activity = template.create() assert activity.conversation.id == "conv123" @@ -455,7 +463,9 @@ def test_create_original_from_alias_overrides_from_property_default(self): **{"from_property.id": "default-id", "from_property.name": "Default User"} ) - activity = template.create({"from": {"id": "override-id", "name": "Override 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" @@ -539,21 +549,25 @@ def test_create_activity_with_attachments(self): """ActivityTemplate creates activities with attachments correctly.""" template = ActivityTemplate( type=ActivityTypes.message, - attachments=[{ - "content_type": "application/vnd.microsoft.card.hero", - "content": {"title": "Hero Card", "text": "Some text"} - }] + attachments=[ + { + "content_type": "application/vnd.microsoft.card.hero", + "content": {"title": "Hero Card", "text": "Some text"}, + } + ], ) activity = template.create() assert activity.attachments is not None assert len(activity.attachments) == 1 - assert activity.attachments[0].content_type == "application/vnd.microsoft.card.hero" + assert ( + activity.attachments[0].content_type + == "application/vnd.microsoft.card.hero" + ) def test_create_activity_with_channel_data(self): """ActivityTemplate creates activities with channel_data correctly.""" template = ActivityTemplate( - type=ActivityTypes.message, - channel_data={"custom_key": "custom_value"} + type=ActivityTypes.message, channel_data={"custom_key": "custom_value"} ) activity = template.create() assert activity.channel_data is not None @@ -564,7 +578,7 @@ def test_create_activity_with_value(self): template = ActivityTemplate( type=ActivityTypes.invoke, name="invoke/action", - value={"action": "test", "data": [1, 2, 3]} + value={"action": "test", "data": [1, 2, 3]}, ) activity = template.create() assert activity.value is not None @@ -574,18 +588,14 @@ def test_create_activity_with_value(self): def test_create_activity_with_service_url(self): """ActivityTemplate creates activities with service_url correctly.""" template = ActivityTemplate( - type=ActivityTypes.message, - service_url="https://test.botframework.com" + type=ActivityTypes.message, service_url="https://test.botframework.com" ) activity = template.create() assert activity.service_url == "https://test.botframework.com" def test_create_activity_with_channel_id(self): """ActivityTemplate creates activities with channel_id correctly.""" - template = ActivityTemplate( - type=ActivityTypes.message, - channel_id="emulator" - ) + template = ActivityTemplate(type=ActivityTypes.message, channel_id="emulator") activity = template.create() assert activity.channel_id == "emulator" @@ -606,6 +616,6 @@ def test_modifying_created_activity_does_not_affect_template(self): template = ActivityTemplate(type=ActivityTypes.message, text="Original") activity = template.create() activity.text = "Modified" - + new_activity = template.create() assert new_activity.text == "Original" diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_select.py b/dev/microsoft-agents-testing/tests/core/fluent/test_select.py index 7c5e9cfb..5d5495e9 100644 --- a/dev/microsoft-agents-testing/tests/core/fluent/test_select.py +++ b/dev/microsoft-agents-testing/tests/core/fluent/test_select.py @@ -111,6 +111,7 @@ def test_where_filters_by_value_callable(self): def test_where_filters_by_root_callable_truthiness(self): """where() filters using truthiness from root callable results.""" + class ActivityLike(BaseModel): name: str attachments: list[str] | None = None diff --git a/dev/microsoft-agents-testing/tests/core/test_agent_client.py b/dev/microsoft-agents-testing/tests/core/test_agent_client.py index c0295c28..e4a44abd 100644 --- a/dev/microsoft-agents-testing/tests/core/test_agent_client.py +++ b/dev/microsoft-agents-testing/tests/core/test_agent_client.py @@ -17,49 +17,51 @@ from microsoft_agents.testing.core.fluent import ActivityTemplate from microsoft_agents.testing.core.transport import Transcript, Exchange, Sender - # ============================================================================ # Stub Sender for testing without mocks # ============================================================================ + class StubSender(Sender): """A stub sender that records sent activities and returns configurable responses. - + This is a real implementation of the Sender protocol for testing purposes, not a mock. It captures all sent activities and allows configuring responses. """ - + def __init__(self): self.sent_activities: list[Activity] = [] self.configured_responses: list[Activity] = [] self.configured_invoke_response: InvokeResponse | None = None self.configured_status_code: int = 200 self.configured_error: str | None = None - + def with_responses(self, *responses: Activity) -> "StubSender": """Configure responses to return for the next send.""" self.configured_responses = list(responses) return self - + def with_invoke_response(self, response: InvokeResponse) -> "StubSender": """Configure an invoke response to return.""" self.configured_invoke_response = response return self - + def with_error(self, error: str) -> "StubSender": """Configure an error to return.""" self.configured_error = error return self - + def with_status_code(self, code: int) -> "StubSender": """Configure the status code to return.""" self.configured_status_code = code return self - - async def send(self, activity: Activity, transcript: Transcript | None = None, **kwargs) -> Exchange: + + async def send( + self, activity: Activity, transcript: Transcript | None = None, **kwargs + ) -> Exchange: """Send an activity and return a configured exchange.""" self.sent_activities.append(activity) - + exchange = Exchange( request=activity, request_at=datetime.now(), @@ -69,10 +71,10 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * error=self.configured_error, response_at=datetime.now(), ) - + if transcript is not None: transcript.record(exchange) - + return exchange @@ -80,6 +82,7 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * # Test Helper Functions # ============================================================================ + class TestActivitiesFromEx: """Tests for the activities_from_ex helper function.""" @@ -93,9 +96,9 @@ def test_extracts_responses_from_single_exchange(self): activity1 = Activity(type=ActivityTypes.message, text="Hello") activity2 = Activity(type=ActivityTypes.message, text="World") exchange = Exchange(responses=[activity1, activity2]) - + result = activities_from_ex([exchange]) - + assert len(result) == 2 assert result[0] == activity1 assert result[1] == activity2 @@ -105,12 +108,12 @@ def test_extracts_responses_from_multiple_exchanges(self): activity1 = Activity(type=ActivityTypes.message, text="First") activity2 = Activity(type=ActivityTypes.message, text="Second") activity3 = Activity(type=ActivityTypes.message, text="Third") - + exchange1 = Exchange(responses=[activity1]) exchange2 = Exchange(responses=[activity2, activity3]) - + result = activities_from_ex([exchange1, exchange2]) - + assert len(result) == 3 assert result[0].text == "First" assert result[1].text == "Second" @@ -119,10 +122,12 @@ def test_extracts_responses_from_multiple_exchanges(self): def test_handles_exchanges_with_no_responses(self): """activities_from_ex handles exchanges with no responses.""" exchange1 = Exchange(responses=[]) - exchange2 = Exchange(responses=[Activity(type=ActivityTypes.message, text="Only")]) - + exchange2 = Exchange( + responses=[Activity(type=ActivityTypes.message, text="Only")] + ) + result = activities_from_ex([exchange1, exchange2]) - + assert len(result) == 1 assert result[0].text == "Only" @@ -131,6 +136,7 @@ def test_handles_exchanges_with_no_responses(self): # AgentClient Initialization Tests # ============================================================================ + class TestAgentClientInitialization: """Tests for AgentClient initialization.""" @@ -138,7 +144,7 @@ def test_initialization_with_sender_only(self): """AgentClient initializes with just a sender.""" sender = StubSender() client = AgentClient(sender=sender) - + assert client._sender is sender assert isinstance(client._transcript, Transcript) assert isinstance(client._template, ActivityTemplate) @@ -148,7 +154,7 @@ def test_initialization_with_custom_transcript(self): sender = StubSender() transcript = Transcript() client = AgentClient(sender=sender, transcript=transcript) - + assert client._transcript is transcript def test_initialization_with_custom_template(self): @@ -157,10 +163,12 @@ def test_initialization_with_custom_template(self): template = ActivityTemplate(type=ActivityTypes.message, text="Default") client = AgentClient(sender=sender, template=template) + # ============================================================================ # AgentClient Template Tests # ============================================================================ + class TestAgentClientTemplate: """Tests for AgentClient template property.""" @@ -169,15 +177,15 @@ def test_get_template(self): sender = StubSender() template = ActivityTemplate(type=ActivityTypes.message) client = AgentClient(sender=sender, template=template) - + def test_set_template(self): """template property can be set to a new template.""" sender = StubSender() client = AgentClient(sender=sender) new_template = ActivityTemplate(type=ActivityTypes.event) - + client.template = new_template - + assert client.template is new_template @@ -185,6 +193,7 @@ def test_set_template(self): # AgentClient Build Activity Tests # ============================================================================ + class TestAgentClientBuildActivity: """Tests for the _build_activity method.""" @@ -192,9 +201,9 @@ def test_build_from_string_creates_message_activity(self): """_build_activity creates a message activity from string.""" sender = StubSender() client = AgentClient(sender=sender) - + activity = client._build_activity("Hello World") - + assert activity.type == ActivityTypes.message assert activity.text == "Hello World" @@ -202,10 +211,12 @@ def test_build_from_activity_preserves_activity(self): """_build_activity preserves an Activity object.""" sender = StubSender() client = AgentClient(sender=sender) - original = Activity(type=ActivityTypes.event, name="test-event", value={"key": "value"}) - + original = Activity( + type=ActivityTypes.event, name="test-event", value={"key": "value"} + ) + activity = client._build_activity(original) - + assert activity.type == ActivityTypes.event assert activity.name == "test-event" assert activity.value == {"key": "value"} @@ -214,14 +225,12 @@ def test_build_applies_template_defaults(self): """_build_activity applies template defaults.""" sender = StubSender() template = ActivityTemplate( - channel_id="test-channel", - locale="en-US", - **{"from.id": "user-123"} + channel_id="test-channel", locale="en-US", **{"from.id": "user-123"} ) client = AgentClient(sender=sender, template=template) - + activity = client._build_activity("Hello") - + assert activity.channel_id == "test-channel" assert activity.locale == "en-US" assert activity.from_property.id == "user-123" @@ -231,10 +240,10 @@ def test_build_activity_overrides_template_defaults(self): sender = StubSender() template = ActivityTemplate(channel_id="default-channel", text="default text") client = AgentClient(sender=sender, template=template) - + original = Activity(type=ActivityTypes.message, channel_id="custom-channel") activity = client._build_activity(original) - + assert activity.channel_id == "custom-channel" # text should still come from template since original didn't specify it assert activity.text == "default text" @@ -244,6 +253,7 @@ def test_build_activity_overrides_template_defaults(self): # AgentClient Send Tests # ============================================================================ + class TestAgentClientSend: """Tests for AgentClient.send method.""" @@ -253,10 +263,10 @@ async def test_send_with_string(self): sender = StubSender() response_activity = Activity(type=ActivityTypes.message, text="Response") sender.with_responses(response_activity) - + client = AgentClient(sender=sender) result = await client.send("Hello") - + assert len(sender.sent_activities) == 1 assert sender.sent_activities[0].type == ActivityTypes.message assert sender.sent_activities[0].text == "Hello" @@ -268,11 +278,11 @@ async def test_send_with_activity(self): """send() accepts an Activity object.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="OK")) - + client = AgentClient(sender=sender) activity = Activity(type=ActivityTypes.event, name="custom-event") result = await client.send(activity) - + assert len(sender.sent_activities) == 1 assert sender.sent_activities[0].type == ActivityTypes.event assert sender.sent_activities[0].name == "custom-event" @@ -282,10 +292,10 @@ async def test_send_records_to_transcript(self): """send() records the exchange in the transcript.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) await client.send("Hello") - + history = client.transcript.history() assert len(history) == 1 assert history[0].request.text == "Hello" @@ -296,12 +306,12 @@ async def test_send_multiple_times(self): """Multiple sends accumulate in transcript.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) await client.send("First") await client.send("Second") await client.send("Third") - + history = client.transcript.history() assert len(history) == 3 assert history[0].request.text == "First" @@ -313,6 +323,7 @@ async def test_send_multiple_times(self): # AgentClient Ex Send Tests # ============================================================================ + class TestAgentClientExSend: """Tests for AgentClient.ex_send method.""" @@ -321,10 +332,10 @@ async def test_ex_send_returns_exchanges(self): """ex_send() returns Exchange objects.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) result = await client.ex_send("Hello") - + assert len(result) == 1 assert isinstance(result[0], Exchange) assert result[0].request.text == "Hello" @@ -334,10 +345,10 @@ async def test_ex_send_with_zero_wait(self): """ex_send() with wait=0 returns immediately.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) result = await client.ex_send("Hello", wait=0.0) - + assert len(result) == 1 @@ -345,6 +356,7 @@ async def test_ex_send_with_zero_wait(self): # AgentClient Send Expect Replies Tests # ============================================================================ + class TestAgentClientSendExpectReplies: """Tests for AgentClient.send_expect_replies method.""" @@ -353,10 +365,10 @@ async def test_send_expect_replies_sets_delivery_mode(self): """send_expect_replies() sets the delivery_mode to expect_replies.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) await client.send_expect_replies("Hello") - + assert sender.sent_activities[0].delivery_mode == DeliveryModes.expect_replies @pytest.mark.asyncio @@ -365,10 +377,10 @@ async def test_send_expect_replies_returns_activities(self): response1 = Activity(type=ActivityTypes.message, text="Reply 1") response2 = Activity(type=ActivityTypes.message, text="Reply 2") sender = StubSender().with_responses(response1, response2) - + client = AgentClient(sender=sender) result = await client.send_expect_replies("Hello") - + assert len(result) == 2 assert result[0].text == "Reply 1" assert result[1].text == "Reply 2" @@ -378,10 +390,10 @@ async def test_ex_send_expect_replies_returns_exchanges(self): """ex_send_expect_replies() returns Exchange objects.""" response = Activity(type=ActivityTypes.message, text="Reply") sender = StubSender().with_responses(response) - + client = AgentClient(sender=sender) result = await client.ex_send_expect_replies("Hello") - + assert len(result) == 1 assert isinstance(result[0], Exchange) @@ -390,6 +402,7 @@ async def test_ex_send_expect_replies_returns_exchanges(self): # AgentClient Send Stream Tests # ============================================================================ + class TestAgentClientSendStream: """Tests for AgentClient.send_stream method.""" @@ -435,6 +448,7 @@ async def test_ex_send_stream_returns_exchanges(self): # AgentClient Invoke Tests # ============================================================================ + class TestAgentClientInvoke: """Tests for AgentClient.invoke method.""" @@ -444,11 +458,11 @@ async def test_invoke_returns_invoke_response(self): sender = StubSender() invoke_response = InvokeResponse(status=200, body={"result": "success"}) sender.with_invoke_response(invoke_response) - + client = AgentClient(sender=sender) activity = Activity(type=ActivityTypes.invoke, name="test/invoke") result = await client.invoke(activity) - + assert result.status == 200 assert result.body == {"result": "success"} @@ -458,7 +472,7 @@ async def test_invoke_raises_for_non_invoke_activity(self): sender = StubSender() client = AgentClient(sender=sender) activity = Activity(type=ActivityTypes.message, text="Hello") - + with pytest.raises(ValueError, match="Activity type must be 'invoke'"): await client.invoke(activity) @@ -467,10 +481,10 @@ async def test_invoke_raises_when_no_response(self): """invoke() raises RuntimeError when no InvokeResponse received.""" sender = StubSender() # No invoke response configured - + client = AgentClient(sender=sender) activity = Activity(type=ActivityTypes.invoke, name="test/invoke") - + with pytest.raises(RuntimeError, match="No InvokeResponse received"): await client.invoke(activity) @@ -478,10 +492,10 @@ async def test_invoke_raises_when_no_response(self): async def test_invoke_raises_when_error_present(self): """invoke() raises Exception when error is present in exchange.""" sender = StubSender().with_error("Connection failed") - + client = AgentClient(sender=sender) activity = Activity(type=ActivityTypes.invoke, name="test/invoke") - + with pytest.raises(Exception, match="Connection failed"): await client.invoke(activity) @@ -491,11 +505,11 @@ async def test_ex_invoke_returns_exchange(self): sender = StubSender() invoke_response = InvokeResponse(status=200, body={"result": "ok"}) sender.with_invoke_response(invoke_response) - + client = AgentClient(sender=sender) activity = Activity(type=ActivityTypes.invoke, name="test/invoke") result = await client.ex_invoke(activity) - + assert isinstance(result, Exchange) assert result.invoke_response.status == 200 @@ -504,6 +518,7 @@ async def test_ex_invoke_returns_exchange(self): # AgentClient Transcript Access Tests # ============================================================================ + class TestAgentClientTranscriptAccess: """Tests for AgentClient transcript access methods.""" @@ -512,13 +527,13 @@ async def test_history_returns_all_activities(self): """history() returns all activities from transcript.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) await client.send("First") await client.send("Second") - + history = client.history() - + # 2 responses (one per send) assert len(history) == 2 assert history[0].text == "Reply" @@ -529,10 +544,10 @@ async def test_recent_returns_activities(self): """recent() returns recent activities.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) await client.send("Hello") - + recent = client.recent() assert len(recent) == 1 assert recent[0].text == "Reply" @@ -542,13 +557,13 @@ async def test_ex_history_returns_all_exchanges(self): """ex_history() returns all exchanges from transcript.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) await client.send("First") await client.send("Second") - + history = client.ex_history() - + assert len(history) == 2 assert history[0].request.text == "First" assert history[1].request.text == "Second" @@ -558,10 +573,10 @@ async def test_ex_recent_returns_exchanges(self): """ex_recent() returns recent exchanges.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) await client.send("Hello") - + recent = client.ex_recent() assert len(recent) == 1 assert recent[0].request.text == "Hello" @@ -571,13 +586,13 @@ async def test_clear_clears_transcript(self): """clear() clears the transcript history.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) await client.send("Hello") assert len(client.history()) == 1 - + client.clear() - + assert len(client.history()) == 0 @@ -585,6 +600,7 @@ async def test_clear_clears_transcript(self): # AgentClient Select/Expect Tests # ============================================================================ + class TestAgentClientSelectExpect: """Tests for AgentClient select and expect methods.""" @@ -592,13 +608,13 @@ class TestAgentClientSelectExpect: async def test_select_returns_select_instance(self): """select() returns a Select instance.""" from microsoft_agents.testing.core import ActivitySelect - + sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) await client.send("Hello") - + result = client.select() assert isinstance(result, ActivitySelect) @@ -621,13 +637,13 @@ async def test_expect_returns_activity_expect_instance(self): async def test_ex_select_returns_select_with_exchanges(self): """ex_select() returns a Select instance with exchanges.""" from microsoft_agents.testing.core import ExchangeSelect - + sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + client = AgentClient(sender=sender) await client.send("Hello") - + result = client.ex_select() assert isinstance(result, ExchangeSelect) @@ -651,6 +667,7 @@ async def test_ex_expect_returns_exchange_expect_instance(self): # AgentClient Child Tests # ============================================================================ + class TestAgentClientChild: """Tests for AgentClient.child method.""" @@ -659,7 +676,7 @@ def test_child_shares_sender(self): sender = StubSender() parent = AgentClient(sender=sender) child = parent.child() - + assert child._sender is parent._sender def test_child_has_child_transcript(self): @@ -667,7 +684,7 @@ def test_child_has_child_transcript(self): sender = StubSender() parent = AgentClient(sender=sender) child = parent.child() - + # Child transcript should have parent as its parent assert child._transcript._parent is parent._transcript @@ -676,12 +693,12 @@ async def test_child_sends_propagate_to_parent(self): """Exchanges from child propagate to parent transcript.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + parent = AgentClient(sender=sender) child = parent.child() - + await child.send("From child") - + # Should be in both transcripts assert len(child.ex_history()) == 1 assert len(parent.ex_history()) == 1 @@ -692,10 +709,10 @@ async def test_parent_and_child_independent_sends(self): """Parent and child can send independently.""" sender = StubSender() sender.with_responses(Activity(type=ActivityTypes.message, text="Reply")) - + parent = AgentClient(sender=sender) child = parent.child() - + await parent.send("From parent") await child.send("From child") diff --git a/dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py b/dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py index dd31e0a1..49445069 100644 --- a/dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py +++ b/dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py @@ -14,11 +14,11 @@ from microsoft_agents.testing.core.transport import Transcript from microsoft_agents.testing.core.agent_client import AgentClient - # ============================================================================ # _AiohttpClientFactory Initialization Tests # ============================================================================ + class TestAiohttpClientFactoryInitialization: """Tests for _AiohttpClientFactory initialization.""" @@ -28,7 +28,7 @@ def test_initialization_stores_all_parameters(self): config = ClientConfig() transcript = Transcript() sdk_config = {"CONNECTIONS": {}} - + factory = _AiohttpClientFactory( agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", @@ -37,7 +37,7 @@ def test_initialization_stores_all_parameters(self): default_config=config, transcript=transcript, ) - + assert factory._agent_endpoint == "http://localhost:3978" assert factory._response_endpoint == "http://localhost:9378/api/callback" assert factory._sdk_config is sdk_config @@ -55,7 +55,7 @@ def test_initialization_creates_empty_sessions_list(self): default_config=ClientConfig(), transcript=Transcript(), ) - + assert factory._sessions == [] @@ -63,6 +63,7 @@ def test_initialization_creates_empty_sessions_list(self): # _AiohttpClientFactory Tests # ============================================================================ + class TestAiohttpClientFactoryCreateClient: """Tests for _AiohttpClientFactory method.""" @@ -82,7 +83,7 @@ def factory(self): async def test_create_client_returns_agent_client(self, factory): """create_client returns an AgentClient instance.""" client = await factory() - + try: assert isinstance(client, AgentClient) finally: @@ -92,9 +93,9 @@ async def test_create_client_returns_agent_client(self, factory): async def test_create_client_tracks_session(self, factory): """create_client adds created session to sessions list.""" assert len(factory._sessions) == 0 - + await factory() - + try: assert len(factory._sessions) == 1 assert isinstance(factory._sessions[0], ClientSession) @@ -107,7 +108,7 @@ async def test_create_client_tracks_multiple_sessions(self, factory): await factory() await factory() await factory() - + try: assert len(factory._sessions) == 3 finally: @@ -118,7 +119,7 @@ async def test_create_client_uses_default_config_when_none_provided(self, factor """create_client uses default config when no config is passed.""" # Just verify it doesn't raise and creates a client client = await factory() - + try: assert isinstance(client, AgentClient) finally: @@ -131,9 +132,9 @@ async def test_create_client_uses_provided_config(self, factory): headers={"X-Custom": "custom-value"}, auth_token="custom-token", ) - + client = await factory(config=custom_config) - + try: assert isinstance(client, AgentClient) # Verify session was created with custom headers @@ -147,7 +148,7 @@ async def test_create_client_uses_provided_config(self, factory): async def test_create_client_sets_content_type_header(self, factory): """create_client always sets Content-Type header.""" await factory() - + try: session = factory._sessions[0] assert "Content-Type" in session._default_headers @@ -159,23 +160,27 @@ async def test_create_client_sets_content_type_header(self, factory): async def test_create_client_with_auth_token_sets_authorization(self, factory): """create_client sets Authorization header when auth_token is provided.""" config = ClientConfig(auth_token="test-bearer-token") - + await factory(config=config) - + try: session = factory._sessions[0] assert "Authorization" in session._default_headers - assert session._default_headers["Authorization"] == "Bearer test-bearer-token" + assert ( + session._default_headers["Authorization"] == "Bearer test-bearer-token" + ) finally: await factory.cleanup() @pytest.mark.asyncio async def test_create_client_merges_custom_headers(self, factory): """create_client merges custom headers with defaults.""" - config = ClientConfig(headers={"X-Request-Id": "123", "Accept": "application/json"}) - + config = ClientConfig( + headers={"X-Request-Id": "123", "Accept": "application/json"} + ) + await factory(config=config) - + try: session = factory._sessions[0] assert session._default_headers["Content-Type"] == "application/json" @@ -189,9 +194,9 @@ async def test_create_client_uses_custom_activity_template(self, factory): """create_client uses custom activity_template from config.""" custom_template = ActivityTemplate(text="Custom message") config = ClientConfig(activity_template=custom_template) - + client = await factory(config=config) - + try: assert isinstance(client, AgentClient) # The client should use a template derived from the custom template @@ -203,6 +208,7 @@ async def test_create_client_uses_custom_activity_template(self, factory): # _AiohttpClientfactory Authorization Tests # ============================================================================ + class TestAiohttpClientFactoryAuthorization: """Tests for authorization handling in create_client.""" @@ -217,11 +223,11 @@ async def test_explicit_authorization_header_preserved(self): default_config=ClientConfig(), transcript=Transcript(), ) - + config = ClientConfig(headers={"Authorization": "Bearer explicit-token"}) - + await factory(config=config) - + try: session = factory._sessions[0] assert session._default_headers["Authorization"] == "Bearer explicit-token" @@ -239,14 +245,16 @@ async def test_auth_token_overrides_when_no_explicit_authorization(self): default_config=ClientConfig(), transcript=Transcript(), ) - + config = ClientConfig(auth_token="token-from-config") - + await factory(config=config) - + try: session = factory._sessions[0] - assert session._default_headers["Authorization"] == "Bearer token-from-config" + assert ( + session._default_headers["Authorization"] == "Bearer token-from-config" + ) finally: await factory.cleanup() @@ -261,9 +269,9 @@ async def test_no_auth_when_no_token_and_no_sdk_config(self): default_config=ClientConfig(), transcript=Transcript(), ) - + await factory() - + try: session = factory._sessions[0] # No Authorization header should be set @@ -276,7 +284,7 @@ async def test_sdk_config_token_generation_on_failure(self): """SDK config token generation failure is handled gracefully.""" # Provide invalid SDK config that will cause token generation to fail invalid_sdk_config = {"CONNECTIONS": {"SERVICE_CONNECTION": {"SETTINGS": {}}}} - + factory = _AiohttpClientFactory( agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", @@ -285,10 +293,10 @@ async def test_sdk_config_token_generation_on_failure(self): default_config=ClientConfig(), transcript=Transcript(), ) - + # Should not raise even though SDK config is invalid client = await factory() - + try: assert isinstance(client, AgentClient) finally: @@ -299,6 +307,7 @@ async def test_sdk_config_token_generation_on_failure(self): # _AiohttpClientFactory.cleanup Tests # ============================================================================ + class TestAiohttpClientFactoryCleanup: """Tests for _AiohttpClientFactory.cleanup method.""" @@ -313,16 +322,16 @@ async def test_cleanup_closes_all_sessions(self): default_config=ClientConfig(), transcript=Transcript(), ) - + # Create multiple clients await factory() await factory() - + sessions = list(factory._sessions) assert len(sessions) == 2 - + await factory.cleanup() - + # All sessions should be closed for session in sessions: assert session.closed @@ -338,14 +347,14 @@ async def test_cleanup_clears_sessions_list(self): default_config=ClientConfig(), transcript=Transcript(), ) - + await factory() await factory() - + assert len(factory._sessions) == 2 - + await factory.cleanup() - + assert factory._sessions == [] @pytest.mark.asyncio @@ -359,10 +368,10 @@ async def test_cleanup_on_empty_sessions_list(self): default_config=ClientConfig(), transcript=Transcript(), ) - + # Should not raise await factory.cleanup() - + assert factory._sessions == [] @pytest.mark.asyncio @@ -376,12 +385,12 @@ async def test_cleanup_can_be_called_multiple_times(self): default_config=ClientConfig(), transcript=Transcript(), ) - + await factory() - + await factory.cleanup() await factory.cleanup() # Second call should not raise - + assert factory._sessions == [] @@ -389,6 +398,7 @@ async def test_cleanup_can_be_called_multiple_times(self): # _AiohttpClientFactory Template Handling Tests # ============================================================================ + class TestAiohttpClientFactoryTemplateHandling: """Tests for template handling in _AiohttpClientFactory.""" @@ -396,7 +406,7 @@ class TestAiohttpClientFactoryTemplateHandling: async def test_default_template_used_when_config_has_none(self): """Default template is used when config has no activity_template.""" default_template = ActivityTemplate(type="message", text="Default") - + factory = _AiohttpClientFactory( agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", @@ -405,9 +415,9 @@ async def test_default_template_used_when_config_has_none(self): default_config=ClientConfig(), transcript=Transcript(), ) - + client = await factory() - + try: assert isinstance(client, AgentClient) finally: @@ -419,7 +429,7 @@ async def test_config_template_used_when_provided(self): default_template = ActivityTemplate(type="message", text="Default") custom_template = ActivityTemplate(type="event", text="Custom") config = ClientConfig(activity_template=custom_template) - + factory = _AiohttpClientFactory( agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", @@ -428,9 +438,9 @@ async def test_config_template_used_when_provided(self): default_config=ClientConfig(), transcript=Transcript(), ) - + client = await factory(config=config) - + try: assert isinstance(client, AgentClient) finally: @@ -441,6 +451,7 @@ async def test_config_template_used_when_provided(self): # Integration-style Tests # ============================================================================ + class TestAiohttpClientFactoryIntegration: """Integration-style tests for _AiohttpClientFactory.""" @@ -455,24 +466,20 @@ async def test_full_workflow_create_and_cleanup(self): default_config=ClientConfig(headers={"X-Default": "value"}), transcript=Transcript(), ) - + # Create clients with different configs client1 = await factory() - client2 = await factory( - config=ClientConfig(auth_token="token-1") - ) - client3 = await factory( - config=ClientConfig(headers={"X-Custom": "custom"}) - ) - + client2 = await factory(config=ClientConfig(auth_token="token-1")) + client3 = await factory(config=ClientConfig(headers={"X-Custom": "custom"})) + assert len(factory._sessions) == 3 assert isinstance(client1, AgentClient) assert isinstance(client2, AgentClient) assert isinstance(client3, AgentClient) - + # Cleanup all await factory.cleanup() - + assert len(factory._sessions) == 0 for session in [factory._sessions]: pass # All sessions should be closed and list cleared @@ -488,9 +495,9 @@ async def test_session_base_url_is_set_correctly(self): default_config=ClientConfig(), transcript=Transcript(), ) - + await factory() - + try: session = factory._sessions[0] assert session._base_url is None diff --git a/dev/microsoft-agents-testing/tests/core/test_config.py b/dev/microsoft-agents-testing/tests/core/test_config.py index 3e6a129c..5b90ec78 100644 --- a/dev/microsoft-agents-testing/tests/core/test_config.py +++ b/dev/microsoft-agents-testing/tests/core/test_config.py @@ -8,18 +8,18 @@ from microsoft_agents.testing.core.config import ClientConfig, ScenarioConfig from microsoft_agents.testing.core.fluent import ActivityTemplate - # ============================================================================ # ClientConfig Initialization Tests # ============================================================================ + class TestClientConfigInitialization: """Tests for ClientConfig initialization.""" def test_default_initialization(self): """ClientConfig initializes with default values.""" config = ClientConfig() - + assert config.headers == {} assert config.auth_token is None assert config.activity_template is None @@ -28,33 +28,33 @@ def test_initialization_with_headers(self): """ClientConfig initializes with custom headers.""" headers = {"X-Custom-Header": "value", "Accept": "application/json"} config = ClientConfig(headers=headers) - + assert config.headers == headers def test_initialization_with_auth_token(self): """ClientConfig initializes with auth token.""" config = ClientConfig(auth_token="my-token-123") - + assert config.auth_token == "my-token-123" def test_initialization_with_activity_template(self): """ClientConfig initializes with activity template.""" template = ActivityTemplate(text="Hello") config = ClientConfig(activity_template=template) - + assert config.activity_template is template def test_initialization_with_all_parameters(self): """ClientConfig initializes with all parameters.""" headers = {"X-Custom": "value"} template = ActivityTemplate(text="Test") - + config = ClientConfig( headers=headers, auth_token="token-abc", activity_template=template, ) - + assert config.headers == headers assert config.auth_token == "token-abc" assert config.activity_template is template @@ -64,18 +64,18 @@ def test_initialization_with_all_parameters(self): # ClientConfig with_headers Tests # ============================================================================ + class TestClientConfigWithHeaders: """Tests for ClientConfig.with_headers method.""" def test_with_headers_adds_new_headers(self): """with_headers adds new headers to an empty config.""" config = ClientConfig() - + new_config = config.with_headers( - Authorization="Bearer token", - ContentType="application/json" + Authorization="Bearer token", ContentType="application/json" ) - + assert new_config.headers == { "Authorization": "Bearer token", "ContentType": "application/json", @@ -84,43 +84,43 @@ def test_with_headers_adds_new_headers(self): def test_with_headers_merges_existing_headers(self): """with_headers merges with existing headers.""" config = ClientConfig(headers={"Existing": "header"}) - + new_config = config.with_headers(New="value") - + assert new_config.headers == {"Existing": "header", "New": "value"} def test_with_headers_overwrites_duplicate_keys(self): """with_headers overwrites duplicate header keys.""" config = ClientConfig(headers={"Key": "old-value"}) - + new_config = config.with_headers(Key="new-value") - + assert new_config.headers == {"Key": "new-value"} def test_with_headers_returns_new_instance(self): """with_headers returns a new ClientConfig instance.""" config = ClientConfig() - + new_config = config.with_headers(Header="value") - + assert new_config is not config assert config.headers == {} # Original unchanged def test_with_headers_preserves_auth_token(self): """with_headers preserves the auth_token.""" config = ClientConfig(auth_token="my-token") - + new_config = config.with_headers(Header="value") - + assert new_config.auth_token == "my-token" def test_with_headers_preserves_activity_template(self): """with_headers preserves the activity_template.""" template = ActivityTemplate(text="Test") config = ClientConfig(activity_template=template) - + new_config = config.with_headers(Header="value") - + assert new_config.activity_template is template @@ -128,49 +128,50 @@ def test_with_headers_preserves_activity_template(self): # ClientConfig with_auth_token Tests # ============================================================================ + class TestClientConfigWithAuthToken: """Tests for ClientConfig.with_auth_token method.""" def test_with_auth_token_sets_token(self): """with_auth_token sets the auth token.""" config = ClientConfig() - + new_config = config.with_auth_token("new-token") - + assert new_config.auth_token == "new-token" def test_with_auth_token_replaces_existing_token(self): """with_auth_token replaces existing token.""" config = ClientConfig(auth_token="old-token") - + new_config = config.with_auth_token("new-token") - + assert new_config.auth_token == "new-token" def test_with_auth_token_returns_new_instance(self): """with_auth_token returns a new ClientConfig instance.""" config = ClientConfig(auth_token="original") - + new_config = config.with_auth_token("changed") - + assert new_config is not config assert config.auth_token == "original" # Original unchanged def test_with_auth_token_preserves_headers(self): """with_auth_token preserves headers.""" config = ClientConfig(headers={"Key": "value"}) - + new_config = config.with_auth_token("token") - + assert new_config.headers == {"Key": "value"} def test_with_auth_token_preserves_activity_template(self): """with_auth_token preserves activity_template.""" template = ActivityTemplate(text="Test") config = ClientConfig(activity_template=template) - + new_config = config.with_auth_token("token") - + assert new_config.activity_template is template @@ -178,6 +179,7 @@ def test_with_auth_token_preserves_activity_template(self): # ClientConfig with_template Tests # ============================================================================ + class TestClientConfigWithTemplate: """Tests for ClientConfig.with_template method.""" @@ -185,9 +187,9 @@ def test_with_template_sets_template(self): """with_template sets the activity template.""" config = ClientConfig() template = ActivityTemplate(text="Hello") - + new_config = config.with_template(template) - + assert new_config.activity_template is template def test_with_template_replaces_existing_template(self): @@ -195,36 +197,36 @@ def test_with_template_replaces_existing_template(self): old_template = ActivityTemplate(text="Old") new_template = ActivityTemplate(text="New") config = ClientConfig(activity_template=old_template) - + new_config = config.with_template(new_template) - + assert new_config.activity_template is new_template def test_with_template_returns_new_instance(self): """with_template returns a new ClientConfig instance.""" config = ClientConfig() template = ActivityTemplate(text="Test") - + new_config = config.with_template(template) - + assert new_config is not config def test_with_template_preserves_headers(self): """with_template preserves headers.""" config = ClientConfig(headers={"Key": "value"}) template = ActivityTemplate(text="Test") - + new_config = config.with_template(template) - + assert new_config.headers == {"Key": "value"} def test_with_template_preserves_auth_token(self): """with_template preserves auth_token.""" config = ClientConfig(auth_token="my-token") template = ActivityTemplate(text="Test") - + new_config = config.with_template(template) - + assert new_config.auth_token == "my-token" @@ -232,13 +234,14 @@ def test_with_template_preserves_auth_token(self): # ClientConfig Method Chaining Tests # ============================================================================ + class TestClientConfigChaining: """Tests for chaining ClientConfig methods.""" def test_chaining_multiple_methods(self): """Multiple with_* methods can be chained.""" template = ActivityTemplate(text="Test") - + config = ( ClientConfig() .with_headers(Header1="value1") @@ -246,7 +249,7 @@ def test_chaining_multiple_methods(self): .with_template(template) .with_headers(Header2="value2") ) - + assert config.headers == {"Header1": "value1", "Header2": "value2"} assert config.auth_token == "my-token" assert config.activity_template is template @@ -256,13 +259,14 @@ def test_chaining_multiple_methods(self): # ScenarioConfig Initialization Tests # ============================================================================ + class TestScenarioConfigInitialization: """Tests for ScenarioConfig initialization.""" def test_default_initialization(self): """ScenarioConfig initializes with default values.""" config = ScenarioConfig() - + assert config.env_file_path is None assert config.callback_server_port == 9378 assert isinstance(config.client_config, ClientConfig) @@ -270,33 +274,33 @@ def test_default_initialization(self): def test_initialization_with_env_file_path(self): """ScenarioConfig initializes with env_file_path.""" config = ScenarioConfig(env_file_path="/path/to/.env") - + assert config.env_file_path == "/path/to/.env" def test_initialization_with_custom_port(self): """ScenarioConfig initializes with custom callback_server_port.""" config = ScenarioConfig(callback_server_port=8080) - + assert config.callback_server_port == 8080 def test_initialization_with_client_config(self): """ScenarioConfig initializes with custom client_config.""" client_config = ClientConfig(auth_token="test-token") config = ScenarioConfig(client_config=client_config) - + assert config.client_config is client_config assert config.client_config.auth_token == "test-token" def test_initialization_with_all_parameters(self): """ScenarioConfig initializes with all parameters.""" client_config = ClientConfig(headers={"Key": "value"}) - + config = ScenarioConfig( env_file_path="./config.env", callback_server_port=3000, client_config=client_config, ) - + assert config.env_file_path == "./config.env" assert config.callback_server_port == 3000 assert config.client_config is client_config @@ -306,13 +310,14 @@ def test_initialization_with_all_parameters(self): # ScenarioConfig Default ClientConfig Tests # ============================================================================ + class TestScenarioConfigDefaultClientConfig: """Tests for ScenarioConfig's default ClientConfig behavior.""" def test_default_client_config_is_empty(self): """Default client_config has default values.""" config = ScenarioConfig() - + assert config.client_config.headers == {} assert config.client_config.auth_token is None assert config.client_config.activity_template is None @@ -321,7 +326,7 @@ def test_multiple_scenario_configs_have_independent_client_configs(self): """Each ScenarioConfig instance has its own ClientConfig.""" config1 = ScenarioConfig() config2 = ScenarioConfig() - + # Modify one doesn't affect the other (default_factory creates new instances) assert config1.client_config is not config2.client_config @@ -330,6 +335,7 @@ def test_multiple_scenario_configs_have_independent_client_configs(self): # ClientConfig Dataclass Features Tests # ============================================================================ + class TestClientConfigDataclassFeatures: """Tests for ClientConfig dataclass behavior.""" @@ -337,21 +343,21 @@ def test_equality_same_values(self): """ClientConfig instances with same values are equal.""" config1 = ClientConfig(headers={"Key": "value"}, auth_token="token") config2 = ClientConfig(headers={"Key": "value"}, auth_token="token") - + assert config1 == config2 def test_equality_different_headers(self): """ClientConfig instances with different headers are not equal.""" config1 = ClientConfig(headers={"Key": "value1"}) config2 = ClientConfig(headers={"Key": "value2"}) - + assert config1 != config2 def test_equality_different_auth_token(self): """ClientConfig instances with different auth_token are not equal.""" config1 = ClientConfig(auth_token="token1") config2 = ClientConfig(auth_token="token2") - + assert config1 != config2 @@ -359,6 +365,7 @@ def test_equality_different_auth_token(self): # ScenarioConfig Dataclass Features Tests # ============================================================================ + class TestScenarioConfigDataclassFeatures: """Tests for ScenarioConfig dataclass behavior.""" @@ -375,12 +382,12 @@ def test_equality_same_values(self): callback_server_port=8080, client_config=client_config, ) - + assert config1 == config2 def test_equality_different_port(self): """ScenarioConfig instances with different ports are not equal.""" config1 = ScenarioConfig(callback_server_port=8080) config2 = ScenarioConfig(callback_server_port=9090) - + assert config1 != config2 diff --git a/dev/microsoft-agents-testing/tests/core/test_external_scenario.py b/dev/microsoft-agents-testing/tests/core/test_external_scenario.py index 4f167ecf..0a0da510 100644 --- a/dev/microsoft-agents-testing/tests/core/test_external_scenario.py +++ b/dev/microsoft-agents-testing/tests/core/test_external_scenario.py @@ -11,18 +11,18 @@ from microsoft_agents.testing.core.config import ClientConfig from microsoft_agents.testing.core._aiohttp_client_factory import _AiohttpClientFactory - # ============================================================================ # ExternalScenario Initialization Tests # ============================================================================ + class TestExternalScenarioInitialization: """Tests for ExternalScenario initialization.""" def test_initialization_with_endpoint(self): """ExternalScenario initializes with endpoint.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") - + assert scenario._endpoint == "http://localhost:3978/api/messages" def test_initialization_with_endpoint_and_config(self): @@ -32,7 +32,7 @@ def test_initialization_with_endpoint_and_config(self): endpoint="http://localhost:3978/api/messages", config=config, ) - + assert scenario._endpoint == "http://localhost:3978/api/messages" assert scenario._config is config assert scenario._config.callback_server_port == 9000 @@ -40,7 +40,7 @@ def test_initialization_with_endpoint_and_config(self): def test_initialization_with_default_config(self): """ExternalScenario uses default config when none provided.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") - + assert isinstance(scenario._config, ScenarioConfig) assert scenario._config.callback_server_port == 9378 # Default port @@ -57,7 +57,7 @@ def test_initialization_raises_on_none_endpoint(self): def test_inherits_from_scenario(self): """ExternalScenario inherits from Scenario.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") - + assert isinstance(scenario, Scenario) @@ -65,6 +65,7 @@ def test_inherits_from_scenario(self): # ExternalScenario Configuration Tests # ============================================================================ + class TestExternalScenarioConfiguration: """Tests for ExternalScenario configuration handling.""" @@ -75,7 +76,7 @@ def test_config_with_env_file_path(self): endpoint="http://localhost:3978/api/messages", config=config, ) - + assert scenario._config.env_file_path == "/path/to/.env" def test_config_with_client_config(self): @@ -89,7 +90,7 @@ def test_config_with_client_config(self): endpoint="http://localhost:3978/api/messages", config=config, ) - + assert scenario._config.client_config.auth_token == "test-token" assert scenario._config.client_config.headers == {"X-Custom": "value"} @@ -100,7 +101,7 @@ def test_config_with_custom_port(self): endpoint="http://localhost:3978/api/messages", config=config, ) - + assert scenario._config.callback_server_port == 8080 @@ -108,6 +109,7 @@ def test_config_with_custom_port(self): # ExternalScenario.run Tests # ============================================================================ + class TestExternalScenarioRun: """Tests for ExternalScenario.run method.""" @@ -115,33 +117,38 @@ class TestExternalScenarioRun: async def test_run_yields_factory(self): """run() yields a client factory.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class, \ - patch("microsoft_agents.testing.core.external_scenario._AiohttpClientFactory") as mock_factory_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class, patch( + "microsoft_agents.testing.core.external_scenario._AiohttpClientFactory" + ) as mock_factory_class: + mock_dotenv.return_value = {} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + # Create async context manager mock mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + # Setup mock factory mock_factory = MagicMock() mock_factory.cleanup = AsyncMock() mock_factory_class.return_value = mock_factory - + async with scenario.run() as factory: assert factory is mock_factory @@ -153,32 +160,37 @@ async def test_run_loads_env_from_config_path(self): endpoint="http://localhost:3978/api/messages", config=config, ) - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class, \ - patch("microsoft_agents.testing.core.external_scenario._AiohttpClientFactory") as mock_factory_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class, patch( + "microsoft_agents.testing.core.external_scenario._AiohttpClientFactory" + ) as mock_factory_class: + mock_dotenv.return_value = {"KEY": "value"} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + # Setup mock factory mock_factory = MagicMock() mock_factory.cleanup = AsyncMock() mock_factory_class.return_value = mock_factory - + async with scenario.run() as factory: mock_dotenv.assert_called_once_with("/path/to/.env") @@ -190,26 +202,30 @@ async def test_run_creates_callback_server_with_config_port(self): endpoint="http://localhost:3978/api/messages", config=config, ) - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class: + mock_dotenv.return_value = {} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:8080/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + async with scenario.run() as factory: mock_server_class.assert_called_once_with(8080) @@ -217,26 +233,30 @@ async def test_run_creates_callback_server_with_config_port(self): async def test_run_passes_endpoint_to_factory(self): """run() passes endpoint to the client factory.""" scenario = ExternalScenario(endpoint="http://my-agent:3978/api/messages") - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class: + mock_dotenv.return_value = {} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + async with scenario.run() as factory: assert factory._agent_endpoint == "http://my-agent:3978/api/messages" @@ -244,54 +264,67 @@ async def test_run_passes_endpoint_to_factory(self): async def test_run_passes_service_endpoint_to_factory(self): """run() passes callback server's service_endpoint to factory.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class: + mock_dotenv.return_value = {} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + async with scenario.run() as factory: - assert factory._response_endpoint == "http://localhost:9378/v3/conversations/" + assert ( + factory._response_endpoint + == "http://localhost:9378/v3/conversations/" + ) @pytest.mark.asyncio async def test_run_passes_sdk_config_to_factory(self): """run() passes loaded sdk_config to factory.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class: - - expected_sdk_config = {"CONNECTIONS": {"SERVICE_CONNECTION": {"SETTINGS": {}}}} + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class: + + expected_sdk_config = { + "CONNECTIONS": {"SERVICE_CONNECTION": {"SETTINGS": {}}} + } mock_dotenv.return_value = {} mock_load_config.return_value = expected_sdk_config - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + async with scenario.run() as factory: assert factory._sdk_config is expected_sdk_config @@ -304,26 +337,30 @@ async def test_run_passes_client_config_to_factory(self): endpoint="http://localhost:3978/api/messages", config=config, ) - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class: + mock_dotenv.return_value = {} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + async with scenario.run() as factory: assert factory._default_config is client_config @@ -332,6 +369,7 @@ async def test_run_passes_client_config_to_factory(self): # ExternalScenario.run Cleanup Tests # ============================================================================ + class TestExternalScenarioRunCleanup: """Tests for ExternalScenario.run cleanup behavior.""" @@ -339,71 +377,81 @@ class TestExternalScenarioRunCleanup: async def test_run_cleans_up_factory_on_exit(self): """run() calls factory.cleanup() on context exit.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class, \ - patch("microsoft_agents.testing.core.external_scenario._AiohttpClientFactory") as mock_factory_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class, patch( + "microsoft_agents.testing.core.external_scenario._AiohttpClientFactory" + ) as mock_factory_class: + mock_dotenv.return_value = {} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + # Setup mock factory mock_factory = MagicMock() mock_factory.cleanup = AsyncMock() mock_factory_class.return_value = mock_factory - + async with scenario.run() as factory: pass # Just enter and exit - + mock_factory.cleanup.assert_awaited_once() @pytest.mark.asyncio async def test_run_cleans_up_factory_on_exception(self): """run() calls factory.cleanup() even when exception occurs.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class, \ - patch("microsoft_agents.testing.core.external_scenario._AiohttpClientFactory") as mock_factory_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class, patch( + "microsoft_agents.testing.core.external_scenario._AiohttpClientFactory" + ) as mock_factory_class: + mock_dotenv.return_value = {} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + # Setup mock factory mock_factory = MagicMock() mock_factory.cleanup = AsyncMock() mock_factory_class.return_value = mock_factory - + with pytest.raises(RuntimeError): async with scenario.run() as factory: raise RuntimeError("Test exception") - + mock_factory.cleanup.assert_awaited_once() @@ -411,6 +459,7 @@ async def test_run_cleans_up_factory_on_exception(self): # ExternalScenario.client Convenience Method Tests # ============================================================================ + class TestExternalScenarioClient: """Tests for ExternalScenario.client convenience method (inherited from Scenario).""" @@ -418,33 +467,38 @@ class TestExternalScenarioClient: async def test_client_yields_agent_client(self): """client() convenience method yields an AgentClient.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class, \ - patch("microsoft_agents.testing.core.external_scenario._AiohttpClientFactory") as mock_factory_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class, patch( + "microsoft_agents.testing.core.external_scenario._AiohttpClientFactory" + ) as mock_factory_class: + mock_dotenv.return_value = {} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + # Setup mock factory mock_client = MagicMock() mock_factory = AsyncMock(return_value=mock_client) mock_factory.cleanup = AsyncMock() mock_factory_class.return_value = mock_factory - + async with scenario.client() as client: assert client is mock_client mock_factory.assert_awaited_once_with(None) @@ -454,33 +508,38 @@ async def test_client_passes_config_to_factory(self): """client() passes config to factory.__call__.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") custom_config = ClientConfig(auth_token="custom-token") - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class, \ - patch("microsoft_agents.testing.core.external_scenario._AiohttpClientFactory") as mock_factory_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class, patch( + "microsoft_agents.testing.core.external_scenario._AiohttpClientFactory" + ) as mock_factory_class: + mock_dotenv.return_value = {} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + # Setup mock factory mock_client = MagicMock() mock_factory = AsyncMock(return_value=mock_client) mock_factory.cleanup = AsyncMock() mock_factory_class.return_value = mock_factory - + async with scenario.client(config=custom_config) as client: mock_factory.assert_awaited_once_with(custom_config) @@ -489,25 +548,28 @@ async def test_client_passes_config_to_factory(self): # ExternalScenario Edge Cases Tests # ============================================================================ + class TestExternalScenarioEdgeCases: """Tests for ExternalScenario edge cases.""" def test_endpoint_with_trailing_slash(self): """ExternalScenario accepts endpoint with trailing slash.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages/") - + assert scenario._endpoint == "http://localhost:3978/api/messages" def test_endpoint_with_https(self): """ExternalScenario accepts https endpoint.""" - scenario = ExternalScenario(endpoint="https://my-agent.azurewebsites.net/api/messages") - + scenario = ExternalScenario( + endpoint="https://my-agent.azurewebsites.net/api/messages" + ) + assert scenario._endpoint == "https://my-agent.azurewebsites.net/api/messages" def test_endpoint_with_port(self): """ExternalScenario accepts endpoint with explicit port.""" scenario = ExternalScenario(endpoint="http://localhost:8080/api/messages") - + assert scenario._endpoint == "http://localhost:8080/api/messages" @pytest.mark.asyncio @@ -518,41 +580,46 @@ async def test_run_with_none_env_file_path(self): endpoint="http://localhost:3978/api/messages", config=config, ) - - with patch("microsoft_agents.testing.core.external_scenario.dotenv_values") as mock_dotenv, \ - patch("microsoft_agents.testing.core.external_scenario.load_configuration_from_env") as mock_load_config, \ - patch("microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer") as mock_server_class: - + + with patch( + "microsoft_agents.testing.core.external_scenario.dotenv_values" + ) as mock_dotenv, patch( + "microsoft_agents.testing.core.external_scenario.load_configuration_from_env" + ) as mock_load_config, patch( + "microsoft_agents.testing.core.external_scenario.AiohttpCallbackServer" + ) as mock_server_class: + mock_dotenv.return_value = {} mock_load_config.return_value = {} - + # Setup mock callback server mock_server = MagicMock() mock_server.service_endpoint = "http://localhost:9378/v3/conversations/" mock_transcript = MagicMock() - + mock_listen_cm = AsyncMock() mock_listen_cm.__aenter__.return_value = mock_transcript mock_listen_cm.__aexit__.return_value = None mock_server.listen.return_value = mock_listen_cm - + mock_server_class.return_value = mock_server - + async with scenario.run() as factory: mock_dotenv.assert_called_once_with(".env") # ============================================================================ -# ExternalScenario Dataclass/Attribute Tests +# ExternalScenario Dataclass/Attribute Tests # ============================================================================ + class TestExternalScenarioAttributes: """Tests for ExternalScenario attributes and properties.""" def test_endpoint_stored_as_private_attribute(self): """Endpoint is stored as _endpoint.""" scenario = ExternalScenario(endpoint="http://localhost:3978/api/messages") - + assert hasattr(scenario, "_endpoint") assert scenario._endpoint == "http://localhost:3978/api/messages" @@ -563,6 +630,6 @@ def test_config_stored_as_private_attribute(self): endpoint="http://localhost:3978/api/messages", config=config, ) - + assert hasattr(scenario, "_config") assert scenario._config is config diff --git a/dev/microsoft-agents-testing/tests/core/test_integration.py b/dev/microsoft-agents-testing/tests/core/test_integration.py index f8133628..7efd29bf 100644 --- a/dev/microsoft-agents-testing/tests/core/test_integration.py +++ b/dev/microsoft-agents-testing/tests/core/test_integration.py @@ -49,18 +49,18 @@ AiohttpCallbackServer, ) - # ============================================================================ # Mock Agent Server - Simulates a real agent endpoint # ============================================================================ + class MockAgentServer: """A mock agent server for testing HTTP-based agent communication. - + This creates a real HTTP server that responds to agent protocol requests, allowing full end-to-end testing without external dependencies. """ - + def __init__(self, port: int = 9999): self._port = port self._responses: dict[str, list[dict]] = {} @@ -69,45 +69,45 @@ def __init__(self, port: int = 9999): self._received_activities: list[Activity] = [] self._app: Application = Application() self._app.router.add_post("/api/messages", self._handle_messages) - + def on_text(self, text: str, *responses: Activity) -> "MockAgentServer": """Configure responses for specific text.""" self._responses[text.lower()] = [ - r.model_dump(by_alias=True, exclude_none=True, mode="json") + r.model_dump(by_alias=True, exclude_none=True, mode="json") for r in responses ] return self - + def on_invoke(self, name: str, status: int, body: dict) -> "MockAgentServer": """Configure invoke response for specific action.""" self._invoke_responses[name] = {"status": status, "body": body} return self - + def default_response(self, *responses: Activity) -> "MockAgentServer": """Set default response for unmatched messages.""" self._default_response = [ - r.model_dump(by_alias=True, exclude_none=True, mode="json") + r.model_dump(by_alias=True, exclude_none=True, mode="json") for r in responses ] return self - + @property def received_activities(self) -> list[Activity]: """Get all activities received by the server.""" return self._received_activities - + @property def endpoint(self) -> str: """Get the server endpoint URL.""" return f"http://localhost:{self._port}" - + async def _handle_messages(self, request: Request) -> Response: """Handle incoming agent messages.""" try: data = await request.json() activity = Activity.model_validate(data) self._received_activities.append(activity) - + # Handle invoke activities if activity.type == ActivityTypes.invoke: if activity.name in self._invoke_responses: @@ -115,33 +115,33 @@ async def _handle_messages(self, request: Request) -> Response: return Response( status=resp["status"], content_type="application/json", - text=json.dumps(resp["body"]) + text=json.dumps(resp["body"]), ) return Response( status=200, content_type="application/json", - text=json.dumps({"status": "ok"}) + text=json.dumps({"status": "ok"}), ) - + # Handle expect_replies if activity.delivery_mode == DeliveryModes.expect_replies: responses = self._get_responses(activity) return Response( status=200, content_type="application/json", - text=json.dumps({"activities": responses}) + text=json.dumps({"activities": responses}), ) - + # Normal message - just acknowledge return Response( status=200, content_type="application/json", - text=json.dumps({"id": "msg-1"}) + text=json.dumps({"id": "msg-1"}), ) - + except Exception as e: return Response(status=500, text=str(e)) - + def _get_responses(self, activity: Activity) -> list[dict]: """Get configured responses for an activity.""" if activity.text: @@ -149,7 +149,7 @@ def _get_responses(self, activity: Activity) -> list[dict]: if text_lower in self._responses: return self._responses[text_lower] return self._default_response - + @asynccontextmanager async def run(self) -> AsyncIterator["MockAgentServer"]: """Start the mock server and yield self.""" @@ -161,6 +161,7 @@ async def run(self) -> AsyncIterator["MockAgentServer"]: # AiohttpSender Integration Tests # ============================================================================ + class TestAiohttpSenderIntegration: """Integration tests for AiohttpSender with real HTTP.""" @@ -171,14 +172,14 @@ async def test_sender_posts_to_real_server(self): mock_server.default_response(Activity(type=ActivityTypes.message, text="Reply")) agent_endpoint = f"{mock_server.endpoint}/api/messages" - + async with mock_server.run(): async with ClientSession() as session: sender = AiohttpSender(agent_endpoint, session) activity = Activity(type=ActivityTypes.message, text="Hello") - + exchange = await sender.send(activity) - + assert exchange.status_code == 200 assert len(mock_server.received_activities) == 1 assert mock_server.received_activities[0].text == "Hello" @@ -189,21 +190,21 @@ async def test_sender_with_expect_replies(self): mock_server = MockAgentServer(port=9902) mock_server.default_response( Activity(type=ActivityTypes.message, text="Reply 1"), - Activity(type=ActivityTypes.message, text="Reply 2") + Activity(type=ActivityTypes.message, text="Reply 2"), ) agent_endpoint = f"{mock_server.endpoint}/api/messages" - + async with mock_server.run(): async with ClientSession() as session: sender = AiohttpSender(agent_endpoint, session) activity = Activity( type=ActivityTypes.message, text="Hello", - delivery_mode=DeliveryModes.expect_replies + delivery_mode=DeliveryModes.expect_replies, ) - + exchange = await sender.send(activity) - + assert len(exchange.responses) == 2 assert exchange.responses[0].text == "Reply 1" assert exchange.responses[1].text == "Reply 2" @@ -214,14 +215,14 @@ async def test_sender_with_invoke(self): mock_server = MockAgentServer(port=9903) mock_server.on_invoke("action/test", 200, {"result": "success"}) agent_endpoint = f"{mock_server.endpoint}/api/messages" - + async with mock_server.run(): async with ClientSession() as session: sender = AiohttpSender(agent_endpoint, session) activity = Activity(type=ActivityTypes.invoke, name="action/test") - + exchange = await sender.send(activity) - + assert exchange.invoke_response is not None assert exchange.invoke_response.status == 200 assert exchange.invoke_response.body == {"result": "success"} @@ -231,18 +232,18 @@ async def test_sender_records_to_transcript(self): """AiohttpSender records exchanges to transcript.""" mock_server = MockAgentServer(port=9904) agent_endpoint = f"{mock_server.endpoint}/api/messages" - + async with mock_server.run(): async with ClientSession() as session: sender = AiohttpSender(agent_endpoint, session) transcript = Transcript() - + activity1 = Activity(type=ActivityTypes.message, text="First") activity2 = Activity(type=ActivityTypes.message, text="Second") - + await sender.send(activity1, transcript=transcript) await sender.send(activity2, transcript=transcript) - + assert len(transcript.history()) == 2 assert transcript.history()[0].request.text == "First" assert transcript.history()[1].request.text == "Second" @@ -252,6 +253,7 @@ async def test_sender_records_to_transcript(self): # AgentClient with AiohttpSender Integration Tests # ============================================================================ + class TestAgentClientWithAiohttpSender: """Integration tests for AgentClient using AiohttpSender.""" @@ -261,21 +263,21 @@ async def test_client_sends_via_http(self): mock_server = MockAgentServer(port=9905) mock_server.default_response(Activity(type=ActivityTypes.message, text="OK")) agent_endpoint = f"{mock_server.endpoint}/api/messages" - + async with mock_server.run(): async with ClientSession() as session: sender = AiohttpSender(agent_endpoint, session) template = ActivityTemplate( channel_id="test", - **{"conversation.id": "conv-1", "from.id": "user-1"} + **{"conversation.id": "conv-1", "from.id": "user-1"}, ) client = AgentClient(sender=sender, template=template) - + responses = await client.send_expect_replies("Hello") - + assert len(responses) == 1 assert responses[0].text == "OK" - + # Verify server received properly formatted activity received = mock_server.received_activities[0] assert received.channel_id == "test" @@ -286,28 +288,34 @@ async def test_client_sends_via_http(self): async def test_client_full_conversation_flow(self): """AgentClient handles full conversation with multiple exchanges.""" mock_server = MockAgentServer(port=9906) - mock_server.on_text("hello", Activity(type=ActivityTypes.message, text="Hi there!")) - mock_server.on_text("bye", Activity(type=ActivityTypes.message, text="Goodbye!")) - mock_server.default_response(Activity(type=ActivityTypes.message, text="I don't understand")) - + mock_server.on_text( + "hello", Activity(type=ActivityTypes.message, text="Hi there!") + ) + mock_server.on_text( + "bye", Activity(type=ActivityTypes.message, text="Goodbye!") + ) + mock_server.default_response( + Activity(type=ActivityTypes.message, text="I don't understand") + ) + async with mock_server.run(): async with ClientSession() as session: agent_endpoint = f"{mock_server.endpoint}/api/messages" sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) - + # Greeting response1 = await client.send_expect_replies("Hello") assert response1[0].text == "Hi there!" - + # Unknown response2 = await client.send_expect_replies("Random stuff") assert response2[0].text == "I don't understand" - + # Goodbye response3 = await client.send_expect_replies("Bye") assert response3[0].text == "Goodbye!" - + # Verify transcript assert len(client.ex_history()) == 3 @@ -316,17 +324,21 @@ async def test_client_invoke_via_http(self): """AgentClient handles invoke activities via HTTP.""" mock_server = MockAgentServer(port=9907) mock_server.on_invoke("submit/form", 200, {"submitted": True, "id": "form-123"}) - + async with mock_server.run(): async with ClientSession() as session: agent_endpoint = f"{mock_server.endpoint}/api/messages" sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) - + invoke_response = await client.invoke( - Activity(type=ActivityTypes.invoke, name="submit/form", value={"data": "test"}) + Activity( + type=ActivityTypes.invoke, + name="submit/form", + value={"data": "test"}, + ) ) - + assert invoke_response.status == 200 assert invoke_response.body["submitted"] is True assert invoke_response.body["id"] == "form-123" @@ -336,6 +348,7 @@ async def test_client_invoke_via_http(self): # AiohttpCallbackServer Integration Tests # ============================================================================ + class TestAiohttpCallbackServerIntegration: """Integration tests for AiohttpCallbackServer.""" @@ -343,17 +356,19 @@ class TestAiohttpCallbackServerIntegration: async def test_callback_server_receives_activities(self): """Callback server receives and records activities.""" callback_server = AiohttpCallbackServer(port=9908) - + async with callback_server.listen() as transcript: # Post activity to callback server async with ClientSession() as session: activity = Activity(type=ActivityTypes.message, text="Callback message") async with session.post( f"{callback_server.service_endpoint}test-conversation/activities", - json=activity.model_dump(by_alias=True, exclude_none=True, mode="json") + json=activity.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), ) as response: assert response.status == 200 - + # Verify transcript recorded the activity history = transcript.history() assert len(history) == 1 @@ -363,16 +378,20 @@ async def test_callback_server_receives_activities(self): async def test_callback_server_multiple_activities(self): """Callback server handles multiple incoming activities.""" callback_server = AiohttpCallbackServer(port=9909) - + async with callback_server.listen() as transcript: async with ClientSession() as session: for i in range(3): - activity = Activity(type=ActivityTypes.message, text=f"Message {i+1}") + activity = Activity( + type=ActivityTypes.message, text=f"Message {i+1}" + ) await session.post( f"{callback_server.service_endpoint}conv/activities", - json=activity.model_dump(by_alias=True, exclude_none=True, mode="json") + json=activity.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), ) - + history = transcript.history() assert len(history) == 3 assert history[0].responses[0].text == "Message 1" @@ -384,20 +403,22 @@ async def test_callback_server_shares_transcript(self): """Callback server can use provided transcript.""" callback_server = AiohttpCallbackServer(port=9910) parent_transcript = Transcript() - + # Record something before callback server - parent_transcript.record(Exchange( - request=Activity(type=ActivityTypes.message, text="Initial") - )) - + parent_transcript.record( + Exchange(request=Activity(type=ActivityTypes.message, text="Initial")) + ) + async with callback_server.listen(transcript=parent_transcript) as transcript: async with ClientSession() as session: activity = Activity(type=ActivityTypes.message, text="Callback") await session.post( f"{callback_server.service_endpoint}conv/activities", - json=activity.model_dump(by_alias=True, exclude_none=True, mode="json") + json=activity.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), ) - + # Should have initial + callback assert len(parent_transcript.history()) == 2 @@ -406,6 +427,7 @@ async def test_callback_server_shares_transcript(self): # _AiohttpClientFactory Integration Tests # ============================================================================ + class Test_AiohttpClientFactoryIntegration: """Integration tests for _AiohttpClientFactory.""" @@ -413,7 +435,9 @@ class Test_AiohttpClientFactoryIntegration: async def test_factory_creates_working_client(self): """Factory creates clients that can communicate with agent.""" mock_server = MockAgentServer(port=9911) - mock_server.default_response(Activity(type=ActivityTypes.message, text="Factory test OK")) + mock_server.default_response( + Activity(type=ActivityTypes.message, text="Factory test OK") + ) agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): @@ -426,11 +450,11 @@ async def test_factory_creates_working_client(self): default_config=ClientConfig(), transcript=transcript, ) - + try: client = await factory() responses = await client.send_expect_replies("Test message") - + assert len(responses) == 1 assert responses[0].text == "Factory test OK" finally: @@ -441,13 +465,13 @@ async def test_factory_applies_default_template(self): """Factory applies default template to created clients.""" mock_server = MockAgentServer(port=9912) mock_server.default_response(Activity(type=ActivityTypes.message, text="OK")) - + default_template = ActivityTemplate( channel_id="factory-channel", locale="en-US", - **{"recipient.id": "agent-123"} + **{"recipient.id": "agent-123"}, ) - + async with mock_server.run(): agent_endpoint = f"{mock_server.endpoint}/api/messages" transcript = Transcript() @@ -459,11 +483,11 @@ async def test_factory_applies_default_template(self): default_config=ClientConfig(), transcript=transcript, ) - + try: client = await factory() await client.send_expect_replies("Test") - + received = mock_server.received_activities[0] assert received.channel_id == "factory-channel" assert received.locale == "en-US" @@ -475,7 +499,7 @@ async def test_factory_creates_multiple_clients(self): """Factory can create multiple independent clients.""" mock_server = MockAgentServer(port=9913) mock_server.default_response(Activity(type=ActivityTypes.message, text="OK")) - + async with mock_server.run(): agent_endpoint = f"{mock_server.endpoint}/api/messages" transcript = Transcript() @@ -487,18 +511,14 @@ async def test_factory_creates_multiple_clients(self): default_config=ClientConfig(), transcript=transcript, ) - + try: - client1 = await factory( - ClientConfig() - ) - client2 = await factory( - ClientConfig() - ) - + client1 = await factory(ClientConfig()) + client2 = await factory(ClientConfig()) + await client1.send_expect_replies("From Alice") await client2.send_expect_replies("From Bob") - + assert len(mock_server.received_activities) == 2 # Both share the same transcript assert len(transcript.history()) == 2 @@ -509,7 +529,7 @@ async def test_factory_creates_multiple_clients(self): async def test_factory_cleanup_closes_sessions(self): """Factory cleanup closes all created sessions.""" mock_server = MockAgentServer(port=9914) - + async with mock_server.run(): agent_endpoint = f"{mock_server.endpoint}/api/messages" factory = _AiohttpClientFactory( @@ -520,14 +540,14 @@ async def test_factory_cleanup_closes_sessions(self): default_config=ClientConfig(), transcript=Transcript(), ) - + await factory() await factory() - + assert len(factory._sessions) == 2 - + await factory.cleanup() - + assert len(factory._sessions) == 0 @@ -535,6 +555,7 @@ async def test_factory_cleanup_closes_sessions(self): # ExternalScenario Integration Tests # ============================================================================ + class TestExternalScenarioIntegration: """Integration tests for ExternalScenario.""" @@ -560,8 +581,7 @@ def test_external_scenario_accepts_custom_config(self): callback_server_port=8080, ) scenario = ExternalScenario( - endpoint="http://localhost:3978", - config=custom_config + endpoint="http://localhost:3978", config=custom_config ) assert scenario._config.env_file_path == ".env.test" assert scenario._config.callback_server_port == 8080 @@ -571,6 +591,7 @@ def test_external_scenario_accepts_custom_config(self): # Full End-to-End Integration Tests # ============================================================================ + class TestEndToEndIntegration: """Full end-to-end integration tests demonstrating complete workflows.""" @@ -578,10 +599,12 @@ class TestEndToEndIntegration: async def test_complete_http_conversation_flow(self): """Complete conversation flow using real HTTP infrastructure.""" mock_server = MockAgentServer(port=9920) - mock_server.on_text("start", - Activity(type=ActivityTypes.message, text="Welcome! I'm a test agent.") + mock_server.on_text( + "start", + Activity(type=ActivityTypes.message, text="Welcome! I'm a test agent."), ) - mock_server.on_text("help", + mock_server.on_text( + "help", Activity(type=ActivityTypes.message, text="I can help with:"), Activity(type=ActivityTypes.message, text="- Questions"), Activity(type=ActivityTypes.message, text="- Tasks"), @@ -589,7 +612,7 @@ async def test_complete_http_conversation_flow(self): mock_server.default_response( Activity(type=ActivityTypes.message, text="I didn't understand that.") ) - + async with mock_server.run(): # Setup infrastructure transcript = Transcript() @@ -605,39 +628,39 @@ async def test_complete_http_conversation_flow(self): "conversation.id": "e2e-conv", "from.id": "e2e-user", "from.name": "E2E Test User", - } + }, ), default_config=ClientConfig(), transcript=transcript, ) - + try: client = await factory() - + # Start conversation responses = await client.send_expect_replies("start") assert len(responses) == 1 assert "Welcome" in responses[0].text - + # Ask for help responses = await client.send_expect_replies("help") assert len(responses) == 3 - + # Verify using Select help_messages = Select(responses).get() assert len(help_messages) == 3 - + # Verify using Expect Expect(responses).that(type=ActivityTypes.message) - + # Unknown input responses = await client.send_expect_replies("asdfasdf") assert "didn't understand" in responses[0].text - + # Verify full history history = client.ex_history() assert len(history) == 3 - + finally: await factory.cleanup() @@ -645,20 +668,24 @@ async def test_complete_http_conversation_flow(self): async def test_multi_user_http_conversation(self): """Multiple users in same conversation via HTTP.""" mock_server = MockAgentServer(port=9921) - mock_server.default_response(Activity(type=ActivityTypes.message, text="Received")) + mock_server.default_response( + Activity(type=ActivityTypes.message, text="Received") + ) agent_endpoint = f"{mock_server.endpoint}/api/messages" - + async with mock_server.run(): transcript = Transcript() factory = _AiohttpClientFactory( agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, - default_template=ActivityTemplate(**{"conversation.id": "multi-user-conv"}), + default_template=ActivityTemplate( + **{"conversation.id": "multi-user-conv"} + ), default_config=ClientConfig(), transcript=transcript, ) - + try: # Create clients for different users alice = await factory( @@ -667,24 +694,22 @@ async def test_multi_user_http_conversation(self): ) ) bob = await factory( - ClientConfig( - activity_template=ActivityTemplate({"from.id": "bob"}) - ) + ClientConfig(activity_template=ActivityTemplate({"from.id": "bob"})) ) - + # Both users send messages await alice.send_expect_replies("Hello from Alice") await bob.send_expect_replies("Hello from Bob") await alice.send_expect_replies("Alice again") - + # Verify all messages in shared transcript assert len(transcript.history()) == 3 - + # Verify server received from both users from_ids = [a.from_property.id for a in mock_server.received_activities] assert "alice" in from_ids assert "bob" in from_ids - + finally: await factory.cleanup() @@ -694,41 +719,46 @@ async def test_invoke_and_message_mixed_flow(self): mock_server = MockAgentServer(port=9922) mock_server.on_invoke("get/status", 200, {"status": "healthy", "uptime": 12345}) mock_server.on_invoke("submit/data", 200, {"success": True, "id": "data-789"}) - mock_server.default_response(Activity(type=ActivityTypes.message, text="Message received")) - + mock_server.default_response( + Activity(type=ActivityTypes.message, text="Message received") + ) + async with mock_server.run(): async with ClientSession() as session: agent_endpoint = f"{mock_server.endpoint}/api/messages" sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) - + # Regular message msg_response = await client.send_expect_replies("Hello") assert msg_response[0].text == "Message received" - + # Invoke to get status status = await client.invoke( Activity(type=ActivityTypes.invoke, name="get/status") ) assert status.body["status"] == "healthy" - + # Another message await client.send_expect_replies("Still here") - + # Invoke to submit data submit = await client.invoke( - Activity(type=ActivityTypes.invoke, name="submit/data", value={"data": "test"}) + Activity( + type=ActivityTypes.invoke, + name="submit/data", + value={"data": "test"}, + ) ) assert submit.body["success"] is True - + # Verify full exchange history history = client.ex_history() assert len(history) == 4 - + # Filter to just invokes invoke_exchanges = [ - ex for ex in history - if ex.request.type == ActivityTypes.invoke + ex for ex in history if ex.request.type == ActivityTypes.invoke ] assert len(invoke_exchanges) == 2 @@ -736,41 +766,48 @@ async def test_invoke_and_message_mixed_flow(self): async def test_select_and_expect_with_http_responses(self): """Select and Expect work correctly with HTTP responses.""" mock_server = MockAgentServer(port=9924) - mock_server.on_text("report", + mock_server.on_text( + "report", Activity(type=ActivityTypes.typing), Activity(type=ActivityTypes.message, text="Generating report..."), Activity(type=ActivityTypes.message, text="Report: Sales up 20%"), Activity(type=ActivityTypes.event, name="report.complete"), ) - + async with mock_server.run(): async with ClientSession() as session: agent_endpoint = f"{mock_server.endpoint}/api/messages" sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) - + responses = await client.send_expect_replies("report") - + # Use Select to filter - messages = Select(responses).where( - lambda x: x.type == ActivityTypes.message - ).get() + messages = ( + Select(responses) + .where(lambda x: x.type == ActivityTypes.message) + .get() + ) assert len(messages) == 2 - - typing = Select(responses).where( - lambda x: x.type == ActivityTypes.typing - ).get() + + typing = ( + Select(responses) + .where(lambda x: x.type == ActivityTypes.typing) + .get() + ) assert len(typing) == 1 - - events = Select(responses).where( - lambda x: x.type == ActivityTypes.event - ).get() + + events = ( + Select(responses) + .where(lambda x: x.type == ActivityTypes.event) + .get() + ) assert len(events) == 1 assert events[0].name == "report.complete" - + # Use Expect to validate Expect(messages).that(lambda x: x.text is not None) - + # Get last message last_msg = Select(messages).last().get()[0] - assert "Sales up 20%" in last_msg.text \ No newline at end of file + assert "Sales up 20%" in last_msg.text diff --git a/dev/microsoft-agents-testing/tests/core/test_type_defs.py b/dev/microsoft-agents-testing/tests/core/test_type_defs.py index 7b16007f..0a4928e4 100644 --- a/dev/microsoft-agents-testing/tests/core/test_type_defs.py +++ b/dev/microsoft-agents-testing/tests/core/test_type_defs.py @@ -119,4 +119,3 @@ def test_expect_returns_exchange_expect(self): assert isinstance(expect, ExchangeExpect) expect.that_for_one(status_code=200) - diff --git a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py b/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py index f167070c..1222e893 100644 --- a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py +++ b/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py @@ -21,31 +21,31 @@ class TestAiohttpCallbackServerInitialization: def test_default_port(self): """AiohttpCallbackServer should use default port 9378.""" server = AiohttpCallbackServer() - + assert server._port == 9378 def test_custom_port(self): """AiohttpCallbackServer should accept custom port.""" server = AiohttpCallbackServer(port=8080) - + assert server._port == 8080 def test_service_endpoint_default_port(self): """service_endpoint should use the configured port.""" server = AiohttpCallbackServer() - + assert server.service_endpoint == "http://localhost:9378/v3/conversations/" def test_service_endpoint_custom_port(self): """service_endpoint should use custom port.""" server = AiohttpCallbackServer(port=8080) - + assert server.service_endpoint == "http://localhost:8080/v3/conversations/" def test_initial_transcript_is_none(self): """Initial transcript should be None.""" server = AiohttpCallbackServer() - + assert server._transcript is None @@ -56,7 +56,7 @@ class TestAiohttpCallbackServerListen: async def test_listen_yields_transcript(self): """listen should yield a Transcript.""" server = AiohttpCallbackServer(port=19378) - + async with server.listen() as transcript: assert isinstance(transcript, Transcript) @@ -65,7 +65,7 @@ async def test_listen_uses_provided_transcript(self): """listen should use the provided transcript.""" server = AiohttpCallbackServer(port=19874) provided_transcript = Transcript() - + async with server.listen(transcript=provided_transcript) as transcript: assert transcript is provided_transcript @@ -73,7 +73,7 @@ async def test_listen_uses_provided_transcript(self): async def test_listen_creates_new_transcript_if_none(self): """listen should create new transcript if none provided.""" server = AiohttpCallbackServer(port=19875) - + async with server.listen() as transcript: assert transcript is not None assert isinstance(transcript, Transcript) @@ -82,17 +82,17 @@ async def test_listen_creates_new_transcript_if_none(self): async def test_listen_resets_transcript_after_exit(self): """listen should reset internal transcript after context exit.""" server = AiohttpCallbackServer(port=19876) - + async with server.listen(): assert server._transcript is not None - + assert server._transcript is None @pytest.mark.asyncio async def test_listen_raises_if_already_listening(self): """listen should raise RuntimeError if already listening.""" server = AiohttpCallbackServer(port=19877) - + async with server.listen(): with pytest.raises(RuntimeError, match="already listening"): async with server.listen(): @@ -106,17 +106,19 @@ class TestAiohttpCallbackServerHandleRequest: async def test_handle_request_records_activity(self): """Server should record incoming activities to transcript.""" server = AiohttpCallbackServer(port=19878) - + async with server.listen() as transcript: # Create a mock request activity = Activity(type=ActivityTypes.message, text="Hello from agent") - + # Simulate the request by calling _handle_request directly mock_request = AsyncMock() - mock_request.json = AsyncMock(return_value=activity.model_dump(by_alias=True, exclude_none=True)) - + mock_request.json = AsyncMock( + return_value=activity.model_dump(by_alias=True, exclude_none=True) + ) + response = await server._handle_request(mock_request) - + assert response.status == 200 assert len(transcript.history()) == 1 @@ -124,19 +126,19 @@ async def test_handle_request_records_activity(self): async def test_handle_request_parses_activity(self): """Server should parse incoming JSON as Activity.""" server = AiohttpCallbackServer(port=19879) - + async with server.listen() as transcript: activity_data = { "type": "message", "text": "Hello from agent", - "from": {"id": "agent-id", "name": "Agent"} + "from": {"id": "agent-id", "name": "Agent"}, } - + mock_request = AsyncMock() mock_request.json = AsyncMock(return_value=activity_data) - + await server._handle_request(mock_request) - + recorded = transcript.history()[0] assert len(recorded.responses) == 1 assert recorded.responses[0].text == "Hello from agent" @@ -145,15 +147,15 @@ async def test_handle_request_parses_activity(self): async def test_handle_request_returns_200_on_success(self): """Server should return 200 on successful request.""" server = AiohttpCallbackServer(port=19880) - + async with server.listen(): activity_data = {"type": "message", "text": "Hello"} - + mock_request = AsyncMock() mock_request.json = AsyncMock(return_value=activity_data) - + response = await server._handle_request(mock_request) - + assert response.status == 200 assert response.content_type == "application/json" @@ -161,15 +163,15 @@ async def test_handle_request_returns_200_on_success(self): async def test_handle_request_records_response_timestamp(self): """Server should record response timestamp.""" server = AiohttpCallbackServer(port=19881) - + async with server.listen() as transcript: activity_data = {"type": "message", "text": "Hello"} - + mock_request = AsyncMock() mock_request.json = AsyncMock(return_value=activity_data) - + await server._handle_request(mock_request) - + recorded = transcript.history()[0] assert recorded.response_at is not None @@ -181,14 +183,14 @@ class TestAiohttpCallbackServerIntegration: async def test_multiple_activities_recorded_in_order(self): """Multiple activities should be recorded in order.""" server = AiohttpCallbackServer(port=19882) - + async with server.listen() as transcript: for i in range(3): activity_data = {"type": "message", "text": f"Message {i}"} mock_request = AsyncMock() mock_request.json = AsyncMock(return_value=activity_data) await server._handle_request(mock_request) - + history = transcript.history() assert len(history) == 3 for i, exchange in enumerate(history): @@ -200,13 +202,13 @@ async def test_transcript_shared_with_child(self): server = AiohttpCallbackServer(port=19883) parent_transcript = Transcript() child_transcript = Transcript(parent=parent_transcript) - + async with server.listen(transcript=child_transcript): activity_data = {"type": "message", "text": "Hello"} mock_request = AsyncMock() mock_request.json = AsyncMock(return_value=activity_data) await server._handle_request(mock_request) - + # Both should have the exchange assert len(child_transcript.history()) == 1 assert len(parent_transcript.history()) == 1 diff --git a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py b/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py index b90af39b..85d51760 100644 --- a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py +++ b/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py @@ -29,25 +29,27 @@ def create_mock_response(status: int = 200, text: str = "OK"): def create_mock_session(mock_response): """Create a mock session with async context manager support.""" mock_session = MagicMock(spec=ClientSession) - + @asynccontextmanager async def mock_post(*args, **kwargs): yield mock_response - + mock_session.post = mock_post return mock_session + ENDPOINT = "http://localhost:9999/api/messages" + class TestAiohttpSenderInitialization: """Tests for AiohttpSender initialization.""" def test_aiohttp_sender_stores_session(self): """AiohttpSender should store the provided session.""" mock_session = MagicMock(spec=ClientSession) - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) - + assert sender._session is mock_session @@ -58,22 +60,22 @@ class TestAiohttpSenderSend: async def test_send_posts_to_api_messages(self): """send should POST to api/messages endpoint.""" mock_response = create_mock_response(200, "OK") - + mock_session = MagicMock(spec=ClientSession) post_calls = [] - + @asynccontextmanager async def mock_post(*args, **kwargs): post_calls.append((args, kwargs)) yield mock_response - + mock_session.post = mock_post - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") - + await sender.send(activity) - + assert len(post_calls) == 1 assert post_calls[0][0][0] == "http://localhost:9999/api/messages" @@ -81,24 +83,24 @@ async def mock_post(*args, **kwargs): async def test_send_serializes_activity_correctly(self): """send should serialize activity with correct options.""" mock_response = create_mock_response(200, "OK") - + mock_session = MagicMock(spec=ClientSession) post_calls = [] - + @asynccontextmanager async def mock_post(*args, **kwargs): post_calls.append((args, kwargs)) yield mock_response - + mock_session.post = mock_post - + sender = AiohttpSender(ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") - + await sender.send(activity) - + json_data = post_calls[0][1]["json"] - + # Should include the activity data assert json_data["type"] == "message" assert json_data["text"] == "Hello" @@ -108,12 +110,12 @@ async def test_send_returns_exchange(self): """send should return an Exchange object.""" mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") - + exchange = await sender.send(activity) - + assert isinstance(exchange, Exchange) assert exchange.request == activity assert exchange.status_code == 200 @@ -123,12 +125,12 @@ async def test_send_records_timestamps(self): """send should record request and response timestamps.""" mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") - + exchange = await sender.send(activity) - + assert exchange.request_at is not None assert exchange.response_at is not None assert isinstance(exchange.request_at, datetime) @@ -139,13 +141,13 @@ async def test_send_records_to_transcript(self): """send should record exchange to provided transcript.""" mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") transcript = Transcript() - + await sender.send(activity, transcript=transcript) - + assert len(transcript.history()) == 1 assert transcript.history()[0].request.text == "Hello" @@ -154,10 +156,10 @@ async def test_send_without_transcript_does_not_record(self): """send without transcript should not raise.""" mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") - + # Should not raise exchange = await sender.send(activity) assert exchange is not None @@ -166,22 +168,22 @@ async def test_send_without_transcript_does_not_record(self): async def test_send_passes_kwargs(self): """send should pass additional kwargs to the session.post call.""" mock_response = create_mock_response(200, "OK") - + mock_session = MagicMock(spec=ClientSession) post_calls = [] - + @asynccontextmanager async def mock_post(*args, **kwargs): post_calls.append((args, kwargs)) yield mock_response - + mock_session.post = mock_post - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") - + await sender.send(activity, timeout=30) - + assert post_calls[0][1].get("timeout") == 30 @@ -192,19 +194,19 @@ class TestAiohttpSenderErrorHandling: async def test_send_handles_connection_error(self): """send should handle connection errors gracefully.""" mock_session = MagicMock(spec=ClientSession) - + @asynccontextmanager async def mock_post(*args, **kwargs): raise aiohttp.ClientConnectionError("Connection failed") yield # Never reached, but needed for generator - + mock_session.post = mock_post - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") - + exchange = await sender.send(activity) - + assert exchange.error is not None assert "Connection failed" in exchange.error @@ -212,39 +214,39 @@ async def mock_post(*args, **kwargs): async def test_send_handles_timeout_error(self): """send should handle timeout errors gracefully.""" mock_session = MagicMock(spec=ClientSession) - + @asynccontextmanager async def mock_post(*args, **kwargs): raise aiohttp.ServerTimeoutError("Timeout") yield # Never reached, but needed for generator - + mock_session.post = mock_post - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") - + exchange = await sender.send(activity) - + assert exchange.error is not None @pytest.mark.asyncio async def test_send_records_error_to_transcript(self): """send should record error exchanges to transcript.""" mock_session = MagicMock(spec=ClientSession) - + @asynccontextmanager async def mock_post(*args, **kwargs): raise aiohttp.ClientConnectionError("Connection failed") yield # Never reached - + mock_session.post = mock_post - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") transcript = Transcript() - + await sender.send(activity, transcript=transcript) - + assert len(transcript.history()) == 1 assert transcript.history()[0].error is not None @@ -252,17 +254,17 @@ async def mock_post(*args, **kwargs): async def test_send_raises_unexpected_errors(self): """send should re-raise unexpected errors.""" mock_session = MagicMock(spec=ClientSession) - + @asynccontextmanager async def mock_post(*args, **kwargs): raise ValueError("Unexpected error") yield # Never reached - + mock_session.post = mock_post - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") - + with pytest.raises(ValueError, match="Unexpected error"): await sender.send(activity) @@ -273,23 +275,27 @@ class TestAiohttpSenderExpectReplies: @pytest.mark.asyncio async def test_send_expect_replies_parses_responses(self): """send with expect_replies should parse inline responses.""" - responses_json = json.dumps({"activities": [ - {"type": "message", "text": "Reply 1"}, - {"type": "message", "text": "Reply 2"} - ]}) - + responses_json = json.dumps( + { + "activities": [ + {"type": "message", "text": "Reply 1"}, + {"type": "message", "text": "Reply 2"}, + ] + } + ) + mock_response = create_mock_response(200, responses_json) mock_session = create_mock_session(mock_response) - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity( type=ActivityTypes.message, text="Hello", - delivery_mode=DeliveryModes.expect_replies + delivery_mode=DeliveryModes.expect_replies, ) - + exchange = await sender.send(activity) - + assert len(exchange.responses) == 2 assert exchange.responses[0].text == "Reply 1" assert exchange.responses[1].text == "Reply 2" @@ -302,18 +308,15 @@ class TestAiohttpSenderInvoke: async def test_send_invoke_parses_invoke_response(self): """send with invoke activity should parse invoke response.""" invoke_response_json = json.dumps({"result": "success"}) - + mock_response = create_mock_response(200, invoke_response_json) mock_session = create_mock_session(mock_response) - + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) - activity = Activity( - type=ActivityTypes.invoke, - name="testAction" - ) - + activity = Activity(type=ActivityTypes.invoke, name="testAction") + exchange = await sender.send(activity) - + assert exchange.invoke_response is not None assert exchange.invoke_response.status == 200 assert exchange.invoke_response.body == {"result": "success"} @@ -328,4 +331,4 @@ async def test_send_raises_on_non_success_status(self): activity = Activity(type=ActivityTypes.message, text="Hello") with pytest.raises(ClientError, match="404"): - await sender.send(activity) \ No newline at end of file + await sender.send(activity) diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py b/dev/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py index f689f962..c1061f13 100644 --- a/dev/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py +++ b/dev/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py @@ -25,7 +25,7 @@ class TestExchange: def test_exchange_default_initialization(self): """Exchange should initialize with default values.""" exchange = Exchange() - + assert exchange.request is None assert exchange.request_at is None assert exchange.status_code is None @@ -39,7 +39,7 @@ def test_exchange_with_request(self): """Exchange should store the request activity.""" activity = Activity(type=ActivityTypes.message, text="Hello") exchange = Exchange(request=activity) - + assert exchange.request == activity assert exchange.request.text == "Hello" assert exchange.request.type == ActivityTypes.message @@ -49,37 +49,31 @@ def test_exchange_with_responses(self): request = Activity(type=ActivityTypes.message, text="Hello") response1 = Activity(type=ActivityTypes.message, text="Response 1") response2 = Activity(type=ActivityTypes.message, text="Response 2") - - exchange = Exchange( - request=request, - responses=[response1, response2] - ) - + + exchange = Exchange(request=request, responses=[response1, response2]) + assert len(exchange.responses) == 2 assert exchange.responses[0].text == "Response 1" assert exchange.responses[1].text == "Response 2" def test_exchange_with_status_code_and_body(self): """Exchange should store HTTP response metadata.""" - exchange = Exchange( - status_code=200, - body='{"result": "success"}' - ) - + exchange = Exchange(status_code=200, body='{"result": "success"}') + assert exchange.status_code == 200 assert exchange.body == '{"result": "success"}' def test_exchange_with_error(self): """Exchange should store error information.""" exchange = Exchange(error="Connection timeout") - + assert exchange.error == "Connection timeout" def test_exchange_with_invoke_response(self): """Exchange should store invoke response.""" invoke_resp = InvokeResponse(status=200, body={"key": "value"}) exchange = Exchange(invoke_response=invoke_resp) - + assert exchange.invoke_response == invoke_resp assert exchange.invoke_response.status == 200 @@ -91,12 +85,9 @@ def test_latency_with_both_timestamps(self): """Latency should be calculated when both timestamps are present.""" request_time = datetime(2026, 1, 30, 10, 0, 0) response_time = datetime(2026, 1, 30, 10, 0, 1) # 1 second later - - exchange = Exchange( - request_at=request_time, - response_at=response_time - ) - + + exchange = Exchange(request_at=request_time, response_at=response_time) + latency = exchange.latency assert latency is not None assert latency == timedelta(seconds=1) @@ -105,12 +96,9 @@ def test_latency_ms_with_both_timestamps(self): """Latency in milliseconds should be calculated correctly.""" request_time = datetime(2026, 1, 30, 10, 0, 0) response_time = datetime(2026, 1, 30, 10, 0, 0, 500000) # 500ms later - - exchange = Exchange( - request_at=request_time, - response_at=response_time - ) - + + exchange = Exchange(request_at=request_time, response_at=response_time) + latency_ms = exchange.latency_ms assert latency_ms is not None assert latency_ms == 500.0 @@ -118,21 +106,21 @@ def test_latency_ms_with_both_timestamps(self): def test_latency_without_request_timestamp(self): """Latency should be None when request_at is missing.""" exchange = Exchange(response_at=datetime.now()) - + assert exchange.latency is None assert exchange.latency_ms is None def test_latency_without_response_timestamp(self): """Latency should be None when response_at is missing.""" exchange = Exchange(request_at=datetime.now()) - + assert exchange.latency is None assert exchange.latency_ms is None def test_latency_without_any_timestamps(self): """Latency should be None when both timestamps are missing.""" exchange = Exchange() - + assert exchange.latency is None assert exchange.latency_ms is None @@ -200,12 +188,11 @@ async def test_from_request_with_allowed_exception(self): """from_request should handle allowed exceptions.""" activity = Activity(type=ActivityTypes.message, text="Hello") exception = aiohttp.ClientConnectionError("Connection failed") - + exchange = await Exchange.from_request( - request_activity=activity, - response_or_exception=exception + request_activity=activity, response_or_exception=exception ) - + assert exchange.request == activity assert exchange.error == "Connection failed" assert exchange.status_code is None @@ -216,12 +203,11 @@ async def test_from_request_with_timeout_exception(self): """from_request should handle timeout exceptions.""" activity = Activity(type=ActivityTypes.message, text="Hello") exception = aiohttp.ConnectionTimeoutError() - + exchange = await Exchange.from_request( - request_activity=activity, - response_or_exception=exception + request_activity=activity, response_or_exception=exception ) - + assert exchange.request == activity assert exchange.error is not None @@ -230,11 +216,10 @@ async def test_from_request_with_disallowed_exception_raises(self): """from_request should re-raise disallowed exceptions.""" activity = Activity(type=ActivityTypes.message, text="Hello") exception = ValueError("Invalid value") - + with pytest.raises(ValueError, match="Invalid value"): await Exchange.from_request( - request_activity=activity, - response_or_exception=exception + request_activity=activity, response_or_exception=exception ) @pytest.mark.asyncio @@ -243,23 +228,26 @@ async def test_from_request_with_expect_replies_response(self): activity = Activity( type=ActivityTypes.message, text="Hello", - delivery_mode=DeliveryModes.expect_replies + delivery_mode=DeliveryModes.expect_replies, ) - + # Mock aiohttp response mock_response = self._create_mock_response( status=200, - text=json.dumps({"activities": [ - {"type": "message", "text": "Reply 1"}, - {"type": "message", "text": "Reply 2"} - ]}) + text=json.dumps( + { + "activities": [ + {"type": "message", "text": "Reply 1"}, + {"type": "message", "text": "Reply 2"}, + ] + } + ), ) - + exchange = await Exchange.from_request( - request_activity=activity, - response_or_exception=mock_response + request_activity=activity, response_or_exception=mock_response ) - + assert exchange.request == activity assert exchange.status_code == 200 assert len(exchange.responses) == 2 @@ -278,12 +266,14 @@ async def test_from_request_with_stream_delivery_parses_activity_events(self): # Mock aiohttp response with SSE-like payload (event: activity + data: ) mock_response = self._create_mock_response( status=200, - content=self._AsyncBytesIterator([ - b"event: activity\n", - b"data: {\"type\": \"message\", \"text\": \"Stream reply 1\"}\n", - b"event: activity\n", - b"data: {\"type\": \"message\", \"text\": \"Stream reply 2\"}\n", - ]) + content=self._AsyncBytesIterator( + [ + b"event: activity\n", + b'data: {"type": "message", "text": "Stream reply 1"}\n', + b"event: activity\n", + b'data: {"type": "message", "text": "Stream reply 2"}\n', + ] + ), ) exchange = await Exchange.from_request( @@ -293,27 +283,25 @@ async def test_from_request_with_stream_delivery_parses_activity_events(self): assert exchange.request == activity assert exchange.status_code == 200 - assert [a.text for a in exchange.responses] == ["Stream reply 1", "Stream reply 2"] + assert [a.text for a in exchange.responses] == [ + "Stream reply 1", + "Stream reply 2", + ] @pytest.mark.asyncio async def test_from_request_with_invoke_response(self): """from_request should parse invoke response.""" - activity = Activity( - type=ActivityTypes.invoke, - name="testInvoke" - ) - + activity = Activity(type=ActivityTypes.invoke, name="testInvoke") + # Mock aiohttp response mock_response = self._create_mock_response( - status=200, - text=json.dumps({"result": "success"}) + status=200, text=json.dumps({"result": "success"}) ) - + exchange = await Exchange.from_request( - request_activity=activity, - response_or_exception=mock_response + request_activity=activity, response_or_exception=mock_response ) - + assert exchange.request == activity assert exchange.status_code == 200 assert exchange.invoke_response is not None @@ -324,19 +312,12 @@ async def test_from_request_with_invoke_response(self): @pytest.mark.parametrize("response_body", ["", " \n\t"]) async def test_from_request_with_empty_invoke_response_body(self, response_body): """from_request should parse empty invoke response bodies.""" - activity = Activity( - type=ActivityTypes.invoke, - name="testInvoke" - ) + activity = Activity(type=ActivityTypes.invoke, name="testInvoke") - mock_response = self._create_mock_response( - status=200, - text=response_body - ) + mock_response = self._create_mock_response(status=200, text=response_body) exchange = await Exchange.from_request( - request_activity=activity, - response_or_exception=mock_response + request_activity=activity, response_or_exception=mock_response ) assert exchange.request == activity @@ -350,15 +331,14 @@ async def test_from_request_with_empty_invoke_response_body(self, response_body) async def test_from_request_with_regular_message_response(self): """from_request should handle regular message response.""" activity = Activity(type=ActivityTypes.message, text="Hello") - + # Mock aiohttp response mock_response = self._create_mock_response(status=200, text="OK") - + exchange = await Exchange.from_request( - request_activity=activity, - response_or_exception=mock_response + request_activity=activity, response_or_exception=mock_response ) - + assert exchange.request == activity assert exchange.status_code == 200 assert exchange.body == "OK" @@ -370,24 +350,26 @@ async def test_from_request_with_kwargs(self): """from_request should pass through additional kwargs.""" activity = Activity(type=ActivityTypes.message, text="Hello") request_time = datetime(2026, 1, 30, 10, 0, 0) - + mock_response = self._create_mock_response(status=200, text="OK") - + exchange = await Exchange.from_request( request_activity=activity, response_or_exception=mock_response, - request_at=request_time + request_at=request_time, ) - + assert exchange.request_at == request_time @pytest.mark.asyncio async def test_from_request_with_invalid_type_raises(self): """from_request should raise for invalid response types.""" activity = Activity(type=ActivityTypes.message, text="Hello") - - with pytest.raises(ValueError, match="must be an Exception or aiohttp.ClientResponse"): + + with pytest.raises( + ValueError, match="must be an Exception or aiohttp.ClientResponse" + ): await Exchange.from_request( request_activity=activity, - response_or_exception="invalid_type" # type: ignore + response_or_exception="invalid_type", # type: ignore ) diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py b/dev/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py index a15a99aa..9abdb719 100644 --- a/dev/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py +++ b/dev/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py @@ -16,7 +16,7 @@ class TestTranscriptInitialization: def test_transcript_default_initialization(self): """Transcript should initialize with empty history and no parent.""" transcript = Transcript() - + assert transcript._parent is None assert transcript._children == [] assert transcript._history == [] @@ -26,7 +26,7 @@ def test_transcript_with_parent(self): """Transcript should accept a parent transcript.""" parent = Transcript() child = Transcript(parent=parent) - + assert child._parent is parent @@ -38,10 +38,10 @@ def test_history_returns_copy(self): transcript = Transcript() exchange = Exchange(request=Activity(type=ActivityTypes.message, text="Hello")) transcript.record(exchange) - + history = transcript.history() history.append(Exchange()) # Modify the returned list - + # Internal history should not be affected assert len(transcript.history()) == 1 @@ -50,11 +50,11 @@ def test_clear_removes_all_history(self): transcript = Transcript() exchange1 = Exchange(request=Activity(type=ActivityTypes.message, text="Hello")) exchange2 = Exchange(request=Activity(type=ActivityTypes.message, text="World")) - + transcript.record(exchange1) transcript.record(exchange2) assert len(transcript.history()) == 2 - + transcript.clear() assert transcript.history() == [] @@ -66,9 +66,9 @@ def test_record_adds_to_history(self): """record() should add an exchange to the transcript.""" transcript = Transcript() exchange = Exchange(request=Activity(type=ActivityTypes.message, text="Hello")) - + transcript.record(exchange) - + assert len(transcript.history()) == 1 assert transcript.history()[0] == exchange @@ -76,13 +76,15 @@ def test_record_multiple_exchanges(self): """record() should maintain order of exchanges.""" transcript = Transcript() exchange1 = Exchange(request=Activity(type=ActivityTypes.message, text="First")) - exchange2 = Exchange(request=Activity(type=ActivityTypes.message, text="Second")) + exchange2 = Exchange( + request=Activity(type=ActivityTypes.message, text="Second") + ) exchange3 = Exchange(request=Activity(type=ActivityTypes.message, text="Third")) - + transcript.record(exchange1) transcript.record(exchange2) transcript.record(exchange3) - + history = transcript.history() assert len(history) == 3 assert history[0].request.text == "First" @@ -97,10 +99,10 @@ def test_propagate_up_to_parent(self): """Exchanges should propagate up to parent transcript.""" parent = Transcript() child = Transcript(parent=parent) - + exchange = Exchange(request=Activity(type=ActivityTypes.message, text="Hello")) child.record(exchange) - + # Exchange should be in both child and parent assert len(child.history()) == 1 assert len(parent.history()) == 1 @@ -111,10 +113,10 @@ def test_propagate_up_multiple_levels(self): grandparent = Transcript() parent = Transcript(parent=grandparent) child = Transcript(parent=parent) - + exchange = Exchange(request=Activity(type=ActivityTypes.message, text="Hello")) child.record(exchange) - + # Exchange should be in all transcripts assert len(child.history()) == 1 assert len(parent.history()) == 1 @@ -125,14 +127,14 @@ def test_propagate_down_to_children(self): parent = Transcript() child1 = Transcript(parent=parent) child2 = Transcript(parent=parent) - + # Need to register children with parent parent._children.append(child1) parent._children.append(child2) - + exchange = Exchange(request=Activity(type=ActivityTypes.message, text="Hello")) parent.record(exchange) - + # Exchange should be in parent and both children assert len(parent.history()) == 1 assert len(child1.history()) == 1 @@ -143,13 +145,13 @@ def test_propagate_down_multiple_levels(self): grandparent = Transcript() parent = Transcript() child = Transcript() - + grandparent._children.append(parent) parent._children.append(child) - + exchange = Exchange(request=Activity(type=ActivityTypes.message, text="Hello")) grandparent.record(exchange) - + # Exchange should be in all transcripts assert len(grandparent.history()) == 1 assert len(parent.history()) == 1 @@ -160,13 +162,13 @@ def test_child_does_not_propagate_to_siblings(self): parent = Transcript() child1 = Transcript(parent=parent) child2 = Transcript(parent=parent) - + # Only add children for downward propagation test # child1 and child2 have parent set for upward propagation - + exchange = Exchange(request=Activity(type=ActivityTypes.message, text="Hello")) child1.record(exchange) - + # Exchange should be in child1 and parent only assert len(child1.history()) == 1 assert len(parent.history()) == 1 @@ -180,14 +182,14 @@ class TestTranscriptGetRoot: def test_get_root_returns_self_when_no_parent(self): """get_root() should return self when there is no parent.""" transcript = Transcript() - + assert transcript.get_root() is transcript def test_get_root_returns_parent_when_one_level(self): """get_root() should return parent when one level deep.""" parent = Transcript() child = Transcript(parent=parent) - + assert child.get_root() is parent def test_get_root_returns_grandparent_when_two_levels(self): @@ -195,7 +197,7 @@ def test_get_root_returns_grandparent_when_two_levels(self): grandparent = Transcript() parent = Transcript(parent=grandparent) child = Transcript(parent=parent) - + assert child.get_root() is grandparent assert parent.get_root() is grandparent @@ -206,7 +208,7 @@ def test_get_root_returns_topmost_ancestor(self): level2 = Transcript(parent=level1) level3 = Transcript(parent=level2) level4 = Transcript(parent=level3) - + assert level4.get_root() is root assert level3.get_root() is root assert level2.get_root() is root @@ -220,7 +222,7 @@ def test_child_creates_new_transcript(self): """child() should create a new Transcript instance.""" parent = Transcript() child = parent.child() - + assert isinstance(child, Transcript) assert child is not parent @@ -228,16 +230,18 @@ def test_child_has_correct_parent(self): """child() should set the parent reference correctly.""" parent = Transcript() child = parent.child() - + assert child._parent is parent def test_child_is_independent_initially(self): """Child transcript should start with empty history.""" parent = Transcript() - parent.record(Exchange(request=Activity(type=ActivityTypes.message, text="Before"))) - + parent.record( + Exchange(request=Activity(type=ActivityTypes.message, text="Before")) + ) + child = parent.child() - + # Child should have empty history initially assert child.history() == [] @@ -245,10 +249,10 @@ def test_child_propagates_to_parent(self): """Exchanges recorded in child should propagate to parent.""" parent = Transcript() child = parent.child() - + exchange = Exchange(request=Activity(type=ActivityTypes.message, text="Hello")) child.record(exchange) - + assert len(child.history()) == 1 assert len(parent.history()) == 1 @@ -258,10 +262,10 @@ def test_nested_children(self): level1 = root.child() level2 = level1.child() level3 = level2.child() - + exchange = Exchange(request=Activity(type=ActivityTypes.message, text="Deep")) level3.record(exchange) - + # All ancestors should have the exchange assert len(level3.history()) == 1 assert len(level2.history()) == 1 @@ -279,23 +283,23 @@ def test_complex_hierarchy_propagation(self): # a b # / \ \ # c d e - + root = Transcript() a = Transcript(parent=root) b = Transcript(parent=root) c = Transcript(parent=a) d = Transcript(parent=a) e = Transcript(parent=b) - + # Record in leaf node 'c' exchange = Exchange(request=Activity(type=ActivityTypes.message, text="From C")) c.record(exchange) - + # Should propagate to c, a, root assert len(c.history()) == 1 assert len(a.history()) == 1 assert len(root.history()) == 1 - + # Should NOT propagate to siblings or other branches assert len(d.history()) == 0 assert len(b.history()) == 0 @@ -305,17 +309,17 @@ def test_multiple_exchanges_maintain_order(self): """Multiple exchanges should maintain order in history.""" root = Transcript() child = Transcript(parent=root) - + for i in range(5): exchange = Exchange( request=Activity(type=ActivityTypes.message, text=f"Message {i}") ) child.record(exchange) - + # Both should have same order for i, ex in enumerate(child.history()): assert ex.request.text == f"Message {i}" - + for i, ex in enumerate(root.history()): assert ex.request.text == f"Message {i}" @@ -323,11 +327,11 @@ def test_clear_does_not_affect_parent(self): """Clearing child history should not affect parent.""" root = Transcript() child = Transcript(parent=root) - + exchange = Exchange(request=Activity(type=ActivityTypes.message, text="Test")) child.record(exchange) - + child.clear() - + assert len(child.history()) == 0 assert len(root.history()) == 1 diff --git a/dev/microsoft-agents-testing/tests/manual.py b/dev/microsoft-agents-testing/tests/manual.py index 4641af5d..680bf70b 100644 --- a/dev/microsoft-agents-testing/tests/manual.py +++ b/dev/microsoft-agents-testing/tests/manual.py @@ -9,13 +9,14 @@ AgentClient, ) + async def main(): async def init(env: AgentEnvironment): @env.agent_application.activity("message") async def echo_handler(context, state): await context.send_activity(f"Echo: {context.activity.text}") - + scenario = AiohttpScenario( init, ) @@ -23,9 +24,6 @@ async def echo_handler(context, state): async with scenario.client() as client: replies = await client.send("Hello!") client.expect().that(text="Echo: Hello!") - - - env = AiohttpEnvironment() await env.init_env(await QuickstartSample.get_config()) diff --git a/dev/microsoft-agents-testing/tests/test_aiohttp_scenario.py b/dev/microsoft-agents-testing/tests/test_aiohttp_scenario.py index 866c53b5..94115f06 100644 --- a/dev/microsoft-agents-testing/tests/test_aiohttp_scenario.py +++ b/dev/microsoft-agents-testing/tests/test_aiohttp_scenario.py @@ -8,7 +8,6 @@ from microsoft_agents.testing.aiohttp_scenario import AiohttpScenario, AgentEnvironment from microsoft_agents.testing.core import Scenario, ScenarioConfig - # ============================================================================ # AgentEnvironment Tests # ============================================================================ @@ -186,7 +185,8 @@ async def init_agent(env: AgentEnvironment) -> None: scenario = AiohttpScenario(init_agent=init_agent) with pytest.raises( - RuntimeError, match="Agent environment not available. Is the scenario running?" + RuntimeError, + match="Agent environment not available. Is the scenario running?", ): _ = scenario.agent_environment diff --git a/dev/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py b/dev/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py index 9596c32f..4448e57d 100644 --- a/dev/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py +++ b/dev/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py @@ -24,7 +24,6 @@ from microsoft_agents.testing.core import ScenarioConfig from microsoft_agents.testing.core.fluent import Expect, Select - # ============================================================================ # Simple Echo Agent Tests # ============================================================================ @@ -48,7 +47,7 @@ async def on_message(context: TurnContext, state: TurnState): ) async with scenario.client() as client: - await client.send("Hello, Agent!", wait=.2) + await client.send("Hello, Agent!", wait=0.2) client.expect().that_for_any(text="Echo: Hello, Agent!") @pytest.mark.asyncio @@ -95,6 +94,7 @@ async def on_message(context: TurnContext, state: TurnState): client.expect().that_for_any(text="Echo: ") + # ============================================================================ # Multi-Response Agent Tests # ============================================================================ @@ -147,7 +147,9 @@ async def on_message(context: TurnContext, state: TurnState): # Should have both typing and message activities client.expect().that_for_any(type=ActivityTypes.typing) - client.expect().that_for_any(type=ActivityTypes.message, text="Here is my response!") + client.expect().that_for_any( + type=ActivityTypes.message, text="Here is my response!" + ) # ============================================================================ @@ -471,7 +473,11 @@ async def init_agent(env: AgentEnvironment) -> None: @env.agent_application.activity("message") async def on_message(context: TurnContext, state: TurnState): messages_received.append(context.activity.text) - user_id = context.activity.from_property.id if context.activity.from_property else "unknown" + user_id = ( + context.activity.from_property.id + if context.activity.from_property + else "unknown" + ) await context.send_activity(f"Hello, {user_id}!") scenario = AiohttpScenario( diff --git a/dev/microsoft-agents-testing/tests/test_pytest_plugin.py b/dev/microsoft-agents-testing/tests/test_pytest_plugin.py index c86390a3..7ea7041f 100644 --- a/dev/microsoft-agents-testing/tests/test_pytest_plugin.py +++ b/dev/microsoft-agents-testing/tests/test_pytest_plugin.py @@ -15,7 +15,6 @@ from microsoft_agents.testing.aiohttp_scenario import AiohttpScenario, AgentEnvironment - # ============================================================================ # Helper: Create a simple echo agent scenario # ============================================================================ @@ -23,6 +22,7 @@ async def init_echo_agent(env: AgentEnvironment) -> None: """Initialize a simple echo agent for testing.""" + @env.agent_application.activity("message") async def on_message(context: TurnContext, state: TurnState): await context.send_activity(f"Echo: {context.activity.text}") @@ -59,7 +59,7 @@ async def test_agent_client_can_send_message(self, agent_client): async def test_agent_client_has_transcript(self, agent_client): """agent_client maintains a transcript of exchanges.""" await agent_client.send("Test message", wait=0.2) - + # Transcript should have at least one exchange assert agent_client.transcript is not None @@ -69,7 +69,7 @@ async def test_agent_client_multiple_messages(self, agent_client): await agent_client.send("First") await agent_client.send("Second") await agent_client.send("Third", wait=0.2) - + agent_client.expect().that_for_any(text="Echo: First") agent_client.expect().that_for_any(text="Echo: Second") agent_client.expect().that_for_any(text="Echo: Third") @@ -162,7 +162,7 @@ async def test_client_and_environment_work_together( """agent_client and agent_environment can be used together.""" # Verify environment is available assert agent_environment.agent_application is not None - + # Use client to send a message await agent_client.send("Hello from combined test!", wait=0.2) agent_client.expect().that_for_any(text="Echo: Hello from combined test!") @@ -187,7 +187,7 @@ async def test_all_fixtures_available( assert storage is not None assert adapter is not None assert connection_manager is not None - + # Derived fixtures should match environment components assert agent_application is agent_environment.agent_application assert authorization is agent_environment.authorization @@ -203,6 +203,7 @@ async def test_all_fixtures_available( async def init_counter_agent(env: AgentEnvironment) -> None: """Initialize an agent that counts messages using storage.""" + @env.agent_application.activity("message") async def on_message(context: TurnContext, state: TurnState): # Use state to count messages @@ -225,11 +226,11 @@ class TestStatefulAgentWithFixtures: async def test_storage_persists_across_messages(self, agent_client, storage): """Storage fixture provides access to the same storage instance used by agent.""" assert storage is not None - + await agent_client.send("one") await agent_client.send("two") await agent_client.send("three", wait=0.2) - + agent_client.expect().that_for_any(text="Message #1") agent_client.expect().that_for_any(text="Message #2") agent_client.expect().that_for_any(text="Message #3") @@ -270,7 +271,7 @@ def test_url_creates_external_scenario(self): from unittest.mock import Mock from microsoft_agents.testing.pytest_plugin import _get_scenario_from_marker from microsoft_agents.testing.core import ExternalScenario - + marker = Mock() marker.args = ("http://localhost:3978/api/messages",) item = Mock() @@ -294,7 +295,7 @@ def test_marker_requires_argument(self): """@pytest.mark.agent_test requires an argument.""" from unittest.mock import Mock from microsoft_agents.testing.pytest_plugin import _get_scenario_from_marker - + marker = Mock() marker.args = () item = Mock() @@ -307,7 +308,7 @@ def test_marker_rejects_invalid_type(self): """@pytest.mark.agent_test rejects non-string/non-Scenario arguments.""" from unittest.mock import Mock from microsoft_agents.testing.pytest_plugin import _get_scenario_from_marker - + marker = Mock() marker.args = (12345,) item = Mock() diff --git a/dev/microsoft-agents-testing/tests/test_scenario_registry.py b/dev/microsoft-agents-testing/tests/test_scenario_registry.py index 07c0750d..243a3c93 100644 --- a/dev/microsoft-agents-testing/tests/test_scenario_registry.py +++ b/dev/microsoft-agents-testing/tests/test_scenario_registry.py @@ -16,11 +16,11 @@ ) from microsoft_agents.testing.core import ExternalScenario - # ============================================================================ # ScenarioEntry Tests # ============================================================================ + class TestScenarioEntry: """Tests for ScenarioEntry dataclass.""" @@ -78,6 +78,7 @@ def test_namespace_property_without_namespace(self): # ScenarioRegistry Tests # ============================================================================ + class TestScenarioRegistry: """Tests for ScenarioRegistry class.""" @@ -115,7 +116,9 @@ def test_register_duplicate_raises_value_error(self): registry.register("test.echo", scenario1) - with pytest.raises(ValueError, match="Scenario 'test.echo' is already registered"): + with pytest.raises( + ValueError, match="Scenario 'test.echo' is already registered" + ): registry.register("test.echo", scenario2) def test_register_non_scenario_raises_type_error(self): @@ -190,6 +193,7 @@ def test_get_entry_unknown_raises_key_error(self): # ScenarioRegistry Discovery Tests # ============================================================================ + class TestScenarioRegistryDiscovery: """Tests for ScenarioRegistry.discover() method.""" @@ -287,6 +291,7 @@ def test_discover_no_matches(self): # ScenarioRegistry Container Protocol Tests # ============================================================================ + class TestScenarioRegistryContainer: """Tests for ScenarioRegistry container protocol methods.""" @@ -358,6 +363,7 @@ def test_clear_removes_all(self): # Global scenario_registry Tests # ============================================================================ + class TestGlobalScenarioRegistry: """Tests for the global scenario_registry instance.""" @@ -387,6 +393,7 @@ def test_global_registry_can_register_and_get(self): # load_scenarios Tests with Temporary Files # ============================================================================ + class TestLoadScenarios: """Tests for load_scenarios function using temporary files.""" @@ -403,8 +410,7 @@ def test_load_scenarios_from_file_path(self): with tempfile.TemporaryDirectory() as tmpdir: # Create a scenario file scenario_file = Path(tmpdir) / "test_scenarios.py" - scenario_file.write_text( - """ + scenario_file.write_text(""" from microsoft_agents.testing.scenario_registry import scenario_registry from microsoft_agents.testing.core import ExternalScenario @@ -413,8 +419,7 @@ def test_load_scenarios_from_file_path(self): ExternalScenario(endpoint="http://localhost:3978/api/messages"), description="Loaded from file", ) -""" - ) +""") count = load_scenarios(str(scenario_file)) @@ -427,8 +432,7 @@ def test_load_scenarios_multiple_registrations(self): """load_scenarios() returns count of newly registered scenarios.""" with tempfile.TemporaryDirectory() as tmpdir: scenario_file = Path(tmpdir) / "multi_scenarios.py" - scenario_file.write_text( - """ + scenario_file.write_text(""" from microsoft_agents.testing.scenario_registry import scenario_registry from microsoft_agents.testing.core import ExternalScenario @@ -444,8 +448,7 @@ def test_load_scenarios_multiple_registrations(self): "loaded.three", ExternalScenario(endpoint="http://localhost:3980/api/messages"), ) -""" - ) +""") count = load_scenarios(str(scenario_file)) @@ -464,8 +467,7 @@ def test_load_scenarios_with_forward_slashes(self): """load_scenarios() handles file paths with forward slashes.""" with tempfile.TemporaryDirectory() as tmpdir: scenario_file = Path(tmpdir) / "slash_scenarios.py" - scenario_file.write_text( - """ + scenario_file.write_text(""" from microsoft_agents.testing.scenario_registry import scenario_registry from microsoft_agents.testing.core import ExternalScenario @@ -473,8 +475,7 @@ def test_load_scenarios_with_forward_slashes(self): "slash.echo", ExternalScenario(endpoint="http://localhost:3978/api/messages"), ) -""" - ) +""") # Use forward slashes in path forward_slash_path = str(scenario_file).replace("\\", "/") @@ -487,8 +488,7 @@ def test_load_scenarios_with_backslashes(self): """load_scenarios() handles file paths with backslashes.""" with tempfile.TemporaryDirectory() as tmpdir: scenario_file = Path(tmpdir) / "backslash_scenarios.py" - scenario_file.write_text( - """ + scenario_file.write_text(""" from microsoft_agents.testing.scenario_registry import scenario_registry from microsoft_agents.testing.core import ExternalScenario @@ -496,8 +496,7 @@ def test_load_scenarios_with_backslashes(self): "backslash.echo", ExternalScenario(endpoint="http://localhost:3978/api/messages"), ) -""" - ) +""") # Use backslashes in path (Windows style) backslash_path = str(scenario_file).replace("/", "\\") @@ -514,8 +513,7 @@ def test_load_scenarios_existing_scenarios_not_counted(self): with tempfile.TemporaryDirectory() as tmpdir: scenario_file = Path(tmpdir) / "new_scenarios.py" - scenario_file.write_text( - """ + scenario_file.write_text(""" from microsoft_agents.testing.scenario_registry import scenario_registry from microsoft_agents.testing.core import ExternalScenario @@ -523,8 +521,7 @@ def test_load_scenarios_existing_scenarios_not_counted(self): "new.echo", ExternalScenario(endpoint="http://localhost:3979/api/messages"), ) -""" - ) +""") count = load_scenarios(str(scenario_file)) @@ -535,11 +532,9 @@ def test_load_scenarios_with_syntax_error(self): """load_scenarios() returns 0 when file has syntax error.""" with tempfile.TemporaryDirectory() as tmpdir: scenario_file = Path(tmpdir) / "broken_scenarios.py" - scenario_file.write_text( - """ + scenario_file.write_text(""" this is not valid python syntax!!! -""" - ) +""") count = load_scenarios(str(scenario_file)) @@ -549,11 +544,9 @@ def test_load_scenarios_with_import_error(self): """load_scenarios() returns 0 when file has import error.""" with tempfile.TemporaryDirectory() as tmpdir: scenario_file = Path(tmpdir) / "import_error_scenarios.py" - scenario_file.write_text( - """ + scenario_file.write_text(""" from nonexistent_module import something -""" - ) +""") count = load_scenarios(str(scenario_file)) @@ -565,8 +558,7 @@ def test_load_scenarios_cleans_up_sys_path(self): with tempfile.TemporaryDirectory() as tmpdir: scenario_file = Path(tmpdir) / "cleanup_scenarios.py" - scenario_file.write_text( - """ + scenario_file.write_text(""" from microsoft_agents.testing.scenario_registry import scenario_registry from microsoft_agents.testing.core import ExternalScenario @@ -574,8 +566,7 @@ def test_load_scenarios_cleans_up_sys_path(self): "cleanup.echo", ExternalScenario(endpoint="http://localhost:3978/api/messages"), ) -""" - ) +""") load_scenarios(str(scenario_file)) @@ -590,8 +581,7 @@ def test_load_scenarios_in_subdirectory(self): subdir = Path(tmpdir) / "subdir" / "nested" subdir.mkdir(parents=True) scenario_file = subdir / "deep_scenarios.py" - scenario_file.write_text( - """ + scenario_file.write_text(""" from microsoft_agents.testing.scenario_registry import scenario_registry from microsoft_agents.testing.core import ExternalScenario @@ -599,8 +589,7 @@ def test_load_scenarios_in_subdirectory(self): "deep.echo", ExternalScenario(endpoint="http://localhost:3978/api/messages"), ) -""" - ) +""") count = load_scenarios(str(scenario_file)) @@ -613,8 +602,7 @@ def test_load_scenarios_relative_path(self): with tempfile.TemporaryDirectory() as tmpdir: scenario_file = Path(tmpdir) / "relative_scenarios.py" - scenario_file.write_text( - """ + scenario_file.write_text(""" from microsoft_agents.testing.scenario_registry import scenario_registry from microsoft_agents.testing.core import ExternalScenario @@ -622,8 +610,7 @@ def test_load_scenarios_relative_path(self): "relative.echo", ExternalScenario(endpoint="http://localhost:3978/api/messages"), ) -""" - ) +""") # Change to temp directory and use relative path original_cwd = os.getcwd() @@ -641,6 +628,7 @@ def test_load_scenarios_relative_path(self): # load_scenarios Module Path Tests # ============================================================================ + class TestLoadScenariosModulePath: """Tests for load_scenarios with module paths.""" @@ -652,7 +640,9 @@ def teardown_method(self): """Clear the global registry after each test.""" scenario_registry.clear() # Clean up any test modules from sys.modules - modules_to_remove = [k for k in sys.modules.keys() if k.startswith("test_module_")] + modules_to_remove = [ + k for k in sys.modules.keys() if k.startswith("test_module_") + ] for mod in modules_to_remove: del sys.modules[mod] @@ -662,8 +652,7 @@ def test_load_scenarios_from_module_path(self): # Create a module module_dir = Path(tmpdir) module_file = module_dir / "test_module_scenarios.py" - module_file.write_text( - """ + module_file.write_text(""" from microsoft_agents.testing.scenario_registry import scenario_registry from microsoft_agents.testing.core import ExternalScenario @@ -671,8 +660,7 @@ def test_load_scenarios_from_module_path(self): "module.echo", ExternalScenario(endpoint="http://localhost:3978/api/messages"), ) -""" - ) +""") # Add to sys.path temporarily sys.path.insert(0, tmpdir) diff --git a/dev/microsoft-agents-testing/tests/test_scenario_registry_plugin.py b/dev/microsoft-agents-testing/tests/test_scenario_registry_plugin.py index 29f21394..6ab9b106 100644 --- a/dev/microsoft-agents-testing/tests/test_scenario_registry_plugin.py +++ b/dev/microsoft-agents-testing/tests/test_scenario_registry_plugin.py @@ -18,7 +18,6 @@ from microsoft_agents.testing.core import ExternalScenario from microsoft_agents.testing import scenario_registry - # ============================================================================ # Helpers: Define scenarios in this module (separate from test_pytest_plugin) # ============================================================================ @@ -26,6 +25,7 @@ async def init_echo_agent(env: AgentEnvironment) -> None: """Initialize a simple echo agent for testing.""" + @env.agent_application.activity("message") async def on_message(context: TurnContext, state: TurnState): await context.send_activity(f"Echo: {context.activity.text}") diff --git a/dev/microsoft-agents-testing/tests/test_source_scenario.py b/dev/microsoft-agents-testing/tests/test_source_scenario.py index 8c280e62..f3b4db15 100644 --- a/dev/microsoft-agents-testing/tests/test_source_scenario.py +++ b/dev/microsoft-agents-testing/tests/test_source_scenario.py @@ -63,15 +63,14 @@ def _fake_which(name): mock_process = _stub_process() - with patch("shutil.which", side_effect=_fake_which), \ - patch("subprocess.Popen", return_value=mock_process) as mock_popen, \ - patch( - "microsoft_agents.testing.source_scenario.asyncio.sleep", - new_callable=AsyncMock, - ), \ - patch( - "microsoft_agents.testing.source_scenario._terminate_tree" - ): + with patch("shutil.which", side_effect=_fake_which), patch( + "subprocess.Popen", return_value=mock_process + ) as mock_popen, patch( + "microsoft_agents.testing.source_scenario.asyncio.sleep", + new_callable=AsyncMock, + ), patch( + "microsoft_agents.testing.source_scenario._terminate_tree" + ): async with scenario._run_script(): captured["cmd"] = mock_popen.call_args[0][0] @@ -89,15 +88,14 @@ def _fake_which(name): mock_process = _stub_process() - with patch("shutil.which", side_effect=_fake_which), \ - patch("subprocess.Popen", return_value=mock_process) as mock_popen, \ - patch( - "microsoft_agents.testing.source_scenario.asyncio.sleep", - new_callable=AsyncMock, - ), \ - patch( - "microsoft_agents.testing.source_scenario._terminate_tree" - ): + with patch("shutil.which", side_effect=_fake_which), patch( + "subprocess.Popen", return_value=mock_process + ) as mock_popen, patch( + "microsoft_agents.testing.source_scenario.asyncio.sleep", + new_callable=AsyncMock, + ), patch( + "microsoft_agents.testing.source_scenario._terminate_tree" + ): async with scenario._run_script(): captured["cmd"] = mock_popen.call_args[0][0] @@ -110,15 +108,14 @@ async def test_script_cwd_is_agent_path(self, tmp_path): mock_process = _stub_process() - with patch("shutil.which", return_value="/usr/bin/pwsh"), \ - patch("subprocess.Popen", return_value=mock_process) as mock_popen, \ - patch( - "microsoft_agents.testing.source_scenario.asyncio.sleep", - new_callable=AsyncMock, - ), \ - patch( - "microsoft_agents.testing.source_scenario._terminate_tree" - ): + with patch("shutil.which", return_value="/usr/bin/pwsh"), patch( + "subprocess.Popen", return_value=mock_process + ) as mock_popen, patch( + "microsoft_agents.testing.source_scenario.asyncio.sleep", + new_callable=AsyncMock, + ), patch( + "microsoft_agents.testing.source_scenario._terminate_tree" + ): async with scenario._run_script(): pass @@ -154,20 +151,20 @@ def _capture_popen(*args, **kwargs): ) async with scenario._run_script(): - assert captured[-1].poll() is None, ( - f"Scenario {run_index} exited before the context began" - ) + assert ( + captured[-1].poll() is None + ), f"Scenario {run_index} exited before the context began" await asyncio.sleep(5) - assert captured[-1].poll() is None, ( - f"Scenario {run_index} exited on its own during the 5s window" - ) + assert ( + captured[-1].poll() is None + ), f"Scenario {run_index} exited on its own during the 5s window" deadline = time.monotonic() + 2.0 while time.monotonic() < deadline and captured[-1].poll() is None: await asyncio.sleep(0.05) - assert captured[-1].poll() is not None, ( - f"Scenario {run_index} subprocess still running after context exit" - ) + assert ( + captured[-1].poll() is not None + ), f"Scenario {run_index} subprocess still running after context exit" assert len(captured) == 2, "Expected exactly two scripts to be launched" diff --git a/dev/microsoft-agents-testing/tests/test_transcript_formatter.py b/dev/microsoft-agents-testing/tests/test_transcript_formatter.py index 3bf19b48..d26406ed 100644 --- a/dev/microsoft-agents-testing/tests/test_transcript_formatter.py +++ b/dev/microsoft-agents-testing/tests/test_transcript_formatter.py @@ -19,8 +19,10 @@ print_conversation, print_json, ) -from microsoft_agents.testing.formatting.utils import _exchange_sort_key, _format_timestamp - +from microsoft_agents.testing.formatting.utils import ( + _exchange_sort_key, + _format_timestamp, +) T0 = datetime(2026, 2, 6, 10, 0, 0, 0) T1 = T0 + timedelta(seconds=1, milliseconds=234) @@ -121,13 +123,18 @@ def test_strips_timezone_for_comparison(self): class TestFormatTimestamp: def test_formats_hours_minutes_seconds_millis(self): - assert _format_timestamp(datetime(2026, 2, 6, 14, 30, 45, 123456)) == "14:30:45.123" + assert ( + _format_timestamp(datetime(2026, 2, 6, 14, 30, 45, 123456)) + == "14:30:45.123" + ) def test_returns_placeholder_for_none(self): assert _format_timestamp(None) == "??:??.???" def test_truncates_microseconds_to_milliseconds(self): - assert _format_timestamp(datetime(2026, 1, 1, 0, 0, 0, 999999)) == "00:00:00.999" + assert ( + _format_timestamp(datetime(2026, 1, 1, 0, 0, 0, 999999)) == "00:00:00.999" + ) def test_midnight(self): assert _format_timestamp(datetime(2026, 1, 1, 0, 0, 0, 0)) == "00:00:00.000" @@ -176,8 +183,10 @@ def test_multiple_exchanges_sorted_by_request_at(self): def test_responses_included(self): ex = _make_exchange( - request=_user("Hi"), responses=[_agent("Hey")], - request_at=T0, response_at=T1 + request=_user("Hi"), + responses=[_agent("Hey")], + request_at=T0, + response_at=T1, ) data = json.loads(JsonTranscriptFormatter().format(_transcript(ex))) assert data[0]["responses"][0]["text"] == "Hey" @@ -190,7 +199,9 @@ def test_error_field_serialized(self): def test_model_dump_args_forwarded(self): ex = _make_exchange(request_at=T0) data = json.loads( - JsonTranscriptFormatter(model_dump_args={"exclude_none": True}).format(_transcript(ex)) + JsonTranscriptFormatter(model_dump_args={"exclude_none": True}).format( + _transcript(ex) + ) ) # With exclude_none, the null request field should be absent assert "request" not in data[0] @@ -225,7 +236,8 @@ def test_request_then_responses_in_order(self): ex = _make_exchange( request=_user("Hi"), responses=[_agent("Hey"), _agent("How are you?")], - request_at=T0, response_at=T1, + request_at=T0, + response_at=T1, ) data = json.loads(ActivityTranscriptFormatter().format(_transcript(ex))) assert [d["text"] for d in data] == ["Hi", "Hey", "How are you?"] @@ -289,7 +301,9 @@ def test_non_user_message_labeled_agent(self): def test_activity_without_from_property_labeled_agent(self): ex = Exchange( - request=Activity.model_validate({"type": ActivityTypes.message, "text": "No from"}), + request=Activity.model_validate( + {"type": ActivityTypes.message, "text": "No from"} + ), request_at=T0, responses=[], ) @@ -297,7 +311,9 @@ def test_activity_without_from_property_labeled_agent(self): assert "Agent: No from" in result def test_error_exchange_shows_error_marker(self): - ex = Exchange(request=_user("Hi"), request_at=T0, responses=[], error="Connection refused") + ex = Exchange( + request=_user("Hi"), request_at=T0, responses=[], error="Connection refused" + ) result = ConversationTranscriptFormatter().format(_transcript(ex)) assert "[X] Error: Connection refused" in result diff --git a/dev/microsoft-agents-testing/tests/utils/test_poll.py b/dev/microsoft-agents-testing/tests/utils/test_poll.py index 566bee59..f7ed050f 100644 --- a/dev/microsoft-agents-testing/tests/utils/test_poll.py +++ b/dev/microsoft-agents-testing/tests/utils/test_poll.py @@ -22,7 +22,9 @@ async def test_raises_for_negative_interval(self): @pytest.mark.asyncio async def test_raises_when_timeout_less_than_interval(self): """poll() raises ValueError when timeout is less than interval.""" - with pytest.raises(ValueError, match="Timeout must be greater than or equal to interval"): + with pytest.raises( + ValueError, match="Timeout must be greater than or equal to interval" + ): await poll(lambda: True, timeout=0.05, interval=0.5) @pytest.mark.asyncio diff --git a/dev/microsoft-agents-testing/tests/utils/test_pred.py b/dev/microsoft-agents-testing/tests/utils/test_pred.py index 15980718..7b6eefc5 100644 --- a/dev/microsoft-agents-testing/tests/utils/test_pred.py +++ b/dev/microsoft-agents-testing/tests/utils/test_pred.py @@ -158,9 +158,9 @@ def test_empty_dict_filter_can_be_combined_with_kwargs(self): ], ) - assert contains( - {}, content_type="application/vnd.microsoft.card.hero" - )(activity) + assert contains({}, content_type="application/vnd.microsoft.card.hero")( + activity + ) def test_requires_filter_or_keyword_criteria(self): """contains() rejects the unfiltered case instead of matching everything.""" @@ -286,9 +286,11 @@ def test_select_where_filters_by_nested_list_item_kwargs(self): ActivityLike(name="empty", attachments=[]), ] - selected = Select(activities).where( - contains(content_type="application/vnd.microsoft.card.hero") - ).get() + selected = ( + Select(activities) + .where(contains(content_type="application/vnd.microsoft.card.hero")) + .get() + ) assert [cast(ActivityLike, activity).name for activity in selected] == ["hero"] From be87cc34719cfdd1b7d73c568bdd41c2deb8ca81 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 21 Jul 2026 08:43:45 -0700 Subject: [PATCH 3/5] Fixing SelectBase.get signature --- .../microsoft_agents/testing/core/fluent/select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py index d49c1669..99e16a51 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py @@ -140,7 +140,7 @@ def sample(self, n: int) -> Self: ### TERMINAL OPERATIONS ### - def get(self) -> list[dict | BaseModel]: + def get(self) -> list[ModelT]: """Get the selected items as a list.""" return self._items From 670f99b309dd629454a09db056a13a135077a72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Tue, 21 Jul 2026 09:13:11 -0700 Subject: [PATCH 4/5] Potential fix for pull request finding 'CodeQL / Information exposure through an exception' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../testing/core/transport/aiohttp_callback_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py index 59377879..59b0eadd 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py @@ -110,7 +110,7 @@ async def _handle_request(self, request: Request) -> Response: raise e exchange = Exchange(error=str(e), response_at=response_at) - response = Response(status=500, text=str(e)) + response = Response(status=500, text="An internal error has occurred.") self._transcript.record(exchange) return response From c6cc6daec9891c7ffd0094b9e4dd70e5ee575f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Tue, 21 Jul 2026 09:13:46 -0700 Subject: [PATCH 5/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/testing/cli/core/utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py index 02dbdd1c..b3590faa 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py @@ -44,9 +44,7 @@ def _resolve_scenario( ) if agent_name_or_url: - # BUG: Only URLs starting with "https://" are detected as external - # endpoints. Plain "http://" URLs (e.g., http://localhost:3978/...) - # fall through to the registry lookup and will fail to resolve. + # Treat full URLs as external endpoints; otherwise resolve via the scenario registry. if agent_name_or_url.startswith("https://") or agent_name_or_url.startswith( "http://" ):