from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest from pydantic import ValidationError from artemis.agents.flash.progress import TaskProgress from artemis.agents.flash.runner import FlashRunner, _TurnRecord def plan(phone="active", pc="pending"): return { "reason": "按用户要求先手机页脚,再电脑独立检查", "stages": [ { "id": "phone", "scope": "Android 主体、页脚及最后滚动", "resources": ["android"], "status": phone, "evidence": "步骤 12 页脚及 13 滚动截图" if phone in {"done", "blocked"} else "", }, { "id": "pc", "scope": "电脑检查", "resources": ["cua-driver"], "status": pc, "evidence": "步骤 20 电脑截图" if pc == "done" else "", }, ], } def test_ordered_stages_require_evidence_and_keep_outstanding_scope(tmp_path): progress = TaskProgress() progress.update(plan(), resources={"android", "cua-driver"}, directory=tmp_path) assert progress.guard("cua-driver") assert progress.guard("android") is None assert progress.guard(None) is None # read-only preflight assert progress.guard(None, completed=True) assert (tmp_path / "notes/task_progress.md").exists() with pytest.raises(ValidationError): progress.update(plan("pending", "active"), resources={"android", "cua-driver"}) omitted = plan() omitted["stages"] = omitted["stages"][1:] with pytest.raises(ValueError, match="不能删除"): progress.update(omitted, resources={"android", "cua-driver"}) progress.update(plan("done", "active"), resources={"android", "cua-driver"}) assert progress.guard("cua-driver") is None assert progress.guard("android") progress.update(plan("done", "done"), resources={"android", "cua-driver"}) assert progress.guard(None, completed=True) is None def test_pc_first_and_intentional_interleaving_are_supported(): progress = TaskProgress() value = { "reason": "路由器调整后在手机验证", "stages": [ { "id": "mixed", "scope": "电脑改参数,手机核对,循环验证", "resources": ["android", "cua-driver"], "status": "active", } ], } progress.update(value, resources={"android", "cua-driver"}) assert progress.guard("cua-driver") is None assert progress.guard("android") is None value["stages"][0]["resources"] = ["cua-driver"] progress.update(value, resources={"cua-driver"}) assert progress.guard("cua-driver") is None @pytest.mark.asyncio async def test_flash_preflight_does_not_authorize_pc_mutation(monkeypatch): plugins = SimpleNamespace( packages={"cua-driver": None}, bindings={"plug_pc_look": ("cua-driver", None), "plug_pc_click": ("cua-driver", None)}, tool_names={"plug_pc_look", "plug_pc_click"}, is_read_only=lambda name: name == "plug_pc_look", ) monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins) runner = FlashRunner.__new__(FlashRunner) runner.ctx = SimpleNamespace(data_engine=None, execution_target="android") runner.executor = SimpleNamespace(action_tool_names={"click"}) runner._progress = TaskProgress() runner._progress.update(plan(), resources={"android", "cua-driver"}) runner._execute_and_record_action = AsyncMock(return_value=(None, "", 0)) runner._refresh_observation = AsyncMock(return_value=(None, "")) messages = [] await runner._process_tool_calls( [ {"name": "plug_pc_look", "id": "look", "args": {}}, {"name": "plug_pc_click", "id": "click", "args": {}}, ], SimpleNamespace(indexed_elements=[]), messages, "预检", {}, None, "", 0, _TurnRecord(), ) runner._execute_and_record_action.assert_awaited_once() assert runner._execute_and_record_action.call_args.args[0] == "plug_pc_look" assert messages[-1].tool_call_id == "click" and messages[-1].status == "error" runner._refresh_observation.assert_not_awaited() @pytest.mark.asyncio async def test_final_report_reuses_exact_saved_body_and_rejects_missing_note(tmp_path): runner = FlashRunner.__new__(FlashRunner) runner.ctx = SimpleNamespace( data_engine=SimpleNamespace(base_dir=tmp_path, current_step_id="s", record_step=Mock()) ) runner.summarizer = None runner.final_report_received = False from artemis.utils.notes import save_verified_note body = "# 报告\nPC 是深色;手机检查未全部覆盖。" save_verified_note(tmp_path, "report", body) messages = [] args = {"status": "completed", "explanation": "全部完成", "report_note_key": "missing"} result = await runner._finalize_task_report( "report_task_status", args, "r", "", {}, None, "", messages ) assert result is None and not runner.final_report_received args["report_note_key"] = "report" result = await runner._finalize_task_report( "report_task_status", args, "r2", "", {}, None, "", messages ) assert result["explanation"] == body assert ( runner.ctx.data_engine.record_step.call_args.kwargs["action_taken"]["args"]["explanation"] == body )