Files
hermes-agent/tests/gateway/relay/test_wire_voice_media.py
T
Ben Barclay 29033a3fd5 fix(relay): restore voice-note STT — wire media[] MIMEs, message_type "voice", and a User-Agent for CDN downloads (#95274)
* 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.
2026-08-27 15:07:38 +10:00

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)