106 lines
4.1 KiB
Python
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}"
|
||
|
|
)
|