feat: release 1.1.0 with responsive task control

This commit is contained in:
chenmingxuan committed 2026-09-24 03:31:17 +08:00
1 parent 057f0c3050
commit bfcd828fa8
37 files changed
+1601 -151

No files matched your search

@@ -6,6 +6,7 @@ import time
from unittest.mock import AsyncMock
import zipfile
from docx import Document
from fastapi import FastAPI
from fastapi.testclient import TestClient
from openpyxl import load_workbook
@@ -124,6 +125,39 @@ def test_preferences_and_required_device(client):
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
def test_batch_delete_retains_executed_task_and_prevents_request_replay(client, isolated):
created = client.post(
"/api/automation/batches", json=payload(request_id="delete-example")
).json()
first, second = isolated.jobs(created["id"])
isolated.save_job({**first, "status": "completed"})
response = client.delete(f"/api/automation/batches/{created['id']}")
assert response.status_code == 200
assert client.get(f"/api/automation/batches/{created['id']}").status_code == 404
assert isolated.job(first["session_id"])["case"]["name"] == "用例甲"
assert isolated.job(second["session_id"]) is None
assert reports.session_report(first["session_id"])["name"] == "用例甲"
assert (
client.post(
"/api/automation/batches", json=payload(request_id="delete-example")
).status_code
== 409
)
@pytest.mark.parametrize("status", ["queued", "running", "task_paused", "resuming", "finalizing"])
def test_batch_delete_rejects_active_jobs(client, isolated, status):
batch = service.create_batch(payload())
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": status})
response = client.delete(f"/api/automation/batches/{batch['id']}")
assert response.status_code == 409
assert isolated.get("batch", batch["id"]) is not None
assert len(isolated.jobs(batch["id"])) == 2
def test_restart_never_repeats_started_work(isolated):
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
@@ -243,6 +277,50 @@ def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
assert "未验证".encode() in doc.read("word/document.xml")
def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolated):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
isolated.save_job({**job, "status": "failed"})
folder = reports.TRACES_PATH / job["session_id"]
(folder / "notes").mkdir(parents=True)
(folder / "stdout.log").write_text(
"❌ [run] Error running automation: 模型连续 3 次未提供可执行的工具调用\n",
encoding="utf-8",
)
(folder / "notes" / "output.md").write_text(
"## 结果\n- **网络**正常\n\n| 分类 | 状态 |\n| --- | --- |\n| 系统 | 未确认 |",
encoding="utf-8",
)
report = reports.session_report(job["session_id"])
assert "连续 3 次" in report["error"]
assert report["verdict"] == "未验证(任务异常终止)"
assert "abc123secret" not in reports.failure_reason(
folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
)
paths = reports.export_session(job["session_id"])
doc = Document(next(path for path in paths if path.suffix == ".docx"))
assert doc.styles["Normal"].font.name == "Microsoft YaHei"
assert any("异常原因" in paragraph.text for paragraph in doc.paragraphs)
assert any(
run.bold and "网络" in run.text for paragraph in doc.paragraphs for run in paragraph.runs
)
assert any(
cell.text == "分类" for table in doc.tables for row in table.rows for cell in row.cells
)
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
assert "连续 3 次" in wb["任务结果"]["B15"].value
assert wb["结论"]["B2"].value == "结果"
assert wb["结果表1"]["A2"].value == "系统"
assert wb["执行证据"]["A1"].font.name == "微软雅黑"
assert wb["执行证据"].page_setup.orientation == "landscape"
assert reports.execution_result_label({"status": "dispatched"}) == "动作已发出(需核对截图)"
assert reports.conclusion_rows("## 结果\n- **通过**检查\n```txt\n| 不是 | 表格 |\n```") == [
("类型", "结论与发现"),
("小节", "结果"),
("要点", "通过检查"),
("代码", "| 不是 | 表格 |"),
]
def test_task_material_zip_contains_ordered_evidence_and_reports(
client, isolated, tmp_path, monkeypatch
):
+42
View File
@@ -24,6 +24,8 @@ import pytest
from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode
from artemis.context import ArtemisContext
from artemis.core.tool_failure import ToolFailure
from artemis.tools.mobile.read_logs import ReadLogsArgs
from artemis.tools.mobile.search_logs import SearchLogsArgs
_STATUS_CASES = [
pytest.param(ToolFailure("Error searching logs: device gone"), "error", id="tool_failure"),
@@ -76,3 +78,43 @@ async def test_agent_loop_tool_message_status_is_structural(result, expected_sta
assert tool_msgs[0].tool_call_id == "c1"
assert tool_msgs[0].status == expected_status
assert tool_msgs[0].content == str(result)
@pytest.mark.asyncio
async def test_log_cache_normalizes_numeric_tool_arguments_before_running_tools():
node = LogAnalyzerNode(ctx=MagicMock(spec=ArtemisContext))
read_calls = []
async def read_raw_logs(lines=200, **kwargs):
read_calls.append(lines)
return "before\nE/Settings: failed\nafter"
async def unused_search(**kwargs):
pytest.fail("Cached raw logs should be searched locally")
read_tool = node._wrap_with_caching(
StructuredTool.from_function(
coroutine=read_raw_logs, name="read_logs", description="read", args_schema=ReadLogsArgs
)
)
search_tool = node._wrap_with_caching(
StructuredTool.from_function(
coroutine=unused_search,
name="search_logs",
description="search",
args_schema=SearchLogsArgs,
)
)
assert await read_tool.coroutine(lines="200") == "before\nE/Settings: failed\nafter"
assert await read_tool.coroutine(lines=200) == "before\nE/Settings: failed\nafter"
assert await read_tool.coroutine(lines=None) == "before\nE/Settings: failed\nafter"
assert read_calls == [200]
result = await search_tool.coroutine(keyword="Settings", lines="200", context_lines="1")
assert "before" in result and "after" in result
assert "E/Settings: failed" in result
assert "before" not in await search_tool.coroutine(
keyword="Settings", lines="200", context_lines=None
)
assert isinstance(await search_tool.coroutine(keyword="Settings", lines="many"), ToolFailure)
+68 -1
View File
@@ -1,5 +1,6 @@
"""Regression coverage for quoted click targets and prose-only recovery loops."""
"""Regression coverage for operator recovery and task pause handling."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -8,6 +9,7 @@ import pytest
from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError
from artemis.config.settings import settings
from artemis.runtime import task_control
@pytest.fixture
@@ -135,3 +137,68 @@ async def test_iteration_cap_cannot_restart_no_progress_next_turn(operator, monk
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"]
+9
View File
@@ -60,6 +60,15 @@ async def test_running_batch_start_is_idempotent(monkeypatch):
assert request.call_args.args[0] == "GET"
@pytest.mark.asyncio
async def test_delete_batch_uses_the_same_backend_as_the_web_ui(monkeypatch):
request = AsyncMock(return_value={"batch_id": "batch", "status": "deleted"})
monkeypatch.setattr(tools, "_request", request)
assert (await tools.mobile_batch("delete", batch_id="batch"))["status"] == "deleted"
request.assert_awaited_once_with("DELETE", "/api/automation/batches/batch")
@pytest.mark.asyncio
async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path)
+5 -3
View File
@@ -299,11 +299,10 @@ def test_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch)
(trace_id, '{"device_id":"test-device"}'),
)
assert (
mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
)
assert mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
assert mobile_manage_task(action="pause", trace_id=trace_id)["status"] == "pausing"
assert (Path(temp_trace_env) / trace_id / "pause.request").exists()
assert mobile_manage_task(action="status", trace_id=trace_id)["status"] == "pausing"
control._write_atomic(
control.control_dir(trace_id) / "task_control_state.json", {"status": "task_paused"}
)
@@ -316,6 +315,9 @@ def test_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch)
control.consume_instructions(control.control_dir(trace_id))["instruction"]
== "先确认当前画面"
)
guidance = mobile_manage_task(action="guidance_history", trace_id=trace_id)
assert guidance["instructions"][0]["status"] == "consumed"
assert guidance["instructions"][0]["instruction"] == "先确认当前画面"
def test_mobile_manage_task_status_and_stop(temp_trace_env):
+98
View File
@@ -52,3 +52,101 @@ async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_pa
async def _event(name, events):
events.append(name)
@pytest.mark.asyncio
async def test_pause_interrupts_long_helper_and_discards_stale_result(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
events = []
started = asyncio.Event()
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)
control = task_control.TaskControl("task-123", Lock("device"))
async def diagnostic():
started.set()
try:
await asyncio.Event().wait()
finally:
events.append("diagnostic_stopped")
result = asyncio.create_task(
control.run_interruptibly(
diagnostic(),
on_pause=lambda: _event("capture_stopped", events),
on_resume=lambda: _event("capture_started", events),
)
)
await asyncio.wait_for(started.wait(), 1)
task_control.request_pause("task-123")
for _ in range(30):
if task_control.read_state("task-123").get("status") == "task_paused":
break
await asyncio.sleep(0.01)
assert task_control.read_state("task-123")["status"] == "task_paused"
assert events == ["diagnostic_stopped", "capture_stopped", "released"]
task_control.request_resume("task-123")
assert await asyncio.wait_for(result, 2) == (True, None)
assert events[-2:] == ["acquired", "capture_started"]
@pytest.mark.asyncio
async def test_interruptible_call_returns_normal_result_without_pause(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
control = task_control.TaskControl("task-123", object())
async def model_call():
return "current result"
assert await control.run_interruptibly(model_call()) == (False, "current result")
def test_guidance_history_distinguishes_submission_from_runner_consumption(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
first = task_control.add_instruction("task-123", "从快捷设置进入", source="web")
second = task_control.add_instruction("task-123", "不要更改设置", source="mcp")
assert [item["status"] for item in task_control.list_instructions("task-123")] == [
"pending",
"pending",
]
consumed = task_control.consume_instructions(task_control.control_dir("task-123"))
assert consumed["instruction"] == "从快捷设置进入\n\n不要更改设置"
assert task_control.consume_instructions(task_control.control_dir("task-123")) is None
history = task_control.list_instructions("task-123")
assert [item["id"] for item in history] == [first["id"], second["id"]]
assert all(item["status"] == "consumed" and item["consumed_at"] for item in history)
def test_guidance_history_endpoint_returns_pending_and_consumed(tmp_path, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from apps.admin_console.routers.tasks import router
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
task_control.add_instruction("task-123", "从快捷设置进入")
app = FastAPI()
app.include_router(router)
client = TestClient(app)
response = client.get("/api/tasks/task-123/guidance")
assert response.status_code == 200
assert response.json()[0]["status"] == "pending"
task_control.consume_instructions(task_control.control_dir("task-123"))
response = client.get("/api/tasks/task-123/guidance")
assert response.json()[0]["status"] == "consumed"