"""Integration tests for agentic loop WebSocket broadcasts. Verifies that ``run_agentic_loop`` pushes the correct sequence of events through the real ``ws_manager`` and that connected (mock) WebSocket clients receive every broadcast with the expected payloads. """ import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from infrastructure.ws_manager.handler import WebSocketManager from timmy.agentic_loop import run_agentic_loop # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _mock_run(content: str): m = MagicMock() m.content = content return m def _ws_client() -> AsyncMock: """Return a fake WebSocket that records sent messages.""" return AsyncMock() def _collected_events(ws: AsyncMock) -> list[dict]: """Extract parsed JSON events from a mock WebSocket's send_text calls.""" return [json.loads(call.args[0]) for call in ws.send_text.call_args_list] # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestAgenticLoopBroadcastSequence: """Events arrive at WS clients in the correct order with expected data.""" @pytest.mark.asyncio async def test_successful_run_broadcasts_plan_steps_complete(self): """A successful 2-step loop emits plan_ready → 2× step_complete → task_complete.""" mgr = WebSocketManager() ws = _ws_client() mgr._connections = [ws] mock_agent = MagicMock() mock_agent.run = MagicMock( side_effect=[ _mock_run("1. Gather data\n2. Summarise"), _mock_run("Gathered 10 records"), _mock_run("Summary written"), ] ) with ( patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent), patch("infrastructure.ws_manager.handler.ws_manager", mgr), ): result = await run_agentic_loop("Gather and summarise", max_steps=2) assert result.status == "completed" events = _collected_events(ws) event_names = [e["event"] for e in events] assert event_names == [ "agentic.plan_ready", "agentic.step_complete", "agentic.step_complete", "agentic.task_complete", ] @pytest.mark.asyncio async def test_plan_ready_payload(self): """plan_ready contains task_id, task, steps list, and total count.""" mgr = WebSocketManager() ws = _ws_client() mgr._connections = [ws] mock_agent = MagicMock() mock_agent.run = MagicMock( side_effect=[ _mock_run("1. Alpha\n2. Beta"), _mock_run("Alpha done"), _mock_run("Beta done"), ] ) with ( patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent), patch("infrastructure.ws_manager.handler.ws_manager", mgr), ): result = await run_agentic_loop("Two steps") plan_event = _collected_events(ws)[0] assert plan_event["event"] == "agentic.plan_ready" data = plan_event["data"] assert data["task_id"] == result.task_id assert data["task"] == "Two steps" assert data["steps"] == ["Alpha", "Beta"] assert data["total"] == 2 @pytest.mark.asyncio async def test_step_complete_payload(self): """step_complete carries step number, total, description, and result.""" mgr = WebSocketManager() ws = _ws_client() mgr._connections = [ws] mock_agent = MagicMock() mock_agent.run = MagicMock( side_effect=[ _mock_run("1. Only step"), _mock_run("Step result text"), ] ) with ( patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent), patch("infrastructure.ws_manager.handler.ws_manager", mgr), ): await run_agentic_loop("Single step", max_steps=1) step_event = _collected_events(ws)[1] assert step_event["event"] == "agentic.step_complete" data = step_event["data"] assert data["step"] == 1 assert data["total"] == 1 assert data["description"] == "Only step" assert "Step result text" in data["result"] @pytest.mark.asyncio async def test_task_complete_payload(self): """task_complete has status, steps_completed, summary, and duration_ms.""" mgr = WebSocketManager() ws = _ws_client() mgr._connections = [ws] mock_agent = MagicMock() mock_agent.run = MagicMock( side_effect=[ _mock_run("1. Do it"), _mock_run("Done"), ] ) with ( patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent), patch("infrastructure.ws_manager.handler.ws_manager", mgr), ): await run_agentic_loop("Quick", max_steps=1) complete_event = _collected_events(ws)[-1] assert complete_event["event"] == "agentic.task_complete" data = complete_event["data"] assert data["status"] == "completed" assert data["steps_completed"] == 1 assert isinstance(data["duration_ms"], int) assert data["duration_ms"] >= 0 assert data["summary"] class TestAdaptationBroadcast: """Adapted steps emit step_adapted events.""" @pytest.mark.asyncio async def test_adapted_step_broadcasts_step_adapted(self): """A failed-then-adapted step emits agentic.step_adapted.""" mgr = WebSocketManager() ws = _ws_client() mgr._connections = [ws] mock_agent = MagicMock() mock_agent.run = MagicMock( side_effect=[ _mock_run("1. Risky step"), Exception("disk full"), _mock_run("Used /tmp instead"), ] ) with ( patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent), patch("infrastructure.ws_manager.handler.ws_manager", mgr), ): result = await run_agentic_loop("Adapt test", max_steps=1) events = _collected_events(ws) event_names = [e["event"] for e in events] assert "agentic.step_adapted" in event_names adapted = next(e for e in events if e["event"] == "agentic.step_adapted") assert adapted["data"]["error"] == "disk full" assert adapted["data"]["adaptation"] assert result.steps[0].status == "adapted" class TestMultipleClients: """All connected clients receive every broadcast.""" @pytest.mark.asyncio async def test_two_clients_receive_all_events(self): mgr = WebSocketManager() ws1 = _ws_client() ws2 = _ws_client() mgr._connections = [ws1, ws2] mock_agent = MagicMock() mock_agent.run = MagicMock( side_effect=[ _mock_run("1. Step A"), _mock_run("A done"), ] ) with ( patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent), patch("infrastructure.ws_manager.handler.ws_manager", mgr), ): await run_agentic_loop("Multi-client", max_steps=1) events1 = _collected_events(ws1) events2 = _collected_events(ws2) assert len(events1) == len(events2) == 3 # plan + step + complete assert [e["event"] for e in events1] == [e["event"] for e in events2] class TestEventHistory: """Broadcasts are recorded in ws_manager event history.""" @pytest.mark.asyncio async def test_events_appear_in_history(self): mgr = WebSocketManager() mock_agent = MagicMock() mock_agent.run = MagicMock( side_effect=[ _mock_run("1. Only"), _mock_run("Done"), ] ) with ( patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent), patch("infrastructure.ws_manager.handler.ws_manager", mgr), ): await run_agentic_loop("History test", max_steps=1) history_events = [e.event for e in mgr.event_history] assert "agentic.plan_ready" in history_events assert "agentic.step_complete" in history_events assert "agentic.task_complete" in history_events class TestBroadcastGracefulDegradation: """Loop completes even when ws_manager is unavailable.""" @pytest.mark.asyncio async def test_loop_succeeds_when_broadcast_fails(self): """ImportError from ws_manager doesn't crash the loop.""" mock_agent = MagicMock() mock_agent.run = MagicMock( side_effect=[ _mock_run("1. Do it"), _mock_run("Done"), ] ) with ( patch("timmy.agentic_loop._get_loop_agent", return_value=mock_agent), patch( "infrastructure.ws_manager.handler.ws_manager", new_callable=lambda: MagicMock, ) as broken_mgr, ): broken_mgr.broadcast = AsyncMock(side_effect=RuntimeError("ws down")) result = await run_agentic_loop("Resilient task", max_steps=1) assert result.status == "completed" assert len(result.steps) == 1