from __future__ import annotations from datetime import UTC, datetime from typing import Any import pytest from cloud.internal_api.models import AssignmentModel from device.manager import DeviceManager from driver.base import Driver from host_agent.mcp_lock import McpBusyTracker from host_agent.status import AgentStatusTracker from host_agent.web.mcp import ( McpDeviceBusyError, _call_tool_sync, build_mcp_server, ) class _FakeDriver(Driver): """Minimal driver. connect/screenshot/tap are exercised; remaining abstract methods are stubbed to satisfy Driver's ABC contract.""" def __init__(self) -> None: self.taps: list[tuple[float, float]] = [] def connect(self) -> None: return None def disconnect(self) -> None: return None def screenshot(self) -> bytes: return b"fake" def tap(self, x: float, y: float) -> None: self.taps.append((x, y)) def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None: return None def swipe( self, start_x: float, start_y: float, end_x: float, end_y: float, duration_ms: int = 500, ) -> None: return None def swipe_path(self, waypoints: list[tuple[float, float]], duration_ms: int) -> None: return None def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None: return None def input(self, text: str) -> None: return None def launch(self, app_id: str) -> None: return None def terminate(self, app_id: str) -> None: return None def tree(self) -> Any: return None def home(self) -> None: return None def lock(self) -> None: return None def unlock(self) -> None: return None def _make_manager_with_device(device_id: str = "phone-1") -> DeviceManager: manager = DeviceManager() manager.register_device( device_id=device_id, driver_factory=lambda: _FakeDriver(), name=device_id, ) manager.connect(device_id) return manager def test_build_mcp_server_returns_fastmcp_instance() -> None: from mcp.server.fastmcp import FastMCP manager = _make_manager_with_device() tracker = McpBusyTracker() status = AgentStatusTracker() server = build_mcp_server( manager=manager, mcp_busy_tracker=tracker, status_tracker=status ) assert isinstance(server, FastMCP) def test_call_tool_succeeds_when_device_is_free() -> None: manager = _make_manager_with_device() tracker = McpBusyTracker() status = AgentStatusTracker() server = build_mcp_server( manager=manager, mcp_busy_tracker=tracker, status_tracker=status ) result = _call_tool_sync( server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a" ) assert result["ok"] is True assert "phone-1" in tracker.busy_device_ids() def test_call_tool_fails_when_cloud_uses_device() -> None: """AgentStatusTracker.current_assignment.device_id matches -> busy.""" manager = _make_manager_with_device() tracker = McpBusyTracker() status = AgentStatusTracker() status.mark_assignment_started( AssignmentModel( task_id="t1", attempt=1, lease_id="l1", lease_expires_at=datetime.now(UTC), host_id="h1", device_id="phone-1", goal="cloud task", ) ) server = build_mcp_server( manager=manager, mcp_busy_tracker=tracker, status_tracker=status ) with pytest.raises(McpDeviceBusyError) as exc: _call_tool_sync( server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a", ) assert exc.value.device_id == "phone-1" assert exc.value.busy_owner == "cloud_assignment" def test_call_tool_fails_when_another_mcp_session_holds_device() -> None: manager = _make_manager_with_device() tracker = McpBusyTracker() status = AgentStatusTracker() # Pre-acquire as a different session. tracker.acquire("phone-1", "sess-other") server = build_mcp_server( manager=manager, mcp_busy_tracker=tracker, status_tracker=status ) with pytest.raises(McpDeviceBusyError) as exc: _call_tool_sync( server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a", ) assert exc.value.busy_owner.startswith("mcp_session:") def test_call_tool_renews_when_same_session_already_holds() -> None: manager = _make_manager_with_device() tracker = McpBusyTracker() status = AgentStatusTracker() server = build_mcp_server( manager=manager, mcp_busy_tracker=tracker, status_tracker=status ) _call_tool_sync( server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a" ) # Second call from the same session should succeed. result = _call_tool_sync( server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a" ) assert result["ok"] is True def test_list_devices_uses_display_status() -> None: """Connected-but-idle devices report as 'connected', not 'busy'.""" manager = _make_manager_with_device() tracker = McpBusyTracker() status = AgentStatusTracker() server = build_mcp_server( manager=manager, mcp_busy_tracker=tracker, status_tracker=status ) result = _call_tool_sync(server, "list_devices", {}, session_id="sess-a") assert isinstance(result, list) assert result[0]["status"] == "connected" def test_unknown_device_returns_semantic_error_dict() -> None: """take_screenshot against an unknown device returns the api-errors semantic error dict (``ok=False, error="device not found"``) rather than raising. Note: this test adapts the brief's exception-assertion semantics to the actual behavior of ``call_with_semantic_errors`` in ``api/errors.py`` — the brief's expectation that an exception is raised here is incorrect for the current handler implementation.""" manager = _make_manager_with_device() tracker = McpBusyTracker() status = AgentStatusTracker() server = build_mcp_server( manager=manager, mcp_busy_tracker=tracker, status_tracker=status ) result = _call_tool_sync( server, "take_screenshot", {"device_id": "does-not-exist"}, session_id="sess-a", ) assert isinstance(result, dict) assert result["ok"] is False assert "device" in result["error"].lower() def test_manager_required_for_build_mcp_server() -> None: """Reinforces D12 — build_mcp_server requires manager as keyword-only.""" import inspect sig = inspect.signature(build_mcp_server) assert sig.parameters["manager"].kind == inspect.Parameter.KEYWORD_ONLY