From efbb0a5bdaeaacbd32a5a56249988329c13ecbc3 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 4 Jun 2026 15:04:45 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.259=20=E2=80=94=20Release=20IA?= =?UTF-8?q?=20(stage-r7)=20(#3612)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- CHANGELOG.md | 6 + api/routes.py | 36 ++-- api/streaming.py | 57 +++++- tests/test_issue3582_tts_content_length.py | 158 +++++++++++++++ tests/test_issue3583_orphaned_tool_calls.py | 203 ++++++++++++++++++++ 5 files changed, 446 insertions(+), 14 deletions(-) create mode 100644 tests/test_issue3582_tts_content_length.py create mode 100644 tests/test_issue3583_orphaned_tool_calls.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 583d8de2b..65bd9123c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.259] — 2026-06-04 — Release IA (stage-r7 — edge-TTS Content-Length + orphaned tool_calls strip) + +### Fixed +- **Edge-TTS playback no longer has a ~31s delay / playback error.** `_handle_tts` streamed audio without a `Content-Length` on an HTTP/1.0 server, so clients waited for connection close; audio is now buffered and sent with `Content-Length`. (#3582, @rodboev) +- **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. (#3583, @rodboev) + ## [v0.51.258] — 2026-06-04 — Release HZ (stage-r6 — update banner from any panel + inline thinking on settlement) ### Fixed diff --git a/api/routes.py b/api/routes.py index fa164f76c..6dfebc269 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8929,9 +8929,11 @@ def _handle_tts(handler, parsed): only its own thread during Microsoft network I/O + streaming; other clients are unaffected. Combined with early auth, a strict per-client 2 s rate limit, 5000-char cap, and voice allowlist, the blocking cost is bounded and - intentional. Streaming chunks directly via stream_sync() keeps memory - usage low. A cross-thread pool + queue would add complexity and wfile - thread-safety issues with no practical gain under the current model. + intentional. All audio chunks are buffered before sending so that a + Content-Length header can be included. The 5000-char cap bounds audio to + roughly 1-5 MB, making full buffering safe. Without Content-Length the + HTTP/1.0 server leaves the response open until a ~31 s timeout fires, and + the browser cannot play the blob mid-stream. If the HTTP layer ever moves to asyncio we can adopt edge_tts's native async API at that time. """ @@ -9037,17 +9039,27 @@ def _handle_tts(handler, parsed): comm = edge_tts.Communicate(text, voice, **kwargs) - handler.send_response(200) - handler.send_header("Content-Type", "audio/mpeg") - handler.send_header("Cache-Control", "no-store") - handler.end_headers() - + # Buffer all audio chunks before responding so Content-Length is known. + # Without it the HTTP/1.0 server holds the connection open until a ~31 s + # timeout fires and the browser cannot play the resulting blob. + audio_buf = bytearray() for chunk in comm.stream_sync(): if chunk.get("type") == "audio" and chunk.get("data"): - try: - handler.wfile.write(chunk["data"]) - except (BrokenPipeError, ConnectionResetError): - return True + audio_buf.extend(chunk["data"]) + + if not audio_buf: + from api.helpers import bad as _bad + return _bad(handler, "TTS produced no audio", 500) + + handler.send_response(200) + handler.send_header("Content-Type", "audio/mpeg") + handler.send_header("Content-Length", str(len(audio_buf))) + handler.send_header("Cache-Control", "no-store") + handler.end_headers() + try: + handler.wfile.write(audio_buf) + except (BrokenPipeError, ConnectionResetError): + pass return True except BrokenPipeError: diff --git a/api/streaming.py b/api/streaming.py index 818f6f0da..dcd9f6332 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2684,7 +2684,35 @@ def _sanitize_messages_for_api(messages, *, cfg: dict = None): sanitized['content'] = _strip_native_image_parts_from_content(sanitized.get('content')) if sanitized.get('role'): clean.append(sanitized) - return clean + + # Third pass: strip orphaned tool_calls from assistant messages — calls whose id + # has no matching tool-role response in the clean list. Strict providers (DeepSeek, + # newer OpenAI) reject with 400 when an assistant message references a tool call that + # was never answered (e.g. session aborted before results flushed). + answered_ids: set = set() + for msg in clean: + if msg.get('role') == 'tool': + tid = msg.get('tool_call_id') or '' + if tid: + answered_ids.add(tid) + + filtered_clean = [] + for msg in clean: + if msg.get('role') == 'assistant' and msg.get('tool_calls'): + kept = [ + tc for tc in msg['tool_calls'] + if isinstance(tc, dict) and + (tc.get('id') or tc.get('call_id') or '') in answered_ids + ] + if not kept: + # All calls orphaned: drop tool_calls key; if no content, drop message. + msg = {k: v for k, v in msg.items() if k != 'tool_calls'} + if not str(msg.get('content') or '').strip(): + continue + else: + msg = dict(msg, tool_calls=kept) + filtered_clean.append(msg) + return filtered_clean def _api_safe_message_positions(messages): @@ -2718,7 +2746,32 @@ def _api_safe_message_positions(messages): sanitized = {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS} if sanitized.get('role'): out.append((idx, sanitized)) - return out + + # Third pass: strip orphaned tool_calls from assistant messages (mirrors + # _sanitize_messages_for_api pass 3). + answered_ids: set = set() + for _idx, msg in out: + if msg.get('role') == 'tool': + tid = msg.get('tool_call_id') or '' + if tid: + answered_ids.add(tid) + + filtered_out = [] + for idx, msg in out: + if msg.get('role') == 'assistant' and msg.get('tool_calls'): + kept = [ + tc for tc in msg['tool_calls'] + if isinstance(tc, dict) and + (tc.get('id') or tc.get('call_id') or '') in answered_ids + ] + if not kept: + msg = {k: v for k, v in msg.items() if k != 'tool_calls'} + if not str(msg.get('content') or '').strip(): + continue + else: + msg = dict(msg, tool_calls=kept) + filtered_out.append((idx, msg)) + return filtered_out def _deduplicate_context_messages(messages): diff --git a/tests/test_issue3582_tts_content_length.py b/tests/test_issue3582_tts_content_length.py new file mode 100644 index 000000000..59cf529e5 --- /dev/null +++ b/tests/test_issue3582_tts_content_length.py @@ -0,0 +1,158 @@ +"""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)) diff --git a/tests/test_issue3583_orphaned_tool_calls.py b/tests/test_issue3583_orphaned_tool_calls.py new file mode 100644 index 000000000..7bd70aa34 --- /dev/null +++ b/tests/test_issue3583_orphaned_tool_calls.py @@ -0,0 +1,203 @@ +"""Tests for orphaned tool_calls stripping in _sanitize_messages_for_api. + +When a session is aborted before tool results flush, assistant messages may +contain tool_calls entries that have no matching tool-role response. Strict +APIs (DeepSeek, newer OpenAI) reject these histories with HTTP 400. The third +pass in _sanitize_messages_for_api and _api_safe_message_positions removes +those dangling entries. +""" + +from api.streaming import _sanitize_messages_for_api, _api_safe_message_positions + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _asst(content=None, tool_calls=None): + msg = {'role': 'assistant'} + if content is not None: + msg['content'] = content + if tool_calls is not None: + msg['tool_calls'] = tool_calls + return msg + + +def _tool_call(call_id, name='fn'): + return {'id': call_id, 'type': 'function', 'function': {'name': name, 'arguments': '{}'}} + + +def _tool_call_anthropic(call_id, name='fn'): + # Anthropic format uses 'call_id' instead of 'id' + return {'call_id': call_id, 'type': 'function', 'function': {'name': name, 'arguments': '{}'}} + + +def _tool_resp(call_id, content='result'): + return {'role': 'tool', 'tool_call_id': call_id, 'content': content} + + +# --------------------------------------------------------------------------- +# Test 1: basic orphan strip — one answered, one orphaned +# --------------------------------------------------------------------------- + +def test_partial_orphan_strip(): + """Assistant references two tool calls; only one has a tool response. + The orphaned call should be removed; the answered one must stay.""" + messages = [ + {'role': 'user', 'content': 'run two tools'}, + _asst(content='', tool_calls=[_tool_call('tc-1'), _tool_call('tc-2')]), + _tool_resp('tc-1', 'first result'), + # tc-2 response was never flushed (session aborted) + ] + result = _sanitize_messages_for_api(messages) + asst_msgs = [m for m in result if m.get('role') == 'assistant'] + assert len(asst_msgs) == 1 + tc_ids = [tc.get('id') for tc in asst_msgs[0].get('tool_calls', [])] + assert 'tc-1' in tc_ids, "answered call must be kept" + assert 'tc-2' not in tc_ids, "orphaned call must be stripped" + + +# --------------------------------------------------------------------------- +# Test 2: all calls orphaned — with and without content +# --------------------------------------------------------------------------- + +def test_all_orphaned_with_content_keeps_message(): + """All tool_calls orphaned but assistant message has text content. + The message should remain with tool_calls removed.""" + messages = [ + {'role': 'user', 'content': 'hi'}, + _asst(content='Let me check...', tool_calls=[_tool_call('tc-orphan')]), + # no tool response at all + ] + result = _sanitize_messages_for_api(messages) + asst_msgs = [m for m in result if m.get('role') == 'assistant'] + assert len(asst_msgs) == 1, "message with content must survive" + assert 'tool_calls' not in asst_msgs[0], "tool_calls key must be removed" + + +def test_all_orphaned_no_content_drops_message(): + """All tool_calls orphaned and no content. Message should be removed entirely.""" + messages = [ + {'role': 'user', 'content': 'hi'}, + _asst(content='', tool_calls=[_tool_call('tc-orphan')]), + # no tool response + ] + result = _sanitize_messages_for_api(messages) + asst_msgs = [m for m in result if m.get('role') == 'assistant'] + assert len(asst_msgs) == 0, "content-less fully-orphaned assistant message must be dropped" + + +def test_all_orphaned_none_content_drops_message(): + """Same as above but content is None rather than empty string.""" + messages = [ + {'role': 'user', 'content': 'hi'}, + _asst(content=None, tool_calls=[_tool_call('tc-orphan')]), + ] + result = _sanitize_messages_for_api(messages) + asst_msgs = [m for m in result if m.get('role') == 'assistant'] + assert len(asst_msgs) == 0 + + +# --------------------------------------------------------------------------- +# Test 3: no orphans — complete round-trip unchanged +# --------------------------------------------------------------------------- + +def test_no_orphans_unchanged(): + """Complete tool round-trip. Nothing should be stripped.""" + messages = [ + {'role': 'user', 'content': 'use a tool'}, + _asst(content='', tool_calls=[_tool_call('tc-ok')]), + _tool_resp('tc-ok', 'done'), + _asst(content='All done'), + ] + result = _sanitize_messages_for_api(messages) + asst_msgs = [m for m in result if m.get('role') == 'assistant'] + # Both assistant messages present + assert len(asst_msgs) == 2 + first = asst_msgs[0] + assert 'tool_calls' in first + assert first['tool_calls'][0]['id'] == 'tc-ok' + + +# --------------------------------------------------------------------------- +# Test 4: mixed — multiple assistant messages, selective stripping +# --------------------------------------------------------------------------- + +def test_mixed_selective_stripping(): + """Multiple assistant messages: some complete, some orphaned. Only orphans stripped.""" + messages = [ + {'role': 'user', 'content': 'go'}, + _asst(content='', tool_calls=[_tool_call('tc-a')]), + _tool_resp('tc-a', 'a-result'), + _asst(content='', tool_calls=[_tool_call('tc-b'), _tool_call('tc-c')]), + _tool_resp('tc-b', 'b-result'), + # tc-c never answered + _asst(content='Summary'), + ] + result = _sanitize_messages_for_api(messages) + asst_msgs = [m for m in result if m.get('role') == 'assistant'] + assert len(asst_msgs) == 3 + + # First: complete, tc-a retained + assert asst_msgs[0].get('tool_calls', [{}])[0].get('id') == 'tc-a' + + # Second: tc-b kept, tc-c stripped + second_ids = [tc.get('id') for tc in asst_msgs[1].get('tool_calls', [])] + assert 'tc-b' in second_ids + assert 'tc-c' not in second_ids + + # Third: plain text, no tool_calls + assert 'tool_calls' not in asst_msgs[2] + + +# --------------------------------------------------------------------------- +# Test 5: Anthropic call_id format +# --------------------------------------------------------------------------- + +def test_anthropic_call_id_format(): + """Tool calls using call_id (Anthropic format) are stripped correctly.""" + messages = [ + {'role': 'user', 'content': 'anthropic test'}, + _asst(content='', tool_calls=[ + _tool_call_anthropic('ac-answered'), + _tool_call_anthropic('ac-orphaned'), + ]), + _tool_resp('ac-answered', 'ok'), + # ac-orphaned never answered + ] + result = _sanitize_messages_for_api(messages) + asst_msgs = [m for m in result if m.get('role') == 'assistant'] + assert len(asst_msgs) == 1 + tc_call_ids = [tc.get('call_id') for tc in asst_msgs[0].get('tool_calls', [])] + assert 'ac-answered' in tc_call_ids + assert 'ac-orphaned' not in tc_call_ids + + +# --------------------------------------------------------------------------- +# _api_safe_message_positions mirrors the same logic +# --------------------------------------------------------------------------- + +def test_positions_partial_orphan_strip(): + """_api_safe_message_positions also strips the orphaned call.""" + messages = [ + {'role': 'user', 'content': 'run two tools'}, + _asst(content='', tool_calls=[_tool_call('tc-1'), _tool_call('tc-2')]), + _tool_resp('tc-1', 'first result'), + ] + positions = _api_safe_message_positions(messages) + asst_entries = [(i, m) for i, m in positions if m.get('role') == 'assistant'] + assert len(asst_entries) == 1 + tc_ids = [tc.get('id') for tc in asst_entries[0][1].get('tool_calls', [])] + assert 'tc-1' in tc_ids + assert 'tc-2' not in tc_ids + + +def test_positions_all_orphaned_no_content_drops(): + """_api_safe_message_positions drops a fully-orphaned no-content assistant message.""" + messages = [ + {'role': 'user', 'content': 'hi'}, + _asst(content='', tool_calls=[_tool_call('tc-orphan')]), + ] + positions = _api_safe_message_positions(messages) + asst_entries = [(i, m) for i, m in positions if m.get('role') == 'assistant'] + assert len(asst_entries) == 0