diff --git a/ravendb/__init__.py b/ravendb/__init__.py index 172d57b8..112bed1b 100644 --- a/ravendb/__init__.py +++ b/ravendb/__init__.py @@ -115,6 +115,13 @@ GetAiAgentsResponse, AddOrUpdateAiAgentOperation, DeleteAiAgentOperation, + AiConversationDetailLevel, + AiMessageRole, + AiToolCallResult, + AiConversationMessage, + AiConversationMessagesResult, + GetConversationMessagesOptions, + GetConversationMessagesOperation, ) from ravendb.documents.operations.ai import ( ChunkingOptions, diff --git a/ravendb/documents/ai/ai_operations.py b/ravendb/documents/ai/ai_operations.py index 77bf1ad8..dad9074b 100644 --- a/ravendb/documents/ai/ai_operations.py +++ b/ravendb/documents/ai/ai_operations.py @@ -12,6 +12,8 @@ AiAgentConfiguration, AiAgentConfigurationResult, GetAiAgentsResponse, + GetConversationMessagesOptions, + AiConversationMessagesResult, ) @@ -95,6 +97,29 @@ def conversation( return AiConversation(self._store, agent_id, creation_options, conversation_id, change_vector, debug) + def get_conversation_messages( + self, + conversation_id_or_options: "str | GetConversationMessagesOptions", + ) -> "Optional[AiConversationMessagesResult]": + """ + Reads messages from an AI conversation. + + Args: + conversation_id_or_options: Either a conversation document ID string, + or a GetConversationMessagesOptions instance with full control + over paging and filtering. + + Returns: + AiConversationMessagesResult containing the conversation messages. + Returns None if the conversation does not exist. + """ + from ravendb.documents.operations.ai.agents.get_conversation_messages_operation import ( + GetConversationMessagesOperation, + ) + + operation = GetConversationMessagesOperation(conversation_id_or_options) + return self._store.maintenance.send(operation) + def conversation_with_id(self, conversation_id: str, change_vector: str = None) -> AiConversation: """ Continues an existing conversation by its ID. diff --git a/ravendb/documents/operations/ai/agents/__init__.py b/ravendb/documents/operations/ai/agents/__init__.py index d8099d41..44705193 100644 --- a/ravendb/documents/operations/ai/agents/__init__.py +++ b/ravendb/documents/operations/ai/agents/__init__.py @@ -39,6 +39,21 @@ AiConversationParameterOptions, ) +from .ai_conversation_detail_level import AiConversationDetailLevel + +from .ai_conversation_message import ( + AiMessageRole, + AiToolCallResult, + AiConversationMessage, +) + +from .ai_conversation_messages_result import AiConversationMessagesResult + +from .get_conversation_messages_operation import ( + GetConversationMessagesOptions, + GetConversationMessagesOperation, +) + __all__ = [ "AiAgentConfiguration", "AiAgentConfigurationResult", @@ -68,4 +83,11 @@ "GetAiAgentsResponse", "AddOrUpdateAiAgentOperation", "DeleteAiAgentOperation", + "AiConversationDetailLevel", + "AiMessageRole", + "AiToolCallResult", + "AiConversationMessage", + "AiConversationMessagesResult", + "GetConversationMessagesOptions", + "GetConversationMessagesOperation", ] diff --git a/ravendb/documents/operations/ai/agents/ai_conversation_detail_level.py b/ravendb/documents/operations/ai/agents/ai_conversation_detail_level.py new file mode 100644 index 00000000..adeff77f --- /dev/null +++ b/ravendb/documents/operations/ai/agents/ai_conversation_detail_level.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from enum import Enum + + +class AiConversationDetailLevel(Enum): + """ + Controls the level of detail when reading conversation messages. + + Simple: User messages (including attachment-only) and assistant messages + that have content only. System prompts, tool calls, summaries, and + internal messages are excluded. + Detailed: Includes system messages, tool calls with results, and per-message + usage. Summaries and internal messages are excluded. + Full: No filtering — includes all messages: system, tool calls, summaries, + internal. Intended for debugging and future-proofing. + """ + + SIMPLE = "Simple" + DETAILED = "Detailed" + FULL = "Full" diff --git a/ravendb/documents/operations/ai/agents/ai_conversation_message.py b/ravendb/documents/operations/ai/agents/ai_conversation_message.py new file mode 100644 index 00000000..da789066 --- /dev/null +++ b/ravendb/documents/operations/ai/agents/ai_conversation_message.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ravendb.documents.operations.ai.agents.run_conversation_operation import AiUsage + + +class AiMessageRole(Enum): + """Role of a message in an AI conversation.""" + + SYSTEM = "System" + USER = "User" + ASSISTANT = "Assistant" + SUMMARY = "Summary" + INTERNAL = "Internal" + + +def _parse_timestamp(ts_value: Any) -> Optional[datetime]: + """Parse a RavenDB timestamp string into a datetime. + + Handles Z suffix and 7-digit fractional seconds (truncated to 6 for Python 3.9). + """ + if not ts_value: + return None + if isinstance(ts_value, datetime): + return ts_value + if isinstance(ts_value, str): + # Replace Z suffix with +00:00 for Python fromisoformat + ts_str = ts_value.replace("Z", "+00:00") + # Truncate fractional seconds to 6 digits (Python 3.9 limit) + if "." in ts_str: + dot_idx = ts_str.index(".") + remaining = ts_str[dot_idx + 1 :] + frac_end = len(remaining) + for i, c in enumerate(remaining): + if c in ("+", "-") and i > 0: + frac_end = i + break + frac = remaining[:frac_end][:6] + rest = remaining[frac_end:] + ts_str = ts_str[: dot_idx + 1] + frac + rest + try: + return datetime.fromisoformat(ts_str) + except (ValueError, AttributeError): + return None + return None + + +@dataclass +class AiToolCallResult: + """ + Represents a tool call that originated in an assistant message, with its + eventual response (result) and optional sub-conversation ID merged in. + """ + + id: Optional[str] = None + """The tool call ID from the model.""" + + name: Optional[str] = None + """Tool name.""" + + arguments: Optional[str] = None + """Arguments the model passed, as JSON string.""" + + result: Optional[str] = None + """The tool's response content. None if still pending (ActionRequired).""" + + sub_conversation_id: Optional[str] = None + """If this tool call was a sub-agent invocation, the ID of the spawned sub-conversation.""" + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiToolCallResult: + return cls( + id=json_dict.get("Id"), + name=json_dict.get("Name"), + arguments=json_dict.get("Arguments"), + result=json_dict.get("Result"), + sub_conversation_id=json_dict.get("SubConversationId"), + ) + + def to_json(self) -> Dict[str, Any]: + return { + "Id": self.id, + "Name": self.name, + "Arguments": self.arguments, + "Result": self.result, + "SubConversationId": self.sub_conversation_id, + } + + +@dataclass +class AiConversationMessage: + """ + A single message in an AI agent conversation. + + Attributes: + role: The role of the message sender. + content: Text content. When the stored message has multiple text parts, + they are joined with line breaks. None for assistant messages that + only initiated tool calls. + attachments: Attachment file names associated with this message, if any. + timestamp: When this message was recorded (UTC). Guaranteed unique and + monotonic within a conversation — safe to use as a paging cursor. + tool_calls: Tool calls initiated by this assistant message, with their + responses inlined. + usage: Token usage for this message (typically on assistant messages). + sub_conversation_id: For Internal role messages: the ID of the + sub-conversation this message relates to. + """ + + role: Optional[AiMessageRole] = None + content: Optional[str] = None + attachments: Optional[List[str]] = None + timestamp: Optional[datetime] = None + tool_calls: Optional[List[AiToolCallResult]] = None + usage: Optional[Any] = None # AiUsage when resolved + sub_conversation_id: Optional[str] = None + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiConversationMessage: + from ravendb.documents.operations.ai.agents.run_conversation_operation import AiUsage + + role_str = json_dict.get("Role") + if role_str is None: + raise ValueError("Message is missing 'Role' field") + try: + role = AiMessageRole(role_str) + except ValueError: + raise ValueError(f"Unknown AiMessageRole: '{role_str}'") + + content = json_dict.get("Content") + raw_attachments = json_dict.get("Attachments") + attachments = raw_attachments if raw_attachments is not None else None + timestamp = _parse_timestamp(json_dict.get("Timestamp")) + + tool_calls = None + raw_tool_calls = json_dict.get("ToolCalls") + if raw_tool_calls: + tool_calls = [AiToolCallResult.from_json(tc) for tc in raw_tool_calls] + + usage = None + raw_usage = json_dict.get("Usage") + if raw_usage: + usage = AiUsage.from_json(raw_usage) + + sub_conversation_id = json_dict.get("SubConversationId") + + return cls( + role=role, + content=content, + attachments=attachments, + timestamp=timestamp, + tool_calls=tool_calls, + usage=usage, + sub_conversation_id=sub_conversation_id, + ) + + def to_json(self) -> Dict[str, Any]: + return { + "Role": self.role.value if self.role else None, + "Content": self.content, + "Attachments": self.attachments if self.attachments is not None else None, + "Timestamp": self.timestamp.isoformat() if self.timestamp else None, + "ToolCalls": [tc.to_json() for tc in self.tool_calls] if self.tool_calls else None, + "Usage": self.usage.to_json() if self.usage else None, + "SubConversationId": self.sub_conversation_id, + } + + def __repr__(self) -> str: + return ( + f"AiConversationMessage(role={self.role}, content={self.content!r}, " + f"timestamp={self.timestamp}, tool_calls={len(self.tool_calls) if self.tool_calls else 0})" + ) diff --git a/ravendb/documents/operations/ai/agents/ai_conversation_messages_result.py b/ravendb/documents/operations/ai/agents/ai_conversation_messages_result.py new file mode 100644 index 00000000..56d8dc9f --- /dev/null +++ b/ravendb/documents/operations/ai/agents/ai_conversation_messages_result.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ravendb.documents.operations.ai.agents.run_conversation_operation import AiUsage + from ravendb.documents.operations.ai.agents.ai_conversation_message import ( + AiConversationMessage, + ) + + +def _materialize_value(value: Any) -> Any: + """Recursively materialize a parameter value from JSON to proper Python types. + + json.loads already produces native Python types (str, int, float, bool, None, list, dict). + RavenDB internal types like LazyNumberValue never appear in HTTP JSON responses. + This function only handles homogeneous list type coercion and recursive dict traversals. + """ + if value is None: + return None + if isinstance(value, list): + items = [_materialize_value(v) for v in value] + return _to_typed_list(items) + if isinstance(value, dict): + return {k: _materialize_value(v) for k, v in value.items()} + return value + + +def _to_typed_list(items: List[Any]) -> Any: + """Convert a list of values to a typed homogeneous list if possible. + + Mirrors the C# ToTypedList method. Returns the list as-is when already + homogeneous; no-op copies are avoided. + """ + if not items: + return items + + all_string = all(isinstance(item, str) for item in items) + if all_string: + return items + + all_bool = all(isinstance(item, bool) for item in items) + if all_bool: + return items + + all_number = all(isinstance(item, (int, float)) and not isinstance(item, bool) for item in items) + if all_number: + has_double = any(isinstance(item, float) for item in items) + if has_double: + return [float(item) for item in items] + return [int(item) for item in items] + + return items + + +def _materialize_parameters(parameters_dict: Any) -> Dict[str, Any]: + """Convert raw Parameters from JSON into properly typed values.""" + if not parameters_dict: + return {} + result: Dict[str, Any] = {} + for key, value in parameters_dict.items(): + result[key] = _materialize_value(value) + return result + + +@dataclass +class AiConversationMessagesResult: + """ + The result of fetching conversation messages. + + Attributes: + conversation_id: The conversation document ID. + agent: The identifier of the AI agent this conversation belongs to. + parameters: The conversation parameters as a name -> value map, + normalized from the stored format. + total_usage: Cumulative token usage across all turns of this conversation. + last_message_at: When the last message was added to the conversation. + messages: Messages in chronological order (oldest first). + has_more_messages: True if there are more messages beyond the returned page. + sub_conversation_ids: IDs of sub-agent conversations spawned during this + conversation. + attachments: All attachments referenced across the conversation. + """ + + conversation_id: Optional[str] = None + agent: Optional[str] = None + parameters: Dict[str, Any] = field(default_factory=dict) + total_usage: Optional[Any] = None # AiUsage when resolved + last_message_at: Optional[datetime] = None + messages: List[Any] = field(default_factory=list) # List[AiConversationMessage] when resolved + has_more_messages: bool = False + sub_conversation_ids: List[str] = field(default_factory=list) + attachments: List[str] = field(default_factory=list) + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiConversationMessagesResult: + from ravendb.documents.operations.ai.agents.run_conversation_operation import AiUsage + from ravendb.documents.operations.ai.agents.ai_conversation_message import ( + AiConversationMessage, + _parse_timestamp, + ) + + total_usage = None + raw_usage = json_dict.get("TotalUsage") + if raw_usage: + total_usage = AiUsage.from_json(raw_usage) + + last_message_at = _parse_timestamp(json_dict.get("LastMessageAt")) + + messages = [] + raw_messages = json_dict.get("Messages") + if raw_messages: + messages = [AiConversationMessage.from_json(msg) for msg in raw_messages] + + return cls( + conversation_id=json_dict.get("ConversationId"), + agent=json_dict.get("Agent"), + parameters=_materialize_parameters(json_dict.get("Parameters")), + total_usage=total_usage, + last_message_at=last_message_at, + messages=messages, + has_more_messages=json_dict.get("HasMoreMessages", False), + sub_conversation_ids=json_dict.get("SubConversationIds") or [], + attachments=json_dict.get("Attachments") or [], + ) + + def to_json(self) -> Dict[str, Any]: + return { + "ConversationId": self.conversation_id, + "Agent": self.agent, + "Parameters": self.parameters if self.parameters is not None else None, + "TotalUsage": self.total_usage.to_json() if self.total_usage else None, + "LastMessageAt": self.last_message_at.isoformat() if self.last_message_at else None, + "HasMoreMessages": self.has_more_messages, + "SubConversationIds": self.sub_conversation_ids if self.sub_conversation_ids is not None else None, + "Attachments": self.attachments if self.attachments is not None else None, + "Messages": [msg.to_json() for msg in self.messages] if self.messages is not None else None, + } + + def __repr__(self) -> str: + return ( + f"AiConversationMessagesResult(conversation_id={self.conversation_id!r}, " + f"agent={self.agent!r}, messages={len(self.messages)}, " + f"has_more_messages={self.has_more_messages})" + ) diff --git a/ravendb/documents/operations/ai/agents/get_conversation_messages_operation.py b/ravendb/documents/operations/ai/agents/get_conversation_messages_operation.py new file mode 100644 index 00000000..2e7e93d8 --- /dev/null +++ b/ravendb/documents/operations/ai/agents/get_conversation_messages_operation.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Optional +from urllib.parse import quote + +import requests + +from ravendb.documents.conventions import DocumentConventions +from ravendb.documents.operations.definitions import MaintenanceOperation +from ravendb.http.raven_command import RavenCommand +from ravendb.http.server_node import ServerNode +from ravendb.documents.operations.ai.agents.ai_conversation_detail_level import AiConversationDetailLevel +from ravendb.documents.operations.ai.agents.ai_conversation_messages_result import AiConversationMessagesResult + +# RavenDB requires '/' in conversation IDs to be encoded (Uri.EscapeDataString) +_QUOTE_SAFE = "" + + +def _format_datetime_for_url(dt: datetime) -> str: + """Format a datetime for use as a RavenDB query parameter. + + Produces the RavenDB-standard format: yyyy-MM-ddTHH:mm:ss.fffffffZ + (7-digit fractional seconds, Z suffix for UTC). + """ + # Ensure UTC + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + elif dt.tzinfo != timezone.utc: + dt = dt.astimezone(timezone.utc) + # strftime %f gives 6 digits; pad to 7 by appending '0', then append Z + return dt.strftime("%Y-%m-%dT%H:%M:%S.%f") + "0Z" + + +class GetConversationMessagesOptions: + """ + Parameters for reading messages from an AI agent conversation. + + Attributes: + conversation_id: The conversation document ID. Required. + before: Return messages older than this timestamp (exclusive upper bound). + Used for backward paging (scrolling up in a chatbot UI). + after: Return messages newer than this timestamp (exclusive lower bound). + Used for catching up on new messages (e.g., after a Changes() notification). + page_size: Maximum number of messages to return. Default: effectively unlimited + (int.MaxValue = 2147483647). + detail_level: Controls the level of detail in returned messages. + Default: Simple. + """ + + DEFAULT_PAGE_SIZE: int = 2147483647 # int.MaxValue + + def __init__( + self, + conversation_id: Optional[str] = None, + before: Optional[datetime] = None, + after: Optional[datetime] = None, + page_size: int = DEFAULT_PAGE_SIZE, + detail_level: AiConversationDetailLevel = AiConversationDetailLevel.SIMPLE, + ): + self.conversation_id = conversation_id + self.before = before + self.after = after + self.page_size = page_size + self.detail_level = detail_level + + def validate(self) -> None: + """Validates the options and raises appropriate exceptions.""" + if not self.conversation_id or not self.conversation_id.strip(): + raise ValueError("ConversationId cannot be None or empty") + + if self.before is not None and self.after is not None: + raise ValueError("Before and After cannot both be specified.") + + if self.page_size <= 0: + raise ValueError("PageSize must be greater than 0.") + + +class GetConversationMessagesOperation(MaintenanceOperation[AiConversationMessagesResult]): + """ + Reads messages from an AI agent conversation, with optional timestamp-based + paging and view filtering. + """ + + def __init__(self, conversation_id_or_options): + """ + Initialize with either a conversation_id string or a GetConversationMessagesOptions instance. + + Args: + conversation_id_or_options: A conversation ID string or GetConversationMessagesOptions instance. + """ + if isinstance(conversation_id_or_options, GetConversationMessagesOptions): + self._parameters = conversation_id_or_options + elif isinstance(conversation_id_or_options, str): + self._parameters = GetConversationMessagesOptions(conversation_id=conversation_id_or_options) + else: + raise TypeError( + "Expected str or GetConversationMessagesOptions, got " f"{type(conversation_id_or_options).__name__}" + ) + self._parameters.validate() + + def get_command(self, conventions: DocumentConventions) -> RavenCommand[AiConversationMessagesResult]: + return GetConversationMessagesCommand(self._parameters) + + +class GetConversationMessagesCommand(RavenCommand[AiConversationMessagesResult]): + def __init__(self, parameters: GetConversationMessagesOptions): + super().__init__(AiConversationMessagesResult) + self._parameters = parameters + + def is_read_request(self) -> bool: + return True + + def create_request(self, node: ServerNode) -> requests.Request: + url = ( + f"{node.url}/databases/{node.database}/ai/agent/conversation/messages" + f"?conversationId={quote(self._parameters.conversation_id, safe=_QUOTE_SAFE)}" + ) + + if self._parameters.before is not None: + url += f"&before={quote(_format_datetime_for_url(self._parameters.before), safe=_QUOTE_SAFE)}" + if self._parameters.after is not None: + url += f"&after={quote(_format_datetime_for_url(self._parameters.after), safe=_QUOTE_SAFE)}" + + url += f"&pageSize={self._parameters.page_size}" + url += f"&detailLevel={self._parameters.detail_level.value}" + + request = requests.Request("GET", url) + return request + + def set_response(self, response: Optional[str], from_cache: bool) -> None: + if response is None: + self.result = None # 404 — conversation not found + return + + response_json = json.loads(response) + self.result = AiConversationMessagesResult.from_json(response_json) diff --git a/ravendb/tests/ai_agent_tests/test_get_conversation_messages.py b/ravendb/tests/ai_agent_tests/test_get_conversation_messages.py new file mode 100644 index 00000000..da641550 --- /dev/null +++ b/ravendb/tests/ai_agent_tests/test_get_conversation_messages.py @@ -0,0 +1,661 @@ +""" +Unit tests for AiConversation messages API — GetConversationMessages. + +Tests cover: +- AiConversationDetailLevel and AiMessageRole enums +- AiToolCallResult from_json deserialization +- Timestamp parsing (Z suffix, 7-digit fractions) +- Content parsing (multi-part arrays, object content) +- AiConversationMessage.from_json with tool calls, usage, roles +- AiConversationMessagesResult.from_json with parameter materialization +- GetConversationMessagesOptions validation (null, empty, whitespace, Before+After, PageSize) +- GetConversationMessagesOperation string/options overload +- GetConversationMessagesCommand URL creation and set_response +- _format_datetime_for_url correctness +""" + +import json +import unittest +from datetime import datetime, timezone, timedelta + +from ravendb.documents.operations.ai.agents import ( + AiConversationDetailLevel, + AiMessageRole, + AiToolCallResult, + AiConversationMessage, + AiConversationMessagesResult, + GetConversationMessagesOptions, + GetConversationMessagesOperation, + AiUsage, +) +from ravendb.documents.operations.ai.agents.get_conversation_messages_operation import ( + GetConversationMessagesCommand, + _format_datetime_for_url, +) + + +class TestAiConversationDetailLevel(unittest.TestCase): + def test_enum_values(self): + self.assertEqual(AiConversationDetailLevel.SIMPLE.value, "Simple") + self.assertEqual(AiConversationDetailLevel.DETAILED.value, "Detailed") + self.assertEqual(AiConversationDetailLevel.FULL.value, "Full") + + +class TestAiMessageRole(unittest.TestCase): + def test_enum_values(self): + self.assertEqual(AiMessageRole.SYSTEM.value, "System") + self.assertEqual(AiMessageRole.USER.value, "User") + self.assertEqual(AiMessageRole.ASSISTANT.value, "Assistant") + self.assertEqual(AiMessageRole.SUMMARY.value, "Summary") + self.assertEqual(AiMessageRole.INTERNAL.value, "Internal") + + def test_from_valid_string(self): + self.assertEqual(AiMessageRole("System"), AiMessageRole.SYSTEM) + self.assertEqual(AiMessageRole("User"), AiMessageRole.USER) + self.assertEqual(AiMessageRole("Assistant"), AiMessageRole.ASSISTANT) + self.assertEqual(AiMessageRole("Summary"), AiMessageRole.SUMMARY) + self.assertEqual(AiMessageRole("Internal"), AiMessageRole.INTERNAL) + + def test_from_invalid_string_raises(self): + with self.assertRaises(ValueError): + AiMessageRole("Unknown") + with self.assertRaises(ValueError): + AiMessageRole("Tool") + + +class TestAiToolCallResult(unittest.TestCase): + def test_from_json_full(self): + data = { + "Id": "call_abc123", + "Name": "get_orders", + "Arguments": '{"userId": "1"}', + "Result": "Order #42", + "SubConversationId": "SC/1", + } + result = AiToolCallResult.from_json(data) + self.assertEqual(result.id, "call_abc123") + self.assertEqual(result.name, "get_orders") + self.assertEqual(result.arguments, '{"userId": "1"}') + self.assertEqual(result.result, "Order #42") + self.assertEqual(result.sub_conversation_id, "SC/1") + + def test_from_json_minimal(self): + result = AiToolCallResult.from_json({"Id": "call_1", "Name": "get_orders"}) + self.assertEqual(result.id, "call_1") + self.assertEqual(result.name, "get_orders") + self.assertIsNone(result.arguments) + + def test_from_json_empty(self): + result = AiToolCallResult.from_json({}) + self.assertIsNone(result.id) + self.assertIsNone(result.name) + + def test_round_trip(self): + original = AiToolCallResult( + id="call_123", + name="get_weather", + arguments='{"city": "London"}', + result="Sunny, 22C", + sub_conversation_id="sub/1", + ) + json_dict = original.to_json() + restored = AiToolCallResult.from_json(json_dict) + self.assertEqual(original.id, restored.id) + self.assertEqual(original.name, restored.name) + self.assertEqual(original.arguments, restored.arguments) + self.assertEqual(original.result, restored.result) + self.assertEqual(original.sub_conversation_id, restored.sub_conversation_id) + + +class TestTimestampParsing(unittest.TestCase): + def _parse_msg(self, ts_str): + raw = json.dumps({"Role": "User", "Content": "hi", "Timestamp": ts_str}) + return AiConversationMessage.from_json(json.loads(raw)) + + def test_z_suffix(self): + msg = self._parse_msg("2025-03-20T12:34:56.7890123Z") + self.assertIsNotNone(msg.timestamp) + self.assertEqual(msg.timestamp.year, 2025) + self.assertEqual(msg.timestamp.month, 3) + self.assertEqual(msg.timestamp.day, 20) + self.assertEqual(msg.timestamp.hour, 12) + self.assertEqual(msg.timestamp.minute, 34) + self.assertEqual(msg.timestamp.second, 56) + self.assertEqual(msg.timestamp.tzinfo, timezone.utc) + + def test_z_suffix_7_digit_fraction(self): + """7-digit fractional seconds are truncated to 6 digits for Python 3.9.""" + msg = self._parse_msg("2025-03-20T12:34:56.7890123Z") + self.assertEqual(msg.timestamp.microsecond, 789012) + + def test_z_suffix_6_digit_fraction(self): + msg = self._parse_msg("2025-03-20T12:34:56.123456Z") + self.assertEqual(msg.timestamp.microsecond, 123456) + + def test_no_timestamp(self): + raw = json.dumps({"Role": "User", "Content": "hi"}) + msg = AiConversationMessage.from_json(json.loads(raw)) + self.assertIsNone(msg.timestamp) + + def test_null_timestamp(self): + raw = json.dumps({"Role": "User", "Content": "hi", "Timestamp": None}) + msg = AiConversationMessage.from_json(json.loads(raw)) + self.assertIsNone(msg.timestamp) + + +class TestMessageFromJson(unittest.TestCase): + def test_role_fail_fast_unknown(self): + """Unknown roles must raise ValueError, not silently default.""" + raw = json.dumps({"Role": "Tool", "Content": "test", "Timestamp": "2025-01-01T00:00:00Z"}) + with self.assertRaises(ValueError): + AiConversationMessage.from_json(json.loads(raw)) + + def test_role_missing_raises(self): + raw = json.dumps({"Content": "test", "Timestamp": "2025-01-01T00:00:00Z"}) + with self.assertRaises(ValueError): + AiConversationMessage.from_json(json.loads(raw)) + + def test_content_passthrough(self): + """Content passes through from server as-is — normalization is server-side.""" + raw = json.dumps({"Role": "User", "Content": "Hello!", "Timestamp": "2025-01-01T00:00:00Z"}) + msg = AiConversationMessage.from_json(json.loads(raw)) + self.assertEqual(msg.content, "Hello!") + + def test_none_content(self): + raw = json.dumps({"Role": "User", "Content": None, "Timestamp": "2025-01-01T00:00:00Z"}) + msg = AiConversationMessage.from_json(json.loads(raw)) + self.assertIsNone(msg.content) + + def test_user_message(self): + raw = json.dumps( + {"Role": "User", "Content": "Hello", "Timestamp": "2025-01-01T00:00:00Z", "Attachments": ["file1.pdf"]} + ) + msg = AiConversationMessage.from_json(json.loads(raw)) + self.assertEqual(msg.role, AiMessageRole.USER) + self.assertEqual(msg.content, "Hello") + self.assertEqual(msg.attachments, ["file1.pdf"]) + + def test_assistant_with_tool_calls(self): + raw = json.dumps( + { + "Role": "Assistant", + "Content": None, + "Timestamp": "2025-01-01T00:00:00Z", + "ToolCalls": [{"Id": "call_1", "Name": "get_orders", "Arguments": "{}", "Result": "done"}], + } + ) + msg = AiConversationMessage.from_json(json.loads(raw)) + self.assertEqual(msg.role, AiMessageRole.ASSISTANT) + self.assertIsNone(msg.content) + self.assertEqual(len(msg.tool_calls), 1) + self.assertEqual(msg.tool_calls[0].name, "get_orders") + self.assertEqual(msg.tool_calls[0].result, "done") + + def test_assistant_with_usage(self): + raw = json.dumps( + { + "Role": "Assistant", + "Content": "Hello", + "Timestamp": "2025-01-01T00:00:00Z", + "Usage": { + "PromptTokens": 10, + "CompletionTokens": 20, + "TotalTokens": 30, + "CachedTokens": 0, + "ReasoningTokens": 5, + }, + } + ) + msg = AiConversationMessage.from_json(json.loads(raw)) + self.assertIsNotNone(msg.usage) + self.assertEqual(msg.usage.prompt_tokens, 10) + self.assertEqual(msg.usage.reasoning_tokens, 5) + + def test_internal_with_sub_conversation_id(self): + raw = json.dumps( + { + "Role": "Internal", + "Content": "internal msg", + "Timestamp": "2025-01-01T00:00:00Z", + "SubConversationId": "SC/1", + } + ) + msg = AiConversationMessage.from_json(json.loads(raw)) + self.assertEqual(msg.role, AiMessageRole.INTERNAL) + self.assertEqual(msg.sub_conversation_id, "SC/1") + + def test_summary_role(self): + raw = json.dumps({"Role": "Summary", "Content": "Summary", "Timestamp": "2025-01-01T00:00:00Z"}) + msg = AiConversationMessage.from_json(json.loads(raw)) + self.assertEqual(msg.role, AiMessageRole.SUMMARY) + + def test_tool_calls_empty_list_by_default(self): + msg = AiConversationMessage(role=AiMessageRole.USER, content="test") + self.assertIsNone(msg.tool_calls) + + def test_attachments_empty_list_by_default(self): + msg = AiConversationMessage(role=AiMessageRole.USER, content="test") + self.assertIsNone(msg.attachments) + + def test_round_trip(self): + timestamp = datetime(2025, 3, 20, 12, 34, 56, 789012, tzinfo=timezone.utc) + msg = AiConversationMessage( + role=AiMessageRole.USER, content="Hello", attachments=["f.pdf"], timestamp=timestamp + ) + restored = AiConversationMessage.from_json(msg.to_json()) + self.assertEqual(restored.role, AiMessageRole.USER) + self.assertEqual(restored.content, "Hello") + self.assertEqual(restored.attachments, ["f.pdf"]) + + +class TestGetConversationMessagesOptions(unittest.TestCase): + def test_defaults(self): + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A") + self.assertEqual(opts.conversation_id, "Chats/1-A") + self.assertIsNone(opts.before) + self.assertIsNone(opts.after) + self.assertEqual(opts.page_size, 2147483647) + self.assertEqual(opts.detail_level, AiConversationDetailLevel.SIMPLE) + + def test_validate_null(self): + with self.assertRaises(ValueError): + GetConversationMessagesOptions(conversation_id=None).validate() + + def test_validate_empty(self): + with self.assertRaises(ValueError): + GetConversationMessagesOptions(conversation_id="").validate() + + def test_validate_whitespace(self): + with self.assertRaises(ValueError): + GetConversationMessagesOptions(conversation_id=" ").validate() + + def test_validate_before_and_after(self): + opts = GetConversationMessagesOptions( + conversation_id="Chats/1-A", + before=datetime.now(timezone.utc), + after=datetime.now(timezone.utc), + ) + with self.assertRaises(ValueError): + opts.validate() + + def test_validate_page_size_zero(self): + with self.assertRaises(ValueError): + GetConversationMessagesOptions(conversation_id="Chats/1-A", page_size=0).validate() + + def test_validate_page_size_negative(self): + with self.assertRaises(ValueError): + GetConversationMessagesOptions(conversation_id="Chats/1-A", page_size=-5).validate() + + def test_page_size_default_is_large(self): + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A") + self.assertEqual(opts.page_size, 2147483647) + + def test_detail_level_default_is_simple(self): + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A") + self.assertEqual(opts.detail_level, AiConversationDetailLevel.SIMPLE) + + +class TestGetConversationMessagesOperation(unittest.TestCase): + def test_from_string(self): + op = GetConversationMessagesOperation("Chats/1-A") + self.assertEqual(op._parameters.conversation_id, "Chats/1-A") + + def test_from_options(self): + opts = GetConversationMessagesOptions( + conversation_id="Chats/1-A", detail_level=AiConversationDetailLevel.DETAILED, page_size=50 + ) + op = GetConversationMessagesOperation(opts) + self.assertEqual(op._parameters.detail_level, AiConversationDetailLevel.DETAILED) + self.assertEqual(op._parameters.page_size, 50) + + def test_from_invalid_type_raises(self): + with self.assertRaises(TypeError): + GetConversationMessagesOperation(42) + + def test_validates_on_construction(self): + with self.assertRaises(ValueError): + GetConversationMessagesOperation("") + + def test_get_command_is_read_request(self): + from ravendb.documents.conventions import DocumentConventions + + op = GetConversationMessagesOperation("Chats/1-A") + cmd = op.get_command(DocumentConventions()) + self.assertTrue(cmd.is_read_request()) + + +class TestFormatDatetimeForUrl(unittest.TestCase): + def test_utc_datetime(self): + dt = datetime(2025, 3, 20, 12, 34, 56, 789012, tzinfo=timezone.utc) + formatted = _format_datetime_for_url(dt) + self.assertTrue(formatted.endswith("Z")) + self.assertIn("2025-03-20T12:34:56.7890120Z", formatted) + + def test_naive_datetime(self): + dt = datetime(2025, 3, 20, 12, 34, 56, 123456) + formatted = _format_datetime_for_url(dt) + self.assertTrue(formatted.endswith("Z")) + self.assertIn("2025-03-20T12:34:56.1234560Z", formatted) + + def test_non_utc_timezone(self): + dt = datetime(2025, 3, 20, 12, 34, 56, 0, tzinfo=timezone(timedelta(hours=5))) + formatted = _format_datetime_for_url(dt) + self.assertIn("07:34:56", formatted) + self.assertTrue(formatted.endswith("Z")) + + +class TestCommandSetResponse(unittest.TestCase): + def test_null_response_is_404(self): + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A") + cmd = GetConversationMessagesCommand(opts) + cmd.set_response(None, False) + self.assertIsNone(cmd.result) + + def test_response_with_messages(self): + server_json = { + "ConversationId": "Chats/1-A", + "Agent": "my-agent", + "Parameters": {"budget": 3500}, + "TotalUsage": { + "PromptTokens": 10, + "CompletionTokens": 20, + "TotalTokens": 30, + "CachedTokens": 0, + "ReasoningTokens": 0, + }, + "LastMessageAt": "2025-03-20T12:34:56.7890123Z", + "Messages": [ + {"Role": "User", "Content": "Hello", "Timestamp": "2025-03-20T12:34:56.123456Z"}, + { + "Role": "Assistant", + "Content": "Hi!", + "Timestamp": "2025-03-20T12:34:57.000000Z", + "Usage": { + "PromptTokens": 5, + "CompletionTokens": 10, + "TotalTokens": 15, + "CachedTokens": 0, + "ReasoningTokens": 2, + }, + }, + ], + "HasMoreMessages": False, + "SubConversationIds": [], + "Attachments": ["file1.pdf"], + } + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A") + cmd = GetConversationMessagesCommand(opts) + cmd.set_response(json.dumps(server_json), False) + result = cmd.result + self.assertIsNotNone(result) + self.assertEqual(result.conversation_id, "Chats/1-A") + self.assertEqual(result.agent, "my-agent") + self.assertEqual(len(result.messages), 2) + self.assertEqual(result.messages[0].role, AiMessageRole.USER) + self.assertEqual(result.messages[1].role, AiMessageRole.ASSISTANT) + self.assertIsNotNone(result.total_usage) + self.assertEqual(result.total_usage.prompt_tokens, 10) + self.assertEqual(result.attachments, ["file1.pdf"]) + + def test_has_more_messages(self): + server_json = { + "ConversationId": "Chats/1-A", + "Agent": "agent", + "Parameters": {}, + "Messages": [{"Role": "User", "Content": "Hi", "Timestamp": "2025-01-01T00:00:00Z"}], + "HasMoreMessages": True, + } + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A") + cmd = GetConversationMessagesCommand(opts) + cmd.set_response(json.dumps(server_json), False) + self.assertTrue(cmd.result.has_more_messages) + + def test_empty_messages_no_crash(self): + server_json = { + "ConversationId": "Chats/1-A", + "Agent": "agent", + "Messages": [], + "HasMoreMessages": False, + } + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A") + cmd = GetConversationMessagesCommand(opts) + cmd.set_response(json.dumps(server_json), False) + self.assertEqual(len(cmd.result.messages), 0) + + +class TestParameterMaterialization(unittest.TestCase): + def _parse_result(self, params_dict): + return AiConversationMessagesResult.from_json( + { + "ConversationId": "Chats/1-A", + "Agent": "agent", + "Parameters": params_dict, + "Messages": [{"Role": "User", "Content": "hi", "Timestamp": "2025-01-01T00:00:00Z"}], + } + ) + + def test_string_parameter(self): + result = self._parse_result({"name": "test-agent"}) + self.assertEqual(result.parameters["name"], "test-agent") + self.assertIsInstance(result.parameters["name"], str) + + def test_integer_parameter(self): + result = self._parse_result({"budget": 3500}) + self.assertEqual(result.parameters["budget"], 3500) + self.assertIsInstance(result.parameters["budget"], int) + + def test_float_parameter(self): + result = self._parse_result({"price": 19.99}) + self.assertEqual(result.parameters["price"], 19.99) + self.assertIsInstance(result.parameters["price"], float) + + def test_bool_parameter(self): + result = self._parse_result({"enabled": True}) + self.assertEqual(result.parameters["enabled"], True) + self.assertIsInstance(result.parameters["enabled"], bool) + + def test_null_parameter(self): + result = self._parse_result({"value": None}) + self.assertIsNone(result.parameters["value"]) + + def test_empty_parameters(self): + result = self._parse_result({}) + self.assertEqual(len(result.parameters), 0) + + def test_no_parameters_key(self): + result = AiConversationMessagesResult.from_json( + { + "ConversationId": "Chats/1-A", + "Agent": "agent", + "Messages": [{"Role": "User", "Content": "hi", "Timestamp": "2025-01-01T00:00:00Z"}], + } + ) + self.assertEqual(len(result.parameters), 0) + + def test_string_array_parameter(self): + result = self._parse_result({"tags": ["a", "b", "c"]}) + self.assertEqual(result.parameters["tags"], ["a", "b", "c"]) + self.assertIsInstance(result.parameters["tags"][0], str) + + def test_int_array_parameter(self): + result = self._parse_result({"scores": [1, 2, 3]}) + self.assertEqual(result.parameters["scores"], [1, 2, 3]) + + def test_float_array_promotion(self): + result = self._parse_result({"values": [1, 1.5, 2]}) + for v in result.parameters["values"]: + self.assertIsInstance(v, float) + + def test_bool_array_parameter(self): + result = self._parse_result({"flags": [True, False, True]}) + self.assertEqual(result.parameters["flags"], [True, False, True]) + + def test_mixed_array(self): + result = self._parse_result({"mixed": [1, "two", 3.0]}) + self.assertEqual(len(result.parameters["mixed"]), 3) + + def test_parameters_default_to_empty_dict(self): + result = AiConversationMessagesResult() + self.assertEqual(result.parameters, {}) + + def test_messages_default_to_empty_list(self): + result = AiConversationMessagesResult() + self.assertEqual(result.messages, []) + + def test_sub_conversation_ids_default_to_empty_list(self): + result = AiConversationMessagesResult() + self.assertEqual(result.sub_conversation_ids, []) + + def test_attachments_default_to_empty_list(self): + result = AiConversationMessagesResult() + self.assertEqual(result.attachments, []) + + +class TestCreateRequest(unittest.TestCase): + def setUp(self): + from ravendb.http.server_node import ServerNode + + self.node = ServerNode(url="http://localhost:8080", database="testdb", cluster_tag=None) + + def test_basic_url(self): + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A") + cmd = GetConversationMessagesCommand(opts) + req = cmd.create_request(self.node) + self.assertIn("conversationId=Chats%2F1-A", req.url) + self.assertIn("pageSize=2147483647", req.url) + self.assertIn("detailLevel=Simple", req.url) + self.assertNotIn("before", req.url) + self.assertNotIn("after", req.url) + + def test_before_url(self): + dt = datetime(2025, 3, 20, 12, 34, 56, 789012, tzinfo=timezone.utc) + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A", before=dt) + cmd = GetConversationMessagesCommand(opts) + req = cmd.create_request(self.node) + self.assertIn("before=", req.url) + self.assertNotIn("after=", req.url) + + def test_after_url(self): + dt = datetime(2025, 3, 20, 12, 34, 56, 789012, tzinfo=timezone.utc) + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A", after=dt) + cmd = GetConversationMessagesCommand(opts) + req = cmd.create_request(self.node) + self.assertIn("after=", req.url) + + def test_page_size_url(self): + opts = GetConversationMessagesOptions(conversation_id="Chats/1-A", page_size=20) + cmd = GetConversationMessagesCommand(opts) + req = cmd.create_request(self.node) + self.assertIn("pageSize=20", req.url) + + def test_detail_level_detailed(self): + opts = GetConversationMessagesOptions( + conversation_id="Chats/1-A", detail_level=AiConversationDetailLevel.DETAILED + ) + cmd = GetConversationMessagesCommand(opts) + req = cmd.create_request(self.node) + self.assertIn("detailLevel=Detailed", req.url) + + +class TestFullResultDeserialization(unittest.TestCase): + """End-to-end test of the full server response deserialization.""" + + def test_complex_response(self): + server_json = { + "ConversationId": "Chats/1-A", + "Agent": "test-agent", + "Parameters": { + "string_param": "hello", + "int_param": 42, + "float_param": 3.14, + "bool_param": True, + "null_param": None, + "string_list": ["a", "b"], + "int_list": [1, 2, 3], + "bool_list": [True, False], + }, + "TotalUsage": { + "PromptTokens": 100, + "CompletionTokens": 200, + "TotalTokens": 300, + "CachedTokens": 10, + "ReasoningTokens": 50, + }, + "LastMessageAt": "2025-06-15T10:30:00.1234567Z", + "Messages": [ + { + "Role": "System", + "Content": "You are a helpful assistant.", + "Timestamp": "2025-06-15T10:29:00.000000Z", + }, + {"Role": "User", "Content": "Hello!", "Timestamp": "2025-06-15T10:30:00.000000Z"}, + { + "Role": "Assistant", + "Content": None, + "Timestamp": "2025-06-15T10:30:01.000000Z", + "ToolCalls": [ + { + "Id": "call_1", + "Name": "get_orders", + "Arguments": "{}", + "Result": "Order #42", + "SubConversationId": None, + } + ], + "Usage": { + "PromptTokens": 50, + "CompletionTokens": 100, + "TotalTokens": 150, + "CachedTokens": 0, + "ReasoningTokens": 20, + }, + }, + {"Role": "Summary", "Content": "Conversation summarized.", "Timestamp": "2025-06-15T10:31:00.000000Z"}, + ], + "HasMoreMessages": False, + "SubConversationIds": ["SC/1"], + "Attachments": ["report.pdf", "data.csv"], + } + result = AiConversationMessagesResult.from_json(server_json) + self.assertEqual(result.conversation_id, "Chats/1-A") + self.assertEqual(result.agent, "test-agent") + self.assertEqual(len(result.parameters), 8) + self.assertEqual(result.parameters["string_param"], "hello") + self.assertEqual(result.parameters["int_param"], 42) + self.assertEqual(result.parameters["float_param"], 3.14) + self.assertEqual(result.parameters["bool_param"], True) + self.assertIsNone(result.parameters["null_param"]) + self.assertEqual(result.parameters["string_list"], ["a", "b"]) + self.assertEqual(result.parameters["int_list"], [1, 2, 3]) + self.assertEqual(result.parameters["bool_list"], [True, False]) + + self.assertEqual(result.total_usage.prompt_tokens, 100) + self.assertEqual(result.total_usage.reasoning_tokens, 50) + self.assertIsNotNone(result.last_message_at) + self.assertEqual(result.last_message_at.year, 2025) + + self.assertEqual(len(result.messages), 4) + self.assertEqual(result.messages[0].role, AiMessageRole.SYSTEM) + self.assertEqual(result.messages[1].role, AiMessageRole.USER) + self.assertEqual(result.messages[2].role, AiMessageRole.ASSISTANT) + self.assertEqual(result.messages[3].role, AiMessageRole.SUMMARY) + + # Tool calls on assistant message + self.assertEqual(len(result.messages[2].tool_calls), 1) + self.assertEqual(result.messages[2].tool_calls[0].name, "get_orders") + self.assertEqual(result.messages[2].tool_calls[0].result, "Order #42") + + self.assertEqual(result.sub_conversation_ids, ["SC/1"]) + self.assertEqual(result.attachments, ["report.pdf", "data.csv"]) + + +class TestAiOperationsIntegration(unittest.TestCase): + def test_method_exists_on_ai_operations(self): + from ravendb.documents.ai.ai_operations import AiOperations + + self.assertTrue(hasattr(AiOperations, "get_conversation_messages")) + self.assertTrue(callable(AiOperations.get_conversation_messages)) + + +if __name__ == "__main__": + unittest.main()