diff --git a/dev/README.md b/dev/README.md index e8c10764..e69de29b 100644 --- a/dev/README.md +++ b/dev/README.md @@ -1,6 +0,0 @@ -This directory contains tools to aid the developers of the Microsoft 365 Agents SDK for Python. - -### `benchmark` - -This folder contains benchmarking utilities built in Python to send concurrent requests -to an agent. \ No newline at end of file diff --git a/dev/benchmark/__init__.py b/dev/benchmark/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/benchmark/payload.json b/dev/benchmark/payload.json index 28ac2a8c..399023d6 100644 --- a/dev/benchmark/payload.json +++ b/dev/benchmark/payload.json @@ -15,5 +15,6 @@ "id": "user-id-0", "aadObjectId": "00000000-0000-0000-0000-0000000000020" }, - "type": "message" + "type": "message", + "text": "Hello, Bot!" } \ No newline at end of file diff --git a/dev/benchmark/src/main.py b/dev/benchmark/src/main.py index af9e177e..c27b49ca 100644 --- a/dev/benchmark/src/main.py +++ b/dev/benchmark/src/main.py @@ -1,6 +1,8 @@ -import json +import json, sys +from io import StringIO import logging from datetime import datetime, timezone +from contextlib import contextmanager import click @@ -8,25 +10,26 @@ from .executor import Executor, CoroutineExecutor, ThreadExecutor from .aggregated_results import AggregatedResults from .config import BenchmarkConfig +from .output import output_results LOG_FORMAT = "%(asctime)s: %(message)s" logging.basicConfig(format=LOG_FORMAT, level=logging.INFO, datefmt="%H:%M:%S") BenchmarkConfig.load_from_env() - @click.command() @click.option( "--payload_path", "-p", default="./payload.json", help="Path to the payload file." ) @click.option("--num_workers", "-n", default=1, help="Number of workers to use.") +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.") @click.option( "--async_mode", "-a", is_flag=True, help="Run coroutine workers rather than thread workers.", ) -def main(payload_path: str, num_workers: int, async_mode: bool): +def main(payload_path: str, num_workers: int, verbose: bool, async_mode: bool): """Main function to run the benchmark.""" with open(payload_path, "r", encoding="utf-8") as f: @@ -35,10 +38,12 @@ def main(payload_path: str, num_workers: int, async_mode: bool): func = create_payload_sender(payload) executor: Executor = CoroutineExecutor() if async_mode else ThreadExecutor() - + start_time = datetime.now(timezone.utc).timestamp() results = executor.run(func, num_workers=num_workers) end_time = datetime.now(timezone.utc).timestamp() + if verbose: + output_results(results) agg = AggregatedResults(results) agg.display(start_time, end_time) diff --git a/dev/benchmark/src/output.py b/dev/benchmark/src/output.py new file mode 100644 index 00000000..b7efa12d --- /dev/null +++ b/dev/benchmark/src/output.py @@ -0,0 +1,9 @@ +from .executor import ExecutionResult + +def output_results(results: list[ExecutionResult]) -> None: + """Output the results of the benchmark to the console.""" + + for result in results: + status = "Success" if result.success else "Failure" + print(f"Execution ID: {result.exe_id}, Duration: {result.duration:.4f} seconds, Status: {status}") + print(result.result) \ No newline at end of file diff --git a/dev/install.sh b/dev/install.sh new file mode 100644 index 00000000..512d1d88 --- /dev/null +++ b/dev/install.sh @@ -0,0 +1 @@ +pip install -e ./microsoft-agents-testing/ --config-settings editable_mode=compat \ No newline at end of file diff --git a/dev/microsoft-agents-testing/README.md b/dev/microsoft-agents-testing/README.md new file mode 100644 index 00000000..7ed3dd57 --- /dev/null +++ b/dev/microsoft-agents-testing/README.md @@ -0,0 +1 @@ +# Microsoft 365 Agents SDK for Python - Testing Framework diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py new file mode 100644 index 00000000..0de64c77 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py @@ -0,0 +1,36 @@ +from .sdk_config import SDKConfig + +from .auth import ( + generate_token, + generate_token_from_config +) + +from .utils import ( + populate_activity, + get_host_and_port +) + +from .integration import ( + Sample, + Environment, + ApplicationRunner, + AgentClient, + ResponseClient, + AiohttpEnvironment, + Integration +) + +__all__ = [ + "SDKConfig", + "generate_token", + "generate_token_from_config", + "Sample", + "Environment", + "ApplicationRunner", + "AgentClient", + "ResponseClient", + "AiohttpEnvironment", + "Integration", + "populate_activity", + "get_host_and_port" +] \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py new file mode 100644 index 00000000..a34ef21f --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py @@ -0,0 +1,6 @@ +from .generate_token import generate_token, generate_token_from_config + +__all__ = [ + "generate_token", + "generate_token_from_config" +] \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/auth/generate_token.py b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/generate_token.py new file mode 100644 index 00000000..029b05ab --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/generate_token.py @@ -0,0 +1,47 @@ +import requests + +from microsoft_agents.hosting.core import AgentAuthConfiguration +from microsoft_agents.testing.sdk_config import SDKConfig + +def generate_token(app_id: str, app_secret: str, tenant_id: str) -> str: + """Generate a token using the provided app credentials. + + :param app_id: Application (client) ID. + :param app_secret: Application client secret. + :param tenant_id: Directory (tenant) ID. + :return: Generated access token as a string. + """ + + authority_endpoint = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + + res = requests.post( + authority_endpoint, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + data={ + "grant_type": "client_credentials", + "client_id": app_id, + "client_secret": app_secret, + "scope": f"{app_id}/.default", + }, + timeout=10, + ) + return res.json().get("access_token") + +def generate_token_from_config(sdk_config: SDKConfig) -> str: + """Generates a token using a provided config object. + + :param config: Configuration dictionary containing connection settings. + :return: Generated access token as a string. + """ + + settings: AgentAuthConfiguration = sdk_config.get_connection() + + app_id = settings.CLIENT_ID + app_secret = settings.CLIENT_SECRET + tenant_id = settings.TENANT_ID + + if not app_id or not app_secret or not tenant_id: + raise ValueError("Incorrect configuration provided for token generation.") + return generate_token(app_id, app_secret, tenant_id) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/__init__.py new file mode 100644 index 00000000..a283062f --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/__init__.py @@ -0,0 +1,19 @@ +from .core import ( + AgentClient, + ApplicationRunner, + AiohttpEnvironment, + ResponseClient, + Environment, + Integration, + Sample, +) + +__all__ = [ + "AgentClient", + "ApplicationRunner", + "AiohttpEnvironment", + "ResponseClient", + "Environment", + "Integration", + "Sample", +] \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/__init__.py new file mode 100644 index 00000000..9c69a2ae --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/__init__.py @@ -0,0 +1,20 @@ +from .application_runner import ApplicationRunner +from .aiohttp import AiohttpEnvironment +from .client import ( + AgentClient, + ResponseClient, +) +from .environment import Environment +from .integration import Integration +from .sample import Sample + + +__all__ = [ + "AgentClient", + "ApplicationRunner", + "AiohttpEnvironment", + "ResponseClient", + "Environment", + "Integration", + "Sample", +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/__init__.py new file mode 100644 index 00000000..82d2d1d0 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/__init__.py @@ -0,0 +1,4 @@ +from .aiohttp_environment import AiohttpEnvironment +from .aiohttp_runner import AiohttpRunner + +__all__ = ["AiohttpEnvironment", "AiohttpRunner"] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_environment.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_environment.py new file mode 100644 index 00000000..c8256618 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_environment.py @@ -0,0 +1,58 @@ +from tkinter import E +from aiohttp.web import Request, Response, Application, run_app + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import ( + Authorization, + AgentApplication, + TurnState, + MemoryStorage, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.activity import load_configuration_from_env + +from ..application_runner import ApplicationRunner +from ..environment import Environment +from .aiohttp_runner import AiohttpRunner + + +class AiohttpEnvironment(Environment): + """An environment for aiohttp-hosted agents.""" + + async def init_env(self, environ_config: dict) -> None: + environ_config = environ_config or {} + + self.config = load_configuration_from_env(environ_config) + + self.storage = MemoryStorage() + self.connection_manager = MsalConnectionManager(**self.config) + self.adapter = CloudAdapter(connection_manager=self.connection_manager) + self.authorization = Authorization( + self.storage, self.connection_manager, **self.config + ) + + self.agent_application = AgentApplication[TurnState]( + storage=self.storage, + adapter=self.adapter, + authorization=self.authorization, + **self.config + ) + + def create_runner(self, host: str, port: int) -> ApplicationRunner: + + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + APP = Application(middlewares=[jwt_authorization_middleware]) + APP.router.add_post("/api/messages", entry_point) + APP["agent_configuration"] = self.connection_manager.get_default_connection_configuration() + APP["agent_app"] = self.agent_application + APP["adapter"] = self.adapter + + return AiohttpRunner(APP, host, port) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_runner.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_runner.py new file mode 100644 index 00000000..3b6780d4 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_runner.py @@ -0,0 +1,115 @@ +from typing import Optional +from typing import Optional +from threading import Thread, Event +import asyncio + +from aiohttp import ClientSession +from aiohttp.web import Application, Request, Response +from aiohttp.web_runner import AppRunner, TCPSite + +from ..application_runner import ApplicationRunner + + +class AiohttpRunner(ApplicationRunner): + """A runner for aiohttp applications.""" + + def __init__(self, app: Application, host: str = "localhost", port: int = 8000): + assert isinstance(app, Application) + super().__init__(app) + + url = f"{host}:{port}" + self._host = host + self._port = port + if "http" not in url: + url = f"http://{url}" + self._url = url + + self._app.router.add_get("/shutdown", self._shutdown_route) + + self._server_thread: Optional[Thread] = None + self._shutdown_event = Event() + self._runner: Optional[AppRunner] = None + self._site: Optional[TCPSite] = None + + @property + def url(self) -> str: + return self._url + + async def _start_server(self) -> None: + try: + assert isinstance(self._app, Application) + + self._runner = AppRunner(self._app) + await self._runner.setup() + self._site = TCPSite(self._runner, self._host, self._port) + await self._site.start() + + # Wait for shutdown signal + while not self._shutdown_event.is_set(): + await asyncio.sleep(0.1) + + # Cleanup + await self._site.stop() + await self._runner.cleanup() + + except Exception as error: + raise error + + async def __aenter__(self): + if self._server_thread: + raise RuntimeError("ResponseClient is already running.") + + self._shutdown_event.clear() + self._server_thread = Thread( + target=lambda: asyncio.run(self._start_server()), daemon=True + ) + self._server_thread.start() + + # Wait a moment to ensure the server starts + await asyncio.sleep(0.5) + + return self + + async def _stop_server(self): + if not self._server_thread: + raise RuntimeError("ResponseClient is not running.") + + try: + async with ClientSession() as session: + async with session.get( + f"http://{self._host}:{self._port}/shutdown" + ) as response: + pass # Just trigger the shutdown + except Exception: + pass # Ignore errors during shutdown request + + # Set shutdown event as fallback + self._shutdown_event.set() + + # Wait for the server thread to finish + self._server_thread.join(timeout=5.0) + self._server_thread = None + + async def _shutdown_route(self, request: Request) -> Response: + """Handle shutdown request by setting the shutdown event""" + self._shutdown_event.set() + return Response(status=200, text="Shutdown initiated") + + async def __aexit__(self, exc_type, exc, tb): + if not self._server_thread: + raise RuntimeError("ResponseClient is not running.") + try: + async with ClientSession() as session: + async with session.get( + f"http://{self._host}:{self._port}/shutdown" + ) as response: + pass # Just trigger the shutdown + except Exception: + pass # Ignore errors during shutdown request + + # Set shutdown event as fallback + self._shutdown_event.set() + + # Wait for the server thread to finish + self._server_thread.join(timeout=5.0) + self._server_thread = None diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/application_runner.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/application_runner.py new file mode 100644 index 00000000..ebbc56f9 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/application_runner.py @@ -0,0 +1,42 @@ +import asyncio +from abc import ABC, abstractmethod +from typing import Any, Optional +from threading import Thread + + +class ApplicationRunner(ABC): + """Base class for application runners.""" + + def __init__(self, app: Any): + self._app = app + self._thread: Optional[Thread] = None + + @abstractmethod + async def _start_server(self) -> None: + raise NotImplementedError( + "Start server method must be implemented by subclasses" + ) + + async def _stop_server(self) -> None: + pass + + async def __aenter__(self) -> None: + + if self._thread: + raise RuntimeError("Server is already running") + + def target(): + asyncio.run(self._start_server()) + + self._thread = Thread(target=target, daemon=True) + self._thread.start() + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + + if self._thread: + await self._stop_server() + + self._thread.join() + self._thread = None + else: + raise RuntimeError("Server is not running") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/__init__.py new file mode 100644 index 00000000..1d59411e --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/__init__.py @@ -0,0 +1,7 @@ +from .agent_client import AgentClient +from .response_client import ResponseClient + +__all__ = [ + "AgentClient", + "ResponseClient", +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/agent_client.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/agent_client.py new file mode 100644 index 00000000..7330bc98 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/agent_client.py @@ -0,0 +1,152 @@ +from email.policy import default +import os +import json +import asyncio +from typing import Optional, cast + +from aiohttp import ClientSession +from msal import ConfidentialClientApplication + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + DeliveryModes, + ChannelAccount, + ConversationAccount +) +from microsoft_agents.testing.utils import populate_activity + +_DEFAULT_ACTIVITY_VALUES = { + "service_url": "http://localhost", + "channel_id": "test_channel", + "from_property": ChannelAccount(id="sender"), + "recipient": ChannelAccount(id="recipient"), + "locale": "en-US", +} + +class AgentClient: + + def __init__( + self, + agent_url: str, + cid: str, + client_id: str, + tenant_id: str, + client_secret: str, + service_url: Optional[str] = None, + default_timeout: float = 5.0, + default_activity_data: Optional[Activity | dict] = None, + ): + self._agent_url = agent_url + self._cid = cid + self._client_id = client_id + self._tenant_id = tenant_id + self._client_secret = client_secret + self._service_url = service_url + self._headers = None + self._default_timeout = default_timeout + + self._client: Optional[ClientSession] = None + + self._default_activity_data: Activity | dict = default_activity_data or _DEFAULT_ACTIVITY_VALUES + + @property + def agent_url(self) -> str: + return self._agent_url + + @property + def service_url(self) -> Optional[str]: + return self._service_url + + async def get_access_token(self) -> str: + + msal_app = ConfidentialClientApplication( + client_id=self._client_id, + client_credential=self._client_secret, + authority=f"https://login.microsoftonline.com/{self._tenant_id}", + ) + + res = msal_app.acquire_token_for_client(scopes=[f"{self._client_id}/.default"]) + token = res.get("access_token") if res else None + if not token: + raise Exception("Could not obtain access token") + return token + + async def _init_client(self) -> None: + if not self._client: + if self._client_secret: + token = await self.get_access_token() + self._headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + else: + self._headers = {"Content-Type": "application/json"} + + self._client = ClientSession( + base_url=self._agent_url, headers=self._headers + ) + + async def send_request(self, activity: Activity, sleep: float = 0) -> str: + + await self._init_client() + assert self._client + + if activity.conversation: + activity.conversation.id = self._cid + else: + activity.conversation = ConversationAccount(id=self._cid or "") + + if self.service_url: + activity.service_url = self.service_url + + activity = populate_activity(activity, self._default_activity_data) + + async with self._client.post( + "api/messages", + headers=self._headers, + json=activity.model_dump(by_alias=True, exclude_unset=True, exclude_none=True, mode="json"), + ) as response: + content = await response.text() + if not response.ok: + raise Exception(f"Failed to send activity: {response.status}") + await asyncio.sleep(sleep) + return content + + def _to_activity(self, activity_or_text: Activity | str) -> Activity: + if isinstance(activity_or_text, str): + activity = Activity( + type=ActivityTypes.message, + text=activity_or_text, + ) + return activity + else: + return cast(Activity, activity_or_text) + + async def send_activity( + self, activity_or_text: Activity | str, sleep: float = 0, timeout: Optional[float] = None + ) -> str: + timeout = timeout or self._default_timeout + activity = self._to_activity(activity_or_text) + content = await self.send_request(activity, sleep=sleep) + return content + + async def send_expect_replies( + self, activity_or_text: Activity | str, sleep: float = 0, timeout: Optional[float] = None + ) -> list[Activity]: + timeout = timeout or self._default_timeout + activity = self._to_activity(activity_or_text) + activity.delivery_mode = DeliveryModes.expect_replies + activity.service_url = activity.service_url or "http://localhost" # temporary fix + + content = await self.send_request(activity, sleep=sleep) + + activities_data = json.loads(content).get("activities", []) + activities = [Activity.model_validate(act) for act in activities_data] + + return activities + + async def close(self) -> None: + if self._client: + await self._client.close() + self._client = None diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/auto_client.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/auto_client.py new file mode 100644 index 00000000..dcea531b --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/auto_client.py @@ -0,0 +1,18 @@ +# from microsoft_agents.activity import Activity + +# from ..agent_client import AgentClient + +# class AutoClient: + +# def __init__(self, agent_client: AgentClient): +# self._agent_client = agent_client + +# async def generate_message(self) -> str: +# pass + +# async def run(self, max_turns: int = 10, time_between_turns: float = 2.0) -> None: + +# for i in range(max_turns): +# await self._agent_client.send_activity( +# Activity(type="message", text=self.generate_message()) +# ) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/response_client.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/response_client.py new file mode 100644 index 00000000..d93bfb80 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/response_client.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import sys +from io import StringIO +from typing import Optional +from threading import Lock, Thread, Event +import asyncio + +from aiohttp.web import Application, Request, Response + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, +) + +from ..aiohttp import AiohttpRunner + + +class ResponseClient: + + def __init__( + self, + host: str = "localhost", + port: int = 9873, + ): + self._app: Application = Application() + self._prev_stdout = None + service_endpoint = f"{host}:{port}" + self._host = host + self._port = port + if "http" not in service_endpoint: + service_endpoint = f"http://{service_endpoint}" + self._service_endpoint = service_endpoint + self._activities_list = [] + self._activities_list_lock = Lock() + + self._app.router.add_post( + "/v3/conversations/{path:.*}", self._handle_conversation + ) + + self._app_runner = AiohttpRunner(self._app, host, port) + + @property + def service_endpoint(self) -> str: + return self._service_endpoint + + async def __aenter__(self) -> ResponseClient: + self._prev_stdout = sys.stdout + sys.stdout = StringIO() + + await self._app_runner.__aenter__() + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + if self._prev_stdout is not None: + sys.stdout = self._prev_stdout + + await self._app_runner.__aexit__(exc_type, exc_val, exc_tb) + + async def _handle_conversation(self, request: Request) -> Response: + try: + data = await request.json() + activity = Activity.model_validate(data) + + conversation_id = ( + activity.conversation.id if activity.conversation else None + ) + + with self._activities_list_lock: + self._activities_list.append(activity) + + if any(map(lambda x: x.type == "streaminfo", activity.entities or [])): + await self._handle_streamed_activity(activity) + return Response(status=200, text="Stream info handled") + else: + if activity.type != ActivityTypes.typing: + await asyncio.sleep(0.1) # Simulate processing delay + return Response(status=200, text="Activity received") + except Exception as e: + return Response(status=500, text=str(e)) + + async def _handle_streamed_activity( + self, activity: Activity, *args, **kwargs + ) -> bool: + raise NotImplementedError("_handle_streamed_activity is not implemented yet.") + + async def pop(self) -> list[Activity]: + with self._activities_list_lock: + activities = self._activities_list[:] + self._activities_list.clear() + return activities diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/environment.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/environment.py new file mode 100644 index 00000000..2c9b1ae1 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/environment.py @@ -0,0 +1,37 @@ +from abc import ABC, abstractmethod +from typing import Awaitable, Callable + +from microsoft_agents.hosting.core import ( + AgentApplication, + ChannelAdapter, + Connections, + Authorization, + Storage, + TurnState, +) + +from .application_runner import ApplicationRunner + + +class Environment(ABC): + """A sample data object for integration tests.""" + + agent_application: AgentApplication[TurnState] + storage: Storage + adapter: ChannelAdapter + connection_manager: Connections + authorization: Authorization + + config: dict + + driver: Callable[[], Awaitable[None]] + + @abstractmethod + async def init_env(self, environ_config: dict) -> None: + """Initialize the environment.""" + raise NotImplementedError() + + @abstractmethod + def create_runner(self) -> ApplicationRunner: + """Create an application runner for the environment.""" + raise NotImplementedError() diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/integration.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/integration.py new file mode 100644 index 00000000..c3105c74 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/integration.py @@ -0,0 +1,113 @@ +import pytest + +import os +from typing import ( + Optional, + TypeVar, + Any, + AsyncGenerator, +) + +import aiohttp.web +from dotenv import load_dotenv + +from microsoft_agents.testing.utils import get_host_and_port +from .environment import Environment +from .client import AgentClient, ResponseClient +from .sample import Sample + +T = TypeVar("T", bound=type) +AppT = TypeVar("AppT", bound=aiohttp.web.Application) # for future extension w/ Union + + +class Integration: + """Provides integration test fixtures.""" + + _sample_cls: Optional[type[Sample]] = None + _environment_cls: Optional[type[Environment]] = None + + _config: dict[str, Any] = {} + + _service_url: Optional[str] = None + _agent_url: Optional[str] = None + _cid: Optional[str] = None + _client_id: Optional[str] = None + _tenant_id: Optional[str] = None + _client_secret: Optional[str] = None + + _environment: Environment + _sample: Sample + _agent_client: AgentClient + _response_client: ResponseClient + + @property + def service_url(self) -> str: + return self._service_url or self._config.get("service_url", "") + + @property + def agent_url(self) -> str: + return self._agent_url or self._config.get("agent_url", "") + + @pytest.fixture + async def environment(self): + """Provides the test environment instance.""" + if self._environment_cls: + assert self._sample_cls + environment = self._environment_cls() + await environment.init_env(await self._sample_cls.get_config()) + yield environment + else: + yield None + + @pytest.fixture + async def sample(self, environment): + """Provides the sample instance.""" + if environment: + assert self._sample_cls + sample = self._sample_cls(environment) + await sample.init_app() + # host, port = get_host_and_port(self.agent_url) + app_runner = environment.create_runner(sample.app) + async with app_runner: + yield sample + else: + yield None + + def create_agent_client(self) -> AgentClient: + if not self._config: + self._config = {} + + load_dotenv("./src/tests/.env") + self._config.update( + { + "client_id": os.getenv("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", ""), + "tenant_id": os.getenv("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID", ""), + "client_secret": os.getenv("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET", ""), + } + ) + + agent_client = AgentClient( + agent_url=self.agent_url, + cid=self._cid or self._config.get("cid", ""), + client_id=self._client_id or self._config.get("client_id", ""), + tenant_id=self._tenant_id or self._config.get("tenant_id", ""), + client_secret=self._client_secret or self._config.get("client_secret", ""), + ) + return agent_client + + @pytest.fixture + async def agent_client(self, sample, environment) -> AsyncGenerator[AgentClient, None]: + agent_client = self.create_agent_client() + yield agent_client + await agent_client.close() + + async def _create_response_client(self) -> ResponseClient: + host, port = get_host_and_port(self.service_url) + assert host and port + return ResponseClient(host=host, port=port) + + @pytest.fixture + async def response_client(self) -> AsyncGenerator[ResponseClient, None]: + """Provides the response client instance.""" + async with await self._create_response_client() as response_client: + yield response_client \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/sample.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/sample.py new file mode 100644 index 00000000..6dde3668 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/sample.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod + +from .environment import Environment + + +class Sample(ABC): + """Base class for all samples.""" + + def __init__(self, environment: Environment, **kwargs): + self.env = environment + + @classmethod + async def get_config(cls) -> dict: + """Retrieve the configuration for the sample.""" + return {} + + @abstractmethod + async def init_app(self): + """Initialize the application for the sample.""" diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/sdk_config.py b/dev/microsoft-agents-testing/microsoft_agents/testing/sdk_config.py new file mode 100644 index 00000000..6352b42c --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/sdk_config.py @@ -0,0 +1,39 @@ +import os +from copy import deepcopy +from dotenv import load_dotenv, dotenv_values +from typing import Optional + +from microsoft_agents.activity import ( + load_configuration_from_env +) +from microsoft_agents.hosting.core import ( + AgentAuthConfiguration +) + +class SDKConfig: + """Loads and provides access to SDK configuration from a .env file or environment variables. + + Immutable access to the configuration dictionary is provided via the `config` property. + """ + + def __init__(self, env_path: Optional[str] = None, load_into_environment: bool = False): + """Initializes the SDKConfig by loading configuration from a .env file or environment variables. + + :param env_path: Optional path to the .env file. If None, defaults to '.env' in the current directory. + :param load_into_environment: If True, loads the .env file directly into the configuration dictionary. + """ + if load_into_environment: + self._config = load_configuration_from_env(dotenv_values(env_path)) # Load .env file + else: + load_dotenv(env_path) # Load .env file into environment variables + self._config = load_configuration_from_env(os.environ) # Load from environment variables + + @property + def config(self) -> dict: + """Returns the loaded configuration dictionary.""" + return deepcopy(self._config) + + def get_connection(self, connection_name: str = "SERVICE_CONNECTION") -> AgentAuthConfiguration: + """Creates an AgentAuthConfiguration from a provided config object.""" + data = self._config["CONNECTIONS"][connection_name]["SETTINGS"] + return AgentAuthConfiguration(**data) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/__init__.py new file mode 100644 index 00000000..c8c26208 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/__init__.py @@ -0,0 +1,7 @@ +from .populate_activity import populate_activity +from .urls import get_host_and_port + +__all__ = [ + "populate_activity", + "get_host_and_port", +] \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate_activity.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate_activity.py new file mode 100644 index 00000000..5ba73ec6 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate_activity.py @@ -0,0 +1,45 @@ +from microsoft_agents.activity import Activity + +def populate_activity( + original: Activity, + defaults: Activity | dict +) -> Activity: + + if isinstance(defaults, Activity): + defaults = Activity.model_dump(exclude_unset=True) + + new_activity = original.model_copy() + + for key in defaults.keys(): + if getattr(new_activity, key) is None: + setattr(new_activity, key, defaults[key]) + + return new_activity + + +# def _populate_incoming_activity(activity: Activity) -> Activity: + +# activity = activity.model_copy() + +# if not activity.locale: +# activity.locale = "en-US" + +# if not activity.channel_id: +# activity.channel_id = "emulator" + +# if not activity.delivery_mode: +# activity.delivery_mode = DeliveryModes.normal + +# if not activity.service_url: +# activity.service_url = "http://localhost" + +# if not activity.recipient: +# activity.recipient = ChannelAccount(id="agent", name="Agent") + +# if not activity.from_property: +# activity.from_property = ChannelAccount(id="user", name="User") + +# if not activity.conversation: +# activity.conversation = ConversationAccount(id="conversation1") + +# return activity \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/urls.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/urls.py new file mode 100644 index 00000000..df452416 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/urls.py @@ -0,0 +1,9 @@ +from urllib.parse import urlparse + +def get_host_and_port(url: str) -> tuple[str, int]: + """Extract host and port from a URL.""" + + parsed_url = urlparse(url) + host = parsed_url.hostname or "localhost" + port = parsed_url.port or (443 if parsed_url.scheme == "https" else 80) + return host, port \ No newline at end of file diff --git a/dev/microsoft-agents-testing/pyproject.toml b/dev/microsoft-agents-testing/pyproject.toml new file mode 100644 index 00000000..c231347b --- /dev/null +++ b/dev/microsoft-agents-testing/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-hosting-core" +dynamic = ["version", "dependencies"] +description = "Core library for Microsoft Agents" +readme = {file = "readme.md", content-type = "text/markdown"} +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/microsoft/Agents" diff --git a/dev/microsoft-agents-testing/setup.py b/dev/microsoft-agents-testing/setup.py new file mode 100644 index 00000000..02fb3e84 --- /dev/null +++ b/dev/microsoft-agents-testing/setup.py @@ -0,0 +1,18 @@ +from os import environ +from setuptools import setup + +package_version = environ.get("PackageVersion", "0.0.0") + +setup( + version=package_version, + install_requires=[ + "microsoft-agents-activity", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-hosting-aiohttp", + "pyjwt>=2.10.1", + "isodate>=0.6.1", + "azure-core>=1.30.0", + "python-dotenv>=1.1.1", + ], +) diff --git a/dev/microsoft-agents-testing/tests/__init__.py b/dev/microsoft-agents-testing/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/integration/__init__.py b/dev/microsoft-agents-testing/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/integration/core/__init__.py b/dev/microsoft-agents-testing/tests/integration/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/integration/core/_common.py b/dev/microsoft-agents-testing/tests/integration/core/_common.py new file mode 100644 index 00000000..cd22114a --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/_common.py @@ -0,0 +1,15 @@ +from microsoft_agents.testing import ApplicationRunner + + +class SimpleRunner(ApplicationRunner): + async def _start_server(self) -> None: + self._app["running"] = True + + @property + def app(self): + return self._app + + +class OtherSimpleRunner(SimpleRunner): + async def _stop_server(self) -> None: + self._app["running"] = False diff --git a/dev/microsoft-agents-testing/tests/integration/core/client/__init__.py b/dev/microsoft-agents-testing/tests/integration/core/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/integration/core/client/_common.py b/dev/microsoft-agents-testing/tests/integration/core/client/_common.py new file mode 100644 index 00000000..00b4291f --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/client/_common.py @@ -0,0 +1,10 @@ +class DEFAULTS: + + host = "localhost" + response_port = 9873 + agent_url = f"http://{host}:8000/" + service_url = f"http://{host}:{response_port}" + cid = "test-cid" + client_id = "test-client-id" + tenant_id = "test-tenant-id" + client_secret = "test-client-secret" diff --git a/dev/microsoft-agents-testing/tests/integration/core/client/test_agent_client.py b/dev/microsoft-agents-testing/tests/integration/core/client/test_agent_client.py new file mode 100644 index 00000000..1a8055dc --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/client/test_agent_client.py @@ -0,0 +1,86 @@ +import json +from contextlib import contextmanager +import re + +import pytest +from aioresponses import aioresponses +from msal import ConfidentialClientApplication + +from microsoft_agents.activity import Activity +from microsoft_agents.testing import AgentClient + +from ._common import DEFAULTS + + +class TestAgentClient: + + @pytest.fixture + async def agent_client(self): + client = AgentClient( + agent_url=DEFAULTS.agent_url, + cid=DEFAULTS.cid, + client_id=DEFAULTS.client_id, + tenant_id=DEFAULTS.tenant_id, + client_secret=DEFAULTS.client_secret, + service_url=DEFAULTS.service_url, + ) + yield client + await client.close() + + @pytest.fixture + def aioresponses_mock(self): + with aioresponses() as mocked: + yield mocked + + @pytest.mark.asyncio + async def test_send_activity(self, mocker, agent_client, aioresponses_mock): + mocker.patch.object( + AgentClient, "get_access_token", return_value="mocked_token" + ) + mocker.patch.object( + ConfidentialClientApplication, + "__new__", + return_value=mocker.Mock(spec=ConfidentialClientApplication), + ) + + assert agent_client.agent_url + aioresponses_mock.post( + f"{agent_client.agent_url}api/messages", + payload={"response": "Response from service"}, + ) + + response = await agent_client.send_activity("Hello, World!") + data = json.loads(response) + assert data == {"response": "Response from service"} + + @pytest.mark.asyncio + async def test_send_expect_replies(self, mocker, agent_client, aioresponses_mock): + mocker.patch.object( + AgentClient, "get_access_token", return_value="mocked_token" + ) + mocker.patch.object( + ConfidentialClientApplication, + "__new__", + return_value=mocker.Mock(spec=ConfidentialClientApplication), + ) + + assert agent_client.agent_url + activities = [ + Activity(type="message", text="Response from service"), + Activity(type="message", text="Another response"), + ] + aioresponses_mock.post( + agent_client.agent_url + "api/messages", + payload={ + "activities": [ + activity.model_dump(by_alias=True, exclude_none=True) + for activity in activities + ], + }, + ) + + replies = await agent_client.send_expect_replies("Hello, World!") + assert len(replies) == 2 + assert replies[0].text == "Response from service" + assert replies[1].text == "Another response" + assert replies[0].type == replies[1].type == "message" diff --git a/dev/microsoft-agents-testing/tests/integration/core/client/test_response_client.py b/dev/microsoft-agents-testing/tests/integration/core/client/test_response_client.py new file mode 100644 index 00000000..f5d1ed6d --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/client/test_response_client.py @@ -0,0 +1,45 @@ +import pytest +import asyncio +from aiohttp import ClientSession + +from microsoft_agents.activity import Activity +from microsoft_agents.testing import ResponseClient + +from ._common import DEFAULTS + + +class TestResponseClient: + + @pytest.fixture + async def response_client(self): + async with ResponseClient( + host=DEFAULTS.host, port=DEFAULTS.response_port + ) as client: + yield client + + @pytest.mark.asyncio + async def test_init(self, response_client): + assert response_client.service_endpoint == DEFAULTS.service_url + + @pytest.mark.asyncio + async def test_endpoint(self, response_client): + + activity = Activity(type="message", text="Hello, World!") + + async with ClientSession() as session: + async with session.post( + f"{response_client.service_endpoint}/v3/conversations/test-conv", + json=activity.model_dump(by_alias=True, exclude_none=True), + ) as resp: + assert resp.status == 200 + text = await resp.text() + assert text == "Activity received" + + await asyncio.sleep(0.1) # Give some time for the server to process + + activities = await response_client.pop() + assert len(activities) == 1 + assert activities[0].type == "message" + assert activities[0].text == "Hello, World!" + + assert (await response_client.pop()) == [] diff --git a/dev/microsoft-agents-testing/tests/integration/core/test_application_runner.py b/dev/microsoft-agents-testing/tests/integration/core/test_application_runner.py new file mode 100644 index 00000000..65090594 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/test_application_runner.py @@ -0,0 +1,39 @@ +import pytest +from time import sleep + +from ._common import SimpleRunner, OtherSimpleRunner + +class TestApplicationRunner: + + @pytest.mark.asyncio + async def test_simple_runner(self): + + app = {} + runner = SimpleRunner(app) + async with runner: + sleep(0.1) + assert app["running"] is True + + assert app["running"] is True + + @pytest.mark.asyncio + async def test_other_simple_runner(self): + + app = {} + runner = OtherSimpleRunner(app) + async with runner: + sleep(0.1) + assert app["running"] is True + + assert app["running"] is False + + @pytest.mark.asyncio + async def test_double_start(self): + + app = {} + runner = SimpleRunner(app) + async with runner: + sleep(0.1) + with pytest.raises(RuntimeError): + async with runner: + pass diff --git a/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_sample.py b/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_sample.py new file mode 100644 index 00000000..b7019573 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_sample.py @@ -0,0 +1,61 @@ +import pytest +import asyncio +from copy import copy + +from microsoft_agents.testing import ( + ApplicationRunner, + Environment, + Integration, + Sample +) + +from ._common import SimpleRunner + + +class SimpleEnvironment(Environment): + """A simple implementation of the Environment for testing.""" + + async def init_env(self, environ_config: dict) -> None: + self.config = environ_config + # Initialize other components as needed + + def create_runner(self, *args) -> ApplicationRunner: + return SimpleRunner(copy(self.config)) + + +class SimpleSample(Sample): + """A simple implementation of the Sample for testing.""" + + def __init__(self, environment: Environment, **kwargs): + super().__init__(environment, **kwargs) + self.data = kwargs.get("data", "default_data") + self.other_data = None + + @classmethod + async def get_config(cls) -> dict: + return {"sample_key": "sample_value"} + + async def init_app(self): + await asyncio.sleep(0.1) # Simulate some initialization delay + self.other_data = len(self.env.config) + + @property + def app(self) -> None: + return None + +class TestIntegrationFromSample(Integration): + _sample_cls = SimpleSample + _environment_cls = SimpleEnvironment + + @pytest.mark.asyncio + async def test_sample_integration(self, sample, environment): + """Test the integration of SimpleSample with SimpleEnvironment.""" + + assert environment.config == {"sample_key": "sample_value"} + + assert sample.env is environment + assert sample.data == "default_data" + assert sample.other_data == 1 + + runner = environment.create_runner() + assert runner.app == {"sample_key": "sample_value"} diff --git a/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_service_url.py b/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_service_url.py new file mode 100644 index 00000000..4ff707cc --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_service_url.py @@ -0,0 +1,49 @@ +import pytest +import asyncio +import requests +from copy import copy +from aioresponses import aioresponses, CallbackResult + +from microsoft_agents.testing import Integration + +class TestIntegrationFromURL(Integration): + _agent_url = "http://localhost:8000/" + _service_url = "http://localhost:8001/" + + @pytest.mark.asyncio + async def test_service_url_integration(self, agent_client): + """Test the integration using a service URL.""" + + with aioresponses() as mocked: + + mocked.post(f"{self.agent_url}api/messages", status=200, body="Service response") + + res = await agent_client.send_activity("Hello, service!") + assert res == "Service response" + + @pytest.mark.asyncio + async def test_service_url_integration_with_response_side_effect( + self, agent_client, response_client + ): + """Test the integration using a service URL.""" + + with aioresponses() as mocked: + + def callback(url, **kwargs): + a = requests.post( + f"{self.service_url}/v3/conversations/test-conv", + json=kwargs.get("json"), + ) + return CallbackResult(status=200, body="Service response") + + mocked.post(f"{self.agent_url}api/messages", callback=callback) + + res = await agent_client.send_activity("Hello, service!") + assert res == "Service response" + + await asyncio.sleep(1) + + activities = await response_client.pop() + assert len(activities) == 1 + assert activities[0].type == "message" + assert activities[0].text == "Hello, service!" diff --git a/dev/microsoft-agents-testing/tests/manual_test/__init__.py b/dev/microsoft-agents-testing/tests/manual_test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/manual_test/env.TEMPLATE b/dev/microsoft-agents-testing/tests/manual_test/env.TEMPLATE new file mode 100644 index 00000000..01dccc7c --- /dev/null +++ b/dev/microsoft-agents-testing/tests/manual_test/env.TEMPLATE @@ -0,0 +1,3 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/manual_test/main.py b/dev/microsoft-agents-testing/tests/manual_test/main.py new file mode 100644 index 00000000..3246c51d --- /dev/null +++ b/dev/microsoft-agents-testing/tests/manual_test/main.py @@ -0,0 +1,46 @@ +import os +import asyncio + +from microsoft_agents.testing import ( + AiohttpEnvironment, + AgentClient, +) +from ..samples import QuickstartSample + +from dotenv import load_dotenv + +async def main(): + + env = AiohttpEnvironment() + await env.init_env(await QuickstartSample.get_config()) + sample = QuickstartSample(env) + await sample.init_app() + + host, port = "localhost", 3978 + + load_dotenv("./src/tests/.env") + config = { + "client_id": os.getenv("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", ""), + "tenant_id": os.getenv("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID", ""), + "client_secret": os.getenv("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET", ""), + } + + client = AgentClient( + agent_url="http://localhost:3978/", + cid=config.get("cid", ""), + client_id=config.get("client_id", ""), + tenant_id=config.get("tenant_id", ""), + client_secret=config.get("client_secret", ""), + ) + + async with env.create_runner(host, port): + print(f"Server running at http://{host}:{port}/api/messages") + await asyncio.sleep(1) + res = await client.send_expect_replies("Hello, Agent!") + print("\nReply from agent:") + print(res) + + await client.close() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/samples/__init__.py b/dev/microsoft-agents-testing/tests/samples/__init__.py new file mode 100644 index 00000000..a77ee72e --- /dev/null +++ b/dev/microsoft-agents-testing/tests/samples/__init__.py @@ -0,0 +1,3 @@ +from .quickstart_sample import QuickstartSample + +__all__ = ["QuickstartSample"] diff --git a/dev/microsoft-agents-testing/tests/samples/quickstart_sample.py b/dev/microsoft-agents-testing/tests/samples/quickstart_sample.py new file mode 100644 index 00000000..04da32c1 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/samples/quickstart_sample.py @@ -0,0 +1,57 @@ +import re +import os +import sys +import traceback + +from dotenv import load_dotenv + +from microsoft_agents.activity import ConversationUpdateTypes +from microsoft_agents.hosting.core import AgentApplication, TurnContext, TurnState +from microsoft_agents.testing.integration.core import Sample + +class QuickstartSample(Sample): + """A quickstart sample implementation.""" + + @classmethod + async def get_config(cls) -> dict: + """Retrieve the configuration for the sample.""" + + load_dotenv("./src/tests/.env") + + + return { + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID": os.getenv("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID"), + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET": os.getenv("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET"), + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID": os.getenv("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID"), + } + + async def init_app(self): + """Initialize the application for the quickstart sample.""" + + app: AgentApplication[TurnState] = self.env.agent_application + + @app.conversation_update(ConversationUpdateTypes.MEMBERS_ADDED) + async def on_members_added(context: TurnContext, state: TurnState) -> None: + await context.send_activity( + "Welcome to the empty agent! " + "This agent is designed to be a starting point for your own agent development." + ) + + @app.message(re.compile(r"^hello$")) + async def on_hello(context: TurnContext, state: TurnState) -> None: + await context.send_activity("Hello!") + + @app.activity("message") + async def on_message(context: TurnContext, state: TurnState) -> None: + await context.send_activity(f"you said: {context.activity.text}") + + @app.error + async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.")