Compare commits

...
3 Commits
48 changed files with 1226 additions and 272 deletions

No files matched your search

+2
View File
@@ -19,6 +19,8 @@ ARTEMIS_OUTPUTTER_ENABLED=true
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
# 视频分段、重试与单次分析超时(秒)
ARTEMIS_VIDEO_CHUNK_SECONDS=60
+5 -4
View File
@@ -29,9 +29,10 @@ cd Artemis-Eapil
常用选项:
```powershell
.\start-cn.bat -SetupOnly # 仅准备 Python 环境并检查已有 MCP 配置
.\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 MCP
.\start.bat -Port 8001 # 指定端口
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
```
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
@@ -55,13 +56,13 @@ cd Artemis-Eapil
## 让 AI 使用
环境与模型配置完成后,例如为 Codex 接入 MCP:
Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex:
```sh
uv run artemis mcp --install codex
```
在客户端重新加载 MCP,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
安装或更新后,在客户端重新加载 MCP 或重启客户端,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
示例指令:
@@ -69,7 +70,7 @@ uv run artemis mcp --install codex
## 更新与排错
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建和已有 MCP 配置。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步已检测客户端的 MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。MCP 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 AI 客户端重新加载 MCP。
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
+14 -6
View File
@@ -128,7 +128,7 @@ def delete_batch(batch_id: str):
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
try:
if not store.delete_batch(key):
if not service.delete_batch(key):
raise HTTPException(404, "批次不存在")
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
@@ -166,7 +166,8 @@ async def download_report(
try:
if fmt in {"screenshots", "video", "all"}:
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
return FileResponse(path, filename=f"任务{session_id[:8]}-{fmt}.zip")
label = report_export.archive_case_name(report_export.session_report(session_id))
return FileResponse(path, filename=f"{label}-{fmt}.zip")
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
except ValueError as exc:
@@ -184,13 +185,15 @@ def history(
):
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
query = """WITH history AS (
SELECT s.session_id, s.initial_goal, s.start_time, s.end_time,
SELECT s.session_id, s.initial_goal, s.start_time,
COALESCE(json_extract(j.payload,'$.ended_at'),s.end_time) end_time,
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
COALESCE(json_extract(j.payload,'$.case.tags'),'') tags,
COALESCE(json_extract(j.payload,'$.case.case_id'),'') case_id,
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
COALESCE(json_extract(j.payload,'$.error'),'') error
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
UNION ALL
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
@@ -200,7 +203,8 @@ def history(
COALESCE(json_extract(j.payload,'$.case.case_id'),''),
COALESCE(json_extract(j.payload,'$.case.category'),''),
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
COALESCE(json_extract(j.payload,'$.case.preconditions'),'')
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
COALESCE(json_extract(j.payload,'$.error'),'')
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
) """
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
@@ -226,4 +230,8 @@ def history(
+ " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?",
[*args, size, (page - 1) * size],
).fetchall()
return {"items": [dict(r) for r in rows], "total": total, "page": page, "size": size}
items = [dict(row) for row in rows]
for item in items:
if item["case_id"]:
item["name"] = task_import.case_title(item)
return {"items": items, "total": total, "page": page, "size": size}
+14
View File
@@ -27,12 +27,16 @@ try:
from admin_console.database.repositories.trace_repository import trace_repo
from admin_console.services.media_service import media_service
from admin_console.services.model_service import model_service
from admin_console.services.automation_store import store
from admin_console.services.task_import import case_title
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.database.repositories.trace_repository import trace_repo
from apps.admin_console.services.media_service import media_service
from apps.admin_console.services.model_service import model_service
from apps.admin_console.services.automation_store import store
from apps.admin_console.services.task_import import case_title
router = APIRouter(tags=["sessions"])
@@ -52,6 +56,7 @@ async def list_sessions():
def _list_sessions_sync():
rows = session_repo.get_all_sessions()
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map()
@@ -74,6 +79,15 @@ def _list_sessions_sync():
for row_dict in rows:
s_id = str(row_dict.get("session_id"))
job = jobs_by_id.get(s_id)
if job:
case = job.get("case") or {}
row_dict["case_id"] = case.get("case_id") or ""
row_dict["display_name"] = case_title(case) or row_dict.get("initial_goal")
row_dict["error"] = job.get("error") or ""
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
row_dict["status"] = job["status"]
row_dict["end_time"] = job.get("ended_at") or row_dict.get("end_time")
recording = latest_recordings.get(s_id)
d_info_raw = row_dict.get("device_info")
device_id = None
@@ -3,11 +3,13 @@
import asyncio
import logging
import os
import shutil
import time
from uuid import UUID
from apps.admin_console.core.state import state
from apps.admin_console.services.automation_store import store, new_id
from apps.admin_console.services.task_import import case_goal
from apps.admin_console.services.task_import import case_goal, case_title
logger = logging.getLogger(__name__)
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
@@ -82,9 +84,9 @@ async def on_terminal(item: dict, status: str):
job = store.job(sid)
if job is None:
raise RuntimeError("任务元数据写入失败")
job.update(status="finalizing", ended_at=time.time())
if job.get("timed_out"):
status = "failed"
job.update(status="finalizing", terminal_status=status, ended_at=time.time())
store.save_job(job)
try:
job["status"] = status
@@ -99,7 +101,9 @@ async def on_terminal(item: dict, status: str):
}
):
job["status"] = "failed"
job["error"] = "任务执行结束,但业务检查未通过"
job["error"] = job.get("error") or "任务执行结束,但业务检查未通过"
elif job["status"] == "failed" and report.get("error") and not job.get("error"):
job["error"] = report["error"]
# Persist terminal classification before export, but the coordinator waits for finalization.
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
await asyncio.to_thread(export_session, sid, job["formats"])
@@ -107,6 +111,7 @@ async def on_terminal(item: dict, status: str):
except Exception as exc:
logger.exception("Report finalization failed for %s", sid)
job["report_error"] = str(exc)[:500]
job.pop("terminal_status", None)
store.save_job(job)
@@ -125,6 +130,11 @@ async def tick_batches():
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
store.put("batch", batch["id"], batch)
for job in active:
# The queue records started_at at submission, then replaces it
# when the worker actually starts. Waiting for a device must
# not consume this case's execution budget.
if job["status"] != "running":
continue
limit = int(
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
or batch.get("timeout_seconds", 1800)
@@ -134,15 +144,16 @@ async def tick_batches():
if task_state.get("non_execution_started_at"):
excluded += time.time() - float(task_state["non_execution_started_at"])
if (
job["status"] != "finalizing"
and not job.get("timed_out")
not job.get("timed_out")
and time.time() - job.get("started_at", time.time()) - excluded > limit
):
job.update(error="任务达到批次配置的执行时限", timed_out=True)
job.update(
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
)
store.save_job(job)
task_queue_service.stop_tasks(session_id=job["session_id"])
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态")
store.put("batch", batch["id"], batch)
# on_terminal records the failed case. The next pending
# case then runs normally unless stop_on_failure is set.
continue
if batch["status"] != "running":
continue
@@ -202,6 +213,7 @@ async def tick_batches():
runtime_overrides=options,
session_id=job["session_id"],
ingress="batch",
display_name=case_title(job["case"]),
)
if response.get("status") == "rejected" or not response.get("tasks"):
raise ValueError(response.get("error") or "队列拒绝接收任务")
@@ -265,6 +277,35 @@ def create_batch(payload: dict) -> dict:
return store.create_batch(batch, jobs, payload)
def delete_batch(batch_id: str) -> bool:
"""Delete an inactive batch, its sessions and its generated reports."""
from artemis.data_engine.storage import StorageManager
from apps.admin_console.services import report_export
jobs = store.begin_delete_batch(batch_id)
if jobs is None:
return False
storage = StorageManager(store.path, report_export.TRACES_PATH)
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
with report_export.EXPORT_LOCK:
for job in jobs:
session_id = str(UUID(job["session_id"]))
storage.delete_session(UUID(session_id))
report_dir = report_export.EXPORT_ROOT / "sessions" / session_id
if report_dir.is_dir():
shutil.rmtree(report_dir)
mcp_session_dir = mcp_exports / "sessions" / session_id
if mcp_session_dir.is_dir():
shutil.rmtree(mcp_session_dir)
batch_dir = report_export.EXPORT_ROOT / "batches" / batch_id
if batch_dir.is_dir():
shutil.rmtree(batch_dir)
mcp_batch_dir = mcp_exports / "batches" / batch_id
if mcp_batch_dir.is_dir():
shutil.rmtree(mcp_batch_dir)
return store.delete_batch(batch_id)
def batch_action(batch_id: str, action: str) -> dict:
batch = store.get("batch", batch_id)
if not batch:
+68 -12
View File
@@ -54,6 +54,13 @@ class AutomationStore:
def put(self, kind: str, key: str, payload: dict) -> dict:
with self.connection() as conn:
if kind == "batch":
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (key,)
).fetchone()
if row is None or json.loads(row[0]).get("status") == "deleting":
raise ValueError("批次正在删除或已删除,不能继续更新")
conn.execute(
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
(kind, key, json.dumps(payload, ensure_ascii=False), time.time()),
@@ -78,6 +85,14 @@ class AutomationStore:
def save_job(self, payload: dict) -> dict:
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
if batch_id := payload.get("batch_id"):
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
).fetchone()
if row is None or json.loads(row[0]).get("status") == "deleting":
raise ValueError("批次正在删除,不能继续派发任务")
conn.execute(
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at",
(
@@ -106,6 +121,25 @@ class AutomationStore:
)
return [{**json.loads(row[0]), "status": row[1]} for row in rows]
def jobs_for_sessions(self, session_ids: list[str]) -> dict[str, dict]:
"""Load only metadata belonging to the sessions shown in history."""
result = {}
with self.connection() as conn:
for offset in range(0, len(session_ids), 400):
chunk = session_ids[offset : offset + 400]
markers = ",".join("?" for _ in chunk)
rows = conn.execute(
f"SELECT session_id,payload,status FROM automation_jobs WHERE session_id IN ({markers})",
chunk,
).fetchall()
result.update(
{
row["session_id"]: {**json.loads(row["payload"]), "status": row["status"]}
for row in rows
}
)
return result
def preferences(self) -> dict:
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
@@ -164,8 +198,38 @@ class AutomationStore:
conn.commit()
return batch
def begin_delete_batch(self, batch_id: str) -> list[dict] | None:
"""Reserve an inactive batch for cleanup before any more jobs can start."""
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
).fetchone()
if row is None:
return None
batch = json.loads(row[0])
active = conn.execute(
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
(batch_id,),
).fetchone()
if batch["status"] == "running" or active:
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
batch["status"] = "deleting"
conn.execute(
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
(json.dumps(batch, ensure_ascii=False), time.time(), batch_id),
)
jobs = conn.execute(
"SELECT payload,status FROM automation_jobs WHERE batch_id=? ORDER BY position",
(batch_id,),
).fetchall()
conn.commit()
return [{**json.loads(job[0]), "status": job[1]} for job in jobs]
def delete_batch(self, batch_id: str) -> bool:
"""Remove a batch record; retain executed job metadata and its reports."""
"""Remove a batch record and all of its jobs after cleanup."""
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
@@ -175,17 +239,9 @@ class AutomationStore:
if row is None:
return False
batch = json.loads(row[0])
active = conn.execute(
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
(batch_id,),
).fetchone()
if batch["status"] == "running" or active:
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
conn.execute(
"DELETE FROM automation_jobs WHERE batch_id=? AND status='pending'",
(batch_id,),
)
if batch.get("status") != "deleting":
raise ValueError("批次尚未准备删除")
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
conn.execute(
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
+25 -6
View File
@@ -22,6 +22,7 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
from apps.admin_console.database.repositories.session_repository import SessionRepository
from apps.admin_console.database.repositories.step_repository import StepRepository
from apps.admin_console.services.automation_store import store, identifier
from apps.admin_console.services.task_import import case_title
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
EXPORT_LOCK = threading.RLock()
@@ -40,6 +41,14 @@ STATUS = {
}
def archive_case_name(report: dict) -> str:
"""Keep case identifiers readable without allowing ZIP paths or Windows-invalid names."""
fallback = str(report.get("id") or "task")[:8]
candidate = str(report.get("case_id") or fallback).strip()
candidate = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", candidate).strip(" .")
return candidate[:80] or fallback
def read_json(path: Path, fallback=None):
try:
return json.loads(path.read_text(encoding="utf-8"))
@@ -83,6 +92,9 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
return redact(recorded)
if status not in {"failed", "interrupted"}:
return ""
state = read_json(directory / "status.json", {})
if isinstance(state, dict) and state.get("error"):
return redact(state["error"])
for name in ("stdout.log", "stderr.log"):
path = directory / name
try:
@@ -96,6 +108,8 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
match = re.search(r"Error running automation:\s*(.+)", line)
if match:
return redact(match.group(1))
if re.search(r"(?i)(?:timeout|timed out|超时|deadline exceeded)", line):
return redact("模型请求或任务执行超时:" + line.strip())
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
@@ -142,7 +156,7 @@ def session_report(session_id: str, db_path=None) -> dict:
)
return {
"id": session_id,
"name": case.get("name") or session.get("initial_goal", session_id)[:120],
"name": case_title(case) or session.get("initial_goal", session_id)[:120],
"goal": case.get("goal") or session.get("initial_goal", ""),
"expected": case.get("expected", ""),
"status": status,
@@ -638,15 +652,18 @@ def export_session_media(session_id: str, kind: str) -> Path:
"videos": [name for _, name in videos],
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
}
prefix = archive_case_name(report) + "/" if kind == "all" else ""
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
if kind == "all":
for path in export_session(session_id, ["docx", "xlsx"]):
archive.write(path, path.name)
archive.write(path, prefix + path.name)
for source, name in images:
archive.write(source, f"步骤截图/{name}")
archive.write(source, f"{prefix}步骤截图/{name}")
for source, name in videos:
archive.write(source, f"任务录屏/{name}")
archive.writestr("文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2))
archive.write(source, f"{prefix}任务录屏/{name}")
archive.writestr(
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
)
temporary.replace(target)
return target
@@ -691,6 +708,8 @@ def export_batch(batch_id: str) -> Path:
archive.write(path, path.name)
for number, report in enumerate(records, 1):
for path in export_session(report["id"], formats):
archive.write(path, f"任务报告/{number:04d}_{report['id']}/{path.name}")
archive.write(
path, f"任务报告/{number:04d}_{archive_case_name(report)}/{path.name}"
)
temporary.replace(target)
return target
@@ -177,3 +177,10 @@ def parse_workbook(data: bytes) -> dict:
def case_goal(case: dict) -> str:
"""Execution gets the prompt verbatim; metadata stays outside model input."""
return case["goal"]
def case_title(case: dict) -> str:
"""Human-facing title; never prepend metadata to the model prompt."""
case_id = str(case.get("case_id") or "").strip()
name = str(case.get("name") or "").strip()
return " ".join(part for part in (case_id, name) if part)
@@ -1133,6 +1133,7 @@ class TaskQueueService:
verification_level: str | None = None,
explorer_mode: str | None = None,
runtime_overrides: dict[str, Any] | None = None,
display_name: str | None = None,
) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker.
@@ -1189,6 +1190,8 @@ class TaskQueueService:
)
if runtime_overrides:
task_item["runtime_overrides"] = runtime_overrides
if display_name:
task_item["display_name"] = display_name
state.queue_items.append(task_item)
enqueued_tasks.append(task_item)
cls._broadcast_startup_progress(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "artemis-showcase-ui",
"version": "1.1.1",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artemis-showcase-ui",
"version": "1.1.1",
"version": "1.2.1",
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "1.1.1",
"version": "1.2.1",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -76,6 +76,7 @@ limitations under the License.
class="dropdown-item-card"
[class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'"
[ngClass]="getTaskStatus(session)"
(click)="selectTask(session.session_id, $event)"
>
<div class="item-header">
@@ -96,7 +97,7 @@ limitations under the License.
<span class="item-time-divider">·</span>
}
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') {
@if (['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
<button
type="button"
class="btn-stop-card"
@@ -107,7 +108,7 @@ limitations under the License.
<span class="material-symbols-outlined">stop_circle</span>
</button>
}
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
@if (!['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
<button
type="button"
class="btn-delete-card"
@@ -120,12 +121,15 @@ limitations under the License.
}
</div>
</div>
<div class="item-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || '未命名任务' }}
<div class="item-goal" [title]="session.initial_goal || ''">
{{ session.display_name || session.initial_goal || '未命名任务' }}
</div>
@if (session.error && getTaskStatus(session) === 'failed') {
<div class="item-error" [title]="session.error">{{session.error}}</div>
}
<div class="item-footer">
<app-task-id-copy [taskId]="session.session_id" />
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
</div>
</div>
}
@@ -3684,6 +3684,12 @@ $stream-row-height: 25px;
&.running {
border-left: 3.5px solid #2563eb;
}
&.paused, &.task_paused { border-left: 3.5px solid #d97706; background: #fffbeb; }
&.resuming, &.queued { border-left: 3.5px solid #7c3aed; }
&.failed { border-left: 3.5px solid #dc2626; background: #fff7f7; }
&.completed { border-left: 3.5px solid #16a34a; }
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
.item-error { color: #b91c1c; font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
.item-header {
display: flex;
@@ -3729,12 +3735,24 @@ $stream-row-height: 25px;
border-color: #fdba74;
&::before { background-color: #f97316; }
}
&.task_paused {
background: #ffedd5;
color: #9a3412;
border-color: #fb923c;
&::before { background-color: #ea580c; }
}
&.resuming, &.queued {
background: #f3e8ff;
color: #6b21a8;
border-color: #c4b5fd;
&::before { background-color: #7c3aed; }
}
&.completed {
background: #fafafa;
color: #1f1f1f;
border-color: #e2e8f0;
background: #dcfce7;
color: #166534;
border-color: #86efac;
&::before {
background-color: #1f1f1f;
background-color: #16a34a;
}
}
&.pending {
@@ -3746,21 +3764,22 @@ $stream-row-height: 25px;
}
}
&.failed {
background: #fafafa;
color: #ef4444;
border-color: #fee2e2;
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
&::before {
background-color: #ef4444;
}
}
&.cancelled {
background: #fafafa;
color: #d97706;
border-color: #fef3c7;
background: #ffedd5;
color: #9a3412;
border-color: #fdba74;
&::before {
background-color: #f59e0b;
background-color: #ea580c;
}
}
&.interrupted { background: #fef3c7; color: #92400e; border-color: #fbbf24; &::before { background-color: #d97706; } }
.status-dot {
display: none;
@@ -388,13 +388,13 @@ export class AgentStreamComponent implements AfterViewInit {
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'running' || status === 'paused' || status === 'pending';
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status);
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b);
const isRunA = statusA === 'running' || statusA === 'paused';
const isRunB = statusB === 'running' || statusB === 'paused';
const isRunA = ['running', 'paused', 'task_paused', 'resuming'].includes(statusA);
const isRunB = ['running', 'paused', 'task_paused', 'resuming'].includes(statusB);
if (isRunA && !isRunB) return -1;
if (!isRunA && isRunB) return 1;
return (a.start_time || 0) - (b.start_time || 0);
@@ -404,7 +404,7 @@ export class AgentStreamComponent implements AfterViewInit {
public historyTasks = computed(() => {
return this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'completed' || status === 'failed' || status === 'cancelled';
return ['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(status);
});
});
@@ -491,7 +491,7 @@ export class AgentStreamComponent implements AfterViewInit {
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
.includes(this.agentService.currentSession()?.status || '');
if (event.status === 'pending') {
return terminal ? '任务结束前未进入模型' : '已接收,等待下一步';
return terminal ? '任务结束前未进入模型' : '已接收,正在打断当前模型步骤';
}
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
}
@@ -912,14 +912,14 @@ export class AgentStreamComponent implements AfterViewInit {
return tuningLabel(kind, id);
}
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
public getTaskStatus(session: Session): string {
if (session.status) {
const s = session.status.toLowerCase();
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
return (s === 'success' ? 'completed' : s) as any;
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
return s === 'success' ? 'completed' : s;
}
if (s === 'running' || s === 'paused' || s === 'pending') {
return s as any;
if (['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(s)) {
return s;
}
}
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
@@ -1360,6 +1360,14 @@ export class AgentStreamComponent implements AfterViewInit {
return isAndroidAction(action);
}
public formatTaskDuration(start?: number, end?: number): string {
if (!start || !Number.isFinite(start)) return '—';
const seconds = Math.max(0, Math.floor((end || Date.now() / 1000) - start));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours ? `${hours}时${minutes}分` : minutes ? `${minutes}分${seconds % 60}秒` : `${seconds}秒`;
}
public hasRecordedDeviceAction(action: any): boolean {
const actions = Array.isArray(action) ? action : action ? [action] : [];
return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
@@ -28,7 +28,7 @@
@if (liveTask() && agent.currentSessionId()) {
<div class="task-guidance">
<h3>任务指导 · {{label(taskStatus())}}</h3>
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。' }}</p>
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交会立即打断模型思考并重新观察设备;正在执行的设备动作会先安全结束。' }}</p>
<label for="task-guidance-text">补充信息或调整方向</label>
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
<div class="actions">
@@ -61,7 +61,7 @@
@for(b of batches(); track b.id){
<div class="batch-record">
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次记录(保留已执行任务与报告)'"><span class="material-symbols-outlined">delete_outline</span></button>
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
</div>
}
@if(!batches().length){<p>暂无批次。</p>}
@@ -70,10 +70,10 @@
<div class="actions">
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
@if(b.status !== 'running'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a>
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
</section>}
}
@@ -38,7 +38,7 @@ export class AutomationPanelComponent {
});
}
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
async action(fn: () => Promise<void>) {
if (this.busy()) return;
@@ -73,12 +73,13 @@ export class AutomationPanelComponent {
}); }
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
deleteBatch(item: { id: string; name: string }) {
if (!window.confirm(`删除批次“${item.name}”?未执行任务会移除,已执行任务与报告仍保留。`)) return;
if (!window.confirm(`删除批次“${item.name}”及其全部任务记录、报告和文件?此操作无法撤销。`)) return;
void this.action(async () => {
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
if (this.batch()?.id === item.id) this.batch.set(null);
await this.refresh();
this.notice.set('批次记录已删除;已执行任务与报告仍可在历史中查看');
this.agent.fetchSessions();
this.notice.set('批次及关联任务、报告和文件已删除');
});
}
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
@@ -49,6 +49,21 @@ describe('历史任务检索', () => {
fixture.destroy();
});
it('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
{session_id:'case-1', name:'1.1 打开并核对设置', initial_goal:'请打开设置首页并核对两个真实分类', status:'failed', start_time:100, end_time:165, error:'任务执行超时(60 秒)'}
]});
fixture.detectChanges();
const row: HTMLButtonElement = fixture.nativeElement.querySelector('button.row');
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
expect(row.textContent).toContain('1.1 打开并核对设置');
expect(row.textContent).toContain('总耗时 1分5秒');
expect(row.textContent).toContain('任务执行超时');
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
fixture.destroy();
});
it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
@@ -13,12 +13,12 @@ import { AgentService } from '../../services/agent.service';
@if(error()){<p role="alert">{{error()}}</p>}
@if(loading()){<small>正在查询…</small>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)"><strong>{{row.name || row.initial_goal}}</strong><span>{{label(row.status)}} · {{row.start_time*1000 | date:'HH:mm:ss'}} · {{row.tags}}</span></button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
</details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
</section>`,
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #64748b55;border-radius:6px;margin:5px 0;padding:8px}.history-item:has(.row.selected){border-color:#60a5fa;background:#3b82f615}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-block;border-radius:5px;padding:2px 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
})
export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
@@ -27,7 +27,8 @@ export class TaskHistoryComponent {
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
pages(){return Math.max(1,Math.ceil(this.total()/30));}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已取消',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
load(){
@@ -38,6 +38,9 @@ export interface Session {
start_time: number;
end_time?: number;
status?: string;
display_name?: string;
case_id?: string;
error?: string;
video_url?: string;
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
model_info?: ModelInfo;
@@ -34,7 +34,7 @@ limitations under the License.
</div>
<label for="workspace-task-guidance">补充信息或调整方向</label>
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
[disabled]="guidanceBusy()" placeholder="输入指导;运行中将在下一步执行前生效"></textarea>
[disabled]="guidanceBusy()" placeholder="输入指导;将中断当前模型思考并立即重新规划"></textarea>
<div class="guidance-actions">
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
@if (status === 'running') {
@@ -108,7 +108,7 @@ export class AgentService {
const pendingMatch = pendingById.get(s.session_id);
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|| !!activeMatch;
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
const isTerminal = ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s.status || '');
let sStatus = s.status;
if (!isTerminal) {
const isPending = !!pendingMatch && !isCurrentActive;
@@ -1515,12 +1515,12 @@ export class AgentService {
return; // Paused while the tab is in the background; resumed by visibilitychange.
}
this.fetchStatus();
const selected = this.currentSessionId();
if (selected) this.fetchGuidance(selected);
this.pollCounter++;
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
if (this.pollCounter % 3 === 0) {
this.fetchSessions();
const selected = this.currentSessionId();
if (selected) this.fetchGuidance(selected);
}
}, 2000);
});
@@ -1,9 +1,10 @@
import { Pipe, PipeTransform } from '@angular/core';
const LABELS: Record<string, string> = {
running: '执行中', queued: '排队中', pending: '等待中', completed: '已完成',
running: '执行中', queued: '排队中', pending: '等待中', completed: '执行完成',
success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停',
cancelled: '已取消', canceled: '已取消', stopped: '已停止', unknown: '未知',
cancelled: '已终止', canceled: '已终止', stopped: '已终止', interrupted: '中断',
task_paused: '等待指导', resuming: '恢复排队', skipped: '已跳过', unknown: '未知',
idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪',
pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻',
done: '完成', active: '当前', waiting: '等待中', processing: '处理中',
+62 -3
View File
@@ -45,7 +45,7 @@ import re
import uuid
from artemis.config.settings import settings
from artemis.runtime.task_control import TaskControl
from artemis.runtime.task_control import TaskControl, has_pending_instruction
from jinja2 import Template
from langchain_core.messages import (
@@ -937,6 +937,9 @@ class FlashRunner:
action_names = self.executor.action_tool_names
index_elements = self._turn_index_snapshot(state)
for tc in tool_calls:
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
return None, pre_screenshot_bytes, xml_list, action_sequence
name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
args = tc.get("args") or {}
tc_id = tc.get("id") or str(uuid.uuid4())
@@ -1060,6 +1063,8 @@ class FlashRunner:
current_xml_list = xml_list
previous_turn: _TurnRecord | None = None
pending_notices: list[str] = []
no_tool_attempts = 0
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
while limit is None or turns < limit:
control = getattr(self.ctx, "task_control", None)
@@ -1118,7 +1123,36 @@ class FlashRunner:
turn_base = len(messages) - 1
current_tools = report_only_tools if is_final else tools_declaration
response = await self._invoke_model(llm, current_tools, messages)
if isinstance(control, TaskControl):
interrupted, response = await control.run_interruptibly(
self._invoke_model(llm, current_tools, messages),
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
else:
interrupted, response = (
False,
await self._invoke_model(llm, current_tools, messages),
)
if interrupted or (
isinstance(control, TaskControl) and has_pending_instruction(control.directory)
):
# Do not dispatch a stale action or final answer from a prompt
# assembled before the instruction arrived.
(
shot_path,
current_pre_screenshot_bytes,
current_xml_list,
) = await capture_screenshot_and_parse_ui(
self.ctx, state, self.controller, skip_settling=False
)
state.latest_screenshot = shot_path
pending_notices.append(
"新指导已打断旧模型步骤。请重新观察画面并按最新指导决定下一步。"
)
turns -= 1
continue
if response is None:
break
@@ -1145,6 +1179,18 @@ class FlashRunner:
self._record_llm_trace(step_token_usage, raw_text)
tool_calls = self._resolve_tool_calls(response, raw_text)
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
(
shot_path,
current_pre_screenshot_bytes,
current_xml_list,
) = await capture_screenshot_and_parse_ui(
self.ctx, state, self.controller, skip_settling=False
)
state.latest_screenshot = shot_path
pending_notices.append("新指导已收到;忽略旧模型动作,重新观察画面。")
turns -= 1
continue
if not tool_calls:
logger.info(
@@ -1154,11 +1200,24 @@ class FlashRunner:
if is_final:
final_report = {"status": "failed", "explanation": raw_text}
break
no_tool_attempts += 1
if no_tool_attempts >= no_tool_limit:
final_report = {
"status": "failed",
"explanation": (
f"模型连续 {no_tool_attempts} 次未调用工具,"
"已停止任务以避免空转和重复计费;未执行新的设备动作。"
),
}
break
pending_notices.append(_NO_TOOL_CALL_NOTICE)
ledger.stage_turn(messages[turn_base:])
# Keep the observation and any injected user instruction, but
# do not feed the model its own unproductive prose on retry.
ledger.stage_turn(messages[turn_base:-1])
previous_turn = _TurnRecord()
continue
no_tool_attempts = 0
# Process tool calls
turn = _TurnRecord()
(
+10 -4
View File
@@ -33,7 +33,7 @@ from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
from artemis.llm.usage import usage_from_message
from artemis.runtime.task_control import TaskControl
from artemis.runtime.task_control import TaskControl, has_pending_instruction
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
from artemis.tools.command_tool import (
analyze_task_output_wrapper,
@@ -539,6 +539,9 @@ class OperatorNode:
if interrupted:
return None, None, None, False
assert response is not None
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
# The model answered a prompt that predates the user's new direction.
return None, None, None, False
usage = usage_from_message(response)
# The ledger's compaction thresholds read the operator's own
@@ -690,10 +693,10 @@ class OperatorNode:
if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS:
interrupted, result_obj = await self._await_or_pause(tool_call)
if interrupted:
span.result = "任务暂停;诊断已中断,恢复后重新观察屏幕"
span.result = "任务被暂停或指导打断;重新观察屏幕后继续"
results.append((span.result, "error"))
for remaining in other_calls[len(results) :]:
results.append(("任务暂停,未执行该工具", "error"))
results.append(("任务已打断,未执行该工具", "error"))
for completed, (content, status) in zip(
other_calls, results
):
@@ -710,7 +713,7 @@ class OperatorNode:
current_messages.append(
ToolMessage(
tool_call_id=action["id"],
content="任务暂停,未执行屏幕操作",
content="任务已打断,未执行屏幕操作",
status="error",
)
)
@@ -827,6 +830,9 @@ class OperatorNode:
action_calls = []
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
return None, None, None, False
if action_calls:
logger.info(
f"Operator requested {len(action_calls)} action(s)."
+2
View File
@@ -114,6 +114,8 @@ class Settings(BaseSettings):
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
# Consecutive Pro responses without a valid tool call before failing the task.
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
# Consecutive Flash responses without any tool call before failing the task.
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
# Android ADB Connectivity
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
+40 -38
View File
@@ -1158,11 +1158,21 @@ class StorageManager:
"""Delete all data associated with a session, including files on disk."""
session_id_str = str(session_id)
# 1. Get video paths before deleting from DB
# 1. Get files referenced by this session before deleting from DB.
video_paths = []
video_ids: list[str] = []
image_names: set[str] = set()
try:
with self._get_connection() as conn:
image_names = {
str(name)
for row in conn.execute(
"SELECT pre_image_name, post_image_name FROM steps WHERE session_id = ?",
(session_id_str,),
)
for name in row
if name
}
cursor = conn.execute(
"SELECT video_id, local_video_path FROM video_recordings WHERE session_id = ?",
(session_id_str,),
@@ -1173,9 +1183,11 @@ class StorageManager:
if row["local_video_path"]:
video_paths.append(Path(row["local_video_path"]))
except sqlite3.OperationalError as e:
logger.warning(f"Could not query video_recordings: {e}")
logger.warning(f"Could not query session media: {e}")
raise
# 2. Delete from DB tables
orphaned_images: list[str] = []
with self._get_connection() as conn:
conn.execute("PRAGMA foreign_keys = OFF")
board_keys = [f"session:{session_id_str}"] + [
@@ -1207,13 +1219,26 @@ class StorageManager:
(session_id_str,),
)
except sqlite3.OperationalError as e:
logger.warning(f"Failed to delete from table {table}: {e}")
conn.commit()
conn.execute("VACUUM")
logger.error(f"Failed to delete from table {table}: {e}")
raise
for name in image_names:
if not conn.execute(
"SELECT 1 FROM steps WHERE pre_image_name=? OR post_image_name=? LIMIT 1",
(name, name),
).fetchone():
conn.execute("DELETE FROM images WHERE image_name=?", (name,))
orphaned_images.append(name)
conn.commit()
logger.info(f"Database records for session {session_id} cleared.")
images_root = (self.base_trace_dir / "images").resolve()
for name in orphaned_images:
if len(name) == 64 and all(c in "0123456789abcdef" for c in name):
image_path = (images_root / f"{name}.jpg").resolve()
if image_path.is_relative_to(images_root):
image_path.unlink(missing_ok=True)
# 3. Delete session directory (notes, etc.)
session_dir = self.base_trace_dir / session_id_str
if session_dir.exists() and session_dir.is_dir():
@@ -1222,38 +1247,15 @@ class StorageManager:
logger.info(f"Deleted session directory: {session_dir}")
except Exception as e:
logger.error(f"Failed to delete session directory {session_dir}: {e}")
raise
# 4. Delete video files and their containing folders (which might have been renamed)
# 4. Never let a persisted recording path remove a sibling session.
trace_root = self.base_trace_dir.resolve()
for video_path in video_paths:
# video_path is likely: /path/to/traces/task_name/recording.mp4
# If it was renamed, it might be: /path/to/traces/task_name_PASS_timestamp/recording.mp4
task_name = video_path.parent.name
if task_name and task_name != "traces" and task_name != "..":
# Search for directories starting with task_name in base_trace_dir
try:
for path in self.base_trace_dir.iterdir():
if path.is_dir() and path.name.startswith(task_name):
try:
shutil.rmtree(path)
logger.info(f"Deleted trace/video directory: {path}")
except Exception as e:
logger.error(f"Failed to delete directory {path}: {e}")
elif path.is_file() and path.name.startswith(task_name):
try:
path.unlink()
logger.info(f"Deleted trace/video file: {path}")
except Exception as e:
logger.error(f"Failed to delete file {path}: {e}")
except Exception as e:
logger.error(f"Error iterating base_trace_dir for cleanup: {e}")
if video_path.exists():
try:
video_path.unlink()
logger.info(f"Deleted video file directly: {video_path}")
if video_path.parent.exists() and not any(video_path.parent.iterdir()):
video_path.parent.rmdir()
except Exception as e:
logger.error(f"Failed to delete video file directly {video_path}: {e}")
resolved = video_path.resolve()
if not resolved.is_relative_to(trace_root):
continue
relative = resolved.relative_to(trace_root)
if not relative.parts or relative.parts[0] != session_id_str:
continue
resolved.unlink(missing_ok=True)
+10 -1
View File
@@ -84,6 +84,11 @@ def list_instructions(session_id: str) -> list[dict]:
return sorted(records, key=lambda record: (record.get("timestamp", 0), record["id"]))
def has_pending_instruction(base_dir: str | Path) -> bool:
directory = Path(base_dir) / "instructions"
return any(directory.glob("*.json")) or (Path(base_dir) / "injected_instruction.json").exists()
def consume_instructions(base_dir: str | Path) -> dict | None:
directory = Path(base_dir) / "instructions"
found = []
@@ -175,7 +180,7 @@ class TaskControl:
async def run_interruptibly[T](
self, operation: Awaitable[T], *, on_pause=None, on_resume=None
) -> tuple[bool, T | None]:
"""Park a task during a long model/helper call, then discard its stale result.
"""Interrupt a stale model/helper call for pause or fresh user guidance.
Only use for operations whose cancellation cannot leave a device action
half executed. The caller must return to fresh perception after resume.
@@ -189,6 +194,10 @@ class TaskControl:
await asyncio.gather(running, return_exceptions=True)
await self.checkpoint(on_pause=on_pause, on_resume=on_resume)
return True, None
if has_pending_instruction(self.directory):
running.cancel()
await asyncio.gather(running, return_exceptions=True)
return True, None
if running.done():
return False, running.result()
await asyncio.wait({running}, timeout=0.2)
+6 -4
View File
@@ -2,7 +2,7 @@
源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。
先完成项目环境和模型配置,然后在项目目录执行 `uv run artemis mcp --install codex` 安装/更新 MCP 配置。其他客户端将 `codex` 换成对应客户端;`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;已有配置若设置了工具白名单,需要重新安装配置或加入新增工具。复制 Skill 后按宿主方式重新加载技能。
Windows 一键脚本会为检测到的客户端自动安装或更新 MCP 配置。未检测到客户端时,在项目目录执行 `uv run artemis mcp --install codex`;其他客户端将 `codex` 换成对应名称,`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;复制 Skill 后按宿主方式重新加载技能。
可以向 AI 这样下达任务:
@@ -36,12 +36,14 @@ Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当
Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块配置,更新解释器路径和已有工具白名单。保留用户环境变量、禁用状态、禁用工具及其他服务;JSON/TOML 无法安全解析、其他项目和自定义启动包装器保持原样。首次修改保留备份,未接入的客户端不会自动安装。源码更新后需在 AI 客户端重新加载 MCP 连接以使用新实现;脚本不会强制重启你的 IDE。
MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目的客户端安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。无法安全解析配置时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。源码和配置同步后需在 AI 客户端重新加载 MCP 连接或重启客户端,脚本无法替宿主重启已加载的 MCP 进程。
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复,用 `guidance_history` 核对是否领取,或用 `resume` 单独恢复。
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
+4 -2
View File
@@ -20,12 +20,14 @@
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在步骤间安全暂停,释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导在下一步执行前生效,不打断当前步骤。暂停等待和重新排队时间不计入该任务执行时限。
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
## AI 使用
通过 MCP 可直接提交结构化批次、检索历史和导出报告;见 [AI 自动化与 Skill](ai-automation.md)。操作回放已移除,旧记录仅供历史查看,不能继续或重新执行。
@@ -34,4 +36,4 @@
`.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。清空任务历史同时清除批次与执行元数据;已导出的文件保留。
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。全局“清空任务历史”会清除批次与执行元数据;它与上文的单批次删除不同,已导出的文件会保留。
+13
View File
@@ -0,0 +1,13 @@
# ARTEMIS-Eapil 1.1.2
## 修复
- Windows 一键脚本在依赖同步后,为已检测到的 AI 客户端安装或更新 ARTEMIS MCP。旧配置指向其他解压目录时,会迁移到当前目录,并更新 Python、启动参数、环境路径和工具清单。
- 同步保留已有密钥、禁用状态、其他 MCP 服务及自定义设置。自定义启动命令会提示跳过;配置无法安全解析时明确停止启动,原文件保持不变。仅需网页时可用 `-SkipMcpSync`。
- 源码升级后会重新载入新版启动脚本再同步 MCP。客户端需重新加载 MCP 或重启,才能让已运行的旧 MCP 进程退出。
## 安装
下载发布附件 `artemis-eapil-1.1.2-source.zip`,解压后运行 `start.bat` 或国内镜像入口 `start-cn.bat`。附件包含精简 Git 仓库,后续可通过启动脚本检查更新。Gitea 自动生成的“源代码 ZIP”没有 `.git`,不能通过脚本拉取更新。
后端默认测试 2170 项通过、6 项跳过;Ruff、质量基线、核心类型检查通过。附件同时提供 SHA-256 校验文件。
+14
View File
@@ -0,0 +1,14 @@
# ARTEMIS-Eapil 1.2.0
## 工作区改进
- 批量任务在队列和历史中显示“用例编号 + 测试用例”;悬停标题可查看完整提示词。任务状态、完成时间、总耗时和失败原因更易辨认。
- 超时失败会写明原因和批次时限。单任务资料 ZIP 以用例编号命名目录。
- 删除已结束的批次会清理关联任务、步骤、截图、录屏和报告,包括 MCP 导出副本;仍被其他任务引用的截图会保留。正在执行或排队的批次不能删除。
- 网页或 MCP 提交指导时,中断当前模型思考并重新观察设备。已开始的设备动作安全结束;流程显示指导提交、领取和后续动作。
## 安装
下载附件 `artemis-eapil-1.2.0-source.zip`,解压后运行 `start.bat`;国内网络可运行 `start-cn.bat`。附件包含精简 Git 仓库,可供后续启动脚本检查更新。Gitea 自动生成的源码 ZIP 不含 `.git`,请使用发布附件。
升级后刷新网页,并在 AI 客户端重新加载 MCP。验证:Python 默认测试 2177 项通过、6 项跳过;前端 149 项通过且生产构建成功;Ruff、质量基线和核心类型检查通过。
+14
View File
@@ -0,0 +1,14 @@
# ARTEMIS-Eapil 1.2.1
## 修复
- 批量任务达到执行超时后,该用例记录为失败,不再自动暂停整批。当前用例结算后继续执行下一项;开启“失败即停止”的批次仍会跳过剩余项。排队等待不计入执行超时。
- Flash 连续收到无工具调用的回复时,最多尝试 3 次并给出明确失败原因,避免持续空转和重复计费。重试会保留设备观察与用户指导,但不把模型的无效回复送回上下文。可通过 `ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS` 调整上限。
模型请求中的相同缓存 token 数量通常是提示词前缀缓存命中,不能据此判断缓存造成循环。本版针对无工具调用的执行循环建立退出条件。
## 安装与验证
请下载附件 `artemis-eapil-1.2.1-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件包含精简 Git 仓库,可供后续启动脚本检查更新。Gitea 自动生成的源码 ZIP 不含 `.git`,请使用发布附件。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
验证:Python 默认测试 2182 项通过、6 项跳过;Ruff、质量基线和核心类型检查通过。
+24 -7
View File
@@ -67,10 +67,16 @@ async def _request(method: str, route: str, **kwargs) -> Any:
return response.json()
async def _download(route: str, filename: str) -> dict:
async def _download(
route: str,
filename: str,
*,
owner_kind: Literal["sessions", "batches"] | None = None,
owner_id: str | None = None,
) -> dict:
url = await _base_url()
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
path = EXPORT_DIR / f"{uuid4().hex[:12]}-{filename}"
directory = EXPORT_DIR / owner_kind / _id(owner_id) if owner_kind else EXPORT_DIR
path = directory / f"{uuid4().hex[:12]}-{filename}"
partial = path.with_suffix(path.suffix + ".part")
try:
async with httpx.AsyncClient(base_url=url, timeout=120, trust_env=False) as client:
@@ -78,6 +84,7 @@ async def _download(route: str, filename: str) -> dict:
if response.is_error:
await response.aread()
_check(response)
directory.mkdir(parents=True, exist_ok=True)
with partial.open("wb") as stream:
async for chunk in response.aiter_bytes():
stream.write(chunk)
@@ -198,8 +205,8 @@ async def mobile_batch(
use mobile_manage_task(stop) to stop that task. resume never repeats finished cases.
retry_failed creates a new draft only after an explicit rerun request; it is not
transport-idempotent, so after an uncertain response use list instead of retrying.
delete removes a non-active batch record after an explicit user request; it keeps
already executed task sessions and reports. Confirm the batch ID with get first.
delete removes a non-active batch and its task sessions, reports and files after
an explicit user request. Confirm the batch ID with get first; deletion is irreversible.
Restarted batches remain paused. No automatic failed-case retries or action replay.
"""
if action == "create":
@@ -270,7 +277,12 @@ async def mobile_export_report(
return {
"batch_id": bid,
"files": [
await _download(f"/api/automation/batches/{bid}/archive", f"batch-{bid}.zip")
await _download(
f"/api/automation/batches/{bid}/archive",
f"batch-{bid}.zip",
owner_kind="batches",
owner_id=bid,
)
],
}
sid = _id(session_id)
@@ -286,10 +298,15 @@ async def mobile_export_report(
):
raise ValueError("formats 需选择 docx、xlsx、screenshots、video 或 all")
report = await _request("GET", f"/api/automation/reports/{sid}")
from apps.admin_console.services.report_export import archive_case_name
label = archive_case_name(report)
files = [
await _download(
f"/api/automation/reports/{sid}/{fmt}",
f"task-{sid}.{fmt}" if fmt in {"docx", "xlsx"} else f"task-{sid}-{fmt}.zip",
f"task-{sid}.{fmt}" if fmt in {"docx", "xlsx"} else f"{label}-{fmt}.zip",
owner_kind="sessions",
owner_id=sid,
)
for fmt in dict.fromkeys(chosen)
]
+5 -3
View File
@@ -282,8 +282,10 @@ def mobile_manage_task(
- **'resume'**: Queues a paused task for its device again; optional `instruction`
provides guidance before the next action.
- **'inject_instruction'**: Injects real-time guidance mid-flight when the
subagent errs, stalls, or loops. Applied at the start of the next
planning turn (Pro) or the next reactive loop (Flash). Requires
subagent errs, stalls, or loops. An in-flight model response or read-only
helper is cancelled immediately, then the runner observes the device again
and consumes the new guidance. An already-started device action finishes
safely before the runner changes direction. Requires
`instruction` (unless `release_loop=True`). To end a continuous
monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass
`release_loop=True` — this is the only signal that unlocks the
@@ -697,7 +699,7 @@ def mobile_manage_task(
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Successfully injected instruction{suffix}: '{instruction}'",
"message": f"Guidance accepted and current model step interrupted{suffix}; check guidance_history for consumption.",
}
except Exception as e:
return {
+1 -1
View File
@@ -14,7 +14,7 @@
[project]
name = "artemis"
version = "1.1.1"
version = "1.2.1"
description = "Autonomous AI Assistant for Mobile Automation"
authors = [
+11 -4
View File
@@ -18,6 +18,7 @@ param(
[switch]$NoOpen = $false,
[switch]$SetupOnly = $false,
[switch]$SkipUpdate = $false,
[switch]$SkipMcpSync = $false,
[ValidateSet('Official', 'China')][string]$Mirror
)
@@ -50,6 +51,7 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
$reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate')
if ($NoOpen) { $reloadArgs += '-NoOpen' }
if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
if ($SkipMcpSync) { $reloadArgs += '-SkipMcpSync' }
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
& powershell.exe @reloadArgs
exit $LASTEXITCODE
@@ -353,10 +355,15 @@ if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
Write-Host (Get-LaunchText ' [OK] Created .env from .env.example. Configure your model in the web UI.' ' [完成] 已按模板创建 .env;请在网页中配置模型。') -ForegroundColor Green
}
}
Write-Host (Get-LaunchText ' [CHECK] Refreshing already-configured MCP clients...' ' [检查] 正在检查并更新已有的 MCP 客户端配置...') -ForegroundColor Cyan
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
if ($LASTEXITCODE -ne 0) {
Write-Host (Get-LaunchText ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' ' [提示] MCP 配置更新失败,原配置仍保留;请查看上方错误。') -ForegroundColor Yellow
if (-not $SkipMcpSync) {
Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
if ($LASTEXITCODE -ne 0) {
Write-Host (Get-LaunchText ' [ERROR] MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
exit 1
}
} else {
Write-Host (Get-LaunchText ' [SKIP] MCP synchronization was skipped.' ' [跳过] 已跳过 MCP 同步。') -ForegroundColor Yellow
}
if ($SetupOnly) {
Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green
+280 -102
View File
@@ -1,4 +1,4 @@
"""Check an existing checkout and refresh only already-configured local MCP clients."""
"""Check an existing checkout and synchronize installed local MCP clients."""
from __future__ import annotations
@@ -140,10 +140,10 @@ def update_source(root: Path, port: int = 8000) -> int:
def _atomic_write(path: Path, text: str) -> None:
if path.read_text(encoding="utf-8") == text:
if path.is_file() and path.read_text(encoding="utf-8") == text:
return
backup = path.with_name(path.name + ".artemis-update.bak")
if not backup.exists():
if path.is_file() and not backup.exists():
shutil.copy2(path, backup)
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
try:
@@ -154,87 +154,120 @@ def _atomic_write(path: Path, text: str) -> None:
Path(name).unlink(missing_ok=True)
def _belongs_to_project(config: dict, root: Path) -> bool:
if config.get("url"):
def _is_standard_local_artemis(config: dict) -> bool:
if config.get("url") or config.get("type") not in (None, "stdio"):
return False
args = config.get("args", [])
if args[:2] not in (["-m", "mcp_server"], ["-m", "mcp_server.server"]):
# A hand-written wrapper/uv/SSE command may need custom arguments; leave it alone.
return False
candidates = [config.get("cwd"), config.get("env", {}).get("PYTHONPATH")]
for value in candidates:
if isinstance(value, str) and Path(value).resolve() == root.resolve():
return True
command = config.get("command")
return (
isinstance(command, str)
and Path(command).resolve().parent == (root / ".venv" / "Scripts").resolve()
)
# Running this launcher selects this checkout, including after a user moves
# a release ZIP to a new directory. Do not take over custom wrappers/SSE.
return args in (["-m", "mcp_server"], ["-m", "mcp_server.server"])
def _tool_updates(config: dict, names: tuple[str, ...]) -> dict:
updates = {}
if "enabled_tools" in config:
updates["enabled_tools"] = list(dict.fromkeys([*config["enabled_tools"], *names]))
if isinstance(config.get("tools"), dict):
updates["tools"] = {**{name: {"eager": True} for name in names}, **config["tools"]}
return updates
def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, ...]) -> dict:
updated = {**config, "command": template["command"], "args": template["args"]}
if "cwd" in template or "cwd" in config:
updated["cwd"] = str(root)
environment = config.get("env", {})
if not isinstance(environment, dict):
raise ValueError("The existing Artemis MCP environment is not an object")
updated["env"] = {**environment, **template["env"]}
if "enabled_tools" in template or "enabled_tools" in config:
updated["enabled_tools"] = list(names)
if "tools" in template or "tools" in config:
current_tools = config.get("tools", {})
if not isinstance(current_tools, dict):
raise ValueError("The existing Artemis MCP tools setting is not an object")
updated["tools"] = {name: {"eager": True, **current_tools.get(name, {})} for name in names}
for key in ("type", "required", "startup_timeout_sec", "connectionTimeoutMs"):
if key in template and key not in updated:
updated[key] = template[key]
return updated
def refresh_json(
path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...]
) -> bool:
if not path.is_file():
path: Path,
keys: tuple[str, ...],
root: Path,
python: str,
names: tuple[str, ...],
template: dict | None = None,
install_missing: bool = False,
) -> bool | None:
if not path.is_file() and not install_missing:
return False
raw = path.read_text(encoding="utf-8")
raw = path.read_text(encoding="utf-8") if path.is_file() else "{}"
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("MCP configuration must be a JSON object")
parent = data
for key in keys:
parent = parent.get(key, {})
child = parent.get(key)
if child is None and install_missing:
child = parent[key] = {}
if not isinstance(child, dict):
raise ValueError(f"Invalid MCP configuration section: {key}")
parent = child
config = parent.get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root):
return False
updated = {**config, "command": python, **_tool_updates(config, names)}
if config is None and install_missing:
if template is None:
raise ValueError("A standard MCP template is required for installation")
updated = template
elif isinstance(config, dict) and _is_standard_local_artemis(config):
if template is None:
template = {
"command": python,
"args": ["-m", "mcp_server"],
"env": {"PYTHONPATH": str(root)},
}
updated = _updated_entry(config, template, root, names)
elif config is not None and not isinstance(config, dict):
raise ValueError("The Artemis MCP configuration is not an object")
else:
return None
if updated == config:
return False
parent["artemis"] = updated
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
return True
def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -> bool:
if not path.is_file():
return False
raw = path.read_text(encoding="utf-8")
config = tomllib.loads(raw).get("mcp_servers", {}).get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root):
return False
wanted = {"command": python, **_tool_updates(config, names)}
wanted = {key: value for key, value in wanted.items() if config.get(key) != value}
if not wanted:
return False
def _toml_value(value: object) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return json.dumps(value, ensure_ascii=False)
def _toml_inline_table(values: dict) -> str:
return "{ " + ", ".join(f"{key} = {_toml_value(value)}" for key, value in values.items()) + " }"
def _replace_toml_tables(raw: str, assignments: dict[str, dict]) -> str:
"""Update only Artemis TOML tables; preserve every unrelated section and setting."""
lines = raw.splitlines(keepends=True)
inside = False
found = set()
output = []
output: list[str] = []
section = ""
found: dict[str, set[str]] = {name: set() for name in assignments}
seen: set[str] = set()
def append_missing():
for key, value in wanted.items():
if key not in found:
output.append(f"{key} = {json.dumps(value, ensure_ascii=False)}\n")
found.add(key)
def append_missing() -> None:
if section in assignments:
for key, value in assignments[section].items():
if key not in found[section]:
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
found[section].add(key)
# Replace complete assignment spans, including multiline arrays, using TOML parsing.
index = 0
while index < len(lines):
line = lines[index]
if line.lstrip().startswith("["):
if inside:
append_missing()
append_missing()
header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip()
inside = header == "[mcp_servers.artemis]"
match = re.match(r"\s*(\w+)\s*=", line) if inside else None
if match and match[1] in wanted:
section = header[1:-1] if header.startswith("[") and header.endswith("]") else ""
seen.add(section)
match = re.match(r"\s*(\w+)\s*=", line) if section in assignments else None
if match and match[1] in assignments[section]:
key = match[1]
original = line
while True:
@@ -246,91 +279,236 @@ def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -
if index >= len(lines):
raise ValueError("Cannot safely parse existing MCP setting")
original += lines[index]
output.append(f"{key} = {json.dumps(wanted[key], ensure_ascii=False)}\n")
found.add(key)
value = assignments[section][key]
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
found[section].add(key)
else:
output.append(line)
index += 1
if inside:
append_missing()
updated = "".join(output)
parsed = tomllib.loads(updated)
if any(parsed["mcp_servers"]["artemis"].get(k) != v for k, v in wanted.items()):
raise ValueError("Could not locate the existing Artemis table safely")
_atomic_write(path, updated)
return updated != raw
append_missing()
for name, values in assignments.items():
if name not in seen:
if output and not output[-1].endswith("\n"):
output.append("\n")
output.append(f"\n[{name}]\n")
for key, value in values.items():
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
return "".join(output)
def refresh_mcp(root: Path) -> None:
def refresh_codex(
path: Path,
root: Path,
python: str,
names: tuple[str, ...],
template: dict | None = None,
install_missing: bool = False,
) -> bool | None:
if not path.is_file() and not install_missing:
return False
raw = path.read_text(encoding="utf-8") if path.is_file() else ""
parsed = tomllib.loads(raw)
config = parsed.get("mcp_servers", {}).get("artemis")
if config is None and install_missing:
if template is None:
raise ValueError("A standard MCP template is required for installation")
updated = template
elif isinstance(config, dict) and _is_standard_local_artemis(config):
if template is None:
template = {
"command": python,
"args": ["-m", "mcp_server"],
"cwd": str(root),
"env": {"PYTHONPATH": str(root)},
"enabled_tools": list(names),
}
updated = _updated_entry(config, template, root, names)
else:
return None
top = {
key: value
for key, value in updated.items()
if key != "env" and (not config or config.get(key) != value)
}
environment = updated["env"]
if config and config.get("env") == environment:
env_updates = {}
else:
env_updates = {
key: value
for key, value in environment.items()
if not config or config.get("env", {}).get(key) != value
}
env_header = "mcp_servers.artemis.env"
has_env_table = re.search(r"(?m)^\s*\[mcp_servers\.artemis\.env\]\s*(?:#.*)?$", raw) is not None
if config and "env" in config and not has_env_table and env_updates:
top["env"] = environment
env_updates = {}
assignments = {"mcp_servers.artemis": top}
if env_updates:
assignments[env_header] = env_updates
if not top and not env_updates:
return False
result = _replace_toml_tables(raw, assignments)
actual = tomllib.loads(result)["mcp_servers"]["artemis"]
if any(actual.get(key) != value for key, value in top.items()) or any(
actual.get("env", {}).get(key) != value for key, value in env_updates.items()
):
raise ValueError("Could not safely refresh the Artemis MCP table")
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, result)
return result != raw
def refresh_mcp(root: Path) -> int:
sys.path.insert(0, str(root))
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir
from artemis.interfaces.cli.commands.mcp import (
ARTEMIS_MCP_TOOLS,
_get_config_snippet,
_get_vscode_user_dir,
)
home = Path.home()
vscode = _get_vscode_user_dir()
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
cline_store = vscode / "globalStorage/saoudrizwan.claude-dev"
roo_store = vscode / "globalStorage/rooveterinaryinc.roo-cline"
paths = [
(home / ".gemini/config/mcp_config.json", ("mcpServers",)),
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",)),
(home / ".gemini/antigravity/mcp_config.json", ("mcpServers",)),
(home / ".cursor/mcp.json", ("mcpServers",)),
(home / ".codeium/windsurf/mcp_config.json", ("mcpServers",)),
(home / ".claude.json", ("mcpServers",)),
(appdata / "Claude/claude_desktop_config.json", ("mcpServers",)),
(vscode / "mcp.json", ("servers",)),
(home / ".copilot/mcp-config.json", ("servers",)),
(home / ".cline/data/settings/cline_mcp_settings.json", ("mcpServers",)),
(home / ".cline/mcp.json", ("mcpServers",)),
(home / ".gemini/config/mcp_config.json", ("mcpServers",), "antigravity", home / ".gemini"),
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"),
(
vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
home / ".gemini/antigravity/mcp_config.json",
("mcpServers",),
"antigravity",
home / ".gemini",
),
(home / ".cursor/mcp.json", ("mcpServers",), "cursor", home / ".cursor"),
(
home / ".codeium/windsurf/mcp_config.json",
("mcpServers",),
"windsurf",
home / ".codeium/windsurf",
),
(home / ".claude.json", ("mcpServers",), "claude", home / ".claude"),
(
appdata / "Claude/claude_desktop_config.json",
("mcpServers",),
"claude",
appdata / "Claude",
),
(vscode / "mcp.json", ("servers",), "vscode", vscode),
(home / ".copilot/mcp-config.json", ("servers",), "vscode", home / ".copilot"),
(
home / ".cline/data/settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
home / ".cline",
),
(home / ".cline/mcp.json", ("mcpServers",), "cline", home / ".cline"),
(
cline_store / "settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
cline_store,
),
(
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
roo_store / "settings/mcp_settings.json",
("mcpServers",),
"roo",
roo_store,
),
(
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json",
roo_store / "settings/cline_mcp_settings.json",
("mcpServers",),
"roo",
roo_store,
),
(root / ".roo/mcp.json", ("mcpServers",)),
(home / ".openclaw/openclaw.json", ("mcp", "servers")),
(root / ".roo/mcp.json", ("mcpServers",), "roo", root / ".roo"),
(home / ".openclaw/openclaw.json", ("mcp", "servers"), "openclaw", home / ".openclaw"),
]
changed = 0
codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml"
for path, keys in [*paths, (codex, ())]:
codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex"))
paths.append((codex_home / "config.toml", (), "codex", codex_home))
installed = updated_count = ready = skipped = errors = 0
for path, keys, client, marker in paths:
if not path.is_file() and not marker.is_dir():
continue
existed = path.is_file()
template = _get_config_snippet(client, sys.executable, str(root))
for key in keys:
template = template[key]
template = template["artemis"] if keys else template["mcp_servers"]["artemis"]
try:
updated = (
refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS)
changed = (
refresh_json(
path,
keys,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
if keys
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS)
else refresh_codex(
path,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
)
if updated:
if changed:
print(
launch_text(
f"[UPDATED] Existing MCP configuration: {path}",
f"[已更新] 已有 MCP 配置:{path}",
f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}",
f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}",
)
)
changed += 1
if existed:
updated_count += 1
else:
installed += 1
elif changed is None:
print(
launch_text(
f"[SKIP] Custom Artemis MCP entry in {path}; use the manual installer for this client.",
f"[跳过] {path} 使用自定义 ARTEMIS MCP 启动方式;请手动检查此客户端。",
)
)
skipped += 1
else:
ready += 1
except (OSError, ValueError, TypeError, AttributeError):
print(
launch_text(
f"[WARN] Could not safely refresh {path}; original configuration preserved.",
f"[提示] 无法安全更新 {path};原配置已保留。",
f"[ERROR] Could not safely synchronize {path}; original configuration preserved.",
f"[错误] 无法安全同步 {path};原配置已保留。",
)
)
errors += 1
print(
launch_text(
f"[OK] MCP source is current; refreshed {changed} existing client configuration(s). Reload the MCP connection in your AI client after a source update.",
f"[完成] MCP 源码已就绪;更新了 {changed} 个已有客户端配置。源码升级后请在 AI 客户端重新加载 MCP。",
f"[CHECK] MCP clients: installed {installed}, updated {updated_count}, already current {ready}, custom skipped {skipped}, errors {errors}.",
f"[检查] MCP 客户端:新装 {installed} 个、更新 {updated_count} 个、已是最新 {ready} 个、自定义跳过 {skipped} 个、失败 {errors} 个。",
)
)
print(
launch_text(
"[TIP] For a new client run: uv run artemis mcp --install <client>",
"[提示] 新客户端可执行:uv run artemis mcp --install <client>",
"[ACTION] Reload or restart your AI client to load the current MCP process and tool schemas.",
"[操作] 请在 AI 客户端重新加载 MCP 或重启客户端,使新版进程和工具签名生效。",
)
)
if not (installed or updated_count or ready or skipped or errors):
print(
launch_text(
"[TIP] No supported client detected; install one with: uv run artemis mcp --install <client>",
"[提示] 未检测到受支持的客户端;可执行 uv run artemis mcp --install <client>。",
)
)
return 1 if errors else 0
def frontend_fingerprint(root: Path) -> str:
@@ -362,7 +540,7 @@ if __name__ == "__main__":
if args.action == "source":
sys.exit(update_source(args.root, args.port))
elif args.action == "mcp":
refresh_mcp(args.root)
sys.exit(refresh_mcp(args.root))
else:
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
current = frontend_fingerprint(args.root)
+2 -2
View File
@@ -14,7 +14,7 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。
4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。
5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。提示词列原样作为目标,前置约束列只作元数据。
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause`,其返回的 `pausing` 只表示请求已接收;轮询到 `task_paused` 才表示设备已释放。Pro 等待模型回复或只读诊断时可中断,已开始的设备动作会先安全结束。`inject_instruction` 可在运行中指导下一步,暂停中提交则恢复;`guidance_history` 的 `pending/consumed` 区分提交和执行端领取,无新指导用 `resume`。恢复进入设备 FIFO 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause`,其返回的 `pausing` 只表示请求已接收;轮询到 `task_paused` 才表示设备已释放。`inject_instruction` 会立即取消进行中的模型回复或只读诊断并重新观察手机,已开始的设备动作会先安全结束;暂停中提交则恢复。`guidance_history` 的 `pending/consumed` 区分提交和执行端领取,无新指导用 `resume`。恢复进入设备 FIFO 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
## 结果、证据与交付
@@ -32,6 +32,6 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,已执行的任务历史和报告会保留。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
@@ -56,7 +56,7 @@
- 单项 `pending/queued/running/pausing/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`pausing` 是请求已发出而设备仍被占用,`task_paused` 才表示设备已释放;`finalizing` 在生成报告,不能提前宣称报告已完成。
- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 会在安全边界释放设备,Pro 的模型等待与只读诊断可中断。恢复后重新观察画面,并排到设备队尾,不阻塞后续项。`mobile_manage_task(guidance_history)` 可核对指导是否从 `pending` 变为 `consumed`,但仍需检查后续操作是否符合指导。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
- 用户明确要求删除批次记录时,先 `get` 核对批次编号与状态,再 `delete`;正在执行、排队或等待指导的批次不能删除,已经运行过的任务历史及报告不会随批次记录一起消失。
- 用户明确要求删除批次记录时,先 `get` 核对批次编号与状态,再 `delete`;正在执行、排队或等待指导的批次不能删除。删除会同时移除该批次任务历史、报告和文件,无法撤销。
## 报告
+2 -1
View File
@@ -25,8 +25,9 @@ echo -SetupOnly 安装或检查环境,不启动网页
echo -Port 8001 使用指定端口,默认 8000
echo -NoOpen 启动服务后不自动打开浏览器
echo -SkipUpdate 跳过 Git 源码更新检查
echo -SkipMcpSync 仅启动网页,跳过 MCP 安装和更新
echo -Help 显示本说明
echo.
echo 国内镜像设置:config\downloads.json
echo 首次运行需联网;后续运行会检查源码、依赖和已有 MCP 配置。
echo 首次运行需联网;后续运行会检查源码、依赖,并同步已安装 AI 客户端的 MCP。
endlocal & exit /b 0
+172 -8
View File
@@ -2,8 +2,10 @@
from io import BytesIO
import json
import sqlite3
import time
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
from uuid import UUID
import zipfile
from docx import Document
@@ -125,19 +127,44 @@ def test_preferences_and_required_device(client):
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
def test_batch_delete_retains_executed_task_and_prevents_request_replay(client, isolated):
def test_batch_delete_removes_tasks_and_files_and_prevents_request_replay(client, isolated):
created = client.post(
"/api/automation/batches", json=payload(request_id="delete-example")
).json()
first, second = isolated.jobs(created["id"])
isolated.save_job({**first, "status": "completed"})
trace_dir = reports.TRACES_PATH / first["session_id"]
trace_dir.mkdir(parents=True)
(trace_dir / "result.json").write_text("{}", encoding="utf-8")
report_dir = reports.EXPORT_ROOT / "sessions" / first["session_id"]
report_dir.mkdir(parents=True)
(report_dir / "任务报告.docx").write_bytes(b"report")
batch_dir = reports.EXPORT_ROOT / "batches" / created["id"]
batch_dir.mkdir(parents=True)
(batch_dir / "批次报告.zip").write_bytes(b"zip")
mcp_task_dir = reports.EXPORT_ROOT.parent / "mcp_exports" / "sessions" / first["session_id"]
mcp_task_dir.mkdir(parents=True)
(mcp_task_dir / "报告.docx").write_bytes(b"report")
mcp_batch_dir = reports.EXPORT_ROOT.parent / "mcp_exports" / "batches" / created["id"]
mcp_batch_dir.mkdir(parents=True)
(mcp_batch_dir / "批次.zip").write_bytes(b"zip")
unrelated = reports.TRACES_PATH / "unrelated-task"
unrelated.mkdir()
(unrelated / "keep.txt").write_text("keep", encoding="utf-8")
response = client.delete(f"/api/automation/batches/{created['id']}")
assert response.status_code == 200
assert client.get(f"/api/automation/batches/{created['id']}").status_code == 404
assert isolated.job(first["session_id"])["case"]["name"] == "用例甲"
assert isolated.job(first["session_id"]) is None
assert isolated.job(second["session_id"]) is None
assert reports.session_report(first["session_id"])["name"] == "用例甲"
assert not trace_dir.exists()
assert not report_dir.exists()
assert not batch_dir.exists()
assert not mcp_task_dir.exists()
assert not mcp_batch_dir.exists()
assert (unrelated / "keep.txt").read_text(encoding="utf-8") == "keep"
with pytest.raises(ValueError, match="不存在"):
reports.session_report(first["session_id"])
assert (
client.post(
"/api/automation/batches", json=payload(request_id="delete-example")
@@ -158,6 +185,49 @@ def test_batch_delete_rejects_active_jobs(client, isolated, status):
assert len(isolated.jobs(batch["id"])) == 2
def test_batch_deletion_reservation_blocks_new_dispatch(isolated):
batch = service.create_batch(payload())
job = isolated.jobs(batch["id"])[0]
isolated.begin_delete_batch(batch["id"])
with pytest.raises(ValueError, match="正在删除"):
isolated.save_job({**job, "status": "queued"})
with pytest.raises(ValueError, match="正在删除"):
isolated.put("batch", batch["id"], {**batch, "status": "running"})
assert isolated.jobs_for_sessions([job["session_id"]])[job["session_id"]]["status"] == "pending"
def test_batch_deletion_preserves_shared_screenshot_until_last_task(client, isolated):
first_batch = service.create_batch(payload())
second_batch = service.create_batch(payload(name="另一批次"))
first = isolated.jobs(first_batch["id"])[0]
other = isolated.jobs(second_batch["id"])[0]
shared_hash = "a" * 64
storage = StorageManager(isolated.path, reports.TRACES_PATH)
image_file = reports.TRACES_PATH / "images" / f"{shared_hash}.jpg"
image_file.parent.mkdir(parents=True)
image_file.write_bytes(b"shared image")
with sqlite3.connect(isolated.path) as conn:
conn.execute("INSERT INTO images(image_name) VALUES (?)", (shared_hash,))
for job in (first, other):
conn.execute(
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info) "
"VALUES (?,?,?,?,?)",
(job["session_id"], "观察设置", 1.0, "completed", "{}"),
)
conn.execute(
"INSERT INTO steps(step_id,session_id,pre_image_name) VALUES (?,?,?)",
(job["session_id"], job["session_id"], shared_hash),
)
assert client.delete(f"/api/automation/batches/{first_batch['id']}").status_code == 200
assert image_file.read_bytes() == b"shared image"
assert storage.get_session(UUID(other["session_id"])) is not None
assert client.delete(f"/api/automation/batches/{second_batch['id']}").status_code == 200
assert not image_file.exists()
def test_restart_never_repeats_started_work(isolated):
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
@@ -260,6 +330,96 @@ async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
assert "disk full" in saved["report_error"]
@pytest.mark.asyncio
async def test_batch_timeout_stays_visible_in_job_report_and_history(client, isolated):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
isolated.save_job(
{**job, "status": "running", "timed_out": True, "error": "任务执行超时(30 秒)"}
)
await service.on_terminal({"session_id": job["session_id"]}, "cancelled")
saved = isolated.job(job["session_id"])
assert saved["status"] == "failed"
assert "超时" in saved["error"]
assert "超时" in reports.session_report(job["session_id"])["error"]
history = client.get("/api/automation/history").json()["items"]
assert any(
item["session_id"] == job["session_id"] and "超时" in item["error"] for item in history
)
@pytest.mark.asyncio
@pytest.mark.parametrize("stop_on_failure", [False, True])
async def test_timed_out_case_releases_next_batch_case(isolated, monkeypatch, stop_on_failure):
from apps.admin_console import server
from artemis.runtime.device_pool import DeviceStatus
batch = service.create_batch(payload(timeout_seconds=1, stop_on_failure=stop_on_failure))
service.batch_action(batch["id"], "resume")
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": "running", "started_at": time.time() - 2})
monkeypatch.setattr(
service, "state", type("State", (), {"is_paused": False, "active_runs": {}})()
)
stop = Mock(return_value=True)
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
monkeypatch.setattr(
"artemis.runtime.device_pool.try_list_devices_async",
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
)
monkeypatch.setattr(reports, "export_batch", Mock())
await service.tick_batches()
assert isolated.get("batch", batch["id"])["status"] == "running"
assert "超时" in isolated.job(first["session_id"])["error"]
stop.assert_called_once_with(session_id=first["session_id"])
await service.tick_batches()
stop.assert_called_once()
await service.on_terminal({"session_id": first["session_id"]}, "cancelled")
await service.tick_batches()
statuses = [job["status"] for job in isolated.jobs(batch["id"])]
assert statuses == (["failed", "skipped"] if stop_on_failure else ["failed", "queued"])
assert enqueue.await_count == (0 if stop_on_failure else 1)
@pytest.mark.asyncio
async def test_queued_case_does_not_consume_execution_timeout(isolated, monkeypatch):
from apps.admin_console import server
batch = service.create_batch(payload(timeout_seconds=1))
service.batch_action(batch["id"], "resume")
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": "queued", "started_at": time.time() - 30})
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
stop = Mock()
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
await service.tick_batches()
stop.assert_not_called()
assert isolated.job(first["session_id"])["status"] == "queued"
assert isolated.get("batch", batch["id"])["status"] == "running"
@pytest.mark.asyncio
async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
folder = reports.TRACES_PATH / job["session_id"]
folder.mkdir(parents=True)
(folder / "stderr.log").write_text(
"TimeoutError: model request timed out after 300 seconds\n", encoding="utf-8"
)
await service.on_terminal({"session_id": job["session_id"]}, "failed")
saved = isolated.job(job["session_id"])
assert saved["status"] == "failed"
assert "超时" in saved["error"]
assert reports.session_report(job["session_id"])["status"] == "failed"
assert "超时" in reports.session_report(job["session_id"])["error"]
def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
batch = service.create_batch(payload())
job = isolated.jobs(batch["id"])[0]
@@ -296,6 +456,7 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
assert "abc123secret" not in reports.failure_reason(
folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
)
assert "超时" not in reports.failure_reason(folder, "failed", "LLMExhaustedError: 额度不足")
paths = reports.export_session(job["session_id"])
doc = Document(next(path for path in paths if path.suffix == ".docx"))
assert doc.styles["Normal"].font.name == "Microsoft YaHei"
@@ -325,6 +486,7 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
client, isolated, tmp_path, monkeypatch
):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
isolated.save_job({**job, "case": {**job["case"], "case_id": "1.1"}})
evidence = tmp_path / "evidence"
evidence.mkdir()
first = evidence / "first.png"
@@ -343,14 +505,16 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
assert response.status_code == 200
with zipfile.ZipFile(BytesIO(response.content)) as archive:
files = archive.namelist()
assert all(name.startswith("1.1/") for name in files)
assert any(name.endswith(".docx") for name in files)
assert any(name.endswith(".xlsx") for name in files)
assert files.index("步骤截图/0001_步骤0001_操作前.png") < files.index(
"步骤截图/0002_步骤0002_操作后.png"
assert files.index("1.1/步骤截图/0001_步骤0001_操作前.png") < files.index(
"1.1/步骤截图/0002_步骤0002_操作后.png"
)
assert archive.read("任务录屏/0001_recording.mp4") == b"recording.mp4"
manifest = json.loads(archive.read("文件清单.json"))
assert archive.read("1.1/任务录屏/0001_recording.mp4") == b"recording.mp4"
manifest = json.loads(archive.read("1.1/文件清单.json"))
assert manifest["session_id"] == job["session_id"]
assert "1.1-all.zip" in response.headers["content-disposition"]
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
@@ -5,6 +5,14 @@ import pytest
from apps.admin_console.routers import sessions as sessions_router
@pytest.fixture(autouse=True)
def isolate_jobs(monkeypatch):
mocked = MagicMock()
mocked.jobs_for_sessions.return_value = {}
monkeypatch.setattr(sessions_router, "store", mocked)
return mocked
@pytest.mark.asyncio
async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
repo = MagicMock()
@@ -96,3 +104,42 @@ async def test_list_sessions_extracts_device_serial(monkeypatch):
assert result[0]["device_id"] == "63191FDKX00062"
assert result[1]["device_serial"] == "emulator-5554"
assert result[1]["device_id"] == "emulator-5554"
@pytest.mark.asyncio
async def test_list_sessions_uses_batch_title_and_timeout_error(monkeypatch, isolate_jobs):
repo = MagicMock()
repo.get_all_sessions.return_value = [
{
"session_id": "session-case",
"status": "completed",
"start_time": 1.0,
"initial_goal": "打开设置并检查页面",
"device_info": '{"profile":"flash"}',
}
]
repo.get_video_recordings_map.return_value = {}
repo.get_latest_video_recordings_map.return_value = {}
repo.get_agent_trace_names_map.return_value = {}
monkeypatch.setattr(sessions_router, "session_repo", repo)
monkeypatch.setattr(
sessions_router.media_service, "build_video_index", MagicMock(return_value={})
)
isolate_jobs.jobs_for_sessions.return_value = {
"session-case": {
"session_id": "session-case",
"status": "failed",
"ended_at": 65.0,
"error": "任务执行超时(60 秒)",
"case": {"case_id": "1.1", "name": "打开并核对设置", "goal": "打开设置并检查页面"},
}
}
result = await sessions_router.list_sessions()
isolate_jobs.jobs_for_sessions.assert_called_once_with(["session-case"])
assert result[0]["display_name"] == "1.1 打开并核对设置"
assert result[0]["initial_goal"] == "打开设置并检查页面"
assert result[0]["status"] == "failed"
assert result[0]["end_time"] == 65.0
assert "超时" in result[0]["error"]
@@ -636,6 +636,32 @@ async def test_run_no_tool_call_turn_is_nudged_not_terminated(mock_context):
and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content)
for m in turn2_messages
)
assert not any(
isinstance(m, AIMessage) and m.content == "thinking only" for m in turn2_messages
)
@pytest.mark.asyncio
@pytest.mark.parametrize("attempts", [1, 3])
async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch, attempts):
from artemis.config.settings import settings
monkeypatch.setattr(settings, "ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS", attempts)
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts)]
with (
patch("artemis.controllers.unified_controller.get_driver"),
patch(
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
AsyncMock(return_value=_INITIAL_OBSERVATION),
),
):
runner = _make_runner(mock_context, responses, max_turns=0)
result = await runner.run(State(initial_goal="Open Settings"))
assert result["status"] == "failed"
assert f"连续 {attempts} 次未调用工具" in result["explanation"]
assert runner._invoke_model.await_count == attempts
runner.executor.execute.assert_not_awaited()
# ---------------------------------------------------------------------------
+8 -2
View File
@@ -80,13 +80,19 @@ async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
monkeypatch.setattr(
tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)
)
result = await tools._download("/api/automation/reports/test/docx", "test.docx")
result = await tools._download(
"/api/automation/reports/test/docx",
"test.docx",
owner_kind="sessions",
owner_id="test",
)
from pathlib import Path
assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes"
assert Path(result["path"]).is_absolute()
assert Path(result["path"]).parent == tmp_path / "sessions" / "test"
assert result["download_url"] == "http://test/api/automation/reports/test/docx"
assert not list(tmp_path.glob("*.part"))
assert not list(tmp_path.rglob("*.part"))
@pytest.mark.asyncio
+1 -1
View File
@@ -373,7 +373,7 @@ def test_mobile_manage_task_inject_instruction(temp_trace_env):
ok_res = mobile_manage_task(
action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly"
)
assert "Successfully injected" in ok_res["message"]
assert "current model step interrupted" in ok_res["message"]
@pytest.mark.asyncio
+29
View File
@@ -116,6 +116,35 @@ async def test_interruptible_call_returns_normal_result_without_pause(tmp_path,
assert await control.run_interruptibly(model_call()) == (False, "current result")
@pytest.mark.asyncio
async def test_new_guidance_cancels_stale_model_without_releasing_device(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
released = []
class Lock:
def release(self):
released.append(True)
control = task_control.TaskControl("task-123", Lock())
started = asyncio.Event()
cancelled = asyncio.Event()
async def stale_model():
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
call = asyncio.create_task(control.run_interruptibly(stale_model()))
await asyncio.wait_for(started.wait(), 1)
task_control.add_instruction("task-123", "改为检查当前页面", source="mcp")
assert await asyncio.wait_for(call, 1) == (True, None)
assert cancelled.is_set()
assert released == []
assert task_control.consume_instructions(control.directory)["instruction"] == "改为检查当前页面"
def test_guidance_history_distinguishes_submission_from_runner_consumption(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
first = task_control.add_instruction("task-123", "从快捷设置进入", source="web")
+157 -6
View File
@@ -120,7 +120,7 @@ command = "leave-alone"
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
def test_json_only_updates_existing_matching_project(tmp_path):
def test_json_migrates_existing_standard_mcp_to_current_checkout(tmp_path):
path = tmp_path / "mcp.json"
root = tmp_path / "project"
config = {
@@ -132,19 +132,170 @@ def test_json_only_updates_existing_matching_project(tmp_path):
"autoApprove": [],
}
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
assert not update.refresh_json(
path, ("mcpServers",), tmp_path / "different", "new", ("mobile_batch",)
)
assert update.refresh_json(path, ("mcpServers",), root, "new", ("mobile_batch",))
moved_root = tmp_path / "new release"
assert update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
result = json.loads(path.read_text())["mcpServers"]
assert result["artemis"]["env"] == {"CUSTOM": "preserved"}
assert result["artemis"]["env"]["CUSTOM"] == "preserved"
assert result["artemis"]["env"]["PYTHONPATH"] == str(moved_root)
assert result["artemis"]["cwd"] == str(moved_root)
assert result["artemis"]["disabled"] is True
assert result["other"] == {"command": "keep"}
assert not update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
missing = tmp_path / "not-installed.json"
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
assert not missing.exists()
def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
home.mkdir()
codex_home = home / ".codex"
codex_home.mkdir()
codex_config = codex_home / "config.toml"
codex_config.write_text(
'model = "my-model"\n'
"[mcp_servers.artemis]\n"
'command = "old-python"\n'
'args = ["-m", "mcp_server.server"]\n'
'cwd = "C:/old-release"\n'
'enabled_tools = ["mobile_run_task", "retired_tool"]\n'
"enabled = false\n"
"[mcp_servers.artemis.env]\n"
'PYTHONPATH = "C:/old-release"\n'
'OPENAI_API_KEY = "keep-secret"\n',
encoding="utf-8",
)
cursor_home = home / ".cursor"
cursor_home.mkdir()
(cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}')
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
monkeypatch.setenv("APPDATA", str(home / "AppData"))
root = tmp_path / "new release"
assert update.refresh_mcp(root) == 0
codex = tomllib.loads(codex_config.read_text(encoding="utf-8"))
entry = codex["mcp_servers"]["artemis"]
assert codex["model"] == "my-model"
assert entry["command"] == update.sys.executable
assert entry["cwd"] == str(root)
assert entry["args"] == ["-m", "mcp_server"]
assert entry["enabled"] is False
assert entry["env"]["OPENAI_API_KEY"] == "keep-secret"
assert entry["env"]["PYTHONPATH"] == str(root)
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8"))
assert cursor["mcpServers"]["artemis"]["env"]["PYTHONPATH"] == str(root)
assert cursor["mcpServers"]["other"] == {"command": "keep"}
first = codex_config.read_text(encoding="utf-8")
assert update.refresh_mcp(root) == 0
assert codex_config.read_text(encoding="utf-8") == first
def test_mcp_sync_fails_on_invalid_detected_config_without_overwriting(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
cursor_home = home / ".cursor"
cursor_home.mkdir(parents=True)
config = cursor_home / "mcp.json"
config.write_text("{invalid json", encoding="utf-8")
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.delenv("CODEX_HOME", raising=False)
assert update.refresh_mcp(tmp_path / "project") == 1
assert config.read_text(encoding="utf-8") == "{invalid json"
def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
codex_home = home / ".codex"
codex_home.mkdir(parents=True)
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
root = tmp_path / "project"
assert update.refresh_mcp(root) == 0
config = tomllib.loads((codex_home / "config.toml").read_text(encoding="utf-8"))
assert config["mcp_servers"]["artemis"]["cwd"] == str(root)
assert config["mcp_servers"]["artemis"]["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
def test_codex_install_preserves_existing_user_tables(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
'model = "keep"\n[mcp_servers.other]\ncommand = "other-tool"\n', encoding="utf-8"
)
template = {
"command": "new-python",
"args": ["-m", "mcp_server"],
"cwd": str(tmp_path),
"env": {"PYTHONPATH": str(tmp_path)},
"enabled_tools": ["mobile_run_task"],
}
assert update.refresh_codex(
path,
tmp_path,
"new-python",
("mobile_run_task",),
template=template,
install_missing=True,
)
data = tomllib.loads(path.read_text(encoding="utf-8"))
assert data["model"] == "keep"
assert data["mcp_servers"]["other"]["command"] == "other-tool"
assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
def test_codex_inline_environment_sync_is_idempotent(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
'[mcp_servers.artemis]\ncommand = "old-python"\n'
'args = ["-m", "mcp_server"]\n'
'env = { PYTHONPATH = "C:/old", OPENAI_API_KEY = "keep-secret" }\n',
encoding="utf-8",
)
template = {
"command": "new-python",
"args": ["-m", "mcp_server"],
"cwd": str(tmp_path),
"env": {"PYTHONPATH": str(tmp_path)},
"enabled_tools": ["mobile_run_task"],
}
assert update.refresh_codex(
path, tmp_path, "new-python", ("mobile_run_task",), template=template
)
data = tomllib.loads(path.read_text(encoding="utf-8"))["mcp_servers"]["artemis"]
assert data["env"]["OPENAI_API_KEY"] == "keep-secret"
assert data["env"]["PYTHONPATH"] == str(tmp_path)
assert not update.refresh_codex(
path, tmp_path, "new-python", ("mobile_run_task",), template=template
)
def test_custom_mcp_wrapper_is_left_unchanged(tmp_path):
path = tmp_path / "mcp.json"
original = '{"mcpServers":{"artemis":{"command":"uv","args":["run","custom"]}}}'
path.write_text(original, encoding="utf-8")
assert (
update.refresh_json(
path,
("mcpServers",),
tmp_path,
"new-python",
("mobile_run_task",),
install_missing=True,
template={"command": "new-python", "args": ["-m", "mcp_server"], "env": {}},
)
is None
)
assert path.read_text(encoding="utf-8") == original
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
ui = tmp_path / "apps/showcase_ui"
(ui / "src").mkdir(parents=True)
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]]
name = "artemis"
version = "1.1.1"
version = "1.2.1"
source = { editable = "." }
dependencies = [
{ name = "adbutils" },