nanobot/tests/providers/test_anthropic_merge_consecutive.py
Xubin Ren 009cce78ad fix(anthropic): also enforce leading-user + empty-array recovery
Extend `_merge_consecutive` so the three invariants from
`LLMProvider._enforce_role_alternation` all hold for Anthropic:

1. collapse consecutive same-role turns (unchanged)
2. no trailing assistant — Anthropic rejects prefill (unchanged)
3. no leading assistant — Anthropic requires the first turn be user
4. non-empty messages array — recover the last stripped assistant as a
   user turn when every turn got stripped, so callers don't hit a
   secondary "messages array empty" 400

Anthropic-specific wrinkle: `tool_use` blocks live inside `content` (not
a separate `tool_calls` field) and are illegal inside user turns, so
both recovery paths skip any message carrying them rather than silently
producing a malformed request.

Adds 4 unit tests covering the new branches, including the tool_use
opt-outs, and updates the existing `test_single_assistant_stripped` to
reflect the new rerouting contract.

Made-with: Cursor
2026-04-21 01:32:32 +08:00

140 lines
5.6 KiB
Python

"""Tests for AnthropicProvider._merge_consecutive."""
from nanobot.providers.anthropic_provider import AnthropicProvider
class TestMergeConsecutive:
"""Verify role alternation and trailing-assistant stripping."""
def test_basic_alternation(self):
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "bye"},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 3
assert [m["role"] for m in result] == ["user", "assistant", "user"]
def test_consecutive_same_role_merged(self):
msgs = [
{"role": "user", "content": "a"},
{"role": "user", "content": "b"},
{"role": "assistant", "content": "reply"},
]
result = AnthropicProvider._merge_consecutive(msgs)
# Two user messages merged into one, trailing assistant stripped
assert len(result) == 1
assert result[0]["role"] == "user"
def test_trailing_assistant_stripped(self):
"""Anthropic rejects prefill — trailing assistant must be removed."""
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 1
assert result[0]["role"] == "user"
assert result[0]["content"] == "hello"
def test_multiple_trailing_assistant_stripped(self):
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "a"},
{"role": "user", "content": "ok"},
{"role": "assistant", "content": "b"},
{"role": "assistant", "content": "c"},
]
result = AnthropicProvider._merge_consecutive(msgs)
# b+c merged into one assistant, then stripped as trailing
assert len(result) == 3
assert result[-1]["role"] == "user"
assert result[-1]["content"] == "ok"
def test_empty_messages(self):
assert AnthropicProvider._merge_consecutive([]) == []
def test_single_user_message(self):
msgs = [{"role": "user", "content": "hi"}]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 1
def test_single_assistant_rerouted_to_user(self):
"""When stripping leaves nothing, the last assistant is rerouted to
``user`` so we don't produce an empty messages array."""
msgs = [{"role": "assistant", "content": "hi"}]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 1
assert result[0]["role"] == "user"
assert result[0]["content"] == "hi"
def test_all_assistants_collapse_then_rerouted(self):
"""Consecutive trailing assistants merge into one, which is then
rerouted as a user turn carrying the merged content."""
msgs = [
{"role": "assistant", "content": "a"},
{"role": "assistant", "content": "b"},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 1
assert result[0]["role"] == "user"
# "b" was merged into "a"'s block list during the merge pass.
assert result[0]["content"] == [
{"type": "text", "text": "a"},
{"type": "text", "text": "b"},
]
def test_assistant_with_tool_use_not_rerouted(self):
"""A trailing assistant carrying ``tool_use`` blocks cannot become a
user turn (Anthropic rejects ``tool_use`` inside user messages), so
the method returns an empty list rather than forging a bad request."""
msgs = [
{
"role": "assistant",
"content": [
{"type": "text", "text": "let me search"},
{"type": "tool_use", "id": "t1", "name": "search", "input": {}},
],
}
]
result = AnthropicProvider._merge_consecutive(msgs)
assert result == []
def test_leading_assistant_gets_synthetic_user(self):
"""If the first turn is a bare assistant (e.g. history truncation
dropped the original user request), prepend a synthetic opener so
the conversation still starts with ``user``."""
msgs = [
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "ok"},
{"role": "assistant", "content": "reply"},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert [m["role"] for m in result] == ["user", "assistant", "user"]
assert result[0]["content"] == "(conversation continued)"
assert result[1]["content"] == "hi"
assert result[2]["content"] == "ok"
def test_leading_assistant_with_tool_use_left_alone(self):
"""Don't prepend a synthetic opener before an assistant carrying
``tool_use``; doing so would orphan the paired ``tool_result`` that
follows. The caller will see the original 400 rather than a
harder-to-diagnose tool-pair mismatch."""
msgs = [
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "t1", "name": "search", "input": {}},
],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "ok"},
],
},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert [m["role"] for m in result] == ["assistant", "user"]