fix: preserve exploration demonstration source and recording scope
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user