mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-18 21:50:55 +00:00
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>
This commit is contained in:
@@ -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
|
||||
|
||||
+24
-12
@@ -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:
|
||||
|
||||
+55
-2
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user