"""Tests for SQ/EQ integration into app.py and portal.py (Phase 4) Test scenarios: - app.py initializes event_queue and submission_queue on app.state - portal WebSocket emits correct event types to EQ - EQ emit failure does not break WebSocket flow - Events contain correct session_id and task_id - _emit_event_safe helper handles None EQ gracefully """ from __future__ import annotations from unittest.mock import AsyncMock import pytest from agentkit.core.event_queue import EventQueue, SubmissionQueue from agentkit.core.protocol import ( Event, TaskEventType, TurnEventType, ) from agentkit.llm.gateway import LLMGateway from agentkit.llm.protocol import LLMResponse, TokenUsage from agentkit.server.app import create_app from agentkit.server.routes.portal import _emit_event_safe from agentkit.skills.registry import SkillRegistry from agentkit.tools.registry import ToolRegistry # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def mock_llm_gateway(): gateway = LLMGateway() mock_provider = AsyncMock() mock_provider.chat.return_value = LLMResponse( content="Hello from LLM", model="test-model", usage=TokenUsage(prompt_tokens=10, completion_tokens=20), ) gateway.register_provider("test", mock_provider) return gateway @pytest.fixture def skill_registry(): return SkillRegistry() @pytest.fixture def tool_registry(): return ToolRegistry() @pytest.fixture def app(mock_llm_gateway, skill_registry, tool_registry): return create_app( llm_gateway=mock_llm_gateway, skill_registry=skill_registry, tool_registry=tool_registry, ) # --------------------------------------------------------------------------- # app.py initialization tests # --------------------------------------------------------------------------- class TestAppInitialization: """Verify app.py initializes SQ/EQ on app.state.""" def test_app_has_event_queue(self, app): """app.state should have an EventQueue instance.""" assert hasattr(app.state, "event_queue") assert isinstance(app.state.event_queue, EventQueue) def test_app_has_submission_queue(self, app): """app.state should have a SubmissionQueue instance.""" assert hasattr(app.state, "submission_queue") assert isinstance(app.state.submission_queue, SubmissionQueue) def test_event_queue_initially_open(self, app): """EventQueue should be open (not closed) after app creation.""" assert app.state.event_queue.is_closed is False def test_submission_queue_initially_open(self, app): """SubmissionQueue should be open (not closed) after app creation.""" assert app.state.submission_queue.is_closed is False def test_event_queue_supports_subscribers(self, app): """EventQueue should start with zero subscribers.""" assert app.state.event_queue.subscriber_count == 0 # --------------------------------------------------------------------------- # _emit_event_safe helper tests # --------------------------------------------------------------------------- class TestEmitEventSafe: """Verify the _emit_event_safe helper handles edge cases.""" @pytest.mark.asyncio async def test_emit_with_none_queue_is_noop(self): """Emitting to None queue should be a no-op (no exception).""" # No exception should be raised await _emit_event_safe( None, TaskEventType.TASK_CREATED, task_id="test-task", session_id="test-session", data={"message": "hello"}, ) @pytest.mark.asyncio async def test_emit_with_valid_queue_emits_event(self): """Emitting to a valid EventQueue should emit the event.""" eq = EventQueue() await _emit_event_safe( eq, TaskEventType.TASK_CREATED, task_id="test-task", session_id="test-session", data={"message": "hello"}, ) # Buffer should contain the event assert len(eq._buffer) == 1 event = eq._buffer[0] assert event.event_type == TaskEventType.TASK_CREATED assert event.task_id == "test-task" assert event.session_id == "test-session" assert event.data == {"message": "hello"} @pytest.mark.asyncio async def test_emit_with_failing_queue_does_not_raise(self): """Emit failures should be swallowed (never raise to caller).""" # Create a broken EventQueue that raises on emit class BrokenEventQueue(EventQueue): async def emit(self, event: Event) -> None: raise RuntimeError("Simulated EQ failure") broken_eq = BrokenEventQueue() # Should NOT raise — error is caught and logged await _emit_event_safe( broken_eq, TaskEventType.TASK_CREATED, task_id="test-task", session_id="test-session", ) @pytest.mark.asyncio async def test_emit_with_none_data_uses_empty_dict(self): """Emitting with data=None should default to empty dict.""" eq = EventQueue() await _emit_event_safe( eq, TaskEventType.TASK_STARTED, task_id="t1", session_id="s1", data=None, ) assert eq._buffer[0].data == {} # --------------------------------------------------------------------------- # Portal WebSocket EQ integration tests # --------------------------------------------------------------------------- class TestPortalWebSocketEQIntegration: """Verify portal WebSocket emits events to EQ alongside WebSocket messages. NOTE: Starlette TestClient's sync WS client has known issues with disconnect handling (see test_portal_routes.py::TestPortalWebSocket for skipped tests). These tests verify EQ integration through the helper function and app state rather than full end-to-end WS flows. """ @pytest.mark.asyncio async def test_app_state_event_queue_is_functional(self, app): """The EventQueue on app.state should be functional (accept emits).""" eq: EventQueue = app.state.event_queue # Emit a task.created event as the WebSocket handler would await _emit_event_safe( eq, TaskEventType.TASK_CREATED, task_id="test-task-id", session_id="test-session-id", data={"message": "hello"}, ) # Verify the event was buffered assert len(eq._buffer) == 1 event = eq._buffer[0] assert event.event_type == "task.created" assert event.task_id == "test-task-id" assert event.session_id == "test-session-id" @pytest.mark.asyncio async def test_eq_emit_failure_does_not_break_app(self, app): """If EQ.emit() raises, the app should continue to function normally.""" # Replace EQ with a broken one that raises on emit class BrokenEventQueue(EventQueue): async def emit(self, event: Event) -> None: raise RuntimeError("Simulated EQ failure") app.state.event_queue = BrokenEventQueue() # The _emit_event_safe helper should swallow the error await _emit_event_safe( app.state.event_queue, TaskEventType.TASK_CREATED, task_id="t1", session_id="s1", ) # App state should still be accessible assert app.state.event_queue is not None @pytest.mark.asyncio async def test_event_queue_close_on_shutdown(self): """EventQueue should be closed when app shuts down.""" app = create_app( llm_gateway=LLMGateway(), skill_registry=SkillRegistry(), tool_registry=ToolRegistry(), ) eq = app.state.event_queue sq = app.state.submission_queue assert eq.is_closed is False assert sq.is_closed is False # Simulate lifespan shutdown by calling close directly # (TestClient lifespan handling would also work but is heavier) eq.close() sq.close() assert eq.is_closed is True assert sq.is_closed is True @pytest.mark.asyncio async def test_full_task_lifecycle_events_simulated(self, app): """Simulate the full task lifecycle that the WebSocket handler would emit. This verifies that the EventQueue correctly receives and buffers the sequence of events emitted during a typical WebSocket chat flow. """ eq: EventQueue = app.state.event_queue task_id = "simulated-task-123" session_id = "simulated-session-456" # Simulate the event sequence from portal_websocket handler await _emit_event_safe( eq, TaskEventType.TASK_CREATED, task_id, session_id, {"message": "hello"} ) await _emit_event_safe( eq, TaskEventType.TASK_STARTED, task_id, session_id, {"agent_name": "default", "execution_mode": "direct_chat"}, ) await _emit_event_safe( eq, TurnEventType.FINAL_ANSWER, task_id, session_id, {"output": "Hello back!"}, ) await _emit_event_safe( eq, TaskEventType.TASK_COMPLETED, task_id, session_id, {"output": "Hello back!"}, ) # Verify all 4 events were buffered in order assert len(eq._buffer) == 4 event_types = [e.event_type for e in eq._buffer] assert event_types == [ "task.created", "task.started", "turn.final_answer", "task.completed", ] # Verify all events carry the same task_id and session_id for event in eq._buffer: assert event.task_id == task_id assert event.session_id == session_id # --------------------------------------------------------------------------- # Event type correctness tests # --------------------------------------------------------------------------- class TestEventTypes: """Verify event type constants are correctly used.""" @pytest.mark.asyncio async def test_task_created_event_type(self): """task.created should map to TaskEventType.TASK_CREATED.""" eq = EventQueue() await _emit_event_safe( eq, TaskEventType.TASK_CREATED, task_id="t1", session_id="s1", data={"message": "test"}, ) event = eq._buffer[0] assert event.event_type == "task.created" @pytest.mark.asyncio async def test_task_started_event_type(self): """task.started should map to TaskEventType.TASK_STARTED.""" eq = EventQueue() await _emit_event_safe( eq, TaskEventType.TASK_STARTED, task_id="t1", session_id="s1", ) event = eq._buffer[0] assert event.event_type == "task.started" @pytest.mark.asyncio async def test_task_completed_event_type(self): """task.completed should map to TaskEventType.TASK_COMPLETED.""" eq = EventQueue() await _emit_event_safe( eq, TaskEventType.TASK_COMPLETED, task_id="t1", session_id="s1", data={"output": "done"}, ) event = eq._buffer[0] assert event.event_type == "task.completed" @pytest.mark.asyncio async def test_task_failed_event_type(self): """task.failed should map to TaskEventType.TASK_FAILED.""" eq = EventQueue() await _emit_event_safe( eq, TaskEventType.TASK_FAILED, task_id="t1", session_id="s1", data={"error": "oops"}, ) event = eq._buffer[0] assert event.event_type == "task.failed" @pytest.mark.asyncio async def test_turn_thinking_event_type(self): """turn.thinking should map to TurnEventType.THINKING.""" eq = EventQueue() await _emit_event_safe( eq, TurnEventType.THINKING, task_id="t1", session_id="s1", ) event = eq._buffer[0] assert event.event_type == "turn.thinking" @pytest.mark.asyncio async def test_turn_tool_call_event_type(self): """turn.tool_call should map to TurnEventType.TOOL_CALL.""" eq = EventQueue() await _emit_event_safe( eq, TurnEventType.TOOL_CALL, task_id="t1", session_id="s1", ) event = eq._buffer[0] assert event.event_type == "turn.tool_call" @pytest.mark.asyncio async def test_turn_tool_result_event_type(self): """turn.tool_result should map to TurnEventType.TOOL_RESULT.""" eq = EventQueue() await _emit_event_safe( eq, TurnEventType.TOOL_RESULT, task_id="t1", session_id="s1", ) event = eq._buffer[0] assert event.event_type == "turn.tool_result" @pytest.mark.asyncio async def test_turn_final_answer_event_type(self): """turn.final_answer should map to TurnEventType.FINAL_ANSWER.""" eq = EventQueue() await _emit_event_safe( eq, TurnEventType.FINAL_ANSWER, task_id="t1", session_id="s1", ) event = eq._buffer[0] assert event.event_type == "turn.final_answer" # --------------------------------------------------------------------------- # Event field correctness tests # --------------------------------------------------------------------------- class TestEventFields: """Verify events contain correct session_id and task_id.""" @pytest.mark.asyncio async def test_event_carries_correct_task_id(self): """Emitted event should carry the provided task_id.""" eq = EventQueue() expected_task_id = "unique-task-id-123" await _emit_event_safe( eq, TaskEventType.TASK_CREATED, task_id=expected_task_id, session_id="s1", ) assert eq._buffer[0].task_id == expected_task_id @pytest.mark.asyncio async def test_event_carries_correct_session_id(self): """Emitted event should carry the provided session_id.""" eq = EventQueue() expected_session_id = "conv-id-456" await _emit_event_safe( eq, TaskEventType.TASK_CREATED, task_id="t1", session_id=expected_session_id, ) assert eq._buffer[0].session_id == expected_session_id @pytest.mark.asyncio async def test_event_has_iso_timestamp(self): """Emitted event should have a non-empty ISO 8601 timestamp.""" eq = EventQueue() await _emit_event_safe( eq, TaskEventType.TASK_CREATED, task_id="t1", session_id="s1", ) timestamp = eq._buffer[0].timestamp assert isinstance(timestamp, str) assert len(timestamp) > 0 # ISO 8601 format should contain 'T' separator assert "T" in timestamp @pytest.mark.asyncio async def test_multiple_events_preserve_order(self): """Multiple events should be buffered in emission order.""" eq = EventQueue() await _emit_event_safe(eq, TaskEventType.TASK_CREATED, "t1", "s1") await _emit_event_safe(eq, TaskEventType.TASK_STARTED, "t1", "s1") await _emit_event_safe(eq, TurnEventType.FINAL_ANSWER, "t1", "s1") await _emit_event_safe(eq, TaskEventType.TASK_COMPLETED, "t1", "s1") event_types = [e.event_type for e in eq._buffer] assert event_types == [ "task.created", "task.started", "turn.final_answer", "task.completed", ] # --------------------------------------------------------------------------- # SubmissionQueue integration tests # --------------------------------------------------------------------------- class TestSubmissionQueueIntegration: """Verify SubmissionQueue is properly initialized on app.state.""" @pytest.mark.asyncio async def test_submission_queue_accepts_submissions(self, app): """SubmissionQueue on app.state should accept submissions.""" sq: SubmissionQueue = app.state.submission_queue task_id = await sq.submit("hello", "session-1") assert isinstance(task_id, str) assert len(task_id) > 0 @pytest.mark.asyncio async def test_submission_queue_close_marks_closed(self, app): """Closing SubmissionQueue should mark it as closed.""" sq: SubmissionQueue = app.state.submission_queue assert sq.is_closed is False sq.close() assert sq.is_closed is True @pytest.mark.asyncio async def test_submission_queue_rejects_after_close(self, app): """Closed SubmissionQueue should reject new submissions.""" sq: SubmissionQueue = app.state.submission_queue sq.close() with pytest.raises(RuntimeError, match="SubmissionQueue is closed"): await sq.submit("hello", "session-1")