331 lines
14 KiB
Python
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()
|