Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .azdo/ci-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ steps:
python -m pip install ./dist/microsoft_agents_hosting_fastapi*.whl
python -m pip install ./dist/microsoft_agents_storage_blob*.whl
python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl
python -m pip install ./dist/microsoft_agents_testing*.whl
displayName: 'Install wheels'

- script: |
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ jobs:
python -m pip install ./dist/microsoft_agents_hosting_fastapi*.whl
python -m pip install ./dist/microsoft_agents_storage_blob*.whl
python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl
python -m pip install ./dist/microsoft_agents_testing*.whl
- name: Test with pytest
run: |
pytest -W "ignore:SelectableGroups dict interface is deprecated. Use select.:DeprecationWarning"
8 changes: 8 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# Microsoft 365 Agents SDK for Python - Release Notes v1.5.0 (Unreleased)

## Major Features & Enhancements

- **Testing Package**: Added the new `microsoft-agents-testing` package with an in-memory `TestAdapter`, fluent `TestFlow` conversation assertions, and `MockUserTokenClient` support for testing OAuth and token-exchange flows without external services.

---

# Microsoft 365 Agents SDK for Python - Release Notes v1.4.0

**Release Date:** 2026-08-18
Expand Down
3 changes: 2 additions & 1 deletion dev/integration/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ dependencies = [
"microsoft-agents-hosting-aiohttp",
"microsoft-agents-hosting-dialogs",
"microsoft-agents-hosting-fastapi",
"microsoft-agents-testing @ file:///${PROJECT_ROOT}/../microsoft-agents-testing",
"microsoft-agents-hosting-testing @ file:///${PROJECT_ROOT}/../microsoft-agents-hosting-testing",
"microsoft-agents-testing @ file:///${PROJECT_ROOT}/../../libraries/microsoft-agents-testing",
]
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

"""Scenario definition for ActivityHandler-based dialog integration tests."""

