fix(anthropic): extend leading-user guard to the extracted-system-absent path

The salvaged guard from #52276 only fired when a system prompt was
present in messages[] (system is not None after extraction). The live
repro of #52160 is the auto path: the system prompt is passed outside
messages[], so after the second compaction messages[0] is the
assistant-role summary with system=None — and the guard never ran.

Make _ensure_leading_user_turn unconditional, exactly mirroring the
Bedrock Converse adapter ('Converse requires the first message to be
from the user' — convert_messages_to_converse). Add a regression test
building the exact post-double-compaction shape (no system in
messages, messages[0]=assistant summary) asserting the converted
payload leads with a user turn, and update existing fixtures that
started with a bare assistant message to locate roles instead of
indexing result[0].
This commit is contained in:
Teknium 2026-07-22 05:52:04 -07:00
parent e71c1137e8
commit b6a2b701c1
2 changed files with 67 additions and 14 deletions

View file

@ -2412,9 +2412,21 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
]
def _ensure_leading_user_turn(result: List[Dict[str, Any]], system: Any) -> None:
"""Anthropic requires messages[0] to be user when system is extracted."""
if system is not None and result and result[0].get("role") != "user":
def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
"""Anthropic requires messages[0] to have role=user.
After a second context compaction on the auto path the summary can be
emitted as role=assistant with nothing in front of it (the system prompt
lives outside messages[] or is extracted into the separate ``system``
param), so messages[0] ends up assistant and the Messages API rejects
the request with HTTP 400 often masked by a misleading
"tool_use ids were found without tool_result blocks" error (#52160).
Mirror the Bedrock Converse adapter, which unconditionally prepends a
minimal user turn when the first message is not user
(convert_messages_to_converse).
"""
if result and result[0].get("role") != "user":
result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]})
@ -2476,7 +2488,7 @@ def convert_messages_to_anthropic(
_strip_orphaned_tool_blocks(result)
result = _merge_consecutive_roles(result)
_ensure_leading_user_turn(result, system)
_ensure_leading_user_turn(result)
_manage_thinking_signatures(result, base_url, model)
_evict_old_screenshots(result)

View file

@ -945,7 +945,7 @@ class TestConvertMessages:
{"role": "tool", "tool_call_id": "tc_1", "content": "search results"},
]
_, result = convert_messages_to_anthropic(messages)
blocks = result[0]["content"]
blocks = next(m for m in result if m["role"] == "assistant")["content"]
assert blocks[0] == {"type": "text", "text": "Let me search."}
assert blocks[1]["type"] == "tool_use"
assert blocks[1]["id"] == "tc_1"
@ -963,8 +963,13 @@ class TestConvertMessages:
{"role": "tool", "tool_call_id": "tc_1", "content": "result data"},
]
_, result = convert_messages_to_anthropic(messages)
# tool result is in the second message (user role)
user_msg = [m for m in result if m["role"] == "user"][0]
# tool result is in the user message following the assistant turn
user_msg = next(
m for m in result
if m["role"] == "user"
and isinstance(m["content"], list)
and any(b.get("type") == "tool_result" for b in m["content"])
)
assert user_msg["content"][0]["type"] == "tool_result"
assert user_msg["content"][0]["tool_use_id"] == "tc_1"
@ -983,7 +988,12 @@ class TestConvertMessages:
]
_, result = convert_messages_to_anthropic(messages)
# assistant + merged user (with 2 tool_results)
user_msgs = [m for m in result if m["role"] == "user"]
user_msgs = [
m for m in result
if m["role"] == "user"
and isinstance(m["content"], list)
and any(b.get("type") == "tool_result" for b in m["content"])
]
assert len(user_msgs) == 1
assert len(user_msgs[0]["content"]) == 2
@ -1041,7 +1051,12 @@ class TestConvertMessages:
{"role": "tool", "tool_call_id": "tc_orphan", "content": "stale result"},
]
_, result = convert_messages_to_anthropic(messages)
user_msg = [m for m in result if m["role"] == "user"][0]
user_msg = next(
m for m in result
if m["role"] == "user"
and isinstance(m["content"], list)
and any(b.get("type") == "tool_result" for b in m["content"])
)
tool_results = [
b for b in user_msg["content"] if b.get("type") == "tool_result"
]
@ -1096,7 +1111,12 @@ class TestConvertMessages:
_, result = convert_messages_to_anthropic(messages)
asst = [m for m in result if m["role"] == "assistant"][0]
assert any(b.get("type") == "tool_use" for b in asst["content"])
user = [m for m in result if m["role"] == "user"][0]
user = next(
m for m in result
if m["role"] == "user"
and isinstance(m["content"], list)
and any(b.get("type") == "tool_result" for b in m["content"])
)
assert any(b.get("type") == "tool_result" for b in user["content"])
def test_system_with_cache_control(self):
@ -1381,6 +1401,27 @@ class TestConvertMessages:
for m in result
)
def test_double_compaction_no_system_in_messages_leads_with_user(self):
"""Exact post-double-compaction shape on the auto path (#52160).
On the auto path the system prompt is NOT inside messages[] and after
the second compaction protect_head has decayed to 0, so the
assistant-role summary is messages[0]. The converted payload must
still lead with a user turn or Anthropic 400s.
"""
messages = [
{"role": "assistant", "content": "[Context compaction summary] earlier work…"},
{"role": "user", "content": "continue"},
]
system, result = convert_messages_to_anthropic(messages)
assert system is None
assert result[0]["role"] == "user"
assert result[0]["content"] == [{"type": "text", "text": " "}]
assert result[1]["role"] == "assistant"
assert "Context compaction summary" in str(result[1]["content"])
def test_leading_user_message_is_not_modified(self):
"""A normal transcript that already starts with user must be untouched."""
messages = [
@ -2145,7 +2186,7 @@ class TestThinkingBlockSignatureManagement:
},
]
_, result = convert_messages_to_anthropic(messages)
blocks = result[0]["content"]
blocks = next(m for m in result if m["role"] == "assistant")["content"]
thinking = [b for b in blocks if b.get("type") == "thinking"]
assert len(thinking) == 1
assert thinking[0]["signature"] == "sig_valid"
@ -2163,7 +2204,7 @@ class TestThinkingBlockSignatureManagement:
},
]
_, result = convert_messages_to_anthropic(messages)
blocks = result[0]["content"]
blocks = next(m for m in result if m["role"] == "assistant")["content"]
# No thinking blocks should remain
assert not any(b.get("type") == "thinking" for b in blocks)
@ -2183,7 +2224,7 @@ class TestThinkingBlockSignatureManagement:
},
]
_, result = convert_messages_to_anthropic(messages)
blocks = result[0]["content"]
blocks = next(m for m in result if m["role"] == "assistant")["content"]
redacted = [b for b in blocks if b.get("type") == "redacted_thinking"]
assert len(redacted) == 1
assert redacted[0]["data"] == "opaque_signature_data"
@ -2277,7 +2318,7 @@ class TestThinkingBlockSignatureManagement:
_, result = convert_messages_to_anthropic(messages)
# First assistant is non-last, so thinking is stripped completely.
# The original content was empty and thinking was unsigned → placeholder
first_assistant = result[0]
first_assistant = next(m for m in result if m["role"] == "assistant")
assert first_assistant["role"] == "assistant"
assert len(first_assistant["content"]) >= 1