feat: add conversation persistence and context management layer
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Failing after 34s

- 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:
2026-01-11 22:42:38 +01:00
co-authored by Claude Opus 4.5
parent 470b7448ac
commit 2523db4da7
19 changed files with 1506 additions and 15 deletions
+299
View File
@@ -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
+90
View File
@@ -0,0 +1,90 @@
"""
Tests for token counting utilities.
"""
import pytest
from src.shared.tokens import count_tokens, count_message_tokens, estimate_tokens
class TestTokenCounting:
"""Tests for token counting functions."""
def test_estimate_tokens_basic(self):
"""Test basic token estimation."""
text = "Hello world"
tokens = estimate_tokens(text)
# ~4 chars per token
assert tokens == len(text) // 4
def test_estimate_tokens_empty(self):
"""Test estimation with empty string."""
assert estimate_tokens("") == 0
def test_estimate_tokens_long_text(self):
"""Test estimation with longer text."""
text = "a" * 400
tokens = estimate_tokens(text)
assert tokens == 100
def test_count_tokens_basic(self):
"""Test actual token counting."""
text = "Hello, how are you today?"
tokens = count_tokens(text)
# Should return reasonable token count
assert tokens > 0
assert tokens < len(text) # Should be fewer tokens than characters
def test_count_tokens_empty(self):
"""Test counting empty string."""
tokens = count_tokens("")
assert tokens == 0
def test_count_message_tokens_single(self):
"""Test counting tokens in single message."""
messages = [{"role": "user", "content": "Hello"}]
tokens = count_message_tokens(messages)
assert tokens > 0
def test_count_message_tokens_multiple(self):
"""Test counting tokens in multiple messages."""
messages = [
{"role": "user", "content": "Hello, how are you?"},
{"role": "assistant", "content": "I'm doing well, thank you!"},
]
tokens = count_message_tokens(messages)
# Should be more than single message
single_tokens = count_message_tokens([messages[0]])
assert tokens > single_tokens
def test_count_message_tokens_empty_list(self):
"""Test counting empty message list."""
tokens = count_message_tokens([])
# litellm may return small overhead even for empty list
assert tokens < 10
class TestTokenCountingAccuracy:
"""Tests for token counting accuracy."""
def test_code_tokens_reasonable(self):
"""Test that code is tokenized reasonably."""
code = """
def hello_world():
print("Hello, World!")
return True
"""
tokens = count_tokens(code)
# Code should have reasonable token count
assert 10 < tokens < 100
def test_special_characters(self):
"""Test tokenization of special characters."""
text = "Hello! @#$%^&*() World?"
tokens = count_tokens(text)
assert tokens > 0
def test_unicode_text(self):
"""Test tokenization of unicode text."""
text = "Hello 世界 🌍"
tokens = count_tokens(text)
assert tokens > 0