97 findings to zero. Most were mechanical — 52 unsorted import blocks, 10 unsorted __all__, assorted pyupgrade and simplify hints. Two were not, and both were visible only because the lint made me look. `webber version` did not exist. src/cli/commands/version.py defines show_version(), main.py imported it, and the registration line was never written — the CLI exposed chat and explore only. The import carried `# noqa: F401`, which is what kept the omission quiet: someone marked the symptom as intentional instead of asking why it was unused. show_version is not redundant with the --version flag; it prints the resolved Ollama URL, model and debug state, which is the form worth having when something is misconfigured. Registered, and the suppression dropped because the import is now genuinely used. test_spawn_explore_agent asserted nothing. It built a mock RunContext, patched get_agent, and stopped at the comment "For now, verify the explore agent would be called correctly". It had been counted as a passing test. An AST sweep of all 238 test functions found it was the only one, which is worth knowing — the problem was contained, not systemic. It is now skipped with a reason, so it reports as unfinished rather than as passing. Reducing it rather than deleting its imports was the point: tidying the imports would have made a hollow test look clean. Two findings were false positives, and both are recorded rather than silently worked around: B023 flagged run_agent closing over full_prompt and ctx. Traced: agent_task is awaited at line 326 before `continue` reaches the next iteration, so neither name can be rebound while the closure is pending, and the exception path cancels and awaits too. Not a bug. Bound as defaults anyway, because that stays true if the await ever moves. I had called it a live bug before tracing it, which is the mistake Rule 5 exists for. RUF012 flagged `rules: list[ApprovalRule] = []` on ApprovalRuleSet. Its suggested fix — annotate ClassVar — would remove the field from the model. ApprovalRuleSet is a pydantic model and pydantic deep-copies defaults per instance; verified by constructing two and confirming their lists are distinct objects. Suppressed with that evidence in the comment. Ruff cannot see the pydantic base because BaseSchema is a local subclass of BaseModel. Also moved a stray `from src.shared.logging import ...` that had drifted below a function definition, and merged a nested if in the ollama provider. 215 passed, 23 skipped, unchanged except for the new skip. `webber version` exercised end to end. mypy is NOT addressed here and the gate still fails on it — 55 errors in 14 files, 35 of them no-any-return from pydantic_ai's untyped returns. That was hidden behind ruff, because the gate stops at the first failing stage. Co-Authored-By: Claude <noreply@anthropic.com>
386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""
|
|
Tests for conversations domain.
|
|
|
|
Tests conversation CRUD, context building, and API endpoints.
|
|
"""
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from src.domains.conversations.models import Conversation, Message
|
|
from src.domains.conversations.schemas import (
|
|
AddMessageRequest,
|
|
CreateConversationRequest,
|
|
)
|
|
|
|
|
|
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 unittest.mock import MagicMock
|
|
|
|
from src.domains.conversations.service import ConversationService
|
|
|
|
# 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 unittest.mock import MagicMock
|
|
|
|
from src.domains.conversations.models import Message
|
|
from src.domains.conversations.service import ConversationService
|
|
|
|
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 unittest.mock import MagicMock
|
|
|
|
from src.domains.conversations.models import Message
|
|
from src.domains.conversations.service import ConversationService
|
|
|
|
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.models import Message
|
|
from src.domains.conversations.summarize import format_messages_for_summary
|
|
|
|
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.models import Message
|
|
from src.domains.conversations.summarize import format_messages_for_summary
|
|
|
|
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
|
|
|
|
|
|
class TestSaveMessagesAPI:
|
|
"""Tests for the save messages endpoint (no agent execution)."""
|
|
|
|
@pytest.mark.anyio
|
|
async def test_save_messages(self, auth_client):
|
|
"""Test saving a message pair without triggering agent."""
|
|
# First create a conversation
|
|
response = await auth_client.post(
|
|
"/conversations/",
|
|
json={"agent_type": "task", "working_dir": "."}
|
|
)
|
|
assert response.status_code == 201
|
|
conv_id = response.json()["id"]
|
|
|
|
# Save a message pair
|
|
response = await auth_client.post(
|
|
f"/conversations/{conv_id}/save",
|
|
json={
|
|
"user_content": "Find all Python files",
|
|
"assistant_content": "I found 5 Python files in the project.",
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["user_message"]["role"] == "user"
|
|
assert data["user_message"]["content"] == "Find all Python files"
|
|
assert data["assistant_message"]["role"] == "assistant"
|
|
assert data["assistant_message"]["content"] == "I found 5 Python files in the project."
|
|
assert data["total_tokens"] > 0
|
|
|
|
@pytest.mark.anyio
|
|
async def test_save_messages_not_found(self, auth_client):
|
|
"""Test saving messages to non-existent conversation."""
|
|
fake_id = uuid4()
|
|
response = await auth_client.post(
|
|
f"/conversations/{fake_id}/save",
|
|
json={
|
|
"user_content": "Test",
|
|
"assistant_content": "Response",
|
|
}
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.anyio
|
|
async def test_save_messages_updates_token_count(self, auth_client):
|
|
"""Test that saving messages updates the conversation token count."""
|
|
# Create conversation
|
|
response = await auth_client.post(
|
|
"/conversations/",
|
|
json={"agent_type": "explore", "working_dir": "."}
|
|
)
|
|
conv_id = response.json()["id"]
|
|
assert response.json()["total_tokens"] == 0
|
|
|
|
# Save first message pair
|
|
response = await auth_client.post(
|
|
f"/conversations/{conv_id}/save",
|
|
json={
|
|
"user_content": "Hello",
|
|
"assistant_content": "Hi there!",
|
|
}
|
|
)
|
|
first_tokens = response.json()["total_tokens"]
|
|
assert first_tokens > 0
|
|
|
|
# Save second message pair
|
|
response = await auth_client.post(
|
|
f"/conversations/{conv_id}/save",
|
|
json={
|
|
"user_content": "How are you?",
|
|
"assistant_content": "I'm doing well, thank you for asking!",
|
|
}
|
|
)
|
|
second_tokens = response.json()["total_tokens"]
|
|
assert second_tokens > first_tokens
|
|
|
|
# Verify via get endpoint
|
|
response = await auth_client.get(f"/conversations/{conv_id}")
|
|
assert response.status_code == 200
|
|
assert response.json()["total_tokens"] == second_tokens
|
|
assert len(response.json()["messages"]) == 4
|