Files
Artemis-Eapil/tests/unit/agents/test_operator_recovery.py
T
chenmingxuan be6e8c2abd 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.
2026-10-27 01:13:41 +08:00

451 lines
18 KiB
Python

"""Regression coverage for operator recovery and task pause handling."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
import pytest
from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError
from artemis.config.settings import settings
from artemis.runtime import task_control
@pytest.fixture
def operator(monkeypatch):
node = object.__new__(OperatorNode)
node.ctx = SimpleNamespace(transcript_ledger=None)
node._available_device_actions = lambda: {"click", "long_press", "input_text"}
node._plan_changed_this_turn = lambda: True
node._max_burst_actions = lambda: 4
state = SimpleNamespace(
operator_raw_data={"width": 1280, "height": 2856},
indexed_elements=[{"center": [310, 2610], "text": "model menu", "resource_id": "model"}]
* 13,
open_incident=None,
)
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", 3)
return node, state
@pytest.mark.parametrize("name", ["click", "long_press", "input_text"])
@pytest.mark.parametrize("target", ["13", "[240, 914]", "240, 914"])
def test_quoted_targets_keep_same_action_and_grounding(operator, name, target):
node, state = operator
expected_target = 13 if target == "13" else [240, 914]
args = {"target": target, "target_description": "model menu", "text": "test"}
actions, error = node._translate_and_validate_tool({"name": name, "args": args}, state)
expected, _ = node._translate_and_validate_tool(
{"name": name, "args": {**args, "target": expected_target}}, state
)
assert error is None
assert actions == expected
if target == "13":
assert actions[0]["target_resource_id"] == "model"
else:
assert actions[0]["target_description"] == "model menu"
@pytest.mark.parametrize("target", ["0", "14", "model menu", True, "true"])
def test_invalid_index_is_not_guessed_or_dispatched(operator, target):
node, state = operator
actions, error = node._translate_and_validate_tool(
{"name": "click", "args": {"target": target}}, state
)
assert not actions
assert error
def test_quoted_coordinates_still_need_description(operator):
node, state = operator
actions, error = node._translate_and_validate_tool(
{"name": "click", "args": {"target": "[240, 914]"}}, state
)
assert not actions
assert "target_description" in error
def click(target="13"):
return AIMessage(
content="Open the model menu.",
tool_calls=[{"name": "click", "args": {"target": target}, "id": "call_click"}],
)
async def run_loop(operator, monkeypatch, responses):
node, state = operator
invoke = AsyncMock(side_effect=responses)
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
messages = []
result = await node._invoke_llm_loop(Mock(), messages, [], [], state)
return result, messages, invoke
@pytest.mark.asyncio
async def test_prose_retry_keeps_context_and_recovers_once(operator, monkeypatch):
prose = AIMessage(content="I will click the model menu.")
result, messages, invoke = await run_loop(operator, monkeypatch, [prose, click()])
assert invoke.await_count == 2
assert len(result[0]) == 1
assert result[0][0]["coordinates"] == [310, 2610]
assert prose in messages
assert any(isinstance(m, SystemMessage) and "No tool call" in m.content for m in messages)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"scenario", ["complete", "pending", "unfinished", "unchanged", "failed", "continuous", "pro"]
)
async def test_completed_exploration_plan_does_not_force_an_extra_action(
operator, monkeypatch, scenario
):
node, state = operator
node.ctx.exploration_id = None if scenario == "pro" else "exploration"
node.ctx.data_engine = None
plan = {"text": "- [ ] Return home"}
if scenario == "unchanged":
plan["text"] = "- [x] Return home"
node._read_task_plan_text = lambda: plan["text"]
monkeypatch.setattr("artemis.exploration.runtime.publish_response", Mock())
monkeypatch.setattr(
"artemis.exploration.runtime.needs_exploration_turn", lambda ctx: scenario == "pending"
)
async def save_note(**kwargs):
plan["text"] = "- [ ] Return home" if scenario == "unfinished" else "- [x] Return home"
if scenario == "continuous":
plan["text"] += " [Loop:continuous]"
return ToolMessage(
content="Saved plan",
tool_call_id="plan",
status="error" if scenario == "failed" else "success",
)
monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", save_note)
response = AIMessage(
content="Confirmed home; recorded evidence.",
tool_calls=[{"name": "save_note", "args": {}, "id": "plan"}],
)
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 == "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]
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 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
@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
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")])
assert invoke.await_count == 2
assert len(result[0]) == 1
assert result[0][0]["coordinates"] == [310, 2610]
outputs = [m for m in messages if isinstance(m, ToolMessage)]
assert [m.status for m in outputs] == ["error", "success"]
@pytest.mark.asyncio
@pytest.mark.parametrize("limit", [1, 3])
async def test_empty_responses_fail_at_configured_limit(operator, monkeypatch, limit):
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", limit)
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3)
fallback = Mock()
get_fallback = Mock(return_value=fallback)
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
node, state = operator
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
await node._invoke_llm_loop(Mock(), [], [], [], state)
assert invoke.await_count == limit + 3
get_fallback.assert_called_once_with(ctx=node.ctx, name="operator", use_fallback=True)
assert invoke.call_args_list[-1].args[0] is fallback.bind_tools.return_value
@pytest.mark.asyncio
async def test_operator_fallback_is_scoped_to_current_step(operator, monkeypatch):
node, state = operator
primary = Mock()
fallback = Mock()
get_fallback = Mock(return_value=fallback)
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
invoke = AsyncMock(side_effect=[AIMessage(content="no tool")] * 3 + [click(), click()])
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
first = await node._invoke_llm_loop(primary, [], [], [], state)
second = await node._invoke_llm_loop(primary, [], [], [], state)
assert first[0] and second[0]
assert get_fallback.call_count == 1
assert [call.args[0] for call in invoke.call_args_list] == [
primary.bind_tools.return_value,
primary.bind_tools.return_value,
primary.bind_tools.return_value,
fallback.bind_tools.return_value,
primary.bind_tools.return_value,
]
@pytest.mark.asyncio
async def test_invalid_calls_and_prose_share_bounded_recovery(operator, monkeypatch):
invoke = AsyncMock(
side_effect=[click("missing"), AIMessage(content="Retry."), click("14")] + [click("14")] * 3
)
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", Mock(return_value=Mock()))
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
node, state = operator
messages = []
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
await node._invoke_llm_loop(Mock(), messages, [], [], state)
assert invoke.await_count == 6
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages)
assert not any(isinstance(m, ToolMessage) and m.content == "Action Recorded" for m in messages)
@pytest.mark.asyncio
async def test_iteration_cap_cannot_restart_no_progress_next_turn(operator, monkeypatch):
monkeypatch.setattr("artemis.agents.operator.operator.OPERATOR_MAX_TOOL_ITERATIONS", 2)
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
node, state = operator
with pytest.raises(OperatorNoProgressError, match="预算内未恢复"):
await node._invoke_llm_loop(Mock(), [], [], [], state)
assert invoke.await_count == 2
@pytest.mark.asyncio
async def test_pause_during_diagnosis_returns_to_fresh_observation(operator, monkeypatch, tmp_path):
node, state = operator
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
started = asyncio.Event()
cancelled = asyncio.Event()
events = []
class Lock:
def __init__(self, device_id, **kwargs):
self.device_id = device_id
self.description = "test"
self.max_concurrency = None
self.concurrency_mode = "per_device"
self.ingress = "web"
self.lock_scope = "test"
def release(self):
events.append("released")
def acquire(self, cancel_event=None):
events.append("acquired")
monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
node.ctx.data_engine = None
node.ctx.task_control = task_control.TaskControl("pro-task", Lock("device"))
node.ctx.on_task_pause = AsyncMock()
node.ctx.on_task_resume = AsyncMock()
async def diagnose(**kwargs):
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", diagnose)
response = AIMessage(
content="Inspect the failure",
tool_calls=[
{"name": "ask_diagnoser", "args": {"query": "why"}, "id": "diagnosis"},
{"name": "click", "args": {"target": 13}, "id": "tap"},
],
)
invoke = AsyncMock(return_value=response)
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
tool = SimpleNamespace(name="ask_diagnoser")
messages = []
execution = asyncio.create_task(node._invoke_llm_loop(Mock(), messages, [tool], [], state))
await asyncio.wait_for(started.wait(), 1)
task_control.request_pause("pro-task")
for _ in range(40):
if task_control.read_state("pro-task").get("status") == "task_paused":
break
await asyncio.sleep(0.01)
assert task_control.read_state("pro-task")["status"] == "task_paused"
assert cancelled.is_set()
assert events == ["released"]
task_control.request_resume("pro-task")
assert (await asyncio.wait_for(execution, 2))[0] is None
assert events == ["released", "acquired"]
assert invoke.await_count == 1
assert [m.status for m in messages if isinstance(m, ToolMessage)] == ["error", "error"]