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:
chenmingxuan committed 2026-10-18 14:18:37 +08:00
1 parent 2328a4df0c
commit e1ea099592
128 files changed
+4944 -700

No files matched your search

+54 -2
View File
@@ -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
+181 -1
View File
@@ -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)
+138
View File
@@ -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).
+55 -4
View File
@@ -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")