## 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>
159 lines
4.9 KiB
Python
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))
|