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:
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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in new issue
Block a user