Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user