"""Regression coverage for quoted click targets and prose-only recovery loops.""" 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 @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 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) 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 @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")]) 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 == 3 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