"""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 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"] == "" 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"] == "" 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, )