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
@@ -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="")