Files
hermes-webui/tests/test_issue3582_tts_content_length.py
T
nesquena-hermesandrodboev efbb0a5bda Release v0.51.259 — Release IA (stage-r7) (#3612)
## Release v0.51.259 — Release IA (stage-r7)

Two ship-ready @rodboev bug-fixes from today (the clean subset of the prioritized 6).

### Fixed
| Issue | Fix |
|-------|-----|
| #3582 | **Edge-TTS playback no longer has a ~31s delay / playback error** — `_handle_tts` streamed audio without `Content-Length` on an HTTP/1.0 server; audio is now buffered and sent with an exact `Content-Length`. |
| #3583 | **CLI-bridge message reconstruction strips orphaned `tool_calls`** (assistant `tool_calls` with no matching `tool` response, left by an aborted bridge) so the next request no longer 400s on strict providers. |

### Held back from the 6-PR batch (Codex regression gate caught a real defect in each)
- **#3586/#3603** (`is_cli_session_row` reclassification) — CORE: messaging rows become non-CLI, but the sidebar open path only imports when `is_cli_session`, so opening a Discord/Telegram session shows a transient stub and the next send 404s on `/api/chat/start`. Needs a client import-gate fix + live verify. **Held.**
- **#3585/#3604** (cron-overflow) — removing `exclude_sources=None` also re-excludes `source='webui'` rows, dropping sidecarless WebUI session recovery from `/api/sessions`. Needs a separate webui recovery pass. **Held.**
- **#3587** (intermediate reasoning) — `on_interim_assistant` is suppressed upstream for contentless tool-call assistant messages (`run_agent.py:3834`), so advancing the reasoning index there never fires at tool-call boundaries → mis-attribution. **Held.**
- **#3538** (self-update stash-pop) — BRICK data-loss (`git reset --merge` + `git stash drop` discards user mods), still unaddressed. **Held.**

### Gate
- Full pytest suite: **7645 passed, 0 failed**
- ruff: CLEAN
- Codex (regression): 3 rounds — 4 PRs dropped/held for real regressions → **SAFE TO SHIP** on the clean 2

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-04 15:04:45 -07:00

159 lines
4.9 KiB
Python

"""Content-Length header present on successful TTS responses (#3582).
The HTTP/1.0 server cannot signal end-of-body via connection close without
triggering a ~31 s client timeout. Buffering all chunks before writing lets
us include a Content-Length header so the browser can play the audio blob.
"""
import io
import json
import sys
import types
import pytest
import api.routes as routes
class _FakeHandler:
def __init__(self, body: bytes, command: str = "POST", headers=None, client="1.2.3.4"):
self.command = command
self.rfile = io.BytesIO(body)
self.wfile = io.BytesIO()
self.headers = headers or {}
self.headers.setdefault("Content-Length", str(len(body)))
self.client_address = (client, 12345)
self.status = None
self.sent_headers = {}
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.sent_headers[key] = value
def end_headers(self):
pass
def body(self):
return self.wfile.getvalue()
def payload(self):
try:
return json.loads(self.body().decode("utf-8"))
except Exception:
return None
def _post(body_dict, client="2.3.4.5", **kw):
body = json.dumps(body_dict).encode()
return _FakeHandler(body, client=client, **kw)
def _reset_limiter():
if hasattr(routes._handle_tts, "_tts_limiter"):
del routes._handle_tts._tts_limiter
@pytest.fixture(autouse=True)
def _setup(monkeypatch):
# Disable auth and reset limiter so guard-rail tests are deterministic.
import api.auth as _auth
monkeypatch.setattr(_auth, "is_auth_enabled", lambda: False)
monkeypatch.setattr(routes, "is_auth_enabled", lambda: False, raising=False)
_reset_limiter()
yield
_reset_limiter()
def _make_edge_tts_mock(audio_chunks):
"""Return a fake edge_tts module whose Communicate.stream_sync yields chunks."""
fake_module = types.ModuleType("edge_tts")
class FakeCommunicate:
def __init__(self, text, voice, **kwargs):
pass
def stream_sync(self):
for data in audio_chunks:
yield {"type": "audio", "data": data}
fake_module.Communicate = FakeCommunicate
return fake_module
def test_content_length_present_and_correct(monkeypatch):
"""Content-Length header matches the actual body written."""
chunk1 = b"\xff\xfb\x90" * 100
chunk2 = b"\xff\xfb\x90" * 50
expected_body = chunk1 + chunk2
monkeypatch.setitem(sys.modules, "edge_tts", _make_edge_tts_mock([chunk1, chunk2]))
h = _post({"text": "hello", "voice": "en-US-AriaNeural"}, client="3.0.0.1")
result = routes._handle_tts(h, None)
assert result is True
assert h.status == 200
assert h.sent_headers.get("Content-Length") == str(len(expected_body))
assert h.body() == expected_body
def test_content_type_is_audio_mpeg(monkeypatch):
"""Content-Type header must be audio/mpeg."""
monkeypatch.setitem(sys.modules, "edge_tts", _make_edge_tts_mock([b"\xff\xfb" * 10]))
h = _post({"text": "world", "voice": "en-US-GuyNeural"}, client="3.0.0.2")
routes._handle_tts(h, None)
assert h.sent_headers.get("Content-Type") == "audio/mpeg"
def test_empty_audio_returns_500(monkeypatch):
"""When TTS produces no audio chunks, return 500 before touching the response."""
monkeypatch.setitem(sys.modules, "edge_tts", _make_edge_tts_mock([]))
h = _post({"text": "silent", "voice": "en-US-AriaNeural"}, client="3.0.0.3")
routes._handle_tts(h, None)
assert h.status == 500
assert "no audio" in (h.payload() or {}).get("error", "")
def test_content_length_single_chunk(monkeypatch):
"""Single chunk path: Content-Length equals that chunk's length."""
data = b"A" * 1024
monkeypatch.setitem(sys.modules, "edge_tts", _make_edge_tts_mock([data]))
h = _post({"text": "one chunk", "voice": "zh-CN-XiaoxiaoNeural"}, client="3.0.0.4")
routes._handle_tts(h, None)
assert h.status == 200
assert h.sent_headers.get("Content-Length") == "1024"
assert h.body() == data
def test_non_audio_chunks_ignored(monkeypatch):
"""Metadata/WordBoundary chunks must not contribute to the audio buffer."""
audio_data = b"\xff\xfb" * 20
fake_module = types.ModuleType("edge_tts")
class FakeCommunicate:
def __init__(self, text, voice, **kwargs):
pass
def stream_sync(self):
yield {"type": "WordBoundary", "data": b"ignored"}
yield {"type": "audio", "data": audio_data}
yield {"type": "SessionEnd", "data": None}
fake_module.Communicate = FakeCommunicate
monkeypatch.setitem(sys.modules, "edge_tts", fake_module)
h = _post({"text": "mixed chunks", "voice": "en-US-AriaNeural"}, client="3.0.0.5")
routes._handle_tts(h, None)
assert h.status == 200
assert h.body() == audio_data
assert h.sent_headers.get("Content-Length") == str(len(audio_data))