diff --git a/README_CN.md b/README_CN.md
index 4716a7a..1927ce2 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -28,21 +28,21 @@
3. 自主执行测试 (Autonomous Test Execution)
- 接管真机自动化操作,跳过广告并实时采集性能指标
+ 驱动真机操作、界面导航并实时分析性能指标
|
@@ -78,8 +78,8 @@
## 快速上手
确保电脑已连接 Android 实体机(已开启 **USB 调试**)或 Android 模拟器。一键启动脚本将会自动完成以下配置:
-- 🛠️ **安装系统环境依赖**:自动检测并安装 ADB、scrcpy、FFmpeg 与 Python(`uv`)运行时及项目依赖。
-- 🔌 **全局挂载 MCP 服务与测试准则 (Rules)**:主动引导并自动将全局 MCP 服务与 **Artemis 移动端测试思维准则 (`rules.md`)** 挂载至你使用的 AI IDE(支持 **Antigravity**、**Cursor**、**Claude Code**、**Codex**、**Windsurf**、**VS Code**、**Cline/Roo**、**OpenClaw**)。
+- **安装系统环境依赖**:自动检测并安装 ADB、scrcpy、FFmpeg 与 Python(`uv`)运行时及项目依赖。
+- **全局挂载 MCP 服务与测试准则 (Rules)**:主动引导并自动将全局 MCP 服务与 **Artemis 移动端测试思维准则 (`rules.md`)** 挂载至你使用的 AI IDE(支持 **Antigravity**、**Cursor**、**Claude Code**、**Codex**、**Windsurf**、**VS Code**、**Cline/Roo**、**OpenClaw**)。
### macOS 与 Linux
@@ -109,7 +109,7 @@ cd artemis
-🔌 接入 Codex / Antigravity / Claude Code / Windsurf (MCP)(点击展开)
+Codex / Antigravity / Claude Code / Windsurf MCP 配置(点击展开)
@@ -192,17 +192,17 @@ PYTHONPATH = "/path/to/artemis"
* **Codex**:将内容添加至 `~/.codex/AGENTS.md`(或当前生效的 `AGENTS.override.md`)。
* **Windsurf / OpenClaw**:将内容添加到工作区规则或全局 System Prompt 中。
-> 💡 更多规范设计细节与 MCP 架构说明,请参阅 [MCP Server 文档](./mcp_server/README.md)。
+> 更多规范设计细节与 MCP 架构说明,请参阅 [MCP Server 文档](./mcp_server/README.md)。
### 4. 在 IDE 中体验真机协同
在 Codex / Antigravity / Claude Code 对话框中直接输入:
-> 💬 *"请帮我把刚刚修改的代码编译成 APK 并安装到手机上,打开登录页面输入测试账号,验证登录后是否有异常弹窗,并把最终页面截图回传。"*
+> *"请帮我把刚刚修改的代码编译成 APK 并安装到手机上,打开登录页面输入测试账号,验证登录后是否有异常弹窗,并把最终页面截图回传。"*
-🐍 Python SDK 极简集成(点击展开)
+Python SDK 集成(点击展开)
@@ -238,12 +238,12 @@ if __name__ == "__main__":
- 控制台功能布局:① 顶栏视图切换(主页与工作区) · ② 运行模式与录屏回放(Flash/Pro 状态与视频回放) · ③ 实时感知推理流(动作分解、点击坐标与结构化总结) · ④ 自然语言下发胶囊(自然语言驱动真机) · ⑤ 任务队列看板(状态流转与历史回溯)
+ 控制台功能概览:① 视图切换(主页与工作区) · ② 运行模式与回放(Flash/Pro 状态与视频回放) · ③ 实时感知推理流(动作感知、目标坐标与结构化总结) · ④ 提示词输入坞 (Prompt Dock)(自然语言下发) · ⑤ 任务队列看板(生命周期与历史回溯)
* **Web 可视化测试控制台 (`uv run artemis ui`)**:集成设备实时投屏与交互面板,支持通过自然语言下发测试用例,实时观测推理步骤、操作轨迹、截图留存与异常状态回放;
-* **MCP 协议集成 (AI IDE 协同)**:作为标准 MCP 服务器无缝接入 **Antigravity、Claude Code、Windsurf** 等开发环境,在 IDE 中直接驱动真机完成自动化测试与 Bug 复现验证;
-* **命令行工具 (`uv run artemis run`)**:支持通过 CLI 直接执行自动化用例、稳定性巡检或 AndroidWorld 基准评测,提供高保真结构化终端输出;
+* **原生 MCP 协议 (IDE 协同)**:作为标准 MCP 服务器无缝接入 **Antigravity、Claude Code、Windsurf** 等开发环境,在 IDE 中直接驱动真机完成自动化测试与 Bug 复现验证;
+* **开发者命令行 CLI (`uv run artemis run`)**:支持通过终端直接执行自动化测试用例、探索性稳定性巡检或 AndroidWorld 基准评测,提供高保真结构化终端输出;
* **Python SDK**:作为标准 Python 库无缝集成至现有自动化测试框架(如 pytest)或 CI/CD 流水线,提供基于 Pydantic 的强类型结构化结果与断言支持。
diff --git a/apps/admin_console/core/state.py b/apps/admin_console/core/state.py
index 452b485..9aafe2f 100644
--- a/apps/admin_console/core/state.py
+++ b/apps/admin_console/core/state.py
@@ -204,3 +204,9 @@ class ServerState:
# Global shared instance
state = ServerState()
+
+import sys
+if __name__ == "admin_console.core.state":
+ sys.modules["apps.admin_console.core.state"] = sys.modules[__name__]
+elif __name__ == "apps.admin_console.core.state":
+ sys.modules["admin_console.core.state"] = sys.modules[__name__]
diff --git a/apps/admin_console/database/repositories/session_repository.py b/apps/admin_console/database/repositories/session_repository.py
index 906c0ac..2ff4988 100644
--- a/apps/admin_console/database/repositories/session_repository.py
+++ b/apps/admin_console/database/repositories/session_repository.py
@@ -141,6 +141,22 @@ class SessionRepository:
except Exception:
return False
+ def mark_recording_ready(self, session_id: str, local_video_path: str) -> bool:
+ """Mark a recording as ready with its finalized video path."""
+ try:
+ with db_session(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "UPDATE video_recordings SET status = 'ready', error = NULL, "
+ "local_video_path = ?, end_time = COALESCE(end_time, ?) "
+ "WHERE session_id = ?",
+ (str(local_video_path), time.time(), str(session_id)),
+ )
+ conn.commit()
+ return cursor.rowcount > 0
+ except Exception:
+ return False
+
def get_llm_traces_for_profile(self, session_id: str, limit: int = 3) -> list[str]:
with db_session(self.db_path) as conn:
cursor = conn.cursor()
diff --git a/apps/admin_console/routers/media.py b/apps/admin_console/routers/media.py
index fb8dc33..285941c 100644
--- a/apps/admin_console/routers/media.py
+++ b/apps/admin_console/routers/media.py
@@ -102,14 +102,18 @@ async def get_session_video(session_id: str):
}
if recording_status == "failed":
- return {
- "session_id": session_id,
- "status": "failed",
- "has_video": False,
- "video_url": None,
- "video_segments": [],
- "message": recording.get("error") or "Recording finalization failed",
- }
+ v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
+ if not v_url:
+ return {
+ "session_id": session_id,
+ "status": "failed",
+ "has_video": False,
+ "video_url": None,
+ "video_segments": [],
+ "message": recording.get("error") or "Recording finalization failed",
+ }
+ # If a video was recovered/found, update DB to ready and proceed to serve it
+ session_repo.mark_recording_ready(session_id, v_url)
v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
video_segments = media_service.resolve_video_segments(v_url)
diff --git a/apps/admin_console/routers/sessions.py b/apps/admin_console/routers/sessions.py
index 79d5ae1..91c71f9 100644
--- a/apps/admin_console/routers/sessions.py
+++ b/apps/admin_console/routers/sessions.py
@@ -63,12 +63,17 @@ async def list_sessions():
orphaned_ids.append(s_id)
recording_status = str((recording or {}).get("status") or "unavailable")
- row_dict["recording_status"] = recording_status
- row_dict["video_url"] = (
+ resolved_v_url = (
media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
- if recording_status in ("ready", "unavailable")
+ if recording_status != "recording"
else None
)
+ if resolved_v_url:
+ recording_status = "ready"
+ row_dict["video_url"] = resolved_v_url
+ else:
+ row_dict["video_url"] = None
+ row_dict["recording_status"] = recording_status
agent_names = agent_names_by_session.get(s_id, [])
sess_profile = model_service.resolve_session_profile(
diff --git a/apps/admin_console/services/media_service.py b/apps/admin_console/services/media_service.py
index 807c91e..6e2883a 100644
--- a/apps/admin_console/services/media_service.py
+++ b/apps/admin_console/services/media_service.py
@@ -17,6 +17,7 @@ import hashlib
import json
import os
from pathlib import Path
+import subprocess
from typing import Any
import urllib.parse
from fastapi import HTTPException
@@ -30,6 +31,72 @@ except ImportError:
class MediaService:
"""Service handling media indexing, local file security, payload unwrapping and notes/plans."""
+ @classmethod
+ def ensure_browser_playable_video(cls, p: Path) -> Path:
+ """Ensure the video file is in a browser-supported container (MP4).
+
+ If given an MKV, check if a converted MP4 already exists or convert it.
+ """
+ try:
+ if not p.exists():
+ return p
+ suffix = p.suffix.lower()
+ if suffix == ".mp4":
+ return p
+ if suffix in (".mkv", ".webm"):
+ mp4_cand = p.with_suffix(".mp4")
+ if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
+ return mp4_cand
+ from artemis.utils.video import get_ffmpeg_path
+ ffmpeg = get_ffmpeg_path()
+ # 1. Attempt fast copy remux
+ subprocess.run(
+ [
+ ffmpeg,
+ "-y",
+ "-fflags",
+ "+genpts",
+ "-i",
+ str(p),
+ "-c",
+ "copy",
+ "-movflags",
+ "+faststart",
+ str(mp4_cand),
+ ],
+ capture_output=True,
+ timeout=15,
+ )
+ if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
+ return mp4_cand
+ # 2. If copy remux failed, fallback to ultrafast re-encode
+ subprocess.run(
+ [
+ ffmpeg,
+ "-y",
+ "-fflags",
+ "+genpts+discardcorrupt",
+ "-i",
+ str(p),
+ "-c:v",
+ "libx264",
+ "-preset",
+ "ultrafast",
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ str(mp4_cand),
+ ],
+ capture_output=True,
+ timeout=30,
+ )
+ if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
+ return mp4_cand
+ except Exception:
+ pass
+ return p
+
@staticmethod
def path_to_video_url(p: Path) -> str:
resolved = p.resolve()
@@ -56,12 +123,14 @@ class MediaService:
for ext in [".mp4", ".mkv", ".webm"]:
vfile = item / f"recording{ext}"
if vfile.exists():
+ vfile = cls.ensure_browser_playable_video(vfile)
url = cls.path_to_video_url(vfile)
idx[item.name] = url
prefix = item.name.split("_PASS_")[0].split("_FAIL_")[0]
idx[prefix] = url
break
elif item.suffix in [".mp4", ".mkv", ".webm"]:
+ item = cls.ensure_browser_playable_video(item)
url = cls.path_to_video_url(item)
idx[item.stem] = url
idx[item.name] = url
@@ -85,12 +154,14 @@ class MediaService:
v_url = None
v_fp = row_dict.get("video_filepath")
if v_fp and os.path.exists(v_fp):
- v_url = cls.path_to_video_url(Path(v_fp))
+ p = cls.ensure_browser_playable_video(Path(v_fp))
+ v_url = cls.path_to_video_url(p)
orig_rec = video_rec_map.get(s_id)
if not v_url and orig_rec:
p = Path(orig_rec)
if p.exists():
+ p = cls.ensure_browser_playable_video(p)
v_url = cls.path_to_video_url(p)
else:
folder_name = p.parent.name
@@ -105,12 +176,14 @@ class MediaService:
for ext in [".mp4", ".webm", ".mkv"]:
vfile = sess_dir / f"recording{ext}"
if vfile.exists():
+ vfile = cls.ensure_browser_playable_video(vfile)
v_url = cls.path_to_video_url(vfile)
break
if not v_url:
try:
for item in sess_dir.iterdir():
if item.is_file() and item.suffix.lower() in [".mp4", ".webm", ".mkv"]:
+ item = cls.ensure_browser_playable_video(item)
v_url = cls.path_to_video_url(item)
break
except Exception:
diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py
index 99f35f8..0570201 100644
--- a/apps/admin_console/services/task_queue_service.py
+++ b/apps/admin_console/services/task_queue_service.py
@@ -32,6 +32,7 @@ try:
)
from admin_console.core.state import state
from admin_console.database.repositories.session_repository import session_repo
+ from admin_console.services.media_service import media_service
from artemis.runtime import DeviceExecutionLock, process_supervisor
except ImportError:
from apps.admin_console.core.config import (
@@ -42,6 +43,7 @@ except ImportError:
)
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
+ from apps.admin_console.services.media_service import media_service
from artemis.runtime import DeviceExecutionLock, process_supervisor
@@ -199,6 +201,28 @@ class TaskQueueService:
except ProcessLookupError:
pass
+ @staticmethod
+ async def _wait_for_worker_process(proc: asyncio.subprocess.Process) -> int:
+ """Wait for worker process to exit, with watchdog fallback if PID was reaped externally."""
+ while True:
+ try:
+ return await asyncio.wait_for(proc.wait(), timeout=1.0)
+ except asyncio.TimeoutError:
+ pid = getattr(proc, "pid", None)
+ if pid:
+ try:
+ import psutil
+
+ p = psutil.Process(pid)
+ if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE:
+ return proc.returncode if proc.returncode is not None else -15
+ except (psutil.NoSuchProcess, ProcessLookupError):
+ return proc.returncode if proc.returncode is not None else -15
+ except Exception:
+ pass
+ else:
+ return proc.returncode if proc.returncode is not None else -15
+
@classmethod
def ensure_worker_running(cls):
"""Guarantees that the background queue worker task is active and running."""
@@ -341,7 +365,7 @@ class TaskQueueService:
await cls._terminate_worker_process(proc)
# 3. Await subprocess completion
- returncode = await proc.wait()
+ returncode = await cls._wait_for_worker_process(proc)
print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
# 4. Perform fallback database status update and notification
@@ -360,12 +384,36 @@ class TaskQueueService:
f"[QueueWorker] Preserved authoritative session {sess_id} "
f"status '{new_status}'"
)
- recording_error = "Task worker exited before recording finalization completed"
- if session_repo.mark_recording_failed_if_pending(sess_id, recording_error):
- cls._broadcast_event(
- "recording_failed",
- {"session_id": sess_id, "error": recording_error},
- )
+ rec_info = session_repo.get_video_recording_for_session(sess_id)
+ rec_status = (rec_info or {}).get("status")
+ recovered_video_path = None
+ if rec_status != "ready":
+ try:
+ video_rec_map = session_repo.get_video_recordings_map()
+ video_idx = await asyncio.to_thread(media_service.build_video_index)
+ recovered_url = await asyncio.to_thread(
+ media_service.resolve_video_url,
+ {"session_id": sess_id},
+ video_rec_map,
+ video_idx,
+ )
+ if recovered_url:
+ session_repo.mark_recording_ready(sess_id, recovered_url)
+ cls._broadcast_event(
+ "recording_ready",
+ {"session_id": sess_id, "video_url": recovered_url},
+ )
+ recovered_video_path = recovered_url
+ except Exception as rec_err:
+ print(f"[QueueWorker] Error attempting recording recovery: {rec_err}")
+
+ if not recovered_video_path and rec_status != "ready":
+ recording_error = "Task worker exited before recording finalization completed"
+ if session_repo.mark_recording_failed_if_pending(sess_id, recording_error):
+ cls._broadcast_event(
+ "recording_failed",
+ {"session_id": sess_id, "error": recording_error},
+ )
state.active_connections.pop(sess_id, None)
cls._broadcast_event(
"session_ended",
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
index dda92c9..17be5d7 100644
--- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
+++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
@@ -263,28 +263,41 @@
[class.recording]="agentService.isCurrentSessionRunning()"
[class.processing]="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'"
[class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()"
+ [class.has-steps]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()"
+ [class.failed]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() === 'failed'"
(click)="agentService.toggleVideoPlayer()"
- [title]="agentService.isCurrentSessionRunning() ? 'Task is currently running (Recording screen)' : (agentService.currentSessionRecordingStatus() === 'processing' ? 'Preparing screen recording' : (agentService.currentSessionVideoUrl() ? 'Play Screen Recording' : 'Check Device Screen Recording'))"
+ [title]="getScreenRecordingButtonTitle()"
>
videocam
-
+
progress_activity
smart_display
-
+
+ slideshow
+
+
+ videocam_off
+
+
videocam
Recording...
Preparing...
Play Recording
- Screen Recording
+ Step Replay
+ Recording Failed
+ Screen Recording
+
+ {{ agentService.currentSessionStepFrames().length }}
+
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss
index 8abd6f6..159858a 100644
--- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss
+++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss
@@ -936,6 +936,56 @@ $stream-row-height: 25px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
+ &.has-steps {
+ background: rgba(240, 253, 250, 0.9);
+ border-color: rgba(94, 234, 212, 0.7);
+ box-shadow: 0 2px 6px -1px rgba(13, 148, 136, 0.12), inset 0 1px 1px rgba(255, 255, 255, 0.9);
+
+ .steps-icon {
+ color: #0d9488;
+ }
+
+ .btn-text {
+ color: #0f766e;
+ }
+
+ &:hover:not(.active) {
+ background: rgba(204, 251, 241, 0.95);
+ border-color: rgba(45, 212, 191, 0.85);
+
+ .steps-icon {
+ color: #0f766e;
+ }
+ }
+ }
+
+ &.failed {
+ background: rgba(254, 242, 242, 0.8);
+ border-color: rgba(254, 202, 202, 0.9);
+
+ .error-icon {
+ color: #ef4444;
+ }
+
+ .btn-text {
+ color: #dc2626;
+ }
+ }
+
+ .steps-count-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1px 5px;
+ border-radius: 10px;
+ background: #0d9488;
+ color: #ffffff;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ margin-left: 2px;
+ }
+
&.active {
background: rgba(26, 115, 232, 0.95);
border-color: rgba(26, 115, 232, 0.95);
@@ -949,6 +999,11 @@ $stream-row-height: 25px;
background: #ffffff;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.9);
}
+
+ .steps-count-badge {
+ background: #ffffff;
+ color: #1a73e8;
+ }
}
}
}
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
index 52c0afe..977a33a 100644
--- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
+++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
@@ -936,6 +936,27 @@ export class AgentStreamComponent implements AfterViewInit {
}
}
+ public getScreenRecordingButtonTitle(): string {
+ if (this.agentService.isCurrentSessionRunning()) {
+ return 'Task is currently running (Recording screen)';
+ }
+ if (this.agentService.currentSessionRecordingStatus() === 'processing') {
+ return 'Preparing screen recording...';
+ }
+ if (this.agentService.currentSessionVideoUrl()) {
+ return 'Play Screen Recording Video';
+ }
+ if (this.agentService.hasCurrentSessionStepFrames()) {
+ return this.agentService.currentSessionRecordingStatus() === 'failed'
+ ? 'Video recording failed — Click to replay step-by-step screenshots'
+ : 'Play step-by-step screenshots replay';
+ }
+ if (this.agentService.currentSessionRecordingStatus() === 'failed') {
+ return 'Screen recording generation failed';
+ }
+ return 'Screen Recording';
+ }
+
public isDeviceActionTool(tool: any): boolean {
return isDeviceActionTool(tool);
}
diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html
index cc240fd..37ecae2 100644
--- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html
+++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html
@@ -33,16 +33,38 @@
|