mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
e8c22caaeb
commit
e5a6e35843
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user