Compare commits

...
1 Commits
24 changed files with 792 additions and 45 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 基于 [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 batch["status"] in {"running", "pausing", "paused"}:
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])): if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
continue 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( batch.update(
status="paused", status="paused",
reason="服务已重启;请检查中断任务,点击继续执行未开始任务", reason=batch.get("reason") or recovery_reason,
pause_source="recovery", pause_source="recovery",
) )
for job in store.jobs(batch["id"]): for job in store.jobs(batch["id"]):
@@ -330,7 +339,7 @@ async def tick_batches():
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次" else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
) )
if rejection: 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) store.put("batch", batch["id"], batch)
continue continue
if (store.get("batch", batch["id"]) or {}).get("status") != "running": if (store.get("batch", batch["id"]) or {}).get("status") != "running":
+33 -1
View File
@@ -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 from apps.admin_console.services.case_review import reviewed_result
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" 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: 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) identifier(session_id)
meta = store.job(session_id) or {} 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() repo = SessionRepository(db_path) if db_path else SessionRepository()
session = repo.get_session_by_id(session_id) or {} session = repo.get_session_by_id(session_id) or {}
if not session and not meta: 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"), "original_case": case.get("original_case"),
"task_config": case.get("task_config") or {}, "task_config": case.get("task_config") or {},
"task_extensions": task_extensions.report_sections(session_id, store), "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": if kind == "all":
archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}") 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( archive.writestr(
prefix + "证据索引.json", prefix + "证据索引.json",
json.dumps( json.dumps(
@@ -1202,6 +1223,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
compress_type=compression(source), compress_type=compression(source),
) )
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"])) 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( archive.writestr(
f"{prefix}/证据索引.json", f"{prefix}/证据索引.json",
json.dumps( json.dumps(
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.7.0", "version": "2.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.7.0", "version": "2.7.1",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.2.4", "@angular/cdk": "^22.2.4",
"@angular/common": "^22.2.3", "@angular/common": "^22.2.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.7.0", "version": "2.7.1",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
+22 -15
View File
@@ -23,11 +23,15 @@ from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace from artemis.data_engine.trace import trace
from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback
from artemis.utils.logger import get_logger from artemis.utils.logger import get_logger
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
logger = get_logger(__name__) logger = get_logger(__name__)
class HopperError(RuntimeError):
"""Extraction failed; this says nothing about whether the requested data exists."""
class _CyFunctionDetectorMeta(type): class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance): def __instancecheck__(self, instance):
name = type(instance).__name__ name = type(instance).__name__
@@ -55,6 +59,12 @@ class HopperOutput(BaseModel):
+ " and how you decided what to extract." + " 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") @trace(type="agent", name="hopper")
async def hopper( async def hopper(
@@ -72,25 +82,22 @@ async def hopper(
HumanMessage(content=f"{request}\nHere is the data you must dig:\n{data}"), 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: try:
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
if use_fallback: if use_fallback:
llm_fallback = get_llm( llm_fallback = get_llm(
ctx=ctx, name="hopper", is_utils=True, use_fallback=True ctx=ctx, name="hopper", is_utils=True, use_fallback=True
).with_structured_output(HopperOutput) ).with_structured_output(HopperOutput)
response: HopperOutput = await with_fallback( return await with_fallback(
main_call=lambda: invoke_llm_with_timeout_message(llm.ainvoke(messages)), main_call=lambda: extract(llm),
fallback_call=lambda: invoke_llm_with_timeout_message( fallback_call=lambda: extract(llm_fallback),
llm_fallback.ainvoke(messages) )
),
) # type: ignore
else: else:
response: HopperOutput = await invoke_llm_with_timeout_message(llm.ainvoke(messages)) return await extract(llm)
return response
except Exception as e: except Exception as e:
logger.error(f"Hopper LLM invocation failed: {e}") logger.error(f"Hopper LLM invocation failed: {e}")
return HopperOutput( raise HopperError("模型未能完成数据提取,请查看模型请求诊断。") from e
found=False,
output=None,
reason=f"Failed due to LLM error: {str(e)[:500]}",
)
+7 -1
View File
@@ -36,6 +36,8 @@ from artemis.controllers.types import (
SwipeStartEndPercentagesRequest, SwipeStartEndPercentagesRequest,
TapOutput, 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.logger import get_logger
from artemis.utils.video import ( from artemis.utils.video import (
ANDROID_RECORDING_SEGMENT_SECONDS, ANDROID_RECORDING_SEGMENT_SECONDS,
@@ -82,9 +84,13 @@ class UnifiedMobileController:
@staticmethod @staticmethod
async def _spawn_scrcpy(command: list[str]) -> asyncio.subprocess.Process: 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] = { kwargs: dict[str, Any] = {
"stdout": asyncio.subprocess.PIPE, "stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE, "stderr": asyncio.subprocess.PIPE,
"env": adb.environment(),
} }
if os.name == "nt": if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
@@ -606,7 +612,7 @@ class UnifiedMobileController:
config = recording_config(self.ctx) config = recording_config(self.ctx)
bitrate = config.h265_kbps if codec == "h265" else config.h264_kbps bitrate = config.h265_kbps if codec == "h265" else config.h264_kbps
return build_scrcpy_record_command( return build_scrcpy_record_command(
"scrcpy", find_scrcpy(),
device_id, device_id,
path, path,
f"{bitrate}K", f"{bitrate}K",
+5
View File
@@ -39,6 +39,7 @@ from artemis.runtime.observation_budget import (
current_observation, current_observation,
) )
from artemis.toolchain import find_ffmpeg, find_scrcpy 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.video import build_scrcpy_record_command
from artemis.utils.ui_filter import filter_ui_hierarchy from artemis.utils.ui_filter import filter_ui_hierarchy
from artemis.utils.logger import get_logger from artemis.utils.logger import get_logger
@@ -608,6 +609,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
self._recording_output_path = out_dir / "recording.mp4" self._recording_output_path = out_dir / "recording.mp4"
logger.info(f"Starting scrcpy video recording to {self._recording_mkv_path}...") logger.info(f"Starting scrcpy video recording to {self._recording_mkv_path}...")
try: 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() scrcpy_bin = find_scrcpy()
cmd = build_scrcpy_record_command( cmd = build_scrcpy_record_command(
scrcpy_bin, scrcpy_bin,
@@ -617,6 +621,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
) )
self._scrcpy_process = await asyncio.create_subprocess_exec( self._scrcpy_process = await asyncio.create_subprocess_exec(
*cmd, *cmd,
env=adb.environment(),
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
+2
View File
@@ -167,6 +167,8 @@ class AdbSession:
def environment(self, base: Mapping[str, str] | None = None) -> dict[str, str]: def environment(self, base: Mapping[str, str] | None = None) -> dict[str, str]:
environment = dict(base if base is not None else os.environ) environment = dict(base if base is not None else os.environ)
self.endpoint.apply_to_environment(environment) self.endpoint.apply_to_environment(environment)
if self.adb_path:
environment["ADB"] = self.adb_path
return environment return environment
def run(self, arguments: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess: def run(self, arguments: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
+207
View File
@@ -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)
+15
View File
@@ -654,6 +654,13 @@ class Agent:
# publish a new active session while the current task is still # publish a new active session while the current task is still
# finishing. # finishing.
self._prepare_tracing(task=task, context=context) 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": if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
from artemis.runtime.task_control import TaskControl from artemis.runtime.task_control import TaskControl
@@ -1333,6 +1340,14 @@ class Agent:
async def _finalize_tracing(self, task: Task, context: ArtemisContext): async def _finalize_tracing(self, task: Task, context: ArtemisContext):
if context.data_engine: 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" session_status = "completed"
if task.status == "failed": if task.status == "failed":
session_status = "failed" session_status = "failed"
+9 -4
View File
@@ -748,10 +748,15 @@ class RobustChatModelWrapper:
if observation is not None and (usage := usage_from_message(response)) is not None: if observation is not None and (usage := usage_from_message(response)) is not None:
observation.data["usage"] = usage observation.data["usage"] = usage
ensure_response_complete(response) ensure_response_complete(response)
if self.structured_mode == "parsed" and isinstance(response, dict) and "raw" in response: if self.structured_mode == "parsed":
if response.get("parsing_error") is not None: if isinstance(response, dict) and "raw" in response:
raise response["parsing_error"] if response.get("parsing_error") is not None:
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 return response
async def ainvoke(self, *args, **kwargs): async def ainvoke(self, *args, **kwargs):
+5 -7
View File
@@ -18,7 +18,7 @@ from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field 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.context import ArtemisContext
from artemis.controllers.platform_specific_commands_controller import ( from artemis.controllers.platform_specific_commands_controller import (
list_packages_async, 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: async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool = True) -> str | None:
"""Finds the package name for a given application name. """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) package_cache = getattr(ctx, "package_cache", None)
if package_cache is None or not isinstance(package_cache, dict): 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 except Exception: # pylint: disable=broad-exception-caught
package_cache = {} 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]}") logger.info(f"Cache hit for app '{app_name}': {package_cache[app_name]}")
return 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, use_fallback=use_fallback,
) )
if not hopper_output.found or not hopper_output.output: if not hopper_output.found or not hopper_output.output:
if isinstance(package_cache, dict):
package_cache[app_name] = None
return None return None
package_name = hopper_output.output.strip() 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}', " f"Hopper returned package '{package_name}' for '{app_name}', "
"but it is NOT physically installed on the device!" "but it is NOT physically installed on the device!"
) )
if isinstance(package_cache, dict):
package_cache[app_name] = None
return None return None
if isinstance(package_cache, dict): if isinstance(package_cache, dict):
package_cache[app_name] = package_name package_cache[app_name] = package_name
return package_name return package_name
except HopperError:
raise
except Exception as e: # pylint: disable=broad-exception-caught except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to find package for '{app_name}': {e}") logger.error(f"Failed to find package for '{app_name}': {e}")
return None return None
+2
View File
@@ -50,6 +50,8 @@ Flash 开关保存在全局配置中,网页切换后自动保存,失败恢
诊断可单独下载,也会收录到单任务“全部资料”和批次 ZIP 各任务目录的 `模型请求诊断.json`。请求状态复用任务 trace 数据库,每次尝试一条记录;同一阶段的流式更新至多每 5 秒保存一次,阶段变化立即记录,不额外调用模型、抓取屏幕或记录认证头。强制退出导致缺少结束事件时保留最后阶段,不推断为成功。 诊断可单独下载,也会收录到单任务“全部资料”和批次 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 秒,提前完成就立即结束;用户停止、探索指导及任务本身的执行时限仍生效。 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` 关联。请求超时仅重试同一模型请求,保留已取得的工具证据;耗尽请求重试或验收总预算时明确记为未验证,不再进入模型故障后的人工恢复等待。用户停止或探索指导引发的取消单独记录为中断,不重试。超时或中断后的半截输出会从流式展示中移除,只有完整响应经解析、校验后才可能确认通过。 页面在验收期间单独显示证据准备/核对/读取、结论整理、完整性校验阶段,以及已用时间、剩余总预算、请求与实际重试次数。诊断 JSON 的 `final_reviews` 记录每次验收的预算、截止时间和结束原因,相关模型请求通过 `final_review.review_id` 关联。请求超时仅重试同一模型请求,保留已取得的工具证据;耗尽请求重试或验收总预算时明确记为未验证,不再进入模型故障后的人工恢复等待。用户停止或探索指导引发的取消单独记录为中断,不重试。超时或中断后的半截输出会从流式展示中移除,只有完整响应经解析、校验后才可能确认通过。
+37
View File
@@ -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
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "2.7.0" version = "2.7.1"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+70 -2
View File
@@ -181,18 +181,23 @@ def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
exporter.assert_not_called() 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 = service.create_batch(payload())
batch_id = batch["id"] batch_id = batch["id"]
isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True}) isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True})
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip" path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
path.parent.mkdir(parents=True) path.parent.mkdir(parents=True)
path.write_bytes(b"legacy archive") path.write_bytes(b"legacy archive")
if signature:
reports.remember(path, signature)
base = f"/api/automation/batches/{batch_id}/archive" base = f"/api/automation/batches/{batch_id}/archive"
assert client.get(base + "/status").json()["status"] == "idle" assert client.get(base + "/status").json()["status"] == "idle"
response = client.get(base) response = client.get(base)
assert response.status_code == 200 assert response.status_code == 200
assert zipfile.is_zipfile(BytesIO(response.content)) 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" 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"] assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"]
archive = reports.export_batch(batch["id"]) archive = reports.export_batch(batch["id"])
with zipfile.ZipFile(archive) as z: 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")] diagnostics = [name for name in z.namelist() if name.endswith("模型请求诊断.json")]
assert len(diagnostics) == 2 assert len(diagnostics) == 2
for name in diagnostics: 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"} 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( def test_all_material_cache_reuses_reports_and_invalidates_on_review_or_new_files(
isolated, monkeypatch isolated, monkeypatch
): ):
+64
View File
@@ -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 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 @pytest.mark.asyncio
async def test_trace_finalization_failure_does_not_escape(): async def test_trace_finalization_failure_does_not_escape():
agent = object.__new__(Agent) agent = object.__new__(Agent)
+50
View File
@@ -1,6 +1,7 @@
"""Tests for the LLM gateway: complete(), classified recovery, and fallback.""" """Tests for the LLM gateway: complete(), classified recovery, and fallback."""
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
import pytest 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) @pytest.fixture(autouse=True)
def _isolate_gateway_state(monkeypatch, tmp_path): def _isolate_gateway_state(monkeypatch, tmp_path):
llm_service._ENDPOINT_BREAKER._states.clear() llm_service._ENDPOINT_BREAKER._states.clear()
@@ -25,6 +25,7 @@ import pytest
import cv2 import cv2
from artemis.context import ArtemisContext from artemis.context import ArtemisContext
from artemis.config.settings import settings
from artemis.controllers.unified_controller import UnifiedMobileController from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.drivers.mock.mock_driver import MockDeviceDriver from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.utils.video import ( 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 @pytest.mark.asyncio
async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_path): async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_path):
silent_video = tmp_path / "silent.mp4" silent_video = tmp_path / "silent.mp4"
+37 -7
View File
@@ -13,9 +13,9 @@
# limitations under the License. # limitations under the License.
from unittest.mock import MagicMock, patch 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.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 import pytest
@@ -55,7 +55,7 @@ async def test_find_package_cache_hit(mock_hopper, mock_list_packages, mock_cont
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("artemis.tools.mobile.launch_app.list_packages_async") @patch("artemis.tools.mobile.launch_app.list_packages_async")
@patch("artemis.tools.mobile.launch_app.hopper") @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" mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
# Hopper doesn't find it # 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") pkg1 = await find_package(mock_context, "App 3")
assert pkg1 is None assert pkg1 is None
assert mock_hopper.call_count == 1 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.reset_mock()
mock_hopper.return_value = HopperOutput(found=True, output="com.example.app1", reason="Found")
pkg2 = await find_package(mock_context, "App 3") pkg2 = await find_package(mock_context, "App 3")
assert pkg2 is None assert pkg2 == "com.example.app1"
mock_hopper.assert_not_called() mock_hopper.assert_called_once()
@pytest.mark.asyncio @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", data="com.example.app1\ncom.example.app2",
use_fallback=False, 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
Generated
+1 -1
View File
@@ -248,7 +248,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "2.7.0" version = "2.7.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },