232 lines
8.6 KiB
Python
232 lines
8.6 KiB
Python
"""Inbound replay dedupe on the relay adapter (transplanted from the
|
|
live-cards branch for the rc.4 relay-fixes train).
|
|
|
|
Live-canary finding #3 (Alice, staging): the relay inbound leg is
|
|
at-least-once. On WS re-handshake the connector replays its durable
|
|
per-instance buffer; a long multi-tool turn straddling a quiet socket drop
|
|
got its ORIGINAL inbound replayed after the turn finished, re-running the
|
|
entire turn — the user saw the final answer posted 2-5x. Platform message
|
|
identity (chat_id + message_id/ts) is stable across replays, so a bounded
|
|
seen-set drops them. Fail-open: events without a message_id never dedupe
|
|
(dropping a real message is strictly worse than rerunning one).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import MessageEvent, SessionSource
|
|
from gateway.relay.adapter import RelayAdapter
|
|
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
|
|
from tests.gateway.relay.stub_connector import StubConnector
|
|
|
|
|
|
def make_desc(**kw) -> CapabilityDescriptor:
|
|
base = dict(
|
|
contract_version=CONTRACT_VERSION,
|
|
platform="slack",
|
|
label="Slack",
|
|
max_message_length=39000,
|
|
supports_draft_streaming=True,
|
|
supports_edit=True,
|
|
supports_threads=True,
|
|
markdown_dialect="slack",
|
|
len_unit="chars",
|
|
emoji="\U0001f4ac",
|
|
platform_hint="",
|
|
pii_safe=False,
|
|
supported_ops=("send", "edit", "typing"),
|
|
)
|
|
base.update(kw)
|
|
return CapabilityDescriptor(**base)
|
|
|
|
|
|
def _connected_adapter(**desc_kw):
|
|
desc = make_desc(**desc_kw)
|
|
stub = StubConnector(desc)
|
|
adapter = RelayAdapter(PlatformConfig(), desc, transport=stub)
|
|
return adapter, stub
|
|
|
|
|
|
@pytest.fixture()
|
|
def loop():
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
yield loop
|
|
loop.close()
|
|
|
|
|
|
def _record(bucket, event):
|
|
async def _coro():
|
|
bucket.append(event)
|
|
return _coro()
|
|
|
|
|
|
async def _false_coro():
|
|
return False
|
|
|
|
|
|
async def _none_coro():
|
|
return None
|
|
|
|
|
|
class TestInboundReplayDedupe:
|
|
"""Finding #3 (live canary): connector replay of the original inbound
|
|
after a WS re-handshake must not re-run the turn."""
|
|
|
|
def _event(self, message_id="1700.100", chat_id="C1", text="hi"):
|
|
# A REAL MessageEvent, shaped exactly as _event_from_wire produces it:
|
|
# chat identity lives on event.source, NOT as a top-level attribute.
|
|
# (The first version of these tests used a SimpleNamespace with a
|
|
# top-level chat_id — a shape no production code path produces — and
|
|
# green-lit a dedupe key that read the wrong field.)
|
|
source = SessionSource(
|
|
platform=Platform.SLACK,
|
|
chat_id=chat_id,
|
|
chat_type="channel",
|
|
user_id="U1",
|
|
message_id=message_id,
|
|
)
|
|
return MessageEvent(text=text, source=source, message_id=message_id)
|
|
|
|
def _tap(self, adapter, handled):
|
|
adapter.handle_message = lambda e: _record(handled, e)
|
|
adapter._consume_prompt_response = lambda e: _false_coro()
|
|
adapter._localize_inbound_media = lambda e: _none_coro()
|
|
|
|
def test_replayed_inbound_dropped(self, loop):
|
|
adapter, _ = _connected_adapter()
|
|
handled = []
|
|
self._tap(adapter, handled)
|
|
e = self._event()
|
|
loop.run_until_complete(adapter._on_inbound(e))
|
|
loop.run_until_complete(adapter._on_inbound(e)) # replay
|
|
assert len(handled) == 1
|
|
|
|
def test_distinct_messages_both_handled(self, loop):
|
|
adapter, _ = _connected_adapter()
|
|
handled = []
|
|
self._tap(adapter, handled)
|
|
loop.run_until_complete(adapter._on_inbound(self._event("1700.100")))
|
|
loop.run_until_complete(adapter._on_inbound(self._event("1700.200")))
|
|
assert len(handled) == 2
|
|
|
|
def test_missing_message_id_fails_open(self, loop):
|
|
adapter, _ = _connected_adapter()
|
|
handled = []
|
|
self._tap(adapter, handled)
|
|
e = self._event(message_id=None)
|
|
loop.run_until_complete(adapter._on_inbound(e))
|
|
loop.run_until_complete(adapter._on_inbound(e))
|
|
assert len(handled) == 2 # never dedupe without identity
|
|
|
|
def test_seen_set_bounded(self, loop):
|
|
adapter, _ = _connected_adapter()
|
|
adapter.handle_message = lambda e: _none_coro()
|
|
adapter._consume_prompt_response = lambda e: _false_coro()
|
|
adapter._localize_inbound_media = lambda e: _none_coro()
|
|
for i in range(600):
|
|
loop.run_until_complete(adapter._on_inbound(self._event(f"ts.{i}")))
|
|
assert len(adapter._seen_inbound) <= adapter._SEEN_INBOUND_MAX
|
|
|
|
|
|
class TestWireLevelReplayDedupe:
|
|
"""The full production inbound path: a connector wire frame decoded by
|
|
_event_from_wire, then dispatched through RelayAdapter._on_inbound.
|
|
|
|
This is the layer the hand-built-event tests above cannot vouch for: the
|
|
dedupe key must work on the exact object shape the wire decoder emits.
|
|
The original dedupe commit shipped green on hand-built events while being
|
|
a no-op on decoded ones — this class exists so that can't recur.
|
|
"""
|
|
|
|
WIRE = {
|
|
"text": "hi",
|
|
"message_type": "text",
|
|
"message_id": "1700.100",
|
|
"source": {
|
|
"platform": "slack",
|
|
"chat_id": "C1",
|
|
"chat_type": "channel",
|
|
"user_id": "U1",
|
|
"message_id": "1700.100",
|
|
},
|
|
}
|
|
|
|
def _tap(self, adapter, handled):
|
|
adapter.handle_message = lambda e: _record(handled, e)
|
|
adapter._consume_prompt_response = lambda e: _false_coro()
|
|
adapter._localize_inbound_media = lambda e: _none_coro()
|
|
|
|
def _decode(self, **overrides):
|
|
from gateway.relay.ws_transport import _event_from_wire
|
|
|
|
raw = {**self.WIRE, **overrides}
|
|
if "source" in overrides:
|
|
raw["source"] = {**self.WIRE["source"], **overrides["source"]}
|
|
return _event_from_wire(raw)
|
|
|
|
def test_decoded_event_yields_a_dedupe_key(self):
|
|
adapter, _ = _connected_adapter()
|
|
key = adapter._inbound_dedupe_key(self._decode())
|
|
assert key is not None, (
|
|
"the wire decoder's event shape must produce a dedupe key — "
|
|
"None here means the dedupe is fail-open for ALL production "
|
|
"traffic (the original ship-broken state)"
|
|
)
|
|
|
|
def test_replayed_wire_frame_dropped(self, loop):
|
|
adapter, _ = _connected_adapter()
|
|
handled = []
|
|
self._tap(adapter, handled)
|
|
loop.run_until_complete(adapter._on_inbound(self._decode()))
|
|
# The connector re-delivers the SAME frame on re-handshake; the
|
|
# decoder builds a fresh object each time, so identity must come
|
|
# from the key, not object identity.
|
|
loop.run_until_complete(adapter._on_inbound(self._decode()))
|
|
assert len(handled) == 1
|
|
|
|
def test_same_ids_on_different_platforms_not_conflated(self, loop):
|
|
# Phase 1.5 multiplex: one adapter fronts several platforms. Numeric
|
|
# chat/message ids can collide across platforms; both must dispatch.
|
|
adapter, _ = _connected_adapter()
|
|
handled = []
|
|
self._tap(adapter, handled)
|
|
loop.run_until_complete(adapter._on_inbound(self._decode()))
|
|
loop.run_until_complete(
|
|
adapter._on_inbound(self._decode(source={"platform": "discord"}))
|
|
)
|
|
assert len(handled) == 2
|
|
|
|
|
|
class TestDedupeKeyPlatformNormalization:
|
|
"""The platform component of the key must be spelling-invariant: a
|
|
Platform enum and its plain-string form are ONE platform (one key), and
|
|
two different string platforms must never collapse into a shared empty
|
|
component. Production wire decoding always yields the enum; alternate
|
|
event constructors may carry the string."""
|
|
|
|
def _key(self, platform):
|
|
adapter, _ = _connected_adapter()
|
|
source = SessionSource(
|
|
platform=platform, chat_id="C1", chat_type="channel", message_id="m1"
|
|
)
|
|
event = MessageEvent(text="hi", source=source, message_id="m1")
|
|
return adapter._inbound_dedupe_key(event)
|
|
|
|
def test_enum_and_string_spellings_produce_one_key(self):
|
|
assert self._key(Platform.SLACK) == self._key("slack")
|
|
|
|
def test_distinct_string_platforms_stay_distinct(self):
|
|
assert self._key("slack") != self._key("discord")
|
|
|
|
def test_missing_platform_still_yields_a_key(self):
|
|
# Fail-open on identity is reserved for missing message/chat ids;
|
|
# a missing platform alone must not disable dedupe.
|
|
key = self._key(None)
|
|
assert key is not None
|
|
assert key.startswith(":")
|