diff --git a/kiro/converters_anthropic.py b/kiro/converters_anthropic.py index ea297dc1..db97ff8f 100644 --- a/kiro/converters_anthropic.py +++ b/kiro/converters_anthropic.py @@ -24,7 +24,7 @@ to the unified format used by converters_core.py. """ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from loguru import logger @@ -75,6 +75,50 @@ def convert_anthropic_content_to_text(content: Any) -> str: return str(content) if content else "" +def extract_inline_system_messages( + messages: List[AnthropicMessage], +) -> Tuple[List[AnthropicMessage], str]: + """ + Separates inline system-role messages from the conversation. + + The Anthropic API carries the system prompt in a dedicated top-level + ``system`` field, but some clients (Claude Code 2.1.x and its IDE + extensions) also inline ``{"role": "system"}`` entries into the messages + array to deliver runtime context such as ```` blocks. + + Those entries are not part of the user/assistant turn structure that Kiro + expects, so they are peeled off here and merged into the system prompt. + This mirrors convert_openai_messages_to_unified(), which already extracts + system messages the same way, keeping both API surfaces consistent. + + Args: + messages: List of Anthropic messages, possibly containing system roles + + Returns: + Tuple of: + - List of messages with system-role entries removed + - Combined text of the extracted system messages ("" if none) + """ + conversation_messages = [] + system_parts = [] + + for msg in messages: + if msg.role == "system": + text = convert_anthropic_content_to_text(msg.content) + if text: + system_parts.append(text) + else: + conversation_messages.append(msg) + + if system_parts: + logger.debug( + f"Extracted {len(system_parts)} inline system message(s) from messages array " + f"and merged into system prompt" + ) + + return conversation_messages, "\n".join(system_parts) + + def extract_system_prompt(system: Any) -> str: """ Extracts system prompt text from Anthropic system field. @@ -450,8 +494,18 @@ def anthropic_to_kiro( Raises: ValueError: If there are no messages to send """ + # Peel off any inline system-role messages before building the conversation + # (Claude Code 2.1.x inlines context this way) + conversation_messages, inline_system_prompt = extract_inline_system_messages(request.messages) + # Convert messages to unified format - unified_messages = convert_anthropic_messages(request.messages) + unified_messages = convert_anthropic_messages(conversation_messages) + + # If the request contained nothing but system messages, keep a minimal user + # turn so the payload stays valid (Kiro requires a current user message) + if not unified_messages: + logger.debug("All messages were system-role; inserting placeholder user message") + unified_messages = [UnifiedMessage(role="user", content="(empty placeholder)")] # Convert tools to unified format unified_tools = convert_anthropic_tools(request.tools) @@ -460,6 +514,13 @@ def anthropic_to_kiro( # It can be a string or list of content blocks (for prompt caching) system_prompt = extract_system_prompt(request.system) + # Append inline system content after the top-level system prompt, preserving + # the order the client sent it in + if inline_system_prompt: + system_prompt = ( + f"{system_prompt}\n{inline_system_prompt}" if system_prompt else inline_system_prompt + ) + # Get model ID for Kiro API (normalizes + resolves hidden models) # Pass-through principle: we normalize and send to Kiro, Kiro decides if valid model_id = get_model_id_for_kiro(request.model, HIDDEN_MODELS) diff --git a/kiro/models_anthropic.py b/kiro/models_anthropic.py index c63d60ba..f1f1efdf 100644 --- a/kiro/models_anthropic.py +++ b/kiro/models_anthropic.py @@ -183,11 +183,15 @@ class AnthropicMessage(BaseModel): Message in Anthropic format. Attributes: - role: Message role (user or assistant) + role: Message role. Normally "user" or "assistant", but accepted as a + free-form string: clients (e.g. Claude Code) inject mid-conversation + "system" messages, and other roles such as "developer" appear in the + wild. Unknown roles are normalized to "user" downstream by + normalize_message_roles() before the payload reaches Kiro. content: Message content (string or list of content blocks) """ - role: Literal["user", "assistant"] + role: str content: Union[str, List[ContentBlock]] model_config = {"extra": "allow"} diff --git a/tests/unit/test_converters_anthropic.py b/tests/unit/test_converters_anthropic.py index b2b37402..cd54f884 100644 --- a/tests/unit/test_converters_anthropic.py +++ b/tests/unit/test_converters_anthropic.py @@ -22,6 +22,7 @@ extract_images_from_tool_results, extract_tool_uses_from_anthropic_content, convert_anthropic_messages, + extract_inline_system_messages, convert_anthropic_tools, anthropic_to_kiro, extract_thinking_config_from_anthropic, @@ -1884,3 +1885,219 @@ def test_extracts_and_passes_thinking_config(self): print(f"Checking for 6000...") assert "6000" in content assert "enabled" in content + + +# ================================================================================================== +# Tests for extract_inline_system_messages +# ================================================================================================== + +class TestExtractInlineSystemMessages: + """ + Tests for extract_inline_system_messages function. + + Regression coverage for issues #190, #219, #226, #255: Claude Code 2.1.x + inlines role="system" messages into the messages array. + """ + + def test_extracts_single_system_message(self): + """ + What it does: Verifies a system message is peeled off and its text returned. + Purpose: Inline system content must leave the conversation and become system prompt. + """ + print("Setup: user + system + user messages...") + messages = [ + AnthropicMessage(role="user", content="First"), + AnthropicMessage(role="system", content="Context"), + AnthropicMessage(role="user", content="Second"), + ] + + print("Action: Extracting inline system messages...") + conversation, system_text = extract_inline_system_messages(messages) + + print(f"Comparing conversation length: Expected 2, Got {len(conversation)}") + assert len(conversation) == 2 + assert all(m.role == "user" for m in conversation) + print("Checking extracted system text...") + assert system_text == "Context" + + def test_extracts_system_message_with_content_blocks(self): + """ + What it does: Verifies extraction from list-of-blocks content. + Purpose: Claude Code sends system content as blocks with cache_control. + """ + print("Setup: system message with content blocks...") + messages = [ + AnthropicMessage(role="user", content="Hi"), + AnthropicMessage( + role="system", + content=[{"type": "text", "text": "Agent types: claude", "cache_control": {"type": "ephemeral"}}], + ), + ] + + print("Action: Extracting inline system messages...") + conversation, system_text = extract_inline_system_messages(messages) + + print(f"Comparing conversation length: Expected 1, Got {len(conversation)}") + assert len(conversation) == 1 + print("Checking text extracted, cache_control ignored...") + assert system_text == "Agent types: claude" + + def test_joins_multiple_system_messages_in_order(self): + """ + What it does: Verifies multiple system messages are joined in send order. + Purpose: Ordering must be preserved so context reads coherently. + """ + print("Setup: two system messages...") + messages = [ + AnthropicMessage(role="system", content="First rule"), + AnthropicMessage(role="user", content="Question"), + AnthropicMessage(role="system", content="Second rule"), + ] + + print("Action: Extracting inline system messages...") + conversation, system_text = extract_inline_system_messages(messages) + + print(f"Comparing conversation length: Expected 1, Got {len(conversation)}") + assert len(conversation) == 1 + print(f"Comparing joined text: Got {system_text!r}") + assert system_text == "First rule\nSecond rule" + + def test_no_system_messages_returns_unchanged(self): + """ + What it does: Verifies a normal conversation passes through untouched. + Purpose: Ensure the common path is not altered. + """ + print("Setup: normal user/assistant conversation...") + messages = [ + AnthropicMessage(role="user", content="Hi"), + AnthropicMessage(role="assistant", content="Hello"), + ] + + print("Action: Extracting inline system messages...") + conversation, system_text = extract_inline_system_messages(messages) + + print(f"Comparing conversation length: Expected 2, Got {len(conversation)}") + assert len(conversation) == 2 + print(f"Comparing system text: Expected '', Got {system_text!r}") + assert system_text == "" + + def test_skips_empty_system_message(self): + """ + What it does: Verifies an empty system message adds no stray separator. + Purpose: Avoid injecting blank lines into the system prompt. + """ + print("Setup: system message with empty content...") + messages = [ + AnthropicMessage(role="user", content="Hi"), + AnthropicMessage(role="system", content=""), + ] + + print("Action: Extracting inline system messages...") + conversation, system_text = extract_inline_system_messages(messages) + + print(f"Comparing system text: Expected '', Got {system_text!r}") + assert system_text == "" + assert len(conversation) == 1 + + +class TestInlineSystemMessagesEndToEnd: + """Integration tests for inline system messages through anthropic_to_kiro.""" + + def test_system_role_accepted_by_model(self): + """ + What it does: Verifies AnthropicMessage accepts role="system". + Purpose: The 422 in issues #190/#219/#226/#255 happened at Pydantic validation. + """ + print("Creating a message with role='system'...") + msg = AnthropicMessage(role="system", content="Runtime context") + + print(f"Comparing role: Expected 'system', Got {msg.role}") + assert msg.role == "system" + + def test_inline_system_merged_into_system_prompt(self): + """ + What it does: Verifies inline system text lands in the system prompt, and no + synthetic assistant turn is fabricated between the user turns. + Purpose: The exact failing shape from issue #255 must convert cleanly. + """ + print("Creating request: user message followed by inline system message...") + request = AnthropicMessagesRequest( + model="claude-sonnet-4.5", + messages=[ + AnthropicMessage(role="user", content="What is the branch structure?"), + AnthropicMessage(role="system", content="INLINE_CONTEXT_MARKER"), + ], + max_tokens=1024, + system=[{"type": "text", "text": "BASE_PROMPT_MARKER"}], + ) + + print("Calling anthropic_to_kiro...") + with patch("kiro.converters_anthropic.get_model_id_for_kiro", return_value="claude-sonnet-4.5"): + payload = anthropic_to_kiro(request, "test-conv-123", "arn:aws:test") + + conversation_state = payload["conversationState"] + history = conversation_state.get("history", []) + content = conversation_state["currentMessage"]["userInputMessage"]["content"] + + print(f"Comparing history length: Expected 0, Got {len(history)}") + assert len(history) == 0, "No synthetic assistant turn should be fabricated" + + print("Checking system prompt ordering: base before inline...") + assert "BASE_PROMPT_MARKER" in content + assert "INLINE_CONTEXT_MARKER" in content + assert content.index("BASE_PROMPT_MARKER") < content.index("INLINE_CONTEXT_MARKER") + + print("Checking the user's actual question survived...") + assert "What is the branch structure?" in content + + def test_system_only_request_gets_placeholder_user_turn(self): + """ + What it does: Verifies a messages array of only system entries still converts. + Purpose: Stripping all messages must not raise "No messages to send". + """ + print("Creating request with only a system message...") + request = AnthropicMessagesRequest( + model="claude-sonnet-4.5", + messages=[AnthropicMessage(role="system", content="ONLY_SYSTEM_MARKER")], + max_tokens=1024, + ) + + print("Calling anthropic_to_kiro...") + with patch("kiro.converters_anthropic.get_model_id_for_kiro", return_value="claude-sonnet-4.5"): + payload = anthropic_to_kiro(request, "test-conv-123", "arn:aws:test") + + content = payload["conversationState"]["currentMessage"]["userInputMessage"]["content"] + + print("Checking system text preserved and placeholder inserted...") + assert "ONLY_SYSTEM_MARKER" in content + assert "(empty placeholder)" in content + + def test_normal_conversation_unaffected(self): + """ + What it does: Verifies a standard conversation still builds normal history. + Purpose: Guard against regression on the common path. + """ + print("Creating a normal multi-turn request...") + request = AnthropicMessagesRequest( + model="claude-sonnet-4.5", + messages=[ + AnthropicMessage(role="user", content="First question"), + AnthropicMessage(role="assistant", content="First answer"), + AnthropicMessage(role="user", content="Second question"), + ], + max_tokens=1024, + ) + + print("Calling anthropic_to_kiro...") + with patch("kiro.converters_anthropic.get_model_id_for_kiro", return_value="claude-sonnet-4.5"): + payload = anthropic_to_kiro(request, "test-conv-123", "arn:aws:test") + + conversation_state = payload["conversationState"] + history = conversation_state.get("history", []) + content = conversation_state["currentMessage"]["userInputMessage"]["content"] + + print(f"Comparing history length: Expected 2 entries, Got {len(history)}") + assert len(history) == 2 + assert "userInputMessage" in history[0] + assert "assistantResponseMessage" in history[1] + assert "Second question" in content diff --git a/tests/unit/test_routes_anthropic.py b/tests/unit/test_routes_anthropic.py index d156f66a..31c25ef8 100644 --- a/tests/unit/test_routes_anthropic.py +++ b/tests/unit/test_routes_anthropic.py @@ -337,12 +337,13 @@ def test_validates_invalid_json(self, test_client, valid_proxy_api_key): print(f"Status: {response.status_code}") assert response.status_code == 422 - def test_validates_invalid_role(self, test_client, valid_proxy_api_key): + def test_accepts_unknown_role(self, test_client, valid_proxy_api_key): """ - What it does: Verifies invalid message role is rejected. - Purpose: Anthropic model strictly validates role (only 'user' or 'assistant'). + What it does: Verifies an unknown message role is not rejected at validation. + Purpose: Clients (e.g. Claude Code) inject mid-conversation 'system' messages. + Unknown roles are normalized to 'user' downstream, so they must not 422. """ - print("Action: POST /v1/messages with invalid role...") + print("Action: POST /v1/messages with an unknown role...") response = test_client.post( "/v1/messages", headers={"x-api-key": valid_proxy_api_key}, @@ -352,10 +353,10 @@ def test_validates_invalid_role(self, test_client, valid_proxy_api_key): "messages": [{"role": "invalid_role", "content": "Hello"}] } ) - + print(f"Status: {response.status_code}") - # Anthropic model strictly validates role - only 'user' or 'assistant' allowed - assert response.status_code == 422 + # Must pass Pydantic validation; failures beyond that are upstream/auth concerns + assert response.status_code != 422 def test_accepts_valid_request_format(self, test_client, valid_proxy_api_key): """