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

+4
View File
@@ -800,6 +800,10 @@ async def test_exploration_review_sees_conversation_and_can_read_earlier_message
):
await run_final_check(ctx, goal="探索设置", plan_text="", ledger=[], check_items=[])
assert conversation in review.call_args.args[1]
assert any(
"不能用手机上的成功证明电脑步骤完成" in str(message.content)
for message in review.call_args.args[1]
)
assert history in review.call_args.args[2]
+32 -1
View File
@@ -12,7 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import Mock, patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from artemis.agents.planner.planner import PlannerNode
from artemis.context import DevicePlatform, ArtemisContext
@@ -97,6 +98,36 @@ async def test_planner_initial_plan(mock_context):
assert "Goal: Test Goal" in human_msg_content[0]["text"]
@pytest.mark.asyncio
@pytest.mark.parametrize("target", ["mixed", "computer"])
async def test_planner_knows_computer_plugins_with_mobile_primary_screen(mock_context, target):
state = DummyState(initial_goal="按人工示范浏览首页")
mock_context.execution_target = target
mock_context.plugins = SimpleNamespace(prompt=lambda: "电脑插件:cua-driver,可按需加载")
controller = Mock()
controller.take_screenshot = AsyncMock(return_value=_valid_screen_base64())
controller.get_screen_data = AsyncMock(
return_value=SimpleNamespace(observation_text="电脑窗口状态")
)
llm = Mock()
async def stream(*_args, **_kwargs):
yield SimpleNamespace(content="- [ ] 浏览首页", tool_calls=[])
llm.astream.side_effect = stream
llm.bind_tools.return_value = llm
with (
patch("artemis.agents.planner.planner.get_llm", return_value=llm),
patch("artemis.agents.planner.planner.UnifiedMobileController", return_value=controller),
):
await PlannerNode(mock_context)(state)
content = llm.astream.call_args.args[0][1].content
text = "\n".join(part["text"] for part in content if part["type"] == "text")
assert text.count("电脑插件:cua-driver") == 1
assert f"当前主设备截图({'电脑' if target == 'computer' else '安卓'})" in text
assert "不决定其他步骤的执行平台" in text
def test_validate_plan_format_single_sourced_status_alphabet():
"""Regression: the Planner's format gate is single-sourced from
plan_grammar — a legal in-progress '[/]' line must never be rejected."""
@@ -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
+39
View File
@@ -82,6 +82,45 @@ async def test_generation_is_idempotent_and_preserves_rerun_independence(synthes
model.with_structured_output.return_value.ainvoke.assert_awaited_once()
@pytest.mark.asyncio
async def test_case_synthesis_receives_legacy_demonstration_platform(synthesis):
repository, session, run, model = synthesis
repository.database.put(
"exploration_demo",
"old-computer-demo",
{
"id": "old-computer-demo",
"exploration_id": session["id"],
"run_id": run["id"],
"source": "computer",
"status": "completed",
"media": [{"kind": "computer"}],
},
)
repository.add_message(
session["id"],
"浏览器中查看设置后返回首页",
"old-summary",
role="assistant",
metadata={"kind": "demonstration", "demonstration_id": "old-computer-demo"},
)
await generate_case(
repository, session["id"], "generate", run["id"], 0, ctx=SimpleNamespace(task_control=None)
)
messages = model.with_structured_output.return_value.ainvoke.await_args.args[0]
content = json.loads(messages[1].content)
assert content["demonstrations"] == [
{
"id": "old-computer-demo",
"run_id": run["id"],
"source": "computer",
"recorded_sources": ["computer"],
}
]
assert "不能用手机上的成功证明电脑步骤完成" in messages[0].content
assert repository.run(run["id"])["outcome"]["test_result"]["status"] == "passed"
@pytest.mark.asyncio
async def test_synthesis_repairs_unbound_parameters_once_with_original_evidence(synthesis):
repository, session, run, model = synthesis
+79
View File
@@ -214,6 +214,85 @@ async def test_same_screen_reuses_recording_and_keeps_both_target_ids(tmp_path,
assert next(iter(recorder.sources.values()))["target_ids"] == ["window-1", "window-2"]
@pytest.mark.asyncio
async def test_manual_window_demo_follows_only_foreground_and_never_captures_two_screens(
tmp_path, fake_capture, monkeypatch
):
from artemis.exploration import demo_runtime
ctx = context(tmp_path, enabled=False, computer_scope="window")
ctx.plugins = SimpleNamespace(
packages={
"visual-test": (
SimpleNamespace(capabilities=SimpleNamespace(recording="windows")),
None,
None,
),
}
)
current = [source(screen={"left": -1920, "top": 0, "width": 4480, "height": 1440})]
scopes = []
def foreground(scope="screen"):
scopes.append(scope)
return current[0]
monkeypatch.setattr(demo_runtime, "desktop_source", foreground)
capture = demo_runtime.DemonstrationCapture(ctx, {"id": "demo", "source": "computer"})
try:
await capture.start()
assert capture.computer.config.computer_scope == "window"
assert argument(fake_capture.commands[0], "--hwnd") == "123"
await capture.refresh_computer()
assert len(fake_capture.commands) == 1
current[0] = source(window={"left": 1800, "top": 40, "width": 800, "height": 600})
await capture.refresh_computer()
assert len(fake_capture.commands) == 1
current[0] = source(target_id="456")
await capture.refresh_computer()
assert argument(fake_capture.commands[-1], "--hwnd") == "456"
assert fake_capture.processes[0].returncode == 0
assert len(capture.computer.active) == 1
# Desktop/no foreground window is a recording gap, never a reason to
# silently switch to capturing all monitors.
current[0] = None
await capture.refresh_computer()
assert not capture.computer.active
assert len(fake_capture.commands) == 2
current[0] = source(target_id="456")
await capture.refresh_computer()
assert len(capture.computer.active) == 1
assert len(fake_capture.commands) == 3
assert all(scope == "window" for scope in scopes)
assert all(
"gdigrab" not in command and "desktop" not in command
for command in fake_capture.commands
)
finally:
await capture.stop()
assert not capture.errors
assert len(capture.media) == 2
assert all(track["scope"] == "window" for track in capture.media)
assert all(process.returncode == 0 for process in fake_capture.processes)
assert ctx.agent_config.recording.computer_scope == "window"
@pytest.mark.asyncio
@pytest.mark.parametrize("exclusive", [False, True])
async def test_window_recording_exclusivity_does_not_change_normal_plugin_recordings(
tmp_path, fake_capture, exclusive
):
recorder = PluginRecordings(context(tmp_path, computer_scope="window"))
try:
await recorder.observe("first", "visual-test", source(), exclusive=exclusive)
await recorder.observe(
"second", "visual-test", source(target_id="456"), exclusive=exclusive
)
assert len(recorder.active) == (1 if exclusive else 2)
finally:
await recorder.stop()
@pytest.mark.asyncio
async def test_low_disk_space_records_failure_without_starting_capture(
tmp_path, fake_capture, monkeypatch
+1
View File
@@ -91,6 +91,7 @@ def test_assistant_message_count_does_not_change_exploration_context(monkeypatch
get=lambda _: session, recent_messages=lambda *a, **k: history, case=lambda _: None
)
monkeypatch.setattr("artemis.exploration.runtime.explorations", store)
monkeypatch.setattr("artemis.exploration.demonstrations.Demonstrations.list", lambda *a: [])
monkeypatch.setattr("artemis.exploration.editorial.prior_context", lambda *a, **k: [])
ctx = SimpleNamespace(exploration_id="explore", exploration_run_id="run")
first = conversation_message(ctx).content