Files
hermes-webui/tests/test_pr1375_partial_tool_calls_sanitize.py
T
nesquena-hermes f53556b3ff fix(cancel-stream): rename tool_calls to _partial_tool_calls (Opus MUST-FIX)
Opus pass-2 review of v0.50.251 caught a critical regression in PR
#1375:

The cancel-partial message stored captured tool calls under the
'tool_calls' key. That key is whitelisted by _API_SAFE_MSG_KEYS so
_sanitize_messages_for_api forwarded the entries to the next-turn
LLM call. But the captured entries use the WebUI internal shape
({name, args, done, duration, is_error}) — they don't have the
OpenAI/Anthropic id + function: {name, arguments} envelope. Strict
providers (OpenAI, Anthropic, Z.AI/GLM) would 400 on the malformed
entries. Net effect: the very cancel-then-continue scenario PR
#1375 aimed to improve becomes a hard fail.

Fix:
- Rename the persisted key to '_partial_tool_calls' (underscore-
  prefixed private key NOT in _API_SAFE_MSG_KEYS, so sanitize
  correctly strips it).
- Update static/messages.js hasMessageToolMetadata check to also
  recognize _partial_tool_calls for UI rendering.
- Update test_issue1361_cancel_data_loss.py assertion to check
  _partial_tool_calls (and tool_calls as legacy fallback).

Plus 2 NIT fixes from the same Opus review:

NIT 1 (api/profiles.py:153): re.match → re.fullmatch for consistency
with other _PROFILE_ID_RE callers in the codebase. The trailing-
newline footgun ($ matches before final \n in re.match) is now
closed. Without #1373's is_dir() guard, a name like 'valid\n' would
have created a directory named 'valid\n' on Linux. Doesn't escape
<HERMES_HOME>/profiles/ via Path joining, but unintended.

NIT 2 (test_issue798.py): R19j coverage gaps — added trailing-
newline tests, length-boundary tests (64-char valid, 65-char
rejected), single-char minimum, and non-ASCII / Unicode-trick tests.

New regression test (tests/test_pr1375_partial_tool_calls_sanitize.py):
- test_partial_tool_calls_field_not_forwarded_to_llm: pins that
  sanitize-for-API strips _partial_tool_calls + reasoning + does
  NOT have tool_calls on a partial message
- test_legitimate_tool_calls_are_preserved_for_completed_turns:
  pins that real OpenAI-shape tool_calls on completed turns survive
  sanitize unchanged

Tests: 3486 passing (3484 → 3486, +2 sanitize tests).
2026-04-30 23:43:23 +00:00

106 lines
4.1 KiB
Python

"""Regression test for the v0.50.251 Opus pass-2 MUST-FIX on PR #1375.
Original PR #1375 stored cancelled tool calls under the message key
`tool_calls`. That key is whitelisted by `_API_SAFE_MSG_KEYS` so the
sanitize-for-API path forwarded them to the next-turn LLM call. But the
captured entries use the WebUI internal shape ({name, args, done,
duration, is_error}) — they don't have OpenAI/Anthropic's id +
function: {name, arguments} envelope. Strict providers (OpenAI,
Anthropic, Z.AI/GLM) would 400 on the malformed entries — turning a
"data lost on cancel" bug into a "next message returns 400" bug.
The fix renames the key to `_partial_tool_calls` (underscore-prefixed
private key NOT in the whitelist), so sanitize correctly strips it.
The UI reads it via static/messages.js.
This test pins the invariant: a partial assistant message with
`_partial_tool_calls` set must produce ZERO `tool_calls` after
sanitize-for-API.
"""
import pathlib
import sys
import pytest
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(REPO_ROOT))
def test_partial_tool_calls_field_not_forwarded_to_llm():
"""The `_partial_tool_calls` field must not survive _sanitize_messages_for_api.
Otherwise the malformed entries get sent to the LLM and cause 400 errors."""
from api.streaming import _sanitize_messages_for_api
messages = [
{"role": "user", "content": "do a search"},
{
"role": "assistant",
"content": "Looking up...",
"_partial": True,
"_partial_tool_calls": [
{"name": "web_search", "args": {"query": "x"}, "done": False},
],
"reasoning": "Let me think about this",
},
]
sanitized = _sanitize_messages_for_api(messages)
# The partial assistant message must NOT have _partial_tool_calls (private key).
# It must NOT have tool_calls (would bypass the rename and 400 the LLM).
# It must NOT have reasoning (not in whitelist).
assistant_msgs = [m for m in sanitized if m.get("role") == "assistant"]
assert assistant_msgs, "Sanitized output must include the assistant message"
for m in assistant_msgs:
assert "_partial_tool_calls" not in m, (
"Sanitize-for-API must strip _partial_tool_calls — it's a UI-only key. "
f"Got: {m}"
)
assert "tool_calls" not in m, (
"Sanitize-for-API must NOT have tool_calls on a partial message — the "
"captured entries use WebUI shape and would 400 on strict providers. "
f"Got: {m}"
)
assert "reasoning" not in m, (
"Sanitize-for-API must strip reasoning (not in _API_SAFE_MSG_KEYS). "
f"Got: {m}"
)
def test_legitimate_tool_calls_are_preserved_for_completed_turns():
"""Completed assistant turns with REAL tool_calls (with id + function envelope)
must still pass through sanitize unchanged. The rename only affects
cancel-partial messages, not normal completed turns."""
from api.streaming import _sanitize_messages_for_api
messages = [
{"role": "user", "content": "search"},
{
"role": "assistant",
"content": "I'll search.",
"tool_calls": [
{
"id": "call_abc",
"type": "function",
"function": {"name": "web_search", "arguments": '{"query":"x"}'}
},
],
},
{
"role": "tool",
"tool_call_id": "call_abc",
"name": "web_search",
"content": "result",
},
]
sanitized = _sanitize_messages_for_api(messages)
assistant_msgs = [m for m in sanitized if m.get("role") == "assistant"]
assert assistant_msgs, "Sanitized output must include the assistant message"
assert assistant_msgs[0].get("tool_calls"), (
"Legitimate tool_calls on completed turns must survive sanitize. "
f"Got: {assistant_msgs[0]}"
)
# Must still have the OpenAI envelope shape
tc = assistant_msgs[0]["tool_calls"][0]
assert "id" in tc and "function" in tc, (
f"tool_calls envelope must be preserved. Got: {tc}"
)