feat: add conversation persistence and context management layer
- SQLAlchemy async database layer (SQLite dev, PostgreSQL prod) - Conversation and Message models with UUID primary keys - Token counting utilities using litellm - Context summarization at 80% token threshold - REST API endpoints for multi-turn conversations - 19 conversation tests, 6 token tests (176 total passing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Tests for conversations domain.
|
||||
|
||||
Tests conversation CRUD, context building, and API endpoints.
|
||||
"""
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from src.domains.conversations.models import Conversation, Message
|
||||
from src.domains.conversations.schemas import (
|
||||
CreateConversationRequest,
|
||||
AddMessageRequest,
|
||||
ConversationResponse,
|
||||
MessageResponse,
|
||||
)
|
||||
|
||||
|
||||
class TestConversationModels:
|
||||
"""Tests for conversation database models."""
|
||||
|
||||
def test_conversation_creation(self):
|
||||
"""Test Conversation model creation with explicit values."""
|
||||
conv = Conversation(
|
||||
user_id="test-user",
|
||||
agent_type="explore",
|
||||
working_dir=".",
|
||||
total_tokens=0,
|
||||
)
|
||||
assert conv.user_id == "test-user"
|
||||
assert conv.agent_type == "explore"
|
||||
assert conv.working_dir == "."
|
||||
assert conv.total_tokens == 0
|
||||
|
||||
def test_conversation_with_values(self):
|
||||
"""Test Conversation with explicit values."""
|
||||
conv = Conversation(
|
||||
user_id="test-user",
|
||||
agent_type="plan",
|
||||
working_dir="/tmp/project",
|
||||
title="Test Conversation",
|
||||
)
|
||||
assert conv.agent_type == "plan"
|
||||
assert conv.working_dir == "/tmp/project"
|
||||
assert conv.title == "Test Conversation"
|
||||
|
||||
def test_message_creation(self):
|
||||
"""Test Message model creation with explicit values."""
|
||||
msg = Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="Hello",
|
||||
token_count=0,
|
||||
is_summary=False,
|
||||
)
|
||||
assert msg.role == "user"
|
||||
assert msg.content == "Hello"
|
||||
assert msg.token_count == 0
|
||||
assert msg.is_summary is False
|
||||
|
||||
def test_message_repr(self):
|
||||
"""Test Message string representation."""
|
||||
msg = Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="This is a test message",
|
||||
)
|
||||
repr_str = repr(msg)
|
||||
assert "user" in repr_str
|
||||
assert "This is a test" in repr_str
|
||||
|
||||
|
||||
class TestConversationSchemas:
|
||||
"""Tests for Pydantic schemas."""
|
||||
|
||||
def test_create_request_defaults(self):
|
||||
"""Test CreateConversationRequest defaults."""
|
||||
request = CreateConversationRequest()
|
||||
assert request.agent_type == "explore"
|
||||
assert request.working_dir == "."
|
||||
assert request.title is None
|
||||
|
||||
def test_create_request_custom(self):
|
||||
"""Test CreateConversationRequest with values."""
|
||||
request = CreateConversationRequest(
|
||||
agent_type="task",
|
||||
working_dir="/home/user/project",
|
||||
title="My Task",
|
||||
)
|
||||
assert request.agent_type == "task"
|
||||
assert request.working_dir == "/home/user/project"
|
||||
assert request.title == "My Task"
|
||||
|
||||
def test_add_message_request_valid(self):
|
||||
"""Test AddMessageRequest validation."""
|
||||
request = AddMessageRequest(content="Hello, world!")
|
||||
assert request.content == "Hello, world!"
|
||||
|
||||
def test_add_message_request_empty_fails(self):
|
||||
"""Test that empty content fails validation."""
|
||||
with pytest.raises(ValueError):
|
||||
AddMessageRequest(content="")
|
||||
|
||||
|
||||
class TestConversationAPI:
|
||||
"""Tests for conversation API endpoints."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_conversation(self, auth_client):
|
||||
"""Test creating a conversation."""
|
||||
response = await auth_client.post(
|
||||
"/conversations/",
|
||||
json={"agent_type": "explore", "working_dir": "."}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert data["agent_type"] == "explore"
|
||||
assert data["total_tokens"] == 0
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_conversation_with_title(self, auth_client):
|
||||
"""Test creating a conversation with title."""
|
||||
response = await auth_client.post(
|
||||
"/conversations/",
|
||||
json={
|
||||
"agent_type": "plan",
|
||||
"working_dir": "/tmp",
|
||||
"title": "Planning Session"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["title"] == "Planning Session"
|
||||
assert data["agent_type"] == "plan"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_conversations_empty(self, auth_client):
|
||||
"""Test listing conversations when empty."""
|
||||
response = await auth_client.get("/conversations/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "conversations" in data
|
||||
assert "total" in data
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_conversation_not_found(self, auth_client):
|
||||
"""Test getting non-existent conversation."""
|
||||
fake_id = uuid4()
|
||||
response = await auth_client.get(f"/conversations/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_conversation_not_found(self, auth_client):
|
||||
"""Test deleting non-existent conversation."""
|
||||
fake_id = uuid4()
|
||||
response = await auth_client.delete(f"/conversations/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_message_not_found(self, auth_client):
|
||||
"""Test adding message to non-existent conversation."""
|
||||
fake_id = uuid4()
|
||||
response = await auth_client.post(
|
||||
f"/conversations/{fake_id}/messages",
|
||||
json={"content": "Hello"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestConversationService:
|
||||
"""Tests for ConversationService business logic."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_prompt_no_history(self):
|
||||
"""Test building context prompt with no history."""
|
||||
from src.domains.conversations.service import ConversationService
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Create mock session
|
||||
mock_session = MagicMock()
|
||||
service = ConversationService(mock_session)
|
||||
|
||||
prompt = service.build_context_prompt([], "What files are here?")
|
||||
|
||||
assert "<current_request>" in prompt
|
||||
assert "What files are here?" in prompt
|
||||
assert "<recent_conversation>" not in prompt
|
||||
assert "<conversation_summary>" not in prompt
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_prompt_with_history(self):
|
||||
"""Test building context prompt with message history."""
|
||||
from src.domains.conversations.service import ConversationService
|
||||
from src.domains.conversations.models import Message
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock_session = MagicMock()
|
||||
service = ConversationService(mock_session)
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="Find Python files",
|
||||
),
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="assistant",
|
||||
content="Found 10 Python files.",
|
||||
),
|
||||
]
|
||||
|
||||
prompt = service.build_context_prompt(messages, "Show the largest")
|
||||
|
||||
assert "<recent_conversation>" in prompt
|
||||
assert "USER: Find Python files" in prompt
|
||||
assert "ASSISTANT: Found 10 Python files" in prompt
|
||||
assert "<current_request>" in prompt
|
||||
assert "Show the largest" in prompt
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_prompt_with_summary(self):
|
||||
"""Test building context prompt with summary message."""
|
||||
from src.domains.conversations.service import ConversationService
|
||||
from src.domains.conversations.models import Message
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock_session = MagicMock()
|
||||
service = ConversationService(mock_session)
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="summary",
|
||||
content="Previously discussed: project setup",
|
||||
is_summary=True,
|
||||
),
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="Now what?",
|
||||
),
|
||||
]
|
||||
|
||||
prompt = service.build_context_prompt(messages, "Continue")
|
||||
|
||||
assert "<conversation_summary>" in prompt
|
||||
assert "Previously discussed: project setup" in prompt
|
||||
|
||||
|
||||
class TestSummarization:
|
||||
"""Tests for conversation summarization."""
|
||||
|
||||
def test_format_messages_for_summary(self):
|
||||
"""Test formatting messages for summarization."""
|
||||
from src.domains.conversations.summarize import format_messages_for_summary
|
||||
from src.domains.conversations.models import Message
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="Hello",
|
||||
),
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="assistant",
|
||||
content="Hi there!",
|
||||
),
|
||||
]
|
||||
|
||||
formatted = format_messages_for_summary(messages)
|
||||
|
||||
assert "USER: Hello" in formatted
|
||||
assert "ASSISTANT: Hi there!" in formatted
|
||||
|
||||
def test_format_messages_with_summary(self):
|
||||
"""Test formatting messages that include a summary."""
|
||||
from src.domains.conversations.summarize import format_messages_for_summary
|
||||
from src.domains.conversations.models import Message
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="summary",
|
||||
content="Previous context summary",
|
||||
is_summary=True,
|
||||
),
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="Continue",
|
||||
),
|
||||
]
|
||||
|
||||
formatted = format_messages_for_summary(messages)
|
||||
|
||||
assert "[Previous Summary]" in formatted
|
||||
assert "Previous context summary" in formatted
|
||||
Reference in New Issue
Block a user