Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bf59897bb |
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
|
||||
|
||||
**2.7.0**:新增 Responses WebSocket 增量传输,复用连接减少多轮任务的重复上传;默认启用,并将旧的全局 HTTP 配置迁移一次,之后仍可手动切回 HTTP。见 [2.7.0 发行说明](docs/releases/2.7.0.md)。包含 [2.6.0 的 Android 原生控件与多指手势](docs/releases/2.6.0.md)、[2.5.0 的动作序列与缓存统计](docs/releases/2.5.0.md)及 [2.4.5 的插件开发 SDK](docs/releases/2.4.5.md);SDK 与插件继续独立版本化,升级主程序不会自动更新或授权插件。
|
||||
**2.7.1**:统一任务操作与录屏使用的 ADB,修复模型空结果引起的应用查找误报,并在任务资料 ZIP 中保留运行环境诊断和批次原暂停原因。见 [2.7.1 发行说明](docs/releases/2.7.1.md)。继续包含 [2.7.0 的 Responses WebSocket 增量传输](docs/releases/2.7.0.md)、[2.6.0 的 Android 原生控件与多指手势](docs/releases/2.6.0.md)、[2.5.0 的动作序列与缓存统计](docs/releases/2.5.0.md)及 [2.4.5 的插件开发 SDK](docs/releases/2.4.5.md);SDK 与插件继续独立版本化,升级主程序不会自动更新或授权插件。
|
||||
|
||||
## 主要功能
|
||||
|
||||
|
||||
@@ -123,9 +123,18 @@ def recover_batches():
|
||||
if batch["status"] in {"running", "pausing", "paused"}:
|
||||
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
|
||||
continue
|
||||
recovery_reason = "服务已重启;请检查中断任务,点击继续执行未开始任务"
|
||||
batch.setdefault("recovery_events", []).append(
|
||||
{
|
||||
"message": recovery_reason,
|
||||
"recovered_at": time.time(),
|
||||
"previous_reason": batch.get("reason"),
|
||||
"previous_pause_source": batch.get("pause_source"),
|
||||
}
|
||||
)
|
||||
batch.update(
|
||||
status="paused",
|
||||
reason="服务已重启;请检查中断任务,点击继续执行未开始任务",
|
||||
reason=batch.get("reason") or recovery_reason,
|
||||
pause_source="recovery",
|
||||
)
|
||||
for job in store.jobs(batch["id"]):
|
||||
@@ -330,7 +339,7 @@ async def tick_batches():
|
||||
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
|
||||
)
|
||||
if rejection:
|
||||
batch.update(status="paused", reason=str(rejection))
|
||||
batch.update(status="paused", reason=str(rejection), pause_source="device")
|
||||
store.put("batch", batch["id"], batch)
|
||||
continue
|
||||
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
|
||||
|
||||
@@ -36,7 +36,7 @@ from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
|
||||
from apps.admin_console.services.case_review import reviewed_result
|
||||
|
||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-3"
|
||||
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-4"
|
||||
|
||||
|
||||
def batch_archive_signature(batch_id: str | None = None) -> str:
|
||||
@@ -197,6 +197,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
|
||||
identifier(session_id)
|
||||
meta = store.job(session_id) or {}
|
||||
batch = store.get("batch", meta["batch_id"]) if meta.get("batch_id") else None
|
||||
repo = SessionRepository(db_path) if db_path else SessionRepository()
|
||||
session = repo.get_session_by_id(session_id) or {}
|
||||
if not session and not meta:
|
||||
@@ -270,6 +271,15 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
"original_case": case.get("original_case"),
|
||||
"task_config": case.get("task_config") or {},
|
||||
"task_extensions": task_extensions.report_sections(session_id, store),
|
||||
"runtime_diagnostics": read_json(directory / "runtime_diagnostics.json"),
|
||||
"batch_diagnostics": (
|
||||
{
|
||||
key: batch.get(key)
|
||||
for key in ("id", "status", "reason", "pause_source", "recovery_events")
|
||||
}
|
||||
if batch
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -1081,6 +1091,17 @@ def export_session_media(session_id: str, kind: str, *, report: dict | None = No
|
||||
)
|
||||
if kind == "all":
|
||||
archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}")
|
||||
archive.writestr(
|
||||
prefix + "运行环境诊断.json",
|
||||
json.dumps(
|
||||
{
|
||||
"runtime": report.get("runtime_diagnostics"),
|
||||
"batch": report.get("batch_diagnostics"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
)
|
||||
archive.writestr(
|
||||
prefix + "证据索引.json",
|
||||
json.dumps(
|
||||
@@ -1202,6 +1223,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
||||
compress_type=compression(source),
|
||||
)
|
||||
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
|
||||
archive.writestr(
|
||||
f"{prefix}/运行环境诊断.json",
|
||||
json.dumps(
|
||||
{
|
||||
"runtime": report.get("runtime_diagnostics"),
|
||||
"batch": report.get("batch_diagnostics"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
)
|
||||
archive.writestr(
|
||||
f"{prefix}/证据索引.json",
|
||||
json.dumps(
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.2.4",
|
||||
"@angular/common": "^22.2.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
@@ -23,11 +23,15 @@ from artemis.context import ArtemisContext
|
||||
from artemis.data_engine.trace import trace
|
||||
from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback
|
||||
from artemis.utils.logger import get_logger
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HopperError(RuntimeError):
|
||||
"""Extraction failed; this says nothing about whether the requested data exists."""
|
||||
|
||||
|
||||
class _CyFunctionDetectorMeta(type):
|
||||
def __instancecheck__(self, instance):
|
||||
name = type(instance).__name__
|
||||
@@ -55,6 +59,12 @@ class HopperOutput(BaseModel):
|
||||
+ " and how you decided what to extract."
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_found_output(self):
|
||||
if self.found and (not self.output or not self.output.strip()):
|
||||
raise ValueError("A successful extraction must contain a nonempty output")
|
||||
return self
|
||||
|
||||
|
||||
@trace(type="agent", name="hopper")
|
||||
async def hopper(
|
||||
@@ -72,25 +82,22 @@ async def hopper(
|
||||
HumanMessage(content=f"{request}\nHere is the data you must dig:\n{data}"),
|
||||
]
|
||||
|
||||
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
|
||||
async def extract(model) -> HopperOutput:
|
||||
response = await invoke_llm_with_timeout_message(model.ainvoke(messages))
|
||||
return HopperOutput.model_validate(response)
|
||||
|
||||
try:
|
||||
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
|
||||
if use_fallback:
|
||||
llm_fallback = get_llm(
|
||||
ctx=ctx, name="hopper", is_utils=True, use_fallback=True
|
||||
).with_structured_output(HopperOutput)
|
||||
response: HopperOutput = await with_fallback(
|
||||
main_call=lambda: invoke_llm_with_timeout_message(llm.ainvoke(messages)),
|
||||
fallback_call=lambda: invoke_llm_with_timeout_message(
|
||||
llm_fallback.ainvoke(messages)
|
||||
),
|
||||
) # type: ignore
|
||||
return await with_fallback(
|
||||
main_call=lambda: extract(llm),
|
||||
fallback_call=lambda: extract(llm_fallback),
|
||||
)
|
||||
else:
|
||||
response: HopperOutput = await invoke_llm_with_timeout_message(llm.ainvoke(messages))
|
||||
return response
|
||||
return await extract(llm)
|
||||
except Exception as e:
|
||||
logger.error(f"Hopper LLM invocation failed: {e}")
|
||||
return HopperOutput(
|
||||
found=False,
|
||||
output=None,
|
||||
reason=f"Failed due to LLM error: {str(e)[:500]}",
|
||||
)
|
||||
raise HopperError("模型未能完成数据提取,请查看模型请求诊断。") from e
|
||||
@@ -36,6 +36,8 @@ from artemis.controllers.types import (
|
||||
SwipeStartEndPercentagesRequest,
|
||||
TapOutput,
|
||||
)
|
||||
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
|
||||
from artemis.toolchain import find_scrcpy
|
||||
from artemis.utils.logger import get_logger
|
||||
from artemis.utils.video import (
|
||||
ANDROID_RECORDING_SEGMENT_SECONDS,
|
||||
@@ -82,9 +84,13 @@ class UnifiedMobileController:
|
||||
|
||||
@staticmethod
|
||||
async def _spawn_scrcpy(command: list[str]) -> asyncio.subprocess.Process:
|
||||
adb = AdbSession(current_adb_endpoint())
|
||||
if not adb.adb_path:
|
||||
raise FileNotFoundError("Android Platform Tools (adb) could not be found.")
|
||||
kwargs: dict[str, Any] = {
|
||||
"stdout": asyncio.subprocess.PIPE,
|
||||
"stderr": asyncio.subprocess.PIPE,
|
||||
"env": adb.environment(),
|
||||
}
|
||||
if os.name == "nt":
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
@@ -606,7 +612,7 @@ class UnifiedMobileController:
|
||||
config = recording_config(self.ctx)
|
||||
bitrate = config.h265_kbps if codec == "h265" else config.h264_kbps
|
||||
return build_scrcpy_record_command(
|
||||
"scrcpy",
|
||||
find_scrcpy(),
|
||||
device_id,
|
||||
path,
|
||||
f"{bitrate}K",
|
||||
|
||||
@@ -39,6 +39,7 @@ from artemis.runtime.observation_budget import (
|
||||
current_observation,
|
||||
)
|
||||
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
||||
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
|
||||
from artemis.utils.video import build_scrcpy_record_command
|
||||
from artemis.utils.ui_filter import filter_ui_hierarchy
|
||||
from artemis.utils.logger import get_logger
|
||||
@@ -608,6 +609,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
self._recording_output_path = out_dir / "recording.mp4"
|
||||
logger.info(f"Starting scrcpy video recording to {self._recording_mkv_path}...")
|
||||
try:
|
||||
adb = AdbSession(current_adb_endpoint())
|
||||
if not adb.adb_path:
|
||||
raise FileNotFoundError("Android Platform Tools (adb) could not be found.")
|
||||
scrcpy_bin = find_scrcpy()
|
||||
cmd = build_scrcpy_record_command(
|
||||
scrcpy_bin,
|
||||
@@ -617,6 +621,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
)
|
||||
self._scrcpy_process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
env=adb.environment(),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
@@ -167,6 +167,8 @@ class AdbSession:
|
||||
def environment(self, base: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
environment = dict(base if base is not None else os.environ)
|
||||
self.endpoint.apply_to_environment(environment)
|
||||
if self.adb_path:
|
||||
environment["ADB"] = self.adb_path
|
||||
return environment
|
||||
|
||||
def run(self, arguments: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Small, task-owned runtime snapshots; no polling or readiness decisions."""
|
||||
|
||||
import asyncio
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
from functools import lru_cache
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import psutil
|
||||
|
||||
from artemis._version import get_version
|
||||
from artemis.config.paths import ROOT_DIR
|
||||
from artemis.runtime.adb_endpoint import current_adb_endpoint
|
||||
from artemis.toolchain import toolchain
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
FILENAME = "runtime_diagnostics.json"
|
||||
|
||||
|
||||
def _command(arguments: list[str]) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=3,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||
check=False,
|
||||
)
|
||||
return result.stdout.strip()[:4096] if result.returncode == 0 else None
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def _tool_identity(name: str) -> dict:
|
||||
path = toolchain.resolve(name)
|
||||
result = {"path": path, "version": None, "sha256": None}
|
||||
if path:
|
||||
result["version"] = _command([path, "version" if name == "adb" else "--version"])
|
||||
try:
|
||||
with Path(path).open("rb") as stream:
|
||||
result["sha256"] = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
except OSError:
|
||||
logger.debug("Could not hash %s", name)
|
||||
return result
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def runtime_identity(android: bool) -> dict:
|
||||
"""Read identity once per worker, before later source updates can change it."""
|
||||
git = ["git", "-C", str(ROOT_DIR)]
|
||||
checkout = (ROOT_DIR / ".git").exists()
|
||||
commit = _command([*git, "rev-parse", "--verify", "HEAD"]) if checkout else None
|
||||
changes = (
|
||||
_command([*git, "status", "--porcelain", "--untracked-files=no"]) if checkout else None
|
||||
)
|
||||
return {
|
||||
"artemis_version": get_version(),
|
||||
"source_commit": commit,
|
||||
"source_modified": bool(changes) if changes is not None else None,
|
||||
"installation_archive_sha256": None,
|
||||
"python_version": platform.python_version(),
|
||||
"platform": platform.system(),
|
||||
"tools": {name: _tool_identity(name) for name in ("adb", "scrcpy")} if android else {},
|
||||
}
|
||||
|
||||
|
||||
class _PerformanceInformation(ctypes.Structure):
|
||||
# https://learn.microsoft.com/windows/win32/api/psapi/ns-psapi-performance_information
|
||||
_fields_ = (
|
||||
[("cb", wintypes.DWORD)]
|
||||
+ [
|
||||
(name, ctypes.c_size_t)
|
||||
for name in (
|
||||
"CommitTotal",
|
||||
"CommitLimit",
|
||||
"CommitPeak",
|
||||
"PhysicalTotal",
|
||||
"PhysicalAvailable",
|
||||
"SystemCache",
|
||||
"KernelTotal",
|
||||
"KernelPaged",
|
||||
"KernelNonpaged",
|
||||
"PageSize",
|
||||
)
|
||||
]
|
||||
+ [(name, wintypes.DWORD) for name in ("HandleCount", "ProcessCount", "ThreadCount")]
|
||||
)
|
||||
|
||||
|
||||
def _windows_commit() -> dict | None:
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
values = _PerformanceInformation()
|
||||
values.cb = ctypes.sizeof(values)
|
||||
query = ctypes.WinDLL("psapi", use_last_error=True).GetPerformanceInfo
|
||||
query.argtypes = [ctypes.POINTER(_PerformanceInformation), wintypes.DWORD]
|
||||
query.restype = wintypes.BOOL
|
||||
if not query(ctypes.byref(values), values.cb):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
committed = values.CommitTotal * values.PageSize
|
||||
limit = values.CommitLimit * values.PageSize
|
||||
return {
|
||||
"committed_bytes": committed,
|
||||
"limit_bytes": limit,
|
||||
"available_bytes": max(0, limit - committed),
|
||||
"note": "系统提交额度,不能当作页面文件大小。",
|
||||
}
|
||||
|
||||
|
||||
def _memory_snapshot() -> dict:
|
||||
result = {"physical": None, "system_commit": None, "worker": None}
|
||||
try:
|
||||
memory = psutil.virtual_memory()
|
||||
result["physical"] = {"total_bytes": memory.total, "available_bytes": memory.available}
|
||||
result["system_commit"] = _windows_commit()
|
||||
except (OSError, psutil.Error):
|
||||
logger.debug("Host memory snapshot unavailable")
|
||||
try:
|
||||
memory = psutil.Process().memory_info()
|
||||
result["worker"] = {
|
||||
"pid": os.getpid(),
|
||||
"rss_bytes": memory.rss,
|
||||
"private_bytes": getattr(memory, "private", None),
|
||||
}
|
||||
except (OSError, psutil.Error):
|
||||
logger.debug("Worker memory snapshot unavailable")
|
||||
return result
|
||||
|
||||
|
||||
def _adb_server() -> dict:
|
||||
endpoint = current_adb_endpoint()
|
||||
result = {"endpoint": endpoint.to_dict(), "local_listener": None}
|
||||
if endpoint.host.lower() not in {"localhost", "127.0.0.1", "::1"}:
|
||||
return result # A remote server's process cannot be observed on this host.
|
||||
try:
|
||||
for connection in psutil.net_connections(kind="tcp"):
|
||||
if (
|
||||
connection.status == psutil.CONN_LISTEN
|
||||
and connection.laddr.port == endpoint.port
|
||||
and connection.laddr.ip
|
||||
in {
|
||||
endpoint.host,
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
*(("127.0.0.1", "::1") if endpoint.host.lower() == "localhost" else ()),
|
||||
}
|
||||
and connection.pid
|
||||
):
|
||||
process = psutil.Process(connection.pid)
|
||||
result["local_listener"] = {
|
||||
"pid": process.pid,
|
||||
"name": process.name(),
|
||||
"path": process.exe(),
|
||||
}
|
||||
break
|
||||
except (OSError, psutil.Error):
|
||||
logger.debug("ADB listener identity unavailable")
|
||||
return result
|
||||
|
||||
|
||||
def write_runtime_diagnostics(directory: Path, phase: str, *, android: bool = True) -> None:
|
||||
"""Keep a start snapshot and the latest failure snapshot without changing task results."""
|
||||
if phase not in {"start", "failure"}:
|
||||
raise ValueError("Unknown runtime snapshot phase")
|
||||
path = directory / FILENAME
|
||||
try:
|
||||
payload = {"schema_version": 1}
|
||||
if path.is_file() and path.stat().st_size <= 256 * 1024:
|
||||
try:
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(saved, dict):
|
||||
payload = saved
|
||||
except (OSError, ValueError):
|
||||
logger.debug("Previous runtime snapshot unreadable")
|
||||
if phase == "start" and phase in payload:
|
||||
return
|
||||
payload[phase] = {
|
||||
"captured_at": time.time(),
|
||||
"runtime": runtime_identity(android),
|
||||
"memory": _memory_snapshot(),
|
||||
"adb_server": _adb_server() if android else None,
|
||||
}
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
except (OSError, psutil.Error, ValueError, TypeError):
|
||||
logger.warning("Could not save runtime snapshot (%s)", phase, exc_info=True)
|
||||
|
||||
|
||||
async def capture_runtime_diagnostics(directory: Path, phase: str, *, android: bool = True) -> None:
|
||||
"""Keep optional evidence failures outside the task's execution result."""
|
||||
try:
|
||||
await asyncio.to_thread(write_runtime_diagnostics, directory, phase, android=android)
|
||||
except (OSError, RuntimeError):
|
||||
logger.warning("Could not start runtime snapshot (%s)", phase, exc_info=True)
|
||||
@@ -654,6 +654,13 @@ class Agent:
|
||||
# publish a new active session while the current task is still
|
||||
# finishing.
|
||||
self._prepare_tracing(task=task, context=context)
|
||||
from artemis.runtime.diagnostics import capture_runtime_diagnostics
|
||||
|
||||
await capture_runtime_diagnostics(
|
||||
context.data_engine.base_dir,
|
||||
"start",
|
||||
android=request.execution_target != "computer",
|
||||
)
|
||||
if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
|
||||
from artemis.runtime.task_control import TaskControl
|
||||
|
||||
@@ -1333,6 +1340,14 @@ class Agent:
|
||||
|
||||
async def _finalize_tracing(self, task: Task, context: ArtemisContext):
|
||||
if context.data_engine:
|
||||
if task.status == "failed":
|
||||
from artemis.runtime.diagnostics import capture_runtime_diagnostics
|
||||
|
||||
await capture_runtime_diagnostics(
|
||||
context.data_engine.base_dir,
|
||||
"failure",
|
||||
android=context.execution_target != "computer",
|
||||
)
|
||||
session_status = "completed"
|
||||
if task.status == "failed":
|
||||
session_status = "failed"
|
||||
|
||||
@@ -748,10 +748,15 @@ class RobustChatModelWrapper:
|
||||
if observation is not None and (usage := usage_from_message(response)) is not None:
|
||||
observation.data["usage"] = usage
|
||||
ensure_response_complete(response)
|
||||
if self.structured_mode == "parsed" and isinstance(response, dict) and "raw" in response:
|
||||
if self.structured_mode == "parsed":
|
||||
if isinstance(response, dict) and "raw" in response:
|
||||
if response.get("parsing_error") is not None:
|
||||
raise response["parsing_error"]
|
||||
return response["parsed"]
|
||||
raise ResponseIntegrityError("invalid_structured_output") from response[
|
||||
"parsing_error"
|
||||
]
|
||||
response = response.get("parsed")
|
||||
if response is None:
|
||||
raise ResponseIntegrityError("missing_structured_output")
|
||||
return response
|
||||
|
||||
async def ainvoke(self, *args, **kwargs):
|
||||
|
||||
@@ -18,7 +18,7 @@ from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from artemis.agents.hopper.hopper import HopperOutput, hopper
|
||||
from artemis.agents.hopper.hopper import HopperError, HopperOutput, hopper
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.controllers.platform_specific_commands_controller import (
|
||||
list_packages_async,
|
||||
@@ -53,7 +53,7 @@ LAUNCH_APP_DOCSTRING = (
|
||||
async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool = True) -> str | None:
|
||||
"""Finds the package name for a given application name.
|
||||
|
||||
Returns None if package not found or on error.
|
||||
Returns None if no installed package matches. Model failures raise HopperError.
|
||||
"""
|
||||
package_cache = getattr(ctx, "package_cache", None)
|
||||
if package_cache is None or not isinstance(package_cache, dict):
|
||||
@@ -63,7 +63,7 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
package_cache = {}
|
||||
|
||||
if isinstance(package_cache, dict) and app_name in package_cache:
|
||||
if isinstance(package_cache, dict) and package_cache.get(app_name):
|
||||
logger.info(f"Cache hit for app '{app_name}': {package_cache[app_name]}")
|
||||
return package_cache[app_name]
|
||||
|
||||
@@ -84,8 +84,6 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
|
||||
use_fallback=use_fallback,
|
||||
)
|
||||
if not hopper_output.found or not hopper_output.output:
|
||||
if isinstance(package_cache, dict):
|
||||
package_cache[app_name] = None
|
||||
return None
|
||||
|
||||
package_name = hopper_output.output.strip()
|
||||
@@ -94,13 +92,13 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
|
||||
f"Hopper returned package '{package_name}' for '{app_name}', "
|
||||
"but it is NOT physically installed on the device!"
|
||||
)
|
||||
if isinstance(package_cache, dict):
|
||||
package_cache[app_name] = None
|
||||
return None
|
||||
|
||||
if isinstance(package_cache, dict):
|
||||
package_cache[app_name] = package_name
|
||||
return package_name
|
||||
except HopperError:
|
||||
raise
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
logger.error(f"Failed to find package for '{app_name}': {e}")
|
||||
return None
|
||||
|
||||
@@ -50,6 +50,8 @@ Flash 开关保存在全局配置中,网页切换后自动保存,失败恢
|
||||
|
||||
诊断可单独下载,也会收录到单任务“全部资料”和批次 ZIP 各任务目录的 `模型请求诊断.json`。请求状态复用任务 trace 数据库,每次尝试一条记录;同一阶段的流式更新至多每 5 秒保存一次,阶段变化立即记录,不额外调用模型、抓取屏幕或记录认证头。强制退出导致缺少结束事件时保留最后阶段,不推断为成功。
|
||||
|
||||
新任务还会在开始和失败收尾时保存一次运行环境快照,单任务“全部资料”和批次 ZIP 各任务目录收录为 `运行环境诊断.json`。内容包括 ARTEMIS/Python 版本、可取得的源码提交与本地修改标记、实际解析的 ADB/scrcpy 路径、版本与 SHA256,以及采集时的内存和本机 ADB 监听进程信息;仅 PC 任务不查询安卓工具。Windows 的系统提交量、提交上限与余量按字节记录,不代表页面文件单独大小。快照不持续监控、不控制任务派发,也不能还原采集间隔内的内存变化;远程 ADB 进程、未保留的安装 ZIP 哈希及旧任务缺失字段均保留未知,导出时不会用当前环境补写历史。批次重启仍需手动继续,原暂停原因保留,恢复事件另行留档。
|
||||
|
||||
Flash、Pro 和探索的最终验收使用独立总预算,不再复用中间检查点的 180 秒时限。预算按实际 Checker 的单次请求超时 `T`、重试次数 `R`,以及普通/限流重试间隔的较大值 `D` 自动计算:`min(3600, T × (3 + R) + D × R + 60)` 秒。它为取证、结论及必要的结构化整理预留三次正常请求时间,再共用一份重试时间余量和 60 秒工具读取余量;不会按 20 轮循环逐轮放大。每个请求仍最多重试 `R` 次,但全部请求和重试必须在总预算内完成。默认配置的上限为 2760 秒,提前完成就立即结束;用户停止、探索指导及任务本身的执行时限仍生效。
|
||||
|
||||
页面在验收期间单独显示证据准备/核对/读取、结论整理、完整性校验阶段,以及已用时间、剩余总预算、请求与实际重试次数。诊断 JSON 的 `final_reviews` 记录每次验收的预算、截止时间和结束原因,相关模型请求通过 `final_review.review_id` 关联。请求超时仅重试同一模型请求,保留已取得的工具证据;耗尽请求重试或验收总预算时明确记为未验证,不再进入模型故障后的人工恢复等待。用户停止或探索指导引发的取消单独记录为中断,不重试。超时或中断后的半截输出会从流式展示中移除,只有完整响应经解析、校验后才可能确认通过。
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# ARTEMIS 2.7.1
|
||||
|
||||
本版在正式 2.7.0 基础上改进 ADB 一致性、模型提取失败处理和故障证据留存,便于排查设备离线及批次中断。
|
||||
|
||||
## 任务操作与录屏共用 ADB
|
||||
|
||||
- scrcpy 录屏显式使用 ARTEMIS 选定的 ADB 可执行文件和服务端地址,覆盖普通录屏、分段及驱动直接录屏入口,减少各自选择不同 ADB 的情况。
|
||||
- 运行环境诊断记录解析到的 ADB/scrcpy 路径、版本和 SHA256,以及可取得的本机 ADB 监听进程信息;远程服务端进程保留未知。
|
||||
|
||||
## 模型空结果与应用查找
|
||||
|
||||
- 结构化模型输出为空或解析失败时,进入现有的有限重试和兜底流程;Hopper 对“已找到但提取内容为空”的结果也执行校验,取消请求仍直接中断。
|
||||
- 模型提取或初始化失败明确报错,与有效查询后未找到已安装应用分开;仅缓存已确认安装的包名,避免失败结果影响后续查找。
|
||||
|
||||
## 故障留档与批次恢复
|
||||
|
||||
- 新任务在开始和失败收尾时分别保存运行环境快照,包含程序/Python 版本、可取得的源码提交与本地修改标记、工具来源、宿主内存及任务进程内存。Windows 系统提交量、提交上限和余量按字节记录,不能当作页面文件单独大小。
|
||||
- 单任务“全部资料”及批次 ZIP 各任务目录增加 `运行环境诊断.json`。升级前的批次 ZIP 缓存会重新打包;旧任务未采集的运行信息保留未知,导出时不使用当前环境补写历史。
|
||||
- 服务重启后保留原暂停原因,恢复事件单独记录,批次仍需手动继续。诊断采集失败仅记日志,不改变任务结果。
|
||||
- 快照仅在上述时点采集,不增加持续内存监控、保护阈值、自动暂停或录屏处理并发限制。它们用于减少干扰和保留证据,尚不能证明本次 ADB 崩溃的唯一原因,也不代表宿主内存压力已经解决。
|
||||
|
||||
详见[工作区诊断说明](../automation-workspace.md)。
|
||||
|
||||
## 版本与验证
|
||||
|
||||
- 主程序与前端根版本升级到 2.7.1;SDK、插件、Android 助手及锁定的第三方依赖保持现有独立版本。
|
||||
- 回归覆盖录屏 ADB 环境隔离、模型空结果与解析错误重试、Hopper 兜底与取消、包名缓存、批次恢复、诊断导出和旧 ZIP 缓存升级。
|
||||
- 最终源码 ZIP 使用独立解压目录和空 npm 缓存,分别验证官方源和国内镜像的锁定安装与生产构建。实际运行时、源码提交、SHA256、检查结果和未测范围见附件 `INSTALL-VERIFICATION.md`。
|
||||
- 本次不恢复用户批次,不新增实机业务测试或付费模型调用;自动化回归不能代替现场内存压力与 ADB 崩溃复现。
|
||||
|
||||
## 升级
|
||||
|
||||
已有安装等待任务结束后运行 `update.bat` 或 `update-cn.bat`,再重启 ARTEMIS;Codex 用户重新加载 MCP。升级程序会终止本项目运行进程,请保留需要继续的暂停任务。
|
||||
|
||||
新安装下载 `artemis-2.7.1-source.zip`,对照 `SHA256SUMS.txt` 校验后解压启动,不覆盖正在运行的安装目录。
|
||||
|
||||
继续沿用 [2.7.0 的 WebSocket 默认协议及一次性配置迁移](2.7.0.md#默认协议与旧配置迁移)。已经手动切回 HTTP 的配置保持原选择。
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "2.7.0"
|
||||
version = "2.7.1"
|
||||
description = "Autonomous AI Assistant for Mobile Automation"
|
||||
|
||||
authors = [
|
||||
|
||||
@@ -181,18 +181,23 @@ def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
|
||||
exporter.assert_not_called()
|
||||
|
||||
|
||||
def test_legacy_batch_archive_is_rebuilt_with_current_recording_layout(client, isolated):
|
||||
@pytest.mark.parametrize("signature", [None, "artemis-batch-layout-3"])
|
||||
def test_legacy_batch_archive_is_rebuilt_with_runtime_diagnostics(client, isolated, signature):
|
||||
batch = service.create_batch(payload())
|
||||
batch_id = batch["id"]
|
||||
isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True})
|
||||
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"legacy archive")
|
||||
if signature:
|
||||
reports.remember(path, signature)
|
||||
base = f"/api/automation/batches/{batch_id}/archive"
|
||||
assert client.get(base + "/status").json()["status"] == "idle"
|
||||
response = client.get(base)
|
||||
assert response.status_code == 200
|
||||
assert zipfile.is_zipfile(BytesIO(response.content))
|
||||
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||
assert len([name for name in archive.namelist() if name.endswith("运行环境诊断.json")]) == 2
|
||||
assert client.get(base + "/status").json()["status"] == "ready"
|
||||
|
||||
|
||||
@@ -721,7 +726,7 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
|
||||
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"]
|
||||
archive = reports.export_batch(batch["id"])
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
assert len(z.namelist()) == 10 # summaries + reports, diagnostics and evidence per task
|
||||
assert len(z.namelist()) == 12 # summaries + reports, diagnostics and evidence per task
|
||||
diagnostics = [name for name in z.namelist() if name.endswith("模型请求诊断.json")]
|
||||
assert len(diagnostics) == 2
|
||||
for name in diagnostics:
|
||||
@@ -1304,6 +1309,69 @@ def test_batch_zip_contains_each_tasks_android_artifacts(isolated):
|
||||
assert {archive.read(name).decode() for name in names} == {"任务0", "任务1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pause_source", ["device", "initialization", "user", "model"])
|
||||
def test_restart_retains_original_pause_reason_and_archives_recovery(isolated, pause_source):
|
||||
batch = service.create_batch(payload())
|
||||
reason = "原始暂停原因"
|
||||
isolated.put(
|
||||
"batch",
|
||||
batch["id"],
|
||||
{**batch, "status": "paused", "pause_source": pause_source, "reason": reason},
|
||||
)
|
||||
service.recover_batches()
|
||||
recovered = isolated.get("batch", batch["id"])
|
||||
assert recovered["status"] == "paused"
|
||||
assert recovered["pause_source"] == "recovery"
|
||||
assert recovered["reason"] == reason
|
||||
event = recovered["recovery_events"][0]
|
||||
assert event["previous_reason"] == reason
|
||||
assert event["previous_pause_source"] == pause_source
|
||||
assert "服务已重启" in event["message"]
|
||||
assert all(job["status"] == "pending" for job in isolated.jobs(batch["id"]))
|
||||
service.recover_batches()
|
||||
assert isolated.get("batch", batch["id"])["reason"] == reason
|
||||
|
||||
|
||||
def test_runtime_evidence_is_exported_from_the_task_without_resampling(isolated, monkeypatch):
|
||||
batch = service.create_batch(payload())
|
||||
first, second = isolated.jobs(batch["id"])
|
||||
snapshot = {
|
||||
"schema_version": 1,
|
||||
"failure": {
|
||||
"captured_at": 1,
|
||||
"runtime": {"source_commit": "worker-commit"},
|
||||
"memory": {"available_bytes": 163},
|
||||
},
|
||||
}
|
||||
directory = reports.TRACES_PATH / first["session_id"]
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "runtime_diagnostics.json").write_text(json.dumps(snapshot), encoding="utf-8")
|
||||
isolated.put(
|
||||
"batch",
|
||||
batch["id"],
|
||||
{**batch, "status": "paused", "reason": "设备离线", "pause_source": "device"},
|
||||
)
|
||||
service.recover_batches()
|
||||
|
||||
def assert_snapshots(archive_path):
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
records = [
|
||||
json.loads(archive.read(name))
|
||||
for name in archive.namelist()
|
||||
if name.endswith("运行环境诊断.json")
|
||||
]
|
||||
recorded = next(record for record in records if record["runtime"])
|
||||
assert recorded["runtime"] == snapshot
|
||||
assert recorded["batch"]["reason"] == "设备离线"
|
||||
assert recorded["batch"]["recovery_events"][0]["previous_pause_source"] == "device"
|
||||
return records
|
||||
|
||||
assert_snapshots(reports.export_session_media(first["session_id"], "all"))
|
||||
records = assert_snapshots(reports.export_batch(batch["id"]))
|
||||
assert len(records) == 2
|
||||
assert next(record for record in records if record["runtime"] is None)["runtime"] is None
|
||||
|
||||
|
||||
def test_all_material_cache_reuses_reports_and_invalidates_on_review_or_new_files(
|
||||
isolated, monkeypatch
|
||||
):
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Hopper failures must not become successful negative lookups."""
|
||||
|
||||
import asyncio
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.agents.hopper.hopper import HopperError, HopperOutput
|
||||
|
||||
module = import_module("artemis.agents.hopper.hopper")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("invalid", [None, {}, {"found": True, "output": " ", "reason": "Empty"}])
|
||||
async def test_invalid_extraction_switches_to_fallback(monkeypatch, invalid):
|
||||
good = HopperOutput(found=True, output="com.example.app", reason="Matched")
|
||||
primary = SimpleNamespace(ainvoke=AsyncMock(return_value=invalid))
|
||||
secondary = SimpleNamespace(ainvoke=AsyncMock(return_value=good))
|
||||
factory = Mock(
|
||||
side_effect=[
|
||||
SimpleNamespace(with_structured_output=lambda _: primary),
|
||||
SimpleNamespace(with_structured_output=lambda _: secondary),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(module, "get_llm", factory)
|
||||
assert (
|
||||
await module.hopper(SimpleNamespace(data_engine=None), "Find app", "com.example.app")
|
||||
== good
|
||||
)
|
||||
assert primary.ainvoke.await_count == secondary.ainvoke.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("use_fallback", [False, True])
|
||||
async def test_exhausted_extraction_raises_a_distinct_error(monkeypatch, use_fallback):
|
||||
model = SimpleNamespace(ainvoke=AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
module, "get_llm", lambda **_: SimpleNamespace(with_structured_output=lambda _: model)
|
||||
)
|
||||
with pytest.raises(HopperError, match="模型未能完成数据提取"):
|
||||
await module.hopper(
|
||||
SimpleNamespace(data_engine=None), "Find app", "packages", use_fallback=use_fallback
|
||||
)
|
||||
assert model.ainvoke.await_count == (2 if use_fallback else 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_extraction_never_uses_fallback(monkeypatch):
|
||||
model = SimpleNamespace(ainvoke=AsyncMock(side_effect=asyncio.CancelledError))
|
||||
monkeypatch.setattr(
|
||||
module, "get_llm", lambda **_: SimpleNamespace(with_structured_output=lambda _: model)
|
||||
)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await module.hopper(SimpleNamespace(data_engine=None), "Find app", "packages")
|
||||
assert model.ainvoke.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_setup_failure_is_not_reported_as_missing_data(monkeypatch):
|
||||
monkeypatch.setattr(module, "get_llm", Mock(side_effect=ValueError("invalid model settings")))
|
||||
with pytest.raises(HopperError, match="模型未能完成数据提取"):
|
||||
await module.hopper(SimpleNamespace(data_engine=None), "Find app", "packages")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Runtime snapshots are bounded, read-only evidence, never a task gate."""
|
||||
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
from artemis.runtime import diagnostics as module
|
||||
from artemis.runtime.adb_endpoint import AdbEndpoint
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_identity_cache():
|
||||
identity = module.runtime_identity
|
||||
identity.cache_clear()
|
||||
yield
|
||||
identity.cache_clear()
|
||||
|
||||
|
||||
def test_tool_identity_records_exact_binary_hash_and_version(monkeypatch, tmp_path):
|
||||
adb = tmp_path / "platform tools" / "adb.exe"
|
||||
adb.parent.mkdir()
|
||||
adb.write_bytes(b"selected adb")
|
||||
monkeypatch.setattr(module.toolchain, "resolve", lambda _: str(adb))
|
||||
command = Mock(return_value="Android Debug Bridge version test")
|
||||
monkeypatch.setattr(module, "_command", command)
|
||||
record = module._tool_identity("adb")
|
||||
assert record["path"] == str(adb)
|
||||
assert record["sha256"] == hashlib.sha256(b"selected adb").hexdigest()
|
||||
assert record["version"] == command.return_value
|
||||
command.assert_called_once_with([str(adb), "version"])
|
||||
|
||||
|
||||
def test_unknown_source_and_archive_identity_are_not_invented(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(module, "ROOT_DIR", tmp_path)
|
||||
monkeypatch.setattr(module, "get_version", lambda: "test-version")
|
||||
command = Mock()
|
||||
monkeypatch.setattr(module, "_command", command)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "private-test-key")
|
||||
record = module.runtime_identity(False)
|
||||
assert record["source_commit"] is None
|
||||
assert record["source_modified"] is None
|
||||
assert record["installation_archive_sha256"] is None
|
||||
assert record["artemis_version"] == "test-version"
|
||||
assert record["tools"] == {}
|
||||
assert "private-test-key" not in json.dumps(record)
|
||||
command.assert_not_called()
|
||||
|
||||
|
||||
def test_version_commands_are_bounded_and_never_repair_devices(monkeypatch):
|
||||
run = Mock(return_value=SimpleNamespace(stdout="version", returncode=0))
|
||||
monkeypatch.setattr(module.subprocess, "run", run)
|
||||
assert module._command(["adb", "version"]) == "version"
|
||||
assert run.call_args.kwargs["timeout"] == 3
|
||||
assert run.call_args.kwargs["check"] is False
|
||||
assert run.call_args.args == (["adb", "version"],)
|
||||
run.side_effect = OSError("Cannot create process")
|
||||
assert module._command(["adb", "version"]) is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(module.sys.platform != "win32", reason="Windows PSAPI structure")
|
||||
def test_system_commit_converts_pages_to_bytes(monkeypatch):
|
||||
def query(pointer, size):
|
||||
values = pointer._obj
|
||||
assert size == ctypes.sizeof(module._PerformanceInformation)
|
||||
values.PageSize = 4096
|
||||
values.CommitTotal = 900
|
||||
values.CommitLimit = 1000
|
||||
return True
|
||||
|
||||
dll = SimpleNamespace(GetPerformanceInfo=Mock(side_effect=query))
|
||||
monkeypatch.setattr(module.ctypes, "WinDLL", lambda *args, **kwargs: dll)
|
||||
result = module._windows_commit()
|
||||
assert result["committed_bytes"] == 900 * 4096
|
||||
assert result["limit_bytes"] == 1000 * 4096
|
||||
assert result["available_bytes"] == 100 * 4096
|
||||
|
||||
|
||||
def test_remote_adb_process_is_explicitly_unknown(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
module, "current_adb_endpoint", lambda: AdbEndpoint.create("192.0.2.10", 5037)
|
||||
)
|
||||
connections = Mock()
|
||||
monkeypatch.setattr(module.psutil, "net_connections", connections)
|
||||
result = module._adb_server()
|
||||
assert result["local_listener"] is None
|
||||
assert result["endpoint"]["host"] == "192.0.2.10"
|
||||
connections.assert_not_called()
|
||||
|
||||
|
||||
def test_local_adb_listener_identity_excludes_unrelated_bindings(monkeypatch):
|
||||
monkeypatch.setattr(module, "current_adb_endpoint", lambda: AdbEndpoint.local())
|
||||
connections = [
|
||||
SimpleNamespace(status=psutil.CONN_LISTEN, laddr=SimpleNamespace(ip=ip, port=5037), pid=pid)
|
||||
for ip, pid in [("192.0.2.10", 5), ("127.0.0.1", 6)]
|
||||
]
|
||||
monkeypatch.setattr(module.psutil, "net_connections", lambda **_: connections)
|
||||
process = SimpleNamespace(pid=6, name=lambda: "adb.exe", exe=lambda: "selected/adb.exe")
|
||||
lookup = Mock(return_value=process)
|
||||
monkeypatch.setattr(module.psutil, "Process", lookup)
|
||||
assert module._adb_server()["local_listener"] == {
|
||||
"pid": 6,
|
||||
"name": "adb.exe",
|
||||
"path": "selected/adb.exe",
|
||||
}
|
||||
lookup.assert_called_once_with(6)
|
||||
|
||||
|
||||
def test_start_snapshot_survives_failure_and_repeated_start(monkeypatch, tmp_path):
|
||||
identity = Mock(return_value={"source_commit": "test-commit"})
|
||||
memory = Mock(side_effect=[{"available_bytes": 100}, {"available_bytes": 1}])
|
||||
monkeypatch.setattr(module, "runtime_identity", identity)
|
||||
monkeypatch.setattr(module, "_memory_snapshot", memory)
|
||||
monkeypatch.setattr(module, "_adb_server", lambda: {"local_listener": None})
|
||||
module.write_runtime_diagnostics(tmp_path, "start")
|
||||
module.write_runtime_diagnostics(tmp_path, "start")
|
||||
module.write_runtime_diagnostics(tmp_path, "failure")
|
||||
payload = json.loads((tmp_path / module.FILENAME).read_text(encoding="utf-8"))
|
||||
assert payload["start"]["memory"]["available_bytes"] == 100
|
||||
assert payload["failure"]["memory"]["available_bytes"] == 1
|
||||
assert payload["failure"]["runtime"]["source_commit"] == "test-commit"
|
||||
assert memory.call_count == 2
|
||||
assert not (tmp_path / "runtime_diagnostics.tmp").exists()
|
||||
|
||||
|
||||
def test_snapshot_failure_cannot_fail_a_task(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(module, "runtime_identity", lambda _: {})
|
||||
monkeypatch.setattr(module, "_memory_snapshot", Mock(side_effect=OSError("commit exhausted")))
|
||||
module.write_runtime_diagnostics(tmp_path, "failure")
|
||||
assert not (tmp_path / module.FILENAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"error", [OSError("commit exhausted"), RuntimeError("cannot start thread")]
|
||||
)
|
||||
async def test_snapshot_thread_creation_failure_is_optional(monkeypatch, tmp_path, error):
|
||||
monkeypatch.setattr(module.asyncio, "to_thread", AsyncMock(side_effect=error))
|
||||
await module.capture_runtime_diagnostics(tmp_path, "start")
|
||||
assert not (tmp_path / module.FILENAME).exists()
|
||||
@@ -22,6 +22,22 @@ from artemis.runtime.device_lock import DeviceBusyError
|
||||
from artemis.sdk.types.exceptions import AgentError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["failed", "completed", "cancelled"])
|
||||
async def test_failure_snapshot_is_written_before_tracing_ends(tmp_path, status):
|
||||
agent = object.__new__(Agent)
|
||||
task = MagicMock(status=status)
|
||||
context = MagicMock(execution_target="computer", execution_setup=None)
|
||||
context.data_engine.base_dir = tmp_path
|
||||
with patch("artemis.runtime.diagnostics.write_runtime_diagnostics") as snapshot:
|
||||
await agent._finalize_tracing(task, context)
|
||||
if status == "failed":
|
||||
snapshot.assert_called_once_with(tmp_path, "failure", android=False)
|
||||
else:
|
||||
snapshot.assert_not_called()
|
||||
context.data_engine.end_session.assert_called_once_with(status=status)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_finalization_failure_does_not_escape():
|
||||
agent = object.__new__(Agent)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for the LLM gateway: complete(), classified recovery, and fallback."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
|
||||
import pytest
|
||||
@@ -16,6 +17,55 @@ from artemis.services.llm import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"invalid",
|
||||
[
|
||||
None,
|
||||
{"raw": AIMessage(content=""), "parsed": None, "parsing_error": None},
|
||||
{
|
||||
"raw": AIMessage(content=""),
|
||||
"parsed": None,
|
||||
"parsing_error": ValueError("Invalid schema"),
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_missing_structured_output_retries_inside_existing_budget(invalid):
|
||||
expected = {"found": True, "output": "com.example.app", "reason": "Matched"}
|
||||
base = SimpleNamespace(
|
||||
ainvoke=AsyncMock(
|
||||
side_effect=[
|
||||
invalid,
|
||||
{"raw": AIMessage(content=""), "parsed": expected, "parsing_error": None},
|
||||
]
|
||||
)
|
||||
)
|
||||
model = RobustChatModelWrapper(base, structured_mode="parsed")
|
||||
assert await model.ainvoke([]) == expected
|
||||
assert base.ainvoke.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_structured_output_exhausts_then_uses_fallback():
|
||||
primary = SimpleNamespace(
|
||||
ainvoke=AsyncMock(return_value={"raw": AIMessage(content=""), "parsed": None})
|
||||
)
|
||||
expected = {"found": False, "output": None, "reason": "No match"}
|
||||
secondary = SimpleNamespace(
|
||||
ainvoke=AsyncMock(return_value={"raw": AIMessage(content=""), "parsed": expected})
|
||||
)
|
||||
main = RobustChatModelWrapper(
|
||||
primary, endpoint=ModelEndpoint(model_name="primary"), structured_mode="parsed"
|
||||
)
|
||||
fallback = RobustChatModelWrapper(
|
||||
secondary, endpoint=ModelEndpoint(model_name="fallback"), structured_mode="parsed"
|
||||
)
|
||||
result = await with_fallback(lambda: main.ainvoke([]), lambda: fallback.ainvoke([]))
|
||||
assert result == expected
|
||||
assert primary.ainvoke.await_count == 2
|
||||
assert secondary.ainvoke.await_count == 1
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_gateway_state(monkeypatch, tmp_path):
|
||||
llm_service._ENDPOINT_BREAKER._states.clear()
|
||||
|
||||
@@ -25,6 +25,7 @@ import pytest
|
||||
import cv2
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.config.settings import settings
|
||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
||||
from artemis.utils.video import (
|
||||
@@ -42,6 +43,55 @@ from artemis.utils.video import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("host,port", [("127.0.0.1", 5037), ("192.0.2.10", 5038)])
|
||||
async def test_scrcpy_uses_selected_adb_binary_and_endpoint(monkeypatch, tmp_path, host, port):
|
||||
import os
|
||||
|
||||
adb = str(tmp_path / "platform tools" / "adb.exe")
|
||||
monkeypatch.setattr("artemis.runtime.adb_endpoint.toolchain.resolve", lambda _: adb)
|
||||
monkeypatch.setattr(settings, "ADB_HOST", host)
|
||||
monkeypatch.setattr(settings, "ADB_PORT", port)
|
||||
monkeypatch.setenv("ADB", "other-adb")
|
||||
monkeypatch.setenv("ADB_SERVER_SOCKET", "tcp:unrelated:5999")
|
||||
command = ["scrcpy", "--no-playback"]
|
||||
with patch("asyncio.create_subprocess_exec", AsyncMock()) as spawn:
|
||||
await UnifiedMobileController._spawn_scrcpy(command)
|
||||
environment = spawn.call_args.kwargs["env"]
|
||||
assert environment["ADB"] == adb
|
||||
assert environment["ADB_SERVER_SOCKET"] == f"tcp:{'localhost' if port == 5037 else host}:{port}"
|
||||
assert environment["ADB_HOST"] == host
|
||||
assert environment["ADB_PORT"] == str(port)
|
||||
assert spawn.call_args.args == tuple(command)
|
||||
assert os.environ["ADB"] == "other-adb"
|
||||
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:unrelated:5999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scrcpy_does_not_fall_back_to_an_unselected_adb(monkeypatch):
|
||||
monkeypatch.setattr("artemis.runtime.adb_endpoint.toolchain.resolve", lambda _: None)
|
||||
with patch("asyncio.create_subprocess_exec", AsyncMock()) as spawn:
|
||||
with pytest.raises(FileNotFoundError, match="adb"):
|
||||
await UnifiedMobileController._spawn_scrcpy(["scrcpy"])
|
||||
spawn.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_android_recording_also_uses_selected_adb(monkeypatch, tmp_path):
|
||||
from artemis.drivers.android.adb_driver import AndroidAdbDriver
|
||||
|
||||
adb = str(tmp_path / "platform-tools" / "adb.exe")
|
||||
monkeypatch.setattr("artemis.runtime.adb_endpoint.toolchain.resolve", lambda _: adb)
|
||||
monkeypatch.setattr(settings, "ADB_HOST", "192.0.2.10")
|
||||
monkeypatch.setattr(settings, "ADB_PORT", 5038)
|
||||
driver = AndroidAdbDriver("fixture-device", MagicMock())
|
||||
with patch("asyncio.create_subprocess_exec", AsyncMock()) as spawn:
|
||||
await driver.start_video_recording(tmp_path)
|
||||
assert spawn.await_count == 1
|
||||
assert spawn.call_args.kwargs["env"]["ADB"] == adb
|
||||
assert spawn.call_args.kwargs["env"]["ADB_SERVER_SOCKET"] == "tcp:192.0.2.10:5038"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_path):
|
||||
silent_video = tmp_path / "silent.mp4"
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from artemis.agents.hopper.hopper import HopperOutput
|
||||
from artemis.agents.hopper.hopper import HopperError, HopperOutput
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.tools.mobile.launch_app import find_package
|
||||
from artemis.tools.mobile.launch_app import find_package, launch_app
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ async def test_find_package_cache_hit(mock_hopper, mock_list_packages, mock_cont
|
||||
@pytest.mark.asyncio
|
||||
@patch("artemis.tools.mobile.launch_app.list_packages_async")
|
||||
@patch("artemis.tools.mobile.launch_app.hopper")
|
||||
async def test_find_package_not_found_caches_none(mock_hopper, mock_list_packages, mock_context):
|
||||
async def test_find_package_not_found_is_not_cached(mock_hopper, mock_list_packages, mock_context):
|
||||
mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
|
||||
|
||||
# Hopper doesn't find it
|
||||
@@ -64,13 +64,14 @@ async def test_find_package_not_found_caches_none(mock_hopper, mock_list_package
|
||||
pkg1 = await find_package(mock_context, "App 3")
|
||||
assert pkg1 is None
|
||||
assert mock_hopper.call_count == 1
|
||||
assert mock_context.package_cache["App 3"] is None
|
||||
assert "App 3" not in mock_context.package_cache
|
||||
|
||||
# Second call: cache hit (None), does not call hopper again
|
||||
# An earlier miss must not prevent a later successful lookup.
|
||||
mock_hopper.reset_mock()
|
||||
mock_hopper.return_value = HopperOutput(found=True, output="com.example.app1", reason="Found")
|
||||
pkg2 = await find_package(mock_context, "App 3")
|
||||
assert pkg2 is None
|
||||
mock_hopper.assert_not_called()
|
||||
assert pkg2 == "com.example.app1"
|
||||
mock_hopper.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -90,3 +91,32 @@ async def test_find_package_passes_use_fallback(mock_hopper, mock_list_packages,
|
||||
data="com.example.app1\ncom.example.app2",
|
||||
use_fallback=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("artemis.tools.mobile.launch_app.list_packages_async")
|
||||
@patch("artemis.tools.mobile.launch_app.hopper")
|
||||
async def test_model_failure_is_reported_without_poisoning_package_cache(
|
||||
mock_hopper, mock_list_packages, mock_context
|
||||
):
|
||||
mock_list_packages.return_value = "com.example.app1"
|
||||
mock_hopper.side_effect = HopperError("模型未能完成数据提取,请查看模型请求诊断。")
|
||||
result = await launch_app.execute(ctx=mock_context, app_name="App 1")
|
||||
assert "模型未能完成数据提取" in result
|
||||
assert "Package not found" not in result
|
||||
assert "App 1" not in mock_context.package_cache
|
||||
mock_hopper.side_effect = None
|
||||
mock_hopper.return_value = HopperOutput(found=True, output="com.example.app1", reason="Found")
|
||||
assert await find_package(mock_context, "App 1") == "com.example.app1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("artemis.tools.mobile.launch_app.list_packages_async")
|
||||
@patch("artemis.tools.mobile.launch_app.hopper")
|
||||
async def test_invented_package_is_never_cached(mock_hopper, mock_list_packages, mock_context):
|
||||
mock_list_packages.return_value = "com.example.app1"
|
||||
mock_hopper.return_value = HopperOutput(
|
||||
found=True, output="com.example.missing", reason="Guess"
|
||||
)
|
||||
assert await find_package(mock_context, "App 1") is None
|
||||
assert "App 1" not in mock_context.package_cache
|
||||
Reference in new issue
Block a user