feat: release 2.2.0 with PC-only tests and execution reliability fixes

Unify task scheduling and queue state, fix Android input and observation recovery, preserve screenshot paths, and match final review criteria accurately. Add collapsible copyable results and publish the complete 2.1.0 upgrade notes. Validation: 3189 backend tests, 326 frontend tests, 24 client tests, production build and quality gates.
This commit is contained in:
chenmingxuan committed 2026-10-27 01:13:41 +08:00
1 parent fac4ccb760
commit be6e8c2abd
79 files changed
+2263 -277

No files matched your search

+72
View File
@@ -144,6 +144,78 @@ def test_normalize_report_downgrades_vague_failures_and_fills_missing():
# --- Assembly: prompt segments x entry x content x config (§8 item 18) ---------------
@pytest.mark.parametrize("kind,when", [("verify", "on_complete"), ("assert", "at_end")])
@pytest.mark.parametrize("status", ["passed", "failed", "inconclusive"])
@pytest.mark.parametrize("bullet", ["", "- "])
def test_rendered_check_label_matches_original_without_changing_verdict(kind, when, status, bullet):
item = _ci(kind=kind, when=when, text="截图路径与回执一致")
verdict = CheckVerdict(
kind=kind,
item_text=f"{bullet}[{kind}] ({when}) {item.text}",
status=status,
evidence="截图与笔记回执",
suggestion="保留原建议",
)
normalized = _normalize_report(CheckReport(verdicts=[verdict]), [item])
assert [v.model_dump() for v in normalized.verdicts] == [
{**verdict.model_dump(), "item_text": item.text}
]
assert verdict.item_text != item.text # The original response is not rewritten.
@pytest.mark.parametrize(
"kind,text",
[
("assert", "[verify] (on_complete) 截图有效"),
("verify", "[assert] (on_complete) 截图有效"),
("verify", "[verify] (at_end) 截图有效"),
("verify", "[verify] (on_complete) 截图无效"),
("verify", "[verify] (on_complete) 截图有效。"),
],
)
def test_unmatched_label_does_not_cover_declared_check(kind, text):
item = _ci(text="截图有效")
verdict = CheckVerdict(kind=kind, item_text=text, status="passed", evidence="e")
normalized = _normalize_report(CheckReport(verdicts=[verdict]), [item])
assert normalized.verdicts[0] == verdict
assert normalized.verdicts[1].item_text == item.text
assert normalized.verdicts[1].status == "inconclusive"
def test_literal_criterion_takes_precedence_over_another_items_display_label():
plain = _ci(text="截图有效")
literal = _ci(text="[verify] (on_complete) 截图有效")
verdict = CheckVerdict(kind="verify", item_text=literal.text, status="passed", evidence="e")
normalized = _normalize_report(CheckReport(verdicts=[verdict]), [plain, literal])
assert [(v.item_text, v.status) for v in normalized.verdicts] == [
(literal.text, "passed"),
(plain.text, "inconclusive"),
]
def test_conflicting_plain_and_label_verdicts_keep_the_failure():
item = _ci(text="截图有效")
normalized = _normalize_report(
CheckReport(
verdicts=[
CheckVerdict(kind="verify", item_text=item.text, status="passed", evidence="e1"),
CheckVerdict(
kind="verify",
item_text="[verify] (on_complete) 截图有效",
status="failed",
evidence="e2",
),
]
),
[item],
)
assert [(v.item_text, v.status) for v in normalized.verdicts] == [
(item.text, "passed"),
(item.text, "failed"),
]
assert not verdicts_allow_release(normalized)
def test_prompt_segments_verify_only_has_no_assert_section():
prompts = {
"base_rules": "BASE",
@@ -139,6 +139,42 @@ def test_empty_evidence_list_does_not_turn_an_unsupported_failure_into_evidence(
assert _normalize_report(report, []).verdicts[0].status == "inconclusive"
@pytest.mark.asyncio
@pytest.mark.parametrize("structured_response", [False, True])
async def test_display_labels_match_in_text_and_structured_verdict_paths(structured_response):
from artemis.agents.checker.checker import _normalize_report
from artemis.utils.plan_grammar import CheckItem
items = [
CheckItem(kind="verify", when="on_complete", text="截图保存成功", parent_key="p"),
CheckItem(kind="verify", when="on_complete", text="路径与回执一致", parent_key="p"),
CheckItem(kind="assert", when="at_end", text="没有交互动作", parent_key=None),
]
payload = {
"verdicts": [
{
"kind": i.kind,
"item_text": f"[{i.kind}] ({i.when}) {i.text}",
"status": "passed",
"evidence": "完整工具回执",
}
for i in items
],
"test_result": _result(),
}
llm, structured = _llm(CheckReport.model_validate(payload))
answer = "已检查,待结构化输出。" if structured_response else json.dumps(payload)
report = await _structured_report(llm, [AIMessage(content=answer)], items)
normalized = _normalize_report(report, items)
assert [v.item_text for v in normalized.verdicts] == [i.text for i in items]
assert [v.status for v in normalized.verdicts] == ["passed"] * 3
assert normalized.test_result.status == "passed"
if structured_response:
structured.ainvoke.assert_awaited_once()
else:
structured.ainvoke.assert_not_awaited()
def test_nested_json_string_result_is_decoded_and_still_validated():
result = _result()
assert (
@@ -145,6 +145,19 @@ def test_render_shifted_and_occupied_evidence():
assert "now covered by 'Sign Up' button" in text
def test_unavailable_pixel_verification_does_not_claim_target_disappeared():
incident = _disappeared(
category="pixel_verification_unavailable",
reason="Failed to acquire live screenshot: device offline",
)
text = render_execution_incident(incident, STEPS)
assert "device offline" in text
assert "was NOT executed" in text
assert "Restore a working observation" in text
assert "no longer on screen" not in text
assert "Your last dispatched action" not in text
def test_render_burst_abort_and_exec_error():
burst = _disappeared(kind=KIND_EXEC_ERROR, category="general", burst_size=3, action_index=1)
burst["reason"] = "Error: tap rejected"
+112 -1
View File
@@ -135,7 +135,12 @@ async def test_completed_exploration_plan_does_not_force_an_extra_action(
result = await node._invoke_llm_loop(
Mock(), messages, [SimpleNamespace(name="save_note")], [], state
)
if finishing:
if scenario == "pro":
assert invoke.await_count == 1
assert result[0] is None
assert state.execution_report is None
assert result[3] is False
elif finishing:
assert invoke.await_count == 2
assert result[0] is None
assert final.content in result[1]
@@ -154,6 +159,112 @@ async def test_completed_exploration_plan_does_not_force_an_extra_action(
assert any(isinstance(m, ToolMessage) and m.tool_call_id == "plan" for m in messages)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("plan", "released", "finishes"),
[
("- [x] Inspect page", False, True),
("- [ ] Inspect page", False, False),
("- [!] Inspect page", False, False),
("- [x] Inspect page\n - [ ] Check input", False, False),
("- [x] Inspect page\n - [/] Check input", False, False),
("- [x] [Loop:continuous] Inspect page", False, False),
("- [x] [Loop:continuous] Inspect page", True, True),
("", False, False),
],
)
async def test_pro_completion_does_not_request_a_synthetic_action(
operator, monkeypatch, plan, released, finishes
):
node, state = operator
node._read_task_plan_text = lambda: plan
state.user_stop_requested = released
result, messages, invoke = await run_loop(operator, monkeypatch, [click()])
assert invoke.await_count == (0 if finishes else 1)
assert bool(result[0]) is not finishes
assert state.execution_report is None
assert result[3] is False
@pytest.mark.asyncio
async def test_pro_completion_on_last_tool_iteration_still_reaches_final_review(
operator, monkeypatch, tmp_path
):
from artemis.context import ExecutionSetup
from artemis.graph.graph import convergence_gate
from artemis.utils.notes import get_note_file_path
node, state = operator
node.ctx.data_engine = Mock(base_dir=str(tmp_path))
node.ctx.execution_setup = ExecutionSetup(disable_checker=False)
path = get_note_file_path(tmp_path, "task_plan")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("- [/] Inspect page\n- assert@end: Input is empty", encoding="utf-8")
async def save_note(**kwargs):
path.write_text("- [x] Inspect page\n- assert@end: Input is empty", encoding="utf-8")
return ToolMessage(content="Saved plan", tool_call_id="plan")
monkeypatch.setattr("artemis.agents.operator.operator.OPERATOR_MAX_TOOL_ITERATIONS", 1)
monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", save_note)
invoke = AsyncMock(
return_value=AIMessage(
content="Input is empty; recording completion.",
tool_calls=[{"name": "save_note", "args": {}, "id": "plan"}],
)
)
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
result = await node._invoke_llm_loop(Mock(), [], [SimpleNamespace(name="save_note")], [], state)
assert result[0] is None
assert result[3] is False
assert invoke.await_count == 1
assert convergence_gate(state, node.ctx) == "exit_settlement"
@pytest.mark.asyncio
@pytest.mark.parametrize("feedback_field", ["injected_instruction", "operator_feedback"])
async def test_completed_pro_plan_still_processes_new_feedback(
operator, monkeypatch, feedback_field
):
node, state = operator
node.ctx.data_engine = None
node._read_task_plan_text = lambda: "- [x] Inspect page"
setattr(state, feedback_field, "Review the saved evidence before finishing.")
monkeypatch.setattr(
"artemis.agents.operator.operator.invoke_tool_with_injection",
AsyncMock(return_value=ToolMessage(content="Saved evidence", tool_call_id="read")),
)
invoke = AsyncMock(
side_effect=[
AIMessage(
content="Reading evidence.",
tool_calls=[
{"name": "read_note", "args": {"key": "evidence"}, "id": "read"},
],
),
AIMessage(content="Reviewed the evidence; input is empty."),
]
)
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
messages = []
result = await node._invoke_llm_loop(
Mock(), messages, [SimpleNamespace(name="read_note")], [], state
)
assert invoke.await_count == 2
assert result[0] is None
assert result[3] is False
assert messages[-1].content == "Reviewed the evidence; input is empty."
def test_pro_completion_does_not_bypass_pending_guidance(operator, monkeypatch, tmp_path):
node, state = operator
node._read_task_plan_text = lambda: "- [x] Inspect page"
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
node.ctx.task_control = task_control.TaskControl("pro-guidance", Mock())
task_control.add_instruction("pro-guidance", "Inspect another input first.")
assert not node._pro_ready_for_settlement(state)
@pytest.mark.asyncio
async def test_final_report_is_discarded_when_guidance_arrives(operator, monkeypatch):
node, state = operator
@@ -21,6 +21,7 @@ from unittest.mock import AsyncMock
import base64
import pytest
from PIL import Image
from PIL import ImageDraw
from artemis.agents.validator import precondition_pixel as pp
from langchain_core.messages import HumanMessage, SystemMessage
@@ -28,6 +29,87 @@ from langchain_core.messages import HumanMessage, SystemMessage
_PNG = b"fake-image-bytes"
def _encode_image(image):
buffer = BytesIO()
image.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode()
@pytest.mark.asyncio
@pytest.mark.parametrize("point", [[410, 2310], [1195, 251]])
async def test_pixel_judge_sees_device_target_in_each_resized_image(monkeypatch, point):
monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt"))
completion = AsyncMock(
return_value={
"is_present": True,
"confidence": 0.99,
"target_kind": "control",
"target_matches": True,
}
)
monkeypatch.setattr(pp, "acomplete_structured", completion)
ctx = MagicMock()
ctx.device.device_width, ctx.device.device_height = 1280, 2856
reference = _encode_image(Image.new("RGB", (918, 2048), "white"))
session = AsyncMock()
session.screenshot_b64.return_value = _encode_image(Image.new("RGB", (640, 1428), "white"))
action = {"action": "tap", "coordinates": point.copy(), "target_text": "Input"}
verdict = await pp.validate_action_precondition_pixel(
ctx,
session,
action,
reference,
get_llm_fn=lambda: None,
)
assert verdict[0] is True
assert action["coordinates"] == point # Dispatch still uses native device pixels.
content = completion.await_args.args[1][1].content
images = [item["image_url"]["url"] for item in content if item["type"] == "image_url"]
assert len(images) == 2
for url in images:
with Image.open(BytesIO(base64.b64decode(url.split(",", 1)[1]))) as marked:
x = round(point[0] * marked.width / 1280)
y = round(point[1] * marked.height / 2856)
red, green, blue = marked.getpixel((x, y))
assert red > 180 and green < 60 and blue < 60
wrong = (min(point[0], marked.width - 1), min(point[1], marked.height - 1))
assert min(marked.getpixel(wrong)) > 200
@pytest.mark.asyncio
async def test_negative_cache_tracks_scaled_target_neighborhood(monkeypatch):
monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt"))
attempt = AsyncMock(
return_value=(False, pp.ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, "missing")
)
monkeypatch.setattr(pp, "_run_attempt", attempt)
ctx = MagicMock()
ctx.device.device_width, ctx.device.device_height = 1280, 2856
image = Image.new("RGB", (918, 2048), "white")
frames = [_encode_image(image)]
ImageDraw.Draw(image).rectangle((260, 1620, 330, 1700), fill="blue")
frames.append(_encode_image(image))
ImageDraw.Draw(image).rectangle((0, 0, 20, 20), fill="black")
frames.append(_encode_image(image))
for frame in frames:
await pp.validate_action_precondition_pixel(
ctx,
AsyncMock(),
{"action": "tap", "coordinates": [410, 2310], "target_description": "Input"},
frame,
get_llm_fn=lambda: None,
)
assert attempt.await_count == 2
def test_changed_orientation_does_not_silently_move_marker():
image = base64.b64decode(_encode_image(Image.new("RGB", (2048, 918))))
with pytest.raises(ValueError, match="geometry changed"):
pp._annotate_target(image, [410, 2310], (1280, 2856))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"response",
@@ -80,6 +162,56 @@ async def test_renaming_rejected_point_cannot_dispatch_again(monkeypatch):
assert attempt.await_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"screenshot_error", [RuntimeError("device offline"), TimeoutError("capture timeout")]
)
async def test_screenshot_failure_is_unavailable_and_preserves_cause(monkeypatch, screenshot_error):
monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt"))
monkeypatch.setattr(pp.visualization, "crop_and_annotate_target", lambda *a, **k: _PNG)
monkeypatch.setattr(pp, "_RETRY_DELAY_SECONDS", 0)
complete = AsyncMock()
monkeypatch.setattr(pp, "acomplete_structured", complete)
session = AsyncMock()
session.screenshot_b64.side_effect = screenshot_error
ctx = MagicMock()
ctx.rejected_pixel_targets = {}
action = {"action": "tap", "coordinates": [200, 300], "target_text": "Projects"}
for _ in range(2):
passed, category, reason = await pp.validate_action_precondition_pixel(
ctx, session, action, base64.b64encode(_PNG).decode(), get_llm_fn=lambda: None
)
assert passed is False
assert category == pp.ValidationErrorCategory.PIXEL_VERIFICATION_UNAVAILABLE
assert str(screenshot_error) in reason
assert "action was not executed" in reason
assert session.screenshot_b64.await_count == 2 * pp._MAX_ATTEMPTS
assert not ctx.rejected_pixel_targets
complete.assert_not_awaited()
@pytest.mark.asyncio
async def test_model_failure_is_unavailable_without_losing_parse_error(monkeypatch):
monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt"))
monkeypatch.setattr(pp.visualization, "crop_and_annotate_target", lambda *a, **k: _PNG)
monkeypatch.setattr(pp, "_RETRY_DELAY_SECONDS", 0)
monkeypatch.setattr(
pp, "acomplete_structured", AsyncMock(side_effect=ValueError("invalid response"))
)
session = AsyncMock()
session.screenshot_b64.return_value = base64.b64encode(_PNG).decode()
passed, category, reason = await pp.validate_action_precondition_pixel(
MagicMock(),
session,
{"action": "tap", "coordinates": [200, 300], "target_text": "Projects"},
base64.b64encode(_PNG).decode(),
get_llm_fn=lambda: None,
)
assert passed is False
assert category == pp.ValidationErrorCategory.PIXEL_VERIFICATION_UNAVAILABLE
assert "invalid response" in reason
def _texts(messages: list) -> list[str]:
human = messages[1]
return [part["text"] for part in human.content if part["type"] == "text"]
+2 -2
View File
@@ -638,7 +638,7 @@ async def test_validator_pixel_validation_success(mock_mcp, mock_context, temp_s
# Mock crop and draw helpers to return dummy bytes
with (
patch(
"artemis.utils.visualization.crop_and_annotate_target",
"artemis.agents.validator.precondition_pixel._annotate_target",
return_value=b"dummy_bytes",
),
patch("artemis.agents.validator.validator.get_llm", return_value=mock_llm),
@@ -680,7 +680,7 @@ async def test_validator_pixel_validation_failure(mock_mcp, mock_context, temp_s
with (
patch(
"artemis.utils.visualization.crop_and_annotate_target",
"artemis.agents.validator.precondition_pixel._annotate_target",
return_value=b"dummy_bytes",
),
patch("artemis.agents.validator.validator.get_llm", return_value=mock_llm),