From 8bf59897bb89207576b012594ba2e31c9048101b Mon Sep 17 00:00:00 2001 From: chenmingxuan Date: Thu, 5 Nov 2026 11:40:49 +0800 Subject: [PATCH] fix(runtime): release 2.7.1 with consistent ADB and failure diagnostics --- README.md | 2 +- .../services/automation_service.py | 13 +- apps/admin_console/services/report_export.py | 34 ++- apps/showcase_ui/package-lock.json | 4 +- apps/showcase_ui/package.json | 2 +- artemis/agents/hopper/hopper.py | 37 ++-- artemis/controllers/unified_controller.py | 8 +- artemis/drivers/android/adb_driver.py | 5 + artemis/runtime/adb_endpoint.py | 2 + artemis/runtime/diagnostics.py | 207 ++++++++++++++++++ artemis/sdk/agent.py | 15 ++ artemis/services/llm.py | 13 +- artemis/tools/mobile/launch_app.py | 12 +- docs/automation-workspace.md | 2 + docs/releases/2.7.1.md | 37 ++++ pyproject.toml | 2 +- tests/unit/admin_console/test_automation.py | 72 +++++- tests/unit/agents/test_hopper.py | 64 ++++++ .../unit/runtime/test_runtime_diagnostics.py | 144 ++++++++++++ .../unit/sdk/test_agent_trace_finalization.py | 16 ++ tests/unit/test_llm_gateway.py | 50 +++++ tests/unit/test_unified_controller_video.py | 50 +++++ tests/unit/tools/test_launch_app.py | 44 +++- uv.lock | 2 +- 24 files changed, 792 insertions(+), 45 deletions(-) create mode 100644 artemis/runtime/diagnostics.py create mode 100644 docs/releases/2.7.1.md create mode 100644 tests/unit/agents/test_hopper.py create mode 100644 tests/unit/runtime/test_runtime_diagnostics.py diff --git a/README.md b/README.md index 2d556ee..5d6213c 100644 --- a/README.md +++ b/README.md @@ -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 与插件继续独立版本化,升级主程序不会自动更新或授权插件。 ## 主要功能 diff --git a/apps/admin_console/services/automation_service.py b/apps/admin_console/services/automation_service.py index 0c6be11..796e463 100644 --- a/apps/admin_console/services/automation_service.py +++ b/apps/admin_console/services/automation_service.py @@ -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": diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py index 16953fe..0d1dd76 100644 --- a/apps/admin_console/services/report_export.py +++ b/apps/admin_console/services/report_export.py @@ -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( diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json index 9521413..78756e1 100644 --- a/apps/showcase_ui/package-lock.json +++ b/apps/showcase_ui/package-lock.json @@ -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", diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json index 37db472..cee811e 100644 --- a/apps/showcase_ui/package.json +++ b/apps/showcase_ui/package.json @@ -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", diff --git a/artemis/agents/hopper/hopper.py b/artemis/agents/hopper/hopper.py index ad9e23b..1d42b68 100644 --- a/artemis/agents/hopper/hopper.py +++ b/artemis/agents/hopper/hopper.py @@ -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 diff --git a/artemis/controllers/unified_controller.py b/artemis/controllers/unified_controller.py index 4138f8a..9fcd848 100644 --- a/artemis/controllers/unified_controller.py +++ b/artemis/controllers/unified_controller.py @@ -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", diff --git a/artemis/drivers/android/adb_driver.py b/artemis/drivers/android/adb_driver.py index 4609a85..8367cca 100644 --- a/artemis/drivers/android/adb_driver.py +++ b/artemis/drivers/android/adb_driver.py @@ -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, ) diff --git a/artemis/runtime/adb_endpoint.py b/artemis/runtime/adb_endpoint.py index 3646d7c..7136fa8 100644 --- a/artemis/runtime/adb_endpoint.py +++ b/artemis/runtime/adb_endpoint.py @@ -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: diff --git a/artemis/runtime/diagnostics.py b/artemis/runtime/diagnostics.py new file mode 100644 index 0000000..5dfe2f3 --- /dev/null +++ b/artemis/runtime/diagnostics.py @@ -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) diff --git a/artemis/sdk/agent.py b/artemis/sdk/agent.py index 5093183..1f38dea 100644 --- a/artemis/sdk/agent.py +++ b/artemis/sdk/agent.py @@ -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" diff --git a/artemis/services/llm.py b/artemis/services/llm.py index e853e9f..cb6247c 100644 --- a/artemis/services/llm.py +++ b/artemis/services/llm.py @@ -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 response.get("parsing_error") is not None: - raise response["parsing_error"] - return response["parsed"] + if self.structured_mode == "parsed": + if isinstance(response, dict) and "raw" in response: + if response.get("parsing_error") is not None: + 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): diff --git a/artemis/tools/mobile/launch_app.py b/artemis/tools/mobile/launch_app.py index 5c0de07..527b70b 100644 --- a/artemis/tools/mobile/launch_app.py +++ b/artemis/tools/mobile/launch_app.py @@ -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 diff --git a/docs/automation-workspace.md b/docs/automation-workspace.md index af2d27f..f060d75 100644 --- a/docs/automation-workspace.md +++ b/docs/automation-workspace.md @@ -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` 关联。请求超时仅重试同一模型请求,保留已取得的工具证据;耗尽请求重试或验收总预算时明确记为未验证,不再进入模型故障后的人工恢复等待。用户停止或探索指导引发的取消单独记录为中断,不重试。超时或中断后的半截输出会从流式展示中移除,只有完整响应经解析、校验后才可能确认通过。 diff --git a/docs/releases/2.7.1.md b/docs/releases/2.7.1.md new file mode 100644 index 0000000..b623208 --- /dev/null +++ b/docs/releases/2.7.1.md @@ -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 的配置保持原选择。 diff --git a/pyproject.toml b/pyproject.toml index 1a6f480..6e50fb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ [project] name = "artemis" -version = "2.7.0" +version = "2.7.1" description = "Autonomous AI Assistant for Mobile Automation" authors = [ diff --git a/tests/unit/admin_console/test_automation.py b/tests/unit/admin_console/test_automation.py index e9aca44..0271968 100644 --- a/tests/unit/admin_console/test_automation.py +++ b/tests/unit/admin_console/test_automation.py @@ -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 ): diff --git a/tests/unit/agents/test_hopper.py b/tests/unit/agents/test_hopper.py new file mode 100644 index 0000000..460ccfd --- /dev/null +++ b/tests/unit/agents/test_hopper.py @@ -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") diff --git a/tests/unit/runtime/test_runtime_diagnostics.py b/tests/unit/runtime/test_runtime_diagnostics.py new file mode 100644 index 0000000..a71bd36 --- /dev/null +++ b/tests/unit/runtime/test_runtime_diagnostics.py @@ -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() diff --git a/tests/unit/sdk/test_agent_trace_finalization.py b/tests/unit/sdk/test_agent_trace_finalization.py index ee12c09..3dcfd73 100644 --- a/tests/unit/sdk/test_agent_trace_finalization.py +++ b/tests/unit/sdk/test_agent_trace_finalization.py @@ -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) diff --git a/tests/unit/test_llm_gateway.py b/tests/unit/test_llm_gateway.py index bcf01db..3cebcb4 100644 --- a/tests/unit/test_llm_gateway.py +++ b/tests/unit/test_llm_gateway.py @@ -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() diff --git a/tests/unit/test_unified_controller_video.py b/tests/unit/test_unified_controller_video.py index 2722734..f63b308 100644 --- a/tests/unit/test_unified_controller_video.py +++ b/tests/unit/test_unified_controller_video.py @@ -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" diff --git a/tests/unit/tools/test_launch_app.py b/tests/unit/tools/test_launch_app.py index bc45611..da738e2 100644 --- a/tests/unit/tools/test_launch_app.py +++ b/tests/unit/tools/test_launch_app.py @@ -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 diff --git a/uv.lock b/uv.lock index 6ae417c..6de13df 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ wheels = [ [[package]] name = "artemis" -version = "2.7.0" +version = "2.7.1" source = { editable = "." } dependencies = [ { name = "adbutils" },