fix: preserve exploration demonstration source and recording scope

This commit is contained in:
chenmingxuan committed 2026-10-18 23:20:50 +08:00
1 parent f4cb55c030
commit 973ccae679
22 files changed
+624 -55

No files matched your search

@@ -1,4 +1,5 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -25,6 +26,50 @@ def demo_session(tmp_path, monkeypatch):
return repository, session, run
@pytest.mark.parametrize(
"window_class", ["Chrome_WidgetWin_1", "Progman", "WorkerW", "Shell_TrayWnd"]
)
def test_window_demo_bounds_use_foreground_hwnd_not_virtual_desktop(monkeypatch, window_class):
from artemis.exploration import demo_runtime
user32 = Mock()
user32.GetForegroundWindow.return_value = 2**40 + 123
user32.IsIconic.return_value = False
user32.SetThreadDpiAwarenessContext.return_value = 17
def class_name(hwnd, buffer, size):
buffer.value = window_class
return len(window_class)
def bounds(hwnd, pointer):
rect = pointer._obj
rect.left, rect.top, rect.right, rect.bottom = -1200, 50, -400, 650
return True
def title(hwnd, buffer, size):
buffer.value = "示范浏览器"
return len(buffer.value)
user32.GetClassNameW.side_effect = class_name
user32.GetWindowRect.side_effect = bounds
user32.GetWindowTextW.side_effect = title
monkeypatch.setattr(
demo_runtime.ctypes, "windll", SimpleNamespace(user32=user32), raising=False
)
monkeypatch.setattr(demo_runtime, "os", SimpleNamespace(name="nt"))
result = demo_runtime.desktop_source("window")
user32.GetSystemMetrics.assert_not_called()
assert user32.SetThreadDpiAwarenessContext.call_count == 2
if window_class != "Chrome_WidgetWin_1":
assert result is None
return
assert result.target_id == str(2**40 + 123)
assert result.label == "示范浏览器"
assert result.window == {"left": -1200, "top": 50, "width": 800, "height": 600}
assert result.screen is None
assert (result.width, result.height) == (800, 600)
def test_demo_command_retries_do_not_extend_twice_or_finish_other_sessions(
demo_session, monkeypatch
):
@@ -129,6 +174,129 @@ def test_system_demo_summary_cannot_answer_pending_user_question(demo_session):
assert control.question_answered()
def test_legacy_demo_source_survives_recent_message_window_without_copying_media(demo_session):
from artemis.exploration.runtime import conversation_message
repository, session, run = demo_session
demo = {
"id": "old-demo",
"run_id": run["id"],
"exploration_id": session["id"],
"source": "computer",
"status": "completed",
"media": [{"kind": "computer", "segments": [{"file": "private/path/recording.mp4"}]}],
}
repository.database.put("exploration_demo", demo["id"], demo)
repository.database.put(
"exploration_demo", "failed-demo", {**demo, "id": "failed-demo", "status": "failed"}
)
repository.add_message(
session["id"],
"打开浏览器看首页",
"legacy-summary",
role="assistant",
metadata={"demonstration_id": demo["id"], "kind": "demonstration"},
)
for i in range(4):
repository.add_message(session["id"], "最新指导" * 2000, f"guidance-{i}")
ctx = SimpleNamespace(exploration_id=session["id"], exploration_run_id=run["id"])
payload = json.loads(conversation_message(ctx).content.split("\n", 1)[1])
assert not any(m.get("demonstration_id") for m in payload["messages"])
assert payload["demonstrations"] == [
{
"id": "old-demo",
"run_id": run["id"],
"source": "computer",
"recorded_sources": ["computer"],
}
]
assert "private/path" not in str(payload)
@pytest.mark.asyncio
@pytest.mark.parametrize("needs_user", [True, False])
@pytest.mark.parametrize(
("source", "recorded_sources"),
[("computer", ["computer"]), ("android", ["android"]), ("both", ["android", "computer"])],
)
async def test_demo_source_survives_clarification_and_execution_restart(
demo_session, monkeypatch, needs_user, source, recorded_sources
):
from apps.admin_console.services.exploration_service import ExplorationService
from artemis.exploration import demo_runtime
from artemis.exploration.runtime import conversation_message
repository, session, run = demo_session
demos = Demonstrations(repository)
demo = demos.request(session["id"], source, "demo")
demos.transition(demo["id"], "recording")
demos.command(session["id"], demo["id"], "finish", "finish")
capture = SimpleNamespace(
start=AsyncMock(),
stop=AsyncMock(),
errors=[],
media=[{"id": f"track-{kind}", "kind": kind} for kind in recorded_sources],
)
monkeypatch.setattr(demo_runtime, "DemonstrationCapture", lambda *_: capture)
monkeypatch.setattr(
demo_runtime,
"analyze_demonstration",
AsyncMock(
return_value=demo_runtime.DemonstrationAnalysis(
summary="打开浏览器,浏览首页后返回顶部。",
needs_user=needs_user,
question="目标网址是什么?" if needs_user else "",
)
),
)
async def run_interruptibly(operation, **_):
return False, await operation
control = SimpleNamespace(
session_id=run["id"],
directory=task_control.control_dir(run["id"]),
_state=Mock(),
run_interruptibly=run_interruptibly,
)
ctx = SimpleNamespace(exploration_id=session["id"], exploration_run_id=run["id"])
reason = await demo_runtime.perform_demonstration(ctx, control, demo)
assert reason == ("question" if needs_user else "")
pending = repository.get(session["id"])["pending_question"]
assert bool(pending) == needs_user
ExplorationService(repository).message(session["id"], "www.playones.com,首页看一遍就行", "url")
assert repository.get(session["id"])["pending_question"] is None
# Reopen the durable store: source must not depend on in-memory capture,
# the model repeating a platform name, or whether analysis needed an answer.
restored = ExplorationStore(repository.database)
monkeypatch.setattr("artemis.exploration.runtime.explorations", restored)
payload = json.loads(conversation_message(ctx).content.split("\n", 1)[1])
assert payload["demonstrations"] == [
{
"id": demo["id"],
"run_id": run["id"],
"source": source,
"recorded_sources": recorded_sources,
}
]
summary = next(m for m in payload["messages"] if m.get("demonstration_id") == demo["id"])
assert "打开浏览器" in summary["text"]
assert ("电脑" if source == "computer" else "安卓") in summary["text"]
instructions = task_control.consume_instructions(control.directory)["instruction"]
assert "打开浏览器" in instructions
assert "www.playones.com" in instructions
assert ("电脑" if source == "computer" else "安卓") in instructions
# Explicit user changes remain later guidance; provenance is evidence,
# never a hard global execution-target switch that would forbid mixed tests.
ExplorationService(restored).message(session["id"], "接下来切到手机核对页面", "switch")
changed = json.loads(conversation_message(ctx).content.split("\n", 1)[1])
assert changed["messages"][-1]["text"] == "接下来切到手机核对页面"
assert changed["demonstrations"] == payload["demonstrations"]
assert restored.run(run["id"])["task_config"] == {}
@pytest.mark.asyncio
async def test_saved_video_extracts_copy_and_preserves_source_and_offsets(tmp_path, monkeypatch):
from artemis.exploration.media import SavedVideoSource