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
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user