Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion kiro/converters_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,45 @@ def extract_tool_uses_from_anthropic_content(content: Any) -> List[Dict[str, Any
return tool_calls


def split_inline_system_messages(
messages: List[AnthropicMessage],
) -> tuple[str, List[AnthropicMessage]]:
"""
Separates inline system messages from the conversation.

The Anthropic API carries the system prompt in a dedicated top-level
field, but several clients (Claude Code, Claude Desktop) also place
runtime context in a message with role "system" or "developer" inside
the messages array. Kiro only accepts user/assistant turns, so those
messages are peeled off here and merged into the system prompt, the same
way convert_openai_messages() handles OpenAI system messages.

Args:
messages: List of Anthropic messages, possibly containing inline
system/developer messages

Returns:
Tuple of (inline_system_prompt, conversation_messages)
"""
system_parts = []
conversation = []

for msg in messages:
if msg.role in ("system", "developer"):
text = convert_anthropic_content_to_text(msg.content)
if text:
system_parts.append(text)
else:
conversation.append(msg)

if system_parts:
logger.debug(
f"Merged {len(system_parts)} inline system message(s) into system prompt"
)

return "\n".join(system_parts), conversation


def convert_anthropic_messages(
messages: List[AnthropicMessage],
) -> List[UnifiedMessage]:
Expand Down Expand Up @@ -450,8 +489,13 @@ def anthropic_to_kiro(
Raises:
ValueError: If there are no messages to send
"""
# Peel off inline system messages some clients put in the messages array
inline_system_prompt, conversation_messages = split_inline_system_messages(
request.messages
)

# Convert messages to unified format
unified_messages = convert_anthropic_messages(request.messages)
unified_messages = convert_anthropic_messages(conversation_messages)

# Convert tools to unified format
unified_tools = convert_anthropic_tools(request.tools)
Expand All @@ -460,6 +504,14 @@ def anthropic_to_kiro(
# It can be a string or list of content blocks (for prompt caching)
system_prompt = extract_system_prompt(request.system)

# Top-level system comes first, inline messages are appended in order
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)
Expand Down
8 changes: 7 additions & 1 deletion kiro/models_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,15 @@ class AnthropicMessage(BaseModel):
Attributes:
role: Message role (user or assistant)
content: Message content (string or list of content blocks)

Note:
"system" and "developer" are accepted even though the Anthropic API
rejects them in the messages array: some clients (Claude Desktop,
Claude Code) put reminder/memory blocks there. They are folded into
the user channel by convert_anthropic_messages().
"""

role: Literal["user", "assistant"]
role: Literal["user", "assistant", "system", "developer"]
content: Union[str, List[ContentBlock]]

model_config = {"extra": "allow"}
Expand Down
253 changes: 253 additions & 0 deletions tests/unit/test_converters_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
extract_tool_uses_from_anthropic_content,
convert_anthropic_messages,
convert_anthropic_tools,
split_inline_system_messages,
anthropic_to_kiro,
extract_thinking_config_from_anthropic,
)
Expand Down Expand Up @@ -1884,3 +1885,255 @@ def test_extracts_and_passes_thinking_config(self):
print(f"Checking for <max_thinking_length>6000</max_thinking_length>...")
assert "<max_thinking_length>6000</max_thinking_length>" in content
assert "<thinking_mode>enabled</thinking_mode>" in content


class TestSplitInlineSystemMessages:
"""
Tests for splitting inline system messages out of the conversation.

Claude Code and Claude Desktop place runtime context in a message with
role "system" (or "developer") inside the messages array. Those must be
merged into the system prompt, mirroring the OpenAI conversion path.
"""

def test_extracts_system_message_from_conversation(self):
"""
What it does: Verifies a system message is peeled off the conversation.
Purpose: Kiro only accepts user/assistant turns in history.
"""
print("Setup: user, system, user...")
messages = [
AnthropicMessage(role="user", content="hello"),
AnthropicMessage(role="system", content="<system-reminder>ctx</system-reminder>"),
AnthropicMessage(role="user", content="say ok"),
]

print("Action: Splitting inline system messages...")
system_prompt, conversation = split_inline_system_messages(messages)

print(f"Comparing system prompt: Got {system_prompt!r}")
assert system_prompt == "<system-reminder>ctx</system-reminder>"

print(f"Comparing remaining roles: Got {[m.role for m in conversation]}")
assert [m.role for m in conversation] == ["user", "user"]

def test_extracts_developer_role_message(self):
"""
What it does: Verifies "developer" messages are treated as system.
Purpose: Some clients use the OpenAI-style developer role.
"""
print("Setup: developer message...")
messages = [
AnthropicMessage(role="developer", content="dev context"),
AnthropicMessage(role="user", content="hi"),
]

print("Action: Splitting...")
system_prompt, conversation = split_inline_system_messages(messages)

print(f"Comparing system prompt: Got {system_prompt!r}")
assert system_prompt == "dev context"
assert len(conversation) == 1

def test_joins_multiple_system_messages_in_order(self):
"""
What it does: Verifies several system messages are joined in order.
Purpose: Preserve the order the client sent context in.
"""
print("Setup: two system messages around a user turn...")
messages = [
AnthropicMessage(role="system", content="first"),
AnthropicMessage(role="user", content="hi"),
AnthropicMessage(role="system", content="second"),
]

print("Action: Splitting...")
system_prompt, conversation = split_inline_system_messages(messages)

print(f"Comparing system prompt: Got {system_prompt!r}")
assert system_prompt == "first\nsecond"
assert len(conversation) == 1

def test_extracts_text_from_content_blocks(self):
"""
What it does: Verifies system content in block form is extracted.
Purpose: Claude Code sends blocks with cache_control, not plain strings.
"""
print("Setup: system message with text blocks...")
messages = [
AnthropicMessage(
role="system",
content=[
TextContentBlock(text="block one "),
TextContentBlock(text="block two"),
],
),
AnthropicMessage(role="user", content="hi"),
]

print("Action: Splitting...")
system_prompt, conversation = split_inline_system_messages(messages)

print(f"Comparing system prompt: Got {system_prompt!r}")
assert system_prompt == "block one block two"

def test_skips_system_message_with_empty_content(self):
"""
What it does: Verifies empty system messages add nothing.
Purpose: Avoid injecting blank lines into the system prompt.
"""
print("Setup: empty system message...")
messages = [
AnthropicMessage(role="system", content=""),
AnthropicMessage(role="user", content="hi"),
]

print("Action: Splitting...")
system_prompt, conversation = split_inline_system_messages(messages)

print(f"Comparing system prompt: Expected empty, Got {system_prompt!r}")
assert system_prompt == ""
assert len(conversation) == 1

def test_conversation_without_system_messages_is_unchanged(self):
"""
What it does: Verifies a normal conversation passes through intact.
Purpose: The common case must not be altered.
"""
print("Setup: user/assistant conversation...")
messages = [
AnthropicMessage(role="user", content="hi"),
AnthropicMessage(role="assistant", content="hello"),
]

print("Action: Splitting...")
system_prompt, conversation = split_inline_system_messages(messages)

print(f"Comparing: system empty, conversation length {len(conversation)}")
assert system_prompt == ""
assert conversation == messages

def test_empty_message_list(self):
"""
What it does: Verifies an empty list is handled.
Purpose: Guard the boundary before build_kiro_payload raises.
"""
print("Action: Splitting an empty list...")
system_prompt, conversation = split_inline_system_messages([])

print(f"Comparing: Got {system_prompt!r}, {conversation}")
assert system_prompt == ""
assert conversation == []


class TestAnthropicToKiroInlineSystem:
"""
Tests for inline system messages through the full conversion.

Covers issues #190, #219 and #255: clients inlining role "system" in the
messages array used to fail Pydantic validation with HTTP 422.
"""

def _payload_content(self, request):
"""Runs anthropic_to_kiro and returns the user input content."""
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")
return payload["conversationState"]["currentMessage"]["userInputMessage"]["content"]

def test_inline_system_message_reaches_system_prompt(self):
"""
What it does: Verifies inline system text lands in the Kiro payload.
Purpose: The context the client sent must not be silently dropped.
"""
print("Setup: request with an inline system message...")
request = AnthropicMessagesRequest(
model="claude-sonnet-4.5",
messages=[
AnthropicMessage(role="user", content="hello"),
AnthropicMessage(role="system", content="INLINE_CONTEXT_MARKER"),
AnthropicMessage(role="user", content="say ok"),
],
max_tokens=1024,
)

print("Action: Converting to Kiro payload...")
content = self._payload_content(request)

print("Checking: inline context is present in the payload...")
assert "INLINE_CONTEXT_MARKER" in content

def test_top_level_system_precedes_inline_system(self):
"""
What it does: Verifies top-level system comes before inline system text.
Purpose: The dedicated field is the primary prompt; inline context adds
to it rather than preceding it.
"""
print("Setup: request with both top-level and inline system...")
request = AnthropicMessagesRequest(
model="claude-sonnet-4.5",
system="TOP_LEVEL_MARKER",
messages=[
AnthropicMessage(role="user", content="hello"),
AnthropicMessage(role="system", content="INLINE_MARKER"),
],
max_tokens=1024,
)

print("Action: Converting to Kiro payload...")
content = self._payload_content(request)

print("Checking: both markers present, top-level first...")
assert "TOP_LEVEL_MARKER" in content
assert "INLINE_MARKER" in content
assert content.index("TOP_LEVEL_MARKER") < content.index("INLINE_MARKER")

def test_inline_system_message_is_not_a_conversation_turn(self):
"""
What it does: Verifies the system message is not sent as history.
Purpose: Kiro rejects non user/assistant roles, and a folded-to-user
message would also distort the alternating turn structure.
"""
print("Setup: single user turn plus an inline system message...")
request = AnthropicMessagesRequest(
model="claude-sonnet-4.5",
messages=[
AnthropicMessage(role="system", content="ctx"),
AnthropicMessage(role="user", content="only user turn"),
],
max_tokens=1024,
)

print("Action: Converting to Kiro payload...")
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")

history = payload["conversationState"].get("history", [])
print(f"Comparing history length: Expected 0, Got {len(history)}")
assert len(history) == 0

def test_request_of_only_system_messages_raises(self):
"""
What it does: Verifies a request with no real turns raises ValueError.
Purpose: routes_anthropic converts this into a clear client error
instead of sending an empty conversation to Kiro.
"""
print("Setup: request containing only a system message...")
request = AnthropicMessagesRequest(
model="claude-sonnet-4.5",
messages=[AnthropicMessage(role="system", content="ctx only")],
max_tokens=1024,
)

print("Action: Converting, expecting ValueError...")
with patch(
"kiro.converters_anthropic.get_model_id_for_kiro",
return_value="claude-sonnet-4.5",
):
with pytest.raises(ValueError, match="No messages to send"):
anthropic_to_kiro(request, "test-conv-123", "arn:aws:test")
Loading