Skip to content
Draft
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
cfb8847
basic integration utility
rodrigobr-msft Oct 21, 2025
4d93a46
Integration test suite factory implementation
rodrigobr-msft Oct 23, 2025
be2fb1c
Implementing core models for integration testing
rodrigobr-msft Oct 23, 2025
0d5539a
AutoClient mockup
rodrigobr-msft Oct 23, 2025
bf4c006
Adding runner starter code
rodrigobr-msft Oct 23, 2025
ebe622d
Adding foundational classes
rodrigobr-msft Oct 25, 2025
45678e9
Drafting AgentClient and ResponseClient implementations
rodrigobr-msft Oct 27, 2025
c2b84cb
Spec test
rodrigobr-msft Oct 27, 2025
2816e68
Filling in more implementation details
rodrigobr-msft Oct 27, 2025
2214827
More files
rodrigobr-msft Oct 27, 2025
c90c8e4
Cleaning up implementations
rodrigobr-msft Oct 29, 2025
36f5c3b
Adding expect replies sending method
rodrigobr-msft Oct 29, 2025
dfdd959
Beginning unit tests
rodrigobr-msft Oct 30, 2025
d4828fb
Adding integration decor from sample test cases
rodrigobr-msft Oct 30, 2025
91b3c44
Integration from service url tests
rodrigobr-msft Oct 31, 2025
1eefe44
_handle_conversation implementation for response_client
rodrigobr-msft Oct 31, 2025
a06da9a
AgentClient tests completed
rodrigobr-msft Oct 31, 2025
d36ec7a
Creating response client tests
rodrigobr-msft Nov 3, 2025
c096048
Hosting server for response client
rodrigobr-msft Nov 3, 2025
5451ddf
Response client tests completed
rodrigobr-msft Nov 3, 2025
c2cdd40
Beginning refactor of aiohttp runner
rodrigobr-msft Nov 3, 2025
7d91ef9
Unit test updates
rodrigobr-msft Nov 3, 2025
87610a5
Fixing issues in refactor
rodrigobr-msft Nov 3, 2025
ddfdd0b
Fixed TestIntegrationFromSample unit test
rodrigobr-msft Nov 3, 2025
dd0a87d
Another commit
rodrigobr-msft Nov 3, 2025
6a4e8ef
Reorganizing files
rodrigobr-msft Nov 3, 2025
7793790
Completed TestIntegrationFromServiceUrl unit tests
rodrigobr-msft Nov 3, 2025
3ebbd44
Reformatting with black
rodrigobr-msft Nov 3, 2025
a6681bf
Quickstart integration test beginning
rodrigobr-msft Nov 3, 2025
1a7264b
Draft of quickstart sample setup
rodrigobr-msft Nov 3, 2025
9ba4a43
Environment config connection
rodrigobr-msft Nov 3, 2025
fab4368
Renaming messaging endpoint to agent url
rodrigobr-msft Nov 4, 2025
a111155
Removing unnecessary files
rodrigobr-msft Nov 4, 2025
a2a1092
Fixing agent client payload sending
rodrigobr-msft Nov 4, 2025
ae8a286
First successful integration test
rodrigobr-msft Nov 4, 2025
9a5a6a8
Beginning foundational test cases
rodrigobr-msft Nov 4, 2025
aad011a
TypingIndicator test
rodrigobr-msft Nov 4, 2025
be0032a
Adding more test cases
rodrigobr-msft Nov 4, 2025
e3f4324
More foundational integration test cases
rodrigobr-msft Nov 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dev/integration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Microsoft 365 Agents SDK for Python Integration Testing Framework

1 change: 1 addition & 0 deletions dev/integration/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
aioresponses
Empty file.
21 changes: 21 additions & 0 deletions dev/integration/src/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from .application_runner import ApplicationRunner
from .aiohttp import AiohttpEnvironment
from .client import (
AgentClient,
ResponseClient,
)
from .environment import Environment
from .integration import integration, IntegrationFixtures
from .sample import Sample


__all__ = [
"AgentClient",
"ApplicationRunner",
"AiohttpEnvironment",
"ResponseClient",
"Environment",
"integration",
"IntegrationFixtures",
"Sample",
]
4 changes: 4 additions & 0 deletions dev/integration/src/core/aiohttp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .aiohttp_environment import AiohttpEnvironment
from .aiohttp_runner import AiohttpRunner

__all__ = ["AiohttpEnvironment", "AiohttpRunner"]
58 changes: 58 additions & 0 deletions dev/integration/src/core/aiohttp/aiohttp_environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from tkinter import E
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

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

Unused import of 'E' from 'tkinter'. This appears to be a leftover import that should be removed as tkinter is not used in this file.

Suggested change
from tkinter import E

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

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

Unused import E from the tkinter module should be removed. This import is not used anywhere in the code and tkinter is unrelated to the aiohttp environment implementation.

Suggested change
from tkinter import E

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Import of 'E' is not used.

Suggested change
from tkinter import E

Copilot uses AI. Check for mistakes.
from aiohttp.web import Request, Response, Application, run_app
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Import of 'run_app' is not used.

Suggested change
from aiohttp.web import Request, Response, Application, run_app
from aiohttp.web import Request, Response, Application

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Unused import run_app. This import is not used in the file and should be removed.

Suggested change
from aiohttp.web import Request, Response, Application, run_app
from aiohttp.web import Request, Response, Application

Copilot uses AI. Check for mistakes.

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:
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

This method requires 3 positional arguments, whereas overridden Environment.create_runner requires 1.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

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

