c60a525380
The key derived its platform component with getattr(platform, 'value', ''), which handles the Platform enum the wire decoder always produces but collapses a plain-string platform — or a missing one — to the same empty string. Two DIFFERENT string platforms would then share one key component (cross-platform id collisions conflate), and enum vs string spellings of the SAME platform would produce two keys (a replay decoded differently would not dedupe). Inert on today's wire path (the decoder canonicalizes unknowns to Platform.RELAY), but alternate event constructors carry strings, so normalize at the key: enum value when present, the string itself otherwise, empty only when there is genuinely no platform. Missing platform intentionally still yields a key — fail-open on identity is reserved for missing message/chat ids. Tests pin all three properties; the spelling-invariance pair fails against the previous expression.
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(":")
|