feat: prepare 2.2.0 with UI and execution fixes

Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
This commit is contained in:
chenmingxuan committed 2026-10-26 00:44:13 +08:00
1 parent 83bcfd54d5
commit fac4ccb760
146 files changed
+5689 -2033

No files matched your search

@@ -8,11 +8,13 @@ action), and report their status explicitly instead of sniffing the text for
the word "Error".
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pytest
from artemis.mcp.action_executor import AGENT_TOOL_NAMES, McpActionExecutor
from artemis.mcp.action_types import ActionCode, ActionResult
from artemis.tools.explorer_tool import ExplorerCandidate, ExplorerOutcome
@@ -48,6 +50,61 @@ def test_helper_tools_are_not_device_actions():
assert not (AGENT_TOOL_NAMES & executor.action_tool_names)
@pytest.mark.asyncio
@pytest.mark.parametrize("extension", [False, True])
@pytest.mark.parametrize("ok", [False, True])
async def test_authorized_actions_dispatch_without_secondary_ai_review(extension, ok):
from mcp.types import CallToolResult, TextContent
executor = _make_executor(agent_name="flash")
state = _state()
state.initial_goal = (
"仅发送一次指定的问题。等待真实审批表单,Approve once (not remember rule)。"
"不要普通聊天代答或重复提交;不向该测试另发消息、不停止任务,也不操作其他会话。"
)
name = "submit_form" if extension else "click"
args = (
{"window_id": 123}
if extension
else {
"target": [500, 500],
"target_description": "Approve",
}
)
executor.actuator.extensions.return_value = [SimpleNamespace(name="submit_form")]
session = SimpleNamespace(
call=AsyncMock(
return_value=ActionResult(
ok=ok,
code=ActionCode.OK if ok else ActionCode.DEVICE_ERROR,
action=name,
message="tool outcome",
)
),
call_raw=AsyncMock(
return_value=CallToolResult(
isError=not ok, content=[TextContent(type="text", text="tool outcome")]
)
),
)
executor._session_or_start = AsyncMock(return_value=session)
executor._observe_after_action = AsyncMock(return_value=(SimpleNamespace(ok=False), None))
with patch(
"artemis.services.llm.get_llm", side_effect=AssertionError("Unexpected AI action review")
) as get_llm:
result = await executor.execute(name, args, "action-1", state)
get_llm.assert_not_called()
wire_args = args if extension else {"target": [500, 500], "times": 1, "delay_ms": 100}
dispatched = session.call_raw if extension else session.call
unused = session.call if extension else session.call_raw
dispatched.assert_awaited_once_with(name, wire_args)
unused.assert_not_awaited()
assert result.status == ("success" if ok else "error")
assert result.text_summary == "tool outcome"
@pytest.mark.asyncio
async def test_flash_saves_and_reads_text_with_verified_receipt_without_device_actions(tmp_path):
import hashlib
@@ -27,6 +27,7 @@ from unittest.mock import Mock
import pytest
from artemis.mcp.action_executor import McpActionExecutor, _ArgError
from artemis.utils.action_target import observation_anchor
def _make_executor(width=1080, height=2400):
@@ -90,6 +91,8 @@ def test_click_index_resolves_to_the_elements_center_and_records_observed_fields
"target_bounds": [37, 480, 963, 560],
"target_resource_id": "android:id/title",
"target_class": "android.widget.TextView",
"target_index": 1,
**observation_anchor(_indexed_state()),
}
assert "target_description" not in recorded
+28 -3
View File
@@ -384,7 +384,10 @@ def test_mobile_manage_task_inject_instruction(temp_trace_env):
ok_res = mobile_manage_task(
action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly"
)
assert "current model step interrupted" in ok_res["message"]
assert "next safe checkpoint" in ok_res["message"]
assert "not guaranteed" in ok_res["message"]
assert ok_res["guidance"]["id"]
assert ok_res["guidance"]["status"] == "pending"
@pytest.mark.asyncio
@@ -476,11 +479,33 @@ async def test_mobile_inspect_trace_includes_device_serial(temp_trace_env):
"CREATE TABLE traces (trace_id TEXT PRIMARY KEY, step_id TEXT, session_id TEXT, type TEXT, name TEXT, status TEXT, timestamp REAL, duration REAL, payload TEXT)"
)
conn.execute("INSERT INTO sessions VALUES (?, ?, ?)", (trace_id, 1000.0, None))
conn.executemany(
"INSERT INTO steps (step_id,session_id,step_number,timestamp) VALUES (?,?,?,?)",
[(f"actual-{n}", trace_id, n, 1000 + n) for n in range(1, 4)],
)
conn.commit()
conn.close()
res_summary = await mobile_inspect_trace(action="view_summary", trace_id=trace_id)
assert "**Device Serial:** `pixel-target-999`" in res_summary
res_summary = await mobile_inspect_trace(action="view_summary", trace_id=trace_id, limit=2)
assert "**Device Serial:** `pixel-target-999`" in res_summary.content[0].text
assert res_summary.content[0].text.startswith("# ARTEMIS Flash Execution Summary")
assert res_summary.structuredContent["steps"] == {
"items": [
{"step_id": "actual-1", "step_number": 1},
{"step_id": "actual-2", "step_number": 2},
],
"total": 3,
"offset": 0,
"limit": 2,
"next_offset": 2,
}
last_page = await mobile_inspect_trace(
action="view_summary", trace_id=trace_id, offset=2, limit=2
)
assert last_page.structuredContent["steps"]["next_offset"] is None
assert last_page.structuredContent["steps"]["items"] == [
{"step_id": "actual-3", "step_number": 3}
]
def test_trace_store_device_serial(temp_trace_env):
+58
View File
@@ -0,0 +1,58 @@
"""Protocol regressions without starting ARTEMIS or invoking a device."""
import json
from typing import Any
from mcp.server.fastmcp.exceptions import ToolError
from mcp.shared.memory import create_connected_server_and_client_session
import pytest
from apps.admin_console.schemas.task_schema import RunRequest
from artemis.mcp.tool_server import ArtemisFastMCP
@pytest.mark.asyncio
async def test_unknown_arguments_are_rejected_before_dispatch():
mcp = ArtemisFastMCP("strict-input-test", strict_inputs=True)
dispatched = []
@mcp.tool()
def submit(task_desc: str) -> dict:
dispatched.append(task_desc)
return {"ok": True}
with pytest.raises(ToolError, match="未知参数.*goal"):
await mcp.call_tool("submit", {"task_desc": "original", "goal": "silently replace?"})
assert dispatched == []
assert (await mcp.list_tools())[0].inputSchema["additionalProperties"] is False
await mcp.call_tool("submit", {"task_desc": "accepted"})
assert dispatched == ["accepted"]
@pytest.mark.asyncio
async def test_legacy_json_has_structured_content_without_changing_text():
mcp = ArtemisFastMCP("structured-test", strict_inputs=True)
@mcp.tool()
def legacy() -> Any:
return {"items": [{"step_id": "actual-step"}], "offset": 0, "total": 1, "next_offset": None}
content, structured = await mcp.call_tool("legacy", {})
assert json.loads(content[0].text) == structured
assert structured["items"][0]["step_id"] == "actual-step"
async with create_connected_server_and_client_session(mcp) as session:
wire = await session.call_tool("legacy", {})
assert wire.structuredContent == structured
assert json.loads(wire.content[0].text) == structured
rejected = await session.call_tool("legacy", {"goal": "unknown field"})
assert rejected.isError is True
def test_http_submission_rejects_unknown_and_misspelled_fields():
from pydantic import ValidationError
with pytest.raises(ValidationError, match="extra_forbidden"):
RunRequest.model_validate({"goal": "original", "task_desc": "wrong contract"})
assert (
RunRequest.model_validate({"goal": "accepted", "runtime_overrides": {}}).goal == "accepted"
)