Files
hermes-agent/tests/gateway/relay/test_relay_inbound_dedupe.py
T
Ben Barclay c60a525380 fix(relay): dedupe key platform component is spelling-invariant
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.
2026-08-20 15:26:31 +10:00

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(":")