Python: feat(python): cross-channel hosting improvements (endpoint paths, Activity push, Telegram/Teams fixes) (#6307)

* Update hosting channel endpoint paths

Treat channel paths as concrete endpoint paths so built-in channels can be mounted at their defaults or at the app root without sample-specific subclasses. Update docs, tests, and the Foundry Telegram Invocations sample accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add push support to ActivityProtocolChannel

Implement the ChannelPush protocol so the Activity Protocol channel can
receive cross-channel fan-out (ResponseTarget.all_linked) and echo_input
replay as a non-originating destination:

- Add push() that reconstructs a proactive Bot Framework activity (bot/user
  swap) from the stored conversation reference and POSTs it to
  /v3/conversations/{id}/activities.
- Record a ChannelIdentity (service_url, conversation, bot, user, channel_id,
  locale) on ChannelRequest.identity so the host registers the channel under
  its isolation key for fan-out resolution.
- Route the streaming path through deliver_response so Activity-originated
  turns broadcast like Telegram/Discord.
- Add tests for push delivery, service_url validation, ChannelPush instance
  check, and inbound identity recording.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Don't delete Telegram webhook on shutdown by default

The TelegramChannel deleted its webhook on shutdown in webhook mode. During
a rolling redeploy the new revision registers the webhook on startup, then
the old revision's shutdown deletes it, silently breaking inbound delivery
until the next boot. setWebhook is overwriting/idempotent, so startup
re-asserts the webhook every boot and no teardown is needed.

Add a delete_webhook_on_shutdown flag (default False) so teardown is opt-in
for ephemeral deployments, and leave the webhook in place otherwise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Activity channel streaming on non-Teams channels (405 on updateActivity)

The Activity Protocol channel streamed replies the Teams way: POST a
placeholder, then PUT-edit it as tokens arrive. Only Teams supports the
updateActivity REST op; Web Chat, Direct Line and the Emulator return
405 Method Not Allowed on the PUT, so the user saw only the placeholder.

Gate the placeholder+edit flow on edit-capable channels (msteams). Other
channels now buffer the stream and POST a single final message, mirroring
the non-streaming path's fan-out and response-hook semantics. Also add a
defensive 405 fallback inside the Teams edit loop so an unexpected 405
can never strand the user on the placeholder.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting-activity-protocol): don't parse Teams inline attachment content as a URI

Teams message activities include a text/html attachment whose inline
`content` is raw HTML (not a URL). _parse_activity fell back to
`attachment["content"]` and passed it to Content.from_uri, raising
ContentError ("URI must contain a scheme") and failing the whole turn,
so Teams users got no response.

Only treat `contentUrl` as a URI, require an absolute scheme, and skip
unparseable attachments defensively instead of failing the message.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting-activity-protocol): native slash-command dispatch for Teams/Activity

Add a commands= parameter to ActivityProtocolChannel that intercepts a
leading /command (after stripping the bot's own @mention) and dispatches
to ChannelCommand handlers, mirroring the Telegram channel. Unknown
commands fall through to the agent. The channel run_hook is applied to
command requests so handlers observe the same resolved isolation key as
ordinary messages, and handler errors are swallowed (200, no Bot Service
retry of non-idempotent commands).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting): silent attributed Telegram echoes + Teams markdown rendering

- hosting-telegram: send cross-channel input echoes with disable_notification
  (silent) and detect echo payloads so they aren't re-broadcast.
- hosting-activity-protocol: render outbound + push activities as textFormat
  'markdown' so Teams shows formatted replies (enables per-channel variants).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting-activity-protocol): address PR #6307 review feedback

Consult the host delivery pipeline even for empty streamed replies so
ResponseTarget.none is honoured and non-originating fan-out is consulted
instead of always emitting an originating "(no response)" message. Applies
to both the progressive-edit (Teams) and buffered (Web Chat/Direct Line)
streaming paths.

Re-validate service_url against the allow-list in push(): the identity is
read from a persisted store and push runs out-of-band, so the captured
service_url must be re-checked before a bearer token is sent.

