feat: settle mobile observations before summarizing steps

This commit is contained in:
chenmingxuan committed 2026-09-29 00:39:05 +08:00
1 parent c818a09a49
commit 4465a2e199
12 files changed
+271 -9

No files matched your search

@@ -70,6 +70,12 @@ def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeyp
assert service.get_model_settings()["base_url"] == "https://saved.example/v1"
def test_model_presets_include_flash_pro_and_luna_without_restricting_custom_names():
presets = service.get_model_settings()["model_presets"]
assert presets == ["gpt-5.6-luna", "gpt-6-luna", "mimo-v2.6-flash", "mimo-v2.6-pro"]
assert service.ModelSettingsUpdate(model="custom/model-v2").model == "custom/model-v2"
@pytest.mark.parametrize(
"url",
[
@@ -60,20 +60,21 @@ REFRESHED = [_el(1, "Back", 100, 100), _el(2, "Wi-Fi toggle", 900, 300)]
class _FakeSession:
"""An action session whose observations replay a scripted list sequence."""
"""An action session whose screen advances after each device action."""
started = True
def __init__(self, observations: list[list[dict]]):
self.calls: list[tuple[str, dict]] = []
self._observations = list(observations)
self._observations = observations
async def call(self, name, args):
self.calls.append((name, dict(args)))
return ActionResult.success(name, f"{name} dispatched")
async def observe(self, settle_ms=0):
elements = self._observations.pop(0) if self._observations else []
index = min(len(self.calls) - 1, len(self._observations) - 1)
elements = self._observations[index] if self._observations else []
return ObserveResult(
ok=True,
elements_text="\n".join(f"[{e['index']}] {e['text']}" for e in elements),
@@ -468,6 +468,8 @@ async def test_dual_frame_dispatch_keeps_transition_prompt(mock_context):
pre_img_bytes=b"pre",
post_img_bytes=b"post",
exec_outcome="executed",
post_ui_elements_text="[1] Text: '存储空间' | Bounds: [0,0][100,100]",
observation_settled=False,
)
await summarizer.flush()
@@ -477,6 +479,10 @@ async def test_dual_frame_dispatch_keeps_transition_prompt(mock_context):
assert "Before/After screenshots" in system_text
assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text
assert "--- [2] AFTER ACTION SCREEN ---" in human_text
assert "--- [3] AFTER ACTION UI ELEMENTS ---" in human_text
assert "Text: '存储空间'" in human_text
assert "screen still changing" in human_text
assert "hierarchy names the new page" in system_text
assert "DECISION FRAME" not in human_text
@@ -0,0 +1,104 @@
"""Page transitions are recaptured until screenshot and hierarchy agree."""
from unittest.mock import AsyncMock, Mock
import pytest
from PIL import Image
from artemis.mcp.action_executor import McpActionExecutor
from artemis.mcp.action_types import ObserveResult
def _screen(tmp_path, name: str, *, reverse: bool = False) -> str:
image = Image.new("L", (64, 64))
image.putdata([255 - x * 4 if reverse else x * 4 for _y in range(64) for x in range(64)])
path = tmp_path / name
image.save(path, format="JPEG")
return str(path)
def _observation(path: str, page: str) -> ObserveResult:
return ObserveResult(
ok=True,
screenshot_path=path,
elements_text=f"[1] {page}",
elements=[{"text": page, "resource_id": "page/title", "bounds": [0, 0, 64, 64]}],
)
@pytest.mark.asyncio
async def test_navigation_recaptures_when_image_lags_behind_hierarchy(tmp_path):
home = _screen(tmp_path, "home.jpg")
detail = _screen(tmp_path, "detail.jpg", reverse=True)
session = Mock()
session.observe = AsyncMock(
side_effect=[
_observation(home, "存储空间"),
_observation(detail, "存储空间"),
_observation(detail, "存储空间"),
]
)
executor = McpActionExecutor(Mock(), actuator=Mock())
observed, settled = await executor._observe_after_action(
session, "click", home, _observation(home, "设置首页").elements
)
assert observed.screenshot_path == detail
assert observed.elements_text == "[1] 存储空间"
assert settled is True
assert session.observe.await_count == 3
@pytest.mark.asyncio
async def test_navigation_waits_until_new_page_has_two_stable_frames(tmp_path):
home = _screen(tmp_path, "home.jpg")
detail = _screen(tmp_path, "detail.jpg", reverse=True)
session = Mock()
session.observe = AsyncMock(
side_effect=[
_observation(home, "设置首页"),
_observation(home, "设置首页"),
_observation(detail, "存储空间"),
_observation(detail, "存储空间"),
]
)
executor = McpActionExecutor(Mock(), actuator=Mock())
observed, settled = await executor._observe_after_action(
session, "click", home, _observation(home, "设置首页").elements
)
assert observed.screenshot_path == detail
assert settled is True
assert session.observe.await_count == 4
@pytest.mark.asyncio
async def test_old_bitmap_with_new_hierarchy_is_never_marked_settled(tmp_path):
home = _screen(tmp_path, "home.jpg")
session = Mock()
session.observe = AsyncMock(return_value=_observation(home, "存储空间"))
executor = McpActionExecutor(Mock(), actuator=Mock())
observed, settled = await executor._observe_after_action(
session, "click", home, _observation(home, "设置首页").elements
)
assert observed.elements_text == "[1] 存储空间"
assert settled is False
assert session.observe.await_count == 4
@pytest.mark.asyncio
async def test_read_only_action_keeps_single_observation(tmp_path):
home = _screen(tmp_path, "home.jpg")
session = Mock()
session.observe = AsyncMock(return_value=_observation(home, "设置首页"))
executor = McpActionExecutor(Mock(), actuator=Mock())
observed, settled = await executor._observe_after_action(session, "wait_for_delay", home)
assert observed.screenshot_path == home
assert settled is True
session.observe.assert_awaited_once_with(settle_ms=400)