Files
aiturk-hermes-ide/tests/gateway/test_wecom_stream_dup_fix.py
T

331 lines
14 KiB
Python

"""Behavior-contract tests for the WeCom native-streaming "重复气泡" fix.
Two production bug classes are covered, each toggle-validated so the test
reproduces the duplicate/mis-decline when the fix is disabled and passes when
it is enabled — proving the assertions track behavior, not a frozen snapshot.
Fix A — Layer 2 clock fallback must NOT decline a finalize frame while Layer 1
keep-alive is enabled. Keep-alive refreshes the stream window every ~2min, so
a large ``stream_age`` does not mean the stream is dead; declining it on a blind
clock read forces the consumer's send() fallback to re-deliver content the
intermediate frames already put on screen (the duplicate bubble). Toggle =
``adapter._stream_keepalive_enabled``.
Fix B — an intermediate frame (finalize=False) failing/expiring must be
fire-and-forget (return True, turn stays live, no consumer fallback), because a
later cumulative frame overwrites it. Only a FINAL frame (finalize=True)
failure means the screen is genuinely missing content and must return False to
trip the consumer's send() fallback. Toggle = the ``finalize`` argument.
These drive the REAL ``WeComAdapter._send_stream_frame_inner`` with only the
byte-level ``_send_stream_reply`` seam faked, so the actual finalize / except
control flow runs. Assertions read observable adapter state: the return value
(what the consumer keys its fallback on), whether the turn survived in
``_stream_turns``, whether the chat was marked expired, and how many finalize
frames actually reached ``_send_stream_reply``.
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.wecom.adapter import (
WeComAdapter,
WeComStreamExpiredError,
STREAM_EXPIRED_ERRCODE,
)
CHAT_ID = "chat-dup"
REQ_ID = "req-dup"
TURN_ID = "turn-dup"
# Fire-and-forget intermediate frames are pushed as soon as the cumulative
# text differs from the last sent frame (pure identity-dedup, no chunker /
# min-chars gate). A non-trivial body is used so the intermediate-failure
# tests exercise a real content frame rather than only the seed frame.
BLOCK_TEXT = (
"This is a complete sentence used to fill the block chunker past its "
"minimum character threshold so it actually drains a content frame. "
"Here is a second sentence to be safely over the limit."
)
def _make_adapter(*, keepalive_enabled: bool) -> WeComAdapter:
"""Real adapter with only the stream byte-writer faked.
``_send_stream_reply`` is the seam between per-turn logic and the wire.
Faking it here lets each test dictate per-frame success/expiry while the
real finalize / except branches run.
"""
extra = {"stream_keepalive_enabled": keepalive_enabled}
adapter = WeComAdapter(PlatformConfig(enabled=True, extra=extra))
adapter._last_chat_req_ids[CHAT_ID] = REQ_ID
return adapter
def _finalize_calls(mock: AsyncMock) -> list:
"""finish=True calls that reached ``_send_stream_reply``."""
return [c for c in mock.await_args_list if c.kwargs.get("finish") is True]
# ===========================================================================
# Fix A — keep-alive suppresses the Layer 2 clock decline
# ===========================================================================
class TestKeepaliveSuppressesClockDecline:
"""A long-lived, keep-alive-refreshed stream must still finalize natively;
the clock fallback must not decline it and force a duplicate send()."""
@pytest.mark.asyncio
async def test_keepalive_on_old_stream_finalizes_natively(self):
"""FIX ENABLED: keep-alive on + stream_age >> safe_duration + finalize.
Post-fix contract: the finalize frame is sent on the wire (finish=True),
finalize returns True, the turn is finalized+cleaned, and the chat is
NOT marked expired — so the consumer suppresses its send() fallback and
no duplicate bubble is produced.
"""
adapter = _make_adapter(keepalive_enabled=True)
try:
reply = AsyncMock(return_value={"errcode": 0})
adapter._send_stream_reply = reply
# Open the turn (seed + first intermediate).
await adapter._send_stream_frame_inner(
"partial answer", chat=CHAT_ID, finalize=False, turn_id=TURN_ID,
)
turn = adapter._stream_turns[f"{CHAT_ID}:{TURN_ID}"]
# Age the stream far beyond the Layer 2 safe duration.
turn.start_time -= adapter._stream_safe_duration_seconds + 500
ok = await adapter._send_stream_frame_inner(
"the complete final answer",
chat=CHAT_ID, finalize=True, turn_id=TURN_ID,
)
# Native finalize succeeded — consumer will suppress fallback.
assert ok is True
assert len(_finalize_calls(reply)) == 1, (
"keep-alive on: finalize frame must reach the wire, not be "
"declined by the Layer 2 clock fallback"
)
assert CHAT_ID not in adapter._stream_expired_chats
assert f"{CHAT_ID}:{TURN_ID}" not in adapter._stream_turns
finally:
await adapter.disconnect()
@pytest.mark.asyncio
async def test_keepalive_off_old_stream_declines_finalize(self):
"""FIX DISABLED (toggle): keep-alive off preserves original Layer 2.
With keep-alive off there is nothing refreshing the window, so a
stream older than safe_duration is genuinely doomed — the original
clock decline must still fire: no finalize frame on the wire, return
False, turn retired, chat marked expired (consumer takes over via
send()). This proves Fix A is gated on the toggle, not unconditional.
"""
adapter = _make_adapter(keepalive_enabled=False)
try:
reply = AsyncMock(return_value={"errcode": 0})
adapter._send_stream_reply = reply
await adapter._send_stream_frame_inner(
"partial answer", chat=CHAT_ID, finalize=False, turn_id=TURN_ID,
)
turn = adapter._stream_turns[f"{CHAT_ID}:{TURN_ID}"]
turn.start_time -= adapter._stream_safe_duration_seconds + 500
ok = await adapter._send_stream_frame_inner(
"the complete final answer",
chat=CHAT_ID, finalize=True, turn_id=TURN_ID,
)
assert ok is False, "keep-alive off: old stream must decline finalize"
assert len(_finalize_calls(reply)) == 0, (
"keep-alive off: no finalize frame should reach the wire"
)
assert CHAT_ID in adapter._stream_expired_chats
assert f"{CHAT_ID}:{TURN_ID}" not in adapter._stream_turns
finally:
await adapter.disconnect()
@pytest.mark.asyncio
async def test_keepalive_on_truly_expired_stream_falls_back(self):
"""Even with the clock decline skipped, a genuinely dead stream is safe.
Keep-alive on skips the blind clock check, but if the stream really has
expired the finalize ``_send_stream_reply(finish=True)`` hits 846608 and
raises ``WeComStreamExpiredError`` — the existing except path then
retires the turn and returns False for the (correct, finalize-only)
fallback. This shows Fix A does not lose the real-expiry safety net.
"""
adapter = _make_adapter(keepalive_enabled=True)
try:
async def _reply(req_id, stream_id, content, finish=False):
if finish:
raise WeComStreamExpiredError(errcode=STREAM_EXPIRED_ERRCODE)
return {"errcode": 0}
adapter._send_stream_reply = AsyncMock(side_effect=_reply)
await adapter._send_stream_frame_inner(
"partial answer", chat=CHAT_ID, finalize=False, turn_id=TURN_ID,
)
turn = adapter._stream_turns[f"{CHAT_ID}:{TURN_ID}"]
turn.start_time -= adapter._stream_safe_duration_seconds + 500
ok = await adapter._send_stream_frame_inner(
"the complete final answer",
chat=CHAT_ID, finalize=True, turn_id=TURN_ID,
)
assert ok is False, "real 846608 on finalize must fall back"
assert CHAT_ID in adapter._stream_expired_chats
assert f"{CHAT_ID}:{TURN_ID}" not in adapter._stream_turns
finally:
await adapter.disconnect()
# ===========================================================================
# Fix B — intermediate failures are fire-and-forget; only final falls back
# ===========================================================================
class TestIntermediateFrameFailureIsFireAndForget:
"""A single intermediate frame failing must not trip the consumer fallback
or kill the turn; only a final-frame failure does."""
@pytest.mark.asyncio
async def test_intermediate_expired_returns_true_keeps_turn(self):
"""FIX ENABLED: intermediate finish=False hits 846608.
Contract: return True (no fallback), turn survives in the registry
(keep-alive keeps refreshing), chat NOT marked expired. A later
cumulative frame will overwrite the dropped one.
"""
adapter = _make_adapter(keepalive_enabled=True)
try:
# Seed succeeds; the next intermediate content frame expires.
calls = {"n": 0}
async def _reply(req_id, stream_id, content, finish=False):
calls["n"] += 1
# First call is the seed ("<think></think>"); let it succeed so
# the turn opens, then expire the real content frame.
if calls["n"] >= 2 and not finish:
raise WeComStreamExpiredError(errcode=STREAM_EXPIRED_ERRCODE)
return {"errcode": 0}
adapter._send_stream_reply = AsyncMock(side_effect=_reply)
# Send a non-trivial body so a real content frame is drained
# (fire-and-forget: any content differing from the last frame).
ok = await adapter._send_stream_frame_inner(
BLOCK_TEXT,
chat=CHAT_ID, finalize=False, turn_id=TURN_ID,
)
assert ok is True, "intermediate expiry must be fire-and-forget"
assert f"{CHAT_ID}:{TURN_ID}" in adapter._stream_turns, (
"intermediate failure must NOT retire the turn — keep-alive is "
"still refreshing the live stream"
)
turn = adapter._stream_turns[f"{CHAT_ID}:{TURN_ID}"]
assert turn.expired is False
assert CHAT_ID not in adapter._stream_expired_chats
finally:
await adapter.disconnect()
@pytest.mark.asyncio
async def test_intermediate_generic_exception_returns_true_keeps_turn(self):
"""Same fire-and-forget contract for a generic (non-expiry) exception on
an intermediate frame — the whole except class is fixed, not just the
WeComStreamExpiredError path."""
adapter = _make_adapter(keepalive_enabled=True)
try:
calls = {"n": 0}
async def _reply(req_id, stream_id, content, finish=False):
calls["n"] += 1
if calls["n"] >= 2 and not finish:
raise RuntimeError("transient wire error")
return {"errcode": 0}
adapter._send_stream_reply = AsyncMock(side_effect=_reply)
ok = await adapter._send_stream_frame_inner(
BLOCK_TEXT,
chat=CHAT_ID, finalize=False, turn_id=TURN_ID,
)
assert ok is True
assert f"{CHAT_ID}:{TURN_ID}" in adapter._stream_turns
assert CHAT_ID not in adapter._stream_expired_chats
finally:
await adapter.disconnect()
@pytest.mark.asyncio
async def test_final_expired_returns_false_falls_back(self):
"""FIX-INVARIANT (toggle via finalize flag): a final finish=True frame
that expires MUST return False, retire the turn, and mark the chat
expired — the screen is genuinely missing this content, so the consumer
must run its send() fallback. Contrast with the intermediate case above
proves the fix discriminates on finalize, not blanket-swallows."""
adapter = _make_adapter(keepalive_enabled=True)
try:
async def _reply(req_id, stream_id, content, finish=False):
if finish:
raise WeComStreamExpiredError(errcode=STREAM_EXPIRED_ERRCODE)
return {"errcode": 0}
adapter._send_stream_reply = AsyncMock(side_effect=_reply)
await adapter._send_stream_frame_inner(
"partial answer", chat=CHAT_ID, finalize=False, turn_id=TURN_ID,
)
ok = await adapter._send_stream_frame_inner(
"the complete final answer",
chat=CHAT_ID, finalize=True, turn_id=TURN_ID,
)
assert ok is False, "final-frame expiry MUST fall back (return False)"
assert CHAT_ID in adapter._stream_expired_chats
assert f"{CHAT_ID}:{TURN_ID}" not in adapter._stream_turns
finally:
await adapter.disconnect()
@pytest.mark.asyncio
async def test_final_generic_exception_returns_false_retires(self):
"""A generic exception on the final frame also returns False + retires
the turn (consumer fallback)."""
adapter = _make_adapter(keepalive_enabled=True)
try:
async def _reply(req_id, stream_id, content, finish=False):
if finish:
raise RuntimeError("wire down on finalize")
return {"errcode": 0}
adapter._send_stream_reply = AsyncMock(side_effect=_reply)
await adapter._send_stream_frame_inner(
"partial answer", chat=CHAT_ID, finalize=False, turn_id=TURN_ID,
)
ok = await adapter._send_stream_frame_inner(
"the complete final answer",
chat=CHAT_ID, finalize=True, turn_id=TURN_ID,
)
assert ok is False
assert f"{CHAT_ID}:{TURN_ID}" not in adapter._stream_turns
finally:
await adapter.disconnect()