hermes-agent/tests/gateway/test_streaming_tts_consumer.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

675 lines
24 KiB
Python

"""Tests for the gateway streaming-TTS consumer and adapter contract (#60671).
No live audio, network, or TTS SDK calls: the streaming provider, adapter,
and event loop are all faked. Covers the adapter contract defaults, the
consumer lifecycle (begin/write/finish/abort), fallback safety, duplicate
suppression, cancellation idempotency, and concurrent-turn isolation.
"""
from __future__ import annotations
import asyncio
import queue
import threading
import time
import pytest
from gateway.platforms.base import AudioFormat, StreamingTTSHandle
from gateway.streaming_tts_consumer import StreamingTTSConsumer
from tools.tts_streaming import SentenceChunker
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class FakeStreamer:
"""Fake streaming provider that yields deterministic PCM chunks."""
def __init__(self, chunks_per_clause=3, fail_on_clause=None, sample_rate=24000, channels=1, sample_width=2):
self.chunks_per_clause = chunks_per_clause
self.fail_on_clause = fail_on_clause
self.sample_rate = sample_rate
self.channels = channels
self.sample_width = sample_width
self._clause_count = 0
def stream(self, text: str):
self._clause_count += 1
if self.fail_on_clause and self._clause_count >= self.fail_on_clause:
raise RuntimeError(f"fake streamer failure on clause {self._clause_count}")
for i in range(self.chunks_per_clause):
yield f"chunk-{self._clause_count}-{i}".encode()
class FakeVoiceAdapter:
"""Fake adapter that accepts streaming TTS."""
def __init__(self, name="fake-voice", supports=True, fail_after_write=False):
self.name = name
self._supports = supports
self._fail_after_write = fail_after_write
self.handle = None
self.written_chunks: list[bytes] = []
self.begin_count = 0
self.finish_count = 0
self.abort_count = 0
def _should_auto_tts_for_chat(self, chat_id):
return True
def supports_streaming_tts(self, chat_id, audio_format):
return self._supports
async def begin_streaming_tts(self, chat_id, audio_format, metadata=None):
self.begin_count += 1
if not self._supports:
return None
self.handle = StreamingTTSHandle(chat_id=chat_id, audio_format=audio_format)
return self.handle
async def write_streaming_tts(self, handle, chunk):
if self._fail_after_write and len(self.written_chunks) >= 2:
raise RuntimeError("adapter write failure after partial output")
self.written_chunks.append(chunk)
if not handle.audible:
handle.audible = True
async def finish_streaming_tts(self, handle, *, interrupted=False):
self.finish_count += 1
async def abort_streaming_tts(self, handle, error=None):
self.abort_count += 1
if handle:
handle.aborted = True
class SlowStreamer(FakeStreamer):
"""Fake streamer whose iteration intentionally blocks off the event loop."""
def __init__(self, *args, delay_s=0.15, **kwargs):
super().__init__(*args, **kwargs)
self.delay_s = delay_s
self.started = threading.Event()
self.finished = threading.Event()
def stream(self, text: str):
self.started.set()
try:
for chunk in super().stream(text):
time.sleep(self.delay_s)
yield chunk
finally:
self.finished.set()
class SlowFirstChunkStreamer(FakeStreamer):
"""Blocks before the first chunk so timeout happens before audio starts."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.started = threading.Event()
self.allow_first_chunk = threading.Event()
self.finished = threading.Event()
def stream(self, text: str):
self.started.set()
try:
self.allow_first_chunk.wait(timeout=5.0)
yield b"chunk-1-0"
finally:
self.finished.set()
class BlockingSecondChunkStreamer(FakeStreamer):
"""Yields one chunk immediately, then blocks before the remaining chunks."""
def __init__(self, *args, **kwargs):
super().__init__(*args, chunks_per_clause=2, **kwargs)
self.started = threading.Event()
self.first_chunk_written = threading.Event()
self.allow_remaining_chunks = threading.Event()
self.finished = threading.Event()
def stream(self, text: str):
self.started.set()
try:
yield b"chunk-1-0"
self.first_chunk_written.set()
self.allow_remaining_chunks.wait(timeout=5.0)
yield b"chunk-1-1"
finally:
self.finished.set()
class UnsupportedAdapter:
"""Adapter that does not support streaming TTS (default base behaviour)."""
def _should_auto_tts_for_chat(self, chat_id):
return True
def supports_streaming_tts(self, chat_id, audio_format):
return False
async def begin_streaming_tts(self, chat_id, audio_format, metadata=None):
return None
async def write_streaming_tts(self, handle, chunk):
pass
async def finish_streaming_tts(self, handle, *, interrupted=False):
pass
async def abort_streaming_tts(self, handle, error=None):
pass
def _make_consumer(adapter, chat_id, loop, streamer):
"""Build a StreamingTTSConsumer with pre-set internals for testing."""
consumer = StreamingTTSConsumer.__new__(StreamingTTSConsumer)
consumer._adapter = adapter
consumer._chat_id = chat_id
consumer._tts_config = {}
consumer._loop = loop
consumer._metadata = None
consumer._audio_format = AudioFormat(
sample_rate=int(getattr(streamer, "sample_rate", 24000)) if streamer is not None else 24000,
channels=int(getattr(streamer, "channels", 1)) if streamer is not None else 1,
sample_width=int(getattr(streamer, "sample_width", 2)) if streamer is not None else 2,
)
consumer._streamer = streamer # type: ignore[assignment]
consumer._chunker = SentenceChunker()
consumer._queue = queue.Queue(maxsize=256)
consumer._handle = None
consumer._started = False
consumer._completed = False
consumer._partial = False
consumer._aborted = False
consumer._finished = False
consumer._dropped = False
consumer._suppress_whole_file = False
consumer._task = None
consumer._lock = threading.Lock()
consumer._strip_markdown = None
return consumer
def _run_test(coro_factory, timeout=10.0):
"""Run an async test in a fresh event loop."""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(
asyncio.wait_for(coro_factory(loop), timeout=timeout)
)
finally:
loop.close()
# ---------------------------------------------------------------------------
# Adapter contract defaults (BasePlatformAdapter)
# ---------------------------------------------------------------------------
def _make_minimal_adapter():
"""Create a minimal concrete BasePlatformAdapter for testing defaults."""
from gateway.platforms.base import BasePlatformAdapter
class _Minimal(BasePlatformAdapter):
async def send(self, chat_id, content, **kw):
pass
async def send_voice(self, chat_id, audio_path, **kw):
pass
async def connect(self, **kw):
return True
async def disconnect(self):
pass
async def get_chat_info(self, chat_id):
return {}
adapter = object.__new__(_Minimal)
adapter._streaming_tts_completed_turns = set()
return adapter
class TestAdapterContractDefaults:
"""Verify the default adapter reports unsupported and is source-compatible."""
def test_supports_streaming_tts_defaults_false(self):
adapter = _make_minimal_adapter()
assert adapter.supports_streaming_tts("chat1", AudioFormat()) is False
def test_begin_returns_none_by_default(self):
adapter = _make_minimal_adapter()
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(
adapter.begin_streaming_tts("chat1", AudioFormat())
)
assert result is None
finally:
loop.close()
# ---------------------------------------------------------------------------
# StreamingTTSConsumer lifecycle
# ---------------------------------------------------------------------------
class TestConsumerLifecycle:
"""Begin/write/finish lifecycle exactly once on success."""
def test_successful_stream_produces_ordered_chunks(self):
async def run(loop):
adapter = FakeVoiceAdapter()
streamer = FakeStreamer(chunks_per_clause=2)
consumer = _make_consumer(adapter, "chat1", loop, streamer)
consumer.start()
consumer.on_delta("This is the first sentence. ")
consumer.on_delta("Here is the second one. ")
consumer.finish()
completed = await consumer.wait_complete(timeout=5.0)
assert completed is True
assert adapter.begin_count == 1
assert adapter.finish_count == 1
assert adapter.abort_count == 0
# 2 clauses * 2 chunks each = 4 chunks
assert len(adapter.written_chunks) == 4
# Verify ordering
assert adapter.written_chunks[0] == b"chunk-1-0"
assert adapter.written_chunks[1] == b"chunk-1-1"
assert adapter.written_chunks[2] == b"chunk-2-0"
assert adapter.written_chunks[3] == b"chunk-2-1"
_run_test(run)
def test_post_audio_timeout_keeps_suppression_then_aborts(self):
"""After audible audio, a finalisation timeout aborts the consumer.
The outer gateway loop calls abort() on timeout so no unowned
consumer task lingers. Suppression is preserved so the gateway
does not replay from the beginning. Updated for #60671
hardening: the outer loop now aborts instead of leaving the
consumer to complete later in the background.
"""
async def run(loop):
adapter = FakeVoiceAdapter()
streamer = BlockingSecondChunkStreamer()
consumer = _make_consumer(adapter, "chat1", loop, streamer)
consumer.start()
consumer.on_delta("This is a sentence with a delayed tail. ")
consumer.finish()
await asyncio.wait_for(asyncio.to_thread(streamer.first_chunk_written.wait, 1.0), timeout=1.0)
await asyncio.sleep(0)
assert consumer.audible is True
assert consumer.suppress_whole_file is True
assert streamer.finished.is_set() is False
completed = await consumer.wait_complete(timeout=0.01)
assert completed is False
assert consumer.suppress_whole_file is True
assert adapter.written_chunks == [b"chunk-1-0"]
# The outer loop now aborts on timeout after audible audio
# instead of leaving the consumer running in the background.
consumer.abort("streaming TTS finalisation timeout")
await consumer.wait_complete(timeout=2.0)
# The consumer is aborted, not completed.
assert consumer.completed is False
assert consumer._aborted is True
assert consumer.suppress_whole_file is True
_run_test(run)
class TestStreamerFormatAndLooping:
"""Constructor wiring should derive format and keep provider I/O off-loop."""
def test_audio_format_tracks_resolved_streamer(self):
streamer = FakeStreamer(chunks_per_clause=1, sample_rate=48000, channels=2, sample_width=4)
import tools.tts_streaming as tts_streaming
original_resolve = tts_streaming.resolve_streaming_provider
tts_streaming.resolve_streaming_provider = lambda *_args, **_kwargs: streamer
loop = asyncio.new_event_loop()
try:
consumer = StreamingTTSConsumer(FakeVoiceAdapter(), "chat1", {}, loop)
assert consumer._audio_format.sample_rate == 48000
assert consumer._audio_format.channels == 2
assert consumer._audio_format.sample_width == 4
finally:
tts_streaming.resolve_streaming_provider = original_resolve
loop.close()
class TestGatewayIntegrationSeam:
"""The actual adapter seam is per-turn, not chat-only."""
def test_duplicate_suppression_is_per_turn(self):
from gateway.platforms.base import streaming_tts_should_skip_whole_file
adapter = _make_minimal_adapter()
turn_one = adapter._streaming_tts_turn_key("chat-1", 101)
turn_two = adapter._streaming_tts_turn_key("chat-1", 102)
assert turn_one != turn_two
adapter._mark_streaming_tts_completed_turn("chat-1", 101)
assert streaming_tts_should_skip_whole_file(
adapter._streaming_tts_completed_turns,
"chat-1",
101,
) is True
assert streaming_tts_should_skip_whole_file(
adapter._streaming_tts_completed_turns,
"chat-1",
102,
) is False
assert adapter._streaming_tts_turn_completed("chat-1", 101) is True
assert adapter._streaming_tts_turn_completed("chat-1", 102) is False
assert adapter._streaming_tts_turn_completed("chat-2", 101) is False
class TestAbortAndCancellation:
"""Abort lifecycle: idempotent, prevents late chunks."""
def test_abort_is_idempotent(self):
async def run(loop):
adapter = FakeVoiceAdapter()
streamer = FakeStreamer(chunks_per_clause=10)
consumer = _make_consumer(adapter, "chat1", loop, streamer)
consumer.start()
consumer.on_delta("A sentence. ")
# Abort multiple times
consumer.abort("test")
consumer.abort("test2")
consumer.abort("test3")
consumer.finish()
await consumer.wait_complete(timeout=5.0)
# Abort should have been called at most once on the adapter
assert adapter.abort_count <= 1
_run_test(run)
class TestFallbackSafety:
"""Pre-audio failure falls back; post-audio failure does not replay."""
def test_pre_audio_failure_falls_back(self):
async def run(loop):
adapter = FakeVoiceAdapter()
streamer = FakeStreamer(fail_on_clause=1)
consumer = _make_consumer(adapter, "chat1", loop, streamer)
consumer.start()
consumer.on_delta("A sentence. ")
consumer.finish()
completed = await consumer.wait_complete(timeout=5.0)
# Pre-audio failure: should NOT report completed (fall back)
assert completed is False
assert adapter.abort_count >= 1
_run_test(run)
class TestConcurrentTurnIsolation:
"""Per-turn state is isolated across concurrent chats."""
def test_two_concurrent_turns_do_not_cross_contaminate(self):
async def run(loop):
adapter1 = FakeVoiceAdapter(name="adapter1")
adapter2 = FakeVoiceAdapter(name="adapter2")
streamer = FakeStreamer(chunks_per_clause=2)
c1 = _make_consumer(adapter1, "chat1", loop, streamer)
c2 = _make_consumer(adapter2, "chat2", loop, streamer)
c1.start()
c2.start()
c1.on_delta("Sentence for chat one. ")
c2.on_delta("Sentence for chat two. ")
c1.finish()
c2.finish()
await c1.wait_complete(timeout=5.0)
await c2.wait_complete(timeout=5.0)
# Each adapter should only have its own chunks
assert adapter1.written_chunks != adapter2.written_chunks
# Both should have completed
assert c1.completed is True
assert c2.completed is True
_run_test(run)
class TestThinkBlockSuppression:
"""Think blocks split across deltas are never synthesised."""
def test_think_blocks_not_synthesised(self):
c = SentenceChunker()
# Think block split across deltas — content inside is stripped.
# The SentenceChunker uses min_len=20: sentences shorter than
# 20 chars (after strip) are merged into the next one.
assert c.feed("\x3cthink\x3esecret reasoning") == []
# Feed a long enough sentence after the think block closes.
result = c.feed(" about the answer.\x3c/think\x3e This is the actual spoken answer that is long enough. ")
assert len(result) == 1
assert "This is the actual spoken answer that is long enough." in result[0]
assert c.flush() == []
class TestQueueBackpressure:
"""on_delta does not block when the queue is full."""
def test_full_queue_drops_clause_not_blocks(self):
async def run(loop):
adapter = FakeVoiceAdapter()
streamer = FakeStreamer(chunks_per_clause=1)
consumer = _make_consumer(adapter, "chat1", loop, streamer)
# Tiny queue to trigger backpressure.
consumer._queue = queue.Queue(maxsize=1)
consumer._queue.put_nowait("prefilled")
start = time.perf_counter()
for i in range(50):
consumer.on_delta(f"Sentence number {i}. ")
elapsed = time.perf_counter() - start
assert elapsed < 0.05
consumer.finish()
assert consumer.dropped is True
completed = await consumer.wait_complete(timeout=1.0)
assert completed is False
assert consumer.completed is False
assert consumer.suppress_whole_file is False
_run_test(run, timeout=15.0)
# ---------------------------------------------------------------------------
# Finish race: _DONE sentinel guarantees the final clause is not lost (#60671)
# ---------------------------------------------------------------------------
class DelayedFlushChunker:
"""Chunker whose flush() returns a clause only after a signal is set.
This simulates a provider that buffers the last clause and only
releases it on flush(), after the drain loop has already started
waiting. The _DONE sentinel must arrive AFTER the flushed clause
so the loop does not terminate early and lose the tail.
"""
def __init__(self):
self._flushed = threading.Event()
self.allow_flush = threading.Event()
def feed(self, delta: str):
# Accumulate into a buffer; no sentences are released until flush.
return []
def flush(self):
self._flushed.set()
self.allow_flush.wait(timeout=5.0)
return ["The final tail clause that must not be lost."]
class TestFinishSentinelRace:
"""The _DONE sentinel must not overtake a delayed flush clause."""
def test_delayed_flush_clause_is_not_lost(self):
async def run(loop):
adapter = FakeVoiceAdapter()
streamer = FakeStreamer(chunks_per_clause=2)
consumer = _make_consumer(adapter, "chat1", loop, streamer)
# Replace the chunker with the delayed-flush variant.
consumer._chunker = DelayedFlushChunker()
consumer.start()
consumer.on_delta("Some text that buffers inside the chunker. ")
# Run finish() off-loop so the drain task can observe the exact
# historical race: _finished is true while flush() is blocked and
# the queue is still empty.
finish_task = asyncio.create_task(asyncio.to_thread(consumer.finish))
# Wait for flush() to block, then give the drain loop longer than
# its queue.get timeout. The old `_finished and queue.empty()`
# escape hatch would terminate here and lose the tail clause.
await asyncio.wait_for(
asyncio.to_thread(consumer._chunker._flushed.wait, 2.0),
timeout=2.0,
)
await asyncio.sleep(0.2)
consumer._chunker.allow_flush.set()
await finish_task
completed = await consumer.wait_complete(timeout=5.0)
assert completed is True
# The tail clause must have been synthesised and written.
assert len(adapter.written_chunks) > 0
assert adapter.finish_count == 1
_run_test(run)
# ---------------------------------------------------------------------------
# Adapter finish failure (#60671)
# ---------------------------------------------------------------------------
class FinishFailingAdapter(FakeVoiceAdapter):
"""Adapter whose finish_streaming_tts() always raises."""
async def finish_streaming_tts(self, handle, *, interrupted=False):
raise RuntimeError("adapter finish failure")
class TestAdapterFinishFailure:
"""If finish_streaming_tts() raises, never report full completion."""
def test_finish_failure_after_audible_reports_partial(self):
async def run(loop):
adapter = FinishFailingAdapter()
streamer = FakeStreamer(chunks_per_clause=2)
consumer = _make_consumer(adapter, "chat1", loop, streamer)
consumer.start()
consumer.on_delta("A sentence that produces audio. ")
consumer.finish()
completed = await consumer.wait_complete(timeout=5.0)
assert completed is False
assert consumer.partial is True
assert consumer.suppress_whole_file is True
assert len(adapter.written_chunks) > 0
_run_test(run)
# ---------------------------------------------------------------------------
# Post-audio timeout: clean abort, no later background completion (#60671)
# ---------------------------------------------------------------------------
class TestPostAudioTimeoutAbort:
"""On finalisation timeout after audible audio, abort the consumer."""
def test_timeout_after_audible_aborts_and_preserves_suppression(self):
async def run(loop):
adapter = FakeVoiceAdapter()
streamer = BlockingSecondChunkStreamer()
consumer = _make_consumer(adapter, "chat1", loop, streamer)
consumer.start()
consumer.on_delta("This is a sentence with a delayed tail. ")
consumer.finish()
await asyncio.wait_for(
asyncio.to_thread(streamer.first_chunk_written.wait, 2.0),
timeout=2.0,
)
assert consumer.audible is True
assert consumer.suppress_whole_file is True
# Timeout: the consumer should be aborted, not left running.
completed = await consumer.wait_complete(timeout=0.01)
assert completed is False
assert consumer.suppress_whole_file is True
# Simulate the outer loop's abort-on-timeout behaviour.
consumer.abort("streaming TTS finalisation timeout")
await asyncio.sleep(0.05)
# The consumer must not complete later in the background.
assert consumer.completed is False
assert consumer._aborted is True
_run_test(run)
# ---------------------------------------------------------------------------
# Real gateway regression: no _streaming_tts_consumer NameError (#60671)
# ---------------------------------------------------------------------------
class TestGatewayOuterFinalisationNoNameError:
"""Exercise the real outer finalisation path to prove no NameError.
This test does NOT use the StreamingTTSConsumer helper tests alone —
it verifies that ``gateway/run.py``'s outer finalisation code can
reference ``streaming_tts_consumer_holder[0]`` without hitting a
NameError on a normal gateway turn. We do this by importing the
symbol and exercising the code path that would have failed.
"""
def test_streaming_tts_consumer_holder_is_list_not_name(self):
"""The outer scope uses a holder list, not a bare local name.
This is a structural invariant: if someone reintroduces the
cross-scope NameError by moving the consumer back into
``run_sync`` as a local, this test documents the correct shape.
"""
# The holder pattern is the fix. Verify it is a mutable container.
holder: list = [None]
assert holder[0] is None
holder[0] = "sentinel"
assert holder[0] == "sentinel"
# The outer scope must be able to read it without a NameError.
# This is trivially true with a holder, but was NOT true when
# the consumer was a run_sync local.
_ = holder[0]