This method requires 3 positional arguments, whereas overridden Environment.create_runner requires 1.

Suggested change
def create_runner(self, host: str, port: int) -> ApplicationRunner:
def create_runner(self, runner_config: dict) -> ApplicationRunner:
host = runner_config.get("host", "127.0.0.1")
port = runner_config.get("port", 8080)

Copilot uses AI. Check for mistakes.

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)
115 changes: 115 additions & 0 deletions dev/integration/src/core/aiohttp/aiohttp_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from typing import Optional
from typing import Optional
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

The from typing import Optional import is duplicated on lines 1 and 2. Remove one of these duplicate imports.

Suggested change
from typing import Optional

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Duplicate import statement for typing.Optional. Remove the duplicate on line 2.

Suggested change
from typing import Optional

Copilot uses AI. Check for mistakes.
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

Comment on lines +39 to +57
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Re-raising the caught exception without modification is unnecessary. Either remove the try-except block entirely or add error handling logic. Simply re-raising defeats the purpose of catching the exception.

Suggested change
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
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()

Copilot uses AI. Check for mistakes.
async def __aenter__(self):
if self._server_thread:
raise RuntimeError("ResponseClient is already running.")
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Error message references 'ResponseClient' but this is the AiohttpRunner class. The message should be 'AiohttpRunner is already running.' or 'Server is already running.'

Suggested change
raise RuntimeError("ResponseClient is already running.")
raise RuntimeError("AiohttpRunner is already running.")

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

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

Error message mentions 'ResponseClient' but this is the AiohttpRunner class. The message should say 'AiohttpRunner is already running.' or 'Server is already running.'

Suggested change
raise RuntimeError("ResponseClient is already running.")
raise RuntimeError("Server is already running.")

Copilot uses AI. Check for mistakes.

self._shutdown_event.clear()
Comment on lines +61 to +62
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Re-raising the exception without modification is unnecessary. Either remove the try-except block entirely or handle the exception meaningfully (e.g., logging, cleanup, or error transformation).

Copilot uses AI. Check for mistakes.
self._server_thread = Thread(
target=lambda: asyncio.run(self._start_server()), daemon=True
)
self._server_thread.start()
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Error message refers to 'ResponseClient' but this is the AiohttpRunner class. Change the message to 'AiohttpRunner is already running.' or 'Server is already running.'

Copilot uses AI. Check for mistakes.

# 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.")
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Error message references 'ResponseClient' but this is the AiohttpRunner class. The message should be 'AiohttpRunner is not running.' or 'Server is not running.'

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

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

Error message refers to 'ResponseClient' but this is the AiohttpRunner class. The message should say 'AiohttpRunner is not running.' or 'Server is not running.'

Copilot uses AI. Check for mistakes.

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.")
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Error message references 'ResponseClient' but this is the AiohttpRunner class. The message should be 'AiohttpRunner is not running.' or 'Server is not running.'

Copilot uses AI. Check for mistakes.
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
42 changes: 42 additions & 0 deletions dev/integration/src/core/application_runner.py
Original file line number Diff line number Diff line change
@@ -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:
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

The __aenter__ method should return self (or the runner instance) to be compatible with the async context manager protocol, not None. The return type should be 'ApplicationRunner' and the method should return self.

Copilot uses AI. Check for mistakes.

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")
7 changes: 7 additions & 0 deletions dev/integration/src/core/client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from .agent_client import AgentClient
from .response_client import ResponseClient

__all__ = [
"AgentClient",
"ResponseClient",
]
136 changes: 136 additions & 0 deletions dev/integration/src/core/client/agent_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import os
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

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

Import of 'os' is not used.

Suggested change
import os

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

The 'os' module is imported but not used anywhere in this file. Remove this unused import.

Suggested change
import os

Copilot uses AI. Check for mistakes.
import json
import asyncio
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

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

Import of 'asyncio' is not used.

Suggested change
import asyncio

Copilot uses AI. Check for mistakes.
from typing import Optional, cast

from aiohttp import ClientSession
from msal import ConfidentialClientApplication

from microsoft_agents.activity import Activity, ActivityTypes, DeliveryModes, ConversationAccount

from ..utils import _populate_incoming_activity


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,
):
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

@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 "<conversation_id>")

if self.service_url:
activity.service_url = self.service_url

activity = _populate_incoming_activity(activity)

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
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

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

Variable timeout is not used.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Variable timeout is not used.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

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

Variable timeout is not used.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

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

Variable timeout is not used.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Variable timeout is not used.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Variable timeout is not used.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

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

Variable timeout is not used.

Copilot uses AI. Check for mistakes.
activity = self._to_activity(activity_or_text)
activity.delivery_mode = DeliveryModes.expect_replies
activity.service_url = activity.service_url or "http://localhost" # temporary fix
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

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

Hardcoded fallback URL 'http://localhost' with a comment indicating this is a 'temporary fix'. Consider making this configurable or documenting why this default is necessary. If this is truly temporary, create a TODO or track it for proper resolution.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

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

Temporary fix comment indicates incomplete implementation. Consider either completing the proper implementation or creating a TODO/FIXME comment explaining what needs to be addressed.

Suggested change
activity.service_url = activity.service_url or "http://localhost" # temporary fix
activity.service_url = activity.service_url or "http://localhost" # TODO: Replace hardcoded 'http://localhost' with the appropriate service URL. This is a temporary workaround for missing service_url.

Copilot uses AI. Check for mistakes.

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
Loading