"""Tests for session event logging (tool calls, UI events persistence).""" import json import asyncio from unittest.mock import patch, MagicMock, AsyncMock import pytest import reachy_mini_conversation_app.stream_api as stream_api @pytest.fixture def mock_conversation_logger(): """Provide a mock ConversationLogger and wire it into stream_api.""" mock_logger = MagicMock() mock_logger.log_turn = MagicMock(return_value=1) mock_logger.get_session = MagicMock(return_value=[]) original_logger = stream_api._conversation_logger original_session_id = stream_api._current_session_id stream_api._conversation_logger = mock_logger stream_api._current_session_id = "test-session-abc" yield mock_logger stream_api._conversation_logger = original_logger stream_api._current_session_id = original_session_id @pytest.fixture def mock_broadcast(): """Mock the broadcast to avoid needing real WebSocket connections.""" with patch.object(stream_api.manager, "broadcast", new_callable=AsyncMock) as m: yield m # ───────────────────────────────────────────────────────────── # Tool event persistence # ───────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_emit_tool_start_persists_to_conversation_log( mock_conversation_logger, mock_broadcast ): """emit_tool_start should log a 'tool' turn to the conversation log.""" await stream_api.emit_tool_start( name="log_entry", args={"type": "headache", "intensity": 7}, event_id="evt-001", ) mock_conversation_logger.log_turn.assert_called_once() call_kwargs = mock_conversation_logger.log_turn.call_args assert call_kwargs[1]["role"] == "tool" assert "log_entry" in call_kwargs[1]["content"] assert "headache" in call_kwargs[1]["content"] assert call_kwargs[1]["session_id"] == "test-session-abc" @pytest.mark.asyncio async def test_emit_tool_result_persists_to_conversation_log( mock_conversation_logger, mock_broadcast ): """emit_tool_result should log a 'tool_result' turn.""" await stream_api.emit_tool_result( name="log_entry", result={"status": "ok", "entry_id": 42}, event_id="evt-001", status="completed", ) mock_conversation_logger.log_turn.assert_called_once() call_kwargs = mock_conversation_logger.log_turn.call_args assert call_kwargs[1]["role"] == "tool_result" assert "log_entry" in call_kwargs[1]["content"] assert call_kwargs[1]["metadata"]["status"] == "completed" @pytest.mark.asyncio async def test_emit_tool_result_truncates_large_results( mock_conversation_logger, mock_broadcast ): """Large tool results should be truncated to avoid DB bloat.""" big_result = {"data": "x" * 2000} await stream_api.emit_tool_result( name="big_tool", result=big_result, event_id="evt-002", ) call_kwargs = mock_conversation_logger.log_turn.call_args content = call_kwargs[1]["content"] assert len(content) < 1200 # truncated from 2000+ assert "truncated" in content # ───────────────────────────────────────────────────────────── # UI event persistence # ───────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_emit_ui_component_persists_to_conversation_log( mock_conversation_logger, mock_broadcast ): """emit_ui_component should log a 'ui' turn.""" await stream_api.emit_ui_component( component_name="HeadacheLog", props={"intensity": 7, "location": "frontal"}, message_id="msg-001", ) mock_conversation_logger.log_turn.assert_called_once() call_kwargs = mock_conversation_logger.log_turn.call_args assert call_kwargs[1]["role"] == "ui" assert "HeadacheLog" in call_kwargs[1]["content"] assert "frontal" in call_kwargs[1]["content"] @pytest.mark.asyncio async def test_emit_ui_component_truncates_large_props( mock_conversation_logger, mock_broadcast ): """Large UI props should be truncated.""" big_props = {"data": "y" * 1000} await stream_api.emit_ui_component( component_name="BigComponent", props=big_props, ) call_kwargs = mock_conversation_logger.log_turn.call_args content = call_kwargs[1]["content"] assert len(content) < 700 # truncated from 1000+ # ───────────────────────────────────────────────────────────── # No-op when logger is not initialized # ───────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_emit_tool_start_no_crash_without_logger(mock_broadcast): """Should not crash when conversation logger is not initialized.""" original = stream_api._conversation_logger stream_api._conversation_logger = None try: await stream_api.emit_tool_start( name="test_tool", args={"a": 1}, event_id="evt-999", ) # Should complete without error finally: stream_api._conversation_logger = original # ───────────────────────────────────────────────────────────── # Session-log endpoint # ───────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_session_log_endpoint_returns_turns(mock_conversation_logger): """GET /session-log should return turns from ConversationLogger.""" mock_conversation_logger.get_session.return_value = [ {"turn_index": 0, "role": "user", "content": "Hello"}, {"turn_index": 1, "role": "tool", "content": "log_entry({})"}, {"turn_index": 2, "role": "ui", "content": "HeadacheLog: {}"}, ] result = await stream_api.session_log(session_id="test-session-abc") assert result["session_id"] == "test-session-abc" assert result["turn_count"] == 3 assert result["turns"][1]["role"] == "tool" @pytest.mark.asyncio async def test_session_log_endpoint_no_logger(): """Should return error when logger not initialized.""" original = stream_api._conversation_logger stream_api._conversation_logger = None try: result = await stream_api.session_log() assert "error" in result finally: stream_api._conversation_logger = original