"""Tests for approval boundary handling in WeCom native streaming.""" import asyncio import pytest from unittest.mock import AsyncMock, MagicMock, patch from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig @pytest.fixture def mock_adapter(): """Create a mock WeCom adapter with native streaming support.""" from gateway.platforms.base import BasePlatformAdapter MockAdapter = type("MockAdapter", (BasePlatformAdapter,), { "MAX_MESSAGE_LENGTH": 4096, "SUPPORTS_MESSAGE_EDITING": False, "SUPPORTS_NATIVE_STREAMING": True, }) MockAdapter.__abstractmethods__ = frozenset() adapter = MockAdapter.__new__(MockAdapter) adapter._typing_paused = set() adapter.send_stream_frame = AsyncMock(return_value=True) adapter.send = AsyncMock(return_value=MagicMock(success=True, message_id="msg")) adapter.supports_native_streaming = lambda chat_type=None, metadata=None: True return adapter @pytest.fixture def consumer_config(): """Create a minimal consumer config.""" return StreamConsumerConfig( chat_type="dm", cursor="", edit_interval=0.01, buffer_threshold=5, ) @pytest.mark.asyncio async def test_approval_boundary_finalizes_and_disables_native(mock_adapter, consumer_config): """Approval boundary must finalize the current stream (creating a stable message for pre-approval content) and disable native streaming so post-approval output goes through reliable send().""" consumer = GatewayStreamConsumer( adapter=mock_adapter, chat_id="test_chat", config=consumer_config, ) consumer._use_native_streaming = True consumer._native_stream_opened = True consumer._turn_id = "turn_123" consumer._initial_reply_to_id = "msg_456" consumer._accumulated = "下面我来执行:先确认最新分区" # Signal approval boundary boundary_result = consumer.close_for_approval_prompt() if isinstance(boundary_result, tuple): boundary_future, _ = boundary_result else: boundary_future = boundary_result consumer_task = asyncio.create_task(consumer.run()) result = await asyncio.wait_for(boundary_future, timeout=1.0) assert result is True # Stream must be finalized (stable message created) finalize_calls = [ call for call in mock_adapter.send_stream_frame.call_args_list if call.kwargs.get("finalize") is True ] assert len(finalize_calls) == 1, "Must finalize the stream" finalize_text = finalize_calls[0].args[0] assert finalize_text == "下面我来执行:先确认最新分区" # Native streaming must be disabled for post-approval output assert consumer._use_native_streaming is False, ( "Native streaming must be disabled — post-approval goes via send()" ) assert consumer._native_stream_opened is False consumer.finish() await asyncio.sleep(0.05) consumer_task.cancel() try: await consumer_task except asyncio.CancelledError: pass @pytest.mark.asyncio async def test_approval_boundary_uses_placeholder_when_no_accumulated(mock_adapter, consumer_config): """When there's no accumulated text, finalize with a visible placeholder.""" consumer = GatewayStreamConsumer( adapter=mock_adapter, chat_id="test_chat", config=consumer_config, ) consumer._use_native_streaming = True consumer._native_stream_opened = True consumer._turn_id = "turn_123" consumer._accumulated = "" # No text accumulated boundary_result = consumer.close_for_approval_prompt() if isinstance(boundary_result, tuple): boundary_future, _ = boundary_result else: boundary_future = boundary_result consumer_task = asyncio.create_task(consumer.run()) result = await asyncio.wait_for(boundary_future, timeout=1.0) assert result is True finalize_calls = [ call for call in mock_adapter.send_stream_frame.call_args_list if call.kwargs.get("finalize") is True ] assert len(finalize_calls) == 1 finalize_text = finalize_calls[0].args[0] assert finalize_text == "⏸ 等待审批中..." consumer.finish() await asyncio.sleep(0.05) consumer_task.cancel() try: await consumer_task except asyncio.CancelledError: pass @pytest.mark.asyncio async def test_boundary_uses_custom_placeholder_when_no_accumulated(mock_adapter, consumer_config): """A clarify boundary passes its own placeholder; the empty-content finalize must use it instead of the approval wording so the finalized bubble doesn't read 'waiting for approval' for a decision question.""" consumer = GatewayStreamConsumer( adapter=mock_adapter, chat_id="test_chat", config=consumer_config, ) consumer._use_native_streaming = True consumer._native_stream_opened = True consumer._turn_id = "turn_123" consumer._accumulated = "" # No text accumulated boundary_result = consumer.close_for_approval_prompt("💬 等待你的选择...") if isinstance(boundary_result, tuple): boundary_future, _ = boundary_result else: boundary_future = boundary_result consumer_task = asyncio.create_task(consumer.run()) result = await asyncio.wait_for(boundary_future, timeout=1.0) assert result is True finalize_calls = [ call for call in mock_adapter.send_stream_frame.call_args_list if call.kwargs.get("finalize") is True ] assert len(finalize_calls) == 1 finalize_text = finalize_calls[0].args[0] assert finalize_text == "💬 等待你的选择..." # Native streaming disabled so post-answer output opens a fresh bubble. assert consumer._use_native_streaming is False assert consumer.cfg.buffer_only is True consumer.finish() await asyncio.sleep(0.05) consumer_task.cancel() try: await consumer_task except asyncio.CancelledError: pass @pytest.mark.asyncio async def test_clarify_boundary_logs_use_clarify_prefix(mock_adapter, consumer_config, caplog): """A clarify boundary that fails to finalize must log with a "Clarify" prefix, not "Approval" — otherwise a clarify failure looks like a dangerous-command approval failure during troubleshooting.""" import logging # Make finalize fail so the warning path fires, and the fallback send fail # too so the error path fires — exercising the reason-labelled logs. mock_adapter.send_stream_frame = AsyncMock(return_value=False) mock_adapter.send = AsyncMock(return_value=MagicMock(success=False)) consumer = GatewayStreamConsumer( adapter=mock_adapter, chat_id="test_chat", config=consumer_config, ) consumer._use_native_streaming = True consumer._native_stream_opened = True consumer._turn_id = "turn_123" consumer._accumulated = "部分内容" boundary_result = consumer.close_for_approval_prompt( "💬 等待你的选择...", reason="Clarify", ) if isinstance(boundary_result, tuple): boundary_future, _ = boundary_result else: boundary_future = boundary_result with caplog.at_level(logging.WARNING, logger="gateway.stream_consumer"): consumer_task = asyncio.create_task(consumer.run()) await asyncio.wait_for(boundary_future, timeout=1.0) boundary_logs = [r.getMessage() for r in caplog.records] assert any("Clarify boundary" in m for m in boundary_logs), ( f"Expected a 'Clarify boundary' log, got: {boundary_logs}" ) assert not any("Approval boundary" in m for m in boundary_logs), ( f"Clarify boundary must not log as 'Approval boundary': {boundary_logs}" ) consumer.finish() await asyncio.sleep(0.05) consumer_task.cancel() try: await consumer_task except asyncio.CancelledError: pass @pytest.mark.asyncio async def test_approval_boundary_post_approval_one_shot_send(mock_adapter, consumer_config): """After approval boundary, post-approval content must: 1. Set buffer_only=True (no mid-stream flushes) 2. Accumulate all deltas without sending 3. Deliver everything via one adapter.send() call on finish()""" consumer = GatewayStreamConsumer( adapter=mock_adapter, chat_id="test_chat", config=consumer_config, ) consumer._use_native_streaming = True consumer._native_stream_opened = True consumer._turn_id = "turn_123" consumer._accumulated = "Pre-approval text" boundary_result = consumer.close_for_approval_prompt() if isinstance(boundary_result, tuple): boundary_future, _ = boundary_result else: boundary_future = boundary_result consumer_task = asyncio.create_task(consumer.run()) await asyncio.wait_for(boundary_future, timeout=1.0) # Verify buffer_only is set assert consumer.cfg.buffer_only is True, "Must set buffer_only after boundary" assert consumer._use_native_streaming is False # Send post-approval content — should NOT trigger any immediate send mock_adapter.send_stream_frame.reset_mock() mock_adapter.send.reset_mock() consumer.on_delta("Post-approval result text here") await asyncio.sleep(0.1) # Before finish(): no send() or stream frame calls assert mock_adapter.send.call_count == 0, ( "buffer_only: no send before finish()" ) stream_calls = [ call for call in mock_adapter.send_stream_frame.call_args_list if not call.kwargs.get("finalize") ] assert len(stream_calls) == 0, ( "Post-approval must NOT use native streaming" ) # Now finish — should deliver via send() consumer.finish() await asyncio.sleep(0.1) # adapter.send should have been called with the full post-approval text send_calls = mock_adapter.send.call_args_list assert len(send_calls) >= 1, "finish() must deliver via send()" # The delivered text should contain our post-approval content delivered = send_calls[-1].args[1] if len(send_calls[-1].args) > 1 else send_calls[-1].kwargs.get("content", "") assert "Post-approval result" in delivered consumer_task.cancel() try: await consumer_task except asyncio.CancelledError: pass @pytest.mark.asyncio async def test_approval_boundary_stream_not_opened_at_boundary_time(mock_adapter, consumer_config): """When native streaming is active but _native_stream_opened is still False at the time boundary processes (e.g., seed succeeded but stream was closed by a prior error before boundary arrives), no finalize is sent.""" consumer = GatewayStreamConsumer( adapter=mock_adapter, chat_id="test_chat", config=consumer_config, ) # Let run() do its normal seed (which sets _native_stream_opened=True) # Then manually close it before boundary processes consumer._use_native_streaming = True consumer._native_stream_opened = True consumer._turn_id = "turn_123" consumer._accumulated = "Some text" # Queue boundary boundary_result = consumer.close_for_approval_prompt() if isinstance(boundary_result, tuple): boundary_future, _ = boundary_result else: boundary_future = boundary_result # Simulate: stream was closed by error BEFORE consumer processes boundary consumer._native_stream_opened = False consumer_task = asyncio.create_task(consumer.run()) result = await asyncio.wait_for(boundary_future, timeout=1.0) assert result is True # Native streaming should be disabled after boundary assert consumer._use_native_streaming is False consumer.finish() await asyncio.sleep(0.05) consumer_task.cancel() try: await consumer_task except asyncio.CancelledError: pass @pytest.mark.asyncio async def test_approval_boundary_finalize_fails_fallback_send_succeeds(mock_adapter, consumer_config): """When stream finalize fails but fallback send() succeeds, boundary is True.""" consumer = GatewayStreamConsumer( adapter=mock_adapter, chat_id="test_chat", config=consumer_config, ) consumer._use_native_streaming = True consumer._native_stream_opened = True consumer._turn_id = "turn_123" consumer._accumulated = "Pre-approval text" # Finalize fails (returns False) mock_adapter.send_stream_frame = AsyncMock(return_value=False) # Fallback send succeeds mock_adapter.send = AsyncMock(return_value=MagicMock(success=True, message_id="msg")) boundary_result = consumer.close_for_approval_prompt() if isinstance(boundary_result, tuple): boundary_future, _ = boundary_result else: boundary_future = boundary_result consumer_task = asyncio.create_task(consumer.run()) result = await asyncio.wait_for(boundary_future, timeout=1.0) assert result is True, "Fallback send succeeded → boundary should be True" mock_adapter.send.assert_awaited_once_with("test_chat", "Pre-approval text") consumer.finish() await asyncio.sleep(0.05) consumer_task.cancel() try: await consumer_task except asyncio.CancelledError: pass @pytest.mark.asyncio async def test_approval_boundary_finalize_and_fallback_both_fail(mock_adapter, consumer_config): """When both stream finalize and fallback send() fail, boundary is False.""" consumer = GatewayStreamConsumer( adapter=mock_adapter, chat_id="test_chat", config=consumer_config, ) consumer._use_native_streaming = True consumer._native_stream_opened = True consumer._turn_id = "turn_123" consumer._accumulated = "Pre-approval text" # Finalize fails (raises) mock_adapter.send_stream_frame = AsyncMock(side_effect=RuntimeError("stream dead")) # Fallback send also fails mock_adapter.send = AsyncMock(return_value=MagicMock(success=False, error="timeout")) boundary_result = consumer.close_for_approval_prompt() if isinstance(boundary_result, tuple): boundary_future, _ = boundary_result else: boundary_future = boundary_result consumer_task = asyncio.create_task(consumer.run()) result = await asyncio.wait_for(boundary_future, timeout=1.0) assert result is False, "Both finalize and fallback failed → boundary should be False" consumer.finish() await asyncio.sleep(0.05) consumer_task.cancel() try: await consumer_task except asyncio.CancelledError: pass