From 2b876ad74d669db2fcbbfc7546ef5c332bd2b4e3 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 27 Apr 2026 10:20:07 -0700 Subject: [PATCH 01/14] WIP --- .../hosting/teams/__init__.py | 15 +- .../hosting/teams/teams_agent_extension.py | 1275 +++++++++++++++++ .../test_teams_agent_extension.py | 910 ++++++++++++ 3 files changed, 2199 insertions(+), 1 deletion(-) create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py create mode 100644 tests/hosting_teams/test_teams_agent_extension.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py index 7dc95ffd..2723ff54 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py @@ -1,4 +1,17 @@ from .teams_activity_handler import TeamsActivityHandler +from .teams_agent_extension import ( + TeamsAgentExtension, + MessageExtension, + TaskModule, + Meeting, +) from .teams_info import TeamsInfo -__all__ = ["TeamsActivityHandler", "TeamsInfo"] +__all__ = [ + "TeamsActivityHandler", + "TeamsAgentExtension", + "MessageExtension", + "TaskModule", + "Meeting", + "TeamsInfo", +] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py new file mode 100644 index 00000000..eef68d6a --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py @@ -0,0 +1,1275 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import re +from http import HTTPStatus +from typing import Any, Awaitable, Callable, Generic, Optional, Pattern, TypeVar, Union + +from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.app import AgentApplication, RouteRank +from microsoft_agents.hosting.core.app.state import TurnState + +from microsoft_agents.activity.teams import ( + AppBasedLinkQuery, + ConfigResponse, + FileConsentCardResponse, + MeetingEndEventDetails, + MeetingParticipantsEventDetails, + MeetingStartEventDetails, + MessagingExtensionAction, + MessagingExtensionActionResponse, + MessagingExtensionQuery, + MessagingExtensionResponse, + O365ConnectorCardActionQuery, + ReadReceiptInfo, + TaskModuleRequest, + TaskModuleResponse, +) + +StateT = TypeVar("StateT", bound=TurnState) + +CommandSelector = Union[str, Pattern[str], None] + + +def _match_selector(selector: CommandSelector, value: Optional[str]) -> bool: + if selector is None: + return True + if value is None: + return False + if isinstance(selector, str): + return selector == value + return bool(re.match(selector, value)) + + +def _get_channel_event_type(context: TurnContext) -> Optional[str]: + data = context.activity.channel_data + if data is None: + return None + if isinstance(data, dict): + return data.get("eventType") or data.get("event_type") + return getattr(data, "event_type", None) + + +async def _send_invoke_response(context: TurnContext, body: Any = None) -> None: + serialized_body = None + if body is not None: + if hasattr(body, "model_dump"): + serialized_body = body.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + else: + serialized_body = body + await context.send_activity( + Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse(status=int(HTTPStatus.OK), body=serialized_body), + ) + ) + + +class MessageExtension(Generic[StateT]): + """ + Route registration for Teams Message Extension (composeExtension) invoke activities. + Access via TeamsAgentExtension.message_extension. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + self._app = app + + def on_query( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/query invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/query" + and _match_selector( + command_id, + (context.activity.value or {}).get("commandId"), + ) + ) + + def __call(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + query = MessagingExtensionQuery.model_validate( + context.activity.value or {} + ) + response = await func(context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_select_item( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/selectItem invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/selectItem" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + response = await func(context, state, context.activity.value) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_submit_action( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/submitAction invokes (not bot message preview).""" + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "composeExtension/submitAction" + ): + return False + value = context.activity.value or {} + if value.get("botMessagePreviewAction"): + return False + return _match_selector(command_id, value.get("commandId")) + + def __call(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + action = MessagingExtensionAction.model_validate( + context.activity.value or {} + ) + response = await func(context, state, action) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_agent_message_preview_edit( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/submitAction with botMessagePreviewAction == 'edit'.""" + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "composeExtension/submitAction" + ): + return False + value = context.activity.value or {} + if value.get("botMessagePreviewAction") != "edit": + return False + return _match_selector(command_id, value.get("commandId")) + + def __call(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + action = MessagingExtensionAction.model_validate( + context.activity.value or {} + ) + response = await func(context, state, action) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_agent_message_preview_send( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/submitAction with botMessagePreviewAction == 'send'.""" + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "composeExtension/submitAction" + ): + return False + value = context.activity.value or {} + if value.get("botMessagePreviewAction") != "send": + return False + return _match_selector(command_id, value.get("commandId")) + + def __call(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + action = MessagingExtensionAction.model_validate( + context.activity.value or {} + ) + response = await func(context, state, action) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_fetch_task( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/fetchTask invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/fetchTask" + and _match_selector( + command_id, + (context.activity.value or {}).get("commandId"), + ) + ) + + def __call(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + action = MessagingExtensionAction.model_validate( + context.activity.value or {} + ) + response = await func(context, state, action) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_query_link( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/queryLink invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/queryLink" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + query = AppBasedLinkQuery.model_validate(context.activity.value or {}) + response = await func(context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_anonymous_query_link( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/anonymousQueryLink invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/anonymousQueryLink" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + query = AppBasedLinkQuery.model_validate(context.activity.value or {}) + response = await func(context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_query_url_setting( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/querySettingUrl invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/querySettingUrl" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + query = MessagingExtensionQuery.model_validate( + context.activity.value or {} + ) + response = await func(context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_configure_settings( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/setting invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/setting" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + await func(context, state, context.activity.value) + await _send_invoke_response(context) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_card_button_clicked( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for composeExtension/onCardButtonClicked invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/onCardButtonClicked" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + await func(context, state, context.activity.value) + await _send_invoke_response(context) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + +class TaskModule(Generic[StateT]): + """ + Route registration for Teams Task Module (task/fetch, task/submit) invoke activities. + Access via TeamsAgentExtension.task_module. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + self._app = app + + @staticmethod + def _get_verb(value: Optional[Any]) -> Optional[str]: + if not isinstance(value, dict): + return None + data = value.get("data") + if isinstance(data, dict): + return data.get("verb") + return None + + def on_fetch( + self, + verb: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for task/fetch invokes. + + :param verb: Optional verb string or regex to match against task data. + If None, matches all task/fetch invokes. + """ + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "task/fetch" + ): + return False + return _match_selector(verb, TaskModule._get_verb(context.activity.value)) + + def __call(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + request = TaskModuleRequest.model_validate(context.activity.value or {}) + response = await func(context, state, request) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_submit( + self, + verb: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for task/submit invokes. + + :param verb: Optional verb string or regex to match against task data. + If None, matches all task/submit invokes. + """ + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "task/submit" + ): + return False + return _match_selector(verb, TaskModule._get_verb(context.activity.value)) + + def __call(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + request = TaskModuleRequest.model_validate(context.activity.value or {}) + response = await func(context, state, request) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + +class Meeting(Generic[StateT]): + """ + Route registration for Teams Meeting event activities. + Access via TeamsAgentExtension.meeting. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + self._app = app + + def on_start( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for meeting start events.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.event + and context.activity.name + == "application/vnd.microsoft.meetingStart" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + meeting = MeetingStartEventDetails.model_validate( + context.activity.value or {} + ) + await func(context, state, meeting) + + self._app.add_route( + __selector, + __handler, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_end( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for meeting end events.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.event + and context.activity.name == "application/vnd.microsoft.meetingEnd" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + meeting = MeetingEndEventDetails.model_validate( + context.activity.value or {} + ) + await func(context, state, meeting) + + self._app.add_route( + __selector, + __handler, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_participants_join( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for meeting participant join events.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.event + and context.activity.name + == "application/vnd.microsoft.meetingParticipantJoin" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + details = MeetingParticipantsEventDetails.model_validate( + context.activity.value or {} + ) + await func(context, state, details) + + self._app.add_route( + __selector, + __handler, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_participants_leave( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for meeting participant leave events.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.event + and context.activity.name + == "application/vnd.microsoft.meetingParticipantLeave" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + details = MeetingParticipantsEventDetails.model_validate( + context.activity.value or {} + ) + await func(context, state, details) + + self._app.add_route( + __selector, + __handler, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + +class TeamsAgentExtension(Generic[StateT]): + """ + Adds Teams-specific route registration to an AgentApplication. + + Usage:: + + app = AgentApplication(options) + teams = TeamsAgentExtension(app) + + @teams.task_module.on_fetch("myVerb") + async def handle_fetch(context, state, request: TaskModuleRequest): + return TaskModuleResponse(...) + + @teams.message_extension.on_query("searchCmd") + async def handle_query(context, state, query: MessagingExtensionQuery): + return MessagingExtensionResponse(...) + + @teams.meeting.on_start + async def handle_meeting_start(context, state, meeting: MeetingStartEventDetails): + ... + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + self._app = app + self._message_extension: MessageExtension[StateT] = MessageExtension(app) + self._task_module: TaskModule[StateT] = TaskModule(app) + self._meeting: Meeting[StateT] = Meeting(app) + + @property + def message_extension(self) -> MessageExtension[StateT]: + """Route registration for Message Extension (composeExtension) invokes.""" + return self._message_extension + + @property + def task_module(self) -> TaskModule[StateT]: + """Route registration for Task Module (task/fetch, task/submit) invokes.""" + return self._task_module + + @property + def meeting(self) -> Meeting[StateT]: + """Route registration for Meeting lifecycle events.""" + return self._meeting + + # ── Message update / delete ──────────────────────────────────────────── + + def on_message_edit( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams editMessage events.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message_update + and context.activity.channel_id == "msteams" + and _get_channel_event_type(context) == "editMessage" + ) + + def __register(func: Callable) -> Callable: + self._app.add_route( + __selector, func, rank=rank, auth_handlers=auth_handlers + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_message_undelete( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams undeleteMessage events.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message_update + and context.activity.channel_id == "msteams" + and _get_channel_event_type(context) == "undeleteMessage" + ) + + def __register(func: Callable) -> Callable: + self._app.add_route( + __selector, func, rank=rank, auth_handlers=auth_handlers + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_message_soft_delete( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams softDeleteMessage events.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message_delete + and context.activity.channel_id == "msteams" + and _get_channel_event_type(context) == "softDeleteMessage" + ) + + def __register(func: Callable) -> Callable: + self._app.add_route( + __selector, func, rank=rank, auth_handlers=auth_handlers + ) + return func + + if handler is not None: + return __register(handler) + return __register + + # ── Read receipt ─────────────────────────────────────────────────────── + + def on_read_receipt( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams readReceipt events.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.event + and context.activity.name == "application/vnd.microsoft.readReceipt" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + receipt = ReadReceiptInfo.model_validate(context.activity.value or {}) + await func(context, state, receipt) + + self._app.add_route( + __selector, __handler, rank=rank, auth_handlers=auth_handlers + ) + return func + + if handler is not None: + return __register(handler) + return __register + + # ── Config ───────────────────────────────────────────────────────────── + + def on_config_fetch( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for config/fetch invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "config/fetch" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + response = await func(context, state, context.activity.value) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_config_submit( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for config/submit invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "config/submit" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + response = await func(context, state, context.activity.value) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + # ── File consent ─────────────────────────────────────────────────────── + + def on_file_consent_accept( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for fileConsent/invoke with action == 'accept'.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "fileConsent/invoke" + and isinstance(context.activity.value, dict) + and context.activity.value.get("action") == "accept" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + file_consent = FileConsentCardResponse.model_validate( + context.activity.value or {} + ) + await func(context, state, file_consent) + await _send_invoke_response(context) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_file_consent_decline( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for fileConsent/invoke with action == 'decline'.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "fileConsent/invoke" + and isinstance(context.activity.value, dict) + and context.activity.value.get("action") == "decline" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + file_consent = FileConsentCardResponse.model_validate( + context.activity.value or {} + ) + await func(context, state, file_consent) + await _send_invoke_response(context) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + # ── O365 Connector ───────────────────────────────────────────────────── + + def on_o365_connector_card_action( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for actionableMessage/executeAction invokes.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "actionableMessage/executeAction" + ) + + def __register(func: Callable) -> Callable: + async def __handler(context: TurnContext, state: StateT) -> None: + query = O365ConnectorCardActionQuery.model_validate( + context.activity.value or {} + ) + await func(context, state, query) + await _send_invoke_response(context) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __register(handler) + return __register + + # ── Conversation update events ───────────────────────────────────────── + + def on_members_added( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams membersAdded conversation update events.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.conversation_update + and context.activity.channel_id == "msteams" + and isinstance(context.activity.members_added, list) + and len(context.activity.members_added) > 0 + ) + + def __register(func: Callable) -> Callable: + self._app.add_route( + __selector, func, rank=rank, auth_handlers=auth_handlers + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_members_removed( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams membersRemoved conversation update events.""" + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.conversation_update + and context.activity.channel_id == "msteams" + and isinstance(context.activity.members_removed, list) + and len(context.activity.members_removed) > 0 + ) + + def __register(func: Callable) -> Callable: + self._app.add_route( + __selector, func, rank=rank, auth_handlers=auth_handlers + ) + return func + + if handler is not None: + return __register(handler) + return __register + + def on_channel_created( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams channelCreated conversation update events.""" + return self._on_teams_channel_event( + "channelCreated", handler, auth_handlers=auth_handlers, rank=rank + ) + + def on_channel_deleted( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams channelDeleted conversation update events.""" + return self._on_teams_channel_event( + "channelDeleted", handler, auth_handlers=auth_handlers, rank=rank + ) + + def on_channel_renamed( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams channelRenamed conversation update events.""" + return self._on_teams_channel_event( + "channelRenamed", handler, auth_handlers=auth_handlers, rank=rank + ) + + def on_channel_restored( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams channelRestored conversation update events.""" + return self._on_teams_channel_event( + "channelRestored", handler, auth_handlers=auth_handlers, rank=rank + ) + + def on_team_archived( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams teamArchived conversation update events.""" + return self._on_teams_channel_event( + "teamArchived", handler, auth_handlers=auth_handlers, rank=rank + ) + + def on_team_deleted( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams teamDeleted conversation update events.""" + return self._on_teams_channel_event( + "teamDeleted", handler, auth_handlers=auth_handlers, rank=rank + ) + + def on_team_hard_deleted( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams teamHardDeleted conversation update events.""" + return self._on_teams_channel_event( + "teamHardDeleted", handler, auth_handlers=auth_handlers, rank=rank + ) + + def on_team_renamed( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams teamRenamed conversation update events.""" + return self._on_teams_channel_event( + "teamRenamed", handler, auth_handlers=auth_handlers, rank=rank + ) + + def on_team_restored( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams teamRestored conversation update events.""" + return self._on_teams_channel_event( + "teamRestored", handler, auth_handlers=auth_handlers, rank=rank + ) + + def on_team_unarchived( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Teams teamUnarchived conversation update events.""" + return self._on_teams_channel_event( + "teamUnarchived", handler, auth_handlers=auth_handlers, rank=rank + ) + + def _on_teams_channel_event( + self, + event_type: str, + handler: Optional[Callable], + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.conversation_update + and context.activity.channel_id == "msteams" + and _get_channel_event_type(context) == event_type + ) + + def __register(func: Callable) -> Callable: + self._app.add_route( + __selector, func, rank=rank, auth_handlers=auth_handlers + ) + return func + + if handler is not None: + return __register(handler) + return __register diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py new file mode 100644 index 00000000..95523145 --- /dev/null +++ b/tests/hosting_teams/test_teams_agent_extension.py @@ -0,0 +1,910 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import re +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse +from microsoft_agents.activity.teams import ( + AppBasedLinkQuery, + FileConsentCardResponse, + MeetingEndEventDetails, + MeetingParticipantsEventDetails, + MeetingStartEventDetails, + MessagingExtensionAction, + MessagingExtensionActionResponse, + MessagingExtensionQuery, + MessagingExtensionResponse, + O365ConnectorCardActionQuery, + ReadReceiptInfo, + TaskModuleRequest, + TaskModuleResponse, +) +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.app import AgentApplication, RouteRank +from microsoft_agents.hosting.teams import TeamsAgentExtension + + +def _make_app() -> AgentApplication: + app = MagicMock(spec=AgentApplication) + app._routes = [] + + def _add_route(selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None): + app._routes.append( + dict( + selector=selector, + handler=handler, + is_invoke=is_invoke, + rank=rank, + auth_handlers=auth_handlers, + ) + ) + + app.add_route.side_effect = _add_route + return app + + +def _make_context( + activity_type: str, + name: str = None, + value: dict = None, + channel_id: str = "msteams", + channel_data: dict = None, + members_added=None, + members_removed=None, +) -> TurnContext: + context = MagicMock(spec=TurnContext) + activity = MagicMock(spec=Activity) + activity.type = activity_type + activity.name = name + activity.value = value + activity.channel_id = channel_id + activity.channel_data = channel_data + activity.members_added = members_added + activity.members_removed = members_removed + context.activity = activity + context.send_activity = AsyncMock() + return context + + +class TestTaskModule: + + def setup_method(self): + self.app = _make_app() + self.teams = TeamsAgentExtension(self.app) + + # ── Selector tests ────────────────────────────────────────────────────── + + def test_on_fetch_no_verb_matches_any(self): + @self.teams.task_module.on_fetch() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="task/fetch", value={}) + assert selector(ctx) is True + + def test_on_fetch_verb_matches_exact(self): + @self.teams.task_module.on_fetch("myVerb") + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + ctx_match = _make_context( + ActivityTypes.invoke, name="task/fetch", + value={"data": {"verb": "myVerb"}} + ) + ctx_no_match = _make_context( + ActivityTypes.invoke, name="task/fetch", + value={"data": {"verb": "otherVerb"}} + ) + assert selector(ctx_match) is True + assert selector(ctx_no_match) is False + + def test_on_fetch_verb_matches_regex(self): + @self.teams.task_module.on_fetch(re.compile(r"my.*")) + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + ctx_match = _make_context( + ActivityTypes.invoke, name="task/fetch", + value={"data": {"verb": "mySpecialVerb"}} + ) + ctx_no_match = _make_context( + ActivityTypes.invoke, name="task/fetch", + value={"data": {"verb": "otherVerb"}} + ) + assert selector(ctx_match) is True + assert selector(ctx_no_match) is False + + def test_on_fetch_wrong_invoke_name(self): + @self.teams.task_module.on_fetch() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="task/submit", value={}) + assert selector(ctx) is False + + def test_on_fetch_wrong_activity_type(self): + @self.teams.task_module.on_fetch() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.message, name="task/fetch") + assert selector(ctx) is False + + def test_on_submit_selector(self): + @self.teams.task_module.on_submit("submitVerb") + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + ctx_match = _make_context( + ActivityTypes.invoke, name="task/submit", + value={"data": {"verb": "submitVerb"}} + ) + ctx_no_match = _make_context( + ActivityTypes.invoke, name="task/submit", + value={"data": {"verb": "other"}} + ) + assert selector(ctx_match) is True + assert selector(ctx_no_match) is False + + def test_on_fetch_is_invoke(self): + @self.teams.task_module.on_fetch() + async def handler(ctx, state, req): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_on_submit_is_invoke(self): + @self.teams.task_module.on_submit() + async def handler(ctx, state, req): ... + + assert self.app._routes[0]["is_invoke"] is True + + # ── Handler tests ─────────────────────────────────────────────────────── + + @pytest.mark.asyncio + async def test_on_fetch_handler_passes_request_and_sends_response(self): + response = TaskModuleResponse() + user_handler = AsyncMock(return_value=response) + + @self.teams.task_module.on_fetch() + async def handler(ctx, state, req: TaskModuleRequest): + return await user_handler(ctx, state, req) + + route_handler = self.app._routes[0]["handler"] + state = MagicMock() + ctx = _make_context( + ActivityTypes.invoke, name="task/fetch", + value={"data": {"verb": "myVerb"}, "context": None} + ) + with patch( + "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", + new_callable=AsyncMock, + ) as mock_send: + await route_handler(ctx, state) + assert user_handler.called + args = user_handler.call_args[0] + assert isinstance(args[2], TaskModuleRequest) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_on_fetch_handler_skips_send_when_none_returned(self): + @self.teams.task_module.on_fetch() + async def handler(ctx, state, req): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, name="task/fetch", + value={"data": None, "context": None} + ) + with patch( + "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", + new_callable=AsyncMock, + ) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_not_awaited() + + +class TestMessageExtension: + + def setup_method(self): + self.app = _make_app() + self.teams = TeamsAgentExtension(self.app) + + def test_on_query_matches_by_command_id(self): + @self.teams.message_extension.on_query("searchCmd") + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + ctx_match = _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "searchCmd"}, + ) + ctx_no_match = _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "otherCmd"}, + ) + assert selector(ctx_match) is True + assert selector(ctx_no_match) is False + + def test_on_query_no_command_id_matches_all(self): + @self.teams.message_extension.on_query() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "anyCommand"}, + ) + assert selector(ctx) is True + + def test_on_query_is_invoke(self): + @self.teams.message_extension.on_query() + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_on_submit_action_excludes_preview(self): + @self.teams.message_extension.on_submit_action() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + ctx_normal = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd"}, + ) + ctx_preview = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, + ) + assert selector(ctx_normal) is True + assert selector(ctx_preview) is False + + def test_on_agent_message_preview_edit_selector(self): + @self.teams.message_extension.on_agent_message_preview_edit() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + ctx_edit = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, + ) + ctx_send = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "send"}, + ) + assert selector(ctx_edit) is True + assert selector(ctx_send) is False + + def test_on_agent_message_preview_send_selector(self): + @self.teams.message_extension.on_agent_message_preview_send() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + ctx_send = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "send"}, + ) + ctx_edit = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, + ) + assert selector(ctx_send) is True + assert selector(ctx_edit) is False + + def test_on_fetch_task_matches_command_id(self): + @self.teams.message_extension.on_fetch_task("myCmd") + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + ctx_match = _make_context( + ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={"commandId": "myCmd"}, + ) + ctx_no_match = _make_context( + ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={"commandId": "other"}, + ) + assert selector(ctx_match) is True + assert selector(ctx_no_match) is False + + def test_on_query_link_selector(self): + @self.teams.message_extension.on_query_link + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="composeExtension/queryLink") + ctx_other = _make_context(ActivityTypes.invoke, name="composeExtension/query") + assert selector(ctx) is True + assert selector(ctx_other) is False + + def test_on_anonymous_query_link_selector(self): + @self.teams.message_extension.on_anonymous_query_link + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="composeExtension/anonymousQueryLink") + assert selector(ctx) is True + + def test_on_select_item_selector(self): + @self.teams.message_extension.on_select_item + async def handler(ctx, state, item): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="composeExtension/selectItem") + ctx_other = _make_context(ActivityTypes.invoke, name="composeExtension/query") + assert selector(ctx) is True + assert selector(ctx_other) is False + + def test_on_configure_settings_sends_empty_response(self): + """on_configure_settings always sends a 200 regardless of handler return value.""" + + @self.teams.message_extension.on_configure_settings + async def handler(ctx, state, settings): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_on_card_button_clicked_selector(self): + @self.teams.message_extension.on_card_button_clicked + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="composeExtension/onCardButtonClicked") + assert selector(ctx) is True + + @pytest.mark.asyncio + async def test_on_query_handler_parses_query_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.teams.message_extension.on_query() + async def handler(ctx, state, query: MessagingExtensionQuery): + return await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "search"}, + ) + with patch( + "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", + new_callable=AsyncMock, + ) as mock_send: + await route_handler(ctx, MagicMock()) + args = user_handler.call_args[0] + assert isinstance(args[2], MessagingExtensionQuery) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_on_configure_settings_always_sends_response(self): + @self.teams.message_extension.on_configure_settings + async def handler(ctx, state, settings): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, name="composeExtension/setting", value={} + ) + with patch( + "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", + new_callable=AsyncMock, + ) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_awaited_once_with(ctx) + + +class TestMeeting: + + def setup_method(self): + self.app = _make_app() + self.teams = TeamsAgentExtension(self.app) + + def test_on_start_selector(self): + @self.teams.meeting.on_start + async def handler(ctx, state, meeting): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.meetingStart") + ctx_other = _make_context(ActivityTypes.event, name="application/vnd.microsoft.meetingEnd") + assert selector(ctx) is True + assert selector(ctx_other) is False + + def test_on_end_selector(self): + @self.teams.meeting.on_end + async def handler(ctx, state, meeting): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.meetingEnd") + assert selector(ctx) is True + + def test_on_participants_join_selector(self): + @self.teams.meeting.on_participants_join + async def handler(ctx, state, details): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantJoin", + ) + assert selector(ctx) is True + + def test_on_participants_leave_selector(self): + @self.teams.meeting.on_participants_leave + async def handler(ctx, state, details): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantLeave", + ) + assert selector(ctx) is True + + def test_on_start_is_not_invoke(self): + @self.teams.meeting.on_start + async def handler(ctx, state, meeting): ... + + assert self.app._routes[0]["is_invoke"] is False + + @pytest.mark.asyncio + async def test_on_start_handler_parses_meeting_details(self): + user_handler = AsyncMock() + + @self.teams.meeting.on_start + async def handler(ctx, state, meeting: MeetingStartEventDetails): + await user_handler(ctx, state, meeting) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingStart", + value={}, + ) + await route_handler(ctx, MagicMock()) + args = user_handler.call_args[0] + assert isinstance(args[2], MeetingStartEventDetails) + + @pytest.mark.asyncio + async def test_on_end_handler_parses_meeting_details(self): + user_handler = AsyncMock() + + @self.teams.meeting.on_end + async def handler(ctx, state, meeting: MeetingEndEventDetails): + await user_handler(ctx, state, meeting) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingEnd", + value={}, + ) + await route_handler(ctx, MagicMock()) + args = user_handler.call_args[0] + assert isinstance(args[2], MeetingEndEventDetails) + + @pytest.mark.asyncio + async def test_on_participants_join_handler_parses_details(self): + user_handler = AsyncMock() + + @self.teams.meeting.on_participants_join + async def handler(ctx, state, details: MeetingParticipantsEventDetails): + await user_handler(ctx, state, details) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantJoin", + value={}, + ) + await route_handler(ctx, MagicMock()) + args = user_handler.call_args[0] + assert isinstance(args[2], MeetingParticipantsEventDetails) + + +class TestTeamsAgentExtensionTopLevel: + + def setup_method(self): + self.app = _make_app() + self.teams = TeamsAgentExtension(self.app) + + # ── Message edit / undelete / soft delete ─────────────────────────────── + + def test_on_message_edit_selector(self): + @self.teams.on_message_edit + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "editMessage"}, + ) + assert selector(ctx) is True + + def test_on_message_edit_wrong_event_type(self): + @self.teams.on_message_edit + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "undeleteMessage"}, + ) + assert selector(ctx) is False + + def test_on_message_edit_wrong_channel(self): + @self.teams.on_message_edit + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.message_update, + channel_id="webchat", + channel_data={"eventType": "editMessage"}, + ) + assert selector(ctx) is False + + def test_on_message_undelete_selector(self): + @self.teams.on_message_undelete + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "undeleteMessage"}, + ) + assert selector(ctx) is True + + def test_on_message_soft_delete_selector(self): + @self.teams.on_message_soft_delete + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.message_delete, + channel_data={"eventType": "softDeleteMessage"}, + ) + assert selector(ctx) is True + + # ── Read receipt ─────────────────────────────────────────────────────── + + def test_on_read_receipt_selector(self): + @self.teams.on_read_receipt + async def handler(ctx, state, receipt): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.readReceipt") + assert selector(ctx) is True + + @pytest.mark.asyncio + async def test_on_read_receipt_handler_parses_receipt(self): + user_handler = AsyncMock() + + @self.teams.on_read_receipt + async def handler(ctx, state, receipt: ReadReceiptInfo): + await user_handler(ctx, state, receipt) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.readReceipt", + value={}, + ) + await route_handler(ctx, MagicMock()) + args = user_handler.call_args[0] + assert isinstance(args[2], ReadReceiptInfo) + + # ── Config ───────────────────────────────────────────────────────────── + + def test_on_config_fetch_selector(self): + @self.teams.on_config_fetch + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="config/fetch") + ctx_other = _make_context(ActivityTypes.invoke, name="config/submit") + assert selector(ctx) is True + assert selector(ctx_other) is False + + def test_on_config_fetch_is_invoke(self): + @self.teams.on_config_fetch + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_on_config_submit_selector(self): + @self.teams.on_config_submit + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="config/submit") + assert selector(ctx) is True + + # ── File consent ─────────────────────────────────────────────────────── + + def test_on_file_consent_accept_selector(self): + @self.teams.on_file_consent_accept + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + ctx_accept = _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept"}, + ) + ctx_decline = _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "decline"}, + ) + assert selector(ctx_accept) is True + assert selector(ctx_decline) is False + + def test_on_file_consent_decline_selector(self): + @self.teams.on_file_consent_decline + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + ctx_decline = _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "decline"}, + ) + ctx_accept = _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept"}, + ) + assert selector(ctx_decline) is True + assert selector(ctx_accept) is False + + @pytest.mark.asyncio + async def test_on_file_consent_accept_sends_empty_response(self): + @self.teams.on_file_consent_accept + async def handler(ctx, state, data): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept", "context": None, "uploadInfo": None}, + ) + with patch( + "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", + new_callable=AsyncMock, + ) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_awaited_once_with(ctx) + + # ── O365 ─────────────────────────────────────────────────────────────── + + def test_on_o365_connector_card_action_selector(self): + @self.teams.on_o365_connector_card_action + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="actionableMessage/executeAction") + assert selector(ctx) is True + + @pytest.mark.asyncio + async def test_on_o365_connector_card_action_parses_query(self): + user_handler = AsyncMock() + + @self.teams.on_o365_connector_card_action + async def handler(ctx, state, query: O365ConnectorCardActionQuery): + await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="actionableMessage/executeAction", + value={}, + ) + with patch( + "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", + new_callable=AsyncMock, + ): + await route_handler(ctx, MagicMock()) + args = user_handler.call_args[0] + assert isinstance(args[2], O365ConnectorCardActionQuery) + + # ── Conversation update events ───────────────────────────────────────── + + def test_on_members_added_selector(self): + @self.teams.on_members_added + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + ctx = _make_context( + ActivityTypes.conversation_update, + members_added=[member], + ) + ctx_empty = _make_context( + ActivityTypes.conversation_update, + members_added=[], + ) + assert selector(ctx) is True + assert selector(ctx_empty) is False + + def test_on_members_added_requires_teams_channel(self): + @self.teams.on_members_added + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + ctx = _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + members_added=[member], + ) + assert selector(ctx) is False + + def test_on_members_removed_selector(self): + @self.teams.on_members_removed + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + ctx = _make_context( + ActivityTypes.conversation_update, + members_removed=[member], + ) + assert selector(ctx) is True + + def test_on_channel_created_selector(self): + @self.teams.on_channel_created + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + ctx_other = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelDeleted"}, + ) + assert selector(ctx) is True + assert selector(ctx_other) is False + + def test_on_channel_deleted_selector(self): + @self.teams.on_channel_deleted + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelDeleted"}, + ) + assert selector(ctx) is True + + def test_on_channel_renamed_selector(self): + @self.teams.on_channel_renamed + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRenamed"}, + ) + assert selector(ctx) is True + + def test_on_channel_restored_selector(self): + @self.teams.on_channel_restored + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRestored"}, + ) + assert selector(ctx) is True + + def test_on_team_archived_selector(self): + @self.teams.on_team_archived + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + assert selector(ctx) is True + + def test_on_team_deleted_selector(self): + @self.teams.on_team_deleted + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamDeleted"}, + ) + assert selector(ctx) is True + + def test_on_team_hard_deleted_selector(self): + @self.teams.on_team_hard_deleted + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamHardDeleted"}, + ) + assert selector(ctx) is True + + def test_on_team_renamed_selector(self): + @self.teams.on_team_renamed + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamRenamed"}, + ) + assert selector(ctx) is True + + def test_on_team_restored_selector(self): + @self.teams.on_team_restored + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamRestored"}, + ) + assert selector(ctx) is True + + def test_on_team_unarchived_selector(self): + @self.teams.on_team_unarchived + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamUnarchived"}, + ) + assert selector(ctx) is True + + # ── Sub-object accessors ──────────────────────────────────────────────── + + def test_properties_return_sub_objects(self): + from microsoft_agents.hosting.teams.teams_agent_extension import ( + MessageExtension, + TaskModule, + Meeting, + ) + + assert isinstance(self.teams.message_extension, MessageExtension) + assert isinstance(self.teams.task_module, TaskModule) + assert isinstance(self.teams.meeting, Meeting) + + # ── Decorator style without args ──────────────────────────────────────── + + def test_on_message_edit_direct_decorator(self): + """Verify on_message_edit works as @teams.on_message_edit (no call parens).""" + + @self.teams.on_message_edit + async def handler(ctx, state): ... + + assert len(self.app._routes) == 1 + + def test_on_message_edit_factory_decorator(self): + """Verify on_message_edit works as @teams.on_message_edit() (with call parens).""" + + @self.teams.on_message_edit() + async def handler(ctx, state): ... + + assert len(self.app._routes) == 1 From 2512aa30a2a863d794b84ca099394008ae715383 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Tue, 5 May 2026 16:56:57 -0700 Subject: [PATCH 02/14] Replacing models to leverage teams api --- .../hosting/teams/teams_agent_extension.py | 22 ++++++------- .../microsoft-agents-hosting-teams/setup.py | 1 + .../test_teams_agent_extension.py | 31 +++++++++++++------ 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py index eef68d6a..e13bfaa6 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py @@ -15,18 +15,19 @@ from microsoft_agents.hosting.core.app.state import TurnState from microsoft_agents.activity.teams import ( + MeetingParticipantsEventDetails, + ReadReceiptInfo, +) +from microsoft_teams.api.models import ( AppBasedLinkQuery, ConfigResponse, FileConsentCardResponse, - MeetingEndEventDetails, - MeetingParticipantsEventDetails, - MeetingStartEventDetails, + MeetingDetails, MessagingExtensionAction, MessagingExtensionActionResponse, MessagingExtensionQuery, MessagingExtensionResponse, O365ConnectorCardActionQuery, - ReadReceiptInfo, TaskModuleRequest, TaskModuleResponse, ) @@ -605,15 +606,12 @@ def on_start( def __selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.event - and context.activity.name - == "application/vnd.microsoft.meetingStart" + and context.activity.name == "application/vnd.microsoft.meetingStart" ) def __register(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: - meeting = MeetingStartEventDetails.model_validate( - context.activity.value or {} - ) + meeting = MeetingDetails.model_validate(context.activity.value or {}) await func(context, state, meeting) self._app.add_route( @@ -645,9 +643,7 @@ def __selector(context: TurnContext) -> bool: def __register(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: - meeting = MeetingEndEventDetails.model_validate( - context.activity.value or {} - ) + meeting = MeetingDetails.model_validate(context.activity.value or {}) await func(context, state, meeting) self._app.add_route( @@ -751,7 +747,7 @@ async def handle_query(context, state, query: MessagingExtensionQuery): return MessagingExtensionResponse(...) @teams.meeting.on_start - async def handle_meeting_start(context, state, meeting: MeetingStartEventDetails): + async def handle_meeting_start(context, state, meeting: MeetingDetails): ... """ diff --git a/libraries/microsoft-agents-hosting-teams/setup.py b/libraries/microsoft-agents-hosting-teams/setup.py index f7a4e1bb..62611d34 100644 --- a/libraries/microsoft-agents-hosting-teams/setup.py +++ b/libraries/microsoft-agents-hosting-teams/setup.py @@ -14,5 +14,6 @@ install_requires=[ f"microsoft-agents-hosting-core=={package_version}", "aiohttp>=3.11.11", + "microsoft-teams-api>=2.0.0,<3", ], ) diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py index 95523145..773fb5e3 100644 --- a/tests/hosting_teams/test_teams_agent_extension.py +++ b/tests/hosting_teams/test_teams_agent_extension.py @@ -9,17 +9,18 @@ from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse from microsoft_agents.activity.teams import ( + MeetingParticipantsEventDetails, + ReadReceiptInfo, +) +from microsoft_teams.api.models import ( AppBasedLinkQuery, FileConsentCardResponse, - MeetingEndEventDetails, - MeetingParticipantsEventDetails, - MeetingStartEventDetails, + MeetingDetails, MessagingExtensionAction, MessagingExtensionActionResponse, MessagingExtensionQuery, MessagingExtensionResponse, O365ConnectorCardActionQuery, - ReadReceiptInfo, TaskModuleRequest, TaskModuleResponse, ) @@ -47,6 +48,16 @@ def _add_route(selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_ return app +def _meeting_details_value() -> dict: + return { + "id": "meeting-id", + "type": "scheduled", + "joinUrl": "https://example.com/meet", + "title": "Test Meeting", + "msGraphResourceId": "graph-id", + } + + def _make_context( activity_type: str, name: str = None, @@ -463,36 +474,36 @@ async def test_on_start_handler_parses_meeting_details(self): user_handler = AsyncMock() @self.teams.meeting.on_start - async def handler(ctx, state, meeting: MeetingStartEventDetails): + async def handler(ctx, state, meeting: MeetingDetails): await user_handler(ctx, state, meeting) route_handler = self.app._routes[0]["handler"] ctx = _make_context( ActivityTypes.event, name="application/vnd.microsoft.meetingStart", - value={}, + value=_meeting_details_value(), ) await route_handler(ctx, MagicMock()) args = user_handler.call_args[0] - assert isinstance(args[2], MeetingStartEventDetails) + assert isinstance(args[2], MeetingDetails) @pytest.mark.asyncio async def test_on_end_handler_parses_meeting_details(self): user_handler = AsyncMock() @self.teams.meeting.on_end - async def handler(ctx, state, meeting: MeetingEndEventDetails): + async def handler(ctx, state, meeting: MeetingDetails): await user_handler(ctx, state, meeting) route_handler = self.app._routes[0]["handler"] ctx = _make_context( ActivityTypes.event, name="application/vnd.microsoft.meetingEnd", - value={}, + value=_meeting_details_value(), ) await route_handler(ctx, MagicMock()) args = user_handler.call_args[0] - assert isinstance(args[2], MeetingEndEventDetails) + assert isinstance(args[2], MeetingDetails) @pytest.mark.asyncio async def test_on_participants_join_handler_parses_details(self): From 3dac1508b82ed1eb7435997e16eb9bce472882f5 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Wed, 6 May 2026 16:27:56 -0700 Subject: [PATCH 03/14] Adding sample --- .../hosting/teams/teams_agent_extension.py | 6 +- test_samples/teams_extension/README.md | 57 ++++ test_samples/teams_extension/env.TEMPLATE | 6 + .../teams_extension/meeting_events_agent.py | 114 +++++++ .../message_extensions_agent.py | 293 ++++++++++++++++++ .../teams_extension/shared/__init__.py | 3 + .../teams_extension/shared/start_server.py | 26 ++ .../teams_extension/task_modules_agent.py | 280 +++++++++++++++++ .../test_teams_agent_extension.py | 6 +- 9 files changed, 781 insertions(+), 10 deletions(-) create mode 100644 test_samples/teams_extension/README.md create mode 100644 test_samples/teams_extension/env.TEMPLATE create mode 100644 test_samples/teams_extension/meeting_events_agent.py create mode 100644 test_samples/teams_extension/message_extensions_agent.py create mode 100644 test_samples/teams_extension/shared/__init__.py create mode 100644 test_samples/teams_extension/shared/start_server.py create mode 100644 test_samples/teams_extension/task_modules_agent.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py index e13bfaa6..5486addc 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py @@ -7,7 +7,7 @@ import re from http import HTTPStatus -from typing import Any, Awaitable, Callable, Generic, Optional, Pattern, TypeVar, Union +from typing import Any, Callable, Generic, Optional, Pattern, TypeVar, Union from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse from microsoft_agents.hosting.core import TurnContext @@ -20,16 +20,12 @@ ) from microsoft_teams.api.models import ( AppBasedLinkQuery, - ConfigResponse, FileConsentCardResponse, MeetingDetails, MessagingExtensionAction, - MessagingExtensionActionResponse, MessagingExtensionQuery, - MessagingExtensionResponse, O365ConnectorCardActionQuery, TaskModuleRequest, - TaskModuleResponse, ) StateT = TypeVar("StateT", bound=TurnState) diff --git a/test_samples/teams_extension/README.md b/test_samples/teams_extension/README.md new file mode 100644 index 00000000..4419fb21 --- /dev/null +++ b/test_samples/teams_extension/README.md @@ -0,0 +1,57 @@ +# Teams Extension Samples + +These samples exercise `TeamsAgentExtension` after its model layer was switched +to use the Teams SDK Pydantic models from +[`microsoft-teams-api`](https://pypi.org/project/microsoft-teams-api/) (the +`microsoft_teams.api.models` namespace from +[microsoft/teams.py](https://github.com/microsoft/teams.py)). + +They mirror the AgentApplication-based samples added in +[microsoft/Agents-for-net PR #740](https://github.com/microsoft/Agents-for-net/pull/740), +adapted to Python decorators on `TeamsAgentExtension` sub-objects. + +## Samples + +| File | What it shows | +| --- | --- | +| `message_extensions_agent.py` | `composeExtension/*` invokes — search query, select item, submit action, query link, settings URL, configure settings, fetch task. | +| `task_modules_agent.py` | `task/fetch` and `task/submit` for simple form, webpage dialog, and a multi-step (chained submits) flow. | +| `meeting_events_agent.py` | Meeting start/end via the new single `MeetingDetails` model, plus participant join/leave and read receipts (legacy models retained). | + +## Setup + +From the repo root: + +```bash +. ./scripts/dev_setup.sh # or scripts/dev_setup.ps1 on Windows +pip install microsoft-teams-api # only-binary may be needed on Windows: pip install --only-binary=:all: microsoft-teams-api +cp test_samples/teams_extension/env.TEMPLATE test_samples/teams_extension/.env +# Fill in CLIENTID / CLIENTSECRET / TENANTID in the .env +``` + +## Run + +```bash +cd test_samples/teams_extension +python message_extensions_agent.py +# or +python task_modules_agent.py +# or +python meeting_events_agent.py +``` + +The agent listens on `http://localhost:3978/api/messages`. To exercise the +invoke-style routes (message extension queries, task modules) point the +[Microsoft 365 Agents Playground](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/test-with-toolkit-project) +or a Teams app manifest at the tunnel endpoint. + +## Notes on the Teams SDK model swap + +* `MeetingStartEventDetails` and `MeetingEndEventDetails` collapse to a single + `MeetingDetails` from `microsoft_teams.api.models` — `meeting.on_start` and + `meeting.on_end` both deserialize to that one type. +* `MeetingParticipantsEventDetails` and `ReadReceiptInfo` have no Teams SDK + equivalents and continue to ship from `microsoft_agents.activity.teams`. +* `MessagingExtensionAction.command_context` is **required** in the Teams SDK + model (it was optional previously). Real Teams activities always include it + per the platform contract; mock fixtures may need to add it. diff --git a/test_samples/teams_extension/env.TEMPLATE b/test_samples/teams_extension/env.TEMPLATE new file mode 100644 index 00000000..52d28b55 --- /dev/null +++ b/test_samples/teams_extension/env.TEMPLATE @@ -0,0 +1,6 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id + +# Used by the task_modules_agent for webpage-style task modules +APP_BASE_URL=http://localhost:3978 diff --git a/test_samples/teams_extension/meeting_events_agent.py b/test_samples/teams_extension/meeting_events_agent.py new file mode 100644 index 00000000..b5f3deeb --- /dev/null +++ b/test_samples/teams_extension/meeting_events_agent.py @@ -0,0 +1,114 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Teams meeting events sample. + +Demonstrates the meeting routes on TeamsAgentExtension using the Teams SDK +MeetingDetails model. In the Teams SDK a single MeetingDetails type is used +for both meeting-start and meeting-end events (replacing the prior +MeetingStartEventDetails / MeetingEndEventDetails split). + +Also wires read-receipt and participant join/leave handlers, which keep +the legacy ReadReceiptInfo / MeetingParticipantsEventDetails models from +microsoft_agents.activity.teams (no Teams SDK equivalents). +""" + +import logging +from os import environ, path +from dotenv import load_dotenv + +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.activity.teams import ( + MeetingParticipantsEventDetails, + ReadReceiptInfo, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + MemoryStorage, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.app.oauth.authorization import Authorization +from microsoft_agents.hosting.teams import TeamsAgentExtension +from microsoft_teams.api.models import MeetingDetails + +from shared import start_server + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + +load_dotenv(path.join(path.dirname(__file__), ".env")) +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) +TEAMS = TeamsAgentExtension[TurnState](AGENT_APP) + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + await context.send_activity( + "This sample listens for Teams meeting lifecycle events. " + "Install it in a meeting context to see start, end, join, leave, and read receipts." + ) + + +@TEAMS.meeting.on_start +async def on_meeting_start( + context: TurnContext, _state: TurnState, meeting: MeetingDetails +): + title = meeting.title or "Untitled meeting" + await context.send_activity( + f"Meeting started: {title} (id={meeting.id}, start={meeting.scheduled_start_time})" + ) + + +@TEAMS.meeting.on_end +async def on_meeting_end( + context: TurnContext, _state: TurnState, meeting: MeetingDetails +): + title = meeting.title or "Untitled meeting" + await context.send_activity( + f"Meeting ended: {title} (id={meeting.id}, end={meeting.scheduled_end_time})" + ) + + +@TEAMS.meeting.on_participants_join +async def on_participants_join( + context: TurnContext, _state: TurnState, details: MeetingParticipantsEventDetails +): + members = details.members or [] + log.info("Participants joined: %d", len(members)) + + +@TEAMS.meeting.on_participants_leave +async def on_participants_leave( + context: TurnContext, _state: TurnState, details: MeetingParticipantsEventDetails +): + members = details.members or [] + log.info("Participants left: %d", len(members)) + + +@TEAMS.on_read_receipt +async def on_read_receipt( + context: TurnContext, _state: TurnState, receipt: ReadReceiptInfo +): + log.info("Read receipt: lastReadMessageId=%s", receipt.last_read_message_id) + + +if __name__ == "__main__": + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/test_samples/teams_extension/message_extensions_agent.py b/test_samples/teams_extension/message_extensions_agent.py new file mode 100644 index 00000000..0e6a7abc --- /dev/null +++ b/test_samples/teams_extension/message_extensions_agent.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Teams Message Extensions sample. + +Mirrors the AgentApplication-based MessageExtensions sample from +microsoft/Agents-for-net PR #740 (src/samples/Teams/MessageExtensions), +adapted to Python's TeamsAgentExtension and using the Teams SDK Pydantic +models from microsoft_teams.api.models. +""" + +import logging +from os import environ, path +from dotenv import load_dotenv + +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + MemoryStorage, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.app.oauth.authorization import Authorization +from microsoft_agents.hosting.teams import TeamsAgentExtension +from microsoft_teams.api.models import ( + MessagingExtensionAction, + MessagingExtensionActionResponse, + MessagingExtensionAttachment, + MessagingExtensionAttachmentLayout, + MessagingExtensionQuery, + MessagingExtensionResponse, + MessagingExtensionResult, + MessagingExtensionResultType, + AppBasedLinkQuery, +) + +from shared import start_server + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + +load_dotenv(path.join(path.dirname(__file__), ".env")) +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) +TEAMS = TeamsAgentExtension[TurnState](AGENT_APP) + + +def _adaptive_card(title: str, body_text: str) -> dict: + return { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + {"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Large"}, + {"type": "TextBlock", "text": body_text, "wrap": True, "isSubtle": True}, + ], + } + + +def _thumbnail(title: str, text: str, tap_value: dict) -> dict: + return { + "title": title, + "text": text, + "tap": {"type": "invoke", "value": tap_value}, + } + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + await context.send_activity( + f"Echo: {context.activity.text}\n\n" + "This is a message extension sample. Use the message extension commands " + "in Teams to test functionality." + ) + + +# ── composeExtension/query (search-based) ────────────────────────────────── + +@TEAMS.message_extension.on_query("searchQuery") +async def on_search_query( + context: TurnContext, _state: TurnState, query: MessagingExtensionQuery +) -> MessagingExtensionResponse: + params = {p.name: p.value for p in (query.parameters or [])} + if str(params.get("initialRun", "")).lower() == "true": + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.MESSAGE, + text="Enter search query", + ) + ) + + search_text = str(params.get("searchQuery", "") or "") + log.info("Search query received: %s", search_text) + + attachments = [] + for i in range(1, 6): + card = _adaptive_card( + f"Search Result {i}", + f"Query: '{search_text}' — result description for item {i}", + ) + preview = _thumbnail( + title=f"Result {i}", + text=f"Preview of result {i} for query '{search_text}'.", + tap_value={"index": str(i), "query": search_text}, + ) + attachments.append( + MessagingExtensionAttachment( + content_type="application/vnd.microsoft.card.adaptive", + content=card, + preview=MessagingExtensionAttachment( + content_type="application/vnd.microsoft.card.thumbnail", + content=preview, + ), + ) + ) + + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.RESULT, + attachment_layout=MessagingExtensionAttachmentLayout.LIST, + attachments=attachments, + ) + ) + + +# ── composeExtension/selectItem (item tap from a search result) ──────────── + +@TEAMS.message_extension.on_select_item +async def on_select_item( + context: TurnContext, _state: TurnState, item +) -> MessagingExtensionResponse: + item = item or {} + index = item.get("index", "No Index") + query = item.get("query", "No Query") + log.info("Item selected: %s:%s", index, query) + + card = _adaptive_card( + "Item Selected", + f"You selected item {index} for query '{query}'.", + ) + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.RESULT, + attachment_layout=MessagingExtensionAttachmentLayout.LIST, + attachments=[ + MessagingExtensionAttachment( + content_type="application/vnd.microsoft.card.adaptive", + content=card, + ) + ], + ) + ) + + +# ── composeExtension/submitAction (action-based, "createCard") ───────────── + +@TEAMS.message_extension.on_submit_action("createCard") +async def on_create_card( + context: TurnContext, _state: TurnState, action: MessagingExtensionAction +) -> MessagingExtensionResponse: + data = action.data if isinstance(action.data, dict) else {} + title = data.get("title") or "Default Title" + description = data.get("description") or "Default Description" + log.info("Creating card: title=%s description=%s", title, description) + + card = _adaptive_card(title, description) + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.RESULT, + attachment_layout=MessagingExtensionAttachmentLayout.LIST, + attachments=[ + MessagingExtensionAttachment( + content_type="application/vnd.microsoft.card.adaptive", + content=card, + ) + ], + ) + ) + + +# ── composeExtension/queryLink (link unfurling) ──────────────────────────── + +@TEAMS.message_extension.on_query_link +async def on_query_link( + context: TurnContext, _state: TurnState, query: AppBasedLinkQuery +) -> MessagingExtensionResponse: + url = query.url or "" + log.info("Link query: %s", url) + + if not url: + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.MESSAGE, + text="No URL provided", + ) + ) + + card = _adaptive_card("Link Preview", f"URL: {url}") + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.RESULT, + attachment_layout=MessagingExtensionAttachmentLayout.LIST, + attachments=[ + MessagingExtensionAttachment( + content_type="application/vnd.microsoft.card.adaptive", + content=card, + preview=MessagingExtensionAttachment( + content_type="application/vnd.microsoft.card.thumbnail", + content=_thumbnail("Link Preview", url, {}), + ), + ) + ], + ) + ) + + +# ── composeExtension/querySettingUrl ─────────────────────────────────────── + +@TEAMS.message_extension.on_query_url_setting +async def on_query_settings_url( + context: TurnContext, _state: TurnState, _query: MessagingExtensionQuery +) -> MessagingExtensionResponse: + log.info("Query settings URL requested") + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.CONFIG, + suggested_actions={ + "actions": [ + { + "type": "openUrl", + "title": "Configure", + "value": "https://example.com/settings", + } + ] + }, + ) + ) + + +# ── composeExtension/setting (apply settings) ────────────────────────────── + +@TEAMS.message_extension.on_configure_settings +async def on_configure_settings( + context: TurnContext, _state: TurnState, settings +): + log.info("Configure settings: %s", settings) + + +# ── composeExtension/fetchTask ───────────────────────────────────────────── + +@TEAMS.message_extension.on_fetch_task() +async def on_fetch_task( + context: TurnContext, _state: TurnState, action: MessagingExtensionAction +) -> MessagingExtensionActionResponse: + log.info("FetchTask: command=%s", action.command_id) + card = _adaptive_card( + "Create a card", + "Submit a title and description to generate an Adaptive Card.", + ) + return MessagingExtensionActionResponse( + task={ + "type": "continue", + "value": { + "title": "Create Card", + "height": "small", + "width": "small", + "card": { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": card, + }, + }, + } + ) + + +if __name__ == "__main__": + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/test_samples/teams_extension/shared/__init__.py b/test_samples/teams_extension/shared/__init__.py new file mode 100644 index 00000000..96fec955 --- /dev/null +++ b/test_samples/teams_extension/shared/__init__.py @@ -0,0 +1,3 @@ +from .start_server import start_server + +__all__ = ["start_server"] diff --git a/test_samples/teams_extension/shared/start_server.py b/test_samples/teams_extension/shared/start_server.py new file mode 100644 index 00000000..c7b7259d --- /dev/null +++ b/test_samples/teams_extension/shared/start_server.py @@ -0,0 +1,26 @@ +from os import environ +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.aiohttp import ( + start_agent_process, + jwt_authorization_middleware, + CloudAdapter, +) +from aiohttp.web import Request, Response, Application, run_app + + +def start_server( + agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration +): + 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.router.add_get("/api/messages", lambda _: Response(status=200)) + APP["agent_configuration"] = auth_configuration + APP["agent_app"] = agent_application + APP["adapter"] = agent_application.adapter + + run_app(APP, host="localhost", port=environ.get("PORT", 3978)) diff --git a/test_samples/teams_extension/task_modules_agent.py b/test_samples/teams_extension/task_modules_agent.py new file mode 100644 index 00000000..19655cc8 --- /dev/null +++ b/test_samples/teams_extension/task_modules_agent.py @@ -0,0 +1,280 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Teams Task Modules sample. + +Mirrors the AgentApplication-based TaskModules sample from +microsoft/Agents-for-net PR #740 (src/samples/Teams/TaskModules), adapted +to Python's TeamsAgentExtension. +""" + +import logging +from os import environ, path +from dotenv import load_dotenv + +from microsoft_agents.activity import Attachment, load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + MemoryStorage, + MessageFactory, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.app.oauth.authorization import Authorization +from microsoft_agents.hosting.teams import TeamsAgentExtension +from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse + +from shared import start_server + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + +load_dotenv(path.join(path.dirname(__file__), ".env")) +agents_sdk_config = load_configuration_from_env(environ) + +APP_BASE_URL = environ.get("APP_BASE_URL", "http://localhost:3978") + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) +TEAMS = TeamsAgentExtension[TurnState](AGENT_APP) + + +def _launcher_card() -> Attachment: + """Welcome card with buttons that open each task module via task/fetch.""" + return Attachment( + content_type="application/vnd.microsoft.card.adaptive", + content={ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Task Module Demos", + "weight": "Bolder", + "size": "Large", + }, + { + "type": "TextBlock", + "text": "Pick a task module to launch.", + "wrap": True, + }, + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Simple Form", + "data": {"msteams": {"type": "task/fetch"}, "data": {"verb": "simple_form"}}, + }, + { + "type": "Action.Submit", + "title": "Webpage Dialog", + "data": {"msteams": {"type": "task/fetch"}, "data": {"verb": "webpage_dialog"}}, + }, + { + "type": "Action.Submit", + "title": "Multi-Step Form", + "data": {"msteams": {"type": "task/fetch"}, "data": {"verb": "multi_step_form"}}, + }, + ], + }, + ) + + +def _continue_card_response( + title: str, card_content: dict, *, height: str = "small", width: str = "small" +) -> TaskModuleResponse: + return TaskModuleResponse.model_validate( + { + "task": { + "type": "continue", + "value": { + "title": title, + "height": height, + "width": width, + "card": { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": card_content, + }, + }, + } + } + ) + + +def _continue_url_response( + title: str, url: str, *, height: int = 500, width: int = 800 +) -> TaskModuleResponse: + return TaskModuleResponse.model_validate( + { + "task": { + "type": "continue", + "value": { + "title": title, + "height": height, + "width": width, + "url": url, + "fallbackUrl": url, + }, + } + } + ) + + +def _message_response(text: str) -> TaskModuleResponse: + return TaskModuleResponse.model_validate( + {"task": {"type": "message", "value": text}} + ) + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + await context.send_activity(MessageFactory.attachment(_launcher_card())) + + +# ── Simple Form ──────────────────────────────────────────────────────────── + +_SIMPLE_FORM_CARD = { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + {"type": "TextBlock", "text": "Simple Form", "weight": "Bolder", "size": "Medium"}, + {"type": "Input.Text", "id": "name", "label": "Name", "isRequired": True}, + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": {"verb": "simple_form"}, + } + ], +} + + +@TEAMS.task_module.on_fetch("simple_form") +async def on_simple_form_fetch( + context: TurnContext, _state: TurnState, _request: TaskModuleRequest +) -> TaskModuleResponse: + return _continue_card_response("Simple Form", _SIMPLE_FORM_CARD) + + +@TEAMS.task_module.on_submit("simple_form") +async def on_simple_form_submit( + context: TurnContext, _state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + data = request.data if isinstance(request.data, dict) else {} + name = data.get("name", "Unknown") + await context.send_activity(f"Hi {name}, thanks for submitting the form!") + return _message_response("Form was submitted") + + +# ── Webpage Dialog ───────────────────────────────────────────────────────── + +@TEAMS.task_module.on_fetch("webpage_dialog") +async def on_webpage_dialog_fetch( + context: TurnContext, _state: TurnState, _request: TaskModuleRequest +) -> TaskModuleResponse: + return _continue_url_response("Webpage Dialog", f"{APP_BASE_URL}/dialog-form") + + +@TEAMS.task_module.on_submit("webpage_dialog") +async def on_webpage_dialog_submit( + context: TurnContext, _state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + data = request.data if isinstance(request.data, dict) else {} + name = data.get("name", "Unknown") + email = data.get("email", "no email") + await context.send_activity( + f"Hi {name}, thanks for submitting the form! Your email is {email}." + ) + return _message_response("Form submitted successfully") + + +# ── Multi-Step Form ──────────────────────────────────────────────────────── + +_MULTI_STEP_NAME_CARD = { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + {"type": "TextBlock", "text": "Step 1 — your name", "weight": "Bolder"}, + {"type": "Input.Text", "id": "name", "label": "Name", "isRequired": True}, + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Next", + "data": {"verb": "multi_step_form_submit_name"}, + } + ], +} + + +def _multi_step_email_card(name: str) -> dict: + return { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + {"type": "TextBlock", "text": f"Step 2 — email for {name}", "weight": "Bolder"}, + {"type": "Input.Text", "id": "email", "label": "Email", "isRequired": True}, + {"type": "Input.Text", "id": "name", "value": name, "isVisible": False}, + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": {"verb": "multi_step_form_submit_email"}, + } + ], + } + + +@TEAMS.task_module.on_fetch("multi_step_form") +async def on_multi_step_fetch( + context: TurnContext, _state: TurnState, _request: TaskModuleRequest +) -> TaskModuleResponse: + return _continue_card_response("Multi-step Form — Name", _MULTI_STEP_NAME_CARD) + + +@TEAMS.task_module.on_submit("multi_step_form_submit_name") +async def on_multi_step_submit_name( + context: TurnContext, _state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + data = request.data if isinstance(request.data, dict) else {} + name = data.get("name", "Unknown") + return _continue_card_response( + f"Thanks {name} — your email", _multi_step_email_card(name) + ) + + +@TEAMS.task_module.on_submit("multi_step_form_submit_email") +async def on_multi_step_submit_email( + context: TurnContext, _state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + data = request.data if isinstance(request.data, dict) else {} + name = data.get("name", "Unknown") + email = data.get("email", "no email") + await context.send_activity( + f"Hi {name}, thanks for submitting the form! Your email is {email}." + ) + return _message_response("Multi-step form completed successfully") + + +if __name__ == "__main__": + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py index 773fb5e3..ce0e6424 100644 --- a/tests/hosting_teams/test_teams_agent_extension.py +++ b/tests/hosting_teams/test_teams_agent_extension.py @@ -7,17 +7,13 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse +from microsoft_agents.activity import Activity, ActivityTypes from microsoft_agents.activity.teams import ( MeetingParticipantsEventDetails, ReadReceiptInfo, ) from microsoft_teams.api.models import ( - AppBasedLinkQuery, - FileConsentCardResponse, MeetingDetails, - MessagingExtensionAction, - MessagingExtensionActionResponse, MessagingExtensionQuery, MessagingExtensionResponse, O365ConnectorCardActionQuery, From bcea590ae99dc89f2a9e0c50e1c406141fa40e75 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Fri, 8 May 2026 18:29:21 -0700 Subject: [PATCH 04/14] Fix for search based extension --- .../hosting/teams/teams_agent_extension.py | 3 +- .../teams_extension/TESTING_SEARCH_QUERY.md | 168 ++++++++++++++++++ .../test_teams_agent_extension.py | 16 +- 3 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 test_samples/teams_extension/TESTING_SEARCH_QUERY.md diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py index 5486addc..06516717 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py @@ -88,12 +88,13 @@ def on_query( """Register a handler for composeExtension/query invokes.""" def __selector(context: TurnContext) -> bool: + parameters = (context.activity.value or {}).get("parameters") or [{}] return ( context.activity.type == ActivityTypes.invoke and context.activity.name == "composeExtension/query" and _match_selector( command_id, - (context.activity.value or {}).get("commandId"), + parameters[0].get("value"), ) ) diff --git a/test_samples/teams_extension/TESTING_SEARCH_QUERY.md b/test_samples/teams_extension/TESTING_SEARCH_QUERY.md new file mode 100644 index 00000000..a21eafaf --- /dev/null +++ b/test_samples/teams_extension/TESTING_SEARCH_QUERY.md @@ -0,0 +1,168 @@ +# Manually testing `on_search_query` in `message_extensions_agent.py` + +The route is registered as `@TEAMS.message_extension.on_query("searchQuery")`, so +the manifest **commandId** and the Playground field must both say `searchQuery`. +The handler reads the parameter named `searchQuery` (and optionally `initialRun`). + +## Two ways to test + +| Path | Speed | What you need | Hits real Teams chrome? | +| --- | --- | --- | --- | +| **A. Agents Playground** | Fast (no Azure, no auth) | Local Playground CLI | No — simulator | +| **B. Real Teams via devtunnel** | Slower (needs Azure Bot + manifest sideload) | devtunnel, Azure Bot resource, `.zip` app manifest | Yes | + +--- + +## Path A — Microsoft 365 Agents Playground (recommended for first pass) + +### A1. One-time install (Windows) + +```powershell +winget install --id=Microsoft.M365AgentsPlayground -e +``` + +### A2. Set the env and start the agent + +```powershell +cd test_samples/teams_extension +copy env.TEMPLATE .env +# edit .env → fill CLIENTID/CLIENTSECRET/TENANTID (any valid Azure AD app reg works for Playground) +python message_extensions_agent.py +``` + +Confirm it logs `Listening on http://localhost:3978`. + +### A3. Launch Playground pointed at the agent + +In a second terminal: + +```powershell +agentsplayground -e "http://localhost:3978/api/messages" -c "emulator" +``` + +This opens a browser-based chat that emulates Teams. + +### A4. Trigger the search query + +1. Click the **+** icon in the compose area → **Search Command**. +2. Click **Specify Command ID or Parameter** and enter: + - **Command ID**: `searchQuery` ← must match the decorator argument + - **Parameter name**: `searchQuery` ← what the handler reads from `query.parameters` +3. Type a search term (e.g. `pizza`) in the search field and press Enter. +4. Playground sends a `composeExtension/query` invoke. The Log Panel on the right shows the request and response payloads. +5. The five mock results (`Search Result 1` … `Search Result 5`) appear; the Python log prints `Search query received: pizza`. + +### A5. Test the `initialRun` branch + +Some clients send `initialRun=true` when the extension first opens. Playground +doesn't surface this directly, but you can hit it by: + +- Restarting the agent. +- Opening the **Search Command** dropdown without typing — Playground auto-sends an empty query. To force `initialRun=true`, change the **Parameter name** field to `initialRun` and set its value to `true` (you'll need to send via the raw `Adaptive Card / Activity payload` panel if your Playground build supports it; otherwise verify this branch in a unit test instead — the branch is just a single `if` and is exercised by the existing test `test_on_query_handler_parses_query_and_sends_response`'s shape). + +--- + +## Path B — Real Teams client (full end-to-end) + +### B1. Tunnel localhost so Teams can reach it + +```powershell +devtunnel user login +devtunnel create me-search -a +devtunnel port create -p 3978 me-search +devtunnel host me-search +``` + +Copy the `https://-3978.usw2.devtunnels.ms` URL. + +### B2. Provision an Azure Bot resource + +```powershell +az bot create -g -n --app-type SingleTenant --appid --tenant-id -e "https://-3978.usw2.devtunnels.ms/api/messages" +az bot msteams create -g -n +``` + +Use the same `` / `` / `` you put in `.env`. + +### B3. Build the Teams app manifest + +Create `manifest.json` somewhere with at least: + +```json +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.17/MicrosoftTeams.schema.json", + "manifestVersion": "1.17", + "version": "1.0.0", + "id": "", + "packageName": "com.example.searchext", + "developer": { + "name": "Test", + "websiteUrl": "https://example.com", + "privacyUrl": "https://example.com/privacy", + "termsOfUseUrl": "https://example.com/terms" + }, + "name": { "short": "Search Ext", "full": "Search Extension Sample" }, + "description": { "short": "Test on_search_query", "full": "Test on_search_query end-to-end" }, + "icons": { "color": "color.png", "outline": "outline.png" }, + "accentColor": "#0078D4", + "composeExtensions": [ + { + "botId": "", + "commands": [ + { + "id": "searchQuery", + "type": "query", + "title": "Search", + "description": "Search demo", + "initialRun": true, + "context": ["compose", "commandBox"], + "parameters": [ + { "name": "searchQuery", "title": "Search query", "description": "Text to search", "inputType": "text" } + ] + } + ] + } + ], + "validDomains": ["-3978.usw2.devtunnels.ms"] +} +``` + +The two `searchQuery` strings — `commands[0].id` and `parameters[0].name` — are +what the route filters on. They must match the decorator arg (`"searchQuery"`) +and the parameter name read in the handler. + +Add two square PNGs (`color.png` 192×192, `outline.png` 32×32 transparent) and +zip the three files together as `app.zip`. + +### B4. Sideload and run + +1. Teams → **Apps** → **Manage your apps** → **Upload an app** → **Upload a custom app** → pick `app.zip`. +2. Open any chat. Click the **+** below the compose box → pick **Search Ext**. +3. Type `pizza` → the five mock results render as a list of Adaptive Cards. +4. Tapping a result fires `composeExtension/selectItem` (handled by `on_select_item`) — a useful sanity check that the same wiring delivers two different invokes. + +### B5. Inspect the wire traffic + +- The Python log will show `Search query received: pizza` and `Item selected: 3:pizza` etc. +- The `devtunnel host` terminal prints every HTTP request — useful for confirming `composeExtension/query` arrives with `value.commandId = "searchQuery"` and `value.parameters[0].name = "searchQuery"`. + +--- + +## Quick troubleshooting + +| Symptom | Likely cause | +| --- | --- | +| Selector doesn't fire | `commandId` mismatch — manifest `commands[0].id` ≠ decorator `"searchQuery"`. | +| Handler fires but `params` is empty | Parameter `name` in manifest ≠ what the handler reads (`searchQuery`). | +| 401 on `/api/messages` | `.env` client id/secret/tenant ≠ Azure Bot's app registration. | +| Pydantic `command_context` missing error | Real Teams always sends `commandContext`; Playground does too. If you're crafting raw payloads, include `"commandContext": "compose"`. | + +--- + +## References + +- [Debug message extension app in Microsoft 365 Agents Playground](https://learn.microsoft.com/en-us/microsoftteams/platform/toolkit/debug-message-extension-app-in-test-tool) +- [Build Search-based Message Extensions](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/messaging-extension-v3/search-extensions) +- [Respond to Search Command in Teams](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/search-commands/respond-to-search) +- [Define Search Command](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/search-commands/define-search-command) +- [Test your agent locally in Microsoft 365 Agents Playground](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/test-with-toolkit-project) diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py index ce0e6424..614d6109 100644 --- a/tests/hosting_teams/test_teams_agent_extension.py +++ b/tests/hosting_teams/test_teams_agent_extension.py @@ -220,7 +220,7 @@ def setup_method(self): self.app = _make_app() self.teams = TeamsAgentExtension(self.app) - def test_on_query_matches_by_command_id(self): + def test_on_query_matches_by_first_parameter_value(self): @self.teams.message_extension.on_query("searchCmd") async def handler(ctx, state, query): ... @@ -228,17 +228,17 @@ async def handler(ctx, state, query): ... ctx_match = _make_context( ActivityTypes.invoke, name="composeExtension/query", - value={"commandId": "searchCmd"}, + value={"parameters": [{"name": "searchQuery", "value": "searchCmd"}]}, ) ctx_no_match = _make_context( ActivityTypes.invoke, name="composeExtension/query", - value={"commandId": "otherCmd"}, + value={"parameters": [{"name": "searchQuery", "value": "other"}]}, ) assert selector(ctx_match) is True assert selector(ctx_no_match) is False - def test_on_query_no_command_id_matches_all(self): + def test_on_query_no_selector_matches_all(self): @self.teams.message_extension.on_query() async def handler(ctx, state, query): ... @@ -246,9 +246,15 @@ async def handler(ctx, state, query): ... ctx = _make_context( ActivityTypes.invoke, name="composeExtension/query", - value={"commandId": "anyCommand"}, + value={"parameters": [{"name": "searchQuery", "value": "anything"}]}, + ) + ctx_no_params = _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={}, ) assert selector(ctx) is True + assert selector(ctx_no_params) is True def test_on_query_is_invoke(self): @self.teams.message_extension.on_query() From acc26300ba9ab737a019c02010258ecb0cfc9518 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Tue, 12 May 2026 14:18:59 -0700 Subject: [PATCH 05/14] Changing minimum version for teams integration package to 3.12, appropiate changes to tests --- tests/hosting_core/errors/test_error_resources.py | 9 +++++++++ tests/hosting_teams/test_teams_agent_extension.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/tests/hosting_core/errors/test_error_resources.py b/tests/hosting_core/errors/test_error_resources.py index 05b8a720..ea2f2380 100644 --- a/tests/hosting_core/errors/test_error_resources.py +++ b/tests/hosting_core/errors/test_error_resources.py @@ -5,6 +5,8 @@ Tests for error resources and error message formatting. """ +import sys + import pytest from microsoft_agents.hosting.core.errors import ( ErrorMessage, @@ -12,6 +14,11 @@ error_resources, ) +_requires_py312_teams = pytest.mark.skipif( + sys.version_info < (3, 12), + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + class TestErrorMessage: """Tests for ErrorMessage class.""" @@ -161,6 +168,7 @@ def test_storage_blob_errors_exist(self): except ImportError: pytest.skip("Storage Blob package not available") + @_requires_py312_teams def test_teams_errors_exist(self): """Test that teams errors are defined in their package.""" try: @@ -233,6 +241,7 @@ def test_storage_error_format(self): except ImportError: pytest.skip("Storage Cosmos package not available") + @_requires_py312_teams def test_teams_error_format(self): """Test teams error formatting.""" try: diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py index 614d6109..ae668630 100644 --- a/tests/hosting_teams/test_teams_agent_extension.py +++ b/tests/hosting_teams/test_teams_agent_extension.py @@ -4,9 +4,15 @@ """ import re +import sys import pytest from unittest.mock import AsyncMock, MagicMock, patch +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 12), + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + from microsoft_agents.activity import Activity, ActivityTypes from microsoft_agents.activity.teams import ( MeetingParticipantsEventDetails, From efa51d678729352149342628fe3cc36e3232aa71 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Tue, 12 May 2026 16:38:40 -0700 Subject: [PATCH 06/14] Skipping tests based on supported versions --- .../pyproject.toml | 4 +- .../test_teams_agent_extension.py | 95 ++++++++++++++++--- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/pyproject.toml b/libraries/microsoft-agents-hosting-teams/pyproject.toml index be1c3898..3ab2c145 100644 --- a/libraries/microsoft-agents-hosting-teams/pyproject.toml +++ b/libraries/microsoft-agents-hosting-teams/pyproject.toml @@ -10,11 +10,9 @@ readme = {file = "readme.md", content-type = "text/markdown"} authors = [{name = "Microsoft Corporation"}] license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.10" +requires-python = ">=3.12" 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", diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py index ae668630..f6550a9e 100644 --- a/tests/hosting_teams/test_teams_agent_extension.py +++ b/tests/hosting_teams/test_teams_agent_extension.py @@ -8,27 +8,31 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch +is_supported_version = sys.version_info >= (3, 12) + pytestmark = pytest.mark.skipif( - sys.version_info < (3, 12), + not is_supported_version, reason="microsoft-agents-hosting-teams tests require Python 3.12+", ) from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.activity.teams import ( - MeetingParticipantsEventDetails, - ReadReceiptInfo, -) -from microsoft_teams.api.models import ( - MeetingDetails, - MessagingExtensionQuery, - MessagingExtensionResponse, - O365ConnectorCardActionQuery, - TaskModuleRequest, - TaskModuleResponse, -) from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.app import AgentApplication, RouteRank -from microsoft_agents.hosting.teams import TeamsAgentExtension + +if is_supported_version: + from microsoft_agents.activity.teams import ( + MeetingParticipantsEventDetails, + ReadReceiptInfo, + ) + from microsoft_teams.api.models import ( + MeetingDetails, + MessagingExtensionQuery, + MessagingExtensionResponse, + O365ConnectorCardActionQuery, + TaskModuleRequest, + TaskModuleResponse, + ) + from microsoft_agents.hosting.teams import TeamsAgentExtension def _make_app() -> AgentApplication: @@ -91,6 +95,7 @@ def setup_method(self): # ── Selector tests ────────────────────────────────────────────────────── + @pytestmark def test_on_fetch_no_verb_matches_any(self): @self.teams.task_module.on_fetch() async def handler(ctx, state, req): ... @@ -99,6 +104,7 @@ async def handler(ctx, state, req): ... ctx = _make_context(ActivityTypes.invoke, name="task/fetch", value={}) assert selector(ctx) is True + @pytestmark def test_on_fetch_verb_matches_exact(self): @self.teams.task_module.on_fetch("myVerb") async def handler(ctx, state, req): ... @@ -115,6 +121,7 @@ async def handler(ctx, state, req): ... assert selector(ctx_match) is True assert selector(ctx_no_match) is False + @pytestmark def test_on_fetch_verb_matches_regex(self): @self.teams.task_module.on_fetch(re.compile(r"my.*")) async def handler(ctx, state, req): ... @@ -131,6 +138,7 @@ async def handler(ctx, state, req): ... assert selector(ctx_match) is True assert selector(ctx_no_match) is False + @pytestmark def test_on_fetch_wrong_invoke_name(self): @self.teams.task_module.on_fetch() async def handler(ctx, state, req): ... @@ -139,6 +147,7 @@ async def handler(ctx, state, req): ... ctx = _make_context(ActivityTypes.invoke, name="task/submit", value={}) assert selector(ctx) is False + @pytestmark def test_on_fetch_wrong_activity_type(self): @self.teams.task_module.on_fetch() async def handler(ctx, state, req): ... @@ -147,6 +156,7 @@ async def handler(ctx, state, req): ... ctx = _make_context(ActivityTypes.message, name="task/fetch") assert selector(ctx) is False + @pytestmark def test_on_submit_selector(self): @self.teams.task_module.on_submit("submitVerb") async def handler(ctx, state, req): ... @@ -163,12 +173,14 @@ async def handler(ctx, state, req): ... assert selector(ctx_match) is True assert selector(ctx_no_match) is False + @pytestmark def test_on_fetch_is_invoke(self): @self.teams.task_module.on_fetch() async def handler(ctx, state, req): ... assert self.app._routes[0]["is_invoke"] is True + @pytestmark def test_on_submit_is_invoke(self): @self.teams.task_module.on_submit() async def handler(ctx, state, req): ... @@ -177,6 +189,7 @@ async def handler(ctx, state, req): ... # ── Handler tests ─────────────────────────────────────────────────────── + @pytestmark @pytest.mark.asyncio async def test_on_fetch_handler_passes_request_and_sends_response(self): response = TaskModuleResponse() @@ -202,6 +215,7 @@ async def handler(ctx, state, req: TaskModuleRequest): assert isinstance(args[2], TaskModuleRequest) mock_send.assert_awaited_once_with(ctx, response) + @pytestmark @pytest.mark.asyncio async def test_on_fetch_handler_skips_send_when_none_returned(self): @self.teams.task_module.on_fetch() @@ -226,6 +240,7 @@ def setup_method(self): self.app = _make_app() self.teams = TeamsAgentExtension(self.app) + @pytestmark def test_on_query_matches_by_first_parameter_value(self): @self.teams.message_extension.on_query("searchCmd") async def handler(ctx, state, query): ... @@ -244,6 +259,7 @@ async def handler(ctx, state, query): ... assert selector(ctx_match) is True assert selector(ctx_no_match) is False + @pytestmark def test_on_query_no_selector_matches_all(self): @self.teams.message_extension.on_query() async def handler(ctx, state, query): ... @@ -262,12 +278,14 @@ async def handler(ctx, state, query): ... assert selector(ctx) is True assert selector(ctx_no_params) is True + @pytestmark def test_on_query_is_invoke(self): @self.teams.message_extension.on_query() async def handler(ctx, state, query): ... assert self.app._routes[0]["is_invoke"] is True + @pytestmark def test_on_submit_action_excludes_preview(self): @self.teams.message_extension.on_submit_action() async def handler(ctx, state, action): ... @@ -286,6 +304,7 @@ async def handler(ctx, state, action): ... assert selector(ctx_normal) is True assert selector(ctx_preview) is False + @pytestmark def test_on_agent_message_preview_edit_selector(self): @self.teams.message_extension.on_agent_message_preview_edit() async def handler(ctx, state, action): ... @@ -304,6 +323,7 @@ async def handler(ctx, state, action): ... assert selector(ctx_edit) is True assert selector(ctx_send) is False + @pytestmark def test_on_agent_message_preview_send_selector(self): @self.teams.message_extension.on_agent_message_preview_send() async def handler(ctx, state, action): ... @@ -322,6 +342,7 @@ async def handler(ctx, state, action): ... assert selector(ctx_send) is True assert selector(ctx_edit) is False + @pytestmark def test_on_fetch_task_matches_command_id(self): @self.teams.message_extension.on_fetch_task("myCmd") async def handler(ctx, state, action): ... @@ -340,6 +361,7 @@ async def handler(ctx, state, action): ... assert selector(ctx_match) is True assert selector(ctx_no_match) is False + @pytestmark def test_on_query_link_selector(self): @self.teams.message_extension.on_query_link async def handler(ctx, state, query): ... @@ -350,6 +372,7 @@ async def handler(ctx, state, query): ... assert selector(ctx) is True assert selector(ctx_other) is False + @pytestmark def test_on_anonymous_query_link_selector(self): @self.teams.message_extension.on_anonymous_query_link async def handler(ctx, state, query): ... @@ -358,6 +381,7 @@ async def handler(ctx, state, query): ... ctx = _make_context(ActivityTypes.invoke, name="composeExtension/anonymousQueryLink") assert selector(ctx) is True + @pytestmark def test_on_select_item_selector(self): @self.teams.message_extension.on_select_item async def handler(ctx, state, item): ... @@ -368,6 +392,7 @@ async def handler(ctx, state, item): ... assert selector(ctx) is True assert selector(ctx_other) is False + @pytestmark def test_on_configure_settings_sends_empty_response(self): """on_configure_settings always sends a 200 regardless of handler return value.""" @@ -376,6 +401,7 @@ async def handler(ctx, state, settings): ... assert self.app._routes[0]["is_invoke"] is True + @pytestmark def test_on_card_button_clicked_selector(self): @self.teams.message_extension.on_card_button_clicked async def handler(ctx, state, data): ... @@ -384,6 +410,7 @@ async def handler(ctx, state, data): ... ctx = _make_context(ActivityTypes.invoke, name="composeExtension/onCardButtonClicked") assert selector(ctx) is True + @pytestmark @pytest.mark.asyncio async def test_on_query_handler_parses_query_and_sends_response(self): response = MessagingExtensionResponse() @@ -408,6 +435,7 @@ async def handler(ctx, state, query: MessagingExtensionQuery): assert isinstance(args[2], MessagingExtensionQuery) mock_send.assert_awaited_once_with(ctx, response) + @pytestmark @pytest.mark.asyncio async def test_on_configure_settings_always_sends_response(self): @self.teams.message_extension.on_configure_settings @@ -431,6 +459,7 @@ def setup_method(self): self.app = _make_app() self.teams = TeamsAgentExtension(self.app) + @pytestmark def test_on_start_selector(self): @self.teams.meeting.on_start async def handler(ctx, state, meeting): ... @@ -441,6 +470,7 @@ async def handler(ctx, state, meeting): ... assert selector(ctx) is True assert selector(ctx_other) is False + @pytestmark def test_on_end_selector(self): @self.teams.meeting.on_end async def handler(ctx, state, meeting): ... @@ -449,6 +479,7 @@ async def handler(ctx, state, meeting): ... ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.meetingEnd") assert selector(ctx) is True + @pytestmark def test_on_participants_join_selector(self): @self.teams.meeting.on_participants_join async def handler(ctx, state, details): ... @@ -460,6 +491,7 @@ async def handler(ctx, state, details): ... ) assert selector(ctx) is True + @pytestmark def test_on_participants_leave_selector(self): @self.teams.meeting.on_participants_leave async def handler(ctx, state, details): ... @@ -471,12 +503,14 @@ async def handler(ctx, state, details): ... ) assert selector(ctx) is True + @pytestmark def test_on_start_is_not_invoke(self): @self.teams.meeting.on_start async def handler(ctx, state, meeting): ... assert self.app._routes[0]["is_invoke"] is False + @pytestmark @pytest.mark.asyncio async def test_on_start_handler_parses_meeting_details(self): user_handler = AsyncMock() @@ -495,6 +529,7 @@ async def handler(ctx, state, meeting: MeetingDetails): args = user_handler.call_args[0] assert isinstance(args[2], MeetingDetails) + @pytestmark @pytest.mark.asyncio async def test_on_end_handler_parses_meeting_details(self): user_handler = AsyncMock() @@ -513,6 +548,7 @@ async def handler(ctx, state, meeting: MeetingDetails): args = user_handler.call_args[0] assert isinstance(args[2], MeetingDetails) + @pytestmark @pytest.mark.asyncio async def test_on_participants_join_handler_parses_details(self): user_handler = AsyncMock() @@ -540,6 +576,7 @@ def setup_method(self): # ── Message edit / undelete / soft delete ─────────────────────────────── + @pytestmark def test_on_message_edit_selector(self): @self.teams.on_message_edit async def handler(ctx, state): ... @@ -551,6 +588,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_message_edit_wrong_event_type(self): @self.teams.on_message_edit async def handler(ctx, state): ... @@ -562,6 +600,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is False + @pytestmark def test_on_message_edit_wrong_channel(self): @self.teams.on_message_edit async def handler(ctx, state): ... @@ -574,6 +613,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is False + @pytestmark def test_on_message_undelete_selector(self): @self.teams.on_message_undelete async def handler(ctx, state): ... @@ -585,6 +625,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_message_soft_delete_selector(self): @self.teams.on_message_soft_delete async def handler(ctx, state): ... @@ -598,6 +639,7 @@ async def handler(ctx, state): ... # ── Read receipt ─────────────────────────────────────────────────────── + @pytestmark def test_on_read_receipt_selector(self): @self.teams.on_read_receipt async def handler(ctx, state, receipt): ... @@ -606,6 +648,7 @@ async def handler(ctx, state, receipt): ... ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.readReceipt") assert selector(ctx) is True + @pytestmark @pytest.mark.asyncio async def test_on_read_receipt_handler_parses_receipt(self): user_handler = AsyncMock() @@ -626,6 +669,7 @@ async def handler(ctx, state, receipt: ReadReceiptInfo): # ── Config ───────────────────────────────────────────────────────────── + @pytestmark def test_on_config_fetch_selector(self): @self.teams.on_config_fetch async def handler(ctx, state, data): ... @@ -636,12 +680,14 @@ async def handler(ctx, state, data): ... assert selector(ctx) is True assert selector(ctx_other) is False + @pytestmark def test_on_config_fetch_is_invoke(self): @self.teams.on_config_fetch async def handler(ctx, state, data): ... assert self.app._routes[0]["is_invoke"] is True + @pytestmark def test_on_config_submit_selector(self): @self.teams.on_config_submit async def handler(ctx, state, data): ... @@ -652,6 +698,7 @@ async def handler(ctx, state, data): ... # ── File consent ─────────────────────────────────────────────────────── + @pytestmark def test_on_file_consent_accept_selector(self): @self.teams.on_file_consent_accept async def handler(ctx, state, data): ... @@ -670,6 +717,7 @@ async def handler(ctx, state, data): ... assert selector(ctx_accept) is True assert selector(ctx_decline) is False + @pytestmark def test_on_file_consent_decline_selector(self): @self.teams.on_file_consent_decline async def handler(ctx, state, data): ... @@ -688,6 +736,7 @@ async def handler(ctx, state, data): ... assert selector(ctx_decline) is True assert selector(ctx_accept) is False + @pytestmark @pytest.mark.asyncio async def test_on_file_consent_accept_sends_empty_response(self): @self.teams.on_file_consent_accept @@ -708,6 +757,7 @@ async def handler(ctx, state, data): ... # ── O365 ─────────────────────────────────────────────────────────────── + @pytestmark def test_on_o365_connector_card_action_selector(self): @self.teams.on_o365_connector_card_action async def handler(ctx, state, query): ... @@ -716,6 +766,7 @@ async def handler(ctx, state, query): ... ctx = _make_context(ActivityTypes.invoke, name="actionableMessage/executeAction") assert selector(ctx) is True + @pytestmark @pytest.mark.asyncio async def test_on_o365_connector_card_action_parses_query(self): user_handler = AsyncMock() @@ -740,6 +791,7 @@ async def handler(ctx, state, query: O365ConnectorCardActionQuery): # ── Conversation update events ───────────────────────────────────────── + @pytestmark def test_on_members_added_selector(self): @self.teams.on_members_added async def handler(ctx, state): ... @@ -757,6 +809,7 @@ async def handler(ctx, state): ... assert selector(ctx) is True assert selector(ctx_empty) is False + @pytestmark def test_on_members_added_requires_teams_channel(self): @self.teams.on_members_added async def handler(ctx, state): ... @@ -770,6 +823,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is False + @pytestmark def test_on_members_removed_selector(self): @self.teams.on_members_removed async def handler(ctx, state): ... @@ -782,6 +836,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_channel_created_selector(self): @self.teams.on_channel_created async def handler(ctx, state): ... @@ -798,6 +853,7 @@ async def handler(ctx, state): ... assert selector(ctx) is True assert selector(ctx_other) is False + @pytestmark def test_on_channel_deleted_selector(self): @self.teams.on_channel_deleted async def handler(ctx, state): ... @@ -809,6 +865,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_channel_renamed_selector(self): @self.teams.on_channel_renamed async def handler(ctx, state): ... @@ -820,6 +877,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_channel_restored_selector(self): @self.teams.on_channel_restored async def handler(ctx, state): ... @@ -831,6 +889,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_team_archived_selector(self): @self.teams.on_team_archived async def handler(ctx, state): ... @@ -842,6 +901,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_team_deleted_selector(self): @self.teams.on_team_deleted async def handler(ctx, state): ... @@ -853,6 +913,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_team_hard_deleted_selector(self): @self.teams.on_team_hard_deleted async def handler(ctx, state): ... @@ -864,6 +925,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_team_renamed_selector(self): @self.teams.on_team_renamed async def handler(ctx, state): ... @@ -875,6 +937,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_team_restored_selector(self): @self.teams.on_team_restored async def handler(ctx, state): ... @@ -886,6 +949,7 @@ async def handler(ctx, state): ... ) assert selector(ctx) is True + @pytestmark def test_on_team_unarchived_selector(self): @self.teams.on_team_unarchived async def handler(ctx, state): ... @@ -899,6 +963,7 @@ async def handler(ctx, state): ... # ── Sub-object accessors ──────────────────────────────────────────────── + @pytestmark def test_properties_return_sub_objects(self): from microsoft_agents.hosting.teams.teams_agent_extension import ( MessageExtension, @@ -912,6 +977,7 @@ def test_properties_return_sub_objects(self): # ── Decorator style without args ──────────────────────────────────────── + @pytestmark def test_on_message_edit_direct_decorator(self): """Verify on_message_edit works as @teams.on_message_edit (no call parens).""" @@ -920,6 +986,7 @@ async def handler(ctx, state): ... assert len(self.app._routes) == 1 + @pytestmark def test_on_message_edit_factory_decorator(self): """Verify on_message_edit works as @teams.on_message_edit() (with call parens).""" From 7c4cb5a4a3c91ba8e5e623e0e84b2ae513866409 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Tue, 12 May 2026 16:46:35 -0700 Subject: [PATCH 07/14] Adding version validation in build --- .azdo/ci-pr.yaml | 6 +++++- .github/workflows/python-package.yml | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.azdo/ci-pr.yaml b/.azdo/ci-pr.yaml index 9fa0f87a..441692c0 100644 --- a/.azdo/ci-pr.yaml +++ b/.azdo/ci-pr.yaml @@ -73,7 +73,11 @@ steps: python -m pip install ./dist/microsoft_agents_copilotstudio_client*.whl python -m pip install ./dist/microsoft_agents_hosting_aiohttp*.whl python -m pip install ./dist/microsoft_agents_hosting_dialogs*.whl - python -m pip install ./dist/microsoft_agents_hosting_teams*.whl + if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)"; then + python -m pip install ./dist/microsoft_agents_hosting_teams*.whl + else + echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" + fi python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl displayName: 'Install wheels' diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 25a753a2..17475f1e 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -62,7 +62,11 @@ jobs: python -m pip install ./dist/microsoft_agents_copilotstudio_client*.whl python -m pip install ./dist/microsoft_agents_hosting_aiohttp*.whl python -m pip install ./dist/microsoft_agents_hosting_dialogs*.whl - python -m pip install ./dist/microsoft_agents_hosting_teams*.whl + if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)"; then + python -m pip install ./dist/microsoft_agents_hosting_teams*.whl + else + echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" + fi python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl - name: Test with pytest From f8f088af55b252628a8531fa1da6ff7bf4b82b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 12 May 2026 16:52:12 -0700 Subject: [PATCH 08/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../hosting/teams/teams_agent_extension.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py index 06516717..cdcd9246 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py @@ -88,13 +88,18 @@ def on_query( """Register a handler for composeExtension/query invokes.""" def __selector(context: TurnContext) -> bool: - parameters = (context.activity.value or {}).get("parameters") or [{}] + value = context.activity.value or {} + parameters = value.get("parameters") or [{}] + selected_command_id = value.get("commandId") + if selected_command_id is None: + selected_command_id = parameters[0].get("value") + return ( context.activity.type == ActivityTypes.invoke and context.activity.name == "composeExtension/query" and _match_selector( command_id, - parameters[0].get("value"), + selected_command_id, ) ) From d58862baae59cb8b3c1cd9f0f36bf1ab4982b97a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 12 May 2026 16:52:34 -0700 Subject: [PATCH 09/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- test_samples/teams_extension/shared/start_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_samples/teams_extension/shared/start_server.py b/test_samples/teams_extension/shared/start_server.py index c7b7259d..d7b6cbcc 100644 --- a/test_samples/teams_extension/shared/start_server.py +++ b/test_samples/teams_extension/shared/start_server.py @@ -23,4 +23,4 @@ async def entry_point(req: Request) -> Response: APP["agent_app"] = agent_application APP["adapter"] = agent_application.adapter - run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + run_app(APP, host="localhost", port=int(environ.get("PORT", "3978"))) From 8d2c132066f703f195b3af1d920dcedb156471b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 12 May 2026 16:52:57 -0700 Subject: [PATCH 10/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- test_samples/teams_extension/TESTING_SEARCH_QUERY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_samples/teams_extension/TESTING_SEARCH_QUERY.md b/test_samples/teams_extension/TESTING_SEARCH_QUERY.md index a21eafaf..4c80c644 100644 --- a/test_samples/teams_extension/TESTING_SEARCH_QUERY.md +++ b/test_samples/teams_extension/TESTING_SEARCH_QUERY.md @@ -58,7 +58,7 @@ Some clients send `initialRun=true` when the extension first opens. Playground doesn't surface this directly, but you can hit it by: - Restarting the agent. -- Opening the **Search Command** dropdown without typing — Playground auto-sends an empty query. To force `initialRun=true`, change the **Parameter name** field to `initialRun` and set its value to `true` (you'll need to send via the raw `Adaptive Card / Activity payload` panel if your Playground build supports it; otherwise verify this branch in a unit test instead — the branch is just a single `if` and is exercised by the existing test `test_on_query_handler_parses_query_and_sends_response`'s shape). +- Opening the **Search Command** dropdown without typing — Playground auto-sends an empty query. To force `initialRun=true`, change the **Parameter name** field to `initialRun` and set its value to `true` (you'll need to send via the raw `Adaptive Card / Activity payload` panel if your Playground build supports it; otherwise verify this branch with a dedicated unit test that explicitly sets `initialRun=true` and validates the sample agent's behavior). --- From 63f1a9bbc2fe4494153d74f321b469aebedbf3cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 23:55:50 +0000 Subject: [PATCH 11/14] Route Teams message extension queries by commandId Agent-Logs-Url: https://github.com/microsoft/Agents-for-python/sessions/2086e66b-13ec-46ee-ae6d-0bd5f033ed3c Co-authored-by: axelsrz <5943395+axelsrz@users.noreply.github.com> --- .../hosting/teams/teams_agent_extension.py | 7 +------ tests/hosting_teams/test_teams_agent_extension.py | 6 +++--- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py index cdcd9246..5be467a7 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py @@ -89,17 +89,12 @@ def on_query( def __selector(context: TurnContext) -> bool: value = context.activity.value or {} - parameters = value.get("parameters") or [{}] - selected_command_id = value.get("commandId") - if selected_command_id is None: - selected_command_id = parameters[0].get("value") - return ( context.activity.type == ActivityTypes.invoke and context.activity.name == "composeExtension/query" and _match_selector( command_id, - selected_command_id, + value.get("commandId"), ) ) diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py index f6550a9e..ac7f1529 100644 --- a/tests/hosting_teams/test_teams_agent_extension.py +++ b/tests/hosting_teams/test_teams_agent_extension.py @@ -241,7 +241,7 @@ def setup_method(self): self.teams = TeamsAgentExtension(self.app) @pytestmark - def test_on_query_matches_by_first_parameter_value(self): + def test_on_query_matches_by_command_id(self): @self.teams.message_extension.on_query("searchCmd") async def handler(ctx, state, query): ... @@ -249,12 +249,12 @@ async def handler(ctx, state, query): ... ctx_match = _make_context( ActivityTypes.invoke, name="composeExtension/query", - value={"parameters": [{"name": "searchQuery", "value": "searchCmd"}]}, + value={"commandId": "searchCmd", "parameters": [{"name": "searchQuery", "value": "pizza"}]}, ) ctx_no_match = _make_context( ActivityTypes.invoke, name="composeExtension/query", - value={"parameters": [{"name": "searchQuery", "value": "other"}]}, + value={"commandId": "other", "parameters": [{"name": "searchQuery", "value": "searchCmd"}]}, ) assert selector(ctx_match) is True assert selector(ctx_no_match) is False From 02cf32b0571d40d5d5b9bf9714e96515c08c5283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 12 May 2026 16:59:06 -0700 Subject: [PATCH 12/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../hosting/teams/teams_agent_extension.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py index 5be467a7..f62d94dd 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py @@ -88,15 +88,22 @@ def on_query( """Register a handler for composeExtension/query invokes.""" def __selector(context: TurnContext) -> bool: - value = context.activity.value or {} - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/query" - and _match_selector( - command_id, - value.get("commandId"), + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "composeExtension/query" + ): + return False + + value = context.activity.value + command_value: Optional[str] = None + if isinstance(value, dict): + command_value = value.get("commandId") or value.get("command_id") + elif value is not None: + command_value = getattr(value, "commandId", None) or getattr( + value, "command_id", None ) - ) + + return _match_selector(command_id, command_value) def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: From 9bbeca49bed9114260475254af50936f417b3eef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 00:02:07 +0000 Subject: [PATCH 13/14] Support HOST in Teams extension sample server Agent-Logs-Url: https://github.com/microsoft/Agents-for-python/sessions/8266331d-c38d-4535-99c8-9d268cd4b16f Co-authored-by: axelsrz <5943395+axelsrz@users.noreply.github.com> --- test_samples/teams_extension/shared/start_server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test_samples/teams_extension/shared/start_server.py b/test_samples/teams_extension/shared/start_server.py index d7b6cbcc..0976d010 100644 --- a/test_samples/teams_extension/shared/start_server.py +++ b/test_samples/teams_extension/shared/start_server.py @@ -23,4 +23,8 @@ async def entry_point(req: Request) -> Response: APP["agent_app"] = agent_application APP["adapter"] = agent_application.adapter - run_app(APP, host="localhost", port=int(environ.get("PORT", "3978"))) + run_app( + APP, + host=environ.get("HOST", "localhost"), + port=int(environ.get("PORT", "3978")), + ) From 6adb4312a98ff1201089aef1fd814f04a54faa04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 12 May 2026 17:14:08 -0700 Subject: [PATCH 14/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../hosting/teams/teams_agent_extension.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py index f62d94dd..6209bdd2 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py @@ -174,10 +174,20 @@ def __selector(context: TurnContext) -> bool: or context.activity.name != "composeExtension/submitAction" ): return False - value = context.activity.value or {} - if value.get("botMessagePreviewAction"): + value = context.activity.value + if isinstance(value, dict): + bot_message_preview_action = value.get("botMessagePreviewAction") + resolved_command_id = value.get("commandId") or value.get("command_id") + else: + bot_message_preview_action = getattr( + value, "botMessagePreviewAction", None + ) + resolved_command_id = getattr(value, "commandId", None) or getattr( + value, "command_id", None + ) + if bot_message_preview_action: return False - return _match_selector(command_id, value.get("commandId")) + return _match_selector(command_id, resolved_command_id) def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: