From 37e64c00206e0649f3e5cc92adc7c7c88a0027cd Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 7 Oct 2026 10:27:16 -0700 Subject: [PATCH 1/9] Updating slack extension --- .../hosting/slack/__init__.py | 4 ++ .../hosting/slack/api/slack_api.py | 6 ++ .../hosting/slack/api/slack_model.py | 11 +-- .../hosting/slack/route_handlers.py | 49 +++++++++++++ .../hosting/slack/slack_activity.py | 31 +++++++++ .../hosting/slack/slack_agent_extension.py | 68 ++++++++++++++++--- .../hosting/slack/slack_turn_context.py | 68 +++++++++++++++++++ .../test_slack_agent_extension.py | 46 ++++++++++--- 8 files changed, 260 insertions(+), 23 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_activity.py create mode 100644 libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/__init__.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/__init__.py index 948f4e67b..30cc6c803 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/__init__.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/__init__.py @@ -3,7 +3,9 @@ Licensed under the MIT License. """ +from .slack_activity import SlackActivity from .slack_agent_extension import SlackAgentExtension +from .slack_turn_context import SlackTurnContext from .slack_helpers import ( create_conversation_id, slack_bot_id_from_conversation_id, @@ -15,7 +17,9 @@ ) __all__ = [ + "SlackActivity", "SlackAgentExtension", + "SlackTurnContext", "create_conversation_id", "slack_bot_id_from_conversation_id", "slack_channel_id_from_conversation_id", diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py index 08afe9edc..8adfed28c 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py @@ -95,6 +95,12 @@ async def call( headers["Authorization"] = f"Bearer {token}" session = self._session or ClientSession() + # TODO: ClientSession lifecycle currently creates+closes a brand-new + # aiohttp.ClientSession() on every call() when no session was supplied + # at construction, instead of owning a single long-lived session per + # SlackApi instance. Revisit alongside the TurnContext resource-lifecycle + # work being added in the in-progress A2A extension, so SlackApi can + # participate in the same managed-resource pattern. try: async with session.post( url, data=body, headers=headers, timeout=self._timeout diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py index 9c8e52bd0..c0d2e6847 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Any, Optional, Type, TypeVar, overload +from typing import Any, Type, TypeVar, overload from pydantic import BaseModel, ConfigDict @@ -45,13 +45,16 @@ def get(self, path: str) -> Any: ... @overload def get(self, path: str, default: T) -> T: ... @overload - def get(self, path: str, type_: Type[T]) -> T: ... + def get(self, path: str, *, type_: Type[T]) -> T: ... @overload def get(self, path: str, default: T, type_: Type[T]) -> T: ... def get( - self, path: str, default: Optional[T] = None, type_: Type[T] = None - ) -> Optional[T]: + self, + path: str, + default: T | None = None, + type_: Type[T] | None = None, + ) -> Any: """Get a value at the dot-notation ``path``. Supports dot separators and bracket array indexing (e.g. ``"message.attachments[0].text"``). Returns ``default`` (or ``None``) when the path does not exist. diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/route_handlers.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/route_handlers.py new file mode 100644 index 000000000..723b71ef3 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/route_handlers.py @@ -0,0 +1,49 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Protocol definitions for Slack-aware route handlers. +""" + +from __future__ import annotations + +from typing import Awaitable, Protocol + +from microsoft_agents.hosting.core import AgentApplication, TurnContext +from microsoft_agents.hosting.core.app._type_defs import RouteHandler, _StateContra + +from .slack_turn_context import SlackTurnContext + + +class SlackRouteHandler(Protocol[_StateContra]): + """Protocol for a Slack route handler that receives a :class:`SlackTurnContext`.""" + + def __call__( + self, context: SlackTurnContext, state: _StateContra, / + ) -> Awaitable[None]: + """Handle a turn with Slack context. + + :param context: Slack-aware turn context. + :param state: The current turn state. + """ + ... + + +def wrap_slack_route_handler( + handler: SlackRouteHandler[_StateContra], app: AgentApplication +) -> RouteHandler[_StateContra]: + """Adapt a :class:`SlackRouteHandler` into a plain :class:`RouteHandler`. + + Wraps *handler* so that the core routing engine (which passes a plain + :class:`TurnContext`) receives a compatible callable. + + :param handler: The Slack-specific handler to wrap. + :param app: The agent application handling the turn. + :return: A :class:`RouteHandler` that upgrades the context before delegating. + """ + + async def __func(context: TurnContext, state: _StateContra) -> None: + slack_context = SlackTurnContext(context, app) + await handler(slack_context, state) + + return __func diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_activity.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_activity.py new file mode 100644 index 000000000..d042be97e --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_activity.py @@ -0,0 +1,31 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from microsoft_agents.activity import Activity + +from .api import SlackChannelData + + +class SlackActivity(Activity): + """An :class:`Activity` extended with Slack-specific accessors. + + .. note:: + Instances of this class are produced by reassigning ``__class__`` on an + existing :class:`Activity` (see :class:`SlackTurnContext`), so no new + instance fields may be added here -- only methods/properties that derive + values from the activity's existing ``channel_data``. + """ + + @property + def slack_channel_data(self) -> SlackChannelData: + """The typed Slack channel data (envelope / interactive payload) carried + on this activity's ``channel_data``. + + :return: The parsed :class:`SlackChannelData`, or an empty instance if + the activity has no Slack channel data. + """ + return SlackChannelData.from_activity(self) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index a8782f9e6..01f46d33e 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -8,6 +8,8 @@ import re from typing import Any, Callable, Generic, Optional, Pattern, TypeVar +from typing_extensions import deprecated + from microsoft_agents.activity import ActivityTypes, Channels from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.app import AgentApplication, RouteRank @@ -19,6 +21,7 @@ SlackResponse, SlackStream, ) +from .route_handlers import SlackRouteHandler, wrap_slack_route_handler StateT = TypeVar("StateT", bound=TurnState) @@ -52,9 +55,9 @@ class SlackAgentExtension(Generic[StateT]): app = AgentApplication(options) slack = SlackAgentExtension(app) - @slack.on_message("hello") + @slack.message("hello") async def greet(context, state): - channel_data = SlackChannelData.from_activity(context.activity) + channel_data = context.activity.slack_channel_data await slack.call( context, "chat.postMessage", @@ -75,6 +78,21 @@ def __init__( self._app = application self._slack_api = slack_api or SlackApi() + async def __on_before_turn(context: TurnContext, state: StateT) -> bool: + if _is_slack_channel(context): + # Bind a per-turn SlackApi so SlackTurnContext.client and any + # out-of-band SlackAgentExtension.call()/create_stream() calls + # made during this turn resolve to the same client instance. + # + # Note: unlike the C# extension, this does not reset the + # agent's typing-indicator timer on out-of-band calls, since + # core AgentApplication does not currently expose an + # equivalent public API. + context.services.set(SlackApi, self._slack_api) + return True + + application.before_turn(__on_before_turn) + @property def slack_api(self) -> SlackApi: """The shared :class:`SlackApi` used by :meth:`call` and :meth:`create_stream`.""" @@ -121,13 +139,13 @@ async def create_stream( # ── message routes ───────────────────────────────────────────────────── - def on_message( + def message( self, select: TextSelector = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: + ) -> Callable[[SlackRouteHandler[StateT]], SlackRouteHandler[StateT]]: """Register a handler for Slack message activities. When ``select`` is ``None``, every Slack message matches; otherwise the @@ -149,10 +167,12 @@ def __selector(context: TurnContext) -> bool: return False return _matches_text(select, context.activity.text) - def __call(func: Callable) -> Callable: + def __call( + func: SlackRouteHandler[StateT], + ) -> SlackRouteHandler[StateT]: self._app.add_route( __selector, - func, + wrap_slack_route_handler(func, self._app), rank=effective_rank, auth_handlers=auth_handlers, ) @@ -160,15 +180,28 @@ def __call(func: Callable) -> Callable: return __call + @deprecated( + "SlackAgentExtension.on_message is deprecated; use SlackAgentExtension.message instead." + ) + def on_message( + self, + select: TextSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable[[SlackRouteHandler[StateT]], SlackRouteHandler[StateT]]: + """Deprecated alias for :meth:`message`.""" + return self.message(select, auth_handlers=auth_handlers, rank=rank) + # ── event routes ─────────────────────────────────────────────────────── - def on_event( + def event( self, event_name: TextSelector = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: + ) -> Callable[[SlackRouteHandler[StateT]], SlackRouteHandler[StateT]]: """Register a handler for Slack event activities. When ``event_name`` is ``None``, every Slack event matches; otherwise @@ -188,13 +221,28 @@ def __selector(context: TurnContext) -> bool: return False return _matches_text(event_name, context.activity.name) - def __call(func: Callable) -> Callable: + def __call( + func: SlackRouteHandler[StateT], + ) -> SlackRouteHandler[StateT]: self._app.add_route( __selector, - func, + wrap_slack_route_handler(func, self._app), rank=effective_rank, auth_handlers=auth_handlers, ) return func return __call + + @deprecated( + "SlackAgentExtension.on_event is deprecated; use SlackAgentExtension.event instead." + ) + def on_event( + self, + event_name: TextSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable[[SlackRouteHandler[StateT]], SlackRouteHandler[StateT]]: + """Deprecated alias for :meth:`event`.""" + return self.event(event_name, auth_handlers=auth_handlers, rank=rank) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py new file mode 100644 index 000000000..f39d029d8 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py @@ -0,0 +1,68 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import cast + +from microsoft_agents.hosting.core import AgentApplication, TurnContext + +from .api import SlackApi +from .slack_activity import SlackActivity + + +class SlackTurnContext(TurnContext): + """A context object for handling Slack-specific turn functionality. + + Wraps a plain :class:`TurnContext` so that Slack-aware route handlers + receive a typed context without changing the core routing engine. + """ + + def __init__(self, context: TurnContext, app: AgentApplication) -> None: + """Initialise the Slack turn context from a plain turn context. + + :param context: The base turn context provided by the core runtime. + :param app: The agent application that is handling the turn. + """ + super().__init__(context) + self._app = app + self._turn_state = context.turn_state + + self._original = context + + self._activity.__class__ = SlackActivity + self._slack_activity = cast(SlackActivity, self._activity) + + @property + def responded(self) -> bool: + return self._original.responded + + @responded.setter + def responded(self, value: bool): + self._original.responded = value + + @property + def streaming_response(self): + return self._original.streaming_response + + @property + def activity(self) -> SlackActivity: + """The current activity, typed as a :class:`SlackActivity`. + + :return: The turn's activity exposing Slack-specific accessors. + """ + return self._slack_activity + + @property + def client(self) -> SlackApi | None: + """The :class:`SlackApi` client bound to this turn, if any. + + :return: The per-turn :class:`SlackApi` registered on + ``services`` by :class:`SlackAgentExtension`, or ``None`` if none + was registered. + """ + if self.services.has(SlackApi): + return self.services.get(SlackApi) + return None diff --git a/tests/hosting_slack/test_slack_agent_extension.py b/tests/hosting_slack/test_slack_agent_extension.py index 13a048bbb..72ade5104 100644 --- a/tests/hosting_slack/test_slack_agent_extension.py +++ b/tests/hosting_slack/test_slack_agent_extension.py @@ -58,18 +58,18 @@ def _make_context( return context -class TestOnMessage: +class TestMessage: def setup_method(self): self.app = _make_app() self.slack = SlackAgentExtension(self.app) def test_no_text_matches_any_slack_message(self): - @self.slack.on_message() + @self.slack.message() async def handler(context, state): return True route = self.app.routes[-1] - # bare on_message should default to RouteRank.LAST + # bare message() should default to RouteRank.LAST assert route["rank"] == RouteRank.LAST # matches any Slack message assert route["selector"](_make_context(ActivityTypes.message, text="anything")) @@ -81,7 +81,7 @@ async def handler(context, state): assert not route["selector"](_make_context(ActivityTypes.event, text="x")) def test_literal_text_match(self): - @self.slack.on_message("hello") + @self.slack.message("hello") async def handler(context, state): return True @@ -90,7 +90,7 @@ async def handler(context, state): assert not sel(_make_context(ActivityTypes.message, text="bye")) def test_regex_text_match(self): - @self.slack.on_message(re.compile(r"^-stream\b.*")) + @self.slack.message(re.compile(r"^-stream\b.*")) async def handler(context, state): return True @@ -99,20 +99,20 @@ async def handler(context, state): assert not sel(_make_context(ActivityTypes.message, text="stream now")) def test_custom_rank_preserved(self): - @self.slack.on_message("hi", rank=RouteRank.FIRST) + @self.slack.message("hi", rank=RouteRank.FIRST) async def handler(context, state): return True assert self.app.routes[-1]["rank"] == RouteRank.FIRST -class TestOnEvent: +class TestEvent: def setup_method(self): self.app = _make_app() self.slack = SlackAgentExtension(self.app) def test_no_name_matches_any_slack_event(self): - @self.slack.on_event() + @self.slack.event() async def handler(context, state): return True @@ -125,7 +125,7 @@ async def handler(context, state): assert not route["selector"](_make_context(ActivityTypes.message)) def test_literal_event_name(self): - @self.slack.on_event("app_mention") + @self.slack.event("app_mention") async def handler(context, state): return True @@ -134,6 +134,34 @@ async def handler(context, state): assert not sel(_make_context(ActivityTypes.event, name="other")) +class TestDeprecatedAliases: + def setup_method(self): + self.app = _make_app() + self.slack = SlackAgentExtension(self.app) + + def test_on_message_is_deprecated_and_delegates_to_message(self): + with pytest.deprecated_call(): + + @self.slack.on_message("hello") + async def handler(context, state): + return True + + sel = self.app.routes[-1]["selector"] + assert sel(_make_context(ActivityTypes.message, text="hello")) + assert not sel(_make_context(ActivityTypes.message, text="bye")) + + def test_on_event_is_deprecated_and_delegates_to_event(self): + with pytest.deprecated_call(): + + @self.slack.on_event("app_mention") + async def handler(context, state): + return True + + sel = self.app.routes[-1]["selector"] + assert sel(_make_context(ActivityTypes.event, name="app_mention")) + assert not sel(_make_context(ActivityTypes.event, name="other")) + + class TestCall: @pytest.mark.asyncio async def test_call_delegates_to_slack_api(self): From dc620219ae332576c878c5e1c3440a4a98ef1c19 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 7 Oct 2026 10:35:33 -0700 Subject: [PATCH 2/9] Route decorator typing annotation improvement --- .../hosting/slack/slack_agent_extension.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index 01f46d33e..56e192fec 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -6,13 +6,14 @@ from __future__ import annotations import re -from typing import Any, Callable, Generic, Optional, Pattern, TypeVar +from typing import Any, Generic, Optional, Pattern, TypeVar from typing_extensions import deprecated from microsoft_agents.activity import ActivityTypes, Channels from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.app import AgentApplication, RouteRank +from microsoft_agents.hosting.core.app._type_defs import _RouteDecorator from microsoft_agents.hosting.core.app.state import TurnState from .api import ( @@ -145,7 +146,7 @@ def message( *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable[[SlackRouteHandler[StateT]], SlackRouteHandler[StateT]]: + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Register a handler for Slack message activities. When ``select`` is ``None``, every Slack message matches; otherwise the @@ -189,7 +190,7 @@ def on_message( *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable[[SlackRouteHandler[StateT]], SlackRouteHandler[StateT]]: + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Deprecated alias for :meth:`message`.""" return self.message(select, auth_handlers=auth_handlers, rank=rank) @@ -201,7 +202,7 @@ def event( *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable[[SlackRouteHandler[StateT]], SlackRouteHandler[StateT]]: + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Register a handler for Slack event activities. When ``event_name`` is ``None``, every Slack event matches; otherwise @@ -243,6 +244,6 @@ def on_event( *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable[[SlackRouteHandler[StateT]], SlackRouteHandler[StateT]]: + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Deprecated alias for :meth:`event`.""" return self.event(event_name, auth_handlers=auth_handlers, rank=rank) From 90765caab2f94cd14f526b00c4b8e5a546d9deb9 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 7 Oct 2026 13:35:40 -0700 Subject: [PATCH 3/9] Improving SlackModel.get API --- .../hosting/slack/api/slack_model.py | 41 ++-- .../hosting/slack/slack_agent_extension.py | 18 +- .../hosting/slack/slack_turn_context.py | 2 + .../microsoft-agents-hosting-slack/readme.md | 80 ++++++- .../test_slack_agent_extension.py | 225 +++++++++++++++++- tests/hosting_slack/test_slack_response.py | 88 ++++++- 6 files changed, 414 insertions(+), 40 deletions(-) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py index c0d2e6847..f687d1739 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py @@ -5,13 +5,14 @@ from __future__ import annotations -from typing import Any, Type, TypeVar, overload +from typing import Any, TypeVar, overload -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, TypeAdapter from .._path_navigator import try_get_path_value -T = TypeVar("T") +_T = TypeVar("_T") +_DefaultT = TypeVar("_DefaultT") class SlackModel(BaseModel): @@ -41,33 +42,35 @@ def _normalize_path(self, path: str) -> str: return path @overload - def get(self, path: str) -> Any: ... - @overload - def get(self, path: str, default: T) -> T: ... + def get(self, path: str, default: _DefaultT, type_: type[_T]) -> _T | _DefaultT: ... + @overload - def get(self, path: str, *, type_: Type[T]) -> T: ... + def get(self, path: str, *, type_: type[_T]) -> _T | None: ... + @overload - def get(self, path: str, default: T, type_: Type[T]) -> T: ... + def get(self, path: str, default: Any = None, type_: None = None) -> Any: ... def get( self, path: str, - default: T | None = None, - type_: Type[T] | None = None, - ) -> Any: + default: Any = None, + type_: type[Any] | None = None, + ) -> Any | None: """Get a value at the dot-notation ``path``. Supports dot separators and bracket array indexing (e.g. ``"message.attachments[0].text"``). Returns ``default`` (or ``None``) when the path does not exist. - ``type_`` is accepted for API symmetry with the C# generic ``Get`` but - is not enforced at runtime — Pydantic models keep values in their - deserialized shape. + When ``type_`` is supplied, Pydantic's ``TypeAdapter`` converts and + validates existing values, raising ``ValidationError`` on failure. + Existing null values are validated too; they do not use ``default``. + Missing-path defaults are returned unchanged, without validation. + Without ``type_``, existing values are returned as-is. + An empty path selects the entire serialized model. """ - if not path: - return self._data() - - found, value = try_get_path_value(self._data(), self._normalize_path(path)) - return value if found else default + found, value = self.try_get(path) + if not found: + return default + return TypeAdapter(type_).validate_python(value) if type_ is not None else value def try_get(self, path: str) -> tuple[bool, Any]: """Like :meth:`get`, but returns ``(found, value)``.""" diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index 56e192fec..466c2514d 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -80,8 +80,9 @@ def __init__( self._slack_api = slack_api or SlackApi() async def __on_before_turn(context: TurnContext, state: StateT) -> bool: - if _is_slack_channel(context): - # Bind a per-turn SlackApi so SlackTurnContext.client and any + if _is_slack_channel(context) and not context.services.has(SlackApi): + # Bind the default only when no per-turn client was supplied, + # so SlackTurnContext.client and any # out-of-band SlackAgentExtension.call()/create_stream() calls # made during this turn resolve to the same client instance. # @@ -120,19 +121,28 @@ async def create_stream( turn_context: TurnContext, thread_ts: Optional[str] = None, ) -> SlackStream: - """Create and start a :class:`SlackStream` for the current Slack thread.""" + """Create and start a :class:`SlackStream` for the current Slack thread. + + Requires an event envelope with a channel and either an explicit + ``thread_ts`` or an ``event.ts`` timestamp. + """ channel_data = SlackChannelData.from_activity(turn_context.activity) if channel_data.envelope is None: raise ValueError( "create_stream requires a Slack event envelope on the activity" ) + channel = channel_data.envelope.get("event.channel") + if not isinstance(channel, str) or not channel: + raise ValueError("create_stream requires a non-empty event.channel") resolved_thread_ts = thread_ts or channel_data.envelope.get("event.ts") + if not isinstance(resolved_thread_ts, str) or not resolved_thread_ts: + raise ValueError("create_stream requires a non-empty thread_ts or event.ts") api = self._slack_api if turn_context.services.has(SlackApi): api = turn_context.services.get(SlackApi) stream = SlackStream( api, - channel_data.envelope.get("event.channel"), + channel, resolved_thread_ts, channel_data.api_token or "", ) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py index f39d029d8..136eacd8a 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py @@ -18,6 +18,7 @@ class SlackTurnContext(TurnContext): Wraps a plain :class:`TurnContext` so that Slack-aware route handlers receive a typed context without changing the core routing engine. + Turn state and buffered replies are shared with the original context. """ def __init__(self, context: TurnContext, app: AgentApplication) -> None: @@ -29,6 +30,7 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None: super().__init__(context) self._app = app self._turn_state = context.turn_state + self.buffered_reply_activities = context.buffered_reply_activities self._original = context diff --git a/libraries/microsoft-agents-hosting-slack/readme.md b/libraries/microsoft-agents-hosting-slack/readme.md index e3ebae1a1..c01226138 100644 --- a/libraries/microsoft-agents-hosting-slack/readme.md +++ b/libraries/microsoft-agents-hosting-slack/readme.md @@ -104,35 +104,93 @@ pip install microsoft-agents-hosting-slack ## Usage +Given configured application options (including storage) and a connection manager: + ```python from microsoft_agents.hosting.core.app import AgentApplication -from microsoft_agents.hosting.slack import SlackAgentExtension -from microsoft_agents.hosting.slack.api import SlackChannelData +from microsoft_agents.hosting.core.app.state import TurnState +from microsoft_agents.hosting.slack import SlackAgentExtension, SlackTurnContext -app = AgentApplication(options) +app = AgentApplication[TurnState](options, connection_manager=connections) slack = SlackAgentExtension(app) -@slack.on_message() -async def on_slack_message(context, state): - channel_data = SlackChannelData.from_activity(context.activity) +@slack.message() +async def on_slack_message(context: SlackTurnContext, state: TurnState) -> None: + channel_data = context.activity.slack_channel_data await slack.call( context, "chat.postMessage", { - "channel": channel_data.get("event.channel"), + "channel": channel_data.channel, "text": f"You said: {context.activity.text}", }, - token=channel_data.api_token, + token=channel_data.api_token or "", ) ``` +### Routes and turn context + +Use `@slack.message("hello")` for a specific message or `@slack.event("reaction_added")` +for a specific event. Both decorators match only Slack activities. String selectors +are case-sensitive exact matches; compiled regular expressions must match the entire +text or event name. Omitting the selector matches any Slack message/event and defaults +to `RouteRank.LAST`. Both decorators accept `auth_handlers` and an explicit `rank`. + +Handlers receive a `SlackTurnContext` whose `activity` is a `SlackActivity`. The wrapper +shares turn state, services, and buffered replies with the original context, and +forwards the response flag and streaming-response accessor to it. + +`context.client` exposes the `SlackApi` registered in turn services. Before each Slack +turn, the extension registers its shared default client only if no client is already +present. A client supplied by middleware or an earlier `before_turn` handler is +preserved, and `context.client`, `slack.call(...)`, and `slack.create_stream(...)` use +that same instance. A manually constructed context without a registered client returns +`None` from `client`. Pass `slack_api=...` to `SlackAgentExtension` to configure its +default client. + +`slack.create_stream(context, thread_ts=...)` requires a Slack event envelope with +a non-empty channel ID and either an explicit thread timestamp or `event.ts`. +Missing channel or timestamp values raise `ValueError` before making an API call. + +`on_message()` and `on_event()` remain available as deprecated aliases and emit +`DeprecationWarning`; new code should use `message()` and `event()`. + +### Channel data and path lookups + +`context.activity.slack_channel_data` exposes `envelope`, `payload`, `api_token`, +`channel`, and `thread_ts`. For a plain `TurnContext`, use +`SlackChannelData.from_activity(context.activity)`. + +Path navigation belongs to the envelope, payload, and response models, not to +`SlackChannelData` itself. For example, when an envelope is present, +`channel_data.envelope.get("event.channel")` reads its channel and +`channel_data.envelope.get("event.blocks[0].type")` reads a nested array field. +Unknown Slack fields are preserved. + +`get(path)` returns the value or `None` when absent; `get(path, default=...)` uses the +default only for a missing path, not for an existing null value. Supply `type_` to +convert and validate an existing value with Pydantic's `TypeAdapter`, for example +`response.get("count", type_=int)` or `response.get("event", type_=EventContent)`. +Invalid values (including null when the requested type does not accept it) raise +`pydantic.ValidationError`, even when a default is provided. Missing-path defaults +are returned unchanged, without validation. An empty path selects the entire +serialized model and applies the same validation. + +Typed overloads return the requested type plus `None` when no default is supplied, +or the requested type plus the default's type when one is supplied. Without `type_`, +values remain dynamically typed. `try_get(path)` returns a raw `(found, value)` +tuple without conversion or validation. + ## Key Classes -- **`SlackAgentExtension`** — registers Slack-channel-scoped message/event handlers; exposes `call(...)` and `create_stream(...)`. -- **`SlackChannelData`** — typed wrapper around Bot Service's Slack channel-data payload with `get(path)` / `try_get(path)` accessors. +- **`SlackAgentExtension`** — registers Slack-channel-scoped `message()` / `event()` handlers; exposes `call(...)` and `create_stream(...)`. +- **`SlackTurnContext`** — Slack-aware turn context exposing the typed activity and registered API client. +- **`SlackActivity`** — activity exposing typed `slack_channel_data`. +- **`SlackChannelData`** — typed wrapper around Bot Service's Slack channel-data payload, with envelope and interactive-payload accessors. +- **`SlackModel`** — base for envelope, payload, and response models with `get(path)` / `try_get(path)` accessors. - **`SlackApi`** — async HTTP client for the Slack Web API. - **`SlackStream`** — wraps Slack's streaming methods for incremental message updates. -- **`SlackHelpers`** — encode/decode and conversation-id parsing utilities. +- **Helper functions** — encode/decode and conversation-id parsing utilities. # Quick Links diff --git a/tests/hosting_slack/test_slack_agent_extension.py b/tests/hosting_slack/test_slack_agent_extension.py index 72ade5104..5ff1a6c59 100644 --- a/tests/hosting_slack/test_slack_agent_extension.py +++ b/tests/hosting_slack/test_slack_agent_extension.py @@ -6,15 +6,32 @@ from __future__ import annotations import re -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, call import pytest -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ChannelId, + ConversationAccount, + DeliveryModes, +) +from microsoft_agents.hosting.core import ( + ChannelServiceAdapter, + MemoryStorage, + TurnContext, +) from microsoft_agents.hosting.core.app import AgentApplication, RouteRank -from microsoft_agents.hosting.slack import SlackAgentExtension -from microsoft_agents.hosting.slack.api import SlackApi +from microsoft_agents.hosting.core.app.state import TurnState +from microsoft_agents.hosting.core.authorization import Connections +from microsoft_agents.hosting.slack import ( + SlackActivity, + SlackAgentExtension, + SlackTurnContext, +) +from microsoft_agents.hosting.slack.api import SlackApi, SlackResponse def _make_app() -> MagicMock: @@ -196,3 +213,201 @@ async def test_call_prefers_turn_context_service_when_present(self): ctx.services.get.assert_called_once_with(SlackApi) per_turn_api.call.assert_awaited_once() default_api.call.assert_not_awaited() + + +class TestTurnPipeline: + def setup_method(self): + self.app = AgentApplication( + storage=MemoryStorage(), + connection_manager=MagicMock(spec=Connections), + ) + self.adapter = ChannelServiceAdapter(None) + self.context = TurnContext( + self.adapter, + Activity( + type=ActivityTypes.message, + channel_id="slack", + text="hello", + name="hello", + service_url="https://example.invalid", + conversation=ConversationAccount(id="conversation"), + from_property=ChannelAccount(id="user"), + recipient=ChannelAccount(id="bot"), + ), + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("route_name", ["message", "event"]) + async def test_registered_handler_receives_slack_context(self, route_name): + app = _make_app() + slack = SlackAgentExtension(app) + client = SlackApi() + self.context.services.set(SlackApi, client) + self.context.activity.type = route_name + state = TurnState() + + async def handler(context, received_state): + assert isinstance(context, SlackTurnContext) + assert isinstance(context.activity, SlackActivity) + assert context.activity is self.context.activity + assert context.services is self.context.services + assert context.client is client + assert received_state is state + + delegate = AsyncMock(side_effect=handler) + register = slack.message if route_name == "message" else slack.event + assert register("hello")(delegate) is delegate + + await app.routes[-1]["handler"](self.context, state) + + delegate.assert_awaited_once() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "activity_type", [ActivityTypes.message, ActivityTypes.event] + ) + async def test_wrapped_handlers_preserve_buffered_replies(self, activity_type): + slack = SlackAgentExtension(self.app) + self.context.activity.type = activity_type + self.context.activity.delivery_mode = DeliveryModes.expect_replies + self.context.buffered_reply_activities.append( + Activity(type=ActivityTypes.message, text="before") + ) + register = ( + slack.message if activity_type == ActivityTypes.message else slack.event + ) + + @register("hello") + async def handler(context, state): + assert isinstance(context, SlackTurnContext) + assert isinstance(context.activity, SlackActivity) + assert context.turn_state is self.context.turn_state + await context.send_activity("first") + await context.send_activities( + [ + Activity(type=ActivityTypes.message, text="second"), + Activity(type=ActivityTypes.message, text="third"), + ] + ) + + await self.app.on_turn(self.context) + + assert self.context.responded + response = self.adapter._process_turn_results(self.context) + assert response.status == 200 + assert [activity["text"] for activity in response.body["activities"]] == [ + "before", + "first", + "second", + "third", + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("client_source", ["default", "services", "before_turn"]) + async def test_client_binding_through_turn_pipeline(self, client_source): + default_api = MagicMock(spec=SlackApi) + per_turn_api = MagicMock(spec=SlackApi) + if client_source == "services": + self.context.services.set(SlackApi, per_turn_api) + elif client_source == "before_turn": + + @self.app.before_turn + async def bind_client(context, state): + context.services.set(SlackApi, per_turn_api) + return True + + slack = SlackAgentExtension(self.app, slack_api=default_api) + expected_api = default_api if client_source == "default" else per_turn_api + unused_api = per_turn_api if client_source == "default" else default_api + expected_api.call.return_value = SlackResponse(ok=True, ts="stream-ts") + self.context.activity.channel_data = { + "SlackMessage": {"event": {"channel": "C1", "ts": "thread-ts"}}, + "ApiToken": "test-token", + } + + @slack.message("hello") + async def handler(context, state): + assert context.client is expected_api + await slack.call(context, "auth.test") + await slack.create_stream(context) + + await self.app.on_turn(self.context) + + assert self.context.services.get(SlackApi) is expected_api + expected_api.call.assert_has_awaits( + [ + call("auth.test", None, ""), + call( + "chat.startStream", + { + "channel": "C1", + "thread_ts": "thread-ts", + "task_display_mode": "plan", + }, + "test-token", + ), + ] + ) + assert expected_api.call.await_count == 2 + unused_api.call.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize("has_client", [False, True]) + async def test_non_slack_turn_does_not_change_client_binding(self, has_client): + self.context.activity.channel_id = ChannelId("msteams") + per_turn_api = MagicMock(spec=SlackApi) + if has_client: + self.context.services.set(SlackApi, per_turn_api) + SlackAgentExtension(self.app) + handler = AsyncMock() + self.app.message("hello")(handler) + + await self.app.on_turn(self.context) + + handler.assert_awaited_once() + assert self.context.services.get(SlackApi) is ( + per_turn_api if has_client else None + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "event, error", + [ + ({"ts": "thread-ts"}, "non-empty event.channel"), + ({"channel": "", "ts": "thread-ts"}, "non-empty event.channel"), + ({"channel": "C1"}, "non-empty thread_ts or event.ts"), + ({"channel": "C1", "ts": ""}, "non-empty thread_ts or event.ts"), + ], + ) + async def test_create_stream_requires_channel_and_timestamp(self, event, error): + api = MagicMock(spec=SlackApi) + slack = SlackAgentExtension(self.app, slack_api=api) + self.context.activity.channel_data = {"SlackMessage": {"event": event}} + + with pytest.raises(ValueError, match=re.escape(error)): + await slack.create_stream(self.context) + + api.call.assert_not_awaited() + + @pytest.mark.asyncio + async def test_create_stream_accepts_explicit_timestamp_without_event_timestamp( + self, + ): + api = MagicMock(spec=SlackApi) + api.call.return_value = SlackResponse(ok=True, ts="stream-ts") + slack = SlackAgentExtension(self.app, slack_api=api) + self.context.activity.channel_data = { + "SlackMessage": {"event": {"channel": "C1"}} + } + + await slack.create_stream(self.context, thread_ts="explicit-ts") + + api.call.assert_awaited_once_with( + "chat.startStream", + { + "channel": "C1", + "thread_ts": "explicit-ts", + "task_display_mode": "plan", + }, + "", + ) diff --git a/tests/hosting_slack/test_slack_response.py b/tests/hosting_slack/test_slack_response.py index 2f0815525..75a9a5fb5 100644 --- a/tests/hosting_slack/test_slack_response.py +++ b/tests/hosting_slack/test_slack_response.py @@ -3,7 +3,13 @@ Licensed under the MIT License. """ -from microsoft_agents.hosting.slack.api import SlackResponse +from typing import Any + +import pytest +from pydantic import ValidationError +from typing_extensions import assert_type + +from microsoft_agents.hosting.slack.api import EventContent, SlackResponse class TestSlackResponse: @@ -44,3 +50,83 @@ def test_try_get(self): assert found and value == "missing_charset" found, _ = r.try_get("nope") assert not found + + def test_get_with_type_hint_returns_none_or_default_for_missing_path(self): + r = SlackResponse(ok=True) + + assert r.get("missing", type_=str) is None + assert r.get("missing", default="fallback", type_=str) == "fallback" + assert r.get("missing", "fallback", str) == "fallback" + assert r.get("missing", default="fallback", type_=int) == "fallback" + + def test_get_overload_return_types(self): + r = SlackResponse.model_validate({"ok": True, "count": "42"}) + + assert assert_type(r.get("count", type_=int), int | None) == 42 + assert assert_type(r.get("count", 0, int), int) == 42 + assert ( + assert_type(r.get("missing", default="fallback", type_=int), int | str) + == "fallback" + ) + assert assert_type(r.get("missing", None, int), int | None) is None + assert assert_type(r.get("count", default=0), Any) == "42" + + @pytest.mark.parametrize( + ("value", "type_", "expected"), + [ + ("42", int, 42), + ("false", bool, False), + ("hello", str, "hello"), + (["1", "2"], list[int], [1, 2]), + ], + ) + def test_get_converts_and_validates_values(self, value, type_, expected): + r = SlackResponse.model_validate({"ok": True, "value": value}) + + result = r.get("value", type_=type_) + + assert result == expected + assert type(result) is type(expected) + assert r.get("value") == value + assert r.try_get("value") == (True, value) + + def test_get_converts_nested_value_to_model(self): + r = SlackResponse.model_validate( + {"ok": True, "messages": [{"type": "message", "text": "hello"}]} + ) + + message = assert_type( + r.get("messages[0]", type_=EventContent), EventContent | None + ) + + assert isinstance(message, EventContent) + assert message.type == "message" + assert message.text == "hello" + + def test_get_rejects_invalid_values_even_with_default(self): + r = SlackResponse.model_validate({"ok": True, "message": {"text": "hello"}}) + + with pytest.raises(ValidationError): + r.get("message", type_=str) + with pytest.raises(ValidationError): + r.get("message.text", default=0, type_=int) + with pytest.raises(ValidationError): + r.get("message.text", type_=EventContent) + + def test_get_empty_path_validates_entire_model(self): + r = SlackResponse(ok=True) + + assert r.get("") == r.model_dump(mode="json", by_alias=True) + result = r.get("", type_=SlackResponse) + assert isinstance(result, SlackResponse) + assert result == r + with pytest.raises(ValidationError): + r.get("", type_=int) + + def test_get_existing_null_does_not_use_default(self): + r = SlackResponse(ok=True, error=None) + + assert r.get("error", default="fallback") is None + assert r.get("error", default="fallback", type_=type(None)) is None + with pytest.raises(ValidationError): + r.get("error", default="fallback", type_=str) From dee04c53e57d45ba5c8340c3459ee8295a7ecb4a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 12 Oct 2026 14:34:33 -0700 Subject: [PATCH 4/9] Small improvements --- .../hosting/core/app/__init__.py | 1 + .../hosting/slack/_path_navigator.py | 4 +- .../hosting/slack/api/action_payload.py | 10 +- .../hosting/slack/api/chunks.py | 8 +- .../hosting/slack/api/event_content.py | 26 ++-- .../hosting/slack/api/event_envelope.py | 28 ++-- .../hosting/slack/api/slack_api.py | 4 +- .../hosting/slack/api/slack_channel_data.py | 36 +++-- .../hosting/slack/api/slack_model.py | 18 ++- .../hosting/slack/api/slack_response.py | 12 +- .../hosting/slack/api/slack_stream.py | 8 +- .../hosting/slack/slack_agent_extension.py | 20 +-- .../hosting/slack/slack_helpers.py | 18 ++- .../microsoft-agents-hosting-slack/readme.md | 11 +- .../test_slack_agent_extension.py | 112 ++++++++++++++++ .../hosting_slack/test_slack_channel_data.py | 125 +++++++++++++++++- tests/hosting_slack/test_slack_response.py | 17 ++- 17 files changed, 359 insertions(+), 99 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py index 053b72dc7..07ca96394 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py @@ -45,6 +45,7 @@ "InputFileDownloader", "Query", "Route", + "RouteRank", "RouteHandler", "TypingChannelStrategy", "TypingIndicator", diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py index f7cc736ba..6629bc207 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py @@ -5,10 +5,10 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any -def _parse_path(path: str) -> Optional[list[int | str]]: +def _parse_path(path: str) -> list[int | str] | None: """Tokenize a dot/bracket path into a list of segments. Supports ``a.b.c``, ``a[0].b``, ``a[1][2]``. Integer brackets become ``int`` diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/action_payload.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/action_payload.py index fd7a21e06..1ff9919c1 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/action_payload.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/action_payload.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from pydantic import ConfigDict @@ -20,7 +20,7 @@ class ActionPayload(SlackModel): model_config = ConfigDict(extra="allow", populate_by_name=True) - type: Optional[str] = None - channel: Optional[Any] = None - message: Optional[Any] = None - actions: Optional[Any] = None + type: str | None = None + channel: Any | None = None + message: Any | None = None + actions: Any | None = None diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/chunks.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/chunks.py index 1473660da..9e8e8766f 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/chunks.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/chunks.py @@ -8,7 +8,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, ConfigDict, Field @@ -66,9 +66,9 @@ class TaskUpdateChunk(BaseModel): id: str title: str status: str = SlackTaskStatus.IN_PROGRESS - details: Optional[str] = None - output: Optional[str] = None - sources: Optional[list[Source]] = None + details: str | None = None + output: str | None = None + sources: list[Source] | None = None # Type alias for any chunk variant. diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_content.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_content.py index 3adda2e74..5a9311cf9 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_content.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_content.py @@ -5,8 +5,6 @@ from __future__ import annotations -from typing import Optional - from pydantic import ConfigDict from .slack_model import SlackModel @@ -27,19 +25,19 @@ class EventContent(SlackModel): model_config = ConfigDict(extra="allow", populate_by_name=True) # ── Common event fields (https://docs.slack.dev/apis/events-api/#event-type-structure) - type: Optional[str] = None - event_ts: Optional[str] = None - user: Optional[str] = None - ts: Optional[str] = None - subtype: Optional[str] = None - channel: Optional[str] = None - channel_type: Optional[str] = None - team: Optional[str] = None + type: str | None = None + event_ts: str | None = None + user: str | None = None + ts: str | None = None + subtype: str | None = None + channel: str | None = None + channel_type: str | None = None + team: str | None = None # ── message event fields ── - text: Optional[str] = None - client_msg_id: Optional[str] = None + text: str | None = None + client_msg_id: str | None = None # ── reaction_added / reaction_removed event fields ── - reaction: Optional[str] = None - item_user: Optional[str] = None + reaction: str | None = None + item_user: str | None = None diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_envelope.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_envelope.py index 9883b17bf..efb1f9a0e 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_envelope.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_envelope.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from pydantic import ConfigDict, Field @@ -31,21 +31,21 @@ class EventEnvelope(SlackModel): model_config = ConfigDict(extra="allow", populate_by_name=True) - token: Optional[str] = None - team_id: Optional[str] = None - context_team_id: Optional[str] = None - context_enterprise_id: Optional[str] = None - api_app_id: Optional[str] = None + token: str | None = None + team_id: str | None = None + context_team_id: str | None = None + context_enterprise_id: str | None = None + api_app_id: str | None = None # Python name `event_content` ↔ JSON field `event` - event_content: Optional[EventContent] = Field(default=None, alias="event") - - type: Optional[str] = None - event_id: Optional[str] = None - event_time: Optional[int] = None - authorizations: Optional[Any] = None - is_ext_shared_channel: Optional[bool] = None - event_context: Optional[str] = None + event_content: EventContent | None = Field(default=None, alias="event") + + type: str | None = None + event_id: str | None = None + event_time: int | None = None + authorizations: Any | None = None + is_ext_shared_channel: bool | None = None + event_context: str | None = None def _normalize_path(self, path: str) -> str: """Map the C# property alias ``event_content`` to JSON field ``event``.""" diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py index 8adfed28c..6392c98e2 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py @@ -6,7 +6,7 @@ from __future__ import annotations import json -from typing import Any, Optional +from typing import Any from aiohttp import ClientSession, ClientTimeout from pydantic import BaseModel @@ -58,7 +58,7 @@ class SlackApi: def __init__( self, - session: Optional[ClientSession] = None, + session: ClientSession | None = None, *, base_url: str = SLACK_API_BASE, request_timeout: float = 30.0, diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py index eb28fcd28..d7de63b1c 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py @@ -5,7 +5,8 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any +from typing_extensions import Self from pydantic import BaseModel, ConfigDict, Field @@ -25,32 +26,41 @@ class SlackChannelData(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) - envelope: Optional[EventEnvelope] = Field(default=None, alias="SlackMessage") - payload: Optional[ActionPayload] = Field(default=None, alias="Payload") - api_token: Optional[str] = Field(default=None, alias="ApiToken") + envelope: EventEnvelope | None = Field(default=None, alias="SlackMessage") + payload: ActionPayload | None = Field(default=None, alias="Payload") + api_token: str | None = Field(default=None, alias="ApiToken") @property - def channel(self) -> Optional[str]: + def channel(self) -> str | None: """Slack channel id, sourced from the envelope or action payload.""" + obj: Any = None if self.envelope is not None: - return self.envelope.get("event.channel") - if self.payload is not None: - return self.payload.get("channel") + obj = self.envelope.get("event.channel") + if isinstance(obj, dict) and "id" in obj: + obj = obj.get("id") + elif self.payload is not None: + obj = self.payload.get("channel") + if isinstance(obj, dict) and "id" in obj: + obj = obj.get("id") + if not isinstance(obj, str): + obj = self.payload.get("container.channel_id") + if isinstance(obj, str): + return obj return None @property - def thread_ts(self) -> Optional[str]: + def thread_ts(self) -> str | None: """Thread timestamp, sourced from the envelope event or payload message.""" if self.envelope is not None: - return self.envelope.get("event.thread_ts") or self.envelope.get("event.ts") + return self.envelope.get("event.thread_ts", type_=str) or self.envelope.get("event.ts", type_=str) if self.payload is not None: - return self.payload.get("message.thread_ts") or self.payload.get( - "message.ts" + return self.payload.get("message.thread_ts", type_=str) or self.payload.get( + "message.ts", type_=str ) return None @classmethod - def from_activity(cls, activity: Any) -> "SlackChannelData": + def from_activity(cls, activity: Any) -> Self: """Build a :class:`SlackChannelData` from an Activity's ``channel_data``. Accepts ``Activity.channel_data`` that is either a ``dict`` (typical for diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py index f687d1739..887097867 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py @@ -5,6 +5,7 @@ from __future__ import annotations +from types import NoneType from typing import Any, TypeVar, overload from pydantic import BaseModel, ConfigDict, TypeAdapter @@ -53,23 +54,26 @@ def get(self, path: str, default: Any = None, type_: None = None) -> Any: ... def get( self, path: str, - default: Any = None, - type_: type[Any] | None = None, - ) -> Any | None: + default: _DefaultT | None = None, + type_: type[_T] | None = None, + ) -> _T | _DefaultT | None: """Get a value at the dot-notation ``path``. Supports dot separators and bracket array indexing (e.g. ``"message.attachments[0].text"``). Returns ``default`` (or ``None``) when the path does not exist. When ``type_`` is supplied, Pydantic's ``TypeAdapter`` converts and - validates existing values, raising ``ValidationError`` on failure. - Existing null values are validated too; they do not use ``default``. - Missing-path defaults are returned unchanged, without validation. - Without ``type_``, existing values are returned as-is. + validates non-null values, raising ``ValidationError`` on failure. + Existing null values return ``default`` (or ``None``) for typed lookups, + except when ``type_`` is ``type(None)``, which validates null directly. + Defaults are returned unchanged, without validation. + Without ``type_``, existing values are returned as-is, including null. An empty path selects the entire serialized model. """ found, value = self.try_get(path) if not found: return default + if type_ is not None and type_ is not NoneType and value is None: + return default return TypeAdapter(type_).validate_python(value) if type_ is not None else value def try_get(self, path: str) -> tuple[bool, Any]: diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_response.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_response.py index bc719fb7c..d84763378 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_response.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_response.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from pydantic import ConfigDict @@ -28,15 +28,15 @@ class SlackResponse(SlackModel): model_config = ConfigDict(extra="allow", populate_by_name=True) ok: bool = False - error: Optional[str] = None - warning: Optional[str] = None - ts: Optional[str] = None - response_metadata: Optional[Any] = None + error: str | None = None + warning: str | None = None + ts: str | None = None + response_metadata: Any | None = None class SlackResponseException(Exception): """Raised when a Slack Web API call returns a non-2xx status or ``ok=false``.""" - def __init__(self, message: str, response: Optional[SlackResponse] = None) -> None: + def __init__(self, message: str, response: SlackResponse | None = None) -> None: super().__init__(message) self.response = response diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py index ec9f352b2..81496a79e 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py @@ -6,7 +6,7 @@ from __future__ import annotations import json -from typing import Any, Optional +from typing import Any from pydantic import BaseModel @@ -41,7 +41,7 @@ def __init__( self._channel = channel self._thread_ts = thread_ts self._token = token - self._message_ts: Optional[str] = None + self._message_ts: str | None = None async def start( self, task_display_mode: str = TaskDisplayMode.PLAN @@ -106,7 +106,7 @@ async def append( async def stop( self, - chunks: Optional[list[BaseModel]] = None, + chunks: list[BaseModel] | None = None, blocks: str | list[Any] | dict | None = None, ) -> None: """Stop the active stream, optionally finalizing with chunks and/or @@ -139,7 +139,7 @@ async def stop( await self._slack_api.call("chat.stopStream", body, self._token) @staticmethod - def _resolve_blocks(blocks: Any) -> Optional[list[Any]]: + def _resolve_blocks(blocks: Any) -> list[Any] | None: if blocks is None: return None if isinstance(blocks, str): diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index 466c2514d..144921ba3 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -6,7 +6,7 @@ from __future__ import annotations import re -from typing import Any, Generic, Optional, Pattern, TypeVar +from typing import Any, Generic, Pattern, TypeVar from typing_extensions import deprecated @@ -29,7 +29,7 @@ TextSelector = str | Pattern[str] | None -def _matches_text(selector: TextSelector, text: Optional[str]) -> bool: +def _matches_text(selector: TextSelector, text: str | None) -> bool: if selector is None: return True if text is None: @@ -74,7 +74,7 @@ def __init__( self, application: AgentApplication[StateT], *, - slack_api: Optional[SlackApi] = None, + slack_api: SlackApi | None = None, ) -> None: self._app = application self._slack_api = slack_api or SlackApi() @@ -114,12 +114,14 @@ async def call( api = self._slack_api if turn_context is not None and turn_context.services.has(SlackApi): api = turn_context.services.get(SlackApi) + if api is None: + raise RuntimeError("No SlackApi instance available") return await api.call(method, options, token) async def create_stream( self, turn_context: TurnContext, - thread_ts: Optional[str] = None, + thread_ts: str | None = None, ) -> SlackStream: """Create and start a :class:`SlackStream` for the current Slack thread. @@ -140,6 +142,8 @@ async def create_stream( api = self._slack_api if turn_context.services.has(SlackApi): api = turn_context.services.get(SlackApi) + if api is None: + raise RuntimeError("No SlackApi instance available") stream = SlackStream( api, channel, @@ -154,7 +158,7 @@ def message( self, select: TextSelector = None, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Register a handler for Slack message activities. @@ -198,7 +202,7 @@ def on_message( self, select: TextSelector = None, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Deprecated alias for :meth:`message`.""" @@ -210,7 +214,7 @@ def event( self, event_name: TextSelector = None, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Register a handler for Slack event activities. @@ -252,7 +256,7 @@ def on_event( self, event_name: TextSelector = None, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Deprecated alias for :meth:`event`.""" diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_helpers.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_helpers.py index b2f4ce4fe..9bbcf2044 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_helpers.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_helpers.py @@ -5,17 +5,15 @@ from __future__ import annotations -from typing import Optional - -def slack_encode(value: Optional[str]) -> Optional[str]: +def slack_encode(value: str | None) -> str | None: """Encode text for Slack. See https://api.slack.com/docs/message-formatting.""" if value is None: return None return value.replace("&", "&").replace("<", "<").replace(">", ">") -def slack_decode(value: Optional[str]) -> Optional[str]: +def slack_decode(value: str | None) -> str | None: """Decode text from Slack. See https://api.slack.com/docs/message-formatting.""" if value is None: return None @@ -26,7 +24,7 @@ def create_conversation_id( slack_bot_id: str, slack_team_id: str, slack_channel_id: str, - slack_thread_ts: Optional[str] = None, + slack_thread_ts: str | None = None, ) -> str: """Compose a Bot Service-compatible conversation id from Slack identifiers.""" if slack_thread_ts is None: @@ -34,7 +32,7 @@ def create_conversation_id( return f"{slack_bot_id}:{slack_team_id}:{slack_channel_id}:{slack_thread_ts}" -def _from_conversation_id(conversation_id: str, pos: int) -> Optional[str]: +def _from_conversation_id(conversation_id: str, pos: int) -> str | None: if conversation_id is None or not conversation_id.strip(): raise ValueError("conversation_id must be a non-empty string") parts = conversation_id.split(":") @@ -45,17 +43,17 @@ def _from_conversation_id(conversation_id: str, pos: int) -> Optional[str]: return parts[pos] -def slack_bot_id_from_conversation_id(conversation_id: str) -> Optional[str]: +def slack_bot_id_from_conversation_id(conversation_id: str) -> str | None: return _from_conversation_id(conversation_id, 0) -def slack_team_id_from_conversation_id(conversation_id: str) -> Optional[str]: +def slack_team_id_from_conversation_id(conversation_id: str) -> str | None: return _from_conversation_id(conversation_id, 1) -def slack_channel_id_from_conversation_id(conversation_id: str) -> Optional[str]: +def slack_channel_id_from_conversation_id(conversation_id: str) -> str | None: return _from_conversation_id(conversation_id, 2) -def slack_thread_ts_from_conversation_id(conversation_id: str) -> Optional[str]: +def slack_thread_ts_from_conversation_id(conversation_id: str) -> str | None: return _from_conversation_id(conversation_id, 3) diff --git a/libraries/microsoft-agents-hosting-slack/readme.md b/libraries/microsoft-agents-hosting-slack/readme.md index c01226138..02d892b57 100644 --- a/libraries/microsoft-agents-hosting-slack/readme.md +++ b/libraries/microsoft-agents-hosting-slack/readme.md @@ -167,12 +167,13 @@ Path navigation belongs to the envelope, payload, and response models, not to `channel_data.envelope.get("event.blocks[0].type")` reads a nested array field. Unknown Slack fields are preserved. -`get(path)` returns the value or `None` when absent; `get(path, default=...)` uses the -default only for a missing path, not for an existing null value. Supply `type_` to -convert and validate an existing value with Pydantic's `TypeAdapter`, for example +`get(path)` returns the value or `None` when absent. Without `type_`, a default +applies only to missing paths; existing null values remain `None`. Supply `type_` to +convert and validate non-null values with Pydantic's `TypeAdapter`, for example `response.get("count", type_=int)` or `response.get("event", type_=EventContent)`. -Invalid values (including null when the requested type does not accept it) raise -`pydantic.ValidationError`, even when a default is provided. Missing-path defaults +Invalid non-null values raise `pydantic.ValidationError`, even when a default is +provided. Typed lookups return the default (or `None`) for existing null values, +except when `type_=type(None)` explicitly requests null validation. Defaults are returned unchanged, without validation. An empty path selects the entire serialized model and applies the same validation. diff --git a/tests/hosting_slack/test_slack_agent_extension.py b/tests/hosting_slack/test_slack_agent_extension.py index 5ff1a6c59..1ca517588 100644 --- a/tests/hosting_slack/test_slack_agent_extension.py +++ b/tests/hosting_slack/test_slack_agent_extension.py @@ -151,6 +151,55 @@ async def handler(context, state): assert not sel(_make_context(ActivityTypes.event, name="other")) +@pytest.mark.parametrize("route_name", ["message", "event"]) +class TestRoutingContracts: + @pytest.mark.parametrize( + "select, value, matches", + [ + ("hello", "hello", True), + ("hello", "HELLO", False), + ("hello", "say hello", False), + ("hello", None, False), + (re.compile("hello"), "hello", True), + (re.compile("hello"), "say hello", False), + (re.compile("hello"), "hello there", False), + (re.compile("hello"), None, False), + (re.compile("hello", re.IGNORECASE), "HELLO", True), + (None, None, True), + ], + ) + def test_selector_contract(self, route_name, select, value, matches): + app = _make_app() + slack = SlackAgentExtension(app) + register = slack.message if route_name == "message" else slack.event + register(select)(AsyncMock()) + context = _make_context( + route_name, + text=value if route_name == "message" else "unrelated", + name=value if route_name == "event" else "unrelated", + ) + + assert app.routes[-1]["selector"](context) is matches + + @pytest.mark.parametrize("select", [None, "hello"]) + def test_registration_preserves_auth_handlers_and_explicit_rank( + self, route_name, select + ): + app = _make_app() + slack = SlackAgentExtension(app) + register = slack.message if route_name == "message" else slack.event + handler = AsyncMock() + auth_handlers = ["primary", "secondary"] + + returned = register(select, auth_handlers=auth_handlers, rank=RouteRank.FIRST)( + handler + ) + + assert returned is handler + assert app.routes[-1]["auth_handlers"] == auth_handlers + assert app.routes[-1]["rank"] == RouteRank.FIRST + + class TestDeprecatedAliases: def setup_method(self): self.app = _make_app() @@ -178,6 +227,25 @@ async def handler(context, state): assert sel(_make_context(ActivityTypes.event, name="app_mention")) assert not sel(_make_context(ActivityTypes.event, name="other")) + @pytest.mark.parametrize("route_name", ["message", "event"]) + @pytest.mark.parametrize("select", [None, "hello"]) + def test_alias_forwards_registration_options(self, route_name, select): + register = ( + self.slack.on_message if route_name == "message" else self.slack.on_event + ) + handler = AsyncMock() + auth_handlers = ["primary", "secondary"] + + with pytest.deprecated_call(): + returned = register( + select, auth_handlers=auth_handlers, rank=RouteRank.FIRST + )(handler) + + assert returned is handler + assert len(self.app.routes) == 1 + assert self.app.routes[-1]["auth_handlers"] == auth_handlers + assert self.app.routes[-1]["rank"] == RouteRank.FIRST + class TestCall: @pytest.mark.asyncio @@ -236,6 +304,50 @@ def setup_method(self): ), ) + @pytest.mark.parametrize("original_first", [False, True]) + def test_wrapped_context_shares_streaming_response(self, original_first): + original_response = self.context.streaming_response if original_first else None + wrapped = SlackTurnContext(self.context, self.app) + + response = wrapped.streaming_response + + assert response is self.context.streaming_response + assert wrapped.streaming_response is response + if original_first: + assert response is original_response + + def test_wrapped_context_without_registered_client_returns_none(self): + wrapped = SlackTurnContext(self.context, self.app) + + assert wrapped.client is None + assert not self.context.services.has(SlackApi) + + @pytest.mark.parametrize("set_on_wrapper", [False, True]) + def test_wrapped_context_shares_responded_state(self, set_on_wrapper): + wrapped = SlackTurnContext(self.context, self.app) + assert wrapped.responded is False + assert self.context.responded is False + + if set_on_wrapper: + wrapped.responded = True + else: + self.context.responded = True + + assert wrapped.responded is True + assert self.context.responded is True + + def test_wrapped_context_preserves_responded_setter_validation(self): + wrapped = SlackTurnContext(self.context, self.app) + wrapped.responded = True + + with pytest.raises( + ValueError, match="cannot set TurnContext.responded to False" + ): + wrapped.responded = False + + assert wrapped.responded is True + assert self.context.responded is True + @pytest.mark.asyncio @pytest.mark.parametrize("route_name", ["message", "event"]) async def test_registered_handler_receives_slack_context(self, route_name): diff --git a/tests/hosting_slack/test_slack_channel_data.py b/tests/hosting_slack/test_slack_channel_data.py index c1c6cd468..646b29cc8 100644 --- a/tests/hosting_slack/test_slack_channel_data.py +++ b/tests/hosting_slack/test_slack_channel_data.py @@ -9,6 +9,8 @@ import json +import pytest + from microsoft_agents.hosting.slack.api import ( EventContent, SlackChannelData, @@ -234,12 +236,129 @@ def test_thread_ts_from_payload_message(self): } cd = SlackChannelData.model_validate(raw) assert cd.payload is not None - # payload.channel is itself an object in this fixture - assert cd.channel == {"id": "C123"} - # path-based access drills into it + assert cd.channel == "C123" + # Normalization leaves the raw payload available for path navigation. assert cd.payload.get("channel.id") == "C123" assert cd.thread_ts == "111.222" + @pytest.mark.parametrize("channel", ["C123", {"id": "C123"}]) + def test_channel_normalizes_string_and_object(self, channel): + cd = SlackChannelData.model_validate({"Payload": {"channel": channel}}) + + assert cd.channel == "C123" + assert cd.payload is not None + assert cd.payload.get("channel") == channel + + @pytest.mark.parametrize( + "payload", + [ + {}, + {"channel": None}, + {"channel": 123}, + {"channel": False}, + {"channel": []}, + {"channel": {}}, + {"channel": {"id": None}}, + {"channel": {"id": 123}}, + ], + ) + def test_missing_or_invalid_channel_returns_none(self, payload): + cd = SlackChannelData.model_validate({"Payload": payload}) + + assert cd.channel is None + + @pytest.mark.parametrize( + "channel_fields", + [{}, {"channel": None}, {"channel": {}}, {"channel": {"id": None}}], + ) + def test_channel_falls_back_to_container(self, channel_fields): + cd = SlackChannelData.model_validate( + { + "Payload": { + **channel_fields, + "container": {"channel_id": "C123"}, + } + } + ) + + assert cd.channel == "C123" + + @pytest.mark.parametrize("channel", ["C123", {"id": "C123"}]) + def test_payload_channel_takes_precedence_over_container(self, channel): + cd = SlackChannelData.model_validate( + { + "Payload": { + "channel": channel, + "container": {"channel_id": "C_OTHER"}, + } + } + ) + + assert cd.channel == "C123" + + @pytest.mark.parametrize("channel", ["", {"id": ""}]) + def test_empty_channel_does_not_fall_back_to_container(self, channel): + cd = SlackChannelData.model_validate( + {"Payload": {"channel": channel, "container": {"channel_id": "C_OTHER"}}} + ) + + assert cd.channel == "" + + @pytest.mark.parametrize( + "container", + [None, {}, {"channel_id": None}, {"channel_id": 123}], + ) + def test_missing_or_invalid_container_channel_returns_none(self, container): + cd = SlackChannelData.model_validate({"Payload": {"container": container}}) + + assert cd.channel is None + + @pytest.mark.parametrize( + "payload", + [ + {}, + {"channel": None}, + {"channel": "C_PAYLOAD"}, + {"channel": {"id": "C_PAYLOAD"}}, + {"container": {"channel_id": "C_CONTAINER"}}, + ], + ) + def test_envelope_channel_takes_precedence_over_payload(self, payload): + cd = SlackChannelData.model_validate( + { + "SlackMessage": {"event": {"channel": "C_ENVELOPE"}}, + "Payload": payload, + } + ) + + assert cd.channel == "C_ENVELOPE" + + def test_envelope_without_channel_does_not_fall_back_to_payload(self): + cd = SlackChannelData.model_validate( + {"SlackMessage": {"event": {}}, "Payload": {"channel": "C_PAYLOAD"}} + ) + + assert cd.channel is None + + +class TestThreadTimestamp: + @pytest.mark.parametrize( + "source, field", [("SlackMessage", "event"), ("Payload", "message")] + ) + @pytest.mark.parametrize( + "timestamps, expected", + [ + ({"thread_ts": None, "ts": "111.222"}, "111.222"), + ({"thread_ts": "111.000", "ts": "111.222"}, "111.000"), + ({"thread_ts": None, "ts": None}, None), + ({}, None), + ], + ) + def test_nullable_timestamp_fallback(self, source, field, timestamps, expected): + cd = SlackChannelData.model_validate({source: {field: timestamps}}) + + assert cd.thread_ts == expected + class TestFromActivity: def test_from_activity_with_dict_channel_data(self): diff --git a/tests/hosting_slack/test_slack_response.py b/tests/hosting_slack/test_slack_response.py index 75a9a5fb5..cc18d9dfe 100644 --- a/tests/hosting_slack/test_slack_response.py +++ b/tests/hosting_slack/test_slack_response.py @@ -123,10 +123,23 @@ def test_get_empty_path_validates_entire_model(self): with pytest.raises(ValidationError): r.get("", type_=int) - def test_get_existing_null_does_not_use_default(self): + def test_get_untyped_or_null_typed_null_does_not_use_default(self): r = SlackResponse(ok=True, error=None) assert r.get("error", default="fallback") is None assert r.get("error", default="fallback", type_=type(None)) is None + assert r.try_get("error") == (True, None) + + @pytest.mark.parametrize("type_", [str, int, EventContent, list[int]]) + def test_get_typed_null_returns_default_unchanged(self, type_): + r = SlackResponse(ok=True, error=None) + + assert r.get("error", type_=type_) is None + assert r.get("error", default="fallback", type_=type_) == "fallback" + assert r.get("error", "fallback", type_) == "fallback" + + def test_get_null_type_rejects_non_null_value(self): + r = SlackResponse(ok=False, error="not_authed") + with pytest.raises(ValidationError): - r.get("error", default="fallback", type_=str) + r.get("error", type_=type(None)) From e96b74888363dc1ed23f6fa5577a093aadc3d207 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 12 Oct 2026 14:35:56 -0700 Subject: [PATCH 5/9] Formatting --- .../microsoft_agents/hosting/slack/api/slack_channel_data.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py index d7de63b1c..d6a80e9e8 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py @@ -52,7 +52,9 @@ def channel(self) -> str | None: def thread_ts(self) -> str | None: """Thread timestamp, sourced from the envelope event or payload message.""" if self.envelope is not None: - return self.envelope.get("event.thread_ts", type_=str) or self.envelope.get("event.ts", type_=str) + return self.envelope.get("event.thread_ts", type_=str) or self.envelope.get( + "event.ts", type_=str + ) if self.payload is not None: return self.payload.get("message.thread_ts", type_=str) or self.payload.get( "message.ts", type_=str From 340b626c33de017c2602c274588686268f5306ab Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 13 Oct 2026 09:26:32 -0700 Subject: [PATCH 6/9] Updating changelog --- changelog.md | 6 + .../hosting/slack/api/slack_channel_data.py | 7 + .../hosting/slack/api/slack_model.py | 15 +- .../hosting/slack/slack_agent_extension.py | 45 +++-- .../hosting/slack/slack_turn_context.py | 4 +- .../microsoft-agents-hosting-slack/readme.md | 158 ++---------------- 6 files changed, 80 insertions(+), 155 deletions(-) diff --git a/changelog.md b/changelog.md index ff324578e..2db481dd5 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,12 @@ **Release Date:** Unreleased **Previous Version:** 1.8.0 (Released 2026-10-01) +## Slack + +- **Slack-Aware Turn Context**: Added `SlackTurnContext`, `SlackActivity`, and `SlackRouteHandler` so Slack handlers receive a typed context with direct access to Slack channel data and the per-turn API client. +- **Route Decorators**: Added typed `SlackAgentExtension.message()` and `.event()` decorators. Deprecated `on_message()` and `on_event()` while retaining them as backward-compatible aliases. +- **Typed Model Lookups**: Added a `type_` parameter and typed overloads to `SlackModel.get()` for converting and validating nested values with Pydantic. + ## Bug Fixes - **Connector User-Agent Headers**: Added the SDK `User-Agent` header to `MCSConnectorClient` and ensured `ConnectorClient`, `TeamsConnectorClient`, and `UserTokenClient` apply it to caller-provided `ClientSession` instances. diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py index d6a80e9e8..599ee25c5 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py @@ -22,6 +22,13 @@ class SlackChannelData(BaseModel): Bot Service historically named the envelope property ``SlackMessage`` and the API token ``ApiToken``; this model accepts both the PascalCase JSON names (via aliases) and snake_case Python names. + + Access this model through ``SlackActivity.slack_channel_data`` or use + :meth:`from_activity` with a plain turn context's activity. Nested path + lookups belong to the envelope and payload, not this wrapper. For example, + when an envelope is present, ``envelope.get("event.channel")`` reads the + channel and ``envelope.get("event.blocks[0].type")`` reads an array field. + Unknown Slack fields are preserved. """ model_config = ConfigDict(extra="allow", populate_by_name=True) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py index 887097867..7de10f56a 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py @@ -68,6 +68,15 @@ def get( Defaults are returned unchanged, without validation. Without ``type_``, existing values are returned as-is, including null. An empty path selects the entire serialized model. + + For example, ``response.get("count", type_=int)`` converts a value to + an integer, while ``envelope.get("event", type_=EventContent)`` + validates it as an event model. Invalid non-null values raise + ``pydantic.ValidationError`` even when a default is provided. + + Typed overloads return the requested type plus ``None`` when no + default is supplied, or the requested type plus the default's type + when one is supplied. Untyped lookups remain dynamically typed. """ found, value = self.try_get(path) if not found: @@ -77,7 +86,11 @@ def get( return TypeAdapter(type_).validate_python(value) if type_ is not None else value def try_get(self, path: str) -> tuple[bool, Any]: - """Like :meth:`get`, but returns ``(found, value)``.""" + """Return a raw ``(found, value)`` tuple without conversion or validation. + + An existing null value returns ``(True, None)``; a missing path returns + ``(False, None)``. An empty path selects the entire serialized model. + """ if not path: return True, self._data() return try_get_path_value(self._data(), self._normalize_path(path)) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index 144921ba3..a2c9c431e 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -48,16 +48,23 @@ class SlackAgentExtension(Generic[StateT]): Slack-specific route registration and helpers for an :class:`~microsoft_agents.hosting.core.app.AgentApplication`. + Handlers registered with :meth:`message` or :meth:`event` receive a + :class:`SlackTurnContext` with a typed :class:`SlackActivity`. + Pass ``slack_api=...`` to configure the extension's default API client. + Before each Slack turn, this client is registered in turn services only + if no :class:`SlackApi` is already present. Clients supplied by middleware + or an earlier ``before_turn`` handler are preserved, so ``context.client``, + :meth:`call`, and :meth:`create_stream` use the same per-turn instance. + Usage:: - from microsoft_agents.hosting.slack import SlackAgentExtension - from microsoft_agents.hosting.slack.api import SlackChannelData + from microsoft_agents.hosting.slack import SlackAgentExtension, SlackTurnContext app = AgentApplication(options) slack = SlackAgentExtension(app) @slack.message("hello") - async def greet(context, state): + async def greet(context: SlackTurnContext, state): channel_data = context.activity.slack_channel_data await slack.call( context, @@ -66,7 +73,7 @@ async def greet(context, state): "channel": channel_data.channel, "text": f"Hi, {context.activity.from_property.name}!", }, - token=channel_data.api_token, + token=channel_data.api_token or "", ) """ @@ -125,8 +132,14 @@ async def create_stream( ) -> SlackStream: """Create and start a :class:`SlackStream` for the current Slack thread. - Requires an event envelope with a channel and either an explicit - ``thread_ts`` or an ``event.ts`` timestamp. + Requires a Slack event envelope with a non-empty string + ``event.channel`` and either an explicit non-empty string ``thread_ts`` + or an ``event.ts`` timestamp. Uses the per-turn :class:`SlackApi` when + registered, otherwise the extension's default client. + + :return: A started stream ready to append content and stop. + :raises ValueError: If the envelope, channel, or timestamp is missing + or invalid, before making an API call. """ channel_data = SlackChannelData.from_activity(turn_context.activity) if channel_data.envelope is None: @@ -164,12 +177,15 @@ def message( """Register a handler for Slack message activities. When ``select`` is ``None``, every Slack message matches; otherwise the - activity's ``text`` must equal ``select`` (string) or fully match it - (compiled regex). + activity's ``text`` must equal ``select`` (case-sensitive string) or + fully match it (compiled regex). Non-Slack activities never match. + The handler receives a :class:`SlackTurnContext` and the turn state. ``rank`` defaults to :attr:`RouteRank.DEFAULT`; when ``select`` is ``None`` the rank is downgraded to :attr:`RouteRank.LAST` so explicit text routes win — matching the C# behavior. + + ``auth_handlers`` selects the authentication handlers for the route. """ effective_rank = ( RouteRank.LAST if (select is None and rank == RouteRank.DEFAULT) else rank @@ -205,7 +221,7 @@ def on_message( auth_handlers: list[str] | None = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: - """Deprecated alias for :meth:`message`.""" + """Deprecated alias for :meth:`message`; emits ``DeprecationWarning``.""" return self.message(select, auth_handlers=auth_handlers, rank=rank) # ── event routes ─────────────────────────────────────────────────────── @@ -220,8 +236,13 @@ def event( """Register a handler for Slack event activities. When ``event_name`` is ``None``, every Slack event matches; otherwise - the activity's ``name`` must equal it (string) or fully match it - (compiled regex). + the activity's ``name`` must equal it (case-sensitive string) or fully + match it (compiled regex). Non-Slack activities never match. + The handler receives a :class:`SlackTurnContext` and the turn state. + + Omitting ``event_name`` with the default rank uses + :attr:`RouteRank.LAST` so specific event routes win. An explicit rank + is preserved. ``auth_handlers`` selects authentication handlers. """ effective_rank = ( RouteRank.LAST @@ -259,5 +280,5 @@ def on_event( auth_handlers: list[str] | None = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: - """Deprecated alias for :meth:`event`.""" + """Deprecated alias for :meth:`event`; emits ``DeprecationWarning``.""" return self.event(event_name, auth_handlers=auth_handlers, rank=rank) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py index 136eacd8a..b865b2f8e 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py @@ -18,7 +18,9 @@ class SlackTurnContext(TurnContext): Wraps a plain :class:`TurnContext` so that Slack-aware route handlers receive a typed context without changing the core routing engine. - Turn state and buffered replies are shared with the original context. + Turn state, services, and buffered replies are shared with the original + context. The response flag and streaming-response accessor are forwarded + to it. """ def __init__(self, context: TurnContext, app: AgentApplication) -> None: diff --git a/libraries/microsoft-agents-hosting-slack/readme.md b/libraries/microsoft-agents-hosting-slack/readme.md index 02d892b57..386d387e7 100644 --- a/libraries/microsoft-agents-hosting-slack/readme.md +++ b/libraries/microsoft-agents-hosting-slack/readme.md @@ -2,81 +2,8 @@ [![PyPI version](https://img.shields.io/pypi/v/microsoft-agents-hosting-slack)](https://pypi.org/project/microsoft-agents-hosting-slack/) -Integration library for building Slack agents using the Microsoft 365 Agents SDK. Provides direct-to-Slack responses (the full Slack Web API surface, beyond what Azure Bot Service exposes), a typed `SlackChannelData` envelope with dot-notation property access, and a `SlackStream` helper for `chat.startStream` / `chat.appendStream` / `chat.stopStream`. - - -## Release Notes - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
VersionDateRelease Notes
1.8.02026-10-01 - - 1.8.0 Release Notes - -
1.7.02026-09-17 - - 1.7.0 Release Notes - -
1.5.02026-08-26 - - 1.5.0 Release Notes - -
1.4.02026-08-18 - - 1.4.0 Release Notes - -
1.3.02026-07-30 - - 1.3.0 Release Notes - -
1.2.02026-07-17 - - 1.2.0 Release Notes - -
1.1.02026-06-19 - - 1.1.0 Release Notes - -
+Build Slack agents with the Microsoft 365 Agents SDK using typed message and event +handlers, direct Slack Web API calls, and streaming responses. ## Packages Overview @@ -94,7 +21,7 @@ We offer the following PyPI packages to create conversational experiences based | `microsoft-agents-storage-blob` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-blob)](https://pypi.org/project/microsoft-agents-storage-blob/) | Extension to use Azure Blob as storage. | | `microsoft-agents-storage-cosmos` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-cosmos)](https://pypi.org/project/microsoft-agents-storage-cosmos/) | Extension to use CosmosDB as storage. | | `microsoft-agents-authentication-msal` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-authentication-msal)](https://pypi.org/project/microsoft-agents-authentication-msal/) | MSAL-based authentication for Microsoft Agents. | -| `microsoft-agents-authentication-entra-auth-sidecar` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-authentication-entra-auth-sidecar)](https://pypi.org/project/microsoft-agents-authentication-entra-auth-sidecar/) | Credential-free Entra ID Agent ID authentication via the sidecar. | +| `microsoft-agents-authentication-entra-auth-sidecar` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-authentication-entra-auth-sidecar)](https://pypi.org/project/microsoft-agents-authentication-entra-auth-sidecar/) | Credential-free Entra ID Agent ID authentication via the sidecar. | ## Installation @@ -128,73 +55,22 @@ async def on_slack_message(context: SlackTurnContext, state: TurnState) -> None: ) ``` -### Routes and turn context - Use `@slack.message("hello")` for a specific message or `@slack.event("reaction_added")` -for a specific event. Both decorators match only Slack activities. String selectors -are case-sensitive exact matches; compiled regular expressions must match the entire -text or event name. Omitting the selector matches any Slack message/event and defaults -to `RouteRank.LAST`. Both decorators accept `auth_handlers` and an explicit `rank`. - -Handlers receive a `SlackTurnContext` whose `activity` is a `SlackActivity`. The wrapper -shares turn state, services, and buffered replies with the original context, and -forwards the response flag and streaming-response accessor to it. - -`context.client` exposes the `SlackApi` registered in turn services. Before each Slack -turn, the extension registers its shared default client only if no client is already -present. A client supplied by middleware or an earlier `before_turn` handler is -preserved, and `context.client`, `slack.call(...)`, and `slack.create_stream(...)` use -that same instance. A manually constructed context without a registered client returns -`None` from `client`. Pass `slack_api=...` to `SlackAgentExtension` to configure its -default client. - -`slack.create_stream(context, thread_ts=...)` requires a Slack event envelope with -a non-empty channel ID and either an explicit thread timestamp or `event.ts`. -Missing channel or timestamp values raise `ValueError` before making an API call. - -`on_message()` and `on_event()` remain available as deprecated aliases and emit -`DeprecationWarning`; new code should use `message()` and `event()`. - -### Channel data and path lookups - -`context.activity.slack_channel_data` exposes `envelope`, `payload`, `api_token`, -`channel`, and `thread_ts`. For a plain `TurnContext`, use -`SlackChannelData.from_activity(context.activity)`. - -Path navigation belongs to the envelope, payload, and response models, not to -`SlackChannelData` itself. For example, when an envelope is present, -`channel_data.envelope.get("event.channel")` reads its channel and -`channel_data.envelope.get("event.blocks[0].type")` reads a nested array field. -Unknown Slack fields are preserved. - -`get(path)` returns the value or `None` when absent. Without `type_`, a default -applies only to missing paths; existing null values remain `None`. Supply `type_` to -convert and validate non-null values with Pydantic's `TypeAdapter`, for example -`response.get("count", type_=int)` or `response.get("event", type_=EventContent)`. -Invalid non-null values raise `pydantic.ValidationError`, even when a default is -provided. Typed lookups return the default (or `None`) for existing null values, -except when `type_=type(None)` explicitly requests null validation. Defaults -are returned unchanged, without validation. An empty path selects the entire -serialized model and applies the same validation. - -Typed overloads return the requested type plus `None` when no default is supplied, -or the requested type plus the default's type when one is supplied. Without `type_`, -values remain dynamically typed. `try_get(path)` returns a raw `(found, value)` -tuple without conversion or validation. - -## Key Classes - -- **`SlackAgentExtension`** — registers Slack-channel-scoped `message()` / `event()` handlers; exposes `call(...)` and `create_stream(...)`. -- **`SlackTurnContext`** — Slack-aware turn context exposing the typed activity and registered API client. -- **`SlackActivity`** — activity exposing typed `slack_channel_data`. -- **`SlackChannelData`** — typed wrapper around Bot Service's Slack channel-data payload, with envelope and interactive-payload accessors. -- **`SlackModel`** — base for envelope, payload, and response models with `get(path)` / `try_get(path)` accessors. -- **`SlackApi`** — async HTTP client for the Slack Web API. -- **`SlackStream`** — wraps Slack's streaming methods for incremental message updates. -- **Helper functions** — encode/decode and conversation-id parsing utilities. - -# Quick Links +for a specific event. Both accept compiled regular expressions and match only Slack +activities. `on_message()` and `on_event()` are deprecated aliases. + +Handlers receive a `SlackTurnContext` with typed `activity.slack_channel_data` and +access to the per-turn Slack API client through `context.client`. Envelope, payload, +and response models support nested lookups such as `envelope.get("event.channel")` +and typed lookups such as `response.get("count", type_=int)`. + +Use `slack.create_stream(context, thread_ts=...)` to start a `SlackStream`, then +append content and stop the stream. See the API docstrings for routing options, +client configuration, streaming requirements, and lookup behavior. + +## Quick Links +- [Release Notes](https://github.com/microsoft/Agents-for-python/blob/main/changelog.md) - 📦 [All SDK Packages on PyPI](https://pypi.org/search/?q=microsoft-agents) - 📖 [Complete Documentation](https://aka.ms/agents) - 🐛 [Report Issues](https://github.com/microsoft/Agents-for-python/issues) From 0fe5584d05c2051ef10095836f5f93364349126c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 13 Oct 2026 12:02:18 -0700 Subject: [PATCH 7/9] _TurnContextWrapper and minor improvements to Slack extension --- .../hosting/core/_utils/__init__.py | 4 +- .../core/_utils/_turn_context_wrapper.py | 149 +++++++++ .../hosting/msteams/teams_turn_context.py | 20 +- .../hosting/slack/api/slack_api.py | 62 ++-- .../hosting/slack/slack_agent_extension.py | 42 ++- .../hosting/slack/slack_turn_context.py | 33 +- .../hosting_core/test_turn_context_wrapper.py | 313 ++++++++++++++++++ .../test_slack_agent_extension.py | 127 ++++++- tests/hosting_slack/test_slack_api.py | 96 +++++- 9 files changed, 759 insertions(+), 87 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_turn_context_wrapper.py create mode 100644 tests/hosting_core/test_turn_context_wrapper.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/__init__.py index a9481463d..6f3ad43a3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/__init__.py @@ -1,6 +1,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from ._service_set import _ServiceSet - -__all__ = ["_ServiceSet"] +# intentionally left empty to avoid dependency issues diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_turn_context_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_turn_context_wrapper.py new file mode 100644 index 000000000..3fd76f605 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_turn_context_wrapper.py @@ -0,0 +1,149 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC + +from typing import Awaitable, Callable, TYPE_CHECKING + +from microsoft_agents.activity import ( + Activity, + ResourceResponse, + ConversationReference, +) + +from microsoft_agents.hosting.core.turn_context import ( + TurnContext, + OnSendActivitiesHandler, + OnUpdateActivityHandler, + OnDeleteActivityHandler, +) + + +class _TurnContextWrapper(TurnContext, ABC): + """A base class for wrapping a TurnContext with additional functionality.""" + + def __init__(self, context: TurnContext) -> None: + """Initialise the turn context from a plain turn context. + + :param context: The base turn context provided by the core runtime. + """ + super().__init__(context) + + self._original = context + self._turn_state = context.turn_state + self.buffered_reply_activities = context.buffered_reply_activities + + @property + def responded(self) -> bool: + return self._original.responded + + @responded.setter + def responded(self, value: bool): + self._original.responded = value + + @property + def streaming_response(self): + return self._original.streaming_response + + async def send_activity( + self, + activity_or_text: Activity | str, + speak: str | None = None, + input_hint: str | None = None, + ) -> ResourceResponse: + """ + Send an activity to the conversation. + + :param activity_or_text: The activity to send or a string representing the text of the activity. + :param speak: Optional speech text for the activity. + :param input_hint: Optional input hint for the activity. + :return: A ResourceResponse representing the result of the send operation. + """ + return await self._original.send_activity( + activity_or_text, speak=speak, input_hint=input_hint + ) + + async def send_activities( + self, activities: list[Activity] + ) -> list[ResourceResponse]: + """ + Send multiple activities to the conversation. + + :param activities: A list of activities to send. + :return: A list of ResourceResponse objects representing the result of each send operation. + """ + return await self._original.send_activities(activities) + + async def update_activity(self, activity: Activity): + """ + Replaces an existing activity. + :param activity: + :return: + """ + return await self._original.update_activity(activity) + + async def delete_activity(self, id_or_reference: str | ConversationReference): + """ + Deletes an existing activity. + :param id_or_reference: + :return: + """ + return await self._original.delete_activity(id_or_reference) + + def on_send_activities(self, handler: OnSendActivitiesHandler) -> TurnContext: + """ + Registers a handler to be notified of and potentially intercept the sending of activities. + :param handler: the handler to register + :type handler: OnSendActivitiesHandler + :return: + """ + self._original.on_send_activities(handler) + return self + + def on_update_activity(self, handler: OnUpdateActivityHandler) -> TurnContext: + """ + Registers a handler to be notified of and potentially intercept an activity being updated. + :param handler: the handler to register + :type handler: OnUpdateActivityHandler + :return: + """ + self._original.on_update_activity(handler) + return self + + def on_delete_activity(self, handler: OnDeleteActivityHandler) -> TurnContext: + """ + Registers a handler to be notified of and potentially intercept an activity being deleted. + :param handler: the handler to register + :type handler: OnDeleteActivityHandler + :return: + """ + self._original.on_delete_activity(handler) + return self + + async def send_trace_activity( + self, + name: str, + value: object = None, + value_type: str | None = None, + label: str | None = None, + ) -> ResourceResponse: + return await self._original.send_trace_activity( + name=name, value=value, value_type=value_type, label=label + ) + + async def _aclose(self) -> None: + """ + Asynchronously close any resources associated with the turn context. + + :return: None + """ + await self._original._aclose() + + def _on_aclose(self, handler: Callable[[TurnContext], Awaitable[None]]) -> None: + """ + Register a handler to be called when the turn context is asynchronously closed. + + :param handler: A callable that takes the turn context and returns an awaitable. + :return: None + """ + self._original._on_aclose(handler) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index e90c900bc..93d12363b 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -19,6 +19,9 @@ InputHints, ResourceResponse, ) +from microsoft_agents.hosting.core._utils._turn_context_wrapper import ( + _TurnContextWrapper, +) from microsoft_agents.hosting.core import ( AgentApplication, TurnContext, @@ -35,7 +38,7 @@ from ._utils import _apply_prompt_preview_normalizer, _is_recipient_targeted -class TeamsTurnContext(TurnContext): +class TeamsTurnContext(_TurnContextWrapper): """A context object for handling Teams-specific turn functionality. Wraps a plain :class:`TurnContext` so that Teams-aware route handlers @@ -50,9 +53,6 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None: """ super().__init__(context) self._app = app - self._turn_state = context.turn_state - - self._original = context self._set_teams_activity() @@ -76,18 +76,6 @@ def _set_teams_activity(self) -> None: self._activity.__class__ = TeamsActivity self._teams_activity = cast(TeamsActivity, self._activity) - @property - def responded(self) -> bool: - return self._original.responded - - @responded.setter - def responded(self, value: bool): - self._original.responded = value - - @property - def streaming_response(self): - return self._original.streaming_response - @property def activity(self) -> TeamsActivity: """The current activity, typed as a :class:`TeamsActivity`. diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py index 6392c98e2..e6aa4f052 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py @@ -63,7 +63,13 @@ def __init__( base_url: str = SLACK_API_BASE, request_timeout: float = 30.0, ) -> None: - self._session = session + """Initialize the SlackApi client. + + :param session: An optional aiohttp ClientSession. If not provided, a new session is created. + :param base_url: The base URL for the Slack API. + :param request_timeout: The request timeout in seconds. + """ + self._session = session or ClientSession() self._owns_session = session is None self._base_url = base_url self._timeout = ClientTimeout(total=request_timeout) @@ -94,33 +100,27 @@ async def call( if token and token.strip(): headers["Authorization"] = f"Bearer {token}" - session = self._session or ClientSession() - # TODO: ClientSession lifecycle currently creates+closes a brand-new - # aiohttp.ClientSession() on every call() when no session was supplied - # at construction, instead of owning a single long-lived session per - # SlackApi instance. Revisit alongside the TurnContext resource-lifecycle - # work being added in the in-progress A2A extension, so SlackApi can - # participate in the same managed-resource pattern. - try: - async with session.post( - url, data=body, headers=headers, timeout=self._timeout - ) as response: - text = await response.text() - try: - payload = json.loads(text) if text else {} - data = SlackResponse.model_validate(payload) - except Exception as exc: - raise SlackResponseException( - f"Slack API error on {method} (HTTP {response.status}):\n{text}" - ) from exc - - if not response.ok or not data.ok: - raise SlackResponseException( - f"Slack API error on {method} (HTTP {response.status}):\n{text}", - data, - ) - - return data - finally: - if self._owns_session: - await session.close() + async with self._session.post( + url, data=body, headers=headers, timeout=self._timeout + ) as response: + text = await response.text() + try: + payload = json.loads(text) if text else {} + data = SlackResponse.model_validate(payload) + except Exception as exc: + raise SlackResponseException( + f"Slack API error on {method} (HTTP {response.status}):\n{text}" + ) from exc + + if not response.ok or not data.ok: + raise SlackResponseException( + f"Slack API error on {method} (HTTP {response.status}):\n{text}", + data, + ) + + return data + + async def aclose(self) -> None: + """Close the owned ClientSession, if any.""" + if self._owns_session: + await self._session.close() diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index a2c9c431e..a907f56f5 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -8,6 +8,7 @@ import re from typing import Any, Generic, Pattern, TypeVar +from aiohttp import ClientSession from typing_extensions import deprecated from microsoft_agents.activity import ActivityTypes, Channels @@ -83,10 +84,16 @@ def __init__( *, slack_api: SlackApi | None = None, ) -> None: + """Initialise the Slack agent extension with the given application and optional Slack API client. + + :param application: The agent application that this extension is attached to. + :param slack_api: Optional default SlackApi client to use for turns where no per-turn client is supplied. + """ self._app = application - self._slack_api = slack_api or SlackApi() + self._slack_api = slack_api async def __on_before_turn(context: TurnContext, state: StateT) -> bool: + if _is_slack_channel(context) and not context.services.has(SlackApi): # Bind the default only when no per-turn client was supplied, # so SlackTurnContext.client and any @@ -97,16 +104,24 @@ async def __on_before_turn(context: TurnContext, state: StateT) -> bool: # agent's typing-indicator timer on out-of-band calls, since # core AgentApplication does not currently expose an # equivalent public API. - context.services.set(SlackApi, self._slack_api) + context.services.set(SlackApi, self.slack_api) return True application.before_turn(__on_before_turn) @property def slack_api(self) -> SlackApi: - """The shared :class:`SlackApi` used by :meth:`call` and :meth:`create_stream`.""" + """Gets the shared SlackApi instance. Creates a new one if it doesn't exist.""" + if not self._slack_api: + self._slack_api = SlackApi() return self._slack_api + def _get_slack_api(self, turn_context: TurnContext) -> SlackApi: + """Retrieve the SlackApi instance for the current turn context, falling back to the default client if none is set.""" + if turn_context is not None and turn_context.services.has(SlackApi): + return turn_context.services.get(SlackApi) or self.slack_api + return self.slack_api + # ── direct Slack API access ──────────────────────────────────────────── async def call( @@ -117,12 +132,15 @@ async def call( token: str = "", ) -> SlackResponse: """Invoke a Slack Web API method, preferring a per-turn :class:`SlackApi` - if one has been cached on ``turn_context.services``.""" - api = self._slack_api - if turn_context is not None and turn_context.services.has(SlackApi): - api = turn_context.services.get(SlackApi) - if api is None: - raise RuntimeError("No SlackApi instance available") + if one has been cached on ``turn_context.services``. + + :param turn_context: The current turn context. + :param method: The Slack Web API method to call. + :param options: Optional parameters for the API call. + :param token: Optional Slack API token to use for the call. + :return: The response from the Slack API. + """ + api = self._get_slack_api(turn_context) return await api.call(method, options, token) async def create_stream( @@ -152,11 +170,7 @@ async def create_stream( resolved_thread_ts = thread_ts or channel_data.envelope.get("event.ts") if not isinstance(resolved_thread_ts, str) or not resolved_thread_ts: raise ValueError("create_stream requires a non-empty thread_ts or event.ts") - api = self._slack_api - if turn_context.services.has(SlackApi): - api = turn_context.services.get(SlackApi) - if api is None: - raise RuntimeError("No SlackApi instance available") + api = self._get_slack_api(turn_context) stream = SlackStream( api, channel, diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py index b865b2f8e..c78e8a237 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_turn_context.py @@ -7,13 +7,17 @@ from typing import cast +from microsoft_agents.activity import Activity from microsoft_agents.hosting.core import AgentApplication, TurnContext +from microsoft_agents.hosting.core._utils._turn_context_wrapper import ( + _TurnContextWrapper, +) from .api import SlackApi from .slack_activity import SlackActivity -class SlackTurnContext(TurnContext): +class SlackTurnContext(_TurnContextWrapper): """A context object for handling Slack-specific turn functionality. Wraps a plain :class:`TurnContext` so that Slack-aware route handlers @@ -31,26 +35,11 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None: """ super().__init__(context) self._app = app - self._turn_state = context.turn_state - self.buffered_reply_activities = context.buffered_reply_activities - - self._original = context - - self._activity.__class__ = SlackActivity + self._activity = context.activity + if not issubclass(type(self._activity), SlackActivity): + self._activity.__class__ = SlackActivity self._slack_activity = cast(SlackActivity, self._activity) - @property - def responded(self) -> bool: - return self._original.responded - - @responded.setter - def responded(self, value: bool): - self._original.responded = value - - @property - def streaming_response(self): - return self._original.streaming_response - @property def activity(self) -> SlackActivity: """The current activity, typed as a :class:`SlackActivity`. @@ -59,6 +48,12 @@ def activity(self) -> SlackActivity: """ return self._slack_activity + @activity.setter + def activity(self, value: Activity): + raise NotImplementedError( + "Directly setting the activity is not supported in SlackTurnContext." + ) + @property def client(self) -> SlackApi | None: """The :class:`SlackApi` client bound to this turn, if any. diff --git a/tests/hosting_core/test_turn_context_wrapper.py b/tests/hosting_core/test_turn_context_wrapper.py new file mode 100644 index 000000000..857026faa --- /dev/null +++ b/tests/hosting_core/test_turn_context_wrapper.py @@ -0,0 +1,313 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from microsoft_agents.activity import ( + Activity, + ChannelAccount, + ChannelId, + ConversationAccount, + ConversationReference, + ResourceResponse, +) +from microsoft_agents.hosting.core import ChannelAdapter, TurnContext +from microsoft_agents.hosting.core._utils._turn_context_wrapper import ( + _TurnContextWrapper, +) +from microsoft_agents.hosting.core.authorization import ClaimsIdentity + + +class WrappedContext(_TurnContextWrapper): + pass + + +@pytest.fixture +def original(): + adapter = MagicMock(spec=ChannelAdapter) + adapter.send_activities.return_value = [ResourceResponse(id="sent")] + adapter.update_activity.return_value = ResourceResponse(id="updated") + adapter.delete_activity.return_value = None + return TurnContext( + adapter, + Activity( + type="message", + id="incoming", + channel_id=ChannelId("test"), + service_url="https://example.invalid", + conversation=ConversationAccount(id="conversation"), + from_property=ChannelAccount(id="user"), + recipient=ChannelAccount(id="agent"), + ), + identity=ClaimsIdentity({"aud": "agent"}), + ) + + +@pytest.fixture +def wrapped(original): + return WrappedContext(original) + + +def test_shares_context_references_and_mutable_state(original): + original.turn_state["before"] = "value" + original.services.set(str, "before") + before = Activity(type="message", text="before") + original.buffered_reply_activities.append(before) + + wrapped = WrappedContext(original) + + assert isinstance(wrapped, TurnContext) + assert wrapped.adapter is original.adapter + assert wrapped.activity is original.activity + assert wrapped.identity is original.identity + assert wrapped.turn_state is original.turn_state + assert wrapped.services is original.services + assert wrapped.buffered_reply_activities is original.buffered_reply_activities + assert wrapped.turn_state["before"] == "value" + assert wrapped.services.get(str) == "before" + assert wrapped.buffered_reply_activities == [before] + + wrapped.turn_state["after"] = "value" + wrapped.services.set(str, "after") + after = Activity(type="message", text="after") + wrapped.buffered_reply_activities.append(after) + original.activity.text = "changed" + + assert original.turn_state["after"] == "value" + assert original.services.get(str) == "after" + assert original.buffered_reply_activities == [before, after] + assert wrapped.activity.text == "changed" + + +@pytest.mark.parametrize("initially_responded", [False, True]) +def test_preserves_initial_responded_state(original, initially_responded): + if initially_responded: + original.responded = True + + assert WrappedContext(original).responded is initially_responded + + +@pytest.mark.parametrize("set_on_wrapper", [False, True]) +def test_shares_responded_changes(original, wrapped, set_on_wrapper): + target = wrapped if set_on_wrapper else original + target.responded = True + + assert original.responded is True + assert wrapped.responded is True + + +def test_preserves_responded_validation(original, wrapped): + wrapped.responded = True + + with pytest.raises(ValueError, match="cannot set TurnContext.responded to False"): + wrapped.responded = False + + assert original.responded is True + assert wrapped.responded is True + + +@pytest.mark.parametrize("original_first", [False, True]) +def test_shares_lazy_streaming_response(original, wrapped, original_first): + first = original if original_first else wrapped + response = first.streaming_response + + assert original.streaming_response is response + assert wrapped.streaming_response is response + + +@pytest.mark.asyncio +@pytest.mark.parametrize("as_text", [False, True]) +@pytest.mark.parametrize("with_options", [False, True]) +async def test_send_activity_delegates_to_original_override( + original, wrapped, monkeypatch, as_text, with_options +): + activity = "hello" if as_text else Activity(type="message", text="hello") + options = {"speak": "spoken", "input_hint": "ignoringInput"} if with_options else {} + response = ResourceResponse(id="custom-send") + delegate = AsyncMock(return_value=response) + monkeypatch.setattr(original, "send_activity", delegate) + + result = await wrapped.send_activity(activity, **options) + + assert result is response + delegate.assert_awaited_once_with( + activity, + speak=options.get("speak"), + input_hint=options.get("input_hint"), + ) + original.adapter.send_activities.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method, args, kwargs", + [ + ("send_activities", ([Activity(type="message", text="hello")],), {}), + ("send_activities", ([],), {}), + ("update_activity", (Activity(type="message", id="update"),), {}), + ("delete_activity", ("delete-id",), {}), + ( + "delete_activity", + ( + ConversationReference( + activity_id="delete-id", + conversation=ConversationAccount(id="conversation"), + ), + ), + {}, + ), + ( + "send_trace_activity", + (), + { + "name": "trace", + "value": {"key": "value"}, + "value_type": "custom-type", + "label": "custom-label", + }, + ), + ], +) +async def test_operations_delegate_arguments_and_results( + original, wrapped, monkeypatch, method, args, kwargs +): + response = object() + delegate = AsyncMock(return_value=response) + monkeypatch.setattr(original, method, delegate) + + result = await getattr(wrapped, method)(*args, **kwargs) + + assert result is response + delegate.assert_awaited_once_with(*args, **kwargs) + original.adapter.send_activities.assert_not_awaited() + original.adapter.update_activity.assert_not_awaited() + original.adapter.delete_activity.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_trace_forwards_default_options(original, wrapped, monkeypatch): + delegate = AsyncMock(return_value=ResourceResponse(id="trace")) + monkeypatch.setattr(original, "send_trace_activity", delegate) + + result = await wrapped.send_trace_activity("trace") + + assert result is delegate.return_value + delegate.assert_awaited_once_with( + name="trace", value=None, value_type=None, label=None + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method, args", + [ + ("send_activity", ("hello",)), + ("send_activities", ([],)), + ("update_activity", (Activity(type="message", id="update"),)), + ("delete_activity", ("delete-id",)), + ("send_trace_activity", ("trace",)), + ("_aclose", ()), + ], +) +async def test_propagates_original_operation_errors( + original, wrapped, monkeypatch, method, args +): + error = RuntimeError("original operation failed") + delegate = AsyncMock(side_effect=error) + monkeypatch.setattr(original, method, delegate) + + with pytest.raises(RuntimeError) as exc_info: + await getattr(wrapped, method)(*args) + + assert exc_info.value is error + delegate.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "registration, operation, argument", + [ + ("on_send_activities", "send_activities", [Activity(type="message")]), + ("on_update_activity", "update_activity", Activity(type="message", id="id")), + ("on_delete_activity", "delete_activity", "id"), + ], +) +async def test_hooks_register_on_original_and_receive_original_context( + original, wrapped, monkeypatch, registration, operation, argument +): + register = MagicMock(wraps=getattr(original, registration)) + monkeypatch.setattr(original, registration, register) + + async def handler(context, value, next_handler): + assert context is original + return await next_handler() + + hook = AsyncMock(side_effect=handler) + + assert getattr(wrapped, registration)(hook) is wrapped + register.assert_called_once_with(hook) + + await getattr(wrapped, operation)(argument) + + hook.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_preserves_adapter_context_and_responded_state(original, wrapped): + result = await wrapped.send_activity("hello") + + original.adapter.send_activities.assert_awaited_once() + context, activities = original.adapter.send_activities.call_args.args + assert context is original + assert activities[0].text == "hello" + assert result.id == "sent" + assert original.responded is True + assert wrapped.responded is True + + +@pytest.mark.asyncio +async def test_cleanup_registers_on_original_and_runs_handlers_in_order( + original, wrapped, monkeypatch +): + before = AsyncMock() + after = AsyncMock() + original._on_aclose(before) + register = MagicMock(wraps=original._on_aclose) + close = AsyncMock(wraps=original._aclose) + monkeypatch.setattr(original, "_on_aclose", register) + monkeypatch.setattr(original, "_aclose", close) + calls = MagicMock() + calls.attach_mock(before, "before") + calls.attach_mock(after, "after") + + wrapped._on_aclose(after) + register.assert_called_once_with(after) + await wrapped._aclose() + + close.assert_awaited_once_with() + before.assert_awaited_once_with(original) + after.assert_awaited_once_with(original) + assert [entry[0] for entry in calls.mock_calls] == ["before", "after"] + + +@pytest.mark.asyncio +async def test_nested_wrappers_preserve_delegation_and_shared_state( + original, wrapped, monkeypatch +): + outer = WrappedContext(wrapped) + delegate = AsyncMock(return_value=ResourceResponse(id="nested")) + monkeypatch.setattr(original, "send_activity", delegate) + + result = await outer.send_activity("hello") + outer.responded = True + + assert result.id == "nested" + delegate.assert_awaited_once_with("hello", speak=None, input_hint=None) + assert outer.turn_state is original.turn_state + assert outer.services is original.services + assert outer.buffered_reply_activities is original.buffered_reply_activities + assert outer.streaming_response is original.streaming_response + assert original.responded is True + assert wrapped.responded is True diff --git a/tests/hosting_slack/test_slack_agent_extension.py b/tests/hosting_slack/test_slack_agent_extension.py index 1ca517588..67c176386 100644 --- a/tests/hosting_slack/test_slack_agent_extension.py +++ b/tests/hosting_slack/test_slack_agent_extension.py @@ -283,6 +283,34 @@ async def test_call_prefers_turn_context_service_when_present(self): default_api.call.assert_not_awaited() +class TestDefaultClient: + def test_construction_is_lazy_and_property_caches_client(self, monkeypatch): + client = MagicMock(spec=SlackApi) + factory = MagicMock(return_value=client) + monkeypatch.setattr( + "microsoft_agents.hosting.slack.slack_agent_extension.SlackApi", factory + ) + + slack = SlackAgentExtension(_make_app()) + + factory.assert_not_called() + assert slack.slack_api is client + assert slack.slack_api is client + factory.assert_called_once_with() + + def test_injected_client_does_not_create_default(self, monkeypatch): + client = MagicMock(spec=SlackApi) + factory = MagicMock() + monkeypatch.setattr( + "microsoft_agents.hosting.slack.slack_agent_extension.SlackApi", factory + ) + + slack = SlackAgentExtension(_make_app(), slack_api=client) + + assert slack.slack_api is client + factory.assert_not_called() + + class TestTurnPipeline: def setup_method(self): self.app = AgentApplication( @@ -322,6 +350,37 @@ def test_wrapped_context_without_registered_client_returns_none(self): assert wrapped.client is None assert not self.context.services.has(SlackApi) + def test_wrapped_context_rejects_activity_replacement(self): + wrapped = SlackTurnContext(self.context, self.app) + activity = wrapped.activity + + with pytest.raises( + NotImplementedError, match="Directly setting the activity is not supported" + ): + wrapped.activity = Activity(type=ActivityTypes.message) + + assert wrapped.activity is activity + assert self.context.activity is activity + + def test_wrapped_context_preserves_slack_activity_subclasses(self): + class CustomSlackActivity(SlackActivity): + custom_id: str + + activity = CustomSlackActivity( + type=ActivityTypes.message, + channel_id=ChannelId("slack"), + custom_id="custom", + channel_data={"Payload": {"channel": {"id": "C1"}}}, + ) + self.context.activity = activity + + wrapped = SlackTurnContext(self.context, self.app) + + assert wrapped.activity is activity + assert isinstance(wrapped.activity, CustomSlackActivity) + assert wrapped.activity.model_dump()["custom_id"] == "custom" + assert wrapped.activity.slack_channel_data.channel == "C1" + @pytest.mark.parametrize("set_on_wrapper", [False, True]) def test_wrapped_context_shares_responded_state(self, set_on_wrapper): wrapped = SlackTurnContext(self.context, self.app) @@ -353,7 +412,7 @@ def test_wrapped_context_preserves_responded_setter_validation(self): async def test_registered_handler_receives_slack_context(self, route_name): app = _make_app() slack = SlackAgentExtension(app) - client = SlackApi() + client = MagicMock(spec=SlackApi) self.context.services.set(SlackApi, client) self.context.activity.type = route_name state = TurnState() @@ -379,7 +438,7 @@ async def handler(context, received_state): "activity_type", [ActivityTypes.message, ActivityTypes.event] ) async def test_wrapped_handlers_preserve_buffered_replies(self, activity_type): - slack = SlackAgentExtension(self.app) + slack = SlackAgentExtension(self.app, slack_api=MagicMock(spec=SlackApi)) self.context.activity.type = activity_type self.context.activity.delivery_mode = DeliveryModes.expect_replies self.context.buffered_reply_activities.append( @@ -463,6 +522,70 @@ async def handler(context, state): assert expected_api.call.await_count == 2 unused_api.call.assert_not_awaited() + @pytest.mark.asyncio + @pytest.mark.parametrize("operation", ["call", "create_stream"]) + async def test_helpers_create_default_client_before_first_turn( + self, monkeypatch, operation + ): + call_api = AsyncMock(return_value=SlackResponse(ok=True, ts="stream-ts")) + monkeypatch.setattr(SlackApi, "call", call_api) + slack = SlackAgentExtension(self.app) + self.context.activity.channel_data = { + "SlackMessage": {"event": {"channel": "C1", "ts": "thread-ts"}}, + "ApiToken": "test-token", + } + + try: + if operation == "call": + result = await slack.call(self.context, "auth.test") + assert result is call_api.return_value + call_api.assert_awaited_once_with("auth.test", None, "") + else: + await slack.create_stream(self.context) + call_api.assert_awaited_once_with( + "chat.startStream", + { + "channel": "C1", + "thread_ts": "thread-ts", + "task_display_mode": "plan", + }, + "test-token", + ) + + client = slack.slack_api + await self.app.on_turn(self.context) + assert self.context.services.get(SlackApi) is client + assert slack.slack_api is client + finally: + await slack.slack_api.aclose() + + @pytest.mark.asyncio + @pytest.mark.parametrize("operation", ["call", "create_stream"]) + async def test_helpers_use_per_turn_client_without_creating_default( + self, monkeypatch, operation + ): + factory = MagicMock(side_effect=AssertionError("Unexpected default session")) + monkeypatch.setattr( + "microsoft_agents.hosting.slack.api.slack_api.ClientSession", factory + ) + client = MagicMock(spec=SlackApi) + client.call.return_value = SlackResponse(ok=True, ts="stream-ts") + self.context.services.set(SlackApi, client) + self.context.activity.channel_data = { + "SlackMessage": {"event": {"channel": "C1", "ts": "thread-ts"}} + } + slack = SlackAgentExtension(self.app) + + if operation == "call": + await slack.call(self.context, "auth.test") + else: + await slack.create_stream(self.context) + await self.app.on_turn(self.context) + + client.call.assert_awaited_once() + assert self.context.services.get(SlackApi) is client + factory.assert_not_called() + @pytest.mark.asyncio @pytest.mark.parametrize("has_client", [False, True]) async def test_non_slack_turn_does_not_change_client_binding(self, has_client): diff --git a/tests/hosting_slack/test_slack_api.py b/tests/hosting_slack/test_slack_api.py index 9e058f632..e555a22a3 100644 --- a/tests/hosting_slack/test_slack_api.py +++ b/tests/hosting_slack/test_slack_api.py @@ -7,6 +7,7 @@ import json from typing import Any +from unittest.mock import MagicMock import pytest @@ -38,6 +39,8 @@ class _FakeSession: def __init__(self, response: _FakeResponse) -> None: self._response = response self.calls: list[dict[str, Any]] = [] + self.closed = False + self.close_count = 0 def post(self, url: str, *, data: str, headers: dict, timeout=None): self.calls.append( @@ -45,8 +48,9 @@ def post(self, url: str, *, data: str, headers: dict, timeout=None): ) return self._response - async def close(self) -> None: # honoured if owned - return None + async def close(self) -> None: + self.closed = True + self.close_count += 1 @pytest.mark.asyncio @@ -133,3 +137,91 @@ class Opts(BaseModel): await api.call("chat.postMessage", Opts(channel="C1"), token="t") body = json.loads(session.calls[0]["data"]) assert body == {"channel": "C1"} + + +@pytest.mark.asyncio +async def test_owned_session_is_reused_until_explicit_close(monkeypatch): + session = _FakeSession(_FakeResponse(200, '{"ok": true}')) + factory = MagicMock(return_value=session) + monkeypatch.setattr( + "microsoft_agents.hosting.slack.api.slack_api.ClientSession", factory + ) + api = SlackApi(base_url="https://example.invalid/api", request_timeout=7) + + try: + await api.call("auth.test") + await api.call("chat.postMessage", {"text": "hello"}) + + factory.assert_called_once_with() + assert len(session.calls) == 2 + assert session.calls[0]["url"] == "https://example.invalid/api/auth.test" + assert session.calls[0]["timeout"].total == 7 + assert session.closed is False + assert session.close_count == 0 + finally: + await api.aclose() + + assert session.closed is True + assert session.close_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status, body", + [ + (200, '{"ok": false, "error": "invalid_auth"}'), + (500, '{"ok": false, "error": "internal_error"}'), + (500, "not-json"), + ], +) +async def test_api_error_does_not_close_owned_session(monkeypatch, status, body): + session = _FakeSession(_FakeResponse(status, body)) + monkeypatch.setattr( + "microsoft_agents.hosting.slack.api.slack_api.ClientSession", + MagicMock(return_value=session), + ) + api = SlackApi() + + try: + with pytest.raises(SlackResponseException): + await api.call("auth.test") + + assert session.closed is False + assert session.close_count == 0 + session._response = _FakeResponse(200, '{"ok": true}') + assert (await api.call("auth.test")).ok is True + finally: + await api.aclose() + + assert session.close_count == 1 + + +@pytest.mark.asyncio +async def test_injected_session_is_not_closed_or_replaced(monkeypatch): + session = _FakeSession(_FakeResponse(200, '{"ok": true}')) + factory = MagicMock() + monkeypatch.setattr( + "microsoft_agents.hosting.slack.api.slack_api.ClientSession", factory + ) + api = SlackApi(session=session) + + await api.call("auth.test") + await api.aclose() + + factory.assert_not_called() + assert session.closed is False + assert session.close_count == 0 + assert (await api.call("auth.test")).ok is True + + +@pytest.mark.asyncio +async def test_aclose_closes_real_owned_session(): + api = SlackApi() + session = api._session + try: + assert session.closed is False + finally: + await api.aclose() + + assert session.closed is True + await api.aclose() From 5f400235aab6a76be42c2b672f488ca1cc53f4fb Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 13 Oct 2026 12:07:00 -0700 Subject: [PATCH 8/9] Updating changelog --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 7c6947818..6da07dbe1 100644 --- a/changelog.md +++ b/changelog.md @@ -20,6 +20,7 @@ ## Bug Fixes - **Connector User-Agent Headers**: Added the SDK `User-Agent` header to `MCSConnectorClient` and ensured `ConnectorClient`, `TeamsConnectorClient`, and `UserTokenClient` apply it to caller-provided `ClientSession` instances. +- **Teams Meeting Event Models**: Fixed meeting start and end routes to use `MeetingStartEventValue` and `MeetingEndEventValue`, respectively, instead of `MeetingDetails`, ensuring handlers receive correctly typed event data (#619). --- From 214ea9ed543ec966b466a257cb38348a27781c02 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 13 Oct 2026 12:10:44 -0700 Subject: [PATCH 9/9] Lazy ClientSession construction in SlackApi --- .../core/_utils/_turn_context_wrapper.py | 2 +- .../hosting/slack/api/slack_api.py | 6 +- .../hosting/slack/slack_agent_extension.py | 1 - .../test_slack_agent_extension.py | 9 ++ tests/hosting_slack/test_slack_api.py | 100 +++++++++++++++++- 5 files changed, 111 insertions(+), 7 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_turn_context_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_turn_context_wrapper.py index 3fd76f605..2f5b746b0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_turn_context_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_turn_context_wrapper.py @@ -3,7 +3,7 @@ from abc import ABC -from typing import Awaitable, Callable, TYPE_CHECKING +from typing import Awaitable, Callable from microsoft_agents.activity import ( Activity, diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py index e6aa4f052..f58a8a85a 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py @@ -69,7 +69,7 @@ def __init__( :param base_url: The base URL for the Slack API. :param request_timeout: The request timeout in seconds. """ - self._session = session or ClientSession() + self._session = session self._owns_session = session is None self._base_url = base_url self._timeout = ClientTimeout(total=request_timeout) @@ -100,6 +100,8 @@ async def call( if token and token.strip(): headers["Authorization"] = f"Bearer {token}" + if not self._session: + self._session = ClientSession() async with self._session.post( url, data=body, headers=headers, timeout=self._timeout ) as response: @@ -122,5 +124,5 @@ async def call( async def aclose(self) -> None: """Close the owned ClientSession, if any.""" - if self._owns_session: + if self._owns_session and self._session: await self._session.close() diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index a907f56f5..aa6542f09 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -8,7 +8,6 @@ import re from typing import Any, Generic, Pattern, TypeVar -from aiohttp import ClientSession from typing_extensions import deprecated from microsoft_agents.activity import ActivityTypes, Channels diff --git a/tests/hosting_slack/test_slack_agent_extension.py b/tests/hosting_slack/test_slack_agent_extension.py index 67c176386..2b3f57848 100644 --- a/tests/hosting_slack/test_slack_agent_extension.py +++ b/tests/hosting_slack/test_slack_agent_extension.py @@ -284,6 +284,15 @@ async def test_call_prefers_turn_context_service_when_present(self): class TestDefaultClient: + def test_default_client_can_be_accessed_without_running_event_loop(self): + slack = SlackAgentExtension(_make_app()) + + client = slack.slack_api + + assert isinstance(client, SlackApi) + assert slack.slack_api is client + assert client._session is None + def test_construction_is_lazy_and_property_caches_client(self, monkeypatch): client = MagicMock(spec=SlackApi) factory = MagicMock(return_value=client) diff --git a/tests/hosting_slack/test_slack_api.py b/tests/hosting_slack/test_slack_api.py index e555a22a3..1882e2ca9 100644 --- a/tests/hosting_slack/test_slack_api.py +++ b/tests/hosting_slack/test_slack_api.py @@ -5,11 +5,13 @@ from __future__ import annotations +import asyncio import json from typing import Any from unittest.mock import MagicMock import pytest +from aiohttp import ClientSession from microsoft_agents.hosting.slack.api import ( SlackApi, @@ -53,6 +55,12 @@ async def close(self) -> None: self.close_count += 1 +def test_construction_without_running_event_loop_does_not_create_session(): + api = SlackApi(request_timeout=7) + + assert api._session is None + + @pytest.mark.asyncio async def test_call_serializes_dict_and_sends_bearer_token(): session = _FakeSession(_FakeResponse(200, '{"ok": true, "ts": "123.456"}')) @@ -140,7 +148,7 @@ class Opts(BaseModel): @pytest.mark.asyncio -async def test_owned_session_is_reused_until_explicit_close(monkeypatch): +async def test_owned_session_is_created_on_first_call_and_reused(monkeypatch): session = _FakeSession(_FakeResponse(200, '{"ok": true}')) factory = MagicMock(return_value=session) monkeypatch.setattr( @@ -149,7 +157,13 @@ async def test_owned_session_is_reused_until_explicit_close(monkeypatch): api = SlackApi(base_url="https://example.invalid/api", request_timeout=7) try: + factory.assert_not_called() + assert api._session is None + await api.call("auth.test") + factory.assert_called_once_with() + assert api._session is session + await api.call("chat.postMessage", {"text": "hello"}) factory.assert_called_once_with() @@ -165,6 +179,78 @@ async def test_owned_session_is_reused_until_explicit_close(monkeypatch): assert session.close_count == 1 +@pytest.mark.asyncio +async def test_aclose_before_first_call_does_not_create_session(monkeypatch): + factory = MagicMock() + monkeypatch.setattr( + "microsoft_agents.hosting.slack.api.slack_api.ClientSession", factory + ) + api = SlackApi() + + await api.aclose() + await api.aclose() + + factory.assert_not_called() + assert api._session is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method, options, error", + [ + ("", None, ValueError), + (" ", None, ValueError), + ("chat.postMessage", {"unsupported": object()}, TypeError), + ], +) +async def test_invalid_request_does_not_create_session( + monkeypatch, method, options, error +): + factory = MagicMock() + monkeypatch.setattr( + "microsoft_agents.hosting.slack.api.slack_api.ClientSession", factory + ) + api = SlackApi() + + try: + with pytest.raises(error): + await api.call(method, options) + + factory.assert_not_called() + assert api._session is None + finally: + await api.aclose() + + +@pytest.mark.asyncio +async def test_concurrent_first_calls_share_one_session(monkeypatch): + response = _FakeResponse(200, '{"ok": true}') + session = _FakeSession(response) + factory = MagicMock(return_value=session) + monkeypatch.setattr( + "microsoft_agents.hosting.slack.api.slack_api.ClientSession", factory + ) + + async def read_response(): + await asyncio.sleep(0) + return '{"ok": true}' + + monkeypatch.setattr(response, "text", read_response) + api = SlackApi() + + try: + responses = await asyncio.gather(api.call("auth.test"), api.call("auth.test")) + + assert all(response.ok for response in responses) + factory.assert_called_once_with() + assert len(session.calls) == 2 + assert session.close_count == 0 + finally: + await api.aclose() + + assert session.close_count == 1 + + @pytest.mark.asyncio @pytest.mark.parametrize( "status, body", @@ -215,10 +301,18 @@ async def test_injected_session_is_not_closed_or_replaced(monkeypatch): @pytest.mark.asyncio -async def test_aclose_closes_real_owned_session(): +async def test_aclose_closes_real_owned_session_after_first_call(monkeypatch): + post = MagicMock(return_value=_FakeResponse(200, '{"ok": true}')) + monkeypatch.setattr(ClientSession, "post", post) api = SlackApi() - session = api._session + assert api._session is None + try: + response = await api.call("auth.test") + session = api._session + assert isinstance(session, ClientSession) + assert response.ok is True + post.assert_called_once() assert session.closed is False finally: await api.aclose()