Python: add agent-framework-hosting-activity-protocol channel (#5641)

* feat(hosting-activity-protocol): rename Bot Framework channel to ActivityProtocolChannel

The existing Bot-Framework-via-Azure-Bot-Service channel was previously
shipped under the name ``hosting-teams`` / ``TeamsChannel``. That name
is misleading for what the channel actually does -- it speaks the Bot
Framework Activity Protocol against Azure Bot Service, which fans out
across MS Teams, Slack, Webex, Telegram-via-Bot-Service, etc., and does
not provide any Teams-specific affordances.

This PR renames the package atomically and frees the ``hosting-teams``
name for a future Teams-native channel built on
``microsoft-teams-apps`` (PR-5b, spec req #28).

Renames (all in one commit):

- Package: ``agent-framework-hosting-teams`` ->
  ``agent-framework-hosting-activity-protocol``
- Module: ``agent_framework_hosting_teams`` ->
  ``agent_framework_hosting_activity_protocol``
- Channel class: ``TeamsChannel`` -> ``ActivityProtocolChannel``
- Helper: ``teams_isolation_key`` -> ``activity_protocol_isolation_key``
  (isolation key prefix ``teams:`` -> ``activity:``)
- Channel name: ``"teams"`` -> ``"activity"``; default mount path
  ``/teams`` -> ``/activity``
- Internal helper: ``_parse_teams_activity`` -> ``_parse_activity``
- Worker task name + a couple of error strings updated for consistency

Updates README.md and the module docstring to call out:

- this is the channel-neutral Activity Protocol channel,
- it surfaces what every Bot-Service-connected channel has in common
  (text in / text out),
- a forthcoming ``agent-framework-hosting-teams`` package will layer
  Teams-specific affordances (adaptive cards, message extensions,
  dialogs, SSO, ...) on the same Bot Service transport.

Workspace: registers ``agent-framework-hosting-activity-protocol`` in
``python/pyproject.toml`` and adds the matching pyright
``executionEnvironments`` entry.

Behavior is unchanged. Pyright + mypy clean, 11 tests pass.

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

* review: address PR-5 round 2 feedback

- security (#3198327004): add `service_url_allowed_hosts` constructor
  option (default `botframework.com` + `smba.trafficmanager.net`) and
  reject inbound activities whose `serviceUrl` host falls outside it
  with HTTP 400 — without this gate a malicious caller could redirect
  outbound replies (and the attached bearer token) to an
  attacker-controlled host
- security (#3198324219): add `inbound_auth_validator` async callback;
  log a loud WARNING at startup when no validator AND no operator
  reverse-proxy is configured so the dev-mode bypass cannot
  accidentally ship to production. Document the contract: prototype
  intentionally does not ship JWT validation (out of scope); operators
  must plug a validator or terminate auth in front of the channel
- retry semantics (#3198328746): distinguish transient outbound
  failures (httpx network errors, non-2xx from Bot Service) — return
  502 so Bot Service retries — from deterministic agent failures —
  return 200 so Bot Service does not retry the same broken activity
  in a loop
- bug (#3198330424): fix the placeholder-failure deadlock. When
  `send_initial_placeholder` fails, `activity_id` stays `None`, the
  edit-worker loop exit condition (`accumulated == last_sent`) is
  unreachable while no PUT is possible, and the worker would deadlock
  on `wake.wait()` forever after `worker_done` is set. Now: skip the
  worker entirely on placeholder failure and POST a single final
  activity at the end with whatever accumulated
- tests (#3198334465, #3187178091, #3198336045): add coverage for
  - `_is_service_url_allowed` allow/deny matrix + webhook 400 on
    disallowed serviceUrl
  - `inbound_auth_validator` allow/deny/raises paths
  - outbound `Authorization: Bearer <token>` header presence in
    production mode and absence in dev mode
  - the streaming path (`_stream_to_conversation`): placeholder +
    final edit, placeholder-failure fallback (with timeout guard
    against deadlock regression), and empty-stream `(no response)`
    placeholder replacement
  - retry-signal differentiation: outbound `httpx.ConnectError` →
    502; deterministic `ValueError` from the agent → 200

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

* test(hosting): drop redundant @pytest.mark.asyncio decorators

asyncio_mode = "auto" is configured in pyproject.toml across the
hosting packages, so individual @pytest.mark.asyncio decorators are
unnecessary.

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

* feat(hosting-activity-protocol): add response hooks

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

* docs(hosting-activity-protocol): mark constructor keyword args

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-05-28 14:37:18 +02:00
committed by GitHub
Unverified
parent f0b9ab6733
commit cdea9fa956
9 changed files with 1379 additions and 0 deletions
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,43 @@
# agent-framework-hosting-activity-protocol
Bot Framework **Activity Protocol** channel for
[agent-framework-hosting](../hosting). Connects to **Azure Bot Service** so
the same agent can be reached from Microsoft Teams, Slack, Webex,
Telegram-via-bot-channel, and any other channel Azure Bot Service
supports — without having to learn each channel's native protocol.
> Looking for a deeper Microsoft Teams integration with adaptive cards,
> message extensions, dialogs, SSO, etc? See the companion
> [`agent-framework-hosting-teams`](../hosting-teams) package, which is
> built on `microsoft-teams-apps` and exposes Teams-specific affordances
> on top of (still) Azure Bot Service.
Handles inbound `message` activities, outbound replies, mid-stream
`updateActivity` edits, typing indicators, and both client-secret and
certificate credential modes for the outbound Bot Framework token.
## Usage
```python
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_activity_protocol import ActivityProtocolChannel
host = AgentFrameworkHost(
target=my_agent,
channels=[
ActivityProtocolChannel(
app_id="<entra app id>",
client_secret="<entra client secret>",
tenant_id="botframework.com", # or your tenant id
)
],
)
host.serve()
```
For tenants that disallow client secrets, supply `certificate_path=` (and
optionally `certificate_password=`) instead. See the docstring at the top of
`_channel.py` for the openssl one-liner that generates a usable PEM.
In dev mode (no credentials), the channel skips outbound auth so the Bot
Framework Emulator can hit the endpoint without setup.
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Bot Framework Activity Protocol channel for :mod:`agent_framework_hosting`."""
from ._channel import ActivityProtocolChannel, activity_protocol_isolation_key
__all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"]
@@ -0,0 +1,734 @@
# Copyright (c) Microsoft. All rights reserved.
r"""Built-in channel: Bot Framework Activity Protocol (Azure Bot Service).
Activity Protocol is the Bot Framework messaging shape used by Azure Bot
Service to fan one bot endpoint out across many surfaces (Microsoft
Teams, Slack, Webex, Telegram, …). An incoming ``Activity`` is POSTed to
your bot's ``/messages`` endpoint, and you reply by POSTing one or more
``Activity`` objects back to the conversation URL the inbound activity
carried in ``serviceUrl``. Auth is an OAuth2 client-credentials token
from Entra (the legacy multi-tenant ``botframework.com`` authority for
public Bot Framework channels, or your own tenant for single-tenant
bots).
This is the channel-neutral Activity-Protocol channel — it surfaces what
every Bot-Service-connected channel has in common (text in, text out).
For deeper Microsoft Teams affordances (adaptive cards, message
extensions, dialogs, SSO, …) on the same Bot Service transport, see the
companion ``agent-framework-hosting-teams`` package.
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),
- 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,
- two credential modes for the outbound token — **client secret** or
**certificate** (for tenants that disallow secrets) — both via
``azure.identity.aio``,
- dev-mode auth bypass when no credentials are passed so the Bot Framework
Emulator can hit the endpoint with no credentials.
Out of scope for the prototype: full JWT validation of inbound requests,
adaptive cards, file uploads, OAuth sign-in flows, and the Teams streaming
preview API (``StreamItem``).
Generating a certificate
------------------------
For tenants that disallow client secrets, register a certificate on your
Bot Framework / Entra app instead. Self-signed PEM (private key + cert in
one file) is what ``azure.identity.CertificateCredential`` expects::
# 1. Generate a 2048-bit RSA key + self-signed cert (10y), single PEM.
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \\
-subj "/CN=my-teams-bot" \\
-keyout teams-bot.key -out teams-bot.crt
cat teams-bot.key teams-bot.crt > teams-bot.pem
# 2. Upload teams-bot.crt to your Entra app under
# "Certificates & secrets""Certificates""Upload certificate".
# 3. Point the channel at the combined PEM:
ActivityProtocolChannel(
app_id="<app id>",
tenant_id="<tenant id>", # or "botframework.com" for legacy bots
certificate_path="teams-bot.pem",
)
To encrypt the private key, drop ``-nodes`` from the openssl command and
pass ``certificate_password=<bytes>`` to the channel.
"""
from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable, Mapping
from typing import Any
from urllib.parse import urlparse
import httpx
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
Content,
Message,
ResponseStream,
)
from agent_framework_hosting import (
ChannelContext,
ChannelContribution,
ChannelRequest,
ChannelResponseContext,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
ChannelStreamTransformHook,
HostedRunResult,
apply_response_hook,
apply_run_hook,
logger,
)
from azure.core.credentials_async import AsyncTokenCredential
from azure.identity.aio import CertificateCredential, ClientSecretCredential
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
# Bot Framework v4 multi-tenant authority used by the public Bot Framework
# channels (including Microsoft Teams). Single-tenant bots should override
# ``tenant_id`` with their own tenant.
_BOTFRAMEWORK_TENANT = "botframework.com"
_BOTFRAMEWORK_SCOPE = "https://api.botframework.com/.default"
# Default allow-list of host suffixes the channel will POST a bearer token
# to. Bot Service surfaces ``serviceUrl`` per-conversation as one of these
# canonical hosts; a malicious inbound activity claiming a serviceUrl
# outside this set could otherwise exfiltrate a real Bot Framework access
# token. Operators with a private deployment (sovereign cloud, Direct Line
# only, etc.) override this via ``service_url_allowed_hosts``.
_DEFAULT_SERVICE_URL_HOSTS = (
"botframework.com",
"smba.trafficmanager.net",
)
InboundAuthValidator = Callable[[Request], Awaitable[bool]]
def activity_protocol_isolation_key(conversation_id: Any) -> str:
"""Build the namespaced isolation key the Teams channel writes under.
Exposed at module scope so other channels' run hooks can opt into the
same per-conversation session (e.g. a Responses caller resuming a Teams
conversation by passing the conversation id).
"""
return f"activity:{conversation_id}"
class _OutboundError(RuntimeError):
"""Marker for transient outbound failures that should produce 502/retry."""
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.
"""
parts: list[Content] = []
if (text := activity.get("text")) and isinstance(text, str):
parts.append(Content.from_text(text=text))
for attachment in activity.get("attachments") or []:
if not isinstance(attachment, Mapping):
continue
url = attachment.get("contentUrl") or attachment.get("content")
content_type = attachment.get("contentType")
if isinstance(url, str) and isinstance(content_type, str) and "/" in content_type:
parts.append(Content.from_uri(uri=url, media_type=content_type))
if not parts:
parts.append(Content.from_text(text=""))
return Message("user", parts)
class ActivityProtocolChannel:
"""Microsoft Teams channel via Bot Framework v4 webhook.
Streaming
---------
When ``stream=True`` (default), the channel sends an initial placeholder
activity, then edits it in place as the agent emits ``AgentResponseUpdate``
chunks (``PUT /v3/conversations/{id}/activities/{id}``). When ``stream=False``
it just sends the final reply. A ``stream_transform_hook`` can rewrite or
drop individual updates before they hit the wire.
"""
name = "activity"
def __init__(
self,
*,
path: str = "/activity",
app_id: str | None = None,
app_password: str | None = None,
certificate_path: str | None = None,
certificate_password: bytes | None = None,
tenant_id: str = _BOTFRAMEWORK_TENANT,
token_scope: str = _BOTFRAMEWORK_SCOPE,
credential: AsyncTokenCredential | None = None,
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
send_typing_action: bool = True,
stream: bool = True,
stream_transform_hook: ChannelStreamTransformHook | None = None,
stream_edit_min_interval: float = 0.7,
inbound_auth_validator: InboundAuthValidator | None = None,
service_url_allowed_hosts: tuple[str, ...] = _DEFAULT_SERVICE_URL_HOSTS,
) -> None:
"""Configure the Teams channel.
Keyword Args:
path: Mount path. The webhook lives at ``{path}/messages``.
app_id: Bot Framework / Entra application (client) id. Required
whenever any credential is supplied.
app_password: Application secret for OAuth2 client credentials.
Mutually exclusive with ``certificate_path``.
certificate_path: Path to a PEM file containing **both** the
private key and the X.509 certificate. Use this for tenants
that disallow client secrets. See the module docstring for an
``openssl`` recipe.
certificate_password: Password for the PEM private key, if any.
tenant_id: Entra tenant. Defaults to ``"botframework.com"`` for
public Bot Framework channels; pass your tenant id for
single-tenant bots.
token_scope: OAuth2 scope to request. Defaults to the Bot
Framework resource.
credential: Bring your own ``AsyncTokenCredential`` (e.g. a
``DefaultAzureCredential`` configured elsewhere). Overrides
``app_password`` / ``certificate_path``.
run_hook: Optional rewrite of ``ChannelRequest`` before invocation.
response_hook: Optional rewrite of the
:class:`HostedRunResult` before the originating Activity
reply is serialized. The host also invokes this hook when
delivering to this channel as a non-originating push
destination.
send_typing_action: Whether to send ``typing`` activities while
the agent runs.
stream: Whether to stream by default. ``run_hook`` can flip per
request.
stream_transform_hook: Optional rewrite of each
``AgentResponseUpdate`` before it hits the wire.
stream_edit_min_interval: Seconds between successive in-place
edits. Teams is more rate-sensitive than Telegram, so default
is higher.
inbound_auth_validator: Optional async callable invoked for each
inbound webhook request **before** the activity is parsed.
Return ``True`` to allow, ``False`` to reject with HTTP 401.
The webhook endpoint accepts unauthenticated requests by
default — Bot Framework normally validates inbound calls via
the JWT in the ``Authorization`` header (see Microsoft's
bot framework auth docs). The prototype intentionally does
NOT ship a built-in JWT validator (key rotation, OpenID
config caching, etc. are out of scope); plug your own
validator here, or terminate auth in front of the channel
(e.g. APIM, Application Gateway). When no credentials AND
no validator are configured the channel logs a loud
warning at startup so the dev-mode bypass cannot
accidentally ship.
service_url_allowed_hosts: Host (or host suffix) allow-list the
channel will POST a bearer token to. Defaults to the public
Bot Framework host suffixes (``botframework.com`` and
``smba.trafficmanager.net``). An inbound activity claiming a
``serviceUrl`` outside this set is rejected — without this
gate a malicious caller could redirect outbound replies (and
the attached bearer token) to an attacker-controlled host.
Pass an extended tuple for sovereign clouds or private
deployments; pass ``()`` to disable the check entirely
(only safe with strong inbound auth).
"""
if app_password and certificate_path:
raise ValueError("ActivityProtocolChannel: pass either app_password or certificate_path, not both.")
self.path = path
self._app_id = app_id
self._token_scope = token_scope
self._tenant_id = tenant_id
self._hook = run_hook
self.response_hook = response_hook
self._send_typing_action = send_typing_action
self._stream_default = stream
self._stream_transform_hook = stream_transform_hook
self._stream_edit_min_interval = stream_edit_min_interval
self._inbound_auth_validator = inbound_auth_validator
self._service_url_allowed_hosts = tuple(h.lower().lstrip(".") for h in service_url_allowed_hosts)
self._ctx: ChannelContext | None = None
self._http: httpx.AsyncClient | None = None
# Build the credential up front so misconfiguration fails at construction.
self._credential: AsyncTokenCredential | None
if credential is not None:
self._credential = credential
elif app_id and certificate_path:
self._credential = CertificateCredential(
tenant_id=tenant_id,
client_id=app_id,
certificate_path=certificate_path,
password=certificate_password,
)
elif app_id and app_password:
self._credential = ClientSecretCredential(
tenant_id=tenant_id,
client_id=app_id,
client_secret=app_password,
)
else:
self._credential = None # dev mode
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Capture the host context and register the ``POST /messages`` webhook."""
self._ctx = context
return ChannelContribution(
routes=[Route("/messages", self._handle, methods=["POST"])],
on_startup=[self._on_startup],
on_shutdown=[self._on_shutdown],
)
# -- lifecycle --------------------------------------------------------- #
async def _on_startup(self) -> None:
"""Open the outbound HTTP client and emit a startup banner.
When no Bot Framework credential is configured we log a loud warning —
outbound replies will not authenticate, which is only acceptable
against the local Bot Framework Emulator.
When no inbound auth validator is configured we also log a loud
warning so the dev-mode bypass cannot accidentally ship to
production: Bot Framework normally validates inbound requests via
a JWT in ``Authorization``; without that gate any caller that can
reach the webhook can drive the bot.
"""
if self._http is None:
self._http = httpx.AsyncClient(timeout=30.0)
if self._credential is None:
logger.warning(
"ActivityProtocolChannel running without credentials — outbound replies "
"will not authenticate. Use only with the Bot Framework "
"Emulator for local development."
)
else:
cred_kind = type(self._credential).__name__
logger.info(
"ActivityProtocolChannel listening on %s/messages (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 — "
"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.",
self.path,
)
async def _on_shutdown(self) -> None:
"""Close the HTTP client and best-effort close the credential.
Credential ``close`` failures are logged but never raised — shutdown
must never be allowed to mask the original cause of an app exit.
"""
if self._http is not None:
await self._http.aclose()
if self._credential is not None:
close = getattr(self._credential, "close", None)
if close is not None:
try:
await close()
except Exception: # pragma: no cover - best-effort
logger.exception("ActivityProtocolChannel credential close failed")
# -- token management -------------------------------------------------- #
async def _get_token(self) -> str | None:
"""Acquire (and cache) an outbound bearer token.
``azure.identity`` credentials cache and refresh internally, so we
just delegate.
"""
if self._credential is None:
return None
access_token = await self._credential.get_token(self._token_scope)
return access_token.token
def _auth_headers(self, token: str | None) -> dict[str, str]:
"""Return Bot Framework auth headers, or an empty dict in dev mode."""
return {"Authorization": f"Bearer {token}"} if token else {}
# -- request handling -------------------------------------------------- #
def _is_service_url_allowed(self, service_url: str | None) -> bool:
"""Return ``True`` if ``service_url`` host matches the allow-list."""
if not self._service_url_allowed_hosts:
return True
if not service_url:
return False
try:
host = (urlparse(service_url).hostname or "").lower()
except Exception:
return False
if not host:
return False
return any(host == allowed or host.endswith(f".{allowed}") for allowed in self._service_url_allowed_hosts)
async def _handle(self, request: Request) -> Response:
"""Bot Framework webhook entry point.
Only ``message`` activities are processed; ``conversationUpdate``,
``invoke``, ``typing`` and other activity types are silently
acknowledged. Auth-rejected requests return 401, malformed JSON
returns 400, and serviceUrl outside the allow-list returns 400.
For *transient* outbound failures (network error / non-2xx from
Bot Service / token acquisition failure) we surface 502 so Bot
Service retries the inbound activity. Non-transient failures
(parsing errors, validation errors, deterministic agent crashes)
return 200 so Bot Service does not retry the same broken
activity in a loop.
"""
if self._inbound_auth_validator is not None:
try:
allowed = await self._inbound_auth_validator(request)
except Exception:
logger.exception("ActivityProtocolChannel inbound_auth_validator raised; rejecting request")
return JSONResponse({"error": "unauthorized"}, status_code=401)
if not allowed:
return JSONResponse({"error": "unauthorized"}, status_code=401)
try:
activity = await request.json()
except Exception:
return JSONResponse({"error": "invalid json"}, status_code=400)
# We accept only message activities for now. ``conversationUpdate``,
# ``invoke``, ``typing`` and friends are silently ack'd.
if activity.get("type") != "message":
return JSONResponse({}, status_code=202)
service_url = activity.get("serviceUrl")
if not self._is_service_url_allowed(service_url if isinstance(service_url, str) else None):
logger.warning(
"ActivityProtocolChannel rejecting activity with serviceUrl=%r (not in allow-list)",
service_url,
)
return JSONResponse({"error": "serviceUrl not allowed"}, status_code=400)
try:
await self._process_activity(activity)
except (httpx.HTTPError, _OutboundError):
# Transient outbound failure (network error, non-2xx from Bot
# Service, token acquisition error). Surface 502 so Bot
# Service retries the inbound activity rather than dropping it.
logger.exception("ActivityProtocolChannel outbound transient failure — signalling Bot Service to retry")
return JSONResponse({"error": "upstream failure"}, status_code=502)
except Exception:
# Deterministic / agent-side failure: 200 so Bot Service does
# not retry the same broken activity in a loop. Operator picks
# the failure up via logs / telemetry.
logger.exception("ActivityProtocolChannel activity processing failed")
# Bot Framework expects 200 OK to dequeue the activity.
return JSONResponse({}, status_code=200)
async def _process_activity(self, activity: Mapping[str, Any]) -> None:
"""Build a :class:`ChannelRequest` from a message Activity and dispatch.
The Teams isolation key is per-conversation so all members of a
group chat share session state. Activity metadata (``reply_to_id``,
``recipient``) is preserved so reply-as-reaction style flows can
reconstruct the original message context.
"""
if self._ctx is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
conversation = activity.get("conversation") or {}
conversation_id = conversation.get("id")
service_url = activity.get("serviceUrl")
if not isinstance(conversation_id, str) or not isinstance(service_url, str):
logger.warning("Teams activity missing conversation.id or serviceUrl — dropping")
return
parsed = _parse_activity(activity)
channel_request = ChannelRequest(
channel=self.name,
operation="message.create",
input=[parsed],
session=ChannelSession(isolation_key=activity_protocol_isolation_key(conversation_id)),
attributes={
"conversation_id": conversation_id,
"service_url": service_url,
"from_id": (activity.get("from") or {}).get("id"),
"channel_id": activity.get("channelId"),
},
metadata={"reply_to_id": activity.get("id"), "recipient": activity.get("recipient")},
stream=self._stream_default,
)
if self._hook is not None:
channel_request = await apply_run_hook(
self._hook,
channel_request,
target=self._ctx.target,
protocol_request=activity,
)
await self._dispatch(activity, channel_request)
# -- outbound helpers -------------------------------------------------- #
async def _dispatch(self, inbound: Mapping[str, Any], request: ChannelRequest) -> None:
"""Run the target and ship the result back into the originating Teams conversation.
Optionally fires a typing indicator before non-streaming runs;
streaming runs route through ``_stream_to_conversation`` which
progressively edits a single placeholder activity.
"""
if self._ctx is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
if self._send_typing_action:
await self._send_typing(inbound)
if not request.stream:
result = await self._ctx.run(request)
include_originating = await self._ctx.deliver_response(request, result)
if include_originating:
result = await self._apply_response_hook(result, request)
text = getattr(result.result, "text", None) or "(no response)"
await self._send_message(inbound, text)
return
stream = self._ctx.run_stream(request)
await self._stream_to_conversation(inbound, stream)
async def _apply_response_hook(
self,
result: HostedRunResult[Any],
request: ChannelRequest,
) -> HostedRunResult[Any]:
"""Apply the channel-level response hook for an originating reply."""
if self.response_hook is None:
return result
context = ChannelResponseContext(
request=request,
channel_name=self.name,
destination_identity=None,
originating=True,
is_echo=False,
)
return await apply_response_hook(self.response_hook, result, context=context)
async def _stream_to_conversation(
self,
inbound: Mapping[str, Any],
stream: ResponseStream[AgentResponseUpdate, AgentResponse],
) -> None:
"""Iterate the stream and progressively edit a single Teams activity.
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.
"""
accumulated = ""
last_sent = ""
last_edit_at = 0.0
activity_id: str | None = None
placeholder_ok = False
worker_done = asyncio.Event()
wake = asyncio.Event()
async def send_initial_placeholder() -> None:
nonlocal activity_id, last_edit_at, placeholder_ok
try:
activity_id = await self._send_message(inbound, "")
last_edit_at = time.monotonic()
placeholder_ok = activity_id is not None
except Exception:
logger.exception(
"Activity placeholder send failed — falling back to single final POST",
)
placeholder_ok = False
async def edit_worker() -> None:
nonlocal last_sent, last_edit_at
# 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
# accumulated text in one shot.
if not placeholder_ok:
return
while not (worker_done.is_set() and accumulated == last_sent):
await wake.wait()
wake.clear()
if accumulated == last_sent:
continue
elapsed = time.monotonic() - last_edit_at
if elapsed < self._stream_edit_min_interval:
try:
await asyncio.wait_for(wake.wait(), timeout=self._stream_edit_min_interval - elapsed)
wake.clear()
except asyncio.TimeoutError:
pass
snapshot = accumulated
if snapshot == last_sent:
continue
try:
await self._update_activity(inbound, activity_id or "", snapshot)
except Exception: # pragma: no cover
logger.exception("Activity interim edit failed")
last_sent = snapshot
last_edit_at = time.monotonic()
await send_initial_placeholder()
edit_task = asyncio.create_task(edit_worker(), name="activity-edit-worker")
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
wake.set()
except Exception:
logger.exception("Activity streaming consumption failed")
finally:
worker_done.set()
wake.set()
try:
await edit_task
except Exception: # pragma: no cover
logger.exception("Activity edit worker crashed")
try:
await stream.get_final_response()
except Exception: # pragma: no cover
logger.exception("Stream finalize failed")
# 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:
text = accumulated or "(no response)"
try:
await self._send_message(inbound, text)
except Exception: # pragma: no cover
logger.exception("Activity fallback final send failed")
elif activity_id is not None and accumulated and accumulated != last_sent:
try:
await self._update_activity(inbound, activity_id, accumulated)
except Exception: # pragma: no cover
logger.exception("Activity final edit failed")
elif not accumulated and activity_id is not None:
# No text streamed — replace the placeholder with a stub so the
# user isn't left staring at "…".
try:
await self._update_activity(inbound, activity_id, "(no response)")
except Exception: # pragma: no cover
logger.exception("Activity placeholder replace failed")
# -- Bot Framework REST helpers --------------------------------------- #
def _activity_payload(self, inbound: Mapping[str, Any], text: str) -> dict[str, Any]:
"""Build the outbound Activity envelope (text-only message)."""
recipient = inbound.get("from") or {}
from_user = inbound.get("recipient") or {}
return {
"type": "message",
"from": from_user,
"recipient": recipient,
"conversation": inbound.get("conversation") or {},
"replyToId": inbound.get("id"),
"channelId": inbound.get("channelId"),
"serviceUrl": inbound.get("serviceUrl"),
"text": text,
"textFormat": "plain",
}
async def _send_message(self, inbound: Mapping[str, Any], text: str) -> str | None:
"""POST a new Activity. Returns the assigned activity id."""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
service_url = str(inbound.get("serviceUrl") or "").rstrip("/")
conversation_id = (inbound.get("conversation") or {}).get("id")
if not service_url or not isinstance(conversation_id, str):
return None
url = f"{service_url}/v3/conversations/{conversation_id}/activities"
token = await self._get_token()
response = await self._http.post(
url, json=self._activity_payload(inbound, text), headers=self._auth_headers(token)
)
response.raise_for_status()
payload = response.json() if response.content else {}
return payload.get("id") if isinstance(payload, dict) else None
async def _update_activity(self, inbound: Mapping[str, Any], activity_id: str, text: str) -> None:
"""PUT-edit an existing Activity (Teams updateActivity)."""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
service_url = str(inbound.get("serviceUrl") or "").rstrip("/")
conversation_id = (inbound.get("conversation") or {}).get("id")
if not service_url or not isinstance(conversation_id, str):
return
url = f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}"
token = await self._get_token()
response = await self._http.put(
url, json=self._activity_payload(inbound, text), headers=self._auth_headers(token)
)
response.raise_for_status()
async def _send_typing(self, inbound: Mapping[str, Any]) -> None:
"""Send a Teams typing indicator; failures are logged and swallowed.
The typing activity is purely a UX nicety — if it fails (token
expired, transient network issue, channel that doesn't support
typing) we never surface that to the user or block the actual
agent run.
"""
if self._http is None: # pragma: no cover - guarded by lifecycle
raise RuntimeError("activity channel not started")
service_url = str(inbound.get("serviceUrl") or "").rstrip("/")
conversation_id = (inbound.get("conversation") or {}).get("id")
if not service_url or not isinstance(conversation_id, str):
return
url = f"{service_url}/v3/conversations/{conversation_id}/activities"
token = await self._get_token()
try:
await self._http.post(
url,
json={
"type": "typing",
"from": inbound.get("recipient") or {},
"recipient": inbound.get("from") or {},
"conversation": inbound.get("conversation") or {},
"serviceUrl": inbound.get("serviceUrl"),
},
headers=self._auth_headers(token),
)
except Exception: # pragma: no cover - non-critical UX
logger.exception("Teams typing send failed")
__all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"]
@@ -0,0 +1,107 @@
[project]
name = "agent-framework-hosting-activity-protocol"
description = "Bot Framework Activity Protocol channel for agent-framework-hosting (Teams, Slack, etc. via Azure Bot Service)."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"agent-framework-hosting==1.0.0a260424",
"httpx>=0.27,<1",
"azure-identity>=1.20,<2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting_activity_protocol"]
exclude = ['tests']
# Bot Framework activities arrive as loosely-typed JSON-ish maps. Strict
# ``Unknown`` reporting on every ``.get(...)`` adds noise without catching
# real bugs — narrowing happens via runtime isinstance checks instead.
reportUnknownArgumentType = "none"
reportUnknownMemberType = "none"
reportUnknownVariableType = "none"
reportUnknownLambdaType = "none"
reportOptionalMemberAccess = "none"
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_hosting_activity_protocol"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_activity_protocol"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_activity_protocol --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,452 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for :mod:`agent_framework_hosting_activity_protocol`.
The Bot Framework outbound calls and azure-identity credentials are mocked
out so the suite never touches the network. Live token acquisition,
streaming edits and certificate paths are out of scope here.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from agent_framework_hosting import AgentFrameworkHost, 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
def test_activity_protocol_isolation_key_format() -> None:
assert activity_protocol_isolation_key("19:meeting_xyz@thread.v2") == "activity:19:meeting_xyz@thread.v2"
assert activity_protocol_isolation_key(123) == "activity:123"
class TestParseActivity:
def test_text_only(self) -> None:
msg = _parse_activity({"type": "message", "text": "hello"})
assert msg.role == "user"
assert msg.text == "hello"
def test_with_attachment(self) -> None:
msg = _parse_activity({
"type": "message",
"text": "see this",
"attachments": [
{"contentType": "image/png", "contentUrl": "https://example.com/x.png"},
],
})
assert msg.text == "see this"
assert any((getattr(c, "uri", None) or "").endswith("/x.png") for c in msg.contents)
def test_skips_invalid_attachments(self) -> None:
msg = _parse_activity({
"type": "message",
"text": "hi",
"attachments": [
"not-a-mapping",
{"contentType": "image/png"}, # no url
{"contentUrl": "https://example.com/y", "contentType": "no-slash"},
],
})
assert msg.text == "hi"
# No URI content survived.
assert not any(getattr(c, "uri", None) for c in msg.contents)
@dataclass
class _FakeAgentResponse:
text: str
class _FakeAgent:
def __init__(self, reply: str = "ok") -> None:
self._reply = reply
self.runs: list[Any] = []
def create_session(self, *, session_id: str | None = None) -> Any:
return {"session_id": session_id}
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.runs.append({"messages": messages, "stream": stream, "kwargs": kwargs})
async def _coro() -> _FakeAgentResponse:
return _FakeAgentResponse(text=self._reply)
return _coro()
def _make_teams(stream: bool = False) -> tuple[ActivityProtocolChannel, _FakeAgent]:
agent = _FakeAgent("hi there")
ch = ActivityProtocolChannel(stream=stream, send_typing_action=False)
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
_VALID_ACTIVITY: dict[str, Any] = {
"type": "message",
"id": "in-1",
"text": "hello bot",
"conversation": {"id": "19:meeting_xyz@thread.v2"},
"from": {"id": "user-1"},
"recipient": {"id": "bot-1"},
"channelId": "msteams",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
}
class TestTeamsWebhook:
def test_message_activity_dispatches_to_agent(self) -> None:
ch, agent = _make_teams()
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
assert agent.runs, "expected the agent to be invoked"
# And the channel posted a reply back to the conversation URL.
assert ch._http is not None
ch._http.post.assert_called() # type: ignore[attr-defined]
url, _ = ch._http.post.call_args[0], ch._http.post.call_args[1] # type: ignore[attr-defined] # noqa: F841
assert "/v3/conversations/" in ch._http.post.call_args[0][0] # type: ignore[attr-defined]
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "hi there"
def test_response_hook_can_rewrite_originating_reply(self) -> None:
contexts: list[Any] = []
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
contexts.append(kwargs["context"])
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
ch, agent = _make_teams()
ch.response_hook = hook
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
assert ch._http is not None
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "HI THERE"
assert contexts
assert contexts[0].channel_name == "activity"
assert contexts[0].originating is True
def test_non_message_activities_are_acked(self) -> None:
ch, agent = _make_teams()
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post(
"/activity/messages",
json={"type": "conversationUpdate", "conversation": {"id": "x"}},
)
assert r.status_code == 202
assert not agent.runs
def test_invalid_json_returns_400(self) -> None:
ch, agent = _make_teams()
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post(
"/activity/messages",
content=b"not-json",
headers={"content-type": "application/json"},
)
assert r.status_code == 400
assert not agent.runs
def test_message_missing_serviceurl_is_dropped(self) -> None:
ch, agent = _make_teams()
host = AgentFrameworkHost(target=agent, channels=[ch])
bad = dict(_VALID_ACTIVITY)
bad.pop("serviceUrl")
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=bad)
# No serviceUrl → fails the allow-list check (None doesn't match
# any allowed host suffix), surfaced as 400 so a misconfigured
# caller knows the activity was structurally invalid.
assert r.status_code == 400
assert not agent.runs
class TestOutbound:
async def test_send_message_posts_to_conversation_url(self) -> None:
ch, _agent = _make_teams()
await ch._send_message(_VALID_ACTIVITY, "hi")
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 "/v3/conversations/" in url
body = ch._http.post.call_args[1]["json"] # type: ignore[attr-defined]
assert body["text"] == "hi"
class TestConfig:
def test_rejects_both_secret_and_certificate(self) -> None:
with pytest.raises(ValueError, match="not both"):
ActivityProtocolChannel(
app_id="x",
app_password="s",
certificate_path="/tmp/does-not-exist.pem",
)
def test_dev_mode_no_credential(self) -> None:
ch = ActivityProtocolChannel()
assert ch._credential is None
class TestServiceUrlAllowList:
"""``serviceUrl`` is supplied by the inbound activity and the channel
POSTs a real bearer token to it — anything outside the Bot Framework
host suffixes must be rejected so a malicious caller can't redirect
outbound replies to an attacker-controlled host."""
def test_default_allows_smba_trafficmanager(self) -> None:
ch = ActivityProtocolChannel()
assert ch._is_service_url_allowed("https://smba.trafficmanager.net/amer/")
assert ch._is_service_url_allowed("https://emea.smba.trafficmanager.net/")
assert ch._is_service_url_allowed("https://api.botframework.com/")
def test_default_rejects_arbitrary_host(self) -> None:
ch = ActivityProtocolChannel()
assert not ch._is_service_url_allowed("https://attacker.example.com/")
assert not ch._is_service_url_allowed("https://botframework.com.attacker.com/")
assert not ch._is_service_url_allowed("")
assert not ch._is_service_url_allowed(None)
def test_custom_allowlist(self) -> None:
ch = ActivityProtocolChannel(service_url_allowed_hosts=("internal.contoso.com",))
assert ch._is_service_url_allowed("https://internal.contoso.com/v3/")
assert ch._is_service_url_allowed("https://eu.internal.contoso.com/")
assert not ch._is_service_url_allowed("https://smba.trafficmanager.net/")
def test_empty_allowlist_disables_check(self) -> None:
ch = ActivityProtocolChannel(service_url_allowed_hosts=())
assert ch._is_service_url_allowed("https://anywhere.example.org/")
def test_webhook_rejects_disallowed_serviceurl(self) -> None:
ch, agent = _make_teams()
host = AgentFrameworkHost(target=agent, channels=[ch])
bad = dict(_VALID_ACTIVITY)
bad["serviceUrl"] = "https://attacker.example.com/v3/"
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=bad)
assert r.status_code == 400
assert not agent.runs
# No outbound POST attempted with a bearer token.
assert ch._http is not None
ch._http.post.assert_not_called() # type: ignore[attr-defined]
class TestInboundAuthValidator:
def test_allow_passes_through(self) -> None:
async def allow(_req: Any) -> bool:
return True
ch, agent = _make_teams()
ch._inbound_auth_validator = allow
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
assert agent.runs
def test_reject_returns_401(self) -> None:
async def deny(_req: Any) -> bool:
return False
ch, agent = _make_teams()
ch._inbound_auth_validator = deny
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 401
assert not agent.runs
def test_validator_raises_returns_401(self) -> None:
async def boom(_req: Any) -> bool:
raise RuntimeError("validator broke")
ch, agent = _make_teams()
ch._inbound_auth_validator = boom
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 401
assert not agent.runs
class TestOutboundAuthHeader:
async def test_no_credential_sends_no_authorization_header(self) -> None:
ch, _agent = _make_teams()
# Default _make_teams has no credential — dev mode.
await ch._send_message(_VALID_ACTIVITY, "hi")
assert ch._http is not None
headers = ch._http.post.call_args[1]["headers"] # type: ignore[attr-defined]
assert "Authorization" not in headers
async def test_with_credential_sends_bearer_token(self) -> None:
ch, _agent = _make_teams()
# Inject a fake credential with a fixed token.
token_obj = MagicMock()
token_obj.token = "tok-abc123"
cred = MagicMock()
cred.get_token = AsyncMock(return_value=token_obj)
ch._credential = cred # type: ignore[assignment]
await ch._send_message(_VALID_ACTIVITY, "hi")
assert ch._http is not None
headers = ch._http.post.call_args[1]["headers"] # type: ignore[attr-defined]
assert headers.get("Authorization") == "Bearer tok-abc123"
class TestRetrySignal:
"""Distinguish transient outbound failures (network / 5xx) — which
must surface 502 so Bot Service retries — from deterministic agent
failures (which must return 200 to avoid retry loops)."""
def test_outbound_http_error_returns_502(self) -> None:
import httpx as _httpx
ch, agent = _make_teams()
# Make _send_message raise a transient httpx error.
assert ch._http is not None
ch._http.post = AsyncMock(side_effect=_httpx.ConnectError("nope")) # type: ignore[attr-defined]
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
assert r.status_code == 502
def test_deterministic_agent_failure_returns_200(self) -> None:
ch, agent = _make_teams()
def boom(messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
async def _coro() -> Any:
raise ValueError("agent crashed")
return _coro()
agent.run = boom # type: ignore[assignment]
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post("/activity/messages", json=_VALID_ACTIVITY)
# Deterministic failure → 200 (Bot Service does not retry the same
# broken activity in a loop).
assert r.status_code == 200
class TestStreaming:
async def test_stream_sends_placeholder_and_edits(self) -> None:
ch, _agent = _make_teams(stream=True)
# Build a fake stream that emits two text chunks then finalizes.
@dataclass
class _Up:
text: str
class _Stream:
def __init__(self) -> None:
self._chunks = ["hel", "lo"]
def __aiter__(self) -> Any:
async def gen() -> Any:
for c in self._chunks:
yield _Up(c)
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="hello")
# 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]
assert ch._http is not None
# Placeholder POST + at least one final PUT.
ch._http.post.assert_called() # type: ignore[attr-defined]
ch._http.put.assert_called() # type: ignore[attr-defined]
# Final edit body carries the full accumulated text.
last_put_body = ch._http.put.call_args[1]["json"] # type: ignore[attr-defined]
assert last_put_body["text"] == "hello"
async def test_stream_placeholder_failure_falls_back_to_single_post(self) -> None:
# The bug: when send_initial_placeholder fails, activity_id stays
# None, the edit_worker can never reach its exit condition
# (`accumulated == last_sent` while no PUT possible) and the
# whole conversation deadlocks. After the fix we fall back to
# buffering the stream and POSTing a single final activity.
ch, _agent = _make_teams(stream=True)
# Make the FIRST POST (placeholder) raise; subsequent POST (final
# fallback) succeeds.
import httpx as _httpx
ok_response = MagicMock()
ok_response.raise_for_status = MagicMock()
ok_response.json = MagicMock(return_value={"id": "act-final"})
ok_response.content = b"{}"
post_mock = AsyncMock(side_effect=[_httpx.HTTPError("boom"), ok_response])
assert ch._http is not None
ch._http.post = post_mock # type: ignore[attr-defined]
@dataclass
class _Up:
text: str
class _Stream:
def __aiter__(self) -> Any:
async def gen() -> Any:
yield _Up("partial-1")
yield _Up("-partial-2")
return gen()
async def get_final_response(self) -> Any:
return _FakeAgentResponse(text="partial-1-partial-2")
ch._stream_edit_min_interval = 0.0
# Should NOT hang. Use asyncio.wait_for with a small timeout to
# guard the test against future regressions of the deadlock.
import asyncio as _asyncio
await _asyncio.wait_for(
ch._stream_to_conversation(_VALID_ACTIVITY, _Stream()), # type: ignore[arg-type]
timeout=2.0,
)
# Two POSTs total: placeholder (failed) + fallback final.
assert post_mock.await_count == 2
# Fallback POST contains the full accumulated text.
fallback_body = post_mock.call_args[1]["json"]
assert fallback_body["text"] == "partial-1-partial-2"
async def test_stream_with_no_text_replaces_placeholder(self) -> None:
ch, _agent = _make_teams(stream=True)
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, _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)"
+2
View File
@@ -88,6 +88,7 @@ agent-framework-github-copilot = { workspace = true }
agent-framework-hosting = { workspace = true }
agent-framework-hosting-invocations = { workspace = true }
agent-framework-hosting-telegram = { workspace = true }
agent-framework-hosting-activity-protocol = { workspace = true }
agent-framework-hyperlight = { workspace = true }
agent-framework-lab = { workspace = true }
agent-framework-mem0 = { workspace = true }
@@ -213,6 +214,7 @@ executionEnvironments = [
{ root = "packages/hosting/tests", reportPrivateUsage = "none" },
{ root = "packages/hosting-invocations/tests", reportPrivateUsage = "none" },
{ root = "packages/hosting-telegram/tests", reportPrivateUsage = "none" },
{ root = "packages/hosting-activity-protocol/tests", reportPrivateUsage = "none" },
{ root = "packages/lab/gaia/tests", reportPrivateUsage = "none" },
{ root = "packages/lab/lightning/tests", reportPrivateUsage = "none" },
{ root = "packages/lab/tau2/tests", reportPrivateUsage = "none" },
+13
View File
@@ -51,6 +51,7 @@ members = [
"agent-framework-hosting-responses",
"agent-framework-hosting-invocations",
"agent-framework-hosting-telegram",
"agent-framework-hosting-activity-protocol",
"agent-framework-hyperlight",
"agent-framework-lab",
"agent-framework-mem0",
@@ -671,6 +672,17 @@ requires-dist = [
{ name = "openai", specifier = ">=1.99.0,<3" },
]
[[package]]
name = "agent-framework-hosting-activity-protocol"
version = "1.0.0a260424"
source = { editable = "packages/hosting-activity-protocol" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[[package]]
name = "agent-framework-hosting-invocations"
version = "1.0.0a260424"
@@ -678,6 +690,7 @@ source = { editable = "packages/hosting-invocations" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "azure-identity", specifier = ">=1.20,<2" },
{ name = "httpx", specifier = ">=0.27,<1" },
]