aac36fd96f
Eight adapters (discord, telegram, wecom, matrix, whatsapp, simplex, feishu, weixin) each kept a copy of the delayed text-batch flush that base._enqueue_text_event schedules. Two correctness fixes had landed in single copies only: Discord's asyncio.shield around the dispatch (#12444 — a late chunk cancelling the flush task aborted the in-flight agent turn) and WeCom/Weixin's synchronous task-identity check before the pop (a superseded task waking late popped the event and the successor found nothing). The other adapters carried both bugs latent. The base now owns `_flush_text_batch` with both fixes, plus the `_pending_text_batches` / `_pending_text_batch_tasks` dicts and `_SPLIT_THRESHOLD` / delay attrs (defaults; adapters set their own). Platform policy goes through three small hooks instead of a copied body: `_text_batch_delay_for(pending)` (Telegram's fast/short tiers, WeCom's attachment-only wait), `_pop_text_batch(key)` (Feishu's side count table) and `_dispatch_text_batch(event)` (Feishu's per-chat lock). Telegram keeps its `_flush_buffered` body because its contract differs on purpose — a cancel after the pop must hold-and-re-raise so teardown can stop a flush — and gains the identity check there. Matrix's `_split_threshold` is renamed to the shared `_SPLIT_THRESHOLD`; SimpleX exposes its single delay through the shared attr names. `helpers.TextBatchAggregator` (zero users) sits inside the revert-scheduled PLUGIN-COMPAT block and is left for that revert.
96 lines
3.7 KiB
Python
96 lines
3.7 KiB
Python
"""Invariants for BasePlatformAdapter's inbound text-batch flush.
|
|
|
|
Every adapter that batches inbound text used to carry its own ``_flush_text_batch``; only Discord
|
|
shielded the dispatch (#12444) and only WeCom/Weixin checked task identity before the pop. Both
|
|
fixes now live in the base flush; these tests pin them against the base class and sweep every
|
|
adapter that still overrides the flush (Telegram keeps a hold-queue variant) for the same contract.
|
|
"""
|
|
|
|
import asyncio
|
|
from typing import Any, Dict
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import BasePlatformAdapter
|
|
from gateway.platforms.event import MessageEvent, MessageType
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
class _Adapter(BasePlatformAdapter):
|
|
def __init__(self):
|
|
super().__init__(PlatformConfig(enabled=True), Platform.TELEGRAM)
|
|
self._text_batch_delay_seconds = 0.0
|
|
self._text_batch_split_delay_seconds = 0.0
|
|
self.dispatched: list = []
|
|
self.entered = asyncio.Event()
|
|
self.release = asyncio.Event()
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
pass
|
|
|
|
async def send(self, *a: Any, **k: Any) -> None:
|
|
pass
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
return {}
|
|
|
|
async def handle_message(self, event: MessageEvent) -> None:
|
|
self.entered.set()
|
|
await self.release.wait() # a real agent turn awaits here
|
|
self.dispatched.append(event)
|
|
|
|
|
|
def _event(text: str) -> MessageEvent:
|
|
return MessageEvent(text=text, message_type=MessageType.TEXT,
|
|
source=SessionSource(platform=Platform.TELEGRAM, chat_id="c", chat_type="dm"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_mid_dispatch_does_not_abort_the_turn():
|
|
"""A cancel landing while handle_message is in flight (a late chunk re-arming the timer) must not
|
|
abort the dispatch: the batch is delivered exactly once and the task exits clean."""
|
|
adapter = _Adapter()
|
|
adapter._pending_text_batches["k"] = _event("hello")
|
|
task = asyncio.create_task(adapter._flush_text_batch("k"))
|
|
adapter._pending_text_batch_tasks["k"] = task
|
|
await adapter.entered.wait()
|
|
task.cancel()
|
|
adapter.release.set()
|
|
await task # no CancelledError escapes
|
|
assert [e.text for e in adapter.dispatched] == ["hello"]
|
|
assert adapter._pending_text_batch_tasks == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_superseded_flush_leaves_batch_for_its_successor():
|
|
"""When a newer flush task owns the key by the time the old one wakes, the old one must neither
|
|
pop nor dispatch — otherwise the successor finds an empty batch and the message is lost."""
|
|
adapter = _Adapter()
|
|
adapter.release.set()
|
|
event = _event("hello")
|
|
adapter._pending_text_batches["k"] = event
|
|
stale = asyncio.create_task(adapter._flush_text_batch("k"))
|
|
adapter._pending_text_batch_tasks["k"] = stale
|
|
successor = asyncio.create_task(asyncio.sleep(60))
|
|
adapter._pending_text_batch_tasks["k"] = successor # re-armed before `stale` ran
|
|
await stale
|
|
successor.cancel()
|
|
assert adapter.dispatched == []
|
|
assert adapter._pending_text_batches.get("k") is event
|
|
assert adapter._pending_text_batch_tasks.get("k") is successor
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enqueue_then_flush_delivers_merged_text_once():
|
|
adapter = _Adapter()
|
|
adapter.release.set()
|
|
adapter._enqueue_text_event(_event("part one"))
|
|
adapter._enqueue_text_event(_event("part two"))
|
|
await asyncio.gather(*adapter._pending_text_batch_tasks.values())
|
|
assert [e.text for e in adapter.dispatched] == ["part one\npart two"]
|
|
assert adapter._pending_text_batches == {} and adapter._pending_text_batch_tasks == {}
|