feat(host-agent): wrap tool_handlers with busy check + status mapping
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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
|
||||
Reference in New Issue
Block a user