from microsoft_agents.testing import (
from microsoft_agents.hosting.testing import (
ActivityHandlerEnvironment,
ActivityHandlerScenario,
ScenarioConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

import pytest

from microsoft_agents.testing import (
from microsoft_agents.hosting.testing import (
ActivityHandlerEnvironment,
ActivityHandlerScenario,
AgentClient,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import pytest

from microsoft_agents.activity import Activity, Attachment, ActivityTypes
from microsoft_agents.testing import AgentClient, ScenarioConfig, ClientConfig, ActivityTemplate
from microsoft_agents.hosting.testing import AgentClient, ScenarioConfig, ClientConfig, ActivityTemplate

from .scenario import create_dialog_scenario

Expand Down
261 changes: 67 additions & 194 deletions dev/integration/tests/auth/test_oauth_continuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,19 @@
Channels,
ResourceResponse,
SignInConstants,
SignInResource,
TokenExchangeResource,
TokenExchangeRequest,
TokenOrSignInResourceResponse,
TokenPostResource,
TokenResponse,
)
from microsoft_agents.hosting.core import TurnContext, TurnState, UserTokenClientBase
from microsoft_agents.testing import (
from microsoft_agents.hosting.core import TurnContext, TurnState
from microsoft_agents.hosting.testing import (
ActivityTemplate,
AgentClient,
AgentEnvironment,
AiohttpScenario,
ClientConfig,
ScenarioConfig,
)
from microsoft_agents.testing import MockUserTokenClient

_APP_ID = "test-app-id"
_CONVERSATION_ID = "auth-continuation-conversation"
Expand All @@ -40,7 +37,6 @@

class _AuthFlowTestState:
def reset(self) -> None:
self.token_available = False
self.exchange_requests = []
self.get_token_or_sign_in_calls = []
self.replay_started = asyncio.Event()
Expand All @@ -53,219 +49,96 @@ def reset(self) -> None:
_auth_flow.reset()


class _FakeUserToken:
def __init__(self, state: _AuthFlowTestState):
self._state = state
def _create_user_token_client(
state: _AuthFlowTestState,
) -> MockUserTokenClient:
client = MockUserTokenClient()
client.add_exchangeable_token(
connection_name=_OAUTH_CONNECTION_NAME,
channel_id=Channels.ms_teams,
user_id="user-id",
exchangeable_item="sso-token",
token="exchanged-token",
)

async def get_token(
self,
user_id: str,
connection_name: str,
channel_id: Optional[str] = None,
code: Optional[str] = None,
) -> TokenResponse:
if self._state.token_available:
return TokenResponse(
connection_name=connection_name,
token="cached-token",
channel_id=channel_id,
)
return TokenResponse()
get_sign_in_resource = client.get_sign_in_resource

async def _get_token_or_sign_in_resource(
self,
user_id: str,
async def get_sign_in_resource_with_fixed_exchange_id(
connection_name: str,
channel_id: str,
state: str,
code: str = "",
final_redirect: str = "",
fwd_url: str = "",
) -> TokenOrSignInResourceResponse:
self._state.get_token_or_sign_in_calls.append(
{
"user_id": user_id,
"connection_name": connection_name,
"channel_id": channel_id,
}
)
if self._state.token_available:
return TokenOrSignInResourceResponse(
token_response=TokenResponse(
connection_name=connection_name,
token="cached-token",
channel_id=channel_id,
)
)
return TokenOrSignInResourceResponse(
sign_in_resource=SignInResource(
sign_in_link="https://example.test/signin",
token_exchange_resource=TokenExchangeResource(
id=_TOKEN_EXCHANGE_ID,
uri="api://test-token-exchange",
provider_id="test-provider",
),
token_post_resource=TokenPostResource(
sas_url="https://example.test/token-post"
),
)
activity: Activity,
final_redirect: str | None = None,
):
resource = await get_sign_in_resource(
connection_name,
activity,
final_redirect,
)
resource.token_exchange_resource.id = _TOKEN_EXCHANGE_ID
return resource

async def get_aad_tokens(
self,
user_id: str,
connection_name: str,
channel_id: Optional[str] = None,
body: Optional[dict] = None,
) -> dict[str, TokenResponse]:
return {}
client.get_sign_in_resource = get_sign_in_resource_with_fixed_exchange_id

async def sign_out(
self,
user_id: str,
connection_name: Optional[str] = None,
channel_id: Optional[str] = None,
) -> None:
self._state.token_available = False
get_token_or_sign_in_resource = client.get_token_or_sign_in_resource

async def get_token_status(
self,
user_id: str,
channel_id: Optional[str] = None,
include: Optional[str] = None,
) -> list:
return []

async def exchange_token(
self,
user_id: str,
async def get_token_or_sign_in_resource_with_tracking(
connection_name: str,
channel_id: str,
body: Optional[dict] = None,
) -> TokenResponse:
self._state.exchange_requests.append(
activity: Activity,
code: str | None = None,
final_redirect: str | None = None,
fwd_url: str | None = None,
):
state.get_token_or_sign_in_calls.append(
{
"user_id": user_id,
"user_id": activity.from_property.id,
"connection_name": connection_name,
"channel_id": channel_id,
"body": body,
"channel_id": activity.channel_id,
}
)
self._state.token_available = True
return TokenResponse(
connection_name=connection_name,
token="exchanged-token",
channel_id=channel_id,
)


class _FakeUserTokenClient(UserTokenClientBase):
def __init__(self, state: _AuthFlowTestState):
self._user_token = _FakeUserToken(state)

@property
def user_token(self) -> _FakeUserToken:
return self._user_token

@property
def agent_sign_in(self):
return None

async def get_user_token(
self,
user_id: str,
connection_name: str,
channel_id: str,
magic_code: str | None = None,
) -> TokenResponse:
return await self._user_token.get_token(
user_id,
connection_name,
channel_id,
code=magic_code,
)

async def get_sign_in_resource(
self,
connection_name: str,
activity: Activity,
final_redirect: str | None = None,
) -> SignInResource:
response = await self.get_token_or_sign_in_resource(
return await get_token_or_sign_in_resource(
connection_name,
activity,
final_redirect=final_redirect,
code,
final_redirect,
fwd_url,
)
return response.sign_in_resource

async def sign_out_user(
self,
user_id: str,
connection_name: str,
channel_id: str,
) -> None:
await self._user_token.sign_out(user_id, connection_name, channel_id)
client.get_token_or_sign_in_resource = (
get_token_or_sign_in_resource_with_tracking
)

async def get_token_status(
self,
user_id: str,
channel_id: str,
include: str | None = None,
) -> list:
return await self._user_token.get_token_status(
user_id,
channel_id,
include=include,
)
exchange_token = client.exchange_token

async def get_aad_tokens(
self,
user_id: str,
connection_name: str,
resource_urls: list[str],
channel_id: str,
) -> dict[str, TokenResponse]:
return await self._user_token.get_aad_tokens(
user_id,
connection_name,
channel_id,
{"resourceUrls": resource_urls},
)

async def exchange_token(
self,
async def exchange_token_with_tracking(
user_id: str,
connection_name: str,
channel_id: str,
exchange_request: TokenExchangeRequest,
) -> TokenResponse:
return await self._user_token.exchange_token(
state.exchange_requests.append(
{
"user_id": user_id,
"connection_name": connection_name,
"channel_id": channel_id,
"body": exchange_request.model_dump(exclude_none=True),
}
)
response = await exchange_token(
user_id,
connection_name,
channel_id,
exchange_request.model_dump(exclude_none=True),
)

async def get_token_or_sign_in_resource(
self,
connection_name: str,
activity: Activity,
code: str | None = None,
final_redirect: str | None = None,
fwd_url: str | None = None,
) -> TokenOrSignInResourceResponse:
return await self._user_token._get_token_or_sign_in_resource(
user_id=activity.from_property.id,
connection_name=connection_name,
channel_id=activity.channel_id,
state="test-state",
code=code or "",
final_redirect=final_redirect or "",
fwd_url=fwd_url or "",
exchange_request,
)
if response.token:
client.add_user_token(
connection_name=connection_name,
channel_id=channel_id,
user_id=user_id,
token=response.token,
)
return response

async def close(self) -> None:
return None
client.exchange_token = exchange_token_with_tracking
return client


class _FakeConversations:
Expand Down Expand Up @@ -329,7 +202,7 @@ async def close(self) -> None:

class _FakeChannelServiceClientFactory:
def __init__(self, state: _AuthFlowTestState):
self._user_token_client = _FakeUserTokenClient(state)
self._user_token_client = _create_user_token_client(state)

async def create_connector_client(
self,
Expand All @@ -347,7 +220,7 @@ async def create_user_token_client(
context,
claims_identity,
use_anonymous: bool = False,
) -> _FakeUserTokenClient:
) -> MockUserTokenClient:
return self._user_token_client


Expand Down
Loading
Loading