feat: release 1.1.0 with responsive task control
This commit is contained in:
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
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user