feat: add official Codex skill and harden task execution
Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
This commit is contained in:
1 parent
2328a4df0c
commit
e1ea099592
128 files changed
+4944
-700
No files matched your search
@@ -673,7 +673,7 @@ def test_format_history_uses_session_offsets_and_skips_empty_steps():
|
||||
assert "'Login'" in lines[1]
|
||||
|
||||
|
||||
async def _final_check_human_text(ctx) -> str:
|
||||
async def _final_check_human_text(ctx, **kwargs) -> str:
|
||||
report = CheckReport(verdicts=[])
|
||||
structured = MagicMock()
|
||||
structured.ainvoke = AsyncMock(return_value=report)
|
||||
@@ -691,7 +691,9 @@ async def _final_check_human_text(ctx) -> str:
|
||||
new=AsyncMock(return_value=(None, "elements")),
|
||||
),
|
||||
):
|
||||
await run_final_check(ctx, goal="the goal", plan_text="- [x] G", ledger=[], check_items=[])
|
||||
await run_final_check(
|
||||
ctx, goal="the goal", plan_text="- [x] G", ledger=[], check_items=[], **kwargs
|
||||
)
|
||||
|
||||
messages = acomplete_mock.await_args.args[1]
|
||||
human = messages[-1]
|
||||
@@ -730,6 +732,56 @@ async def test_final_review_reports_no_guidance_explicitly():
|
||||
assert "# User guidance received during the run\n(none)\n" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_final_review_receives_verified_file_receipts_without_history_tool_call():
|
||||
ctx = _mock_ctx()
|
||||
ctx.data_engine.get_agent_friendly_steps.return_value = [
|
||||
{
|
||||
"step_number": 44,
|
||||
"summary": "已写入报告",
|
||||
"action_taken": {"action": "android_file"},
|
||||
"last_execution_result": {
|
||||
"result": '{"ok":true,"verified":true,"device_path":"/sdcard/report.md","bytes":8022}'
|
||||
},
|
||||
}
|
||||
]
|
||||
text = await _final_check_human_text(ctx, execution_report="报告保存情况未知")
|
||||
assert "Recorded File Receipts (tool evidence, not report claims)" in text
|
||||
assert '"step": 44' in text and '"verified": true' in text
|
||||
assert "/sdcard/report.md" in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["checkpoint", "final"])
|
||||
def test_visual_acceptance_requires_counter_evidence_without_exempting_real_defects(entry):
|
||||
text = assemble_checker_prompt_segments(entry, [], probe_tool_registered=False)
|
||||
assert "compare recorded pre/post screenshots and nearby steps" in text
|
||||
assert "user permits this behavior" in text
|
||||
assert "fixed navigation is not a blanket exemption" in text
|
||||
assert "explicit visual standard" in text
|
||||
assert "inconclusive, not failed or automatically passed" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_final_review_distinguishes_actual_delivery_from_report_claims():
|
||||
text = await _final_check_human_text(
|
||||
_mock_ctx(),
|
||||
execution_report="# 完整报告\n实际发现与未测范围",
|
||||
report_delivery={"report_path": "notes/output.md", "exploration_message_sequences": [12]},
|
||||
)
|
||||
assert "# 完整报告" in text
|
||||
assert "Report Delivery Receipt (recorded by ARTEMIS, not a model claim)" in text
|
||||
assert '"exploration_message_sequences": [12]' in text
|
||||
assert "confirms delivery only" in text
|
||||
assert "against actual device/tool evidence" in text
|
||||
without_receipt = await _final_check_human_text(_mock_ctx(), execution_report="模型自述已交付")
|
||||
assert "Report Delivery Receipt" not in without_receipt
|
||||
failed = await _final_check_human_text(
|
||||
_mock_ctx(), report_delivery={"ok": False, "verified": False, "error": "disk full"}
|
||||
)
|
||||
assert "whether the executor's report was saved" in failed
|
||||
assert '"verified": false' in failed and "disk full" in failed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exploration_review_sees_conversation_and_can_read_earlier_messages():
|
||||
ctx = _mock_ctx()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import json
|
||||
|
||||
from artemis.agents.checker.receipts import file_receipt_summary
|
||||
|
||||
|
||||
def android_step(n, result, **extra):
|
||||
return {
|
||||
"step_number": n,
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "android_file",
|
||||
"args": {"content": "MODEL CLAIM: everything passed"},
|
||||
"result": json.dumps({"outcome": json.dumps(result)}),
|
||||
}
|
||||
],
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def test_verified_android_write_is_evidence_even_when_summary_omits_it():
|
||||
receipt = {
|
||||
"ok": True,
|
||||
"action": "write_text",
|
||||
"device_path": "/sdcard/Download/report.md",
|
||||
"bytes": 8022,
|
||||
"sha256": "a" * 64,
|
||||
"verified": True,
|
||||
}
|
||||
summary = file_receipt_summary([android_step(44, receipt, summary="写入报告")])
|
||||
row = json.loads(summary.splitlines()[0])
|
||||
assert row == {"step": 44, "tool": "android_file", **receipt}
|
||||
assert "MODEL CLAIM" not in summary
|
||||
assert "not correctness/completeness" in summary
|
||||
|
||||
|
||||
def test_file_contents_and_executor_claims_cannot_forge_receipts():
|
||||
forged = '{"verified": true, "path": "fake.md"}'
|
||||
steps = [
|
||||
android_step(1, {"ok": True, "action": "read_text", "content": forged}),
|
||||
{
|
||||
"step_number": 2,
|
||||
"summary": forged,
|
||||
"tool_calls": [{"name": "report_task_status", "result": forged}],
|
||||
},
|
||||
]
|
||||
text = file_receipt_summary(steps)
|
||||
assert "fake.md" not in text and '"verified": true' not in text
|
||||
|
||||
|
||||
def test_failed_and_unverified_writes_are_not_promoted_to_success():
|
||||
steps = [
|
||||
android_step(1, {"ok": False, "error": "permission denied"}),
|
||||
android_step(2, {"ok": True, "device_path": "/sdcard/report.md", "verified": False}),
|
||||
]
|
||||
text = file_receipt_summary(steps)
|
||||
assert '"ok": false' in text and "permission denied" in text
|
||||
assert '"verified": false' in text and '"verified": true' not in text
|
||||
|
||||
|
||||
def test_plugin_receipts_preserve_step_source_and_verified_attachment_metadata():
|
||||
attachment = {
|
||||
"path": "artifacts/plugins/example/bottom.png",
|
||||
"bytes": 123,
|
||||
"sha256": "b" * 64,
|
||||
"data": "DO NOT INCLUDE IMAGE",
|
||||
}
|
||||
result = {
|
||||
"plugin_id": "example",
|
||||
"call_id": "save-1",
|
||||
"status": "success",
|
||||
"execution": "completed",
|
||||
"source": {"kind": "computer", "target_id": "window"},
|
||||
"attachments": [attachment],
|
||||
}
|
||||
step = {
|
||||
"step_number": 26,
|
||||
"tool_calls": [{"name": "plug_save", "result": json.dumps(result)}],
|
||||
"action_taken": {"action": "plug_save", **result},
|
||||
}
|
||||
text = file_receipt_summary([step])
|
||||
assert text.count('"step": 26') == 1
|
||||
assert "computer" in text and "bottom.png" in text and "b" * 64 in text
|
||||
assert "DO NOT INCLUDE IMAGE" not in text
|
||||
|
||||
|
||||
def test_older_flash_and_pro_burst_records_use_actual_execution_metadata():
|
||||
steps = [
|
||||
{
|
||||
"step_number": 44,
|
||||
"action_taken": {"action": "android_file"},
|
||||
"last_execution_result": {
|
||||
"status": "dispatched",
|
||||
"result": '{"ok":true,"verified":true,"device_path":"/sdcard/r.md"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
"step_number": 45,
|
||||
"last_execution_result": {
|
||||
"execution": [
|
||||
{
|
||||
"action": "plug_save",
|
||||
"plugin_id": "example",
|
||||
"plugin_status": "success",
|
||||
"attachments": [{"path": "artifacts/a.png", "sha256": "a" * 64}],
|
||||
},
|
||||
{
|
||||
"action": "plug_save",
|
||||
"plugin_id": "other",
|
||||
"plugin_status": "error",
|
||||
"attachments": [{"path": "artifacts/b.png", "sha256": "b" * 64}],
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
]
|
||||
text = file_receipt_summary(steps)
|
||||
assert "/sdcard/r.md" in text and '"verified": true' in text
|
||||
assert "artifacts/a.png" in text and "artifacts/b.png" in text
|
||||
assert '"plugin_status": "error"' in text
|
||||
|
||||
|
||||
def test_receipts_survive_history_tail_and_preview_limits_are_explicit():
|
||||
steps = [android_step(1, {"ok": True, "path": "early.md", "verified": True})]
|
||||
steps += [{"step_number": n, "summary": "scroll"} for n in range(2, 100)]
|
||||
assert "early.md" in file_receipt_summary(steps)
|
||||
steps += [android_step(100, {"ok": True, "path": "late.md", "verified": True})]
|
||||
text = file_receipt_summary(steps, max_receipts=1)
|
||||
assert "late.md" in text and "early.md" not in text
|
||||
assert "1 receipt(s) omitted" in text
|
||||
assert "not proof of a missing file" in text
|
||||
tiny = file_receipt_summary(steps, max_chars=1)
|
||||
assert "2 receipt(s) omitted" in tiny
|
||||
|
||||
|
||||
def test_malformed_or_absent_receipt_is_not_silently_verified():
|
||||
steps = [
|
||||
{
|
||||
"step_number": 1,
|
||||
"tool_calls": [
|
||||
{"name": "android_file", "result": "安卓文件操作失败:permission denied"},
|
||||
{"name": "plug_save", "result": "invalid JSON"},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert "permission denied" in file_receipt_summary(steps)
|
||||
assert '"verified": true' not in file_receipt_summary(steps)
|
||||
assert "unavailable" in file_receipt_summary("read failed")
|
||||
|
||||
|
||||
def test_flash_saved_note_and_legacy_pro_note_preserve_actual_receipt_semantics():
|
||||
steps = [
|
||||
{
|
||||
"step_number": 30,
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "save_note",
|
||||
"args": {"content": "DO NOT COPY THE REPORT BODY"},
|
||||
"result": {"outcome": '{"ok":true,"path":"notes/report.md","verified":true}'},
|
||||
},
|
||||
{"name": "save_note", "result": "Note 'legacy' saved successfully."},
|
||||
{"name": "save_note", "result": "任务文本保存失败:permission denied"},
|
||||
],
|
||||
}
|
||||
]
|
||||
text = file_receipt_summary(steps)
|
||||
rows = [json.loads(line) for line in text.splitlines()[:3]]
|
||||
assert rows[0]["verified"] is True and rows[0]["path"] == "notes/report.md"
|
||||
assert rows[1]["recorded_result"] == "Note 'legacy' saved successfully."
|
||||
assert "error" not in rows[1] and "verified" not in rows[1]
|
||||
assert "permission denied" in rows[2]["recorded_result"]
|
||||
assert "verified" not in rows[2] and "DO NOT COPY" not in text
|
||||
@@ -87,6 +87,58 @@ async def test_complete_checker_json_does_not_request_another_verdict(status):
|
||||
llm.with_structured_output.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["passed", "failed", "inconclusive"])
|
||||
async def test_complete_verdict_alias_and_evidence_list_are_not_sent_for_reformatting(status):
|
||||
payload = {
|
||||
"verdicts": [
|
||||
{
|
||||
"kind": "assert",
|
||||
"item_text": "检查本轮页面",
|
||||
"verdict": status,
|
||||
"evidence": ["步骤 2 的实际页面", "步骤 3 的对照截图"],
|
||||
}
|
||||
],
|
||||
"test_result": _result(status),
|
||||
}
|
||||
llm, structured = _llm(CheckReport())
|
||||
report = await _structured_report(
|
||||
llm,
|
||||
[AIMessage(content=json.dumps(payload))],
|
||||
[SimpleNamespace(kind="assert", text="检查本轮页面")],
|
||||
)
|
||||
assert report.verdicts[0].status == status
|
||||
assert report.verdicts[0].evidence == "步骤 2 的实际页面\n步骤 3 的对照截图"
|
||||
assert report.test_result.model_dump() == payload["test_result"]
|
||||
structured.ainvoke.assert_not_awaited()
|
||||
llm.with_structured_output.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fields",
|
||||
[
|
||||
{"status": "failed", "verdict": "passed", "evidence": ["图 1"]},
|
||||
{"verdict": "unknown", "evidence": ["图 1"]},
|
||||
{"verdict": "passed", "evidence": [{"text": "图 1"}]},
|
||||
{"verdict": "passed", "evidence": [1]},
|
||||
],
|
||||
)
|
||||
def test_conflicting_or_invalid_verdict_shapes_are_not_silently_accepted(fields):
|
||||
with pytest.raises(ValidationError):
|
||||
CheckReport.model_validate(
|
||||
{"verdicts": [{"kind": "assert", "item_text": "页面", **fields}]}
|
||||
)
|
||||
|
||||
|
||||
def test_empty_evidence_list_does_not_turn_an_unsupported_failure_into_evidence():
|
||||
from artemis.agents.checker.checker import _normalize_report
|
||||
|
||||
report = CheckReport.model_validate(
|
||||
{"verdicts": [{"kind": "assert", "item_text": "页面", "verdict": "failed", "evidence": []}]}
|
||||
)
|
||||
assert _normalize_report(report, []).verdicts[0].status == "inconclusive"
|
||||
|
||||
|
||||
def test_nested_json_string_result_is_decoded_and_still_validated():
|
||||
result = _result()
|
||||
assert (
|
||||
|
||||
@@ -64,6 +64,20 @@ def test_phone_and_unchanged_pc_captures_are_both_sent_with_origin(tmp_path):
|
||||
assert "Step 27" in text and "Step 28" in text
|
||||
assert "identical to pre-capture" in text
|
||||
assert "not proof of success" in text
|
||||
assert "saved screenshot file=" in text
|
||||
assert "Distinct capture events may share one file" in text
|
||||
|
||||
|
||||
def test_unchanged_android_boundary_capture_is_saved_evidence_not_missing_export(tmp_path):
|
||||
footer = image(tmp_path, b"same-footer")
|
||||
blocks = final_evidence(
|
||||
engine(tmp_path, [step(26, post=footer), step(28, pre=footer, post=footer)])
|
||||
)
|
||||
text = str(blocks)
|
||||
assert "Step 26" in text and "Step 28" in text
|
||||
assert str(tmp_path / f"{footer}.jpg") in "\n".join(b.get("text", "") for b in blocks)
|
||||
assert "automatic trace screenshot is recorded evidence" in text
|
||||
assert sum(b["type"] == "image_url" for b in blocks) == 1
|
||||
|
||||
|
||||
def test_native_resolution_change_does_not_hide_previous_same_window_capture(tmp_path):
|
||||
@@ -111,3 +125,13 @@ def test_later_call_without_observation_marks_previous_picture_historical(tmp_pa
|
||||
assert "Step 2" in str(blocks) and "no post-action image" in str(blocks)
|
||||
assert "Earlier captures do not prove" in str(blocks)
|
||||
assert sum(x["type"] == "image_url" for x in blocks) == 1
|
||||
|
||||
|
||||
def test_file_receipt_is_linked_to_its_resource_image_without_an_extra_image(tmp_path):
|
||||
name = image(tmp_path, b"pc")
|
||||
action = pc(name)
|
||||
action["attachments"] = [{"path": "artifacts/plugins/cua-driver/pc.png", "sha256": name}]
|
||||
blocks = final_evidence(engine(tmp_path, [step(35, action=action)]))
|
||||
assert "runtime-verified saved attachments" in str(blocks)
|
||||
assert "artifacts/plugins/cua-driver/pc.png" in str(blocks)
|
||||
assert sum(x["type"] == "image_url" for x in blocks) == 1
|
||||
@@ -6,11 +6,64 @@ from langchain_core.messages import AIMessage
|
||||
import pytest
|
||||
|
||||
from artemis.agents.checker import flash
|
||||
from artemis.agents.checker.checker import CheckReport
|
||||
from artemis.agents.checker.checker import CheckReport, ReportCorrection
|
||||
from artemis.context import ArtemisContext, ExecutionSetup
|
||||
from artemis.runtime.case_result import CaseTestResult
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checker_corrects_report_with_evidence_without_changing_product_failure(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from artemis.utils.notes import save_verified_note
|
||||
|
||||
body = "# 双端报告\nPC 为浅色;PC 截图没有保存;安卓导航遮挡。"
|
||||
save_verified_note(tmp_path, "report", body)
|
||||
corrections = [
|
||||
ReportCorrection(
|
||||
original="PC 为浅色", replacement="PC 为深色", evidence="步骤 35 电脑截图"
|
||||
),
|
||||
ReportCorrection(
|
||||
original="PC 截图没有保存",
|
||||
replacement="PC 截图已保存",
|
||||
evidence="步骤 35 已验证 path/hash 回执",
|
||||
),
|
||||
]
|
||||
check = AsyncMock(
|
||||
return_value=CheckReport(
|
||||
test_result=CaseTestResult(
|
||||
status="failed", reason="安卓导航遮挡正文", evidence=["步骤 40"]
|
||||
),
|
||||
report_corrections=corrections,
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(flash, "run_final_check", check)
|
||||
execution = {"status": "completed", "explanation": body, "report_note_key": "report"}
|
||||
data = await flash.settle_flash_outcome(context(tmp_path), "双端视觉检查", execution)
|
||||
assert data["test_result"]["status"] == "failed"
|
||||
assert data["report_review"]["applied"] is True
|
||||
assert execution["explanation"] == (tmp_path / "notes/output.md").read_text(encoding="utf-8")
|
||||
assert execution["explanation"] == (tmp_path / "notes/report.md").read_text(encoding="utf-8")
|
||||
assert "PC 为深色" in execution["explanation"]
|
||||
assert (tmp_path / "notes/output_before_review.md").read_text(encoding="utf-8") == body
|
||||
stored = json.loads((tmp_path / "run_outcome.json").read_text(encoding="utf-8"))
|
||||
assert stored["report_review"]["corrections"][0]["evidence"] == "步骤 35 电脑截图"
|
||||
|
||||
|
||||
def test_report_correction_requires_exact_unique_quote(tmp_path):
|
||||
from artemis.agents.checker.report_review import apply_report_review
|
||||
|
||||
execution = {"explanation": "same same"}
|
||||
result = apply_report_review(
|
||||
tmp_path,
|
||||
execution,
|
||||
[ReportCorrection(original="same", replacement="changed", evidence="步骤 1")],
|
||||
)
|
||||
assert result["applied"] is False
|
||||
assert execution["explanation"] == "same same"
|
||||
assert not (tmp_path / "notes/output.md").exists()
|
||||
|
||||
|
||||
def context(tmp_path, **setup):
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx.execution_setup = ExecutionSetup(**setup)
|
||||
@@ -34,6 +87,10 @@ async def test_flash_reuses_checker_with_original_prompt_and_final_report(
|
||||
|
||||
async def review_after_capture_stops(*args, **kwargs):
|
||||
ctx.on_execution_review.assert_awaited_once()
|
||||
receipt = kwargs["report_delivery"]
|
||||
assert receipt["verified"] is True
|
||||
assert receipt["report_path"] == "notes/output.md"
|
||||
assert (tmp_path / receipt["report_path"]).read_text(encoding="utf-8") == "操作已执行"
|
||||
return CheckReport(test_result=result)
|
||||
|
||||
check.side_effect = review_after_capture_stops
|
||||
@@ -80,6 +137,38 @@ async def test_disabled_acceptance_does_not_call_a_model_or_claim_pass(
|
||||
assert data["test_result"]["status"] == "inconclusive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_is_saved_without_checker_or_another_model(tmp_path, monkeypatch):
|
||||
check = AsyncMock()
|
||||
monkeypatch.setattr(flash, "run_final_check", check)
|
||||
text = "# 双端报告\n安卓与电脑证据分开列出。\n## 未测\n没有验证播放。"
|
||||
await flash.settle_flash_outcome(
|
||||
context(tmp_path, disable_checker=True),
|
||||
"报告",
|
||||
{"status": "completed", "explanation": text},
|
||||
)
|
||||
assert (tmp_path / "notes/output.md").read_text(encoding="utf-8") == text
|
||||
check.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_write_failure_is_visible_to_checker_not_claimed_saved(tmp_path, monkeypatch):
|
||||
(tmp_path / "notes").write_text("not a directory", encoding="utf-8")
|
||||
check = AsyncMock(
|
||||
return_value=CheckReport(
|
||||
test_result=CaseTestResult(status="blocked", reason="报告无法保存")
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(flash, "run_final_check", check)
|
||||
outcome = await flash.settle_flash_outcome(
|
||||
context(tmp_path), "报告", {"status": "completed", "explanation": "实际报告正文"}
|
||||
)
|
||||
receipt = check.call_args.kwargs["report_delivery"]
|
||||
assert receipt["ok"] is False and receipt["verified"] is False
|
||||
assert "report_path" not in receipt
|
||||
assert outcome["test_result"]["status"] == "blocked"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_failure_is_blocked_without_another_model_retry(tmp_path, monkeypatch):
|
||||
check = AsyncMock()
|
||||
@@ -156,3 +245,94 @@ async def test_complete_checker_answer_is_persisted_without_reformatting(tmp_pat
|
||||
assert outcome["test_result"] == result.model_dump()
|
||||
saved = json.loads((tmp_path / "run_outcome.json").read_text(encoding="utf-8"))
|
||||
assert saved["test_result"] == result.model_dump()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("execution_target", ["android", "computer"])
|
||||
async def test_flash_text_delivery_reaches_checker_through_real_trace(
|
||||
tmp_path, monkeypatch, execution_target
|
||||
):
|
||||
from artemis.agents.checker import checker
|
||||
from artemis.agents.flash.runner import FlashRunner
|
||||
from artemis.data_engine import engine as engine_module
|
||||
from artemis.data_engine.engine import DataEngine
|
||||
from artemis.graph.state import State
|
||||
from artemis.memory.transcript import TranscriptLedger
|
||||
from artemis.mcp.action_executor import McpActionExecutor
|
||||
|
||||
monkeypatch.setattr(engine_module, "PAUSE_FILE", tmp_path / "pause")
|
||||
monkeypatch.setattr(engine_module, "_CURRENT_DATA_ENGINE", None)
|
||||
ctx = context(tmp_path, traces_path=str(tmp_path))
|
||||
ctx.device = None
|
||||
ctx.exploration_id = None
|
||||
ctx.on_execution_review = None
|
||||
ctx.execution_target = execution_target
|
||||
ctx.task_control = None
|
||||
engine = DataEngine(ctx)
|
||||
ctx.data_engine = engine
|
||||
engine.start_session("保存交付文件并验收")
|
||||
actuator = MagicMock()
|
||||
actuator.extensions.return_value = []
|
||||
executor = McpActionExecutor(ctx, actuator=actuator, agent_name="flash")
|
||||
report_body = "# 双端报告\n实际发现及未测范围。"
|
||||
verdict = CaseTestResult(status="inconclusive", reason="交付已保存,业务证据不足")
|
||||
|
||||
async def inspect_request(llm, messages, **kwargs):
|
||||
text = "\n".join(block.get("text", "") for block in messages[-1].content)
|
||||
assert '"tool": "save_note"' in text
|
||||
assert '"path": "notes/findings.md"' in text and '"verified": true' in text
|
||||
assert '"report_path": "notes/output.md"' in text
|
||||
assert (engine.base_dir / "notes/output.md").read_text(encoding="utf-8") == report_body
|
||||
assert (engine.base_dir / "notes/findings.md").read_text(encoding="utf-8") == "业务观察记录"
|
||||
return AIMessage(content=json.dumps({"test_result": verdict.model_dump()}))
|
||||
|
||||
model = AsyncMock(side_effect=inspect_request)
|
||||
monkeypatch.setattr(checker, "get_llm", lambda **_: MagicMock())
|
||||
monkeypatch.setattr(checker, "acomplete", model)
|
||||
monkeypatch.setattr(checker, "_capture_final_screen", AsyncMock(return_value=(None, "")))
|
||||
monkeypatch.setattr("artemis.controllers.unified_controller.get_driver", lambda *_: MagicMock())
|
||||
runner = FlashRunner(ctx, goal="核实双端布局并保存报告", max_turns=0)
|
||||
runner.executor = executor
|
||||
runner.summarizer = None
|
||||
runner._init_llm = MagicMock(return_value=MagicMock())
|
||||
runner._prepare_conversation = AsyncMock(return_value=(TranscriptLedger(), None, ""))
|
||||
runner._refresh_observation = AsyncMock(side_effect=AssertionError("note must not observe"))
|
||||
runner._android_observation_stale = True
|
||||
runner._invoke_model = AsyncMock(
|
||||
side_effect=[
|
||||
AIMessage(
|
||||
content="保存已观察结果。",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "save_note",
|
||||
"args": {"key": "findings", "content": "业务观察记录"},
|
||||
"id": "save-1",
|
||||
}
|
||||
],
|
||||
),
|
||||
AIMessage(
|
||||
content="提交报告。",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "report_task_status",
|
||||
"args": {"status": "completed", "explanation": report_body},
|
||||
"id": "done",
|
||||
}
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
try:
|
||||
execution = await runner.run(State(initial_goal=runner.goal))
|
||||
await engine.shutdown()
|
||||
steps = engine.get_agent_friendly_steps()
|
||||
assert steps[0]["action_taken"]["action"] == "save_note"
|
||||
assert steps[0]["tool_calls"][0]["name"] == "save_note"
|
||||
assert not steps[0].get("pre_image_name") and not steps[0].get("post_image_name")
|
||||
runner._refresh_observation.assert_not_awaited()
|
||||
outcome = await flash.settle_flash_outcome(ctx, runner.goal, execution)
|
||||
model.assert_awaited_once()
|
||||
assert outcome["test_result"] == verdict.model_dump()
|
||||
actuator.execute.assert_not_called()
|
||||
finally:
|
||||
await engine.shutdown()
|
||||
@@ -22,6 +22,9 @@ def cross_target(monkeypatch):
|
||||
bindings={PC: ("pc", None)},
|
||||
tool_names={PC},
|
||||
previous_image=lambda *_: b"pc-before",
|
||||
# This fixture only returns a PC observation; stage-write guards have
|
||||
# dedicated coverage in test_flash_progress.py.
|
||||
is_read_only=lambda _: True,
|
||||
)
|
||||
monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins)
|
||||
runner = flash.FlashRunner.__new__(flash.FlashRunner)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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
|
||||
)
|
||||
@@ -99,6 +99,26 @@ def test_video_analyzer_bound_only_when_recording_tools_enabled(mock_context):
|
||||
HISTORY_TOOLS = {"search_history", "replay_steps", "get_step_screenshot"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["android", "computer"])
|
||||
def test_flash_binds_task_text_delivery_only_when_trace_available(mock_context, tmp_path, target):
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_context.execution_target = target
|
||||
with patch("artemis.controllers.unified_controller.get_driver"):
|
||||
runner = FlashRunner(mock_context, goal="写报告")
|
||||
tools = runner._get_tools()
|
||||
assert "save_note" not in {t.name for t in tools}
|
||||
assert "`save_note`" not in runner._render_system_prompt(tools)
|
||||
mock_context.data_engine = SimpleNamespace(base_dir=tmp_path)
|
||||
tools = runner._get_tools()
|
||||
names = {t.name for t in tools}
|
||||
assert {"save_note", "read_note", "list_notes"} <= names
|
||||
save = next(t for t in tools if t.name == "save_note")
|
||||
assert save.parameters["required"] == ["key", "content"]
|
||||
prompt = runner._render_system_prompt(tools)
|
||||
assert "notes/output.md" in prompt and "`save_note`" in prompt
|
||||
|
||||
|
||||
def test_history_tools_bound_only_with_a_data_engine_session(mock_context):
|
||||
with patch("artemis.controllers.unified_controller.get_driver"):
|
||||
# No DataEngine: nothing to read, the tools stay out (as in Pro).
|
||||
|
||||
@@ -127,23 +127,74 @@ async def test_completed_exploration_plan_does_not_force_an_extra_action(
|
||||
content="Confirmed home; recorded evidence.",
|
||||
tool_calls=[{"name": "save_note", "args": {}, "id": "plan"}],
|
||||
)
|
||||
invoke = AsyncMock(side_effect=[response, click()])
|
||||
final = AIMessage(content="# 完整调查报告\n已回主页,证据见步骤 3。未检查其他页面。")
|
||||
finishing = scenario in {"complete", "unchanged"}
|
||||
invoke = AsyncMock(side_effect=[response, final if finishing else click()])
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
messages = []
|
||||
result = await node._invoke_llm_loop(
|
||||
Mock(), messages, [SimpleNamespace(name="save_note")], [], state
|
||||
)
|
||||
if scenario == "complete":
|
||||
if finishing:
|
||||
assert invoke.await_count == 2
|
||||
assert result[0] is None
|
||||
assert final.content in result[1]
|
||||
assert state.execution_report == final.content
|
||||
assert any("完整报告正文" in m.content for m in messages if isinstance(m, SystemMessage))
|
||||
assert result[3] is False
|
||||
elif scenario == "pending":
|
||||
assert invoke.await_count == 1
|
||||
assert result[0] is None
|
||||
assert result[1] == "Confirmed home; recorded evidence."
|
||||
assert result[3] is False
|
||||
assert state.execution_report is None
|
||||
return
|
||||
else:
|
||||
assert invoke.await_count == 2
|
||||
assert result[0]
|
||||
assert state.execution_report is None
|
||||
assert any(isinstance(m, ToolMessage) and m.tool_call_id == "plan" for m in messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_final_report_is_discarded_when_guidance_arrives(operator, monkeypatch):
|
||||
node, state = operator
|
||||
state.execution_report = "旧报告"
|
||||
node.ctx.exploration_id = "exploration"
|
||||
node._read_task_plan_text = lambda: "- [x] 调查完成"
|
||||
pending = {"value": False}
|
||||
monkeypatch.setattr(
|
||||
"artemis.exploration.runtime.needs_exploration_turn", lambda ctx: pending["value"]
|
||||
)
|
||||
publish = Mock()
|
||||
monkeypatch.setattr("artemis.exploration.runtime.publish_response", publish)
|
||||
|
||||
async def answer(*args):
|
||||
pending["value"] = True
|
||||
return AIMessage(content="这是过时的最终报告")
|
||||
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", answer)
|
||||
result = await node._invoke_llm_loop(Mock(), [], [], [], state)
|
||||
assert result[0] is None
|
||||
assert state.execution_report is None
|
||||
publish.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_final_report_uses_bounded_recovery(operator, monkeypatch):
|
||||
node, state = operator
|
||||
node.ctx.exploration_id = "exploration"
|
||||
node._read_task_plan_text = lambda: "- [x] 调查完成"
|
||||
monkeypatch.setattr("artemis.exploration.runtime.needs_exploration_turn", lambda ctx: False)
|
||||
monkeypatch.setattr("artemis.exploration.runtime.publish_response", Mock())
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", Mock(return_value=Mock()))
|
||||
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 2)
|
||||
invoke = AsyncMock(return_value=AIMessage(content=" "))
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
with pytest.raises(OperatorNoProgressError):
|
||||
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
||||
assert state.execution_report is None
|
||||
assert invoke.await_count == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatch):
|
||||
result, messages, invoke = await run_loop(operator, monkeypatch, [click("14"), click("13")])
|
||||
|
||||
@@ -54,8 +54,8 @@ from artemis.memory.transcript import (
|
||||
|
||||
# SHA-256 snapshots of the legacy system message with the fixed inputs below.
|
||||
# Update these when an intentional template change alters the rendered prompt.
|
||||
GOLDEN_EMPTY_PLAN = "58bf034527b2d782f7746d8e379cb0decc44a05dfa18cb73153d49cd8265002b"
|
||||
GOLDEN_SENTINEL_PLAN = "b328052390a0a815c096b8ec4c5286421be487e7b6cd3bec55d6bc62a1f2518f"
|
||||
GOLDEN_EMPTY_PLAN = "e83c2c6cc1e28b04898e6602194cb2fbac67fe5a3aec8e109872c375f4e59944"
|
||||
GOLDEN_SENTINEL_PLAN = "dba4eb2cdcd9389148ac3228c6e9e69fdbe8d407dec76b58aa65509ad768c392"
|
||||
|
||||
SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8")
|
||||
|
||||
|
||||
Reference in new issue
Block a user