1556 lines
62 KiB
Python
1556 lines
62 KiB
Python
"""Tests for the WeCom platform adapter."""
|
||
|
||
import asyncio
|
||
import base64
|
||
import os
|
||
import socket
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from gateway.config import PlatformConfig
|
||
from gateway.platforms.base import SendResult
|
||
|
||
|
||
class TestWeComRequirements:
|
||
def test_returns_false_without_aiohttp(self, monkeypatch):
|
||
monkeypatch.setattr("plugins.platforms.wecom.adapter.AIOHTTP_AVAILABLE", False)
|
||
monkeypatch.setattr("plugins.platforms.wecom.adapter.HTTPX_AVAILABLE", True)
|
||
from plugins.platforms.wecom.adapter import check_wecom_requirements
|
||
|
||
assert check_wecom_requirements() is False
|
||
|
||
|
||
class TestWeComAdapterInit:
|
||
def test_declares_non_editable_message_capability(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
assert WeComAdapter.SUPPORTS_MESSAGE_EDITING is False
|
||
|
||
|
||
class TestWeComAdapterAuthzScope:
|
||
"""dm_policy/allowlist reads must honor the profile secret scope under
|
||
multiplexing (#93522): a secondary profile's own scope is authoritative
|
||
and must not inherit the default profile's process-env authorization."""
|
||
|
||
@pytest.fixture()
|
||
def multiplex_on(self):
|
||
from agent import secret_scope
|
||
|
||
previous = secret_scope.is_multiplex_active()
|
||
secret_scope.set_multiplex_active(True)
|
||
try:
|
||
yield
|
||
finally:
|
||
secret_scope.set_multiplex_active(previous)
|
||
|
||
def test_scoped_construction_reads_authz_from_scope_not_environ(self, multiplex_on, monkeypatch):
|
||
from agent import secret_scope
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
monkeypatch.setenv("WECOM_DM_POLICY", "pairing")
|
||
monkeypatch.setenv("WECOM_ALLOWED_USERS", "default-user")
|
||
token = secret_scope.set_secret_scope(
|
||
{"WECOM_DM_POLICY": "allowlist", "WECOM_ALLOWED_USERS": "scoped-user"}
|
||
)
|
||
try:
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
finally:
|
||
secret_scope.reset_secret_scope(token)
|
||
assert adapter._dm_policy == "allowlist"
|
||
assert adapter._allow_from == ["scoped-user"]
|
||
|
||
def test_scoped_miss_does_not_admit_default_profiles_allowlist(self, multiplex_on, monkeypatch):
|
||
from agent import secret_scope
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
monkeypatch.setenv("WECOM_DM_POLICY", "allowlist")
|
||
monkeypatch.setenv("WECOM_ALLOWED_USERS", "default-user")
|
||
token = secret_scope.set_secret_scope({"SOMETHING_ELSE": "x"})
|
||
try:
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
finally:
|
||
secret_scope.reset_secret_scope(token)
|
||
assert adapter._dm_policy == "pairing"
|
||
assert adapter._allow_from == []
|
||
|
||
def test_scoped_construction_reads_bot_id_from_scope_not_environ(self, multiplex_on, monkeypatch):
|
||
"""bot_id must honor the same scope as its neighboring _secret read
|
||
(both are read on adjacent lines in __init__) -- a secondary profile's
|
||
own bot_id must never fall back to the default profile's os.environ
|
||
value."""
|
||
from agent import secret_scope
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
monkeypatch.setenv("WECOM_BOT_ID", "default-profile-bot-id")
|
||
monkeypatch.setenv("WECOM_SECRET", "default-profile-secret")
|
||
token = secret_scope.set_secret_scope(
|
||
{"WECOM_BOT_ID": "scoped-bot-id", "WECOM_SECRET": "scoped-secret"}
|
||
)
|
||
try:
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
finally:
|
||
secret_scope.reset_secret_scope(token)
|
||
assert adapter._bot_id == "scoped-bot-id"
|
||
assert adapter._secret == "scoped-secret"
|
||
|
||
def test_scoped_miss_does_not_leak_default_profiles_bot_id(self, multiplex_on, monkeypatch):
|
||
from agent import secret_scope
|
||
from plugins.platforms.wecom.adapter import DEFAULT_WS_URL, WeComAdapter
|
||
|
||
monkeypatch.setenv("WECOM_BOT_ID", "default-profile-bot-id")
|
||
monkeypatch.setenv("WECOM_WEBSOCKET_URL", "wss://default-profile.example/ws")
|
||
token = secret_scope.set_secret_scope({"SOMETHING_ELSE": "x"})
|
||
try:
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
finally:
|
||
secret_scope.reset_secret_scope(token)
|
||
assert adapter._bot_id == ""
|
||
assert adapter._ws_url == DEFAULT_WS_URL
|
||
|
||
|
||
class TestWeComConnect:
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_connect_records_handshake_failure_details(self, monkeypatch):
|
||
import plugins.platforms.wecom.adapter as wecom_module
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
class DummyClient:
|
||
async def aclose(self):
|
||
return None
|
||
|
||
monkeypatch.setattr(wecom_module, "AIOHTTP_AVAILABLE", True)
|
||
monkeypatch.setattr(wecom_module, "HTTPX_AVAILABLE", True)
|
||
monkeypatch.setattr(
|
||
wecom_module,
|
||
"httpx",
|
||
SimpleNamespace(AsyncClient=lambda **kwargs: DummyClient()),
|
||
)
|
||
|
||
adapter = WeComAdapter(
|
||
PlatformConfig(enabled=True, extra={"bot_id": "bot-1", "secret": "secret-1"})
|
||
)
|
||
adapter._open_connection = AsyncMock(side_effect=RuntimeError("invalid secret (errcode=40013)"))
|
||
|
||
success = await adapter.connect()
|
||
|
||
assert success is False
|
||
assert adapter.has_fatal_error is True
|
||
assert adapter.fatal_error_code == "wecom_connect_error"
|
||
assert "invalid secret" in (adapter.fatal_error_message or "")
|
||
|
||
|
||
class TestWeComQrScan:
|
||
@patch("plugins.platforms.wecom.adapter.time")
|
||
@patch("plugins.platforms.wecom.adapter.json.loads")
|
||
@patch("plugins.platforms.wecom.adapter.logger")
|
||
@patch("urllib.request.urlopen")
|
||
@patch("urllib.request.Request")
|
||
def test_qr_scan_timeout_uses_monotonic_clock(
|
||
self,
|
||
mock_request,
|
||
mock_urlopen,
|
||
_mock_logger,
|
||
mock_json_loads,
|
||
mock_time,
|
||
):
|
||
from plugins.platforms.wecom.adapter import qr_scan_for_bot_info
|
||
|
||
generate_resp = MagicMock()
|
||
generate_resp.read.return_value = b'{"data":{"scode":"abc","auth_url":"https://example.com/qr"}}'
|
||
generate_resp.__enter__.return_value = generate_resp
|
||
generate_resp.__exit__.return_value = False
|
||
|
||
poll_resp = MagicMock()
|
||
poll_resp.read.return_value = b'{"data":{"status":"pending"}}'
|
||
poll_resp.__enter__.return_value = poll_resp
|
||
poll_resp.__exit__.return_value = False
|
||
|
||
mock_urlopen.side_effect = [generate_resp, poll_resp]
|
||
mock_json_loads.side_effect = [
|
||
{"data": {"scode": "abc", "auth_url": "https://example.com/qr"}},
|
||
{"data": {"status": "pending"}},
|
||
]
|
||
mock_time.monotonic.side_effect = [1000, 1000.2, 1001.1]
|
||
mock_time.time.side_effect = [1000, 900, 901, 902]
|
||
mock_time.sleep = MagicMock()
|
||
|
||
with patch("builtins.print"), patch.dict("sys.modules", {"qrcode": None}):
|
||
result = qr_scan_for_bot_info(timeout_seconds=1)
|
||
|
||
assert result is None
|
||
assert mock_urlopen.call_count == 2
|
||
|
||
|
||
class TestWeComReplyMode:
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_image_file_uses_passive_reply_media_when_reply_context_exists(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._reply_req_ids["msg-1"] = "req-1"
|
||
adapter._prepare_outbound_media = AsyncMock(
|
||
return_value={
|
||
"data": b"image-bytes",
|
||
"content_type": "image/png",
|
||
"file_name": "demo.png",
|
||
"detected_type": "image",
|
||
"final_type": "image",
|
||
"rejected": False,
|
||
"reject_reason": None,
|
||
"downgraded": False,
|
||
"downgrade_note": None,
|
||
}
|
||
)
|
||
adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "image"})
|
||
adapter._send_reply_request = AsyncMock(
|
||
return_value={"headers": {"req_id": "req-1"}, "errcode": 0}
|
||
)
|
||
|
||
result = await adapter.send_image_file("chat-123", "/tmp/demo.png", reply_to="msg-1")
|
||
|
||
assert result.success is True
|
||
adapter._send_reply_request.assert_awaited_once()
|
||
args = adapter._send_reply_request.await_args.args
|
||
assert args[0] == "req-1"
|
||
assert args[1] == {"msgtype": "image", "image": {"media_id": "media-1"}}
|
||
|
||
|
||
class TestExtractText:
|
||
|
||
def test_extracts_mixed_text(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
body = {
|
||
"msgtype": "mixed",
|
||
"mixed": {
|
||
"msg_item": [
|
||
{"msgtype": "text", "text": {"content": "part1"}},
|
||
{"msgtype": "image", "image": {"url": "https://example.com/x.png"}},
|
||
{"msgtype": "text", "text": {"content": "part2"}},
|
||
]
|
||
},
|
||
}
|
||
text, _reply_text = WeComAdapter._extract_text(body)
|
||
assert text == "part1\npart2"
|
||
|
||
|
||
class TestCallbackDispatch:
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("cmd", ["aibot_msg_callback", "aibot_callback"])
|
||
async def test_dispatch_accepts_new_and_legacy_callback_cmds(self, cmd):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._on_message = AsyncMock()
|
||
|
||
await adapter._dispatch_payload({"cmd": cmd, "headers": {"req_id": "req-1"}, "body": {}})
|
||
|
||
adapter._on_message.assert_awaited_once()
|
||
|
||
|
||
class TestPolicyHelpers:
|
||
|
||
def test_dm_allowlist_honors_env_only_allowed_users(self, monkeypatch):
|
||
"""Env-only setup (WECOM_DM_POLICY + WECOM_ALLOWED_USERS, no config
|
||
``extra``) must populate the DM allowlist. Otherwise ``dm_policy:
|
||
allowlist`` runs with an empty allowlist and drops every listed user
|
||
at intake — the documented env vars become no-ops."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
monkeypatch.setenv("WECOM_DM_POLICY", "allowlist")
|
||
monkeypatch.setenv("WECOM_ALLOWED_USERS", "user-1, user-2")
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
|
||
assert adapter._dm_policy == "allowlist"
|
||
assert adapter._allow_from == ["user-1", "user-2"]
|
||
assert adapter._is_dm_allowed("user-1") is True
|
||
assert adapter._is_dm_allowed("user-2") is True
|
||
assert adapter._is_dm_allowed("stranger") is False
|
||
|
||
|
||
def test_pairing_group_policy_blocks_without_explicit_group_allow_from(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(
|
||
PlatformConfig(enabled=True, extra={"group_policy": "pairing"})
|
||
)
|
||
|
||
assert adapter._is_group_allowed("group-1", "user-1") is False
|
||
|
||
|
||
class TestMediaHelpers:
|
||
def test_detect_wecom_media_type(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
assert WeComAdapter._detect_wecom_media_type("image/png") == "image"
|
||
assert WeComAdapter._detect_wecom_media_type("video/mp4") == "video"
|
||
assert WeComAdapter._detect_wecom_media_type("audio/amr") == "voice"
|
||
assert WeComAdapter._detect_wecom_media_type("application/pdf") == "file"
|
||
|
||
def test_voice_non_amr_downgrades_to_file(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
result = WeComAdapter._apply_file_size_limits(128, "voice", "audio/mpeg")
|
||
|
||
assert result["final_type"] == "file"
|
||
assert result["downgraded"] is True
|
||
assert "AMR" in (result["downgrade_note"] or "")
|
||
|
||
|
||
class TestMediaUpload:
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_download_remote_bytes_blocks_connect_time_rebind(self, monkeypatch):
|
||
import httpcore
|
||
from httpcore._backends.auto import AutoBackend
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
from tools.url_safety import SSRFConnectionBlocked
|
||
|
||
for proxy_var in (
|
||
"HTTP_PROXY",
|
||
"HTTPS_PROXY",
|
||
"ALL_PROXY",
|
||
"http_proxy",
|
||
"https_proxy",
|
||
"all_proxy",
|
||
):
|
||
monkeypatch.delenv(proxy_var, raising=False)
|
||
|
||
answers = iter(("93.184.216.34", "169.254.169.254"))
|
||
|
||
def fake_getaddrinfo(_host, port, *_args, **_kwargs):
|
||
ip = next(answers)
|
||
return [
|
||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 0))
|
||
]
|
||
|
||
connect_attempts = []
|
||
|
||
async def fake_connect_tcp(
|
||
_self,
|
||
host,
|
||
port,
|
||
timeout=None,
|
||
local_address=None,
|
||
socket_options=None,
|
||
):
|
||
connect_attempts.append((host, port))
|
||
raise httpcore.ConnectError("stop before network")
|
||
|
||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||
monkeypatch.setattr(AutoBackend, "connect_tcp", fake_connect_tcp)
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
with pytest.raises(SSRFConnectionBlocked):
|
||
await adapter._download_remote_bytes(
|
||
"http://rebind.example/file.bin", max_bytes=1024
|
||
)
|
||
|
||
assert connect_attempts == []
|
||
|
||
|
||
class TestSend:
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_voice_sends_caption_and_downgrade_note(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._prepare_outbound_media = AsyncMock(
|
||
return_value={
|
||
"data": b"voice-bytes",
|
||
"content_type": "audio/mpeg",
|
||
"file_name": "voice.mp3",
|
||
"detected_type": "voice",
|
||
"final_type": "file",
|
||
"rejected": False,
|
||
"reject_reason": None,
|
||
"downgraded": True,
|
||
"downgrade_note": "语音格式 audio/mpeg 不支持,企微仅支持 AMR 格式,已转为文件格式发送",
|
||
}
|
||
)
|
||
adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "file"})
|
||
adapter._send_media_message = AsyncMock(return_value={"headers": {"req_id": "req-media"}, "errcode": 0})
|
||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="msg-1"))
|
||
|
||
result = await adapter.send_voice("chat-123", "/tmp/voice.mp3", caption="listen")
|
||
|
||
assert result.success is True
|
||
adapter._send_media_message.assert_awaited_once_with("chat-123", "file", "media-1")
|
||
assert adapter.send.await_count == 2
|
||
adapter.send.assert_any_await(chat_id="chat-123", content="listen", reply_to=None)
|
||
adapter.send.assert_any_await(
|
||
chat_id="chat-123",
|
||
content="ℹ️ 语音格式 audio/mpeg 不支持,企微仅支持 AMR 格式,已转为文件格式发送",
|
||
reply_to=None,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_approval_confirmation_uses_proactive_send(self):
|
||
"""Regression: force_proactive_send=True must use APP_CMD_SEND to avoid
|
||
consuming the req_id that the post-approval stream needs. Passive reply
|
||
on the same req_id causes WeCom to render the stream seed as empty bubble."""
|
||
from plugins.platforms.wecom.adapter import APP_CMD_SEND, WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
# Simulate a cached req_id from the user's /approve message
|
||
adapter._last_chat_req_ids["chat-123"] = "req-approve"
|
||
adapter._send_request = AsyncMock(return_value={"headers": {"req_id": "req-approve"}, "errcode": 0})
|
||
adapter._send_reply_request = AsyncMock(
|
||
return_value={"headers": {"req_id": "req-approve"}, "errcode": 0}
|
||
)
|
||
|
||
result = await adapter.send(
|
||
"chat-123",
|
||
"✅ Approved 1 command. Continuing...",
|
||
metadata={"is_approval_prompt": True, "force_proactive_send": True},
|
||
)
|
||
|
||
assert result.success is True
|
||
# Must use APP_CMD_SEND (proactive), NOT _send_reply_request (passive)
|
||
adapter._send_request.assert_awaited_once_with(
|
||
APP_CMD_SEND,
|
||
{
|
||
"chatid": "chat-123",
|
||
"msgtype": "markdown",
|
||
"markdown": {"content": "✅ Approved 1 command. Continuing..."},
|
||
},
|
||
)
|
||
adapter._send_reply_request.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_approval_request_prompt_uses_passive_reply(self):
|
||
"""is_approval_prompt alone (without force_proactive_send) must still use
|
||
passive reply. The initial approval *request* prompt needs passive reply
|
||
because groups cannot use APP_CMD_SEND."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["group-chat"] = "req-user-msg"
|
||
adapter._send_reply_request = AsyncMock(
|
||
return_value={"headers": {"req_id": "req-user-msg"}, "errcode": 0}
|
||
)
|
||
adapter._send_request = AsyncMock(return_value={"errcode": 0})
|
||
|
||
result = await adapter.send(
|
||
"group-chat",
|
||
"⚠️ Dangerous command requires approval...",
|
||
metadata={"is_approval_prompt": True}, # No force_proactive_send
|
||
)
|
||
|
||
assert result.success is True
|
||
# Should use passive reply (preserving req_id for group delivery)
|
||
adapter._send_reply_request.assert_awaited_once()
|
||
adapter._send_request.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_with_active_stream_still_uses_passive_reply(self):
|
||
"""send() should NOT force proactive when a stream is active —
|
||
that's too broad and breaks group delivery. Only explicit
|
||
force_proactive_send metadata triggers proactive mode."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter, StreamTurn
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-123"] = "req-latest"
|
||
# Simulate an active stream turn for this chat
|
||
turn = StreamTurn("chat-123", "req-latest")
|
||
adapter._stream_turns["chat-123:turn-1"] = turn
|
||
|
||
adapter._send_reply_request = AsyncMock(
|
||
return_value={"headers": {"req_id": "req-latest"}, "errcode": 0}
|
||
)
|
||
adapter._send_request = AsyncMock(return_value={"errcode": 0})
|
||
|
||
result = await adapter.send("chat-123", "Some status message")
|
||
|
||
assert result.success is True
|
||
# Should still use passive reply — active stream doesn't force proactive
|
||
adapter._send_reply_request.assert_awaited_once()
|
||
adapter._send_request.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_force_proactive_falls_back_to_passive_for_groups(self):
|
||
"""Regression: force_proactive_send must NOT use APP_CMD_SEND in group chats.
|
||
WeCom AI Bots cannot initiate APP_CMD_SEND in groups — only passive reply
|
||
(APP_CMD_RESPONSE) bound to a req_id works."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["group-chat"] = "req-approve"
|
||
# Mark this chat as a group
|
||
adapter._group_chat_ids.add("group-chat")
|
||
|
||
adapter._send_reply_request = AsyncMock(
|
||
return_value={"headers": {"req_id": "req-approve"}, "errcode": 0}
|
||
)
|
||
adapter._send_request = AsyncMock(return_value={"errcode": 0})
|
||
|
||
result = await adapter.send(
|
||
"group-chat",
|
||
"✅ Approved 1 command. Continuing...",
|
||
metadata={"is_approval_prompt": True, "force_proactive_send": True},
|
||
)
|
||
|
||
assert result.success is True
|
||
# Group chats must fall back to passive reply even with force_proactive_send
|
||
adapter._send_reply_request.assert_awaited_once()
|
||
adapter._send_request.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_group_send_fails_early_without_req_id(self):
|
||
"""Group chats with no cached req_id must fail with a clear error
|
||
instead of attempting APP_CMD_SEND (which WeCom will reject)."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
# No req_id cached for this group
|
||
adapter._group_chat_ids.add("group-no-req")
|
||
adapter._send_request = AsyncMock(return_value={"errcode": 0})
|
||
|
||
result = await adapter.send("group-no-req", "hello group")
|
||
|
||
assert result.success is False
|
||
assert "req_id" in (result.error or "").lower()
|
||
# Should NOT attempt APP_CMD_SEND
|
||
adapter._send_request.assert_not_awaited()
|
||
|
||
|
||
class TestInboundMessages:
|
||
@pytest.mark.asyncio
|
||
async def test_on_message_builds_event(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(
|
||
PlatformConfig(
|
||
enabled=True,
|
||
extra={"group_policy": "allowlist", "group_allow_from": ["group-1"]},
|
||
)
|
||
)
|
||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||
adapter.handle_message = AsyncMock()
|
||
adapter._extract_media = AsyncMock(return_value=(["/tmp/test.png"], ["image/png"]))
|
||
|
||
payload = {
|
||
"cmd": "aibot_msg_callback",
|
||
"headers": {"req_id": "req-1"},
|
||
"body": {
|
||
"msgid": "msg-1",
|
||
"chatid": "group-1",
|
||
"chattype": "group",
|
||
"from": {"userid": "user-1"},
|
||
"msgtype": "text",
|
||
"text": {"content": "hello"},
|
||
},
|
||
}
|
||
|
||
await adapter._on_message(payload)
|
||
|
||
adapter.handle_message.assert_awaited_once()
|
||
event = adapter.handle_message.await_args.args[0]
|
||
assert event.text == "hello"
|
||
assert event.source.chat_id == "group-1"
|
||
assert event.source.user_id == "user-1"
|
||
assert event.media_urls == ["/tmp/test.png"]
|
||
assert event.media_types == ["image/png"]
|
||
|
||
|
||
class TestWeComZombieSessionFix:
|
||
"""Tests for PR #11572 — device_id, markdown reply, group req_id fallback."""
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_on_message_does_not_cache_blocked_sender_req_id(self):
|
||
"""Blocked chats shouldn't populate the proactive-send fallback cache."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(
|
||
PlatformConfig(
|
||
enabled=True,
|
||
extra={"group_policy": "allowlist", "group_allow_from": ["group-ok"]},
|
||
)
|
||
)
|
||
adapter.handle_message = AsyncMock()
|
||
adapter._extract_media = AsyncMock(return_value=([], []))
|
||
|
||
payload = {
|
||
"cmd": "aibot_msg_callback",
|
||
"headers": {"req_id": "req-abc"},
|
||
"body": {
|
||
"msgid": "msg-1",
|
||
"chatid": "group-blocked",
|
||
"chattype": "group",
|
||
"from": {"userid": "user-1"},
|
||
"msgtype": "text",
|
||
"text": {"content": "hi"},
|
||
},
|
||
}
|
||
|
||
await adapter._on_message(payload)
|
||
adapter.handle_message.assert_not_awaited()
|
||
assert "group-blocked" not in adapter._last_chat_req_ids
|
||
|
||
def test_remember_chat_req_id_is_bounded(self):
|
||
from plugins.platforms.wecom.adapter import DEDUP_MAX_SIZE, WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
for i in range(DEDUP_MAX_SIZE + 50):
|
||
adapter._remember_chat_req_id(f"chat-{i}", f"req-{i}")
|
||
assert len(adapter._last_chat_req_ids) <= DEDUP_MAX_SIZE
|
||
# The most recently remembered chat must still be present.
|
||
latest = f"chat-{DEDUP_MAX_SIZE + 49}"
|
||
assert adapter._last_chat_req_ids[latest] == f"req-{DEDUP_MAX_SIZE + 49}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_proactive_group_send_falls_back_to_cached_req_id(self):
|
||
"""Sending into a group without reply_to should use the last cached
|
||
req_id via APP_CMD_RESPONSE — WeCom AI Bots cannot initiate APP_CMD_SEND
|
||
in group chats (errcode 600039)."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["group-1"] = "inbound-req-42"
|
||
adapter._send_reply_request = AsyncMock(
|
||
return_value={"headers": {"req_id": "inbound-req-42"}, "errcode": 0}
|
||
)
|
||
adapter._send_request = AsyncMock(
|
||
return_value={"headers": {"req_id": "new"}, "errcode": 0}
|
||
)
|
||
|
||
result = await adapter.send("group-1", "ping", reply_to=None)
|
||
|
||
assert result.success is True
|
||
# Must route through reply (APP_CMD_RESPONSE), not proactive send.
|
||
adapter._send_reply_request.assert_awaited_once()
|
||
adapter._send_request.assert_not_awaited()
|
||
args = adapter._send_reply_request.await_args.args
|
||
assert args[0] == "inbound-req-42"
|
||
assert args[1]["msgtype"] == "markdown"
|
||
assert args[1]["markdown"]["content"] == "ping"
|
||
|
||
|
||
class TestTextBatchFlushRace:
|
||
"""Regression tests for the cancel-delivery race in _flush_text_batch.
|
||
|
||
When asyncio.sleep() fires and Task.cancel() is called before the task
|
||
runs, CPython sets _must_cancel but cannot cancel the already-done sleep
|
||
future. CancelledError is then delivered at the *next* await
|
||
(handle_message), after the task has already popped the event — the
|
||
superseding task sees an empty batch and silently drops the message.
|
||
The fix adds a synchronous task-registry check between the sleep and
|
||
the pop so a superseded task returns before touching the event.
|
||
"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_superseded_task_does_not_pop_or_process_event(self):
|
||
"""A flush task that has been superseded must leave the event in the
|
||
batch dict for the new task to handle."""
|
||
from gateway.platforms.base import MessageEvent, MessageType
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._text_batch_delay_seconds = 0
|
||
|
||
key = "test-session"
|
||
event = MessageEvent(text="hello", message_type=MessageType.TEXT)
|
||
adapter._pending_text_batches[key] = event
|
||
|
||
handle_calls = []
|
||
|
||
async def fake_handle(evt):
|
||
handle_calls.append(evt)
|
||
|
||
adapter.handle_message = fake_handle
|
||
|
||
# Create T1 and register it.
|
||
t1 = asyncio.create_task(adapter._flush_text_batch(key))
|
||
adapter._pending_text_batch_tasks[key] = t1
|
||
|
||
# Simulate T2 superseding T1 before T1 wakes from sleep.
|
||
t2 = asyncio.create_task(asyncio.sleep(0.2))
|
||
adapter._pending_text_batch_tasks[key] = t2
|
||
|
||
# Yield long enough for T1's sleep(0) to complete and T1 to run.
|
||
await asyncio.sleep(0.05)
|
||
|
||
t2.cancel()
|
||
try:
|
||
await t2
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
# T1 must have returned without processing or removing the event.
|
||
assert handle_calls == [], "superseded task must not call handle_message"
|
||
assert adapter._pending_text_batches.get(key) is event, (
|
||
"superseded task must not pop the event"
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_active_task_processes_event_normally(self):
|
||
"""When the task is not superseded it must still process the event."""
|
||
from gateway.platforms.base import MessageEvent, MessageType
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._text_batch_delay_seconds = 0
|
||
|
||
key = "test-session"
|
||
event = MessageEvent(text="world", message_type=MessageType.TEXT)
|
||
adapter._pending_text_batches[key] = event
|
||
|
||
handle_calls = []
|
||
|
||
async def fake_handle(evt):
|
||
handle_calls.append(evt)
|
||
|
||
adapter.handle_message = fake_handle
|
||
|
||
t1 = asyncio.create_task(adapter._flush_text_batch(key))
|
||
adapter._pending_text_batch_tasks[key] = t1
|
||
|
||
# No superseding task — T1 should process normally.
|
||
await asyncio.sleep(0.05)
|
||
|
||
assert handle_calls == [event], "active task must call handle_message"
|
||
assert adapter._pending_text_batches.get(key) is None, (
|
||
"active task must pop the event after processing"
|
||
)
|
||
|
||
|
||
class TestAttachmentTextMerge:
|
||
"""WeCom sends "image + text" as two separate inbound callbacks (an
|
||
attachment-only frame, then a text frame ~hundreds of ms later).
|
||
|
||
Dispatching the attachment immediately spawns an agent run that the
|
||
trailing text then "interrupts" (junk "⚡ Interrupting" + "✅" acks).
|
||
The adapter buffers an attachment-only message on the existing text-batch
|
||
machinery for a short merge window so the following text merges into ONE
|
||
dispatched event. These tests exercise the real _on_message path.
|
||
"""
|
||
|
||
@staticmethod
|
||
def _make_adapter(merge_delay: float = 0.15):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(
|
||
PlatformConfig(
|
||
enabled=True,
|
||
extra={
|
||
"dm_policy": "open",
|
||
"attachment_text_merge_delay_seconds": merge_delay,
|
||
},
|
||
)
|
||
)
|
||
# DM open policy needs the opt-in env flag; force intake open.
|
||
adapter._is_dm_intake_allowed = lambda sender_id: True
|
||
# Keep the text-split batch window tiny so tests are fast.
|
||
adapter._text_batch_delay_seconds = 0.05
|
||
adapter.handle_message = AsyncMock()
|
||
return adapter
|
||
|
||
@staticmethod
|
||
def _image_payload(msgid: str, media):
|
||
return {
|
||
"cmd": "aibot_msg_callback",
|
||
"headers": {"req_id": f"req-{msgid}"},
|
||
"body": {
|
||
"msgid": msgid,
|
||
"from": {"userid": "user-1"},
|
||
"msgtype": "image",
|
||
"image": {"url": "https://example.com/x.png"},
|
||
"_media": media,
|
||
},
|
||
}
|
||
|
||
@staticmethod
|
||
def _text_payload(msgid: str, content: str):
|
||
return {
|
||
"cmd": "aibot_msg_callback",
|
||
"headers": {"req_id": f"req-{msgid}"},
|
||
"body": {
|
||
"msgid": msgid,
|
||
"from": {"userid": "user-1"},
|
||
"msgtype": "text",
|
||
"text": {"content": content},
|
||
},
|
||
}
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_image_then_text_merge_into_one_event(self):
|
||
"""image-then-text within the window → ONE dispatched event carrying
|
||
both the media and the text, and NO immediate dispatch of the image
|
||
(so the busy-handler interrupt path is never triggered)."""
|
||
adapter = self._make_adapter(merge_delay=0.2)
|
||
|
||
async def fake_extract_media(body):
|
||
if body.get("msgtype") == "image":
|
||
return (["/tmp/x.png"], ["image/png"])
|
||
return ([], [])
|
||
|
||
adapter._extract_media = fake_extract_media
|
||
|
||
await adapter._on_message(self._image_payload("img-1", None))
|
||
# Image must be held, not dispatched.
|
||
adapter.handle_message.assert_not_called()
|
||
|
||
# Text arrives within the merge window.
|
||
await asyncio.sleep(0.05)
|
||
await adapter._on_message(self._text_payload("txt-1", "what is this?"))
|
||
adapter.handle_message.assert_not_called()
|
||
|
||
# After the window elapses, exactly one merged event dispatches.
|
||
await asyncio.sleep(0.3)
|
||
adapter.handle_message.assert_awaited_once()
|
||
event = adapter.handle_message.await_args.args[0]
|
||
from gateway.platforms.base import MessageType
|
||
|
||
assert event.text == "what is this?"
|
||
assert event.media_urls == ["/tmp/x.png"]
|
||
assert event.media_types == ["image/png"]
|
||
assert event.message_type == MessageType.TEXT
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_image_only_dispatched_after_window(self):
|
||
"""image-only with no following text → still dispatched on its own
|
||
after the merge window (must not be dropped)."""
|
||
adapter = self._make_adapter(merge_delay=0.15)
|
||
adapter._extract_media = AsyncMock(return_value=(["/tmp/x.png"], ["image/png"]))
|
||
|
||
await adapter._on_message(self._image_payload("img-1", None))
|
||
adapter.handle_message.assert_not_called()
|
||
|
||
await asyncio.sleep(0.3)
|
||
adapter.handle_message.assert_awaited_once()
|
||
event = adapter.handle_message.await_args.args[0]
|
||
from gateway.platforms.base import MessageType
|
||
|
||
assert event.media_urls == ["/tmp/x.png"]
|
||
assert event.message_type == MessageType.PHOTO
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_multiple_attachments_then_text_all_merged(self):
|
||
"""Two attachment-only frames then text → all media merged into one
|
||
dispatched event with the text."""
|
||
adapter = self._make_adapter(merge_delay=0.2)
|
||
|
||
counter = {"n": 0}
|
||
|
||
async def fake_extract_media(body):
|
||
if body.get("msgtype") == "image":
|
||
counter["n"] += 1
|
||
n = counter["n"]
|
||
return ([f"/tmp/x{n}.png"], ["image/png"])
|
||
return ([], [])
|
||
|
||
adapter._extract_media = fake_extract_media
|
||
|
||
await adapter._on_message(self._image_payload("img-1", None))
|
||
await asyncio.sleep(0.03)
|
||
await adapter._on_message(self._image_payload("img-2", None))
|
||
await asyncio.sleep(0.03)
|
||
await adapter._on_message(self._text_payload("txt-1", "describe both"))
|
||
adapter.handle_message.assert_not_called()
|
||
|
||
await asyncio.sleep(0.35)
|
||
adapter.handle_message.assert_awaited_once()
|
||
event = adapter.handle_message.await_args.args[0]
|
||
assert event.text == "describe both"
|
||
assert event.media_urls == ["/tmp/x1.png", "/tmp/x2.png"]
|
||
assert event.media_types == ["image/png", "image/png"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pure_text_unaffected(self):
|
||
"""Regression: pure text still flows through the text-batch path and
|
||
dispatches as a single text event."""
|
||
adapter = self._make_adapter()
|
||
adapter._extract_media = AsyncMock(return_value=([], []))
|
||
|
||
await adapter._on_message(self._text_payload("txt-1", "just text"))
|
||
adapter.handle_message.assert_not_called()
|
||
|
||
await asyncio.sleep(0.2)
|
||
adapter.handle_message.assert_awaited_once()
|
||
event = adapter.handle_message.await_args.args[0]
|
||
from gateway.platforms.base import MessageType
|
||
|
||
assert event.text == "just text"
|
||
assert event.media_urls == []
|
||
assert event.message_type == MessageType.TEXT
|
||
|
||
|
||
|
||
# === NATIVE STREAMING (msgtype: stream) ===
|
||
|
||
|
||
class TestWeComNativeStreamingCapability:
|
||
"""SUPPORTS_NATIVE_STREAMING + supports_native_streaming() probe."""
|
||
|
||
def test_class_attribute_declares_native_streaming(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
assert WeComAdapter.SUPPORTS_NATIVE_STREAMING is True
|
||
|
||
def test_supports_native_streaming_returns_true_for_dm(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
assert adapter.supports_native_streaming(chat_type="dm") is True
|
||
|
||
def test_supports_native_streaming_returns_true_for_group(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
assert adapter.supports_native_streaming(chat_type="group") is True
|
||
|
||
def test_max_stream_content_length_is_20480(self):
|
||
from plugins.platforms.wecom.adapter import (
|
||
MAX_STREAM_CONTENT_LENGTH, WeComAdapter,
|
||
)
|
||
|
||
assert MAX_STREAM_CONTENT_LENGTH == 20480
|
||
assert WeComAdapter.MAX_STREAM_CONTENT_LENGTH == 20480
|
||
|
||
def test_stream_expired_errcode_constant(self):
|
||
from plugins.platforms.wecom.adapter import STREAM_EXPIRED_ERRCODE
|
||
|
||
assert STREAM_EXPIRED_ERRCODE == 846608
|
||
|
||
# === STREAM TESTS PLACEHOLDER ===
|
||
|
||
|
||
class TestResolveStreamReqId:
|
||
"""`_resolve_stream_req_id` precedence: reply_to → cached chat → None."""
|
||
|
||
def test_prefers_explicit_reply_to(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._reply_req_ids["msg-123"] = "explicit-req"
|
||
adapter._last_chat_req_ids["chat-1"] = "cached-req"
|
||
|
||
assert adapter._resolve_stream_req_id("chat-1", "msg-123") == "explicit-req"
|
||
|
||
def test_falls_back_to_cached_chat_req_id(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "cached-req"
|
||
|
||
assert adapter._resolve_stream_req_id("chat-1", reply_to=None) == "cached-req"
|
||
|
||
def test_returns_none_when_no_anchor(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
assert adapter._resolve_stream_req_id("unknown-chat", None) is None
|
||
|
||
def test_quoted_reply_to_falls_through_to_chat_cache(self):
|
||
"""``quote:msg-id`` (quote-context marker) is not a real reply anchor."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "cached-req"
|
||
|
||
assert adapter._resolve_stream_req_id("chat-1", "quote:m-1") == "cached-req"
|
||
|
||
|
||
# === LIFECYCLE TESTS PLACEHOLDER ===
|
||
|
||
|
||
class TestSendStreamFrame:
|
||
"""`send_stream_frame` lifecycle: init → cumulative updates → finalize."""
|
||
|
||
@staticmethod
|
||
def _mock_send_json_with_immediate_ack(adapter):
|
||
"""Mock _send_reply_queued to bypass ack tracking entirely.
|
||
|
||
For tests that verify frame content/ordering, we don't need actual
|
||
ack tracking — just record what was sent and always succeed.
|
||
"""
|
||
sent_frames = []
|
||
|
||
async def mock_send_reply_queued(reply_req_id, body, *, is_final=False, skip_if_pending=False):
|
||
sent_frames.append({
|
||
"req_id": reply_req_id,
|
||
"body": body,
|
||
"is_final": is_final,
|
||
})
|
||
return {"errcode": 0, "errmsg": "ok"}
|
||
|
||
adapter._send_reply_queued = AsyncMock(side_effect=mock_send_reply_queued)
|
||
adapter._sent_frames = sent_frames
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_first_call_seeds_thinking_frame_then_returns_true(self):
|
||
"""First frame for a chat sends <think></think> seed, then the
|
||
content frame.
|
||
|
||
Fire-and-forget: intermediate frames are pushed immediately (pure
|
||
identity-dedup), so any non-empty payload produces a content frame
|
||
right after the seed — no min_chars / sentence-boundary gating.
|
||
"""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._ws = MagicMock(closed=False)
|
||
# Mock _send_reply_queued to bypass ack tracking
|
||
self._mock_send_json_with_immediate_ack(adapter)
|
||
|
||
payload = "hello world"
|
||
ok = await adapter.send_stream_frame(payload, chat_id="chat-1")
|
||
|
||
assert ok is True
|
||
# seed + content = 2 frames
|
||
assert len(adapter._sent_frames) == 2
|
||
seed_frame = adapter._sent_frames[0]
|
||
assert seed_frame["body"]["msgtype"] == "stream"
|
||
assert seed_frame["body"]["stream"]["content"] == "<think></think>"
|
||
assert seed_frame["body"]["stream"]["finish"] is False
|
||
|
||
content_frame = adapter._sent_frames[1]
|
||
assert content_frame["body"]["stream"]["content"] == payload
|
||
assert content_frame["body"]["stream"]["finish"] is False
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_first_and_second_call_share_stream_id(self):
|
||
"""Successive frames use the same stream_id.
|
||
|
||
Fire-and-forget pushes each distinct cumulative payload immediately,
|
||
so this exercises stream_id continuity across frames, not chunker
|
||
thresholds.
|
||
"""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._ws = MagicMock(closed=False)
|
||
# Immediate ack so all frames are sent (no pending-skip)
|
||
self._mock_send_json_with_immediate_ack(adapter)
|
||
|
||
first = "alpha"
|
||
second = "alpha beta" # cumulative growth — differs from `first`
|
||
await adapter.send_stream_frame(first, chat_id="chat-1")
|
||
await adapter.send_stream_frame(second, chat_id="chat-1")
|
||
|
||
# seed + first + second = 3 frames
|
||
assert len(adapter._sent_frames) == 3
|
||
ids = [frame["body"]["stream"]["id"] for frame in adapter._sent_frames]
|
||
assert ids[0] == ids[1] == ids[2]
|
||
assert ids[0].startswith("stream_")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_intermediate_frame_skipped_when_pending_ack(self):
|
||
"""Intermediate frames are skipped if a prior frame's ack is pending.
|
||
|
||
This is the new ack-tracking semantics: if the seed frame's ack hasn't
|
||
returned yet, the next intermediate frame is skipped (returns success
|
||
but doesn't actually send). This prevents errcode 6000 version conflict.
|
||
"""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._send_json = AsyncMock() # No auto-ack — pending stays pending
|
||
adapter._ws = MagicMock(closed=False)
|
||
|
||
await adapter.send_stream_frame("alpha", chat_id="chat-1")
|
||
# Seed frame sent, pending_ack is set. Immediately send another:
|
||
ok = await adapter.send_stream_frame("alpha beta", chat_id="chat-1")
|
||
|
||
assert ok is True # returns True (skip is silent success)
|
||
# Only seed frame sent; second was skipped due to pending ack.
|
||
assert adapter._send_json.await_count == 1
|
||
|
||
# accumulated_text still updated in StreamTurn despite skip.
|
||
turn = list(adapter._stream_turns.values())[0]
|
||
assert turn.accumulated_text == "alpha beta"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_intermediate_frame_cap_drops_excess(self):
|
||
"""After MAX_INTERMEDIATE_FRAMES, further intermediate frames are dropped."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter, MAX_INTERMEDIATE_FRAMES
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._ws = MagicMock(closed=False)
|
||
# Auto-ack so seed + first frame go through
|
||
self._mock_send_json_with_immediate_ack(adapter)
|
||
|
||
# First call creates turn + seed + content frame.
|
||
turn_id = "cap-test"
|
||
await adapter.send_stream_frame("first", chat_id="chat-1", turn_id=turn_id)
|
||
turn_key = f"chat-1:{turn_id}"
|
||
turn = adapter._stream_turns[turn_key]
|
||
|
||
# Artificially set counter to the cap.
|
||
turn._intermediate_frames_sent = MAX_INTERMEDIATE_FRAMES
|
||
turn._last_frame_sent_at = 0 # clear time throttle
|
||
|
||
# Record count BEFORE the overflow frame to assert it was truly skipped.
|
||
before_overflow = len(adapter._sent_frames)
|
||
|
||
# Next intermediate frame should be dropped.
|
||
ok = await adapter.send_stream_frame("overflow", chat_id="chat-1", turn_id=turn_id)
|
||
assert ok is True
|
||
assert turn.accumulated_text == "overflow"
|
||
# No additional frame sent — overflow was dropped.
|
||
assert len(adapter._sent_frames) == before_overflow
|
||
|
||
# Finalize still goes through unconditionally.
|
||
ok = await adapter.send_stream_frame("final", chat_id="chat-1", finalize=True, turn_id=turn_id)
|
||
assert ok is True
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_finalize_sends_finish_true_and_resets_state(self):
|
||
"""Finalize frame waits for pending ack, sends finish=true, cleans up turn."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._ws = MagicMock(closed=False)
|
||
# Auto-ack so seed + content + finalize all go through
|
||
self._mock_send_json_with_immediate_ack(adapter)
|
||
|
||
# With turn_id, creates independent turn
|
||
turn_id = "test-turn-1"
|
||
await adapter.send_stream_frame("partial", chat_id="chat-1", turn_id=turn_id)
|
||
turn_key = "chat-1:test-turn-1"
|
||
assert turn_key in adapter._stream_turns
|
||
turn = adapter._stream_turns[turn_key]
|
||
assert turn.stream_id is not None
|
||
|
||
ok = await adapter.send_stream_frame(
|
||
"partial final", chat_id="chat-1", finalize=True, turn_id=turn_id,
|
||
)
|
||
|
||
assert ok is True
|
||
# After finalize, turn should be cleaned up
|
||
assert turn_key not in adapter._stream_turns
|
||
# Finalize goes through _send_reply_queued (mocked).
|
||
# Find the finalize frame (is_final=True)
|
||
finalize_frames = [
|
||
f for f in adapter._sent_frames
|
||
if f["body"].get("stream", {}).get("finish") is True
|
||
]
|
||
assert len(finalize_frames) == 1
|
||
assert finalize_frames[0]["body"]["stream"]["content"] == "partial final"
|
||
|
||
# === FAILURE TESTS PLACEHOLDER ===
|
||
|
||
|
||
class TestSendStreamFrameFailures:
|
||
"""Behavior when no req_id, 846608 expiry, or generic transport errors."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_returns_false_when_no_req_id_available(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
# No reply_to, nothing in _last_chat_req_ids.
|
||
adapter._send_reply_request = AsyncMock()
|
||
|
||
ok = await adapter.send_stream_frame("hi", chat_id="unknown-chat")
|
||
|
||
assert ok is False
|
||
adapter._send_reply_request.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_returns_false_when_chat_id_missing_on_first_call(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._send_reply_request = AsyncMock()
|
||
|
||
ok = await adapter.send_stream_frame("hi", chat_id=None)
|
||
|
||
assert ok is False
|
||
adapter._send_reply_request.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_846608_marks_chat_expired_and_returns_false(self):
|
||
"""846608 on finalize frame marks the chat expired and returns False."""
|
||
from plugins.platforms.wecom.adapter import (
|
||
STREAM_EXPIRED_ERRCODE, WeComAdapter,
|
||
)
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._ws = MagicMock(closed=False)
|
||
|
||
# Mock _send_reply_queued: intermediate succeeds, final returns 846608
|
||
async def mock_queued(reply_req_id, body, *, is_final=False, skip_if_pending=False):
|
||
if is_final:
|
||
return {"errcode": STREAM_EXPIRED_ERRCODE, "errmsg": "stream expired"}
|
||
return {"errcode": 0, "errmsg": "ok"}
|
||
|
||
adapter._send_reply_queued = AsyncMock(side_effect=mock_queued)
|
||
|
||
# First call (seed + content) succeeds
|
||
turn_id = "test-turn-2"
|
||
await adapter.send_stream_frame("hello", chat_id="chat-1", turn_id=turn_id)
|
||
# Now try to finalize — ack returns 846608.
|
||
ok = await adapter.send_stream_frame("hello final", chat_id="chat-1", finalize=True, turn_id=turn_id)
|
||
|
||
assert ok is False
|
||
assert "chat-1" in adapter._stream_expired_chats
|
||
# This specific turn should be cleaned up
|
||
turn_key = "chat-1:test-turn-2"
|
||
assert turn_key not in adapter._stream_turns
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_subsequent_call_to_expired_chat_short_circuits(self):
|
||
"""Once a chat is in ``_stream_expired_chats``, send_stream_frame
|
||
bails immediately for new turns without touching the WS."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._stream_expired_chats.add("chat-1")
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._send_reply_request = AsyncMock()
|
||
|
||
# Without turn_id: short-circuits immediately
|
||
ok = await adapter.send_stream_frame("hi", chat_id="chat-1")
|
||
assert ok is False
|
||
adapter._send_reply_request.assert_not_awaited()
|
||
|
||
# With a new turn_id: also short-circuits (can't create new turn)
|
||
ok = await adapter.send_stream_frame("hi", chat_id="chat-1", turn_id="new-turn")
|
||
assert ok is False
|
||
adapter._send_reply_request.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_inbound_message_clears_expired_marker(self):
|
||
"""A fresh inbound req_id must resurrect the stream channel."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._stream_expired_chats.add("chat-1")
|
||
|
||
adapter._remember_chat_req_id("chat-1", "fresh-req-id")
|
||
|
||
assert "chat-1" not in adapter._stream_expired_chats
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_generic_transport_error_on_intermediate_is_fire_and_forget(self):
|
||
"""A generic transport error on an INTERMEDIATE frame is fire-and-forget.
|
||
|
||
The seed frame here fails with a generic RuntimeError. An intermediate
|
||
frame failing is transient and self-healing — a later cumulative frame
|
||
(or the finalize frame) re-carries the full text — so the turn must stay
|
||
live (keep-alive keeps refreshing it) and the call returns True. It
|
||
must NOT retire the turn or trip the consumer's send() fallback, which
|
||
would re-deliver content the stream will overwrite (duplicate bubble).
|
||
|
||
Contrast with the finalize-frame failure paths, which still return False
|
||
and retire so the consumer can fall back (see the double_send /
|
||
stream_dup_fix suites).
|
||
"""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._send_reply_request = AsyncMock(
|
||
side_effect=RuntimeError("ws disconnected"),
|
||
)
|
||
|
||
turn_id = "test-turn-3"
|
||
ok = await adapter.send_stream_frame("hi", chat_id="chat-1", turn_id=turn_id)
|
||
|
||
assert ok is True
|
||
# Intermediate failure keeps the turn alive and leaves the chat usable.
|
||
turn_key = "chat-1:test-turn-3"
|
||
assert turn_key in adapter._stream_turns
|
||
assert "chat-1" not in adapter._stream_expired_chats
|
||
|
||
# === SEND_TYPING TESTS PLACEHOLDER ===
|
||
|
||
|
||
class TestSendTypingTriggersThinking:
|
||
"""``send_typing`` is a no-op — typing is handled by stream consumer."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_typing_is_noop(self):
|
||
"""send_typing must not open any stream — the consumer seed frame does."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._send_json = AsyncMock()
|
||
adapter._ws = MagicMock(closed=False)
|
||
|
||
await adapter.send_typing("chat-1")
|
||
|
||
adapter._send_json.assert_not_awaited()
|
||
# No stream turns created
|
||
assert len(adapter._stream_turns) == 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_typing_does_not_raise(self):
|
||
"""send_typing must never raise regardless of state."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
await adapter.send_typing("chat-1")
|
||
await adapter.send_typing("")
|
||
await adapter.send_typing(None) # type: ignore
|
||
|
||
|
||
class TestStreamContentTruncation:
|
||
"""Bytes (not codepoints) are truncated to MAX_STREAM_CONTENT_LENGTH."""
|
||
|
||
def test_ascii_below_limit_passes_through(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
out = WeComAdapter._truncate_stream_content("hello", 1000)
|
||
assert out == "hello"
|
||
|
||
def test_ascii_above_limit_is_byte_capped(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
big = "x" * 30000
|
||
out = WeComAdapter._truncate_stream_content(big, 20480)
|
||
assert len(out.encode("utf-8")) <= 20480
|
||
|
||
def test_multibyte_truncation_does_not_split_codepoints(self):
|
||
"""A 3-byte CJK char must not be sliced mid-byte and emit garbage."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
# Each "你" is 3 UTF-8 bytes. Limit at 5 bytes — must keep one
|
||
# full char and drop the half-cut second char rather than emit �.
|
||
out = WeComAdapter._truncate_stream_content("你你", 5)
|
||
assert out == "你"
|
||
# Crucially, must be valid UTF-8 (no replacement chars from
|
||
# mid-byte slices).
|
||
assert "�" not in out
|
||
|
||
|
||
@pytest.mark.skip(reason="Obsolete: send() no longer closes streams in new per-turn architecture")
|
||
class TestSendClosesActiveStream:
|
||
"""OBSOLETE: These tests verify old behavior where send() closed active streams.
|
||
|
||
In the new per-turn architecture (post Round 3 fixes), send() and streaming
|
||
are completely independent. Streams are managed by their creators
|
||
(GatewayStreamConsumer) via send_stream_frame(finalize=True, turn_id=...).
|
||
|
||
See test_wecom_per_turn.py for tests of the new per-turn model.
|
||
"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_finalizes_active_stream_opened_by_consumer(self):
|
||
"""When the stream consumer opened a stream and then send() delivers
|
||
the response (e.g. fallback path), send() must close the stream."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._send_json = AsyncMock()
|
||
adapter._ws = MagicMock(closed=False)
|
||
adapter._send_reply_request = AsyncMock(return_value={"errcode": 0})
|
||
|
||
# Manually set active stream state (as the consumer would).
|
||
adapter._active_stream_id = "stream_test"
|
||
adapter._active_stream_req_id = "req-1"
|
||
adapter._active_stream_chat_id = "chat-1"
|
||
|
||
result = await adapter.send("chat-1", "Hello world!")
|
||
|
||
assert result.success is True
|
||
assert adapter._active_stream_id is None
|
||
finalize_calls = [
|
||
call for call in adapter._send_reply_request.await_args_list
|
||
if call.args[1].get("stream", {}).get("finish") is True
|
||
]
|
||
assert len(finalize_calls) == 1
|
||
assert finalize_calls[0].args[1]["stream"]["content"] == "Hello world!"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_ignores_stream_for_different_chat(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-2"] = "req-2"
|
||
adapter._send_reply_request = AsyncMock(return_value={"errcode": 0})
|
||
|
||
adapter._active_stream_id = "stream_test"
|
||
adapter._active_stream_req_id = "req-1"
|
||
adapter._active_stream_chat_id = "chat-1"
|
||
|
||
result = await adapter.send("chat-2", "Hi")
|
||
|
||
assert result.success is True
|
||
assert adapter._active_stream_id is not None # untouched
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_falls_through_when_stream_expired(self):
|
||
from plugins.platforms.wecom.adapter import STREAM_EXPIRED_ERRCODE, WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
|
||
async def fake(req_id, body, **kwargs):
|
||
if body.get("stream", {}).get("finish"):
|
||
return {"errcode": STREAM_EXPIRED_ERRCODE, "errmsg": "expired"}
|
||
return {"errcode": 0, "headers": {"req_id": req_id}}
|
||
|
||
adapter._send_reply_request = AsyncMock(side_effect=fake)
|
||
adapter._active_stream_id = "stream_test"
|
||
adapter._active_stream_req_id = "req-1"
|
||
adapter._active_stream_chat_id = "chat-1"
|
||
|
||
result = await adapter.send("chat-1", "Final answer")
|
||
|
||
assert result.success is True
|
||
assert adapter._active_stream_id is None
|
||
assert "chat-1" in adapter._stream_expired_chats
|
||
|
||
|
||
|
||
class TestFireAndForgetFrameFlow:
|
||
"""Integration: send_stream_frame pushes each distinct cumulative payload
|
||
immediately (pure identity-dedup), with no sentence/min-chars buffering."""
|
||
|
||
def _mock_send_json_with_immediate_ack(self, adapter):
|
||
sent_frames = []
|
||
|
||
async def mock_send(reply_req_id, body, **kwargs):
|
||
is_final = kwargs.get("is_final", False)
|
||
sent_frames.append({
|
||
"req_id": reply_req_id,
|
||
"body": body,
|
||
"is_final": is_final,
|
||
})
|
||
return {"errcode": 0, "errmsg": "ok"}
|
||
|
||
adapter._send_reply_queued = AsyncMock(side_effect=mock_send)
|
||
adapter._sent_frames = sent_frames
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_short_text_sent_immediately(self):
|
||
"""Fire-and-forget: even a short body ships right after the seed —
|
||
there is no min_chars buffering anymore."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._ws = MagicMock(closed=False)
|
||
self._mock_send_json_with_immediate_ack(adapter)
|
||
|
||
ok = await adapter.send_stream_frame("Hello.", chat_id="chat-1")
|
||
assert ok is True
|
||
# seed + content = 2 frames (the 6-char body is NOT buffered).
|
||
assert len(adapter._sent_frames) == 2
|
||
assert adapter._sent_frames[0]["body"]["stream"]["content"] == "<think></think>"
|
||
assert adapter._sent_frames[1]["body"]["stream"]["content"] == "Hello."
|
||
assert adapter._sent_frames[1]["body"]["stream"]["finish"] is False
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_finalize_sends_accumulated_tail(self):
|
||
"""Finalize emits the accumulated text with finish=true.
|
||
|
||
With no chunker, finalize uses the caller's cumulative text directly.
|
||
"""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._ws = MagicMock(closed=False)
|
||
self._mock_send_json_with_immediate_ack(adapter)
|
||
|
||
# 1: intermediate frame (seed + content).
|
||
await adapter.send_stream_frame("Short.", chat_id="chat-1")
|
||
# 2: finalize with the same text. Content equals last_sent_content, so
|
||
# the adapter appends a zero-width space to force a distinct final frame.
|
||
ok = await adapter.send_stream_frame(
|
||
"Short.", chat_id="chat-1", finalize=True,
|
||
)
|
||
assert ok is True
|
||
# seed + content + finalize = 3 frames.
|
||
assert len(adapter._sent_frames) == 3
|
||
final_frame = adapter._sent_frames[-1]
|
||
assert final_frame["body"]["stream"]["finish"] is True
|
||
# Content survives the finalize (zero-width space appended when it
|
||
# matched the previous frame verbatim).
|
||
assert final_frame["body"]["stream"]["content"].startswith("Short.")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_duplicate_intermediate_content_is_deduped(self):
|
||
"""Identical cumulative content skips the send (pure identity-dedup),
|
||
but still returns success."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["chat-1"] = "req-1"
|
||
adapter._ws = MagicMock(closed=False)
|
||
self._mock_send_json_with_immediate_ack(adapter)
|
||
|
||
await adapter.send_stream_frame("same text", chat_id="chat-1")
|
||
ok = await adapter.send_stream_frame("same text", chat_id="chat-1")
|
||
assert ok is True
|
||
# seed + first content only; the identical repeat was deduped.
|
||
assert len(adapter._sent_frames) == 2
|
||
assert adapter._sent_frames[-1]["body"]["stream"]["content"] == "same text"
|
||
|
||
|
||
class TestFinalFrameAckTimeoutSemantics:
|
||
"""Regression: final-frame ack timeout must not raise / trigger fallback.
|
||
|
||
See docs/rca-wecom-stream-final-ack-timeout-duplicate.md — when WeCom's
|
||
ack returns past the 5s window but the frame *was* delivered, raising
|
||
causes the upper layer to fall back to a normal markdown send and the
|
||
user sees the same content twice. The fix: treat ack timeout as
|
||
success-with-uncertainty and let the caller mark the turn delivered.
|
||
"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_final_frame_ack_timeout_returns_success(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._ws = MagicMock(closed=False)
|
||
adapter._REPLY_ACK_TIMEOUT = 0.05 # snappy for the test
|
||
# _send_json succeeds but no ack ever arrives.
|
||
adapter._send_json = AsyncMock()
|
||
|
||
response = await adapter._send_reply_queued(
|
||
"req-1",
|
||
{"msgtype": "stream", "stream": {"id": "stream_x", "content": "final", "finish": True}},
|
||
is_final=True,
|
||
)
|
||
|
||
# Aligned-with-official semantics: success-shaped response with the
|
||
# ack_pending flag set so callers can log / observe but no exception.
|
||
assert response.get("errcode") == 0
|
||
assert response.get("ack_pending") is True
|
||
assert "ack_timeout" in response.get("errmsg", "")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_final_frame_send_failure_still_raises(self):
|
||
"""Genuine send failures (network/serialization) must still propagate.
|
||
|
||
The ack-timeout relaxation only covers the case where the bytes went
|
||
out but the ack didn't return. If ``_send_json`` itself raises, the
|
||
upstream caller still needs to see the error.
|
||
"""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._ws = MagicMock(closed=False)
|
||
adapter._send_json = AsyncMock(side_effect=RuntimeError("ws closed"))
|
||
|
||
with pytest.raises(RuntimeError, match="ws closed"):
|
||
await adapter._send_reply_queued(
|
||
"req-1",
|
||
{"msgtype": "stream", "stream": {"id": "stream_x", "content": "x", "finish": True}},
|
||
is_final=True,
|
||
)
|