Files
hermes-agent/tests/gateway/test_approval_boundary.py
T
wansui 2ecb544551 feat(wecom): native reply streaming (per-turn isolation, dedup-safe delivery, interaction boundaries)
Implement native reply streaming for the WeCom (企业微信) adapter over the
long-connection "msgtype: stream" transport, so a reply renders as a single
live-updating typing bubble instead of one final block. Aligns with the
official wecom-openclaw-plugin streaming behavior.

Includes the machinery intrinsic to native streaming on WeCom:

- Transport: seed frame (<think></think>) opens the typing bubble, intermediate
  frames update it, a finalize frame closes it; native-streaming adapters are
  let past the edit-only gate. Fire-and-forget intermediate frames (WeCom
  long-connection mode has no documented edit-rate limit); an adapter-level
  frame cap is retained. (Early builds gated frames behind a char throttle;
  removed in favor of fire-and-forget + identity dedup.)
- Per-turn isolation: each turn owns a unique turn_id; concurrent messages are
  isolated via (chat_id, turn_id)-keyed state. Dual-lane priority queue
  (control vs normal) plus a per-chat token bucket to stay under WeCom's rate
  limit (errcode 846607).
- Dedup-safe delivery + ack-race handling: deliver-once contract (a frame is
  delivered the moment it is emitted; failures logged, not re-sent; delivery
  marked once per turn), per-req_id reply queue with ack tracking, and the
  timeout-inversion / orphan-queue race fixes. Robust fallback on 846608 /
  846609 / errcode 6000 / passive-reply timeout via proactive send.
- Interaction boundaries: finalize + reset before approval/clarify prompts so
  the prompt is the last thing on screen and never traps a lingering bubble;
  eager re-seed after a clarify answer so the typing bubble reappears instantly.
- Stream-level keepalive: optional periodic finish=false frame + finalize-time
  stream-age guard to refresh WeCom's ~6-minute reply-stream window on long
  turns (mitigates 846604 / 846608). Off by default; tunable via config.yaml.
- Tool-progress folded into the same native-stream bubble instead of separate
  messages; image+text double-callback merged into one turn.

Tests cover the streaming lifecycle, per-turn isolation, duplicate-send / ack
timing, approval + clarify boundaries, eager re-seed, and tool-progress.
2026-08-27 07:33:36 -07:00

415 lines
14 KiB
Python

"""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