diff --git a/dimo/api/conversations.py b/dimo/api/conversations.py new file mode 100644 index 0000000..1f0ec59 --- /dev/null +++ b/dimo/api/conversations.py @@ -0,0 +1,328 @@ +from dimo.errors import check_type, check_optional_type, HTTPError +from typing import Dict, List, Optional, Any, Generator +from requests import Session, RequestException +import json + + +class Conversations: + """ + Client for the DIMO Conversations API. + + This API enables developers to create conversational AI agents that can query + vehicle data, telemetry data, and perform web searches on behalf of users. + + Key Features: + - Create AI agents with access to specific vehicles + - Query vehicle identity (make, model, owner) via GraphQL + - Query real-time telemetry (speed, fuel, location) via GraphQL + - Perform location-based web searches + - Stream responses in real-time using Server-Sent Events (SSE) + - Multi-agent delegation architecture with specialized subagents + """ + + def __init__(self, request_method, get_auth_headers, get_full_path, session: Session): + self._request = request_method + self._get_auth_headers = get_auth_headers + self._get_full_path = get_full_path + self._session = session + + def health_check(self) -> Dict: + """ + Check the service status and configuration. + + Returns: + dict: Service health information including status, version, proxy, and default_model + + Example: + >>> dimo = DIMO("Production") + >>> health = dimo.conversations.health_check() + >>> print(health['status']) + """ + response = self._request("GET", "Conversations", "/") + return response + + def create_agent( + self, + developer_jwt: str, + user: str, + vehicle_ids: Optional[List[int]] = None, + ) -> Dict: + """ + Create a new conversational agent for a user with optional vehicle access. + + Args: + developer_jwt (str): Developer JWT token for authentication + user (str): Wallet address (0x...) or email identifying the user + vehicle_ids (list[int], optional): List of vehicle token IDs this agent can access. + - None (default): Unrestricted access, ownership validated at runtime + - []: Empty list means no vehicle access (identity queries only) + - [872, 1234]: Explicit list of allowed vehicles + + Returns: + dict: Agent information including agentId, mode, user, vehicleIds, and createdAt + + Behavior: + - One agent per user (idempotent creation) + - Validates configuration and mode detection + - Creates/reuses shared identity subagent + - Creates per-vehicle telemetry subagents with token exchange + - Creates shared websearch subagent if enabled + + Example: + >>> dimo = DIMO("Production") + >>> dev_jwt = "your_developer_jwt" + >>> agent = dimo.conversations.create_agent( + ... developer_jwt=dev_jwt, + ... user="0x1234567890abcdef1234567890abcdef12345678", + ... vehicle_ids=[872, 1234], + ... ) + >>> print(agent['agentId']) + """ + check_type("developer_jwt", developer_jwt, str) + check_type("user", user, str) + check_optional_type("vehicle_ids", vehicle_ids, list) + # check_type("enable_websearch", enable_websearch, bool) + + body = { + "user": user, + "vehicleIds": vehicle_ids, + # "enableWebsearch": enable_websearch, + } + + response = self._request( + "POST", + "Conversations", + "/agents", + headers=self._get_auth_headers(developer_jwt), + data=body, + ) + return response + + def delete_agent(self, developer_jwt: str, agent_id: str) -> Dict: + """ + Delete an agent and all associated resources. + + Args: + developer_jwt (str): Developer JWT token for authentication + agent_id (str): The agent ID to delete + + Returns: + dict: Confirmation message + + Behavior: + - Deletes Letta agent from server + - Removes metadata from AgentManager + - Cleanup errors are logged but don't fail the request + + Example: + >>> dimo = DIMO("Production") + >>> dev_jwt = "your_developer_jwt" + >>> result = dimo.conversations.delete_agent( + ... developer_jwt=dev_jwt, + ... agent_id="agent-abc123" + ... ) + >>> print(result['message']) + """ + check_type("developer_jwt", developer_jwt, str) + check_type("agent_id", agent_id, str) + + response = self._request( + "DELETE", + "Conversations", + f"/agents/{agent_id}", + headers=self._get_auth_headers(developer_jwt), + ) + return response + + def send_message( + self, + developer_jwt: str, + agent_id: str, + message: str, + vehicle_ids: Optional[List[int]] = None, + user: Optional[str] = None, + ) -> Dict: + """ + Send a message to an agent and receive the complete response (synchronous). + + Args: + developer_jwt (str): Developer JWT token for authentication + agent_id (str): The agent ID to send the message to + message (str): The message to send to the agent + vehicle_ids (list[int], optional): Optional vehicle IDs override + user (str, optional): Optional user override + + Returns: + dict: Response including agentId, message, response, vehiclesQueried, and timestamp + + Behavior: + - Synchronous request/response + - Agent delegates to subagents as needed + - Returns full response after agent completes reasoning + - Timeout: 120 seconds for complex queries + + Example: + >>> dimo = DIMO("Production") + >>> dev_jwt = "your_developer_jwt" + >>> response = dimo.conversations.send_message( + ... developer_jwt=dev_jwt, + ... agent_id="agent-abc123", + ... message="What's the make and model of my vehicle?" + ... ) + >>> print(response['response']) + """ + check_type("developer_jwt", developer_jwt, str) + check_type("agent_id", agent_id, str) + check_type("message", message, str) + check_optional_type("vehicle_ids", vehicle_ids, list) + check_optional_type("user", user, str) + + body = {"message": message} + if vehicle_ids is not None: + body["vehicleIds"] = vehicle_ids + if user is not None: + body["user"] = user + + response = self._request( + "POST", + "Conversations", + f"/agents/{agent_id}/message", + headers=self._get_auth_headers(developer_jwt), + data=body, + ) + return response + + def stream_message( + self, + developer_jwt: str, + agent_id: str, + message: str, + vehicle_ids: Optional[List[int]] = None, + user: Optional[str] = None, + ) -> Generator[Dict[str, Any], None, None]: + """ + Send a message and receive real-time token-by-token streaming response via SSE. + + Args: + developer_jwt (str): Developer JWT token for authentication + agent_id (str): The agent ID to send the message to + message (str): The message to send to the agent + vehicle_ids (list[int], optional): Optional vehicle IDs override + user (str, optional): Optional user override + + Yields: + dict: SSE events with either {"content": "token"} or {"done": true, ...metadata} + + Behavior: + - Real-time streaming for better UX + - Token-by-token generation from LLM + - Final message includes metadata (agentId, vehiclesQueried) + + Example: + >>> dimo = DIMO("Production") + >>> dev_jwt = "your_developer_jwt" + >>> for chunk in dimo.conversations.stream_message( + ... developer_jwt=dev_jwt, + ... agent_id="agent-abc123", + ... message="What's my current speed?" + ... ): + ... if "content" in chunk: + ... print(chunk["content"], end="", flush=True) + ... elif "done" in chunk: + ... print(f"\\nVehicles queried: {chunk['vehiclesQueried']}") + """ + check_type("developer_jwt", developer_jwt, str) + check_type("agent_id", agent_id, str) + check_type("message", message, str) + check_optional_type("vehicle_ids", vehicle_ids, list) + check_optional_type("user", user, str) + + body = {"message": message} + if vehicle_ids is not None: + body["vehicleIds"] = vehicle_ids + if user is not None: + body["user"] = user + + headers = self._get_auth_headers(developer_jwt) + headers["Accept"] = "text/event-stream" + headers["Content-Type"] = "application/json" + + # Build full URL + url = self._get_full_path("Conversations", f"/agents/{agent_id}/stream") + + # Make streaming request directly with session + try: + response = self._session.request( + method="POST", + url=url, + headers=headers, + data=json.dumps(body), + stream=True, + ) + response.raise_for_status() + except RequestException as exc: + status = getattr(exc.response, "status_code", None) + body_error = None + try: + body_error = exc.response.json() + except Exception: + body_error = exc.response.text if exc.response else None + raise HTTPError(status=status or -1, message=str(exc), body=body_error) + + # Parse SSE stream + for line in response.iter_lines(): + if line: + line = line.decode("utf-8") + if line.startswith("data: "): + data = line[6:] # Remove "data: " prefix + try: + yield json.loads(data) + except json.JSONDecodeError: + # Skip malformed JSON + continue + + def get_history( + self, + developer_jwt: str, + agent_id: str, + limit: int = 100, + ) -> Dict: + """ + Retrieve all messages in a conversation. + + Args: + developer_jwt (str): Developer JWT token for authentication + agent_id (str): The agent ID to get history for + limit (int): Maximum number of messages to return (default: 100) + + Returns: + dict: Conversation history including agentId, messages array, and total count + + Behavior: + - Retrieves from Letta server + - Includes all message roles (user, agent, system) + - Reverse chronological order (newest first) + + Example: + >>> dimo = DIMO("Production") + >>> dev_jwt = "your_developer_jwt" + >>> history = dimo.conversations.get_history( + ... developer_jwt=dev_jwt, + ... agent_id="agent-abc123", + ... limit=50 + ... ) + >>> for msg in history['messages']: + ... print(f"{msg['role']}: {msg['content']}") + """ + check_type("developer_jwt", developer_jwt, str) + check_type("agent_id", agent_id, str) + check_type("limit", limit, int) + + response = self._request( + "GET", + "Conversations", + f"/agents/{agent_id}/history", + headers=self._get_auth_headers(developer_jwt), + params={"limit": limit}, + ) + return response diff --git a/dimo/dimo.py b/dimo/dimo.py index 356a36d..a77dac1 100644 --- a/dimo/dimo.py +++ b/dimo/dimo.py @@ -2,6 +2,7 @@ from .api.attestation import Attestation from .api.auth import Auth +from .api.conversations import Conversations from .api.device_definitions import DeviceDefinitions from .api.token_exchange import TokenExchange from .api.trips import Trips @@ -75,6 +76,7 @@ def __getattr__(self, name: str) -> Any: mapping = { "attestation": (Attestation, ("request", "_get_auth_headers")), "auth": (Auth, ("request", "_get_auth_headers", "env", "self")), + "conversations": (Conversations, ("request", "_get_auth_headers", "_get_full_path", "session")), "device_definitions": (DeviceDefinitions, ("request", "_get_auth_headers")), "token_exchange": ( TokenExchange, diff --git a/dimo/environments.py b/dimo/environments.py index 77cbf40..dc63390 100644 --- a/dimo/environments.py +++ b/dimo/environments.py @@ -2,6 +2,7 @@ "Production": { "Attestation": "https://attestation-api.dimo.zone", "Auth": "https://auth.dimo.zone", + "Conversations": "https://conversations-api.dimo.zone", "Identity": "https://identity-api.dimo.zone/query", "DeviceDefinitions": "https://device-definitions-api.dimo.zone", "Telemetry": "https://telemetry-api.dimo.zone/query", @@ -14,6 +15,7 @@ "Dev": { "Attestation": "https://attestation-api.dev.dimo.zone", "Auth": "https://auth.dev.dimo.zone", + "Conversations": "https://conversations-api.dev.dimo.zone", "Identity": "https://identity-api.dev.dimo.zone/query", "DeviceDefinitions": "https://device-definitions-api.dev.dimo.zone", "Telemetry": "https://telemetry-api.dev.dimo.zone/query", diff --git a/pyproject.toml b/pyproject.toml index 739c256..756107d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dimo-python-sdk" -version = "1.6.0" +version = "1.7.0" authors = [ { name="Barrett Kowalsky", email="barrettkowalsky@gmail.com" }, ] diff --git a/tests/test_conversations.py b/tests/test_conversations.py new file mode 100644 index 0000000..186a42c --- /dev/null +++ b/tests/test_conversations.py @@ -0,0 +1,617 @@ +""" +Tests for the DIMO Conversations API. + +These tests verify the functionality of the Conversations client including: +- Agent creation and deletion +- Synchronous and streaming message sending +- Conversation history retrieval +- Health checks +- Error handling +""" + +import json +from unittest.mock import MagicMock, Mock, patch +import pytest +from dimo.dimo import DIMO +from dimo.errors import HTTPError, DimoTypeError + + +class TestConversationsHealthCheck: + """Test the health_check endpoint.""" + + def test_health_check_success(self, monkeypatch): + """Test successful health check returns service status.""" + client = DIMO(env="Dev") + + # Mock the request method to return health data + fake_request = MagicMock(return_value={ + "status": "healthy", + "version": "1.0.0", + "proxy": "active", + "default_model": "gpt-4" + }) + monkeypatch.setattr(client, "request", fake_request) + + result = client.conversations.health_check() + + # Verify the request was called correctly + fake_request.assert_called_once_with("GET", "Conversations", "/") + + # Verify the response + assert result["status"] == "healthy" + assert result["version"] == "1.0.0" + assert "default_model" in result + + +class TestConversationsCreateAgent: + """Test the create_agent endpoint.""" + + def test_create_agent_minimal(self, monkeypatch): + """Test creating an agent with minimal parameters.""" + client = DIMO(env="Dev") + + # Mock the request method + fake_request = MagicMock(return_value={ + "agentId": "agent-abc123", + "user": "0x1234567890abcdef1234567890abcdef12345678", + "vehicleIds": None, + "mode": "unrestricted", + "createdAt": "2024-01-01T00:00:00Z" + }) + monkeypatch.setattr(client, "request", fake_request) + + dev_jwt = "test_developer_jwt" + user = "0x1234567890abcdef1234567890abcdef12345678" + + result = client.conversations.create_agent( + developer_jwt=dev_jwt, + user=user + ) + + # Verify the request was called correctly + fake_request.assert_called_once() + args, kwargs = fake_request.call_args + + assert args[0] == "POST" + assert args[1] == "Conversations" + assert args[2] == "/agents" + assert kwargs["data"]["user"] == user + assert kwargs["data"]["vehicleIds"] is None + + # Verify the response + assert result["agentId"] == "agent-abc123" + assert result["user"] == user + assert result["mode"] == "unrestricted" + + def test_create_agent_with_vehicle_ids(self, monkeypatch): + """Test creating an agent with specific vehicle IDs.""" + client = DIMO(env="Dev") + + fake_request = MagicMock(return_value={ + "agentId": "agent-def456", + "user": "user@example.com", + "vehicleIds": [872, 1234], + "mode": "restricted", + "createdAt": "2024-01-01T00:00:00Z" + }) + monkeypatch.setattr(client, "request", fake_request) + + dev_jwt = "test_developer_jwt" + user = "user@example.com" + vehicle_ids = [872, 1234] + + result = client.conversations.create_agent( + developer_jwt=dev_jwt, + user=user, + vehicle_ids=vehicle_ids + ) + + # Verify the request + args, kwargs = fake_request.call_args + assert kwargs["data"]["vehicleIds"] == [872, 1234] + + # Verify the response + assert result["vehicleIds"] == vehicle_ids + assert result["mode"] == "restricted" + + def test_create_agent_with_empty_vehicle_list(self, monkeypatch): + """Test creating an agent with empty vehicle list (identity only).""" + client = DIMO(env="Dev") + + fake_request = MagicMock(return_value={ + "agentId": "agent-ghi789", + "user": "0xabcdef", + "vehicleIds": [], + "mode": "identity_only", + "createdAt": "2024-01-01T00:00:00Z" + }) + monkeypatch.setattr(client, "request", fake_request) + + result = client.conversations.create_agent( + developer_jwt="test_jwt", + user="0xabcdef", + vehicle_ids=[] + ) + + assert result["vehicleIds"] == [] + assert result["mode"] == "identity_only" + + def test_create_agent_invalid_types(self): + """Test that type checking is enforced for parameters.""" + client = DIMO(env="Dev") + + # Test invalid developer_jwt type + with pytest.raises(DimoTypeError): + client.conversations.create_agent( + developer_jwt=123, # Should be string + user="0xabcdef" + ) + + # Test invalid user type + with pytest.raises(DimoTypeError): + client.conversations.create_agent( + developer_jwt="test_jwt", + user=123 # Should be string + ) + + # Test invalid vehicle_ids type + with pytest.raises(DimoTypeError): + client.conversations.create_agent( + developer_jwt="test_jwt", + user="0xabcdef", + vehicle_ids="not_a_list" # Should be list or None + ) + + +class TestConversationsDeleteAgent: + """Test the delete_agent endpoint.""" + + def test_delete_agent_success(self, monkeypatch): + """Test successful agent deletion.""" + client = DIMO(env="Dev") + + fake_request = MagicMock(return_value={ + "message": "Agent deleted successfully", + "agentId": "agent-abc123" + }) + monkeypatch.setattr(client, "request", fake_request) + + dev_jwt = "test_developer_jwt" + agent_id = "agent-abc123" + + result = client.conversations.delete_agent( + developer_jwt=dev_jwt, + agent_id=agent_id + ) + + # Verify the request + fake_request.assert_called_once() + args, kwargs = fake_request.call_args + + assert args[0] == "DELETE" + assert args[1] == "Conversations" + assert args[2] == "/agents/agent-abc123" + + # Verify the response + assert result["message"] == "Agent deleted successfully" + assert result["agentId"] == agent_id + + def test_delete_agent_invalid_types(self): + """Test that type checking is enforced.""" + client = DIMO(env="Dev") + + with pytest.raises(DimoTypeError): + client.conversations.delete_agent( + developer_jwt=123, # Should be string + agent_id="agent-abc123" + ) + + with pytest.raises(DimoTypeError): + client.conversations.delete_agent( + developer_jwt="test_jwt", + agent_id=123 # Should be string + ) + + +class TestConversationsSendMessage: + """Test the send_message endpoint (synchronous).""" + + def test_send_message_basic(self, monkeypatch): + """Test sending a basic message and receiving a response.""" + client = DIMO(env="Dev") + + fake_request = MagicMock(return_value={ + "agentId": "agent-abc123", + "message": "What's my car's make and model?", + "response": "Your vehicle is a 2020 Tesla Model 3.", + "vehiclesQueried": [872], + "timestamp": "2024-01-01T00:00:00Z" + }) + monkeypatch.setattr(client, "request", fake_request) + + dev_jwt = "test_developer_jwt" + agent_id = "agent-abc123" + message = "What's my car's make and model?" + + result = client.conversations.send_message( + developer_jwt=dev_jwt, + agent_id=agent_id, + message=message + ) + + # Verify the request + args, kwargs = fake_request.call_args + + assert args[0] == "POST" + assert args[1] == "Conversations" + assert args[2] == "/agents/agent-abc123/message" + assert kwargs["data"]["message"] == message + + # Verify the response + assert result["agentId"] == agent_id + assert result["response"] == "Your vehicle is a 2020 Tesla Model 3." + assert result["vehiclesQueried"] == [872] + + def test_send_message_with_vehicle_ids_override(self, monkeypatch): + """Test sending a message with vehicle IDs override.""" + client = DIMO(env="Dev") + + fake_request = MagicMock(return_value={ + "agentId": "agent-abc123", + "message": "What's the speed?", + "response": "Current speed is 65 mph.", + "vehiclesQueried": [1234], + "timestamp": "2024-01-01T00:00:00Z" + }) + monkeypatch.setattr(client, "request", fake_request) + + result = client.conversations.send_message( + developer_jwt="test_jwt", + agent_id="agent-abc123", + message="What's the speed?", + vehicle_ids=[1234] + ) + + # Verify vehicle_ids was included in request body + args, kwargs = fake_request.call_args + assert kwargs["data"]["vehicleIds"] == [1234] + assert result["vehiclesQueried"] == [1234] + + def test_send_message_with_user_override(self, monkeypatch): + """Test sending a message with user override.""" + client = DIMO(env="Dev") + + fake_request = MagicMock(return_value={ + "agentId": "agent-abc123", + "message": "Hello", + "response": "Hi there!", + "vehiclesQueried": [], + "timestamp": "2024-01-01T00:00:00Z" + }) + monkeypatch.setattr(client, "request", fake_request) + + result = client.conversations.send_message( + developer_jwt="test_jwt", + agent_id="agent-abc123", + message="Hello", + user="0xnewuser" + ) + + # Verify user was included in request body + args, kwargs = fake_request.call_args + assert kwargs["data"]["user"] == "0xnewuser" + + def test_send_message_invalid_types(self): + """Test that type checking is enforced.""" + client = DIMO(env="Dev") + + with pytest.raises(DimoTypeError): + client.conversations.send_message( + developer_jwt=123, # Should be string + agent_id="agent-abc123", + message="Hello" + ) + + with pytest.raises(DimoTypeError): + client.conversations.send_message( + developer_jwt="test_jwt", + agent_id="agent-abc123", + message=123 # Should be string + ) + + +class TestConversationsStreamMessage: + """Test the stream_message endpoint (SSE streaming).""" + + def test_stream_message_success(self, monkeypatch): + """Test streaming a message and receiving token-by-token response.""" + client = DIMO(env="Dev") + + # Mock SSE response data + sse_lines = [ + b"data: {\"content\": \"Your\"}", + b"data: {\"content\": \" vehicle\"}", + b"data: {\"content\": \" is\"}", + b"data: {\"content\": \" a\"}", + b"data: {\"content\": \" Tesla.\"}", + b"data: {\"done\": true, \"agentId\": \"agent-abc123\", \"vehiclesQueried\": [872]}" + ] + + # Mock response object + mock_response = Mock() + mock_response.status_code = 200 + mock_response.iter_lines = Mock(return_value=sse_lines) + mock_response.raise_for_status = Mock() + + # Mock session.request + mock_session = Mock() + mock_session.request = Mock(return_value=mock_response) + monkeypatch.setattr(client.conversations, "_session", mock_session) + + dev_jwt = "test_developer_jwt" + agent_id = "agent-abc123" + message = "What's my car?" + + # Collect streamed chunks + chunks = list(client.conversations.stream_message( + developer_jwt=dev_jwt, + agent_id=agent_id, + message=message + )) + + # Verify we got all chunks + assert len(chunks) == 6 + assert chunks[0] == {"content": "Your"} + assert chunks[1] == {"content": " vehicle"} + assert chunks[-1]["done"] is True + assert chunks[-1]["agentId"] == "agent-abc123" + assert chunks[-1]["vehiclesQueried"] == [872] + + # Verify the request was made correctly + mock_session.request.assert_called_once() + call_args = mock_session.request.call_args + assert call_args[1]["method"] == "POST" + assert "/agents/agent-abc123/stream" in call_args[1]["url"] + assert call_args[1]["stream"] is True + assert call_args[1]["headers"]["Accept"] == "text/event-stream" + + def test_stream_message_with_overrides(self, monkeypatch): + """Test streaming with vehicle_ids and user overrides.""" + client = DIMO(env="Dev") + + # Mock minimal SSE response + sse_lines = [ + b"data: {\"content\": \"Hello\"}", + b"data: {\"done\": true, \"agentId\": \"agent-abc123\", \"vehiclesQueried\": [1234]}" + ] + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.iter_lines = Mock(return_value=sse_lines) + mock_response.raise_for_status = Mock() + + mock_session = Mock() + mock_session.request = Mock(return_value=mock_response) + monkeypatch.setattr(client.conversations, "_session", mock_session) + + # Call with overrides + chunks = list(client.conversations.stream_message( + developer_jwt="test_jwt", + agent_id="agent-abc123", + message="Hello", + vehicle_ids=[1234], + user="0xnewuser" + )) + + # Verify the request included overrides in body + call_args = mock_session.request.call_args + body = json.loads(call_args[1]["data"]) + assert body["message"] == "Hello" + assert body["vehicleIds"] == [1234] + assert body["user"] == "0xnewuser" + + def test_stream_message_handles_malformed_json(self, monkeypatch): + """Test that malformed JSON is skipped gracefully.""" + client = DIMO(env="Dev") + + # Mock SSE with malformed data + sse_lines = [ + b"data: {\"content\": \"Good\"}", + b"data: {malformed json here", # This should be skipped + b"data: {\"content\": \"data\"}", + b"data: {\"done\": true, \"agentId\": \"agent-abc123\", \"vehiclesQueried\": []}" + ] + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.iter_lines = Mock(return_value=sse_lines) + mock_response.raise_for_status = Mock() + + mock_session = Mock() + mock_session.request = Mock(return_value=mock_response) + monkeypatch.setattr(client.conversations, "_session", mock_session) + + # Collect chunks - malformed one should be skipped + chunks = list(client.conversations.stream_message( + developer_jwt="test_jwt", + agent_id="agent-abc123", + message="Test" + )) + + # Should have 3 valid chunks (malformed one skipped) + assert len(chunks) == 3 + assert chunks[0] == {"content": "Good"} + assert chunks[1] == {"content": "data"} + assert chunks[2]["done"] is True + + def test_stream_message_http_error(self, monkeypatch): + """Test that HTTP errors are properly raised.""" + from requests import RequestException + + client = DIMO(env="Dev") + + # Mock a failed request + mock_response = Mock() + mock_response.status_code = 404 + mock_response.json = Mock(return_value={"error": "Agent not found"}) + + mock_exception = RequestException("Not found") + mock_exception.response = mock_response + + mock_session = Mock() + mock_session.request = Mock(side_effect=mock_exception) + monkeypatch.setattr(client.conversations, "_session", mock_session) + + # Verify HTTPError is raised + with pytest.raises(HTTPError) as exc_info: + list(client.conversations.stream_message( + developer_jwt="test_jwt", + agent_id="bad-agent-id", + message="Test" + )) + + assert exc_info.value.status == 404 + + +class TestConversationsGetHistory: + """Test the get_history endpoint.""" + + def test_get_history_default_limit(self, monkeypatch): + """Test retrieving conversation history with default limit.""" + client = DIMO(env="Dev") + + fake_request = MagicMock(return_value={ + "agentId": "agent-abc123", + "messages": [ + {"role": "user", "content": "Hello", "timestamp": "2024-01-01T00:00:00Z"}, + {"role": "agent", "content": "Hi there!", "timestamp": "2024-01-01T00:00:01Z"} + ], + "total": 2 + }) + monkeypatch.setattr(client, "request", fake_request) + + dev_jwt = "test_developer_jwt" + agent_id = "agent-abc123" + + result = client.conversations.get_history( + developer_jwt=dev_jwt, + agent_id=agent_id + ) + + # Verify the request + args, kwargs = fake_request.call_args + + assert args[0] == "GET" + assert args[1] == "Conversations" + assert args[2] == "/agents/agent-abc123/history" + assert kwargs["params"]["limit"] == 100 # Default limit + + # Verify the response + assert result["agentId"] == agent_id + assert len(result["messages"]) == 2 + assert result["total"] == 2 + + def test_get_history_custom_limit(self, monkeypatch): + """Test retrieving conversation history with custom limit.""" + client = DIMO(env="Dev") + + fake_request = MagicMock(return_value={ + "agentId": "agent-abc123", + "messages": [ + {"role": "user", "content": "Test", "timestamp": "2024-01-01T00:00:00Z"} + ], + "total": 1 + }) + monkeypatch.setattr(client, "request", fake_request) + + result = client.conversations.get_history( + developer_jwt="test_jwt", + agent_id="agent-abc123", + limit=50 + ) + + # Verify custom limit was used + args, kwargs = fake_request.call_args + assert kwargs["params"]["limit"] == 50 + + def test_get_history_invalid_types(self): + """Test that type checking is enforced.""" + client = DIMO(env="Dev") + + with pytest.raises(DimoTypeError): + client.conversations.get_history( + developer_jwt=123, # Should be string + agent_id="agent-abc123" + ) + + with pytest.raises(DimoTypeError): + client.conversations.get_history( + developer_jwt="test_jwt", + agent_id=123 # Should be string + ) + + with pytest.raises(DimoTypeError): + client.conversations.get_history( + developer_jwt="test_jwt", + agent_id="agent-abc123", + limit="not_an_int" # Should be int + ) + + +class TestConversationsIntegration: + """Integration tests demonstrating complete workflows.""" + + def test_full_agent_lifecycle(self, monkeypatch): + """Test creating an agent, sending messages, and deleting it.""" + client = DIMO(env="Dev") + + # Track which endpoints are called + calls_made = [] + + def fake_request(*args, **kwargs): + calls_made.append((args[0], args[2])) + if args[0] == "POST" and args[2] == "/agents": + return { + "agentId": "agent-test123", + "user": "0xuser", + "vehicleIds": [872], + "createdAt": "2024-01-01T00:00:00Z" + } + elif args[0] == "POST" and "/message" in args[2]: + return { + "agentId": "agent-test123", + "response": "Your vehicle is a Tesla.", + "vehiclesQueried": [872] + } + elif args[0] == "DELETE": + return {"message": "Agent deleted successfully"} + return {} + + monkeypatch.setattr(client, "request", fake_request) + + # 1. Create agent + agent = client.conversations.create_agent( + developer_jwt="test_jwt", + user="0xuser", + vehicle_ids=[872] + ) + assert agent["agentId"] == "agent-test123" + assert ("POST", "/agents") in calls_made + + # 2. Send message + response = client.conversations.send_message( + developer_jwt="test_jwt", + agent_id=agent["agentId"], + message="What's my vehicle?" + ) + assert response["agentId"] == "agent-test123" + assert "Tesla" in response["response"] + + # 3. Delete agent + delete_result = client.conversations.delete_agent( + developer_jwt="test_jwt", + agent_id=agent["agentId"] + ) + assert "deleted" in delete_result["message"].lower() + assert len(calls_made) == 3 # Verify all 3 operations were called +