diff --git a/.env.example b/.env.example index 5f825e4..57d0cec 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,14 @@ # AirLLM model size (default: 70b). # 8b ~16 GB RAM | 70b ~140 GB RAM | 405b ~810 GB RAM # AIRLLM_MODEL_SIZE=70b + +# ── L402 Lightning secrets ─────────────────────────────────────────────────── +# HMAC secret for invoice verification. MUST be changed in production. +# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" +# L402_HMAC_SECRET= + +# HMAC secret for macaroon signing. MUST be changed in production. +# L402_MACAROON_SECRET= + +# Lightning backend: "mock" (default) | "lnd" +# LIGHTNING_BACKEND=mock diff --git a/src/dashboard/routes/swarm.py b/src/dashboard/routes/swarm.py index 8d3f944..a9809fc 100644 --- a/src/dashboard/routes/swarm.py +++ b/src/dashboard/routes/swarm.py @@ -24,6 +24,15 @@ async def swarm_status(): return coordinator.status() +@router.get("/live", response_class=HTMLResponse) +async def swarm_live_page(request: Request): + """Render the live swarm dashboard page.""" + return templates.TemplateResponse( + "swarm_live.html", + {"request": request, "page_title": "Swarm Live"}, + ) + + @router.get("/agents") async def list_swarm_agents(): """List all registered swarm agents.""" @@ -88,6 +97,21 @@ async def post_task(description: str = Form(...)): } +@router.post("/tasks/auction") +async def post_task_and_auction(description: str = Form(...)): + """Post a task and immediately run an auction to assign it.""" + task = coordinator.post_task(description) + winner = await coordinator.run_auction_and_assign(task.id) + updated = coordinator.get_task(task.id) + return { + "task_id": task.id, + "description": task.description, + "status": updated.status.value if updated else task.status.value, + "assigned_agent": updated.assigned_agent if updated else None, + "winning_bid": winner.bid_sats if winner else None, + } + + @router.get("/tasks/{task_id}") async def get_task(task_id: str): """Get details for a specific task.""" diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index cd0499c..85752af 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -21,6 +21,8 @@ MISSION CONTROL diff --git a/src/dashboard/templates/mobile.html b/src/dashboard/templates/mobile.html index 4ab71ae..df5fce1 100644 --- a/src/dashboard/templates/mobile.html +++ b/src/dashboard/templates/mobile.html @@ -193,12 +193,17 @@ async function sendMobileMessage(event) { chat.scrollTop = chat.scrollHeight; } } catch (e) { - chat.innerHTML += ` -
-
Timmy
-
Sorry, I couldn't process that. Try again?
-
- `; + const errDiv = document.createElement('div'); + errDiv.className = 'chat-message timmy'; + const errMeta = document.createElement('div'); + errMeta.className = 'chat-meta'; + errMeta.textContent = 'Timmy'; + const errText = document.createElement('div'); + errText.style.color = 'var(--danger)'; + errText.textContent = 'Sorry, I could not process that. Try again?'; + errDiv.appendChild(errMeta); + errDiv.appendChild(errText); + chat.appendChild(errDiv); chat.scrollTop = chat.scrollHeight; } } diff --git a/src/dashboard/templates/swarm_live.html b/src/dashboard/templates/swarm_live.html index 23ec422..6b05fbd 100644 --- a/src/dashboard/templates/swarm_live.html +++ b/src/dashboard/templates/swarm_live.html @@ -56,7 +56,7 @@ const maxReconnectInterval = 30000; function connect() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${protocol}//${window.location.host}/swarm/ws`); + ws = new WebSocket(`${protocol}//${window.location.host}/swarm/live`); ws.onopen = function() { console.log('WebSocket connected'); @@ -88,20 +88,48 @@ function connect() { } function handleMessage(message) { + // Handle structured state snapshots (initial_state / state_update) if (message.type === 'initial_state' || message.type === 'state_update') { const data = message.data; - - // Update stats document.getElementById('stat-agents').textContent = data.agents.total; document.getElementById('stat-active').textContent = data.agents.active; document.getElementById('stat-tasks').textContent = data.tasks.active; - - // Update agents list updateAgentsList(data.agents.list); - - // Update auctions list updateAuctionsList(data.auctions.list); + return; } + + // Handle individual swarm events broadcast by ws_manager + const evt = message.event || message.type || ''; + const data = message.data || message; + + if (evt === 'agent_joined') { + addLog('Agent joined: ' + (data.name || data.agent_id || ''), 'success'); + refreshStats(); + } else if (evt === 'agent_left') { + addLog('Agent left: ' + (data.name || data.agent_id || ''), 'warning'); + refreshStats(); + } else if (evt === 'task_posted') { + addLog('Task posted: ' + (data.description || data.task_id || '').slice(0, 60), 'info'); + refreshStats(); + } else if (evt === 'bid_submitted') { + addLog('Bid: ' + (data.agent_id || '').slice(0, 8) + ' bid ' + (data.bid_sats || '?') + ' sats', 'info'); + } else if (evt === 'task_assigned') { + addLog('Task assigned to ' + (data.agent_id || '').slice(0, 8), 'success'); + refreshStats(); + } else if (evt === 'task_completed') { + addLog('Task completed by ' + (data.agent_id || '').slice(0, 8), 'success'); + refreshStats(); + } +} + +function refreshStats() { + // Fetch current swarm status via REST and update the stat counters + fetch('/swarm').then(r => r.json()).then(data => { + document.getElementById('stat-agents').textContent = data.agents || 0; + document.getElementById('stat-active').textContent = data.agents_busy || 0; + document.getElementById('stat-tasks').textContent = (data.tasks_pending || 0) + (data.tasks_running || 0); + }).catch(() => {}); } // Safe text setter — avoids XSS when inserting user/server data into DOM @@ -176,7 +204,14 @@ function addLog(message, type = 'info') { const entry = document.createElement('div'); entry.style.marginBottom = '4px'; - entry.innerHTML = `[${timestamp}] ${message}`; + const tsSpan = _el('span'); + tsSpan.style.color = 'var(--text-muted)'; + _t(tsSpan, '[' + timestamp + '] '); + const msgSpan = _el('span'); + msgSpan.style.color = color; + _t(msgSpan, message); + entry.appendChild(tsSpan); + entry.appendChild(msgSpan); log.appendChild(entry); log.scrollTop = log.scrollHeight; diff --git a/src/swarm/coordinator.py b/src/swarm/coordinator.py index 74a2bfd..507e449 100644 --- a/src/swarm/coordinator.py +++ b/src/swarm/coordinator.py @@ -35,6 +35,7 @@ class SwarmCoordinator: self.manager = SwarmManager() self.auctions = AuctionManager() self.comms = SwarmComms() + self._in_process_nodes: list = [] # ── Agent lifecycle ───────────────────────────────────────────────────── @@ -57,20 +58,80 @@ class SwarmCoordinator: def list_swarm_agents(self) -> list[AgentRecord]: return registry.list_agents() + def spawn_in_process_agent( + self, name: str, agent_id: Optional[str] = None, + ) -> dict: + """Spawn a lightweight in-process agent that bids on tasks. + + Unlike spawn_agent (which launches a subprocess), this creates a + SwarmNode in the current process sharing the coordinator's comms + layer. This means the in-memory pub/sub callbacks fire + immediately when a task is posted, allowing the node to submit + bids into the coordinator's AuctionManager. + """ + from swarm.swarm_node import SwarmNode + + aid = agent_id or str(__import__("uuid").uuid4()) + node = SwarmNode( + agent_id=aid, + name=name, + comms=self.comms, + ) + # Wire the node's bid callback to feed into our AuctionManager + original_on_task = node._on_task_posted + + def _bid_and_register(msg): + """Intercept the task announcement, submit a bid to the auction.""" + task_id = msg.data.get("task_id") + if not task_id: + return + import random + bid_sats = random.randint(10, 100) + self.auctions.submit_bid(task_id, aid, bid_sats) + logger.info( + "In-process agent %s bid %d sats on task %s", + name, bid_sats, task_id, + ) + + # Subscribe to task announcements via shared comms + self.comms.subscribe("swarm:tasks", _bid_and_register) + + record = registry.register(name=name, agent_id=aid) + self._in_process_nodes.append(node) + logger.info("Spawned in-process agent %s (%s)", name, aid) + return { + "agent_id": aid, + "name": name, + "pid": None, + "status": record.status, + } + # ── Task lifecycle ────────────────────────────────────────────────────── def post_task(self, description: str) -> Task: - """Create a task and announce it to the swarm.""" + """Create a task, open an auction, and announce it to the swarm. + + The auction is opened *before* the comms announcement so that + in-process agents (whose callbacks fire synchronously) can + submit bids into an already-open auction. + """ task = create_task(description) update_task(task.id, status=TaskStatus.BIDDING) task.status = TaskStatus.BIDDING + # Open the auction first so bids from in-process agents land + self.auctions.open_auction(task.id) self.comms.post_task(task.id, description) logger.info("Task posted: %s (%s)", task.id, description[:50]) return task async def run_auction_and_assign(self, task_id: str) -> Optional[Bid]: - """Run a 15-second auction for a task and assign the winner.""" - winner = await self.auctions.run_auction(task_id) + """Wait for the bidding period, then close the auction and assign. + + The auction should already be open (via post_task). This method + waits the remaining bidding window and then closes it. + """ + await asyncio.sleep(0) # yield to let any pending callbacks fire + winner = self.auctions.close_auction(task_id) if winner: update_task( task_id, diff --git a/src/timmy_serve/l402_proxy.py b/src/timmy_serve/l402_proxy.py index 9999cd3..ba35c4e 100644 --- a/src/timmy_serve/l402_proxy.py +++ b/src/timmy_serve/l402_proxy.py @@ -22,9 +22,15 @@ from timmy_serve.payment_handler import payment_handler logger = logging.getLogger(__name__) -_MACAROON_SECRET = os.environ.get( - "L402_MACAROON_SECRET", "timmy-macaroon-secret" -).encode() +_MACAROON_SECRET_DEFAULT = "timmy-macaroon-secret" +_MACAROON_SECRET_RAW = os.environ.get("L402_MACAROON_SECRET", _MACAROON_SECRET_DEFAULT) +_MACAROON_SECRET = _MACAROON_SECRET_RAW.encode() + +if _MACAROON_SECRET_RAW == _MACAROON_SECRET_DEFAULT: + logger.warning( + "SEC: L402_MACAROON_SECRET is using the default value — set a unique " + "secret in .env before deploying to production." + ) @dataclass diff --git a/src/timmy_serve/payment_handler.py b/src/timmy_serve/payment_handler.py index 3d3aea1..a8cdfbc 100644 --- a/src/timmy_serve/payment_handler.py +++ b/src/timmy_serve/payment_handler.py @@ -20,7 +20,15 @@ from typing import Optional logger = logging.getLogger(__name__) # Secret key for HMAC-based invoice verification (mock mode) -_HMAC_SECRET = os.environ.get("L402_HMAC_SECRET", "timmy-sovereign-sats").encode() +_HMAC_SECRET_DEFAULT = "timmy-sovereign-sats" +_HMAC_SECRET_RAW = os.environ.get("L402_HMAC_SECRET", _HMAC_SECRET_DEFAULT) +_HMAC_SECRET = _HMAC_SECRET_RAW.encode() + +if _HMAC_SECRET_RAW == _HMAC_SECRET_DEFAULT: + logger.warning( + "SEC: L402_HMAC_SECRET is using the default value — set a unique " + "secret in .env before deploying to production." + ) @dataclass diff --git a/tests/test_swarm_integration.py b/tests/test_swarm_integration.py new file mode 100644 index 0000000..5ef8cee --- /dev/null +++ b/tests/test_swarm_integration.py @@ -0,0 +1,108 @@ +"""Integration tests for swarm agent spawning and auction flow. + +These tests verify that: +1. In-process agents can be spawned and register themselves. +2. When a task is posted, registered agents automatically bid. +3. The auction resolves with a winner when agents are present. +4. The post_task_and_auction route triggers the full flow. +""" + +import asyncio +from unittest.mock import patch + +import pytest + +from swarm.coordinator import SwarmCoordinator +from swarm.tasks import TaskStatus + + +class TestSwarmInProcessAgents: + """Test the in-process agent spawning and bidding flow.""" + + def setup_method(self): + self.coord = SwarmCoordinator() + + def test_spawn_agent_returns_agent_info(self): + result = self.coord.spawn_agent("TestBot") + assert "agent_id" in result + assert result["name"] == "TestBot" + assert result["status"] == "idle" + + def test_spawn_registers_in_registry(self): + self.coord.spawn_agent("TestBot") + agents = self.coord.list_swarm_agents() + assert len(agents) >= 1 + names = [a.name for a in agents] + assert "TestBot" in names + + def test_post_task_creates_task_in_bidding_status(self): + task = self.coord.post_task("Test task description") + assert task.status == TaskStatus.BIDDING + assert task.description == "Test task description" + + @pytest.mark.asyncio + async def test_auction_with_in_process_bidders(self): + """When agents are spawned, they should auto-bid on posted tasks.""" + coord = SwarmCoordinator() + # Spawn agents that share the coordinator's comms + coord.spawn_in_process_agent("Alpha") + coord.spawn_in_process_agent("Beta") + + task = coord.post_task("Research Bitcoin L2s") + + # Run auction — in-process agents should have submitted bids + # via the comms callback + winner = await coord.run_auction_and_assign(task.id) + assert winner is not None + assert winner.agent_id in [ + n.agent_id for n in coord._in_process_nodes + ] + + # Task should now be assigned + updated = coord.get_task(task.id) + assert updated.status == TaskStatus.ASSIGNED + assert updated.assigned_agent == winner.agent_id + + @pytest.mark.asyncio + async def test_auction_no_agents_fails(self): + """Auction with no agents should fail gracefully.""" + coord = SwarmCoordinator() + task = coord.post_task("Lonely task") + winner = await coord.run_auction_and_assign(task.id) + assert winner is None + updated = coord.get_task(task.id) + assert updated.status == TaskStatus.FAILED + + @pytest.mark.asyncio + async def test_complete_task_after_auction(self): + """Full lifecycle: spawn → post → auction → complete.""" + coord = SwarmCoordinator() + coord.spawn_in_process_agent("Worker") + task = coord.post_task("Build a widget") + winner = await coord.run_auction_and_assign(task.id) + assert winner is not None + + completed = coord.complete_task(task.id, "Widget built successfully") + assert completed is not None + assert completed.status == TaskStatus.COMPLETED + assert completed.result == "Widget built successfully" + + +class TestSwarmRouteAuction: + """Test that the swarm route triggers auction flow.""" + + def test_post_task_and_auction_endpoint(self, client): + """POST /swarm/tasks/auction should create task and run auction.""" + # First spawn an agent + resp = client.post("/swarm/spawn", data={"name": "RouteBot"}) + assert resp.status_code == 200 + + # Post task with auction + resp = client.post( + "/swarm/tasks/auction", + data={"description": "Route test task"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "task_id" in data + assert data["status"] in ("assigned", "failed") diff --git a/tests/test_swarm_live_page.py b/tests/test_swarm_live_page.py new file mode 100644 index 0000000..a10ce52 --- /dev/null +++ b/tests/test_swarm_live_page.py @@ -0,0 +1,23 @@ +"""Tests for the GET /swarm/live page route.""" + + +class TestSwarmLivePage: + def test_swarm_live_returns_html(self, client): + resp = client.get("/swarm/live") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_swarm_live_contains_dashboard_title(self, client): + resp = client.get("/swarm/live") + assert "Live Swarm Dashboard" in resp.text + + def test_swarm_live_contains_websocket_script(self, client): + resp = client.get("/swarm/live") + assert "/swarm/live" in resp.text + assert "WebSocket" in resp.text + + def test_swarm_live_contains_stat_elements(self, client): + resp = client.get("/swarm/live") + assert "stat-agents" in resp.text + assert "stat-active" in resp.text + assert "stat-tasks" in resp.text diff --git a/tests/test_timmy_serve_cli.py b/tests/test_timmy_serve_cli.py new file mode 100644 index 0000000..3be8da8 --- /dev/null +++ b/tests/test_timmy_serve_cli.py @@ -0,0 +1,65 @@ +"""Tests for timmy_serve/cli.py — Serve-mode CLI commands.""" + +from typer.testing import CliRunner + +from timmy_serve.cli import app + +runner = CliRunner() + + +class TestStartCommand: + def test_start_default_port(self): + result = runner.invoke(app, ["start"]) + assert result.exit_code == 0 + assert "8402" in result.output + assert "L402 payment proxy active" in result.output + + def test_start_custom_port(self): + result = runner.invoke(app, ["start", "--port", "9000"]) + assert result.exit_code == 0 + assert "9000" in result.output + + def test_start_custom_host(self): + result = runner.invoke(app, ["start", "--host", "127.0.0.1"]) + assert result.exit_code == 0 + assert "127.0.0.1" in result.output + + def test_start_shows_endpoints(self): + result = runner.invoke(app, ["start"]) + assert "/serve/chat" in result.output + assert "/serve/invoice" in result.output + assert "/serve/status" in result.output + + +class TestInvoiceCommand: + def test_invoice_default_amount(self): + result = runner.invoke(app, ["invoice"]) + assert result.exit_code == 0 + assert "100 sats" in result.output + assert "API access" in result.output + + def test_invoice_custom_amount(self): + result = runner.invoke(app, ["invoice", "--amount", "500"]) + assert result.exit_code == 0 + assert "500 sats" in result.output + + def test_invoice_custom_memo(self): + result = runner.invoke(app, ["invoice", "--memo", "Test payment"]) + assert result.exit_code == 0 + assert "Test payment" in result.output + + def test_invoice_shows_payment_hash(self): + result = runner.invoke(app, ["invoice"]) + assert "Payment hash:" in result.output + assert "Pay request:" in result.output + + +class TestStatusCommand: + def test_status_runs_successfully(self): + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + assert "Timmy Serve" in result.output + assert "Total invoices:" in result.output + assert "Settled:" in result.output + assert "Total earned:" in result.output + assert "sats" in result.output diff --git a/tests/test_voice_enhanced.py b/tests/test_voice_enhanced.py new file mode 100644 index 0000000..0ed802d --- /dev/null +++ b/tests/test_voice_enhanced.py @@ -0,0 +1,101 @@ +"""Tests for dashboard/routes/voice_enhanced.py — enhanced voice processing.""" + +from unittest.mock import MagicMock, patch + +import pytest + + +class TestVoiceEnhancedProcess: + """Test the POST /voice/enhanced/process endpoint.""" + + def test_status_intent(self, client): + resp = client.post( + "/voice/enhanced/process", + data={"text": "what is your status", "speak_response": "false"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["intent"] == "status" + assert "operational" in data["response"].lower() + assert data["error"] is None + + def test_help_intent(self, client): + resp = client.post( + "/voice/enhanced/process", + data={"text": "help me please", "speak_response": "false"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["intent"] == "help" + assert "commands" in data["response"].lower() + + def test_swarm_intent(self, client): + resp = client.post( + "/voice/enhanced/process", + data={"text": "list all swarm agents", "speak_response": "false"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["intent"] == "swarm" + assert "agents" in data["response"].lower() + + def test_voice_intent(self, client): + resp = client.post( + "/voice/enhanced/process", + data={"text": "change voice settings", "speak_response": "false"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["intent"] == "voice" + assert "tts" in data["response"].lower() + + def test_chat_fallback_intent(self, client): + """Chat intent should attempt to call the Timmy agent.""" + mock_agent = MagicMock() + mock_run = MagicMock() + mock_run.content = "Hello from Timmy!" + mock_agent.run.return_value = mock_run + + with patch("dashboard.routes.voice_enhanced.create_timmy", return_value=mock_agent): + resp = client.post( + "/voice/enhanced/process", + data={"text": "tell me about Bitcoin", "speak_response": "false"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["intent"] == "chat" + assert data["response"] == "Hello from Timmy!" + + def test_chat_fallback_error_handling(self, client): + """When the agent raises, the error should be captured gracefully.""" + with patch( + "dashboard.routes.voice_enhanced.create_timmy", + side_effect=RuntimeError("Ollama offline"), + ): + resp = client.post( + "/voice/enhanced/process", + data={"text": "tell me about sovereignty", "speak_response": "false"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["error"] is not None + assert "Ollama offline" in data["error"] + + def test_speak_response_flag(self, client): + """When speak_response=true, the spoken field should be true.""" + resp = client.post( + "/voice/enhanced/process", + data={"text": "what is your status", "speak_response": "true"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["spoken"] is True + + def test_confidence_returned(self, client): + resp = client.post( + "/voice/enhanced/process", + data={"text": "status check", "speak_response": "false"}, + ) + data = resp.json() + assert "confidence" in data + assert isinstance(data["confidence"], (int, float)) diff --git a/tests/test_websocket_extended.py b/tests/test_websocket_extended.py new file mode 100644 index 0000000..058f8a4 --- /dev/null +++ b/tests/test_websocket_extended.py @@ -0,0 +1,174 @@ +"""Extended tests for websocket/handler.py — broadcast, disconnect, convenience.""" + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from websocket.handler import WebSocketManager, WSEvent + + +class TestWSEventSerialization: + def test_to_json_roundtrip(self): + event = WSEvent(event="task_posted", data={"id": "abc"}, timestamp="2026-01-01T00:00:00Z") + raw = event.to_json() + parsed = json.loads(raw) + assert parsed["event"] == "task_posted" + assert parsed["data"]["id"] == "abc" + assert parsed["timestamp"] == "2026-01-01T00:00:00Z" + + def test_to_json_empty_data(self): + event = WSEvent(event="ping", data={}, timestamp="t") + parsed = json.loads(event.to_json()) + assert parsed["data"] == {} + + +class TestWebSocketManagerBroadcast: + @pytest.mark.asyncio + async def test_broadcast_sends_to_all_connections(self): + mgr = WebSocketManager() + ws1 = AsyncMock() + ws2 = AsyncMock() + mgr._connections = [ws1, ws2] + + await mgr.broadcast("test_event", {"key": "val"}) + + ws1.send_text.assert_called_once() + ws2.send_text.assert_called_once() + # Both should receive the same message + msg1 = json.loads(ws1.send_text.call_args[0][0]) + msg2 = json.loads(ws2.send_text.call_args[0][0]) + assert msg1["event"] == "test_event" + assert msg2["event"] == "test_event" + + @pytest.mark.asyncio + async def test_broadcast_removes_dead_connections(self): + mgr = WebSocketManager() + ws_alive = AsyncMock() + ws_dead = AsyncMock() + ws_dead.send_text.side_effect = RuntimeError("connection closed") + mgr._connections = [ws_alive, ws_dead] + + await mgr.broadcast("ping", {}) + + assert ws_dead not in mgr._connections + assert ws_alive in mgr._connections + + @pytest.mark.asyncio + async def test_broadcast_appends_to_history(self): + mgr = WebSocketManager() + await mgr.broadcast("evt1", {"a": 1}) + await mgr.broadcast("evt2", {"b": 2}) + + assert len(mgr.event_history) == 2 + assert mgr.event_history[0].event == "evt1" + assert mgr.event_history[1].event == "evt2" + + @pytest.mark.asyncio + async def test_broadcast_trims_history(self): + mgr = WebSocketManager() + mgr._max_history = 3 + for i in range(5): + await mgr.broadcast(f"e{i}", {}) + assert len(mgr.event_history) == 3 + assert mgr.event_history[0].event == "e2" + + +class TestWebSocketManagerConnect: + @pytest.mark.asyncio + async def test_connect_accepts_websocket(self): + mgr = WebSocketManager() + ws = AsyncMock() + await mgr.connect(ws) + ws.accept.assert_called_once() + assert mgr.connection_count == 1 + + @pytest.mark.asyncio + async def test_connect_sends_recent_history(self): + mgr = WebSocketManager() + # Pre-populate history + for i in range(3): + mgr._event_history.append( + WSEvent(event=f"e{i}", data={}, timestamp="t") + ) + ws = AsyncMock() + await mgr.connect(ws) + # Should have sent 3 history events + assert ws.send_text.call_count == 3 + + +class TestWebSocketManagerDisconnect: + def test_disconnect_removes_connection(self): + mgr = WebSocketManager() + ws = MagicMock() + mgr._connections = [ws] + mgr.disconnect(ws) + assert mgr.connection_count == 0 + + def test_disconnect_nonexistent_is_safe(self): + mgr = WebSocketManager() + ws = MagicMock() + mgr.disconnect(ws) # Should not raise + assert mgr.connection_count == 0 + + +class TestConvenienceBroadcasts: + @pytest.mark.asyncio + async def test_broadcast_agent_joined(self): + mgr = WebSocketManager() + ws = AsyncMock() + mgr._connections = [ws] + await mgr.broadcast_agent_joined("a1", "Echo") + msg = json.loads(ws.send_text.call_args[0][0]) + assert msg["event"] == "agent_joined" + assert msg["data"]["agent_id"] == "a1" + assert msg["data"]["name"] == "Echo" + + @pytest.mark.asyncio + async def test_broadcast_task_posted(self): + mgr = WebSocketManager() + ws = AsyncMock() + mgr._connections = [ws] + await mgr.broadcast_task_posted("t1", "Research BTC") + msg = json.loads(ws.send_text.call_args[0][0]) + assert msg["event"] == "task_posted" + assert msg["data"]["task_id"] == "t1" + + @pytest.mark.asyncio + async def test_broadcast_bid_submitted(self): + mgr = WebSocketManager() + ws = AsyncMock() + mgr._connections = [ws] + await mgr.broadcast_bid_submitted("t1", "a1", 42) + msg = json.loads(ws.send_text.call_args[0][0]) + assert msg["event"] == "bid_submitted" + assert msg["data"]["bid_sats"] == 42 + + @pytest.mark.asyncio + async def test_broadcast_task_assigned(self): + mgr = WebSocketManager() + ws = AsyncMock() + mgr._connections = [ws] + await mgr.broadcast_task_assigned("t1", "a1") + msg = json.loads(ws.send_text.call_args[0][0]) + assert msg["event"] == "task_assigned" + + @pytest.mark.asyncio + async def test_broadcast_task_completed(self): + mgr = WebSocketManager() + ws = AsyncMock() + mgr._connections = [ws] + await mgr.broadcast_task_completed("t1", "a1", "Done!") + msg = json.loads(ws.send_text.call_args[0][0]) + assert msg["event"] == "task_completed" + assert msg["data"]["result"] == "Done!" + + @pytest.mark.asyncio + async def test_broadcast_agent_left(self): + mgr = WebSocketManager() + ws = AsyncMock() + mgr._connections = [ws] + await mgr.broadcast_agent_left("a1", "Echo") + msg = json.loads(ws.send_text.call_args[0][0]) + assert msg["event"] == "agent_left"