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"
@@ -92,7 +92,7 @@ class DiscordChannel:
public_key: str,
bot_token: str | None = None,
guild_id: str | None = None,
path: str = "/discord",
path: str = "/discord/interactions",
agent_command: str = "ask",
agent_command_description: str = "Ask the agent",
agent_command_option: str = "prompt",
@@ -120,8 +120,8 @@ class DiscordChannel:
guild_id: Optional guild id for guild-scoped slash command
registration. Recommended for development because global
command registration can take a long time to propagate.
path: Host mount path. The interaction route is contributed as
``/interactions`` below this path.
path: Interaction endpoint path on the host. Use ``""`` to expose
the interaction route at the app root.
agent_command: Slash command name that invokes the hosted agent.
agent_command_description: Description for the agent slash command.
agent_command_option: String option name that carries the prompt.
@@ -159,7 +159,7 @@ class DiscordChannel:
self.agent_command_description = agent_command_description
self.agent_command_option = agent_command_option
self.register_commands = register_commands
self._commands: set[ChannelCommand] = set(commands) or {} # type: ignore
self._commands = tuple(commands or ())
self._command_by_name = {command.name: command for command in self._commands}
self._run_hook = run_hook
self.response_hook = response_hook
@@ -184,7 +184,7 @@ class DiscordChannel:
"""Register the Discord interaction route and lifecycle hooks."""
self._ctx = context
return ChannelContribution(
routes=[Route("/interactions", self._handle, methods=["POST"])],
routes=[Route("/", self._handle, methods=["POST"])],
commands=self._commands,
on_startup=[self._on_startup],
on_shutdown=[self._on_shutdown],
@@ -126,9 +126,9 @@ def test_ping_requires_valid_signature_and_returns_pong() -> None:
body = json.dumps({"type": 1}).encode("utf-8")
with TestClient(app) as client:
ok = client.post("/interactions", content=body, headers=_headers(signing_key, body))
ok = client.post("/", content=body, headers=_headers(signing_key, body))
bad = client.post(
"/interactions",
"/",
content=body,
headers={
**_headers(signing_key, body),
@@ -159,11 +159,11 @@ def test_request_validation_errors() -> None:
unsupported_app = Starlette(routes=list(unsupported_channel.contribute(_FakeContext()).routes)) # type: ignore[arg-type]
with TestClient(app) as client:
too_large = client.post("/interactions", content=b"{}x")
invalid_json = client.post("/interactions", content=b"{")
too_large = client.post("/", content=b"{}x")
invalid_json = client.post("/", content=b"{")
with TestClient(unsupported_app) as client:
non_object = client.post("/interactions", json=[])
unsupported = client.post("/interactions", json={"type": 99})
non_object = client.post("/", json=[])
unsupported = client.post("/", json={"type": 99})
assert too_large.status_code == 413
assert invalid_json.status_code == 400
@@ -1,13 +1,13 @@
# agent-framework-hosting-invocations
Minimal `POST /invoke` channel for [agent-framework-hosting](../hosting). Useful
Minimal `POST /invocations` channel for [agent-framework-hosting](../hosting). Useful
for smoke-testing, durable-task drivers, and bespoke clients that don't speak
the OpenAI Responses protocol.
## Wire shape
```
POST /invocations/invoke
POST /invocations
{
"message": "hello",
"session_id": "user-42",
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
"""Minimal ``POST /invoke`` channel for :mod:`agent_framework_hosting`."""
"""Minimal ``POST /invocations`` channel for :mod:`agent_framework_hosting`."""
from ._channel import InvocationsChannel
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
"""Minimal ``POST /invoke`` channel.
"""Minimal ``POST /invocations`` channel.
Inspired by ``agent-framework-foundry-hosting``'s ``InvocationsHostServer``.
A framework-agnostic surface for callers that just want to send a message and
@@ -32,7 +32,7 @@ from starlette.routing import Route
class InvocationsChannel:
"""Minimal ``POST /invoke`` surface.
"""Minimal ``POST /invocations`` surface.
A run hook can rewrite the channel request (e.g. inject a session, add
options) before the host invokes the agent. A stream-transform hook can
@@ -51,8 +51,8 @@ class InvocationsChannel:
) -> None:
"""Configure the invocations endpoint.
``path`` is the mount root the host prefixes when registering this
channel's routes (the actual handler is ``POST {path}/invoke``).
``path`` is the endpoint path the host uses when registering this
channel. Use ``""`` to expose the handler at the app root.
``run_hook`` may rewrite the :class:`ChannelRequest` before the host
invokes the target — typically to attach session metadata or
translate the wire payload into ``Message`` instances.
@@ -68,12 +68,12 @@ class InvocationsChannel:
self._ctx: ChannelContext | None = None
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Capture the host-supplied context and register ``POST /invoke``."""
"""Capture the host-supplied context and register the endpoint route."""
self._ctx = context
return ChannelContribution(routes=[Route("/invoke", self._handle, methods=["POST"])])
return ChannelContribution(routes=[Route("/", self._handle, methods=["POST"])])
async def _handle(self, request: Request) -> Response:
"""Handle a single ``POST /invoke`` call.
"""Handle a single Invocations call.
Validates the JSON body shape, builds a :class:`ChannelRequest`
(optionally with a ``ChannelSession`` keyed by ``session_id``),
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-hosting-invocations"
description = "Minimal POST /invoke channel for agent-framework-hosting."
description = "Minimal POST /invocations channel for agent-framework-hosting."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
@@ -60,9 +60,9 @@ class _FakeAgent:
return _coro()
def _make_client(agent: _FakeAgent | None = None) -> tuple[TestClient, _FakeAgent]:
def _make_client(agent: _FakeAgent | None = None, *, path: str = "/invocations") -> tuple[TestClient, _FakeAgent]:
agent = agent or _FakeAgent()
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel()])
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel(path=path)])
return TestClient(host.app), agent
@@ -70,14 +70,21 @@ class TestInvocations:
def test_post_invoke_returns_response(self) -> None:
client, _agent = _make_client(_FakeAgent(reply="pong"))
with client:
r = client.post("/invocations/invoke", json={"message": "ping"})
r = client.post("/invocations", json={"message": "ping"})
assert r.status_code == 200
assert r.json() == {"response": "pong", "session_id": None}
def test_empty_path_mounts_at_app_root(self) -> None:
client, _agent = _make_client(_FakeAgent(reply="pong"), path="")
with client:
r = client.post("/", json={"message": "ping"})
assert r.status_code == 200
assert r.json() == {"response": "pong", "session_id": None}
def test_session_id_propagates_to_target(self) -> None:
client, agent = _make_client()
with client:
r = client.post("/invocations/invoke", json={"message": "x", "session_id": "s1"})
r = client.post("/invocations", json={"message": "x", "session_id": "s1"})
assert r.status_code == 200
assert r.json()["session_id"] == "s1"
sess = agent.calls[0]["kwargs"].get("session")
@@ -90,7 +97,7 @@ class TestInvocations:
client, _ = _make_client()
with client:
r = client.post(
"/invocations/invoke",
"/invocations",
content=b"{not json",
headers={"content-type": "application/json"},
)
@@ -99,26 +106,26 @@ class TestInvocations:
def test_empty_message_returns_422(self) -> None:
client, _ = _make_client()
with client:
r = client.post("/invocations/invoke", json={"message": ""})
r = client.post("/invocations", json={"message": ""})
assert r.status_code == 422
def test_non_string_session_id_returns_422(self) -> None:
client, _ = _make_client()
with client:
r = client.post("/invocations/invoke", json={"message": "x", "session_id": 1})
r = client.post("/invocations", json={"message": "x", "session_id": 1})
assert r.status_code == 422
def test_non_object_body_returns_422(self) -> None:
client, _ = _make_client()
with client:
r = client.post("/invocations/invoke", json=[])
r = client.post("/invocations", json=[])
assert r.status_code == 422
def test_streaming_emits_data_lines_and_done(self) -> None:
agent = _FakeAgent(chunks=["hel", "lo"])
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel()])
with TestClient(host.app) as client:
r = client.post("/invocations/invoke", json={"message": "x", "stream": True})
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
body = r.text
assert "data: hel" in body
@@ -136,7 +143,7 @@ class TestInvocations:
agent = _FakeAgent(reply="ok")
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel(run_hook=hook)])
with TestClient(host.app) as client:
r = client.post("/invocations/invoke", json={"message": "x", "stream": True})
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
# Even though caller asked for stream=True, hook flipped it off — so
# we get JSON back, not SSE.
@@ -154,7 +161,7 @@ class TestInvocations:
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel(response_hook=hook)])
with TestClient(host.app) as client:
r = client.post("/invocations/invoke", json={"message": "ping"})
r = client.post("/invocations", json={"message": "ping"})
assert r.status_code == 200
assert r.json() == {"response": "hooked:pong", "session_id": None}
@@ -174,7 +181,7 @@ class TestInvocations:
channels=[InvocationsChannel(stream_transform_hook=transform)],
)
with TestClient(host.app) as client:
r = client.post("/invocations/invoke", json={"message": "x", "stream": True})
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
body = r.text
assert "data: FOO" in body
@@ -192,7 +199,7 @@ class TestInvocations:
channels=[InvocationsChannel(stream_transform_hook=transform)],
)
with TestClient(host.app) as client:
r = client.post("/invocations/invoke", json={"message": "x", "stream": True})
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
body = r.text
assert "data: keep" in body
@@ -210,7 +217,7 @@ class TestInvocations:
channels=[InvocationsChannel(stream_transform_hook=transform)],
)
with TestClient(host.app) as client:
r = client.post("/invocations/invoke", json={"message": "x", "stream": True})
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
assert "data: aa!" in r.text
@@ -221,7 +228,7 @@ class TestInvocations:
agent = _FakeAgent(chunks=["line1\r\nline2"])
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel()])
with TestClient(host.app) as client:
r = client.post("/invocations/invoke", json={"message": "x", "stream": True})
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
body = r.text
assert "data: line1\n" in body
@@ -247,7 +254,7 @@ class TestInvocations:
agent = _AgentWithFailingFinal()
host = AgentFrameworkHost(target=agent, channels=[InvocationsChannel()])
with TestClient(host.app) as client:
r = client.post("/invocations/invoke", json={"message": "x", "stream": True})
r = client.post("/invocations", json={"message": "x", "stream": True})
assert r.status_code == 200
body = r.text
assert "data: partial" in body
@@ -77,7 +77,7 @@ class ResponsesChannel:
Mounts ``POST <path>/responses`` (default path ``/responses`` so the
full route is ``/responses/responses`` when the channel is prefixed,
or just ``/responses`` when ``path=""``).
or just ``/`` when ``path=""``).
"""
name = "responses"
@@ -85,7 +85,7 @@ class ResponsesChannel:
def __init__(
self,
*,
path: str = "",
path: str = "/responses",
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
stream_transform_hook: ChannelStreamTransformHook | None = None,
@@ -94,9 +94,9 @@ class ResponsesChannel:
"""Create a Responses channel.
Keyword Args:
path: Mount prefix on the host. Default ``""`` mounts the
``POST /responses`` route at the app root, matching the
upstream OpenAI surface.
path: Endpoint path on the host. Default ``"/responses"`` matches
the upstream OpenAI surface; use ``""`` to expose this channel
at the app root.
run_hook: Optional :data:`ChannelRunHook` invoked with the
parsed :class:`ChannelRequest` before the agent target
runs. May return a replacement request.
@@ -145,12 +145,12 @@ class ResponsesChannel:
)
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Capture the host-supplied context and register ``POST /responses``."""
"""Capture the host-supplied context and register the endpoint route."""
self._ctx = context
return ChannelContribution(routes=[Route("/responses", self._handle, methods=["POST"])])
return ChannelContribution(routes=[Route("/", self._handle, methods=["POST"])])
async def _handle(self, request: Request) -> Response:
"""Handle a single ``POST /responses`` call.
"""Handle a single Responses API call.
Parses the OpenAI Responses-shaped body into ``Message`` /
``options`` / ``ChannelSession`` triples via :mod:`._parsing`,
@@ -91,9 +91,13 @@ class _RecordingPushChannel:
# --------------------------------------------------------------------------- #
def _make_client(agent: _FakeAgent | None = None) -> tuple[TestClient, AgentFrameworkHost, _FakeAgent]:
def _make_client(
agent: _FakeAgent | None = None,
*,
path: str = "/responses",
) -> tuple[TestClient, AgentFrameworkHost, _FakeAgent]:
agent = agent or _FakeAgent()
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(path=path)])
return TestClient(host.app), host, agent
@@ -110,6 +114,13 @@ class TestResponsesChannelNonStreaming:
assert body["output"][0]["content"][0]["text"] == "hi back"
assert len(agent.calls) == 1
def test_empty_path_mounts_at_app_root(self) -> None:
client, _host, _agent = _make_client(_FakeAgent(reply="hi back"), path="")
with client:
r = client.post("/", json={"input": "hi"})
assert r.status_code == 200
assert r.json()["output"][0]["content"][0]["text"] == "hi back"
def test_invalid_json_returns_400(self) -> None:
client, *_ = _make_client()
with client:
@@ -88,6 +88,21 @@ def _text_result(text: str) -> HostedRunResult[AgentResponse]:
return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]))
def _is_echo_payload(payload: HostedRunResult[AgentResponse]) -> bool:
"""Return ``True`` when a push payload is an echoed user turn.
Per the :class:`~agent_framework_hosting.ChannelPush` contract the host
mirrors the originating user's input as a one-or-more message
:class:`~agent_framework.AgentResponse` with every ``role == "user"``,
delivered *before* the agent's (``role == "assistant"``) reply. Treating a
payload whose messages are all user-role as an echo lets the channel pick
echo-only delivery options (e.g. silent notifications) without the host
having to thread an explicit ``is_echo`` flag through ``push``.
"""
messages = getattr(payload.result, "messages", None) or []
return bool(messages) and all(getattr(m, "role", None) == "user" for m in messages)
def _telegram_media_file_id(message: Mapping[str, Any]) -> tuple[str, str] | None:
"""Return ``(file_id, fallback_media_type)`` for any media on the message."""
photo = message.get("photo")
@@ -151,7 +166,7 @@ class TelegramChannel:
self,
*,
bot_token: str,
path: str = "/telegram",
path: str = "/telegram/webhook",
commands: Sequence[ChannelCommand] = (),
register_native_commands: bool = True,
run_hook: ChannelRunHook | None = None,
@@ -159,6 +174,7 @@ class TelegramChannel:
api_base: str = "https://api.telegram.org",
webhook_url: str | None = None,
secret_token: str | None = None,
delete_webhook_on_shutdown: bool = False,
parse_mode: str | None = None,
send_typing_action: bool = True,
transport: Literal["auto", "polling", "webhook"] = "auto",
@@ -179,6 +195,7 @@ class TelegramChannel:
self._api = f"{api_base}/bot{bot_token}"
self._webhook_url = webhook_url
self._secret_token = secret_token
self._delete_webhook_on_shutdown = delete_webhook_on_shutdown
self._parse_mode = parse_mode
self._send_typing_action = send_typing_action
if transport == "auto":
@@ -210,7 +227,7 @@ class TelegramChannel:
self._ctx = context
routes: list[BaseRoute] = []
if self._transport == "webhook":
routes.append(Route("/webhook", self._handle, methods=["POST"]))
routes.append(Route("/", self._handle, methods=["POST"]))
return ChannelContribution(
routes=routes,
commands=self._commands,
@@ -258,7 +275,7 @@ class TelegramChannel:
logger.info("Telegram polling started (long-poll timeout=%ss)", self._polling_timeout)
async def _on_shutdown(self) -> None:
"""Stop the polling task, drain in-flight workers, drop the webhook, close HTTP.
"""Stop the polling task, drain in-flight workers, close HTTP.
Drain order:
1. Cancel the poll task so no new updates are admitted.
@@ -269,10 +286,17 @@ class TelegramChannel:
``_update_tasks`` (the webhook handler returns 200 immediately
and runs the agent in a background task, which the previous
shutdown ignored entirely).
4. Best-effort `deleteWebhook` and HTTP client close.
4. Close the HTTP client.
Webhook teardown is best-effort — failures (e.g. revoked token at
shutdown) are logged but never raised so app shutdown can complete.
The webhook registration is intentionally **left in place** on
shutdown. A Telegram webhook is a single global resource, so
deleting it here races rolling redeploys: the new revision calls
``setWebhook`` on startup, then the old revision's shutdown would
delete it, silently breaking inbound delivery until the next boot.
``setWebhook`` is overwriting/idempotent, so the next startup
re-asserts it anyway. Set ``delete_webhook_on_shutdown=True`` to opt
into best-effort teardown (e.g. for a one-off/ephemeral deployment);
failures are logged but never raised so app shutdown can complete.
"""
if self._poll_task is not None:
self._poll_task.cancel()
@@ -296,7 +320,7 @@ class TelegramChannel:
await task
self._update_tasks.clear()
if self._http is not None:
if self._transport == "webhook":
if self._transport == "webhook" and self._delete_webhook_on_shutdown:
try:
await self._http.post(f"{self._api}/deleteWebhook")
except Exception: # pragma: no cover - best-effort cleanup
@@ -845,7 +869,17 @@ class TelegramChannel:
raise ValueError(f"Telegram push requires an int chat_id, got {identity.native_id!r}") from exc
if self._http is None:
raise RuntimeError("TelegramChannel.push called before startup")
await self._send(chat_id, payload.result.text)
# The Bot API can only ever send AS the bot, so there is no way to
# impersonate the user for an echo (the MTProto ``send_as`` field is
# not exposed to bots). The next best UX is to deliver echoes
# *silently* (``disable_notification``) so a mirrored input doesn't
# buzz the user's device the way a genuine reply does. Echo phases are
# identified per the ChannelPush contract: a payload whose messages are
# all ``role == "user"`` is the originating turn mirrored here.
extra: dict[str, Any] = {}
if _is_echo_payload(payload):
extra["disable_notification"] = True
await self._send(chat_id, payload.result.text, **extra)
async def _send_photo(self, chat_id: int, photo_url: str, caption: str | None = None) -> None:
"""POST a ``sendPhoto`` to Telegram with an optional caption."""
@@ -123,10 +123,13 @@ def _run_result(text: str) -> HostedRunResult[AgentResponse]:
return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]))
def _make_telegram(stream_default: bool = False) -> tuple[TelegramChannel, _FakeAgent]:
def _make_telegram(
stream_default: bool = False, *, path: str = "/telegram/webhook"
) -> tuple[TelegramChannel, _FakeAgent]:
agent = _FakeAgent("hi")
ch = TelegramChannel(
bot_token="123:abc",
path=path,
webhook_url="https://example.com/hook",
secret_token="s3cr3t",
stream=stream_default,
@@ -158,6 +161,18 @@ class TestTelegramWebhook:
assert r.status_code == 200
assert agent.runs, "expected the agent to be invoked"
def test_empty_path_mounts_at_app_root(self) -> None:
ch, agent = _make_telegram(path="")
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app) as client:
r = client.post(
"/",
json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hello"}},
headers={"x-telegram-bot-api-secret-token": "s3cr3t"},
)
assert r.status_code == 200
assert agent.runs, "expected the agent to be invoked"
def test_webhook_rejects_bad_secret(self) -> None:
ch, agent = _make_telegram()
host = AgentFrameworkHost(target=agent, channels=[ch])
@@ -216,6 +231,23 @@ class TestPushAndCommand:
assert args[0].endswith("/sendMessage")
assert kwargs["json"]["chat_id"] in ("42", 42)
assert kwargs["json"]["text"] == "hi"
# Agent replies must stay loud: no silent flag on a non-echo push.
assert "disable_notification" not in kwargs["json"]
async def test_push_echo_is_silent(self) -> None:
ch, _agent = _make_telegram()
from agent_framework_hosting import ChannelIdentity
echo = HostedRunResult(
AgentResponse(messages=[Message(role="user", contents=[Content.from_text(text="said via X")])])
)
await ch.push(ChannelIdentity(channel="telegram", native_id="42"), echo)
assert ch._http is not None
_args, kwargs = ch._http.post.call_args # type: ignore[attr-defined]
# Bots cannot impersonate the user (no MTProto send_as), so the echo is
# delivered silently instead of buzzing the device like a real reply.
assert kwargs["json"]["disable_notification"] is True
assert kwargs["json"]["text"] == "said via X"
async def test_command_handler_invoked(self) -> None:
captured: list[ChannelCommandContext] = []
@@ -387,3 +419,39 @@ class TestShutdownDrainsWorkers:
await ch._on_shutdown()
assert not ch._chat_workers
assert not ch._update_tasks
def _deletewebhook_called(http_mock: MagicMock) -> bool:
return any(
call.args and str(call.args[0]).endswith("/deleteWebhook") for call in http_mock.post.call_args_list
)
class TestWebhookShutdownTeardown:
async def test_shutdown_keeps_webhook_by_default(self) -> None:
"""Default: shutdown must NOT delete the webhook (avoids redeploy races)."""
ch, _ = _make_telegram()
assert ch._transport == "webhook"
await ch._on_shutdown()
assert not _deletewebhook_called(ch._http) # type: ignore[arg-type]
ch._http.aclose.assert_awaited() # type: ignore[union-attr]
async def test_shutdown_deletes_webhook_when_opted_in(self) -> None:
"""Opt-in: ``delete_webhook_on_shutdown=True`` performs best-effort teardown."""
ch = TelegramChannel(
bot_token="123:abc",
webhook_url="https://example.com/hook",
secret_token="s3cr3t",
delete_webhook_on_shutdown=True,
stream=False,
)
fake_http = MagicMock()
response_mock = MagicMock()
response_mock.json = MagicMock(return_value={"ok": True, "result": {}})
fake_http.post = AsyncMock(return_value=response_mock)
fake_http.get = AsyncMock(return_value=response_mock)
fake_http.aclose = AsyncMock()
ch._http = fake_http
await ch._on_shutdown()
assert _deletewebhook_called(fake_http)
fake_http.aclose.assert_awaited()
@@ -48,7 +48,7 @@ from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import PlainTextResponse
from starlette.routing import BaseRoute, Mount, Route
from starlette.routing import BaseRoute, Mount, Route, WebSocketRoute
from starlette.types import ASGIApp, Receive, Scope, Send
from ._authorization import (
@@ -110,6 +110,21 @@ _EPHEMERAL_RUNTIME_MARKERS: tuple[str, ...] = (
RuntimeMode = Literal["long_running", "ephemeral"]
def _exact_path_route(path: str, route: BaseRoute) -> BaseRoute | None:
"""Clone a root route so ``Mount('/x', Route('/'))`` also handles ``/x`` without a redirect."""
if isinstance(route, Route) and route.path == "/":
return Route(
path,
route.endpoint,
methods=route.methods,
name=route.name,
include_in_schema=route.include_in_schema,
)
if isinstance(route, WebSocketRoute) and route.path == "/":
return WebSocketRoute(path, route.endpoint, name=route.name)
return None
def _detect_runtime_mode(env: Mapping[str, str] | None = None) -> tuple[RuntimeMode, str | None]:
"""Inspect deployment markers and return ``(mode, matched_marker_or_None)``.
@@ -257,7 +272,7 @@ def _workflow_event_to_update(event: WorkflowEvent[Any]) -> AgentResponseUpdate
@asynccontextmanager
async def _suppress_already_consumed() -> AsyncIterator[None]: # noqa: RUF029
async def _suppress_already_consumed() -> AsyncIterator[None]:
"""Yield, swallowing finalizer failures so consumer cleanup never crashes the host.
The bridge stream calls ``get_final_response()`` after iterating the
@@ -1233,7 +1248,7 @@ class AgentFrameworkHost:
Mirrors the ``AgentServerHost`` convention from
``azure.ai.agentserver.core``: one INFO line that captures the
target type, every channel + its mount path, the bind address
target type, every channel + its endpoint path, the bind address
(when known), whether we're running inside a Foundry Hosted
Agents container, and the worker count. Keeps log noise low
while still giving an operator a single grep-able anchor when
@@ -1290,11 +1305,19 @@ class AgentFrameworkHost:
for channel in self.channels:
contribution = channel.contribute(context)
# Channels publish routes relative to their root; mount under channel.path.
# An empty path means "mount at the app root" — useful for single-channel hosts
# that don't want a prefix (e.g. ResponsesChannel exposing POST /responses directly).
# An empty path means "mount at the app root" — useful when an external
# platform requires the channel endpoint at "/" or at a route contributed
# by the channel.
if contribution.routes:
if channel.path:
routes.append(Mount(channel.path, routes=list(contribution.routes)))
channel_routes = list(contribution.routes)
exact_routes = [
exact_route
for route in channel_routes
if (exact_route := _exact_path_route(channel.path, route)) is not None
]
routes.extend(exact_routes)
routes.append(Mount(channel.path, routes=channel_routes))
else:
routes.extend(contribution.routes)
on_startup.extend(contribution.on_startup)
@@ -710,7 +710,7 @@ class Channel(Protocol):
"""
name: str
path: str # default mount path (e.g. "/responses"); use "" to mount routes at the app root
path: str # default endpoint path (e.g. "/responses"); use "" to mount contributed routes at the app root
def contribute(self, context: ChannelContext) -> ChannelContribution: ...
+13 -1
View File
@@ -96,7 +96,7 @@ class _RecordingChannel:
self.pushes: list[tuple[ChannelIdentity, HostedRunResult[Any]]] = []
self._push_raises: Exception | None = None
self._supports_push = supports_push
# Provide a single trivial route so contribute() exercises the mount path.
# Provide a single trivial route so contribute() exercises the endpoint path.
self._routes: Sequence[BaseRoute] = (Route("/ping", _ping),)
def contribute(self, context: ChannelContext) -> ChannelContribution:
@@ -239,6 +239,18 @@ class TestHostWiring:
assert r.status_code == 200
assert r.json() == {"ok": True}
def test_app_mounts_root_route_at_exact_channel_path(self) -> None:
agent = _FakeAgent()
ch = _RecordingChannel(path="/fake")
ch._routes = (Route("/", _ping),)
host = AgentFrameworkHost(target=agent, channels=[ch])
with TestClient(host.app, follow_redirects=False) as client:
r = client.get("/fake")
assert r.status_code == 200
assert r.json() == {"ok": True}
assert client.get("/fake/").status_code == 200
def test_app_mounts_at_root_when_path_is_empty(self) -> None:
agent = _FakeAgent()
ch = _RecordingChannel(path="")