diff --git a/.env b/.env index e2d9cab..846e673 100644 --- a/.env +++ b/.env @@ -22,6 +22,6 @@ EMAIL_HOST= EMAIL_HOST_USER= EMAIL_HOST_PASSWORD= -CHAT_BACKEND=ollama +CHAT_BACKEND=stub diff --git a/app/main.py b/app/main.py index b4e365b..6ef3d91 100644 --- a/app/main.py +++ b/app/main.py @@ -52,7 +52,7 @@ async def lifespan(app: FastAPI): await app.logger.aerror("Error during app startup", error=repr(e)) raise finally: - await app.redis.close() + await app.redis.aclose() await app.chat_agent.aclose() # await app.postgres_pool.close() diff --git a/tests/api/test_chat_ws.py b/tests/api/test_chat_ws.py new file mode 100644 index 0000000..8867df6 --- /dev/null +++ b/tests/api/test_chat_ws.py @@ -0,0 +1,124 @@ +"""Tests for the websocket chat service using the local stub agent. + +Uses Starlette's synchronous ``TestClient`` websocket support (rather than +the ``httpx.AsyncClient`` fixture used elsewhere) since it triggers the +FastAPI lifespan on ``__enter__``/``__exit__``, wiring up +``app.chat_agent`` / ``app.state.chat_sessions`` exactly like a real +server run. +""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +pytestmark = pytest.mark.anyio + + +async def test_chat_ws_full_conversation_flow() -> None: + with ( + TestClient(app) as client, + client.websocket_connect("/v1/chat/ws") as websocket, + ): + # 1. connect + connected = websocket.receive_json() + assert connected["type"] == "connected" + session_id = connected["session_id"] + + # 2. start conversation with a system prompt + websocket.send_json( + {"type": "start", "system_prompt": "You are a helpful assistant."} + ) + started = websocket.receive_json() + print(f"{started=}") + assert started == {"type": "started", "session_id": session_id} + + # 3. send a user message and aggregate the streamed assistant chunks + websocket.send_json({"type": "user_message", "content": "hello"}) + + event = websocket.receive_json() + aggregated = "" + while event["type"] == "assistant_chunk": + assert event["index"] >= 0 + aggregated += event["content"] + event = websocket.receive_json() + + assert event["type"] == "assistant_message" + assert event["message"]["role"] == "assistant" + assert event["message"]["content"] == aggregated + print(f"{event=}") + print(f"{aggregated=}") + assert "hello" in aggregated.lower() + + # 4. request full history (system + user + assistant) + websocket.send_json({"type": "history_request"}) + history = websocket.receive_json() + assert history["type"] == "history" + roles = [m["role"] for m in history["messages"]] + assert roles == ["system", "user", "assistant"] + + # 5. gracefully end the conversation + websocket.send_json({"type": "end"}) + + +async def test_chat_ws_invalid_event_returns_error() -> None: + with ( + TestClient(app) as client, + client.websocket_connect("/v1/chat/ws") as websocket, + ): + websocket.receive_json() # connected + + websocket.send_json({"type": "not_a_real_event"}) + error = websocket.receive_json() + assert error["type"] == "error" + + websocket.send_json({"type": "end"}) + + +async def test_chat_ws_ask_question_and_print_model_answer() -> None: + """Ask a real question over the websocket chat and print the model's answer. + + Runs against whatever ``ChatAgent`` is wired up via ``app.state.chat_agent`` + (the local stub agent by default, or a real Ollama-backed agent when + ``CHAT_BACKEND=ollama`` is configured), exercising the full websocket + conversation flow end-to-end. + """ + question = "What is the capital of France?" + + with ( + TestClient(app) as client, + client.websocket_connect("/v1/chat/ws") as websocket, + ): + # 1. connect + connected = websocket.receive_json() + assert connected["type"] == "connected" + session_id = connected["session_id"] + + # 2. start conversation + websocket.send_json( + {"type": "start", "system_prompt": "You are a helpful assistant."} + ) + started = websocket.receive_json() + assert started == {"type": "started", "session_id": session_id} + + # 3. ask the question and aggregate the streamed answer + websocket.send_json({"type": "user_message", "content": question}) + + event = websocket.receive_json() + answer = "" + while event["type"] == "assistant_chunk": + answer += event["content"] + event = websocket.receive_json() + + assert event["type"] == "assistant_message" + answer = event["message"]["content"] + + print(f"\nQuestion: {question}") + print(f"Model answer: {answer}") + + assert isinstance(answer, str) + assert answer.strip() != "" + + # 4. gracefully end the conversation + websocket.send_json({"type": "end"}) + diff --git a/tests/test_ollama_chat_agent_attrs.py b/tests/test_ollama_chat_agent_attrs.py new file mode 100644 index 0000000..4fac694 --- /dev/null +++ b/tests/test_ollama_chat_agent_attrs.py @@ -0,0 +1,136 @@ +"""Test suite for refactored OllamaChatAgent with attrs features.""" +import httpx +import pytest + +from app.services.chat_agent import OllamaChatAgent, LocalEchoAgent, build_chat_agent +from app.config import ChatConfig + + +class TestOllamaChatAgentAttrsFeatures: + """Test the attrs library implementation and features.""" + + def test_instantiation_with_valid_config(self): + """Test basic instantiation with valid configuration.""" + agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1") + assert agent.model == "llama3.2" + assert agent.base_url == "http://localhost:11434/v1" + assert agent.timeout == 60.0 + + def test_url_validation_rejects_non_http_schemes(self): + """Test that base_url validator rejects non-HTTP schemes.""" + with pytest.raises(ValueError, match="must be HTTP"): + OllamaChatAgent(model="llama3.2", base_url="ftp://invalid.com") + + def test_url_validation_rejects_missing_host(self): + """Test that base_url validator rejects URLs without a host.""" + with pytest.raises(ValueError, match="must include a host"): + OllamaChatAgent(model="llama3.2", base_url="http://") + + def test_timeout_validation_rejects_negative(self): + """Test that timeout validator rejects negative values.""" + with pytest.raises(ValueError, match="must be positive"): + OllamaChatAgent( + model="llama3.2", + base_url="http://localhost:11434/v1", + timeout=-5.0, + ) + + def test_timeout_validation_rejects_zero(self): + """Test that timeout validator rejects zero.""" + with pytest.raises(ValueError, match="must be positive"): + OllamaChatAgent( + model="llama3.2", + base_url="http://localhost:11434/v1", + timeout=0.0, + ) + + def test_timeout_converter_string_to_float(self): + """Test that timeout converter coerces strings to float.""" + agent = OllamaChatAgent( + model="llama3.2", + base_url="http://localhost:11434/v1", + timeout="30", # Pass as string + ) + assert isinstance(agent.timeout, float) + assert agent.timeout == 30.0 + + def test_factory_initialization_of_client(self): + """Test that _client is created via factory function.""" + agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1") + assert hasattr(agent, "_client") + assert isinstance(agent._client, httpx.AsyncClient) + # httpx normalizes URLs by adding a trailing slash + assert str(agent._client.base_url) == "http://localhost:11434/v1/" + assert agent._client.timeout == httpx.Timeout(60.0) + + def test_slots_enabled_no_dict(self): + """Test that slots=True prevents __dict__ attribute.""" + agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1") + # With slots=True, instances shouldn't have __dict__ + # (unless also inherited from a class with __dict__) + assert not hasattr(agent, "__dict__") + + def test_equality_disabled(self): + """Test that eq=False means instances are not equal even with same values.""" + agent1 = OllamaChatAgent( + model="llama3.2", base_url="http://localhost:11434/v1" + ) + agent2 = OllamaChatAgent( + model="llama3.2", base_url="http://localhost:11434/v1" + ) + # With eq=False, only identity comparison works + assert agent1 != agent2 + assert agent1 == agent1 + + def test_repr_hides_client(self): + """Test that repr=False on _client hides it from string representation.""" + agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1") + agent_repr = repr(agent) + # _client should not appear in repr + assert "_client" not in agent_repr + # But model and base_url should + assert "llama3.2" in agent_repr + assert "localhost" in agent_repr + + def test_build_chat_agent_factory_still_works(self): + """Test that the build_chat_agent factory function works unchanged.""" + config = ChatConfig(backend="stub") + local_agent = build_chat_agent(config) + assert isinstance(local_agent, LocalEchoAgent) + + def test_build_chat_agent_ollama_backend(self): + """Test that build_chat_agent can create OllamaChatAgent.""" + config = ChatConfig( + backend="ollama", + base_url="http://localhost:11434/v1", + model="llama3.2", + ) + agent = build_chat_agent(config) + assert isinstance(agent, OllamaChatAgent) + assert agent.model == "llama3.2" + assert agent.base_url == "http://localhost:11434/v1" + + def test_https_urls_accepted(self): + """Test that HTTPS URLs are properly accepted.""" + agent = OllamaChatAgent( + model="llama3.2", base_url="https://api.example.com/v1" + ) + assert agent.base_url == "https://api.example.com/v1" + + def test_metadata_annotations_present(self): + """Test that metadata is properly attached to fields.""" + import attrs + + fields = attrs.fields(OllamaChatAgent) + model_field = fields.model + assert "description" in model_field.metadata + assert "LLM model identifier" in model_field.metadata["description"] + + base_url_field = fields.base_url + assert "description" in base_url_field.metadata + assert "OpenAI-compatible" in base_url_field.metadata["description"] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) +