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
co-authored by Copilot
parent e8c22caaeb
commit e5a6e35843
33 changed files with 1449 additions and 133 deletions
@@ -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