This commit is contained in:
+15
-6
@@ -75,10 +75,13 @@ def tool_handlers(
|
||||
device_id=device_id,
|
||||
manager=device_manager,
|
||||
),
|
||||
"get_ui_tree": lambda device_id=None: call_with_semantic_errors(
|
||||
get_ui_tree,
|
||||
device_id,
|
||||
manager=device_manager,
|
||||
"get_ui_tree": lambda device_id=None, include_app_info=False: (
|
||||
call_with_semantic_errors(
|
||||
get_ui_tree,
|
||||
device_id,
|
||||
manager=device_manager,
|
||||
include_app_info=include_app_info,
|
||||
)
|
||||
),
|
||||
"describe_screen": lambda device_id=None: call_with_semantic_errors(
|
||||
lambda: describe_screen(device_id, manager=device_manager).to_dict()
|
||||
@@ -150,8 +153,14 @@ def create_mcp_server(
|
||||
return handlers["find_icon"](name=name, device_id=device_id)
|
||||
|
||||
@server.tool(name="get_ui_tree")
|
||||
def _get_ui_tree(device_id: str | None = None) -> Any:
|
||||
return handlers["get_ui_tree"](device_id=device_id)
|
||||
def _get_ui_tree(
|
||||
device_id: str | None = None,
|
||||
include_app_info: bool = False,
|
||||
) -> Any:
|
||||
return handlers["get_ui_tree"](
|
||||
device_id=device_id,
|
||||
include_app_info=include_app_info,
|
||||
)
|
||||
|
||||
@server.tool(name="describe_screen")
|
||||
def _describe_screen(device_id: str | None = None) -> dict[str, Any]:
|
||||
|
||||
+43
-1
@@ -75,6 +75,37 @@ _STATE_FIELDS = ("enabled", "clickable", "selected", "checked", "focused")
|
||||
_COLOR_FIELDS = ("foreground_color", "background_color")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveApp:
|
||||
"""Native identifier for the application currently in the foreground."""
|
||||
|
||||
platform: str
|
||||
bundle_id: str | None = None
|
||||
package: str | None = None
|
||||
activity: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
data = {"platform": self.platform}
|
||||
for field_name in ("bundle_id", "package", "activity"):
|
||||
value = getattr(self, field_name)
|
||||
if value is not None:
|
||||
data[field_name] = value
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ActiveApp":
|
||||
def text(key: str) -> str | None:
|
||||
value = data.get(key)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
return cls(
|
||||
platform=text("platform") or "unknown",
|
||||
bundle_id=text("bundle_id"),
|
||||
package=text("package"),
|
||||
activity=text("activity"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SceneElement:
|
||||
id: str
|
||||
@@ -143,12 +174,19 @@ class Scene:
|
||||
# Keep raw OCR observations for local execution evidence without duplicating
|
||||
# them in the normalized, LLM-facing scene payload.
|
||||
ocr_elements: list[SceneElement] = field(default_factory=list)
|
||||
# The foreground app is supplied by the Driver, separately from the
|
||||
# accessibility tree, and is absent for drivers that cannot query it.
|
||||
# Kept last to preserve Scene's existing positional constructor arguments.
|
||||
active_app: ActiveApp | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
data: dict[str, Any] = {
|
||||
"screen": {"width": self.width, "height": self.height},
|
||||
"elements": [element.to_dict() for element in self.elements],
|
||||
}
|
||||
if self.active_app is not None:
|
||||
data["app"] = self.active_app.to_dict()
|
||||
return data
|
||||
|
||||
def ocr_results_to_dict(self) -> list[dict[str, Any]]:
|
||||
return [element.to_dict() for element in self.ocr_elements]
|
||||
@@ -156,12 +194,16 @@ class Scene:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Scene":
|
||||
screen = data.get("screen") or {}
|
||||
raw_app = data.get("app")
|
||||
return cls(
|
||||
width=int(screen.get("width") or data.get("width") or 0),
|
||||
height=int(screen.get("height") or data.get("height") or 0),
|
||||
elements=[
|
||||
SceneElement.from_dict(element) for element in data.get("elements", [])
|
||||
],
|
||||
active_app=ActiveApp.from_dict(raw_app)
|
||||
if isinstance(raw_app, dict)
|
||||
else None,
|
||||
ocr_elements=[
|
||||
SceneElement.from_dict(element)
|
||||
for element in data.get("ocr_elements", [])
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from core.errors import DeviceOfflineError, DriverError
|
||||
from core.models import ActiveApp
|
||||
from driver.base import Driver
|
||||
|
||||
# Android KeyEvent.KEYCODE_HOME. Kept as a literal rather than importing the
|
||||
@@ -170,6 +171,19 @@ class AndroidDriver(Driver):
|
||||
except Exception as exc:
|
||||
raise DriverError("ui tree retrieval failed") from exc
|
||||
|
||||
def active_app(self) -> ActiveApp | None:
|
||||
client = self._require_client()
|
||||
try:
|
||||
package = client.current_package
|
||||
activity = client.current_activity
|
||||
except Exception as exc:
|
||||
raise DriverError("active app retrieval failed") from exc
|
||||
package = package if isinstance(package, str) and package else None
|
||||
activity = activity if isinstance(activity, str) and activity else None
|
||||
if package is None and activity is None:
|
||||
return None
|
||||
return ActiveApp(platform="android", package=package, activity=activity)
|
||||
|
||||
def home(self) -> None:
|
||||
client = self._require_client()
|
||||
try:
|
||||
|
||||
+6
-1
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from core.models import ActiveApp
|
||||
|
||||
|
||||
class Driver(ABC):
|
||||
"""Driver-independent device capability interface.
|
||||
@@ -71,6 +73,10 @@ class Driver(ABC):
|
||||
def tree(self) -> Any:
|
||||
"""Return the raw UI tree from the device driver."""
|
||||
|
||||
def active_app(self) -> ActiveApp | None:
|
||||
"""Return foreground application metadata when the driver supports it."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def home(self) -> None:
|
||||
"""Press the device home button."""
|
||||
@@ -82,4 +88,3 @@ class Driver(ABC):
|
||||
@abstractmethod
|
||||
def unlock(self) -> None:
|
||||
"""Unlock the device."""
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from core.errors import DeviceOfflineError, DriverError
|
||||
from core.models import ActiveApp
|
||||
from driver.base import Driver
|
||||
|
||||
|
||||
@@ -161,6 +162,19 @@ class WDADriver(Driver):
|
||||
except Exception as exc:
|
||||
raise DriverError("ui tree retrieval failed") from exc
|
||||
|
||||
def active_app(self) -> ActiveApp | None:
|
||||
client = self._require_client()
|
||||
try:
|
||||
response = client.execute_script("mobile: activeAppInfo")
|
||||
except Exception as exc:
|
||||
raise DriverError("active app retrieval failed") from exc
|
||||
if not isinstance(response, dict):
|
||||
return None
|
||||
bundle_id = response.get("bundleId")
|
||||
if not isinstance(bundle_id, str) or not bundle_id:
|
||||
return None
|
||||
return ActiveApp(platform="ios", bundle_id=bundle_id)
|
||||
|
||||
def home(self) -> None:
|
||||
client = self._require_client()
|
||||
try:
|
||||
|
||||
@@ -10,6 +10,11 @@ list of UI elements with id, type, text, and pixel bounds), and — when
|
||||
available — a screenshot of the same screen and a short history of recent
|
||||
actions and their outcomes.
|
||||
|
||||
The Scene may include an `app` object with the live foreground application's
|
||||
native identifier. On iOS, use `bundle_id`; on Android, use `package` and
|
||||
`activity`. This metadata may be omitted when the current driver cannot query
|
||||
it.
|
||||
|
||||
Some elements also carry accessibility state fields when the platform
|
||||
reports them: `enabled`, `clickable`, `selected`, `checked`, `focused`.
|
||||
A field is omitted entirely when the platform does not report it for that
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.models import ActiveApp
|
||||
from driver.base import Driver
|
||||
|
||||
PNG_10X20 = (
|
||||
@@ -27,12 +28,14 @@ class FakeDriver(Driver):
|
||||
fail_connect: bool = False,
|
||||
tree: Any = TREE_XML,
|
||||
screenshot: bytes = PNG_10X20,
|
||||
active_app: ActiveApp | None = None,
|
||||
) -> None:
|
||||
self.fail_connect = fail_connect
|
||||
self.connected = False
|
||||
self.calls: list[tuple[str, tuple[Any, ...]]] = []
|
||||
self._tree = tree
|
||||
self._screenshot = screenshot
|
||||
self._active_app = active_app
|
||||
|
||||
def connect(self) -> None:
|
||||
self.calls.append(("connect", ()))
|
||||
@@ -85,6 +88,10 @@ class FakeDriver(Driver):
|
||||
self.calls.append(("tree", ()))
|
||||
return self._tree
|
||||
|
||||
def active_app(self) -> ActiveApp | None:
|
||||
self.calls.append(("active_app", ()))
|
||||
return self._active_app
|
||||
|
||||
def home(self) -> None:
|
||||
self.calls.append(("home", ()))
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.models import ActiveApp
|
||||
from driver.android_driver import AndroidDriver
|
||||
from driver.wda_driver import WDADriver
|
||||
from tools.ui_tree import get_ui_tree
|
||||
from device.manager import DeviceManager
|
||||
from tests.fakes import FakeDriver
|
||||
|
||||
|
||||
def test_android_driver_reads_current_package_and_activity() -> None:
|
||||
driver = AndroidDriver()
|
||||
driver._client = MagicMock()
|
||||
driver._client.current_package = "com.example.mobile"
|
||||
driver._client.current_activity = ".MainActivity"
|
||||
|
||||
assert driver.active_app() == ActiveApp(
|
||||
platform="android",
|
||||
package="com.example.mobile",
|
||||
activity=".MainActivity",
|
||||
)
|
||||
|
||||
|
||||
def test_wda_driver_reads_current_bundle_id() -> None:
|
||||
driver = WDADriver()
|
||||
driver._client = MagicMock()
|
||||
driver._client.execute_script.return_value = {
|
||||
"bundleId": "com.example.mobile",
|
||||
"pid": 123,
|
||||
}
|
||||
|
||||
assert driver.active_app() == ActiveApp(
|
||||
platform="ios",
|
||||
bundle_id="com.example.mobile",
|
||||
)
|
||||
driver._client.execute_script.assert_called_once_with("mobile: activeAppInfo")
|
||||
|
||||
|
||||
def test_ui_tree_keeps_list_response_without_active_app_option() -> None:
|
||||
manager = DeviceManager()
|
||||
manager.register_device(
|
||||
"phone-1",
|
||||
lambda: FakeDriver(
|
||||
active_app=ActiveApp(platform="ios", bundle_id="com.example.mobile")
|
||||
),
|
||||
)
|
||||
manager.connect("phone-1")
|
||||
|
||||
response = get_ui_tree("phone-1", manager=manager)
|
||||
|
||||
assert isinstance(response, list)
|
||||
assert any(node["text"] == "Search" for node in response)
|
||||
@@ -3,11 +3,12 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from core.errors import DriverError
|
||||
from core.models import Bounds
|
||||
from core.models import ActiveApp, Bounds, Scene
|
||||
from device.manager import DeviceManager
|
||||
from driver.base import Driver
|
||||
from perception.ocr import OCRBox
|
||||
from tools.describe_screen import describe_screen
|
||||
from tests.fakes import FakeDriver
|
||||
|
||||
PNG_10X20 = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x0a\x00\x00\x00\x14"
|
||||
|
||||
@@ -91,3 +92,28 @@ def test_describe_screen_degrades_to_ocr_only_when_ui_tree_unavailable() -> None
|
||||
|
||||
assert [element.text for element in scene.elements] == ["Search"]
|
||||
assert all(element.source == "ocr" for element in scene.elements)
|
||||
|
||||
|
||||
def test_describe_screen_includes_live_active_app_metadata() -> None:
|
||||
active_app = ActiveApp(
|
||||
platform="android",
|
||||
package="com.example.mobile",
|
||||
activity=".MainActivity",
|
||||
)
|
||||
manager = DeviceManager()
|
||||
manager.register_device("phone-1", lambda: FakeDriver(active_app=active_app))
|
||||
manager.connect("phone-1")
|
||||
|
||||
scene = describe_screen(
|
||||
"phone-1",
|
||||
manager=manager,
|
||||
ocr_engine=FakeOCREngine([]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert scene.active_app == active_app
|
||||
assert scene.to_dict()["app"] == {
|
||||
"platform": "android",
|
||||
"package": "com.example.mobile",
|
||||
"activity": ".MainActivity",
|
||||
}
|
||||
assert Scene.from_dict(scene.to_dict()).active_app == active_app
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from api.mcp import tool_handlers
|
||||
from core.models import ActiveApp
|
||||
from device.manager import DeviceManager
|
||||
from tests.fakes import FakeDriver
|
||||
|
||||
@@ -36,3 +37,30 @@ def test_mcp_handlers_are_semantic_and_hide_driver_terms() -> None:
|
||||
assert "Appium" not in serialized
|
||||
assert "XCUI" not in serialized
|
||||
assert all(response is not None for response in responses)
|
||||
|
||||
|
||||
def test_mcp_ui_tree_can_include_active_app_info() -> None:
|
||||
manager = DeviceManager()
|
||||
manager.register_device(
|
||||
"android-1",
|
||||
lambda: FakeDriver(
|
||||
active_app=ActiveApp(
|
||||
platform="android",
|
||||
package="com.example.mobile",
|
||||
activity=".MainActivity",
|
||||
)
|
||||
),
|
||||
)
|
||||
manager.connect("android-1", max_retries=1)
|
||||
|
||||
response = tool_handlers(manager=manager)["get_ui_tree"](
|
||||
device_id="android-1",
|
||||
include_app_info=True,
|
||||
)
|
||||
|
||||
assert response["app"] == {
|
||||
"platform": "android",
|
||||
"package": "com.example.mobile",
|
||||
"activity": ".MainActivity",
|
||||
}
|
||||
assert isinstance(response["nodes"], list)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from core.models import ActiveApp
|
||||
from device.manager import DEFAULT_MANAGER, DeviceManager
|
||||
from driver.base import Driver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_driver(
|
||||
device_id: str | None = None,
|
||||
@@ -10,3 +15,15 @@ def get_driver(
|
||||
manager: DeviceManager | None = None,
|
||||
) -> Driver:
|
||||
return (manager or DEFAULT_MANAGER).active_driver(device_id)
|
||||
|
||||
|
||||
def get_active_app_info(driver: Driver) -> ActiveApp | None:
|
||||
"""Best-effort foreground application lookup that cannot block observation."""
|
||||
try:
|
||||
return driver.active_app()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"active app retrieval failed; continuing without app metadata",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -6,7 +6,7 @@ from core.models import Scene
|
||||
from device.manager import DeviceManager
|
||||
from perception.ocr import PaddleOCREngine
|
||||
from perception.provider import DefaultPerceptionProvider, PerceptionProvider
|
||||
from tools._device import get_driver
|
||||
from tools._device import get_active_app_info, get_driver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,4 +28,6 @@ def describe_screen(
|
||||
)
|
||||
raw_tree = None
|
||||
provider = perception_provider or DefaultPerceptionProvider(ocr_engine=ocr_engine)
|
||||
return provider.build_scene(screenshot, raw_tree)
|
||||
scene = provider.build_scene(screenshot, raw_tree)
|
||||
scene.active_app = get_active_app_info(driver)
|
||||
return scene
|
||||
|
||||
+12
-6
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
from device.manager import DeviceManager
|
||||
from perception.ui_parser import parse_ui_tree
|
||||
from tools._device import get_driver
|
||||
from tools._device import get_active_app_info, get_driver
|
||||
|
||||
|
||||
def get_raw_ui_tree(
|
||||
@@ -19,8 +19,14 @@ def get_ui_tree(
|
||||
device_id: str | None = None,
|
||||
*,
|
||||
manager: DeviceManager | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
return [
|
||||
element.to_dict()
|
||||
for element in parse_ui_tree(get_raw_ui_tree(device_id, manager=manager))
|
||||
]
|
||||
include_app_info: bool = False,
|
||||
) -> list[dict[str, object]] | dict[str, object]:
|
||||
driver = get_driver(device_id, manager=manager)
|
||||
nodes = [element.to_dict() for element in parse_ui_tree(driver.tree())]
|
||||
if not include_app_info:
|
||||
return nodes
|
||||
active_app = get_active_app_info(driver)
|
||||
return {
|
||||
"app": active_app.to_dict() if active_app is not None else None,
|
||||
"nodes": nodes,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user