29033a3fd5
* fix(relay): map wire media[] → event.media_types; accept message_type voice A relayed voice note arrived as MessageType.AUDIO with media_types=[] — the STT gate (_event_media_is_stt_input) excludes AUDIO unconditionally and its per-attachment MIME rescue was unreachable, so STT never fired and the agent fell back to the "user sent an audio file attachment" context note (live-verified on staging 2026-08-26, Discord + Telegram). Two wire-boundary fixes, both additive within contract_version 1: - "voice" parses to MessageType.VOICE: the enum already had it — pinned by test so a future refactor can't collapse the two. - media[] is now mapped into event.media_types (positional alignment with media_urls; mime-less entries keep their slot as ""). This is what run.py's per-attachment classifiers key off, so EVERY relayed attachment — image vs document, audio vs voice — now routes like its native-adapter equivalent, not just voice notes. Behaviour pinned: new-connector voice → STT-eligible; legacy audio-typed events unchanged (no STT); music uploads never STT-eligible (direct _event_media_is_stt_input assertions on real wire-parsed events, not mocks). Pairs with the gateway-gateway PR that puts "voice" on the wire. * review: pin the STT gate by test; fail safe on media/media_urls mismatch Addresses independent review of #95274. 1. The PR's acceptance criterion is STT ROUTING, but no committed test called _event_media_is_stt_input — it was only asserted ad-hoc. Adds TestSttGate: voice→eligible, voice-without-media_types→eligible (the new-connector/old-gateway shape), legacy audio-typed voice note→not eligible, music→not eligible. Mutation-verified: removing the VOICE branch from the gate turns these RED. 2. media_urls and media[] are INDEPENDENT wire fields that consumers index by the same i. Mapping MIMEs positionally without checking agreement means a disagreeing producer misassociates a MIME with the wrong URL and mis-routes that attachment — strictly worse than no MIME, which degrades safely to message-level classification. _media_types_from_wire() now maps only when the lengths agree, warns and returns [] otherwise. Note for the record: MessageType.VOICE predates this PR and the gate's VOICE branch ignores media_types, so a NEW connector against an OLD gateway ALREADY fires STT. That is desirable, but it is not "unchanged" — the PR body's rollout matrix said otherwise and is corrected. * fix(relay): send a User-Agent on relay media requests (Discord CDN 403) Discord's CDN rejects urllib's default "Python-urllib/x.y" User-Agent with HTTP 403, and RelayMediaClient never set one. Every Discord CDN pass-through download therefore failed; _localize_inbound_media then kept the raw URL (its "a public URL still has value" branch), and the consumer tried to open a URL as a FILE PATH: WARNING gateway.relay.media: relay media download failed for https://cdn.discordapp.com/...voice-message.ogg: HTTP Error 403 INFO gateway.run: Voice transcription failed for https://cdn.discord... : Audio file not found: https://cdn.discordapp.com/... This killed ALL Discord relay media inbound — voice notes, images and documents alike — not just the voice lane. Telegram/WhatsApp were unaffected because their media is connector-re-hosted (/relay/media/{id}, fetched from our own host) and localizes to real /tmp paths. Reproduced from a clean shell against a live CDN URL: curl (own UA) -> 200 urllib, no UA -> 403 Forbidden urllib + descriptive UA -> 200, 14583 bytes, OggS magic Fix: a module-level _MEDIA_USER_AGENT sent on both download() and upload(). upload() only ever targets our own connector so it was not broken, but a single client should identify itself consistently. Validated on staging: hot-patched hermes-agent-stg-test-6698, restarted the gateway service, and Ben's Discord voice note transcribed successfully — zero new 403s and zero new transcription failures after the patch (last 403 predates it). Test is mutation-verified: removing the UA from download() turns it RED while the other five media tests stay green. * fix(relay): keep url↔mime pairing through media localization Addresses a blocking review finding on my own change: mapping media[] into media_types created a POSITIONAL contract that the rest of the inbound path then broke. 1. _localize_inbound_media (adapter.py) filtered media_urls without filtering media_types. Dropping a dead connector re-host is a NORMAL best-effort path, so every surviving attachment inherited its neighbour's mime. Reproduced through the real functions: before urls [.../relay/media/dead, .../kept.png] types [application/pdf, image/png] after urls [.../kept.png] types [application/pdf, image/png] <-- PNG reads as PDF _event_media_is_image(ev, 0) -> False The loop now carries (url, mime) as PAIRS, so a dropped URL drops its mime with it. 2. _media_types_from_wire compared LENGTHS only, which is not alignment: equal-length-but-reordered wire fields were accepted and paired wrongly, and an absent media_urls skipped the check entirely while still emitting types. Resolution is now BY URL (url -> mime lookup over media_urls); an unmatched URL degrades to "" and falls back to message-level classification. Tests: 4 new cases driving the real chain (wire parse -> localization -> run.py classifier), incl. the dropped-first-attachment case the existing localization test could not catch (it builds events without media_types). The obsolete length-mismatch test now asserts the stronger by-url guarantee. Both fixes mutation-verified: reinstating the URL-only filter fails 1 test, reverting to positional resolution fails 3. Relay suite 258 passed; media/voice/stt selection 685 passed; ruff clean; cross-repo integration payload re-verified. * fix(relay): media_types is always one slot per media_url Self-review after two review rounds flagged this bug class in adjacent seams: I checked the function I edited, not every consumer of the parallel arrays I created. Grepping ALL writers found a third instance. merge_pending_message_event (gateway/platforms/base.py:2725-2735) EXTENDS media_urls and media_types together when a second media message merges into a pending one. My mapping could emit a POPULATED media_urls with an EMPTY media_types (an older connector sends media_urls but no media[]), so extend() concatenated lists of different lengths: A urls [old1.png, old2.png] types [] B urls [new.pdf] types [application/pdf] merged urls [old1.png, old2.png, new.pdf] types [application/pdf] -> old1.png reads as application/pdf; the real PDF gets '' Fix: media_types is now ALWAYS len(media_urls), padded with '' — the url-keyed lookup runs even when media[] is absent, and the localizer rewrites the list unconditionally (no short-circuit that could leave a stale/short list behind). Tests: 4 new cases — padding with no media[], the merge shift above driven through the real merge_pending_message_event, localization preserving the invariant while dropping an entry, and normalization of a short/empty media_types arriving from a non-wire source. All mutation-verified: removing the padding fails 4; restoring the guard fails 1. Relay 262 passed; media/voice/stt selection 689 passed; ruff clean; cross-repo integration payload re-verified.
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""Unit tests for relay voice-note + per-attachment media-type mapping.
|
|
|
|
The relay contract gained ``message_type: "voice"`` (connector PR: voice
|
|
notes on Discord/Telegram/WhatsApp classify distinctly from audio-file
|
|
uploads). The gateway side has two obligations:
|
|
|
|
1. ``MessageType("voice")`` must parse — it already does, since the enum
|
|
predates the wire value — and
|
|
2. the connector's rich ``media[]`` array (each entry carries the
|
|
per-attachment ``mime``) must land on ``event.media_types`` so run.py's
|
|
per-attachment classifiers (``_event_media_is_stt_input``, image vs
|
|
document routing) work for relayed events, not just native adapters.
|
|
|
|
Live-verified failure (staging 2026-08-26): a voice note arrived as
|
|
``MessageType.AUDIO`` with ``media_types == []`` — STT never fired and the
|
|
agent got a "the user sent an audio file attachment" note instead.
|
|
|
|
Pure unit tests: no socket, no websockets dependency.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from gateway.platforms.base import MessageType
|
|
from gateway.relay.ws_transport import _event_from_wire
|
|
|
|
|
|
def _wire_event(message_type: str, **extra):
|
|
src = {
|
|
"platform": "discord",
|
|
"chat_id": "chan-1",
|
|
"chat_type": "dm",
|
|
"user_id": "u-1",
|
|
"user_name": "ben",
|
|
}
|
|
return {
|
|
"text": "",
|
|
"message_type": message_type,
|
|
"source": src,
|
|
**extra,
|
|
}
|
|
|
|
|
|
class TestVoiceMessageType:
|
|
|
|
def test_wire_voice_parses_to_message_type_voice(self):
|
|
ev = _event_from_wire(_wire_event("voice"))
|
|
assert ev.message_type == MessageType.VOICE
|
|
|
|
def test_wire_audio_still_parses_to_message_type_audio(self):
|
|
"""Music files keep the non-STT type — the connector must not have
|
|
collapsed them (it doesn't), and the gateway must not either."""
|
|
ev = _event_from_wire(_wire_event("audio"))
|
|
assert ev.message_type == MessageType.AUDIO
|
|
|
|
|
|
class TestMediaTypesMapping:
|
|
|
|
def test_media_mimes_land_on_event_media_types(self):
|
|
media = [
|
|
{
|
|
"url": "https://cdn.discordapp.com/attachments/1/2/voice-message.ogg",
|
|
"kind": "audio",
|
|
"mime": "audio/ogg",
|
|
"size": 19197,
|
|
"filename": "voice-message.ogg",
|
|
},
|
|
]
|
|
ev = _event_from_wire(
|
|
_wire_event("voice", media=media, media_urls=[m["url"] for m in media])
|
|
)
|
|
assert ev.media_types == ["audio/ogg"]
|
|
# media_urls must remain the parallel legacy field (unchanged).
|
|
assert ev.media_urls == [media[0]["url"]]
|
|
|
|
def test_media_types_parallel_to_media_urls_for_multiple_attachments(self):
|
|
media = [
|
|
{"url": "https://x/photo.png", "kind": "image", "mime": "image/png"},
|
|
{"url": "https://x/doc.pdf", "kind": "document", "mime": "application/pdf"},
|
|
]
|
|
ev = _event_from_wire(
|
|
_wire_event("image", media=media, media_urls=[m["url"] for m in media])
|
|
)
|
|
assert ev.media_types == ["image/png", "application/pdf"]
|
|
assert len(ev.media_types) == len(ev.media_urls)
|
|
|
|
def test_missing_mime_gives_empty_string_at_that_index(self):
|
|
"""A media entry without a mime must not shift the alignment between
|
|
media_urls[i] and media_types[i] — run.py indexes both by position."""
|
|
media = [
|
|
{"url": "https://x/photo.png", "kind": "image", "mime": "image/png"},
|
|
{"url": "https://x/blob", "kind": "document"}, # no mime
|
|
]
|
|
ev = _event_from_wire(
|
|
_wire_event("image", media=media, media_urls=[m["url"] for m in media])
|
|
)
|
|
assert ev.media_types == ["image/png", ""]
|
|
|
|
def test_no_media_field_means_empty_media_types(self):
|
|
"""Older connectors (no media[]) — byte-identical to pre-fix."""
|
|
ev = _event_from_wire(_wire_event("text"))
|
|
assert ev.media_types == []
|
|
|
|
def test_length_mismatch_resolves_by_url_without_misaligning(self):
|
|
"""media_urls and media[] are independent wire fields; consumers index
|
|
BOTH by the same i. Resolution is BY URL, so a length disagreement can
|
|
no longer misalign anything: each surviving URL keeps its own mime and
|
|
an unmatched URL degrades to "" (message-level classification)."""
|
|
media = [
|
|
{"url": "https://x/a.png", "kind": "image", "mime": "image/png"},
|
|
{"url": "https://x/b.pdf", "kind": "document", "mime": "application/pdf"},
|
|
]
|
|
ev = _event_from_wire(
|
|
_wire_event("image", media=media, media_urls=["https://x/a.png"])
|
|
)
|
|
assert ev.media_urls == ["https://x/a.png"]
|
|
assert ev.media_types == ["image/png"]
|
|
|
|
|
|
class TestSttGate:
|
|
"""Direct assertions on run.py's STT gate against REAL wire-parsed
|
|
events. The PR's acceptance criterion is STT routing, not object
|
|
construction — pin the user-visible invariant here.
|
|
|
|
Note the deployment-ordering consequence: MessageType.VOICE parses on
|
|
ANY gateway (the enum predates this PR) and the gate accepts VOICE with
|
|
empty media_types, so a NEW connector + OLD gateway already fires STT
|
|
for voice notes. Desirable — but it means the gate's behaviour is part
|
|
of the wire contract, and it must not silently change."""
|
|
|
|
def test_new_connector_voice_event_is_stt_eligible(self):
|
|
from gateway.run import _event_media_is_stt_input
|
|
|
|
ev = _event_from_wire(
|
|
_wire_event(
|
|
"voice",
|
|
media=[{"url": "https://gw/relay/media/x", "kind": "voice", "mime": "audio/ogg"}],
|
|
media_urls=["https://gw/relay/media/x"],
|
|
)
|
|
)
|
|
assert _event_media_is_stt_input(ev, 0) is True
|
|
|
|
def test_voice_event_stt_eligible_even_without_media_types(self):
|
|
"""A new-connector/old-gateway-shaped event — voice type, no
|
|
media[] — still fires STT (the gate's VOICE branch doesn't consult
|
|
media_types). Pins the rollout behaviour, not just the new one."""
|
|
from gateway.run import _event_media_is_stt_input
|
|
|
|
ev = _event_from_wire(
|
|
_wire_event("voice", media_urls=["https://gw/relay/media/x"])
|
|
)
|
|
# No media[] ⇒ no per-attachment mime, but the slot still exists so the
|
|
# parallel-array invariant holds (see TestParallelArrayLengthInvariant).
|
|
assert ev.media_types == [""]
|
|
assert _event_media_is_stt_input(ev, 0) is True
|
|
|
|
def test_legacy_audio_typed_voice_note_stays_out_of_stt(self):
|
|
from gateway.run import _event_media_is_stt_input
|
|
|
|
ev = _event_from_wire(
|
|
_wire_event(
|
|
"audio",
|
|
media=[{"url": "https://gw/relay/media/x", "kind": "voice", "mime": "audio/ogg"}],
|
|
media_urls=["https://gw/relay/media/x"],
|
|
)
|
|
)
|
|
assert _event_media_is_stt_input(ev, 0) is False
|
|
|
|
def test_music_upload_is_never_stt_eligible(self):
|
|
from gateway.run import _event_media_is_stt_input
|
|
|
|
ev = _event_from_wire(
|
|
_wire_event(
|
|
"audio",
|
|
media=[{"url": "https://cdn/x/song.mp3", "kind": "audio", "mime": "audio/mpeg"}],
|
|
media_urls=["https://cdn/x/song.mp3"],
|
|
)
|
|
)
|
|
assert _event_media_is_stt_input(ev, 0) is False
|
|
|
|
class TestUrlMimePairingThroughLocalization:
|
|
"""The end-to-end invariant my unit tests originally missed.
|
|
|
|
media_urls and media_types are indexed BY POSITION by every downstream
|
|
classifier. _localize_inbound_media drops entries (a dead connector
|
|
re-host) as a NORMAL best-effort path — so if it filters URLs without
|
|
filtering MIMEs in lockstep, every surviving attachment inherits a
|
|
neighbour's type. Drive the REAL path: wire parse -> localization ->
|
|
run.py classifier."""
|
|
|
|
def _adapter(self):
|
|
from gateway.relay.adapter import RelayAdapter
|
|
|
|
return RelayAdapter.__new__(RelayAdapter)
|
|
|
|
def test_dropped_first_attachment_does_not_shift_the_second_mime(self):
|
|
import asyncio
|
|
|
|
from gateway.run import _event_media_is_image, _event_media_type_at
|
|
|
|
rehost = "https://conn.example/relay/media/dead"
|
|
kept = "https://cdn.discordapp.com/attachments/1/2/kept.png"
|
|
ev = _event_from_wire(
|
|
_wire_event(
|
|
"document",
|
|
media=[
|
|
{"url": rehost, "kind": "document", "mime": "application/pdf"},
|
|
{"url": kept, "kind": "image", "mime": "image/png"},
|
|
],
|
|
media_urls=[rehost, kept],
|
|
)
|
|
)
|
|
assert ev.media_types == ["application/pdf", "image/png"]
|
|
|
|
adapter = self._adapter()
|
|
adapter._media_client = None
|
|
adapter._get_media_client = lambda: None # type: ignore[method-assign]
|
|
asyncio.run(adapter._localize_inbound_media(ev))
|
|
|
|
# The dead re-host is dropped; the PNG must keep ITS OWN mime.
|
|
assert ev.media_urls == [kept]
|
|
assert ev.media_types == ["image/png"]
|
|
assert _event_media_type_at(ev, 0) == "image/png"
|
|
assert _event_media_is_image(ev, 0) is True
|
|
|
|
def test_reordered_media_vs_media_urls_resolves_by_url_not_position(self):
|
|
"""Equal-length but differently-ordered wire fields must not pair up
|
|
positionally — resolve each URL's mime by lookup."""
|
|
png = "https://x/a.png"
|
|
pdf = "https://x/b.pdf"
|
|
ev = _event_from_wire(
|
|
_wire_event(
|
|
"image",
|
|
media=[
|
|
{"url": pdf, "mime": "application/pdf"},
|
|
{"url": png, "mime": "image/png"},
|
|
],
|
|
media_urls=[png, pdf],
|
|
)
|
|
)
|
|
assert ev.media_types == ["image/png", "application/pdf"]
|
|
|
|
def test_url_with_no_matching_media_entry_gets_empty_mime(self):
|
|
known = "https://x/a.png"
|
|
orphan = "https://x/unknown.bin"
|
|
ev = _event_from_wire(
|
|
_wire_event(
|
|
"image",
|
|
media=[{"url": known, "mime": "image/png"}],
|
|
media_urls=[known, orphan],
|
|
)
|
|
)
|
|
assert ev.media_types == ["image/png", ""]
|
|
|
|
def test_media_without_media_urls_yields_no_types(self):
|
|
"""No URL list to align to ⇒ the indices are meaningless."""
|
|
ev = _event_from_wire(
|
|
_wire_event("image", media=[{"url": "https://x/a.png", "mime": "image/png"}])
|
|
)
|
|
assert ev.media_urls == []
|
|
assert ev.media_types == []
|
|
|
|
class TestParallelArrayLengthInvariant:
|
|
"""media_types must ALWAYS have one slot per media_url.
|
|
|
|
Not merely an indexing nicety: ``merge_pending_message_event``
|
|
(gateway/platforms/base.py) EXTENDS both lists when a second media message
|
|
is merged into a pending one. If a media_types-less event merges with a
|
|
typed one, extend() concatenates lists of different lengths and shifts
|
|
every later mime onto the wrong url. Found by self-review after two
|
|
rounds of external review flagged this same bug class in adjacent seams."""
|
|
|
|
def test_media_urls_without_media_still_get_one_empty_slot_each(self):
|
|
"""An older connector sends media_urls with no media[]. Padding keeps
|
|
the invariant instead of emitting a length-0 media_types."""
|
|
ev = _event_from_wire(
|
|
_wire_event("image", media_urls=["https://x/a.png", "https://x/b.png"])
|
|
)
|
|
assert ev.media_types == ["", ""]
|
|
assert len(ev.media_types) == len(ev.media_urls)
|
|
|
|
def test_merging_an_untyped_event_with_a_typed_one_keeps_mimes_on_their_urls(self):
|
|
from gateway.platforms.base import merge_pending_message_event
|
|
from gateway.run import _event_media_type_at
|
|
|
|
untyped = _event_from_wire(
|
|
_wire_event("image", media_urls=["https://x/old1.png", "https://x/old2.png"])
|
|
)
|
|
typed = _event_from_wire(
|
|
_wire_event(
|
|
"document",
|
|
media=[{"url": "https://x/new.pdf", "mime": "application/pdf"}],
|
|
media_urls=["https://x/new.pdf"],
|
|
)
|
|
)
|
|
pending = {"k": untyped}
|
|
merge_pending_message_event(pending, "k", typed)
|
|
merged = pending["k"]
|
|
|
|
assert len(merged.media_types) == len(merged.media_urls)
|
|
# The PDF's mime must stay on the PDF, not slide onto old1.png.
|
|
assert _event_media_type_at(merged, 0) == ""
|
|
assert _event_media_type_at(merged, 1) == ""
|
|
assert _event_media_type_at(merged, 2) == "application/pdf"
|
|
|
|
def test_localization_preserves_the_invariant_when_it_drops_entries(self):
|
|
import asyncio
|
|
|
|
from gateway.relay.adapter import RelayAdapter
|
|
|
|
rehost = "https://conn.example/relay/media/dead"
|
|
kept = "https://x/kept.png"
|
|
ev = _event_from_wire(
|
|
_wire_event("image", media_urls=[rehost, kept]) # no media[] at all
|
|
)
|
|
assert ev.media_types == ["", ""]
|
|
|
|
adapter = RelayAdapter.__new__(RelayAdapter)
|
|
adapter._media_client = None
|
|
adapter._get_media_client = lambda: None # type: ignore[method-assign]
|
|
asyncio.run(adapter._localize_inbound_media(ev))
|
|
|
|
assert ev.media_urls == [kept]
|
|
assert len(ev.media_types) == len(ev.media_urls)
|
|
|
|
def test_localization_normalizes_a_short_media_types_from_any_source(self):
|
|
"""Defence in depth: an event that reaches the localizer with a SHORT
|
|
media_types (not produced by _event_from_wire — e.g. a synthetic or
|
|
replayed event) must come out with one slot per surviving url, not
|
|
with the short list passed through."""
|
|
import asyncio
|
|
|
|
from gateway.platforms.base import MessageEvent, MessageType
|
|
from gateway.relay.adapter import RelayAdapter
|
|
|
|
ev = MessageEvent(text="", message_type=MessageType.PHOTO)
|
|
ev.media_urls = ["https://x/a.png", "https://x/b.png"]
|
|
ev.media_types = [] # empty, while urls exist — the shape that must
|
|
# NOT be passed through: consumers would index/merge against a
|
|
# zero-length mime list and shift every later entry.
|
|
|
|
adapter = RelayAdapter.__new__(RelayAdapter)
|
|
adapter._media_client = None
|
|
adapter._get_media_client = lambda: None # type: ignore[method-assign]
|
|
asyncio.run(adapter._localize_inbound_media(ev))
|
|
|
|
assert ev.media_urls == ["https://x/a.png", "https://x/b.png"]
|
|
assert ev.media_types == ["", ""]
|
|
assert len(ev.media_types) == len(ev.media_urls)
|