diff --git a/src/bedrock_agentcore/memory/integrations/strands/__init__.py b/src/bedrock_agentcore/memory/integrations/strands/__init__.py index 9f16293..26a9d04 100644 --- a/src/bedrock_agentcore/memory/integrations/strands/__init__.py +++ b/src/bedrock_agentcore/memory/integrations/strands/__init__.py @@ -1 +1,5 @@ """Strands integration for Bedrock AgentCore Memory.""" + +from .converters import MemoryConverter, OpenAIConverseConverter + +__all__ = ["MemoryConverter", "OpenAIConverseConverter"] diff --git a/src/bedrock_agentcore/memory/integrations/strands/bedrock_converter.py b/src/bedrock_agentcore/memory/integrations/strands/bedrock_converter.py index b9c06c4..90a2661 100644 --- a/src/bedrock_agentcore/memory/integrations/strands/bedrock_converter.py +++ b/src/bedrock_agentcore/memory/integrations/strands/bedrock_converter.py @@ -8,7 +8,9 @@ logger = logging.getLogger(__name__) -CONVERSATIONAL_MAX_SIZE = 9000 +# Bedrock AgentCore Data Plane conversational payload text max is 100000 chars. +# Ref: https://docs.aws.amazon.com/cli/latest/reference/bedrock-agentcore/create-event.html +CONVERSATIONAL_MAX_SIZE = 100000 class AgentCoreMemoryConverter: diff --git a/src/bedrock_agentcore/memory/integrations/strands/config.py b/src/bedrock_agentcore/memory/integrations/strands/config.py index e41f531..8e9c666 100644 --- a/src/bedrock_agentcore/memory/integrations/strands/config.py +++ b/src/bedrock_agentcore/memory/integrations/strands/config.py @@ -33,6 +33,8 @@ class AgentCoreMemoryConfig(BaseModel): Default of 1 means immediate sending (no batching). Max 100. context_tag: XML tag name used to wrap retrieved memory context injected into messages. Default is "user_context". + filter_restored_tool_context: When True, strip historical toolUse/toolResult blocks from + restored messages before loading them into Strands runtime memory. Default is False. """ memory_id: str = Field(min_length=1) @@ -41,3 +43,4 @@ class AgentCoreMemoryConfig(BaseModel): retrieval_config: Optional[Dict[str, RetrievalConfig]] = None batch_size: int = Field(default=1, ge=1, le=100) context_tag: str = Field(default="user_context", min_length=1) + filter_restored_tool_context: bool = Field(default=False) diff --git a/src/bedrock_agentcore/memory/integrations/strands/converters/__init__.py b/src/bedrock_agentcore/memory/integrations/strands/converters/__init__.py new file mode 100644 index 0000000..56d3093 --- /dev/null +++ b/src/bedrock_agentcore/memory/integrations/strands/converters/__init__.py @@ -0,0 +1,9 @@ +"""Converters for Strands <-> STM message formats.""" + +from .openai import OpenAIConverseConverter +from .protocol import MemoryConverter + +__all__ = [ + "OpenAIConverseConverter", + "MemoryConverter", +] diff --git a/src/bedrock_agentcore/memory/integrations/strands/converters/openai.py b/src/bedrock_agentcore/memory/integrations/strands/converters/openai.py new file mode 100644 index 0000000..9c4786c --- /dev/null +++ b/src/bedrock_agentcore/memory/integrations/strands/converters/openai.py @@ -0,0 +1,190 @@ +"""OpenAI-format converter for AgentCore Memory. + +Converts between Strands SessionMessages (Strands-native message shape) and OpenAI message format +stored in AgentCore Memory STM events. +""" + +import json +import logging +from typing import Any, Tuple + +from strands.types.session import SessionMessage + +from .protocol import exceeds_conversational_limit + +logger = logging.getLogger(__name__) + + +def _bedrock_to_openai(message: dict) -> dict: + """Convert a Strands-native message dict to OpenAI message format.""" + role = message.get("role", "user") + content = message.get("content", []) + + if content and "toolResult" in content[0]: + tool_result = content[0]["toolResult"] + text_parts = [c.get("text", "") for c in tool_result.get("content", []) if "text" in c] + result = { + "role": "tool", + "tool_call_id": tool_result["toolUseId"], + "content": "\n".join(text_parts), + } + if "status" in tool_result: + result["status"] = tool_result["status"] + return result + + text_parts = [] + tool_calls = [] + reasoning_blocks: list[dict[str, Any]] = [] + for item in content: + if "text" in item: + text_value = item.get("text") + if isinstance(text_value, str): + text = text_value.strip() + if text: + text_parts.append(text) + elif "reasoningContent" in item: + # OpenAI message shape does not have a stable multi-turn reasoning block field. + # Preserve original block(s) in storage-only extension field for lossless restore. + reasoning_blocks.append(item) + elif "toolUse" in item: + tu = item["toolUse"] + tool_calls.append( + { + "id": tu["toolUseId"], + "type": "function", + "function": { + "name": tu["name"], + "arguments": json.dumps(tu.get("input", {})), + }, + } + ) + + result: dict[str, Any] = {"role": role} + + if tool_calls: + result["content"] = "\n".join(text_parts) if text_parts else None + result["tool_calls"] = tool_calls + else: + result["content"] = "\n".join(text_parts) if text_parts else "" + + if reasoning_blocks: + result["_strands_reasoning_content"] = reasoning_blocks + + return result + + +def _openai_to_bedrock(openai_msg: dict) -> dict: + """Convert an OpenAI message dict to Strands-native message shape.""" + role = openai_msg.get("role", "user") + content_items: list[dict[str, Any]] = [] + + if role == "tool": + tool_result: dict[str, Any] = { + "toolUseId": openai_msg["tool_call_id"], + "content": [{"text": openai_msg.get("content", "")}], + } + if "status" in openai_msg: + tool_result["status"] = openai_msg["status"] + return { + "role": "user", + "content": [{"toolResult": tool_result}], + } + + if role == "system": + return { + "role": "user", + "content": [{"text": openai_msg.get("content", "")}], + } + + text_content = openai_msg.get("content") + if text_content and isinstance(text_content, str): + content_items.append({"text": text_content}) + + for tc in openai_msg.get("tool_calls", []): + fn = tc.get("function", {}) + args_str = fn.get("arguments", "{}") + try: + args = json.loads(args_str) + except (json.JSONDecodeError, ValueError): + args = {} + content_items.append( + { + "toolUse": { + "toolUseId": tc["id"], + "name": fn["name"], + "input": args, + } + } + ) + + for rc in openai_msg.get("_strands_reasoning_content", []): + if isinstance(rc, dict) and "reasoningContent" in rc: + content_items.append(rc) + + bedrock_role = "assistant" if role == "assistant" else "user" + + return {"role": bedrock_role, "content": content_items} + + +class OpenAIConverseConverter: + """Converts between Strands SessionMessages and OpenAI message format in STM.""" + + @staticmethod + def message_to_payload(session_message: SessionMessage) -> list[Tuple[str, str]]: + """Convert a SessionMessage (Strands-native shape) to OpenAI-format STM payload.""" + message = session_message.message + content = message.get("content", []) + if not content: + return [] + + has_non_empty = any( + (isinstance(item.get("text"), str) and item["text"].strip()) + or "toolUse" in item + or "toolResult" in item + for item in content + ) + if not has_non_empty: + return [] + + openai_msg = _bedrock_to_openai(message) + role = openai_msg.get("role", "user") + return [(json.dumps(openai_msg), role)] + + @staticmethod + def events_to_messages(events: list[dict[str, Any]]) -> list[SessionMessage]: + """Convert STM events containing OpenAI-format messages to SessionMessages.""" + messages: list[SessionMessage] = [] + + for event in reversed(events): + for payload_item in event.get("payload", []): + openai_msg = None + + if "conversational" in payload_item: + conv = payload_item["conversational"] + try: + openai_msg = json.loads(conv["content"]["text"]) + except (json.JSONDecodeError, KeyError, ValueError): + logger.error("Failed to parse conversational payload as OpenAI message") + continue + + elif "blob" in payload_item: + try: + blob_data = json.loads(payload_item["blob"]) + if isinstance(blob_data, (tuple, list)) and len(blob_data) == 2: + openai_msg = json.loads(blob_data[0]) + except (json.JSONDecodeError, ValueError): + logger.error("Failed to parse blob payload: %s", payload_item) + continue + + if openai_msg and isinstance(openai_msg, dict): + bedrock_msg = _openai_to_bedrock(openai_msg) + if bedrock_msg.get("content"): + session_msg = SessionMessage(message=bedrock_msg, message_id=0) + messages.append(session_msg) + + return messages + + @staticmethod + def exceeds_conversational_limit(message: tuple[str, str]) -> bool: + """Check if message exceeds conversational payload size limit.""" + return exceeds_conversational_limit(message) diff --git a/src/bedrock_agentcore/memory/integrations/strands/converters/protocol.py b/src/bedrock_agentcore/memory/integrations/strands/converters/protocol.py new file mode 100644 index 0000000..2ae5943 --- /dev/null +++ b/src/bedrock_agentcore/memory/integrations/strands/converters/protocol.py @@ -0,0 +1,28 @@ +"""Shared protocol and utilities for memory converters.""" + +from typing import Any, Protocol, Tuple + +from strands.types.session import SessionMessage + +CONVERSATIONAL_MAX_SIZE = 100000 + + +class MemoryConverter(Protocol): + """Protocol for converting between Strands messages and STM event payloads.""" + + @staticmethod + def message_to_payload(session_message: SessionMessage) -> list[Tuple[str, str]]: + """Convert SessionMessage to STM event payload format.""" + + @staticmethod + def events_to_messages(events: list[dict[str, Any]]) -> list[SessionMessage]: + """Convert STM events to SessionMessages.""" + + @staticmethod + def exceeds_conversational_limit(message: tuple[str, str]) -> bool: + """Check if message exceeds conversational payload size limit.""" + + +def exceeds_conversational_limit(message: tuple[str, str]) -> bool: + """Check if message exceeds the conversational payload size limit.""" + return sum(len(text) for text in message) >= CONVERSATIONAL_MAX_SIZE diff --git a/src/bedrock_agentcore/memory/integrations/strands/session_manager.py b/src/bedrock_agentcore/memory/integrations/strands/session_manager.py index a40bcd8..78a0da7 100644 --- a/src/bedrock_agentcore/memory/integrations/strands/session_manager.py +++ b/src/bedrock_agentcore/memory/integrations/strands/session_manager.py @@ -20,10 +20,16 @@ from typing_extensions import override from bedrock_agentcore.memory.client import MemoryClient -from bedrock_agentcore.memory.models.filters import EventMetadataFilter, LeftExpression, OperatorType, RightExpression +from bedrock_agentcore.memory.models.filters import ( + EventMetadataFilter, + LeftExpression, + OperatorType, + RightExpression, +) from .bedrock_converter import AgentCoreMemoryConverter from .config import AgentCoreMemoryConfig, RetrievalConfig +from .converters import MemoryConverter if TYPE_CHECKING: from strands.agent.agent import Agent @@ -98,6 +104,7 @@ def _get_monotonic_timestamp(cls, desired_timestamp: Optional[datetime] = None) def __init__( self, agentcore_memory_config: AgentCoreMemoryConfig, + converter: Optional[type[MemoryConverter]] = None, region_name: Optional[str] = None, boto_session: Optional[boto3.Session] = None, boto_client_config: Optional[BotocoreConfig] = None, @@ -107,12 +114,15 @@ def __init__( Args: agentcore_memory_config (AgentCoreMemoryConfig): Configuration for AgentCore Memory integration. + converter (Optional[type[MemoryConverter]], optional): Optional custom converter. + If None, native Bedrock/Strands converter is used. region_name (Optional[str], optional): AWS region for Bedrock AgentCore Memory. Defaults to None. boto_session (Optional[boto3.Session], optional): Optional boto3 session. Defaults to None. boto_client_config (Optional[BotocoreConfig], optional): Optional boto3 client configuration. Defaults to None. **kwargs (Any): Additional keyword arguments. """ + self.converter = converter self.config = agentcore_memory_config self.memory_client = MemoryClient(region_name=region_name) session = boto_session or boto3.Session(region_name=region_name) @@ -417,11 +427,12 @@ def create_message( raise SessionException(f"Session ID mismatch: expected {self.config.session_id}, got {session_id}") # Convert and check size ONCE (not again at flush) - messages = AgentCoreMemoryConverter.message_to_payload(session_message) + converter = self.converter or AgentCoreMemoryConverter + messages = converter.message_to_payload(session_message) if not messages: return None - is_blob = AgentCoreMemoryConverter.exceeds_conversational_limit(messages[0]) + is_blob = converter.exceeds_conversational_limit(messages[0]) # Parse the original timestamp and use it as desired timestamp original_timestamp = datetime.fromisoformat(session_message.created_at.replace("Z", "+00:00")) @@ -545,7 +556,10 @@ def list_messages( session_id=session_id, max_results=max_results, ) - messages = AgentCoreMemoryConverter.events_to_messages(events) + converter = self.converter or AgentCoreMemoryConverter + messages = converter.events_to_messages(events) + if self.config.filter_restored_tool_context: + messages = self._filter_restored_tool_context(messages) if limit is not None: return messages[offset : offset + limit] else: @@ -555,6 +569,33 @@ def list_messages( logger.error("Failed to list messages from AgentCore Memory: %s", e) return [] + def _filter_restored_tool_context(self, messages: list[SessionMessage]) -> list[SessionMessage]: + """Strip historical toolUse/toolResult context from restored messages.""" + filtered_messages: list[SessionMessage] = [] + for session_message in messages: + message = session_message.to_message() + filtered_content = [ + content + for content in message.get("content", []) + if "toolUse" not in content and "toolResult" not in content + ] + + if not filtered_content: + continue + + filtered_message: Message = {"role": message["role"], "content": filtered_content} + filtered_messages.append( + SessionMessage( + message=filtered_message, + message_id=session_message.message_id, + redact_message=session_message.redact_message, + created_at=session_message.created_at, + updated_at=session_message.updated_at, + ) + ) + + return filtered_messages + # endregion SessionRepository interface implementation # region RepositorySessionManager overrides @@ -682,7 +723,8 @@ def _flush_messages(self) -> list[dict[str, Any]]: Messages are batched by session_id - all conversational messages for the same session are combined into a single create_event() call to reduce API calls. - Blob messages (>9KB) are sent individually as they require a different API path. + Messages that exceed the conversational payload limit are sent as blob events individually + as they require a different API path. Returns: list[dict[str, Any]]: List of created event responses from AgentCore Memory. diff --git a/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py b/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py index 19b3ec2..247a18a 100644 --- a/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py +++ b/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py @@ -11,7 +11,10 @@ from strands.types.exceptions import SessionException from strands.types.session import Session, SessionAgent, SessionMessage, SessionType -from bedrock_agentcore.memory.integrations.strands.bedrock_converter import AgentCoreMemoryConverter +from bedrock_agentcore.memory.integrations.strands.bedrock_converter import ( + CONVERSATIONAL_MAX_SIZE, + AgentCoreMemoryConverter, +) from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig, RetrievalConfig from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager @@ -1599,7 +1602,7 @@ def track_blob_event(**kwargs): # Add multiple blob messages (>9KB each) for i in range(3): - large_text = f"blob_{i}_" + "x" * 10000 + large_text = f"blob_{i}_" + "x" * (CONVERSATIONAL_MAX_SIZE + 100) message = SessionMessage( message={"role": "user", "content": [{"text": large_text}]}, message_id=i, @@ -1643,7 +1646,7 @@ def track_blob_event(**kwargs): # Directly populate buffer with mixed messages base_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) - blob_content = {"role": "user", "content": [{"text": "blob_A_" + "x" * 10000}]} + blob_content = {"role": "user", "content": [{"text": "blob_A_" + "x" * (CONVERSATIONAL_MAX_SIZE + 100)}]} batching_session_manager._message_buffer = [ # Session A: 2 conversational messages ("session-A", [("SessionA_conv_0", "user")], False, base_time), @@ -1843,8 +1846,8 @@ def test_blob_message_sent_via_gmdp_client(self, batching_session_manager, mock_ """Test large messages (blobs) are sent via gmdp_client.""" mock_memory_client.gmdp_client.create_event.return_value = {"event": {"eventId": "blob_event_123"}} - # Create a message that exceeds CONVERSATIONAL_MAX_SIZE (9000) - large_text = "x" * 10000 + # Create a message that exceeds CONVERSATIONAL_MAX_SIZE. + large_text = "x" * (CONVERSATIONAL_MAX_SIZE + 100) message = SessionMessage( message={"role": "user", "content": [{"text": large_text}]}, message_id=1, @@ -1874,7 +1877,7 @@ def test_mixed_conversational_and_blob_messages(self, batching_session_manager, batching_session_manager.create_message("test-session-456", "test-agent", small_message) # Add large (blob) message - large_text = "x" * 10000 + large_text = "x" * (CONVERSATIONAL_MAX_SIZE + 100) large_message = SessionMessage( message={"role": "user", "content": [{"text": large_text}]}, message_id=2, diff --git a/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager_openai_converter.py b/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager_openai_converter.py new file mode 100644 index 0000000..57a23d6 --- /dev/null +++ b/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager_openai_converter.py @@ -0,0 +1,125 @@ +"""Session manager tests with OpenAI converter.""" +from unittest.mock import Mock, patch + +from strands.types.session import Session, SessionMessage, SessionType + +from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig +from bedrock_agentcore.memory.integrations.strands.converters import OpenAIConverseConverter +from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager + + +def test_create_message_uses_tool_role_with_openai_converter(): + """When configured with OpenAI converter, create_event should receive TOOL role for tool outputs.""" + config = AgentCoreMemoryConfig( + memory_id="test-memory-123", + session_id="test-session-456", + actor_id="test-actor-789", + ) + + mock_memory_client = Mock() + mock_memory_client.create_event.return_value = {"eventId": "event_123"} + mock_memory_client.list_events.return_value = [] + mock_memory_client.gmcp_client = Mock() + mock_memory_client.gmdp_client = Mock() + + with ( + patch( + "bedrock_agentcore.memory.integrations.strands.session_manager.MemoryClient", + return_value=mock_memory_client, + ), + patch("boto3.Session") as mock_boto_session, + patch("strands.session.repository_session_manager.RepositorySessionManager.__init__", return_value=None), + ): + mock_session = Mock() + mock_session.region_name = "us-west-2" + mock_session.client.return_value = Mock() + mock_boto_session.return_value = mock_session + + manager = AgentCoreMemorySessionManager(config, converter=OpenAIConverseConverter) + manager.session_id = config.session_id + manager.session = Session(session_id=config.session_id, session_type=SessionType.AGENT) + + message = SessionMessage( + message={ + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": "call_777", + "content": [{"text": "ok"}], + "status": "success", + } + } + ], + }, + message_id=1, + created_at="2024-01-01T12:00:00Z", + ) + + manager.create_message(config.session_id, "agent-1", message) + + kwargs = mock_memory_client.create_event.call_args.kwargs + assert kwargs["messages"][0][1] == "tool" + + +def test_list_messages_filters_restored_tool_context(): + """Restored history should exclude toolUse/toolResult blocks.""" + config = AgentCoreMemoryConfig( + memory_id="test-memory-123", + session_id="test-session-456", + actor_id="test-actor-789", + filter_restored_tool_context=True, + ) + + mock_memory_client = Mock() + mock_memory_client.list_events.return_value = [{"payload": []}] + mock_memory_client.gmcp_client = Mock() + mock_memory_client.gmdp_client = Mock() + + with ( + patch( + "bedrock_agentcore.memory.integrations.strands.session_manager.MemoryClient", + return_value=mock_memory_client, + ), + patch("boto3.Session") as mock_boto_session, + patch("strands.session.repository_session_manager.RepositorySessionManager.__init__", return_value=None), + ): + mock_session = Mock() + mock_session.region_name = "us-west-2" + mock_session.client.return_value = Mock() + mock_boto_session.return_value = mock_session + + manager = AgentCoreMemorySessionManager(config, converter=OpenAIConverseConverter) + manager.session_id = config.session_id + manager.session = Session(session_id=config.session_id, session_type=SessionType.AGENT) + + manager.converter = Mock() + manager.converter.events_to_messages.return_value = [ + SessionMessage(message={"role": "user", "content": [{"text": "hello"}]}, message_id=0), + SessionMessage( + message={ + "role": "assistant", + "content": [ + {"text": "calling tool"}, + {"toolUse": {"toolUseId": "t1", "name": "foo", "input": {}}}, + ], + }, + message_id=1, + ), + SessionMessage( + message={ + "role": "user", + "content": [{"toolResult": {"toolUseId": "t1", "status": "success", "content": [{"text": "ok"}]}}], + }, + message_id=2, + ), + SessionMessage(message={"role": "assistant", "content": [{"text": "done"}]}, message_id=3), + ] + + messages = manager.list_messages(config.session_id, "agent-1") + + assert [m.message for m in messages] == [ + {"role": "user", "content": [{"text": "hello"}]}, + {"role": "assistant", "content": [{"text": "calling tool"}]}, + {"role": "assistant", "content": [{"text": "done"}]}, + ] diff --git a/tests/bedrock_agentcore/memory/integrations/strands/test_bedrock_converter.py b/tests/bedrock_agentcore/memory/integrations/strands/test_bedrock_converter.py index c44175f..a1f66bd 100644 --- a/tests/bedrock_agentcore/memory/integrations/strands/test_bedrock_converter.py +++ b/tests/bedrock_agentcore/memory/integrations/strands/test_bedrock_converter.py @@ -107,7 +107,7 @@ def test_exceeds_conversational_limit_false(self): def test_exceeds_conversational_limit_true(self): """Test message over conversational limit.""" - long_text = "x" * 5000 + long_text = "x" * 60000 message = (long_text, long_text) result = AgentCoreMemoryConverter.exceeds_conversational_limit(message) assert result is True diff --git a/tests/bedrock_agentcore/memory/integrations/strands/test_openai_converter.py b/tests/bedrock_agentcore/memory/integrations/strands/test_openai_converter.py new file mode 100644 index 0000000..7b94db9 --- /dev/null +++ b/tests/bedrock_agentcore/memory/integrations/strands/test_openai_converter.py @@ -0,0 +1,125 @@ +"""Tests for OpenAIConverseConverter.""" + +import json + +from strands.types.session import SessionMessage + +from bedrock_agentcore.memory.integrations.strands.converters import OpenAIConverseConverter + + +def test_tool_result_message_serializes_as_openai_tool_role(): + """toolResult messages should be saved with role='tool' for STM.""" + msg = SessionMessage( + message={ + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": "call_123", + "content": [{"text": "72°F and sunny"}], + "status": "success", + } + } + ], + }, + message_id=5, + ) + + result = OpenAIConverseConverter.message_to_payload(msg) + + assert len(result) == 1 + payload_json, role = result[0] + assert role == "tool" + + payload = json.loads(payload_json) + assert payload["role"] == "tool" + assert payload["tool_call_id"] == "call_123" + assert payload["content"] == "72°F and sunny" + + +def test_openai_tool_role_deserializes_to_strands_user_tool_result_message(): + """OpenAI role='tool' should restore as Strands toolResult content on user role.""" + events = [ + { + "payload": [ + { + "conversational": { + "content": { + "text": json.dumps( + { + "role": "tool", + "tool_call_id": "call_321", + "content": "done", + "status": "success", + } + ) + } + } + } + ] + } + ] + + messages = OpenAIConverseConverter.events_to_messages(events) + assert len(messages) == 1 + assert messages[0].message["role"] == "user" + assert messages[0].message["content"][0]["toolResult"]["toolUseId"] == "call_321" + + +def test_reasoning_content_round_trips_in_openai_converter(): + """reasoningContent blocks should be preserved via storage extension field.""" + msg = SessionMessage( + message={ + "role": "assistant", + "content": [ + {"text": "answer"}, + {"reasoningContent": {"reasoningText": {"text": "chain", "signature": "abc"}}}, + ], + }, + message_id=6, + ) + + payload_json, _ = OpenAIConverseConverter.message_to_payload(msg)[0] + payload = json.loads(payload_json) + assert "_strands_reasoning_content" in payload + + events = [{"payload": [{"conversational": {"content": {"text": payload_json}}}]}] + restored = OpenAIConverseConverter.events_to_messages(events) + assert len(restored) == 1 + restored_content = restored[0].message["content"] + assert any("reasoningContent" in block for block in restored_content) + + +def test_openai_converter_round_trips_from_blob_payload(): + msg = SessionMessage( + message={ + "role": "assistant", + "content": [ + {"text": "hello from blob"}, + {"reasoningContent": {"reasoningText": {"text": "trace", "signature": "sig"}}}, + ], + }, + message_id=7, + ) + + payload_json, role = OpenAIConverseConverter.message_to_payload(msg)[0] + events = [{"payload": [{"blob": json.dumps((payload_json, role))}]}] + + restored = OpenAIConverseConverter.events_to_messages(events) + assert len(restored) == 1 + assert restored[0].message["role"] == "assistant" + assert any(block.get("text") == "hello from blob" for block in restored[0].message["content"]) + assert any("reasoningContent" in block for block in restored[0].message["content"]) + + +def test_openai_converter_returns_empty_for_empty_or_none_text_content(): + empty_msg = SessionMessage(message={"role": "assistant", "content": []}, message_id=8) + none_msg = SessionMessage(message={"role": "assistant", "content": [{"text": None}]}, message_id=9) + + assert OpenAIConverseConverter.message_to_payload(empty_msg) == [] + assert OpenAIConverseConverter.message_to_payload(none_msg) == [] + + +def test_openai_converter_detects_oversized_payload(): + oversized_payload = ("x" * 100000, "assistant") + assert OpenAIConverseConverter.exceeds_conversational_limit(oversized_payload) is True