Adds tests for empty-stream host consultation/suppression on both streaming
paths and for push rejecting a disallowed service_url.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-06-03 16:37:03 +02:00
committed by GitHub
Unverified
parent e8c22caaeb
commit e5a6e35843
33 changed files with 1449 additions and 133 deletions
@@ -23,7 +23,10 @@ This channel handles:
- inbound ``message`` activities — text and attachments resolved to URIs,
- outbound replies via ``POST /v3/conversations/{id}/activities``,
- streaming via ``PUT /v3/conversations/{id}/activities/{id}`` mid-stream
edits (Teams supports updateActivity in personal chats and groups),
edits on channels that support ``updateActivity`` (Teams personal chats
and groups); every other channel — Web Chat, Direct Line, the Emulator —
rejects the PUT with ``405``, so those buffer the stream and POST a
single final message instead,
- typing indicators while the agent works,
- per-conversation isolation key ``activity:<conversation_id>`` so a Responses
caller can resume a Teams conversation by passing the conversation id,
@@ -67,7 +70,7 @@ from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any
from urllib.parse import urlparse
@@ -79,9 +82,13 @@ from agent_framework import (
Message,
ResponseStream,
)
from agent_framework.exceptions import ContentError
from agent_framework_hosting import (
ChannelCommand,
ChannelCommandContext,
ChannelContext,
ChannelContribution,
ChannelIdentity,
ChannelRequest,
ChannelResponseContext,
ChannelResponseHook,
@@ -116,6 +123,16 @@ _DEFAULT_SERVICE_URL_HOSTS = (
"smba.trafficmanager.net",
)
# Bot Framework channels that support editing an Activity in place via
# ``PUT /v3/conversations/{id}/activities/{id}`` (the ``updateActivity``
# REST operation). Progressive-edit streaming (POST a placeholder, then
# repeatedly PUT it) only works on these. Every other channel — Web Chat,
# Direct Line, the Emulator, etc. — returns ``405 Method Not Allowed`` on
# the PUT, so those channels buffer the stream and POST a single final
# message instead. Teams is the canonical (and effectively only) public
# channel that supports the edit operation.
_EDIT_CAPABLE_CHANNELS = frozenset({"msteams"})
InboundAuthValidator = Callable[[Request], Awaitable[bool]]
@@ -134,13 +151,20 @@ class _OutboundError(RuntimeError):
"""Marker for transient outbound failures that should produce 502/retry."""
def _text_result(text: str) -> HostedRunResult[AgentResponse]:
"""Wrap plain text in a ``HostedRunResult`` for streaming fan-out delivery."""
return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]))
def _parse_activity(activity: Mapping[str, Any]) -> Message:
"""Translate one Bot Framework ``message`` Activity into an Agent Framework Message.
Pulls the activity's ``text`` plus any image/file attachments with a
``contentType`` and resolvable URL into ``Content`` parts. If the
activity has no usable parts an empty text part is emitted so the
caller never sees a content-less message.
Pulls the activity's ``text`` plus any image/file attachments that expose a
resolvable ``contentUrl`` into ``Content`` parts. Bot Framework's inline
``content`` field (e.g. the ``text/html`` rendering Teams attaches alongside
``text``, or an Adaptive Card payload) is *not* a URI, so it is ignored here
to avoid mis-parsing it as a URL. If the activity has no usable parts an
empty text part is emitted so the caller never sees a content-less message.
"""
parts: list[Content] = []
if (text := activity.get("text")) and isinstance(text, str):
@@ -149,16 +173,56 @@ def _parse_activity(activity: Mapping[str, Any]) -> Message:
for attachment in activity.get("attachments") or []:
if not isinstance(attachment, Mapping):
continue
url = attachment.get("contentUrl") or attachment.get("content")
url = attachment.get("contentUrl")
content_type = attachment.get("contentType")
if isinstance(url, str) and isinstance(content_type, str) and "/" in content_type:
if not (isinstance(url, str) and isinstance(content_type, str) and "/" in content_type):
continue
# contentUrl is occasionally a relative reference or otherwise lacks a
# scheme; skip those so one odd attachment can't fail the whole turn.
if not urlparse(url).scheme:
logger.debug("Skipping attachment with non-absolute contentUrl: %r", url)
continue
try:
parts.append(Content.from_uri(uri=url, media_type=content_type))
except ContentError:
logger.debug("Skipping attachment with unparseable contentUrl: %r", url)
continue
if not parts:
parts.append(Content.from_text(text=""))
return Message("user", parts)
def _command_text(activity: Mapping[str, Any]) -> str:
"""Return the activity text with the bot's own @mention stripped.
Channels that require an @mention to address the bot (Teams team and
group-chat scopes) prefix the message ``text`` with a mention whose literal
rendering is carried in the matching ``entities[].text`` (e.g.
``"<at>Personal Assistant</at> /todos"``). Personal 1:1 chats carry no
mention. We remove only the bot's own mention substring(s) — never other
users' mentions — so a leading ``/command`` can be detected in every scope.
"""
text = activity.get("text")
if not isinstance(text, str):
return ""
bot_id = (activity.get("recipient") or {}).get("id")
for entity in activity.get("entities") or []:
if not isinstance(entity, Mapping) or entity.get("type") != "mention":
continue
mentioned = entity.get("mentioned")
mentioned_id = mentioned.get("id") if isinstance(mentioned, Mapping) else None
# Only strip the bot's own mention; leave mentions of other users intact.
# When the recipient id is unknown we cannot disambiguate, so fall back
# to stripping every mention to keep command detection working.
if bot_id is not None and mentioned_id != bot_id:
continue
mention_text = entity.get("text")
if isinstance(mention_text, str) and mention_text:
text = text.replace(mention_text, "")
return text.strip()
class ActivityProtocolChannel:
"""Microsoft Teams channel via Bot Framework v4 webhook.
@@ -176,7 +240,7 @@ class ActivityProtocolChannel:
def __init__(
self,
*,
path: str = "/activity",
path: str = "/activity/messages",
app_id: str | None = None,
app_password: str | None = None,
certificate_path: str | None = None,
@@ -184,6 +248,7 @@ class ActivityProtocolChannel:
tenant_id: str = _BOTFRAMEWORK_TENANT,
token_scope: str = _BOTFRAMEWORK_SCOPE,
credential: AsyncTokenCredential | None = None,
commands: Sequence[ChannelCommand] = (),
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
send_typing_action: bool = True,
@@ -196,7 +261,8 @@ class ActivityProtocolChannel:
"""Configure the Teams channel.
Keyword Args:
path: Mount path. The webhook lives at ``{path}/messages``.
path: Messages endpoint path on the host. Use ``""`` to expose the
webhook at the app root.
app_id: Bot Framework / Entra application (client) id. Required
whenever any credential is supplied.
app_password: Application secret for OAuth2 client credentials.
@@ -214,6 +280,16 @@ class ActivityProtocolChannel:
credential: Bring your own ``AsyncTokenCredential`` (e.g. a
``DefaultAzureCredential`` configured elsewhere). Overrides
``app_password`` / ``certificate_path``.
commands: Discoverable ``/command`` handlers. An inbound message
whose text (after stripping the bot's own @mention) begins with
``/`` and matches a command ``name`` (case-insensitive) is
dispatched to that handler instead of the agent, mirroring the
Telegram channel. The matching ``run_hook`` is applied to the
command request first, so command handlers observe the same
resolved ``session.isolation_key`` as ordinary messages.
Unknown ``/foo`` text falls through to the agent. Handlers reply
via ``ChannelCommandContext.reply``; surface them to users with
a Teams manifest ``commandLists`` entry.
run_hook: Optional rewrite of ``ChannelRequest`` before invocation.
response_hook: Optional rewrite of the
:class:`HostedRunResult` before the originating Activity
@@ -260,6 +336,7 @@ class ActivityProtocolChannel:
self._app_id = app_id
self._token_scope = token_scope
self._tenant_id = tenant_id
self._commands = list(commands)
self._hook = run_hook
self.response_hook = response_hook
self._send_typing_action = send_typing_action
@@ -292,10 +369,11 @@ class ActivityProtocolChannel:
self._credential = None # dev mode
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Capture the host context and register the ``POST /messages`` webhook."""
"""Capture the host context and register the messages webhook."""
self._ctx = context
return ChannelContribution(
routes=[Route("/messages", self._handle, methods=["POST"])],
routes=[Route("/", self._handle, methods=["POST"])],
commands=self._commands,
on_startup=[self._on_startup],
on_shutdown=[self._on_shutdown],
)
@@ -326,14 +404,14 @@ class ActivityProtocolChannel:
else:
cred_kind = type(self._credential).__name__
logger.info(
"ActivityProtocolChannel listening on %s/messages (auth=%s, tenant=%s)",
"ActivityProtocolChannel listening on %s (auth=%s, tenant=%s)",
self.path,
cred_kind,
self._tenant_id,
)
if self._inbound_auth_validator is None:
logger.warning(
"ActivityProtocolChannel %s/messages has no inbound_auth_validator — "
"ActivityProtocolChannel %s has no inbound_auth_validator — "
"the webhook will accept ANY caller. Plug an inbound_auth_validator "
"or terminate auth in front of the channel before exposing this "
"endpoint to a public network.",
@@ -464,12 +542,46 @@ class ActivityProtocolChannel:
logger.warning("Teams activity missing conversation.id or serviceUrl — dropping")
return
# Native command dispatch — a leading ``/command`` (after stripping the
# bot's own @mention) bypasses the agent, mirroring the Telegram channel.
# Unknown commands fall through to the agent as a normal message.
if self._commands:
command_text = _command_text(activity)
if command_text.startswith("/"):
tokens = command_text[1:].split()
if tokens:
command_name = tokens[0].split("@", 1)[0].lower()
handler = next((c for c in self._commands if c.name.lower() == command_name), None)
if handler is not None:
await self._invoke_command(activity, conversation_id, service_url, handler, command_text)
return
parsed = _parse_activity(activity)
# Store a Bot Framework conversation reference on the identity so the
# host can proactively ``push`` to this conversation later (fan-out
# from another channel). Recording the identity also registers this
# channel under the isolation key so ``ResponseTarget.all_linked`` /
# ``.active`` can resolve it.
identity = ChannelIdentity(
channel=self.name,
native_id=conversation_id,
attributes={
"service_url": service_url,
"conversation": dict(conversation),
# Inbound recipient is the bot → outbound ``from``; inbound
# ``from`` is the user → outbound ``recipient``.
"bot": dict(activity.get("recipient") or {}),
"user": dict(activity.get("from") or {}),
"channel_id": activity.get("channelId"),
"locale": activity.get("locale"),
},
)
channel_request = ChannelRequest(
channel=self.name,
operation="message.create",
input=[parsed],
session=ChannelSession(isolation_key=activity_protocol_isolation_key(conversation_id)),
identity=identity,
attributes={
"conversation_id": conversation_id,
"service_url": service_url,
@@ -489,6 +601,69 @@ class ActivityProtocolChannel:
await self._dispatch(activity, channel_request)
async def _invoke_command(
self,
activity: Mapping[str, Any],
conversation_id: str,
service_url: str,
handler: ChannelCommand,
command_text: str,
) -> None:
"""Run a matched ``/command`` handler and reply into the conversation.
The command request mirrors the message-path request (same isolation
key, identity and attributes) and is run through the channel ``run_hook``
first, so handlers observe the same resolved ``session.isolation_key`` as
ordinary messages. Handler/reply failures are logged but never raised:
commands are best-effort, and surfacing a 502 would make Bot Service
retry the inbound activity and re-run a non-idempotent command.
"""
if self._ctx is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
identity = ChannelIdentity(
channel=self.name,
native_id=conversation_id,
attributes={
"service_url": service_url,
"conversation": dict(activity.get("conversation") or {}),
"bot": dict(activity.get("recipient") or {}),
"user": dict(activity.get("from") or {}),
"channel_id": activity.get("channelId"),
"locale": activity.get("locale"),
},
)
request = ChannelRequest(
channel=self.name,
operation="command.invoke",
input=command_text,
session=ChannelSession(isolation_key=activity_protocol_isolation_key(conversation_id)),
identity=identity,
attributes={
"conversation_id": conversation_id,
"service_url": service_url,
"from_id": (activity.get("from") or {}).get("id"),
"channel_id": activity.get("channelId"),
"aad_object_id": (activity.get("from") or {}).get("aadObjectId"),
},
metadata={"reply_to_id": activity.get("id"), "recipient": activity.get("recipient")},
)
if self._hook is not None:
request = await apply_run_hook(
self._hook,
request,
target=self._ctx.target,
protocol_request=activity,
)
async def _reply(body: str) -> None:
await self._send_message(activity, body)
ctx = ChannelCommandContext(request=request, reply=_reply)
try:
await handler.handle(ctx)
except Exception:
logger.exception("ActivityProtocolChannel command %r failed", command_text)
# -- outbound helpers -------------------------------------------------- #
async def _dispatch(self, inbound: Mapping[str, Any], request: ChannelRequest) -> None:
@@ -513,7 +688,7 @@ class ActivityProtocolChannel:
return
stream = self._ctx.run_stream(request)
await self._stream_to_conversation(inbound, stream)
await self._stream_to_conversation(inbound, request, stream)
async def _apply_response_hook(
self,
@@ -535,22 +710,30 @@ class ActivityProtocolChannel:
async def _stream_to_conversation(
self,
inbound: Mapping[str, Any],
request: ChannelRequest,
stream: ResponseStream[AgentResponseUpdate, AgentResponse],
) -> None:
"""Iterate the stream and progressively edit a single Teams activity.
"""Stream the reply back into the originating conversation.
If the initial placeholder POST fails we fall back to buffering
the whole stream and POSTing a single final message at the end.
Without that fallback the edit-loop's exit condition
``accumulated == last_sent`` is unreachable while ``activity_id``
is ``None`` (no PUT possible), and the worker would deadlock
forever on ``wake.wait()`` after ``worker_done`` is set.
Channels that support the ``updateActivity`` REST operation (see
``_EDIT_CAPABLE_CHANNELS`` — effectively only Teams) get the
progressive-edit experience: a ``…`` placeholder is POSTed, then
repeatedly PUT-edited as text accumulates. Every other channel —
Web Chat, Direct Line, the Emulator, etc. — returns ``405 Method
Not Allowed`` on the PUT, so those buffer the whole stream and POST
a single final message (``_buffer_and_send``); attempting the
edit path there would leave the user staring at a stray ``…``.
"""
if str(inbound.get("channelId") or "").lower() not in _EDIT_CAPABLE_CHANNELS:
await self._buffer_and_send(inbound, request, stream)
return
accumulated = ""
last_sent = ""
last_edit_at = 0.0
activity_id: str | None = None
placeholder_ok = False
edit_unsupported = False
worker_done = asyncio.Event()
wake = asyncio.Event()
@@ -567,7 +750,7 @@ class ActivityProtocolChannel:
placeholder_ok = False
async def edit_worker() -> None:
nonlocal last_sent, last_edit_at
nonlocal last_sent, last_edit_at, edit_unsupported
# When the placeholder failed we have no activity_id to PUT
# into; the loop's only useful work is exiting cleanly. Skip
# straight to that — the final flush below will POST the
@@ -591,8 +774,23 @@ class ActivityProtocolChannel:
continue
try:
await self._update_activity(inbound, activity_id or "", snapshot)
except httpx.HTTPStatusError as exc:
# Some channels advertised as edit-capable may still
# reject the PUT (405). Stop editing and let the final
# flush POST the accumulated text as a new message;
# don't advance ``last_sent`` so that flush still fires.
if exc.response.status_code == 405:
edit_unsupported = True
logger.warning(
"Activity edit not supported by channel %r — sending a single final message instead",
inbound.get("channelId"),
)
return
logger.exception("Activity interim edit failed")
continue
except Exception: # pragma: no cover
logger.exception("Activity interim edit failed")
continue
last_sent = snapshot
last_edit_at = time.monotonic()
@@ -627,10 +825,24 @@ class ActivityProtocolChannel:
except Exception: # pragma: no cover
logger.exception("Stream finalize failed")
# Fan the final reply out to any non-originating linked destinations
# (e.g. ``ResponseTarget.all_linked``) and learn whether this channel
# should still render on its own wire. For the default
# ``ResponseTarget.originating`` this is a no-op that returns True.
# Always consult the host even when nothing streamed so that
# ``ResponseTarget.none`` is honoured and non-originating targets are
# still fanned out for empty replies.
include_originating = True
if self._ctx is not None:
include_originating = await self._ctx.deliver_response(request, _text_result(accumulated))
if not include_originating:
return
# Final flush — make sure the user sees everything that arrived after
# the worker's last edit. If the placeholder failed we POST a fresh
# activity here with whatever accumulated.
if not placeholder_ok:
# the worker's last edit. If the placeholder failed, or the channel
# turned out not to support edits (405), POST a fresh activity here
# with whatever accumulated rather than PUT-editing the placeholder.
if not placeholder_ok or edit_unsupported:
text = accumulated or "(no response)"
try:
await self._send_message(inbound, text)
@@ -649,6 +861,62 @@ class ActivityProtocolChannel:
except Exception: # pragma: no cover
logger.exception("Activity placeholder replace failed")
async def _buffer_and_send(
self,
inbound: Mapping[str, Any],
request: ChannelRequest,
stream: ResponseStream[AgentResponseUpdate, AgentResponse],
) -> None:
"""Consume the whole stream and POST a single final message.
Used for Bot Framework channels that do not support editing an
activity in place (everything except Teams — see
``_EDIT_CAPABLE_CHANNELS``). Those channels return ``405`` to
``PUT /v3/conversations/{id}/activities/{id}``, so the progressive
in-place edit cannot be used; we buffer the stream and ``POST`` a
single message at the end. Mirrors the non-streaming path's
fan-out + response-hook semantics so behaviour is consistent
regardless of whether the target streamed.
"""
accumulated = ""
try:
async for update in stream:
if self._stream_transform_hook is not None:
transformed = self._stream_transform_hook(update)
if isinstance(transformed, Awaitable):
transformed = await transformed
if transformed is None:
continue
update = transformed
chunk = getattr(update, "text", None)
if chunk:
accumulated += chunk
except Exception:
logger.exception("Activity streaming consumption failed")
try:
await stream.get_final_response()
except Exception: # pragma: no cover
logger.exception("Stream finalize failed")
# Fan the final reply out to any non-originating linked destinations
# and learn whether this channel should still render on its own wire.
# Always consult the host even when nothing streamed so that
# ``ResponseTarget.none`` is honoured and non-originating targets are
# still fanned out for empty replies.
include_originating = True
if self._ctx is not None:
include_originating = await self._ctx.deliver_response(request, _text_result(accumulated))
if not include_originating:
return
result = await self._apply_response_hook(_text_result(accumulated), request)
text = getattr(result.result, "text", None) or "(no response)"
try:
await self._send_message(inbound, text)
except Exception: # pragma: no cover
logger.exception("Activity buffered final send failed")
# -- Bot Framework REST helpers --------------------------------------- #
def _activity_payload(self, inbound: Mapping[str, Any], text: str) -> dict[str, Any]:
@@ -664,7 +932,7 @@ class ActivityProtocolChannel:
"channelId": inbound.get("channelId"),
"serviceUrl": inbound.get("serviceUrl"),
"text": text,
"textFormat": "plain",
"textFormat": "markdown",
}
async def _send_message(self, inbound: Mapping[str, Any], text: str) -> str | None:
@@ -730,5 +998,56 @@ class ActivityProtocolChannel:
except Exception: # pragma: no cover - non-critical UX
logger.exception("Teams typing send failed")
# -- ChannelPush -------------------------------------------------------- #
async def push(self, identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None:
"""Proactively deliver an out-of-band message into a Bot Framework conversation.
Implements :class:`host.ChannelPush` so this channel can be a
non-originating destination for ``ChannelRequest.response_target``
(e.g. ``ResponseTarget.all_linked`` fan-out from Telegram/Discord, or
``echo_input`` replay). The conversation reference is reconstructed
from ``identity.attributes`` captured on the inbound activity:
``service_url``, ``conversation``, ``bot`` (outbound ``from``),
``user`` (outbound ``recipient``), and ``channel_id``.
Echo payloads (the user's mirrored input) carry ``role="user"``
messages; Bot Service channels can only send AS the bot, so the text
is delivered as a normal bot message.
"""
if self._http is None:
raise RuntimeError("ActivityProtocolChannel.push called before startup")
attrs = identity.attributes
service_url = str(attrs.get("service_url") or "").rstrip("/")
conversation = dict(attrs.get("conversation") or {"id": identity.native_id})
conversation_id = conversation.get("id") or identity.native_id
if not service_url:
raise ValueError("ActivityProtocolChannel.push requires 'service_url' in identity attributes")
# Re-validate the persisted ``service_url`` against the allow-list. The
# identity may have been recorded hours earlier (push runs out-of-band),
# so the allow-list could have narrowed or the store been tampered with
# since; never send a bearer token to a now-disallowed host.
if not self._is_service_url_allowed(service_url):
raise ValueError(f"ActivityProtocolChannel.push: service_url {service_url!r} is not in the allowed hosts")
text = getattr(payload.result, "text", None) or "(no response)"
activity = {
"type": "message",
"from": dict(attrs.get("bot") or {}),
"recipient": dict(attrs.get("user") or {}),
"conversation": conversation,
"channelId": attrs.get("channel_id"),
"serviceUrl": attrs.get("service_url"),
"text": text,
"textFormat": "markdown",
}
if attrs.get("locale"):
activity["locale"] = attrs["locale"]
url = f"{service_url}/v3/conversations/{conversation_id}/activities"
token = await self._get_token()
response = await self._http.post(url, json=activity, headers=self._auth_headers(token))
response.raise_for_status()
__all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"]
@@ -9,16 +9,24 @@ streaming edits and certificate paths are out of scope here.
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from agent_framework_hosting import AgentFrameworkHost, HostedRunResult
from agent_framework_hosting import (
AgentFrameworkHost,
ChannelCommand,
ChannelCommandContext,
ChannelIdentity,
ChannelRequest,
ChannelSession,
HostedRunResult,
)
from starlette.testclient import TestClient
from agent_framework_hosting_activity_protocol import ActivityProtocolChannel, activity_protocol_isolation_key
from agent_framework_hosting_activity_protocol._channel import _parse_activity
from agent_framework_hosting_activity_protocol._channel import _command_text, _parse_activity, _text_result
def test_activity_protocol_isolation_key_format() -> None:
@@ -57,6 +65,74 @@ class TestParseActivity:
# No URI content survived.
assert not any(getattr(c, "uri", None) for c in msg.contents)
def test_skips_teams_text_html_inline_content(self) -> None:
# Teams attaches a text/html rendering whose inline ``content`` is raw
# HTML (not a URL). It must not be parsed as a URI.
msg = _parse_activity({
"type": "message",
"text": "hello there",
"attachments": [
{"contentType": "text/html", "content": "<p>hello there</p>"},
],
})
assert msg.text == "hello there"
assert not any(getattr(c, "uri", None) for c in msg.contents)
def test_skips_attachment_contenturl_without_scheme(self) -> None:
msg = _parse_activity({
"type": "message",
"text": "hi",
"attachments": [
{"contentType": "image/png", "contentUrl": "/relative/path.png"},
],
})
assert msg.text == "hi"
assert not any(getattr(c, "uri", None) for c in msg.contents)
class TestCommandText:
def test_plain_text_unchanged(self) -> None:
assert _command_text({"text": "/help"}) == "/help"
def test_non_string_text_returns_empty(self) -> None:
assert _command_text({"text": None}) == ""
assert _command_text({}) == ""
def test_strips_bot_mention(self) -> None:
activity = {
"text": "<at>Personal Assistant</at> /todos",
"recipient": {"id": "bot-1"},
"entities": [
{"type": "mention", "text": "<at>Personal Assistant</at>", "mentioned": {"id": "bot-1"}},
],
}
assert _command_text(activity) == "/todos"
def test_strips_bot_mention_without_space(self) -> None:
activity = {
"text": "<at>Bot</at>/help",
"recipient": {"id": "bot-1"},
"entities": [{"type": "mention", "text": "<at>Bot</at>", "mentioned": {"id": "bot-1"}}],
}
assert _command_text(activity) == "/help"
def test_keeps_other_user_mention(self) -> None:
activity = {
"text": "/whoami <at>Someone</at>",
"recipient": {"id": "bot-1"},
"entities": [{"type": "mention", "text": "<at>Someone</at>", "mentioned": {"id": "user-9"}}],
}
# Another user's mention must not be stripped.
assert _command_text(activity) == "/whoami <at>Someone</at>"
def test_malformed_entities_are_ignored(self) -> None:
activity = {
"text": "/help",
"recipient": {"id": "bot-1"},
"entities": ["not-a-mapping", {"type": "clientInfo"}, {"type": "mention"}],
}
assert _command_text(activity) == "/help"
@dataclass
class _FakeAgentResponse:
@@ -80,9 +156,11 @@ class _FakeAgent:
return _coro()
def _make_teams(stream: bool = False) -> tuple[ActivityProtocolChannel, _FakeAgent]:
def _make_teams(
stream: bool = False, *, path: str = "/activity/messages"
) -> tuple[ActivityProtocolChannel, _FakeAgent]:
agent = _FakeAgent("hi there")
ch = ActivityProtocolChannel(stream=stream, send_typing_action=False)
ch = ActivityProtocolChannel(path=path, stream=stream, send_typing_action=False)
fake_http = MagicMock()
response_mock = MagicMock()
response_mock.raise_for_status = MagicMock()
@@ -105,6 +183,11 @@ _VALID_ACTIVITY: dict[str, Any] = {
"serviceUrl": "https://smba.trafficmanager.net/amer/",
}
# Minimal request envelope for direct ``_stream_to_conversation`` calls. The
# channel only consults it for cross-channel fan-out, which is skipped when
# ``_ctx`` is unset (as in these unit tests).
_VALID_REQUEST = ChannelRequest(channel="activity", operation="message.create", input=[])
class TestTeamsWebhook:
def test_message_activity_dispatches_to_agent(self) -> None:
@@ -122,6 +205,14 @@ class TestTeamsWebhook:
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "hi there"
def test_empty_path_mounts_at_app_root(self) -> None:
ch, agent = _make_teams(path="")
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/", json=_VALID_ACTIVITY)
assert r.status_code == 200
assert agent.runs, "expected the agent to be invoked"
def test_response_hook_can_rewrite_originating_reply(self) -> None:
contexts: list[Any] = []
@@ -181,6 +272,107 @@ class TestTeamsWebhook:
assert not agent.runs
class TestCommands:
def _make_with_commands(self, commands: list[ChannelCommand]) -> tuple[ActivityProtocolChannel, _FakeAgent]:
agent = _FakeAgent("hi there")
ch = ActivityProtocolChannel(send_typing_action=False, commands=commands)
fake_http = MagicMock()
response_mock = MagicMock()
response_mock.raise_for_status = MagicMock()
response_mock.json = MagicMock(return_value={"id": "act-1"})
fake_http.post = AsyncMock(return_value=response_mock)
fake_http.put = AsyncMock(return_value=response_mock)
fake_http.aclose = AsyncMock()
ch._http = fake_http
return ch, agent
def test_slash_command_bypasses_agent_and_replies(self) -> None:
seen: list[ChannelCommandContext] = []
async def handle(ctx: ChannelCommandContext) -> None:
seen.append(ctx)
await ctx.reply("listed")
ch, agent = self._make_with_commands([ChannelCommand("todos", "List", handle)])
host = AgentFrameworkHost(target=agent, channels=[ch])
activity = dict(_VALID_ACTIVITY, text="/todos")
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=activity)
assert r.status_code == 200
assert not agent.runs, "command must bypass the agent"
assert seen and seen[0].request.operation == "command.invoke"
assert seen[0].request.input == "/todos"
assert seen[0].request.session is not None
assert seen[0].request.session.isolation_key == activity_protocol_isolation_key("19:meeting_xyz@thread.v2")
assert ch._http is not None
assert ch._http.post.call_args[1]["json"]["text"] == "listed" # type: ignore[attr-defined]
def test_command_match_is_case_insensitive(self) -> None:
ran = False
async def handle(ctx: ChannelCommandContext) -> None:
nonlocal ran
ran = True
ch, agent = self._make_with_commands([ChannelCommand("New", "reset", handle)])
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/new"))
assert r.status_code == 200
assert ran
assert not agent.runs
def test_unknown_command_falls_through_to_agent(self) -> None:
async def handle(ctx: ChannelCommandContext) -> None: # pragma: no cover - never called
raise AssertionError("should not run")
ch, agent = self._make_with_commands([ChannelCommand("todos", "List", handle)])
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/unknown"))
assert r.status_code == 200
assert agent.runs, "unknown /command must reach the agent"
def test_command_failure_does_not_retry(self) -> None:
async def handle(ctx: ChannelCommandContext) -> None:
raise RuntimeError("boom")
ch, agent = self._make_with_commands([ChannelCommand("todos", "List", handle)])
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/todos"))
# Best-effort: a failing command is swallowed and acked with 200 so Bot
# Service does not retry (and re-run a non-idempotent command).
assert r.status_code == 200
assert not agent.runs
def test_run_hook_applied_to_command_request(self) -> None:
def hook(request: ChannelRequest, **_: Any) -> ChannelRequest:
return replace(request, session=ChannelSession(isolation_key="resolved-key"))
captured: list[str] = []
async def handle(ctx: ChannelCommandContext) -> None:
assert ctx.request.session is not None
captured.append(ctx.request.session.isolation_key)
agent = _FakeAgent("hi")
ch = ActivityProtocolChannel(send_typing_action=False, commands=[ChannelCommand("todos", "x", handle)])
ch._hook = hook
fake_http = MagicMock()
response_mock = MagicMock()
response_mock.raise_for_status = MagicMock()
response_mock.json = MagicMock(return_value={"id": "act-1"})
fake_http.post = AsyncMock(return_value=response_mock)
fake_http.aclose = AsyncMock()
ch._http = fake_http
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=dict(_VALID_ACTIVITY, text="/todos"))
assert r.status_code == 200
assert captured == ["resolved-key"]
class TestOutbound:
async def test_send_message_posts_to_conversation_url(self) -> None:
ch, _agent = _make_teams()
@@ -193,6 +385,103 @@ class TestOutbound:
assert body["text"] == "hi"
class TestPush:
"""The channel implements ``host.ChannelPush`` so it can be a
non-originating destination for cross-channel fan-out / echo replay."""
def test_is_channel_push_instance(self) -> None:
from agent_framework_hosting import ChannelPush
ch, _agent = _make_teams()
assert isinstance(ch, ChannelPush)
def _identity(self) -> ChannelIdentity:
return ChannelIdentity(
channel="activity",
native_id="19:meeting_xyz@thread.v2",
attributes={
"service_url": "https://smba.trafficmanager.net/amer/",
"conversation": {"id": "19:meeting_xyz@thread.v2"},
"bot": {"id": "bot-1"},
"user": {"id": "user-1"},
"channel_id": "msteams",
"locale": "en-US",
},
)
async def test_push_posts_proactive_activity(self) -> None:
ch, _agent = _make_teams()
await ch.push(self._identity(), _text_result("broadcast hello"))
assert ch._http is not None
ch._http.post.assert_called() # type: ignore[attr-defined]
url = ch._http.post.call_args[0][0] # type: ignore[attr-defined]
assert url == ("https://smba.trafficmanager.net/amer/v3/conversations/19:meeting_xyz@thread.v2/activities")
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "broadcast hello"
# Outbound activity speaks AS the bot: inbound recipient -> from,
# inbound from -> recipient.
assert body["from"] == {"id": "bot-1"}
assert body["recipient"] == {"id": "user-1"}
assert body["conversation"] == {"id": "19:meeting_xyz@thread.v2"}
async def test_push_requires_service_url(self) -> None:
ch, _agent = _make_teams()
identity = ChannelIdentity(
channel="activity",
native_id="conv-x",
attributes={"conversation": {"id": "conv-x"}},
)
with pytest.raises(ValueError, match="service_url"):
await ch.push(identity, _text_result("hi"))
async def test_push_rejects_disallowed_service_url(self) -> None:
# ``push`` runs out-of-band against a persisted identity, so it must
# re-validate the service_url against the allow-list rather than trust
# the value captured (possibly hours) earlier.
ch, _agent = _make_teams()
identity = ChannelIdentity(
channel="activity",
native_id="conv-x",
attributes={
"service_url": "https://attacker.example.com/",
"conversation": {"id": "conv-x"},
"bot": {"id": "bot-1"},
"user": {"id": "user-1"},
},
)
with pytest.raises(ValueError, match="not in the allowed hosts"):
await ch.push(identity, _text_result("hi"))
assert ch._http is not None
ch._http.post.assert_not_called() # type: ignore[attr-defined]
class TestIdentityRecording:
"""``_process_activity`` must stamp the inbound conversation reference
onto ``ChannelRequest.identity`` so the host can record it for fan-out."""
async def test_inbound_sets_request_identity(self) -> None:
ch, agent = _make_teams()
captured: dict[str, Any] = {}
async def hook(req: ChannelRequest, **_: Any) -> ChannelRequest:
captured["request"] = req
return req
ch._hook = hook # type: ignore[assignment]
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 200
request = captured["request"]
assert request.identity is not None
assert request.identity.channel == "activity"
assert request.identity.native_id == "19:meeting_xyz@thread.v2"
attrs = request.identity.attributes
assert attrs["service_url"] == "https://smba.trafficmanager.net/amer/"
assert attrs["bot"] == {"id": "bot-1"}
assert attrs["user"] == {"id": "user-1"}
class TestConfig:
def test_rejects_both_secret_and_certificate(self) -> None:
with pytest.raises(ValueError, match="not both"):
@@ -371,7 +660,7 @@ class TestStreaming:
# Use a tight throttle so the test doesn't sit on `wait_for`.
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(_VALID_ACTIVITY, _Stream()) # type: ignore[arg-type]
await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _Stream()) # type: ignore[arg-type]
assert ch._http is not None
# Placeholder POST + at least one final PUT.
ch._http.post.assert_called() # type: ignore[attr-defined]
@@ -420,7 +709,7 @@ class TestStreaming:
import asyncio as _asyncio
await _asyncio.wait_for(
ch._stream_to_conversation(_VALID_ACTIVITY, _Stream()), # type: ignore[arg-type]
ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _Stream()), # type: ignore[arg-type]
timeout=2.0,
)
# Two POSTs total: placeholder (failed) + fallback final.
@@ -444,9 +733,150 @@ class TestStreaming:
return _FakeAgentResponse(text="")
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(_VALID_ACTIVITY, _EmptyStream()) # type: ignore[arg-type]
await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _EmptyStream()) # type: ignore[arg-type]
# The placeholder PUT-replaces with "(no response)" so the user
# isn't left staring at "…".
assert ch._http is not None
last_put_body = ch._http.put.call_args[1]["json"] # type: ignore[attr-defined]
assert last_put_body["text"] == "(no response)"
async def test_non_edit_channel_buffers_and_posts_single_message(self) -> None:
# Web Chat (and every non-Teams channel) does not support
# PUT /activities/{id}; the channel must buffer the stream and POST
# a single final message rather than the placeholder+edit dance.
ch, _agent = _make_teams(stream=True)
webchat_activity = {**_VALID_ACTIVITY, "channelId": "webchat"}
@dataclass
class _Up:
text: str
class _Stream:
def __aiter__(self) -> Any:
async def gen() -> Any:
yield _Up("hel")
yield _Up("lo")
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="hello")
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(webchat_activity, _VALID_REQUEST, _Stream()) # type: ignore[arg-type]
assert ch._http is not None
# No PUT (no editing); exactly one POST with the full text.
ch._http.put.assert_not_called() # type: ignore[attr-defined]
assert ch._http.post.await_count == 1 # type: ignore[attr-defined]
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "hello"
async def test_non_edit_channel_empty_stream_posts_no_response(self) -> None:
ch, _agent = _make_teams(stream=True)
webchat_activity = {**_VALID_ACTIVITY, "channelId": "directline"}
class _EmptyStream:
def __aiter__(self) -> Any:
async def gen() -> Any:
if False:
yield None # type: ignore[unreachable]
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="")
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(webchat_activity, _VALID_REQUEST, _EmptyStream()) # type: ignore[arg-type]
assert ch._http is not None
ch._http.put.assert_not_called() # type: ignore[attr-defined]
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "(no response)"
async def test_buffer_empty_stream_consults_host_and_can_suppress(self) -> None:
# Empty streamed replies must still consult the host so that
# ``ResponseTarget.none`` (deliver_response -> False) suppresses the
# originating message instead of posting "(no response)".
ch, _agent = _make_teams(stream=True)
webchat_activity = {**_VALID_ACTIVITY, "channelId": "directline"}
ctx = MagicMock()
ctx.deliver_response = AsyncMock(return_value=False)
ch._ctx = ctx
class _EmptyStream:
def __aiter__(self) -> Any:
async def gen() -> Any:
if False:
yield None # type: ignore[unreachable]
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="")
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(webchat_activity, _VALID_REQUEST, _EmptyStream()) # type: ignore[arg-type]
assert ch._http is not None
ctx.deliver_response.assert_awaited_once()
ch._http.post.assert_not_called() # type: ignore[attr-defined]
ch._http.put.assert_not_called() # type: ignore[attr-defined]
async def test_edit_empty_stream_consults_host_and_can_suppress(self) -> None:
# Same contract for the edit-capable (Teams) progressive path.
ch, _agent = _make_teams(stream=True)
ctx = MagicMock()
ctx.deliver_response = AsyncMock(return_value=False)
ch._ctx = ctx
class _EmptyStream:
def __aiter__(self) -> Any:
async def gen() -> Any:
if False:
yield None # type: ignore[unreachable]
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="")
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _EmptyStream()) # type: ignore[arg-type]
ctx.deliver_response.assert_awaited_once()
async def test_edit_405_falls_back_to_single_post(self) -> None:
# Defensive: a channel advertised as edit-capable that nonetheless
# rejects the PUT with 405 must stop editing and POST the final
# text as a fresh message instead of silently leaving "…".
import httpx as _httpx
ch, _agent = _make_teams(stream=True)
assert ch._http is not None
request_405 = _httpx.Request("PUT", "https://smba.trafficmanager.net/amer/v3/x")
response_405 = _httpx.Response(405, request=request_405)
ch._http.put = AsyncMock( # type: ignore[attr-defined]
side_effect=_httpx.HTTPStatusError("405", request=request_405, response=response_405)
)
@dataclass
class _Up:
text: str
class _Stream:
def __aiter__(self) -> Any:
async def gen() -> Any:
yield _Up("hel")
yield _Up("lo")
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="hello")
ch._stream_edit_min_interval = 0.0
await ch._stream_to_conversation(_VALID_ACTIVITY, _VALID_REQUEST, _Stream()) # type: ignore[arg-type]
# Placeholder POST + fallback final POST = 2 POSTs; the final one
# carries the full text.
assert ch._http.post.await_count == 2 # type: ignore[attr-defined]
final_body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert final_body["text"] == "hello"