diff --git a/apps/admin_console/routers/automation.py b/apps/admin_console/routers/automation.py
index cb5d5e2..ddadd12 100644
--- a/apps/admin_console/routers/automation.py
+++ b/apps/admin_console/routers/automation.py
@@ -121,6 +121,20 @@ def batch_detail(batch_id: str):
return {**require("batch", batch_id), "jobs": store.jobs(batch_id)}
+@router.delete("/batches/{batch_id}")
+def delete_batch(batch_id: str):
+ try:
+ key = identifier(batch_id)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ try:
+ if not store.delete_batch(key):
+ raise HTTPException(404, "批次不存在")
+ except ValueError as exc:
+ raise HTTPException(409, str(exc)) from exc
+ return {"batch_id": key, "status": "deleted"}
+
+
@router.post("/batches/{batch_id}/{action}")
def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed"]):
require("batch", batch_id)
diff --git a/apps/admin_console/routers/tasks.py b/apps/admin_console/routers/tasks.py
index 25e8ce1..58eea87 100644
--- a/apps/admin_console/routers/tasks.py
+++ b/apps/admin_console/routers/tasks.py
@@ -24,6 +24,7 @@ from artemis.runtime import DeviceExecutionLock, device_pool
from artemis.runtime.task_control import (
add_instruction,
control_dir,
+ list_instructions,
read_state,
request_pause,
request_resume,
@@ -74,13 +75,24 @@ def pause_running_task(session_id: str):
@router.post("/api/tasks/{session_id}/guide")
def guide_running_task(session_id: str, value: GuidanceRequest):
_live_task(session_id)
- add_instruction(session_id, value.instruction, source="web")
+ guidance = add_instruction(session_id, value.instruction, source="web")
if (
read_state(session_id).get("status") == "task_paused"
or (control_dir(session_id) / "pause.request").exists()
):
request_resume(session_id)
- return {"session_id": session_id, "status": "guidance_submitted"}
+ return {"session_id": session_id, "status": "guidance_submitted", "guidance": guidance}
+
+
+@router.get("/api/tasks/{session_id}/guidance")
+def task_guidance_history(session_id: str):
+ from apps.admin_console.services.automation_store import identifier
+
+ try:
+ identifier(session_id)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ return list_instructions(session_id)
@router.post("/api/tasks/{session_id}/continue")
diff --git a/apps/admin_console/services/automation_store.py b/apps/admin_console/services/automation_store.py
index a2264b4..bc70463 100644
--- a/apps/admin_console/services/automation_store.py
+++ b/apps/admin_console/services/automation_store.py
@@ -164,6 +164,36 @@ class AutomationStore:
conn.commit()
return batch
+ def delete_batch(self, batch_id: str) -> bool:
+ """Remove a batch record; retain executed job metadata and its reports."""
+ 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 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,),
+ )
+ conn.execute(
+ "DELETE FROM automation_records WHERE kind='batch' AND id=?",
+ (batch_id,),
+ )
+ # Keep the request-id tombstone so deletion cannot replay an old batch.
+ conn.commit()
+ return True
+
def delete_history(self, session_id: str | None = None):
with self.connection() as conn:
if session_id:
diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py
index 18914db..0c7496e 100644
--- a/apps/admin_console/services/report_export.py
+++ b/apps/admin_console/services/report_export.py
@@ -2,7 +2,6 @@
from datetime import datetime
from typing import cast
-from docx.styles.style import ParagraphStyle
import json
from pathlib import Path
import re
@@ -10,10 +9,14 @@ import threading
import zipfile
from docx import Document
-from docx.shared import Inches, Pt
+from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
+from docx.oxml import OxmlElement
from docx.oxml.ns import qn
+from docx.shared import Cm, Pt, RGBColor
+from docx.styles.style import ParagraphStyle
from openpyxl import Workbook
-from openpyxl.styles import Font, PatternFill, Alignment
+from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
+from openpyxl.utils import get_column_letter
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
from apps.admin_console.database.repositories.session_repository import SessionRepository
@@ -60,6 +63,42 @@ def verdict(outcome: dict | None) -> str:
return "未验证(执行完成不等于测试通过)"
+def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
+ def redact(value: str) -> str:
+ safe = text(value).strip()
+ safe = re.sub(r"sk-[A-Za-z0-9_-]{12,}", "[已隐藏密钥]", safe)
+ safe = re.sub(
+ r"(?i)(\b(?:authorization\s*:\s*bearer|bearer)\s+)[^\s,;]+",
+ r"\1[已隐藏密钥]",
+ safe,
+ )
+ safe = re.sub(
+ r"(?i)(\b(?:OPENAI_API_KEY|api[_-]?key)\s*[:=]\s*['\"]?)[^\s,'\";]+",
+ r"\1[已隐藏密钥]",
+ safe,
+ )
+ return safe[:500]
+
+ if recorded:
+ return redact(recorded)
+ if status not in {"failed", "interrupted"}:
+ return ""
+ for name in ("stdout.log", "stderr.log"):
+ path = directory / name
+ try:
+ with path.open("rb") as stream:
+ stream.seek(0, 2)
+ stream.seek(max(0, stream.tell() - 131072))
+ lines = stream.read().decode("utf-8", errors="replace").splitlines()
+ except OSError:
+ continue
+ for line in reversed(lines):
+ match = re.search(r"Error running automation:\s*(.+)", line)
+ if match:
+ return redact(match.group(1))
+ return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
+
+
def session_report(session_id: str, db_path=None) -> dict:
identifier(session_id)
meta = store.job(session_id) or {}
@@ -89,24 +128,31 @@ def session_report(session_id: str, db_path=None) -> dict:
if not outcome:
outcome = {"task_status": args.get("status"), "tests": {}}
break
- if not content:
- content = "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
case = meta.get("case") or {}
+ status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
+ error = failure_reason(directory, status, meta.get("error"))
+ verification = verdict(outcome)
+ if status in {"failed", "interrupted"} and verification.startswith("未验证"):
+ verification = "未验证(任务异常终止)"
+ if not content:
+ content = (
+ "本任务未生成最终结论。请查看异常原因和已记录的执行步骤。"
+ if status in {"failed", "interrupted"}
+ else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
+ )
return {
"id": session_id,
"name": case.get("name") or session.get("initial_goal", session_id)[:120],
"goal": case.get("goal") or session.get("initial_goal", ""),
"expected": case.get("expected", ""),
- "status": meta.get("terminal_status")
- or meta.get("status")
- or session.get("status", "unknown"),
- "verdict": verdict(outcome),
+ "status": status,
+ "verdict": verification,
"outcome": outcome,
"content": content,
"steps": steps,
"start": session.get("start_time") or meta.get("started_at"),
"end": session.get("end_time") or meta.get("ended_at"),
- "error": meta.get("error", ""),
+ "error": error,
"device": meta.get("device_serial") or text(session.get("device_info") or ""),
"tags": case.get("tags", ""),
"case_id": case.get("case_id", ""),
@@ -119,7 +165,7 @@ def session_report(session_id: str, db_path=None) -> dict:
def time_text(value) -> str:
return (
- datetime.fromtimestamp(float(value)).astimezone().isoformat(timespec="seconds")
+ datetime.fromtimestamp(float(value)).astimezone().strftime("%Y-%m-%d %H:%M:%S")
if value
else "—"
)
@@ -132,7 +178,7 @@ def metadata(report: dict) -> list[tuple]:
("检测项目", report.get("category") or "—"),
("测试子项", report.get("subcategory") or "—"),
("前置约束提取", report.get("preconditions") or "—"),
- ("任务配置", text(report.get("task_config") or {})),
+ ("任务配置", config_text(report.get("task_config"))),
("任务目标", report["goal"]),
("预期结果", report["expected"] or "未指定"),
("执行状态", STATUS.get(report["status"], report["status"])),
@@ -144,6 +190,16 @@ def metadata(report: dict) -> list[tuple]:
]
+def config_text(config) -> str:
+ if not isinstance(config, dict):
+ return text(config or {})
+ return json.dumps(
+ {key: value for key, value in config.items() if value is not None},
+ ensure_ascii=False,
+ indent=2,
+ )
+
+
def image_path(name: str | None) -> Path | None:
if not name:
return None
@@ -161,10 +217,14 @@ def image_path(name: str | None) -> Path | None:
def markdown_blocks(content: str):
lines = content.splitlines()
index = 0
+ in_code = False
while index < len(lines):
line = lines[index]
+ if line.strip().startswith("```"):
+ in_code = not in_code
if (
- "|" in line
+ not in_code
+ and "|" in line
and index + 1 < len(lines)
and re.fullmatch(r"[\s|:\-]+", lines[index + 1])
and "-" in lines[index + 1]
@@ -180,81 +240,296 @@ def markdown_blocks(content: str):
index += 1
-def word_report(report: dict, path: Path):
- doc = Document()
- style = cast(ParagraphStyle, doc.styles["Normal"])
- style.font.name = "Microsoft YaHei"
- style.font.size = Pt(10)
- style.element.rPr.rFonts.set(qn("w:eastAsia"), "微软雅黑")
- doc.add_heading(report["name"], 0)
- table = doc.add_table(rows=0, cols=2)
- table.columns[0].width = Inches(1.3)
- table.columns[1].width = Inches(5.2)
- table.style = "Light Shading Accent 1"
- for label, value in metadata(report):
+def word_styles(doc, *, landscape: bool = False):
+ section = doc.sections[0]
+ section.page_width, section.page_height = (
+ (Cm(29.7), Cm(21)) if landscape else (Cm(21), Cm(29.7))
+ )
+ section.left_margin = section.right_margin = Cm(2)
+ section.top_margin = section.bottom_margin = Cm(1.9)
+ for name, size, color in (
+ ("Normal", 10.5, "263445"),
+ ("Title", 20, "0F172A"),
+ ("Heading 1", 14, "0F172A"),
+ ("Heading 2", 11.5, "0F172A"),
+ ("Heading 3", 10.5, "0F172A"),
+ ("List Bullet", 10.5, "263445"),
+ ("List Number", 10.5, "263445"),
+ ("Quote", 10.5, "263445"),
+ ):
+ style = cast(ParagraphStyle, doc.styles[name])
+ style.font.name = "Microsoft YaHei"
+ style.font.size = Pt(size)
+ style.font.color.rgb = RGBColor.from_string(color)
+ style.element.get_or_add_rPr().get_or_add_rFonts().set(qn("w:eastAsia"), "微软雅黑")
+ style.paragraph_format.space_after = Pt(7 if name == "Normal" else 9)
+ if name == "Normal":
+ style.paragraph_format.line_spacing = 1.3
+ if name == "Title":
+ ppr = style.element.get_or_add_pPr()
+ border = ppr.find(qn("w:pBdr"))
+ if border is not None:
+ ppr.remove(border)
+ return doc
+
+
+def shade_cell(cell, fill: str):
+ shading = OxmlElement("w:shd")
+ shading.set(qn("w:fill"), fill)
+ cell._tc.get_or_add_tcPr().append(shading)
+
+
+def word_table(doc, rows, widths: tuple[float, ...], *, header: bool = False):
+ table = doc.add_table(rows=0, cols=len(widths))
+ table.autofit = False
+ table.style = "Table Grid"
+ borders = OxmlElement("w:tblBorders")
+ for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
+ line = OxmlElement(f"w:{edge}")
+ line.set(qn("w:val"), "single")
+ line.set(qn("w:sz"), "4")
+ line.set(qn("w:color"), "D9D9D9")
+ borders.append(line)
+ table._tbl.tblPr.append(borders)
+ for column, width in zip(table.columns, widths):
+ column.width = Cm(width)
+ for index, row in enumerate(rows):
cells = table.add_row().cells
- cells[0].text, cells[1].text = label, text(value)
- cells[0].width, cells[1].width = Inches(1.3), Inches(5.2)
- doc.add_heading("结论与任务专属结果", 1)
- for kind, value in markdown_blocks(text(report["content"])):
+ if header and index == 0:
+ table.rows[-1]._tr.get_or_add_trPr().append(OxmlElement("w:tblHeader"))
+ for position, cell in enumerate(cells):
+ cell.width = Cm(widths[position])
+ cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
+ cell.text = text(row[position]) if position < len(row) else ""
+ margins = OxmlElement("w:tcMar")
+ for edge, size in (("top", "85"), ("bottom", "85"), ("left", "110"), ("right", "110")):
+ margin = OxmlElement(f"w:{edge}")
+ margin.set(qn("w:w"), size)
+ margin.set(qn("w:type"), "dxa")
+ margins.append(margin)
+ cell._tc.get_or_add_tcPr().append(margins)
+ if (header and index == 0) or (not header and position == 0):
+ shade_cell(cell, "DDEBF5")
+ for run in cell.paragraphs[0].runs:
+ run.bold = True
+ elif index % 2 == 0:
+ shade_cell(cell, "F6F9FC")
+ for paragraph in cell.paragraphs:
+ paragraph.paragraph_format.space_after = Pt(3)
+ for run in paragraph.runs:
+ run.font.size = Pt(8.5 if len(widths) > 2 else 9.5)
+ doc.add_paragraph().paragraph_format.space_after = Pt(2)
+ return table
+
+
+def add_markdown_inline(paragraph, value: str):
+ """Keep emphasis readable in Word without importing untrusted HTML."""
+ pieces = re.split(r"(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)", value)
+ for piece in pieces:
+ if not piece:
+ continue
+ run = paragraph.add_run(
+ piece.strip("*")
+ if piece.startswith("*")
+ else piece.strip("`")
+ if piece.startswith("`")
+ else piece
+ )
+ run.bold = piece.startswith("**")
+ run.italic = piece.startswith("*") and not piece.startswith("**")
+ if piece.startswith("`"):
+ run.font.name = "Consolas"
+
+
+def add_markdown_content(doc, content: str):
+ in_code = False
+ for kind, value in markdown_blocks(text(content)):
if kind == "table":
- table = doc.add_table(rows=0, cols=max(map(len, value)))
- table.style = "Light Shading Accent 1"
- for row in value:
- cells = table.add_row().cells
- for cell, item in zip(cells, row):
- cell.text = item
- elif isinstance(value, str) and value.startswith("#"):
- doc.add_heading(value.lstrip("# "), min(len(value) - len(value.lstrip("#")), 3))
+ columns = max(map(len, value))
+ if columns:
+ word_table(doc, value, tuple(16 / columns for _ in range(columns)), header=True)
+ continue
+ line = str(value)
+ if line.strip().startswith("```"):
+ in_code = not in_code
+ continue
+ if in_code:
+ paragraph = doc.add_paragraph()
+ shade = OxmlElement("w:shd")
+ shade.set(qn("w:fill"), "F1F5F9")
+ paragraph._p.get_or_add_pPr().append(shade)
+ paragraph.add_run(line).font.name = "Consolas"
+ elif not line.strip():
+ continue
+ elif match := re.match(r"^(#{1,3})\s+(.+)$", line):
+ doc.add_heading(match.group(2), level=min(len(match.group(1)) + 1, 3))
+ elif match := re.match(r"^\s*([-*+] |\d+\. )(.+)$", line):
+ paragraph = doc.add_paragraph(
+ style="List Number" if match.group(1)[0].isdigit() else "List Bullet"
+ )
+ add_markdown_inline(paragraph, match.group(2))
else:
- doc.add_paragraph(str(value))
+ paragraph = doc.add_paragraph(style="Quote" if line.lstrip().startswith("> ") else None)
+ add_markdown_inline(
+ paragraph, line.lstrip("> ") if line.lstrip().startswith("> ") else line
+ )
+
+
+def word_report(report: dict, path: Path):
+ doc = word_styles(Document())
+ doc.add_paragraph(report["name"], style="Title")
+ doc.add_paragraph(
+ f"执行状态:{STATUS.get(report['status'], report['status'])} 验证结论:{report['verdict']}"
+ )
+ if report["error"]:
+ doc.add_heading("异常原因", 1)
+ doc.add_paragraph(text(report["error"]))
+ doc.add_heading("任务信息", 1)
+ details = [
+ (label, value)
+ for label, value in metadata(report)
+ if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"}
+ ]
+ word_table(doc, details, (3.2, 13.3))
+ doc.add_heading("任务目标", 2)
+ doc.add_paragraph(report["goal"] or "未记录")
+ doc.add_heading("预期结果", 2)
+ doc.add_paragraph(report["expected"] or "未指定")
+ if report.get("preconditions"):
+ doc.add_paragraph("前置约束:" + text(report["preconditions"]))
+ if report.get("task_config"):
+ doc.add_paragraph("任务配置:" + config_text(report["task_config"]))
+ doc.add_heading("结论与发现", 1)
+ add_markdown_content(doc, report["content"])
doc.add_heading("执行步骤与证据", 1)
for step in report["steps"]:
- doc.add_paragraph(
- f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))} · {step.get('summary') or '已记录操作'}"
- )
- evidence = report["steps"][:1] + report["steps"][-3:]
- seen = set()
+ paragraph = doc.add_paragraph()
+ paragraph.paragraph_format.keep_together = True
+ paragraph.add_run(
+ f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n"
+ ).bold = True
+ paragraph.add_run(step.get("summary") or "已记录操作")
+ evidence = report["steps"][:1] + report["steps"][-1:]
+ seen: set[str] = set()
+ images: list[tuple[Path, int]] = []
for step in evidence:
name = step.get("post_image_name") or step.get("pre_image_name")
photo = image_path(name)
if photo and name not in seen:
seen.add(name)
- doc.add_paragraph(f"证据:步骤 {step['step_number']} / {name}")
- doc.add_picture(str(photo), width=Inches(2.6))
+ images.append((photo, step["step_number"]))
+ if images:
+ doc.add_page_break()
+ doc.add_heading("关键截图", 2)
+ table = doc.add_table(rows=1, cols=len(images))
+ table.autofit = False
+ for cell, (photo, step_number) in zip(table.rows[0].cells, images):
+ cell.width = Cm(8 if len(images) == 2 else 12)
+ cell.paragraphs[0].add_run(f"步骤 {step_number}").bold = True
+ cell.add_paragraph().add_run().add_picture(
+ str(photo), width=Cm(7.5 if len(images) == 2 else 8.5)
+ )
+ doc.add_paragraph("其余步骤截图可从任务资料包单独下载。")
doc.add_paragraph(f"追溯编号:{report['id']}。未观察、未检查的项目不计为验证通过。")
doc.save(str(path))
-def sheet_rows(ws, rows):
- for row in rows:
+def sheet_rows(ws, rows, widths: tuple[int, ...] = ()):
+ ws.sheet_view.showGridLines = False
+ ws.freeze_panes = "A2"
+ ws.sheet_properties.pageSetUpPr.fitToPage = True
+ ws.page_setup.fitToWidth = 1
+ if len(widths) > 3:
+ ws.page_setup.orientation = "landscape"
+ ws.page_setup.paperSize = ws.PAPERSIZE_A3
+ ws.print_options.horizontalCentered = True
+ for index, row in enumerate(rows, 1):
ws.append([text(value)[:32700] if value is not None else "" for value in row])
- for cell in ws[ws.max_row]:
+ longest = 1
+ for column, cell in enumerate(ws[index], 1):
# Always store text, including leading '=': never export executable formulas.
cell.data_type = "s"
- cell.alignment = Alignment(wrap_text=True, vertical="top")
- ws.freeze_panes = "A2"
- if ws.max_row:
+ cell.alignment = Alignment(wrap_text=True, vertical="center" if index == 1 else "top")
+ cell.font = Font(
+ name="微软雅黑",
+ size=10,
+ bold=index == 1,
+ color="FFFFFF" if index == 1 else "253244",
+ )
+ cell.border = Border(bottom=Side(style="hair", color="E2E8F0"))
+ if index == 1:
+ cell.fill = PatternFill("solid", fgColor="245C78")
+ elif index % 2 == 1 or column == 1:
+ cell.fill = PatternFill("solid", fgColor="F3F7FA" if column == 1 else "F8FAFC")
+ width = widths[column - 1] if column <= len(widths) else 32
+ ws.column_dimensions[get_column_letter(column)].width = width
+ lines = str(cell.value or "").splitlines() or [""]
+ height_lines = sum(
+ max(1, (sum(2 if ord(char) > 255 else 1 for char in line) + width - 1) // width)
+ for line in lines
+ )
+ longest = max(longest, height_lines)
+ ws.row_dimensions[index].height = 30 if index == 1 else min(300, max(27, longest * 18 + 8))
+ if ws.max_row > 1:
ws.auto_filter.ref = ws.dimensions
- for cell in ws[1]:
- cell.font = Font(bold=True, color="FFFFFF")
- cell.fill = PatternFill("solid", fgColor="245C78")
- for column in ws.columns:
- ws.column_dimensions[column[0].column_letter].width = 32
+
+
+def conclusion_rows(content: str):
+ rows = [("类型", "结论与发现")]
+ table_number = 0
+ in_code = False
+ for kind, value in markdown_blocks(content):
+ if kind == "table":
+ table_number += 1
+ rows.append(("表格", f"详见「结果表{table_number}」工作表"))
+ elif line := str(value).strip():
+ if line.startswith("```"):
+ in_code = not in_code
+ continue
+ if in_code:
+ rows.append(("代码", line))
+ continue
+ if match := re.match(r"^#{1,6}\s+(.*)$", line):
+ label, body = "小节", match.group(1)
+ elif match := re.match(r"^(?:[-*+] |\d+\. )(.*)$", line):
+ label, body = "要点", match.group(1)
+ else:
+ label, body = "正文", line.lstrip("> ")
+ rows.append(
+ (
+ label,
+ re.sub(r"\*\*([^*]+)\*\*|`([^`]+)`", lambda m: m.group(1) or m.group(2), body),
+ )
+ )
+ return rows
+
+
+def execution_result_label(result) -> str:
+ if not isinstance(result, dict):
+ return text(result or "未记录")[:120]
+ status = result.get("status")
+ labels = {
+ "dispatched": "动作已发出(需核对截图)",
+ "success": "动作返回成功",
+ "failed": "动作失败",
+ }
+ label = labels.get(status, text(status or "未记录"))
+ if result.get("error"):
+ label += "\n" + text(result["error"])[:180]
+ return label
def excel_report(report: dict, path: Path):
wb = Workbook()
assert wb.active is not None
wb.active.title = "任务结果"
- sheet_rows(wb.active, [("项目", "内容"), *metadata(report)])
- sheet_rows(
- wb.create_sheet("结论"),
- [("最终结论",), *[(line,) for line in report["content"].splitlines()]],
- )
- for number, (kind, rows) in enumerate(markdown_blocks(report["content"])):
+ sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
+ sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96))
+ table_number = 0
+ for kind, rows in markdown_blocks(report["content"]):
if kind == "table":
- sheet_rows(wb.create_sheet(f"结果表{number + 1}"), rows)
+ table_number += 1
+ sheet_rows(wb.create_sheet(f"结果表{table_number}"), rows)
sheet_rows(
wb.create_sheet("执行证据"),
[
@@ -264,12 +539,15 @@ def excel_report(report: dict, path: Path):
s["step_number"],
time_text(s.get("timestamp")),
s.get("summary"),
- text(s.get("last_execution_result")),
- s.get("post_image_name") or s.get("pre_image_name"),
+ execution_result_label(s.get("last_execution_result")),
+ "见步骤截图资料包"
+ if s.get("post_image_name") or s.get("pre_image_name")
+ else "无截图",
)
for s in report["steps"]
],
],
+ (10, 22, 68, 26, 24),
)
wb.save(path)
@@ -392,21 +670,17 @@ def export_batch(batch_id: str) -> Path:
wb = Workbook()
assert wb.active is not None
wb.active.title = "批次结果"
- sheet_rows(wb.active, rows)
+ sheet_rows(wb.active, rows, (40, 48, 16, 28, 70))
path = directory / "批次结果.xlsx"
wb.save(path)
outputs.append(path)
if "docx" in formats:
- doc = Document()
- doc.add_heading(batch["name"], 0)
+ doc = word_styles(Document(), landscape=True)
+ doc.add_paragraph(batch["name"], style="Title")
doc.add_paragraph(
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
)
- table = doc.add_table(rows=0, cols=5)
- table.style = "Light Shading Accent 1"
- for row in rows:
- for cell, value in zip(table.add_row().cells, row):
- cell.text = str(value)
+ word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True)
path = directory / "批次汇总.docx"
doc.save(str(path))
outputs.append(path)
diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json
index f39e776..4a541d2 100644
--- a/apps/showcase_ui/package-lock.json
+++ b/apps/showcase_ui/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "artemis-showcase-ui",
- "version": "1.0.1",
+ "version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artemis-showcase-ui",
- "version": "1.0.1",
+ "version": "1.1.0",
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json
index 074c7b7..df7385a 100644
--- a/apps/showcase_ui/package.json
+++ b/apps/showcase_ui/package.json
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
- "version": "1.0.1",
+ "version": "1.1.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
index 40008a8..05648f5 100644
--- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
+++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
@@ -331,7 +331,7 @@ limitations under the License.
- @if (consolidatedBlocks().length > 0 || showPlanningLoader()) {
+ @if (consolidatedBlocks().length > 0 || showPlanningLoader() || agentService.currentSession()) {
@if (agentService.viewedModel(); as model) {
@@ -492,6 +492,7 @@ limitations under the License.
}
+
}
@@ -568,6 +569,13 @@ limitations under the License.
>
+ @if (block.data?.extra_metadata?.injected_instruction; as guidance) {
+
+
forum用户指导 · 已进入第 {{block.data.step_number}} 步
+
{{ guidance }}
+
{{ hasRecordedDeviceAction(block.data.action_taken) ? '本步设备动作见下方;是否完成目标需以实际屏幕为准。' : '本步没有记录设备动作。' }}
+
+ }
@@ -746,7 +754,7 @@ limitations under the License.
@if (getReportStatusExplanation(item.data); as expText) {
}
@@ -1283,6 +1291,23 @@ limitations under the License.
}
}
+ @if (awaitingGuidance().length) {
+
+ @for (item of awaitingGuidance(); track item.id) {
+
+
用户指导 · {{ guidanceStatusLabel(item) }}
+
{{ item.instruction }}
+
+ }
+
+ }
+ @if (terminalFailureReason(); as reason) {
+
+
任务执行失败 · 原因
+
{{ reason }}
+
+
+ }
@if (outputterReport(); as outReport) {
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss
index cb1c34f..6e3c149 100644
--- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss
+++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss
@@ -1921,6 +1921,8 @@ $stream-row-height: 25px;
}
&.task-report-card {
+ width: min(680px, 100%);
+ box-sizing: border-box;
background: #f0fdf4;
border: 1px solid #bbf7d0;
@@ -1967,12 +1969,13 @@ $stream-row-height: 25px;
}
.report-markdown-body {
- font-size: 12px;
- line-height: 1.5;
+ font-size: 14px;
+ line-height: 1.7;
color: #1f2937;
font-weight: 400;
user-select: text;
- word-break: break-word;
+ word-break: normal;
+ overflow-wrap: anywhere;
p {
margin: 3px 0;
@@ -2227,11 +2230,19 @@ $stream-row-height: 25px;
color: #64748b;
}
- .grid-value {
- font-size: 12px;
- font-weight: 600;
- color: #1e293b;
- word-break: break-all;
+ .grid-value {
+ font-size: 12px;
+ font-weight: 600;
+ color: #1e293b;
+ word-break: break-all;
+
+ &.report-markdown-body {
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 1.7;
+ word-break: normal;
+ overflow-wrap: anywhere;
+ }
&.code-pill {
font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace;
@@ -3971,3 +3982,48 @@ $stream-row-height: 25px;
}
}
}
+.guidance-step-note, .guidance-queue-item {
+ padding: 10px 13px;
+ border: 1px solid #bfdbfe;
+ border-radius: 10px;
+ background: #eff6ff;
+ color: #1e3a8a;
+ line-height: 1.6;
+ overflow-wrap: anywhere;
+ .guidance-step-title { display: flex; align-items: center; gap: 6px; font-weight: 650; }
+ .material-symbols-outlined { font-size: 17px; }
+ p { margin: 5px 0; white-space: pre-wrap; color: #1e293b; }
+ small { color: #475569; }
+}
+.guidance-queue { display: grid; gap: 8px; padding: 12px 16px; }
+.guidance-queue-item strong { font-size: 12px; }
+.session-failure-summary {
+ margin: 12px 16px 28px;
+ padding: 12px 14px;
+ border: 1px solid #fecdd3;
+ border-radius: 10px;
+ background: #fff7f8;
+ color: #881337;
+ strong { font-size: 13px; }
+ p { margin: 6px 0 10px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
+ button { border: 0; background: transparent; padding: 0; color: #be123c; font: inherit; font-weight: 600; cursor: pointer; }
+}
+:host ::ng-deep .report-markdown-body {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ font-size: 13px;
+ font-weight: 400;
+ line-height: 1.7;
+ overflow-wrap: anywhere;
+ word-break: normal;
+ > div { margin: 3px 0; }
+ h1, h2, h3 { margin: 12px 0 6px; font-size: 14px; color: #0f172a; }
+ ul, ol { padding-left: 22px; margin: 8px 0; }
+ li { margin: 4px 0; }
+ pre { overflow: auto; white-space: pre-wrap; padding: 10px; border-radius: 7px; background: #e2e8f0; }
+ table { width: 100%; border-collapse: collapse; }
+ th, td { padding: 7px 9px; border: 1px solid #cbd5e1; text-align: left; vertical-align: top; }
+ th { background: #dbeafe; }
+ .md-para-gap { height: 8px; }
+}
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
index 7e8813b..79da82b 100644
--- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
+++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
@@ -23,7 +23,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
-import { AgentService, StartupProgressEvent } from '../../services/agent.service';
+import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
@@ -247,6 +247,11 @@ export class AgentStreamComponent implements AfterViewInit {
private http = inject(HttpClient);
private destroyRef = inject(DestroyRef);
private zone = inject(NgZone);
+ public terminalFailureReason = signal('');
+ private selectedTerminalStatus = computed(() => {
+ const id = this.agentService.currentSessionId();
+ return this.agentService.sessions().find(session => session.session_id === id)?.status || '';
+ });
// Auto-scroll state tracking
public isUserAtBottom = true;
@@ -474,6 +479,23 @@ export class AgentStreamComponent implements AfterViewInit {
return consolidateLogsToBlocks(this.filteredLogs());
});
+ public awaitingGuidance = computed
(() => {
+ const steps = this.consolidatedBlocks().filter(block => block.type === 'step');
+ return (this.agentService.guidanceEvents?.() || []).filter(event => !steps.some(block =>
+ new Date(block.timestamp).getTime() >= event.timestamp * 1000
+ && String(block.data?.extra_metadata?.injected_instruction || '').includes(event.instruction)
+ ));
+ });
+
+ public guidanceStatusLabel(event: GuidanceEvent): string {
+ const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
+ .includes(this.agentService.currentSession()?.status || '');
+ if (event.status === 'pending') {
+ return terminal ? '任务结束前未进入模型' : '已接收,等待下一步';
+ }
+ return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
+ }
+
public phases = computed(() => {
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
@@ -541,6 +563,17 @@ export class AgentStreamComponent implements AfterViewInit {
}
constructor() {
+ effect((onCleanup) => {
+ const id = this.agentService.currentSessionId();
+ const status = this.selectedTerminalStatus();
+ this.terminalFailureReason.set('');
+ if (!id || !['failed', 'interrupted'].includes(status || '')) return;
+ const request = this.http.get<{error?: string}>(`/api/automation/reports/${encodeURIComponent(id)}`).subscribe({
+ next: report => this.terminalFailureReason.set(report.error || '任务未完成;请查看已记录的执行步骤。'),
+ error: () => this.terminalFailureReason.set('任务未完成,暂时无法读取异常详情。')
+ });
+ onCleanup(() => request.unsubscribe());
+ });
// Run info popover: fetch usage when opened or when the viewed session
// changes, and keep refreshing while that session is still running.
effect((onCleanup) => {
@@ -1327,6 +1360,11 @@ export class AgentStreamComponent implements AfterViewInit {
return isAndroidAction(action);
}
+ public hasRecordedDeviceAction(action: any): boolean {
+ const actions = Array.isArray(action) ? action : action ? [action] : [];
+ return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
+ }
+
public getActionIcon(action: any): string {
return getActionIcon(action);
}
@@ -1813,6 +1851,7 @@ export class AgentStreamComponent implements AfterViewInit {
public hasVisibleContent(block: any): boolean {
if (!block) return false;
if (block.type === 'checker') return true;
+ if (block.data?.extra_metadata?.injected_instruction) return true;
const hasNative = Boolean(this.getNativeThinking(block));
const hasRaw = Boolean(this.getRawThinking(block));
const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true);
diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html
index 8124d82..7028cec 100644
--- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html
+++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html
@@ -12,27 +12,32 @@
当前任务报告
@if (report(); as r) {
- {{r.name}}{{label(r.status)}} · {{r.verdict}}
+
+
{{r.name}}
+
{{label(r.status)}}{{r.verdict}}
+ @if (r.error) {
}
+
+
- @if (liveTask()) {
-
-
任务指导 · {{label(taskStatus())}}
-
运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。
-
-
-
- @if(taskStatus() === 'running'){}
-
- @if(taskStatus() === 'task_paused'){}
-
-
- }
报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。
- {{r.content}}
+
} @else { 从任务队列或历史记录中选择任务,即可下载报告。
}
+ @if (liveTask() && agent.currentSessionId()) {
+
+
任务指导 · {{label(taskStatus())}}
+
{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。' }}
+
+
+
+ @if(taskStatus() === 'running'){}
+
+ @if(taskStatus() === 'task_paused'){}
+
+
+ }
}
@if (tab() !== 'reports') {
@@ -53,7 +58,12 @@
}
批次记录
- @for(b of batches(); track b.id){}
+ @for(b of batches(); track b.id){
+
+
+
+
+ }
@if(!batches().length){暂无批次。
}
@if(batch(); as b){{{b.name}}
{{label(b.status)}} · {{b.reason}}
diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss
index 7670be5..a92ca3d 100644
--- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss
+++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss
@@ -17,8 +17,26 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
+.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
.task-guidance label{margin:8px 0 4px;}
+.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
+.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
+.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}}
+.report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}}
+.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}strong{font-size:12px;color:#475569;}p{margin:5px 0 0;white-space:pre-wrap;color:#1e293b;}}
+.report-content{margin-top:14px;padding:14px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;h3{margin-bottom:12px;}}
+.report-body{overflow-x:auto;overflow-wrap:anywhere;color:#1e293b;line-height:1.75;}
+:host ::ng-deep .report-body, :host ::ng-deep .report-markdown-body {
+ h1,h2,h3,h4{margin:18px 0 8px;color:#0f172a;line-height:1.4;}h1{font-size:18px;}h2{font-size:16px;}h3,h4{font-size:14px;}
+ > div{margin:3px 0;}
+ ul,ol{margin:8px 0;padding-left:24px;}li{margin:4px 0;}
+ blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid #93c5fd;background:#f8fafc;}
+ pre{max-width:100%;overflow:auto;padding:10px 12px;background:#f1f5f9;border-radius:6px;white-space:pre-wrap;}
+ code{font-family:Consolas,monospace;background:#f1f5f9;padding:1px 3px;border-radius:3px;}
+ table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid #e2e8f0;vertical-align:top;}th{background:#eff6ff;color:#1e3a8a;}
+ .md-para-gap{height:8px;}
+}
diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts
new file mode 100644
index 0000000..61ff9bd
--- /dev/null
+++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts
@@ -0,0 +1,80 @@
+import { signal } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+
+import { AgentService } from '../../services/agent.service';
+import { SystemService } from '../../services/system.service';
+import { AutomationPanelComponent } from './automation-panel.component';
+
+describe('AutomationPanelComponent batch deletion', () => {
+ const agent = {
+ currentSessionId: signal(null),
+ runningSessionId: signal(null),
+ agentStatus: signal('idle'),
+ sessions: signal([])
+ };
+ const http = jasmine.createSpyObj('HttpClient', ['get', 'delete']);
+
+ beforeEach(async () => {
+ (http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
+ ? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
+ http.delete.and.returnValue(of({ status: 'deleted' }));
+ http.delete.calls.reset();
+ await TestBed.configureTestingModule({
+ imports: [AutomationPanelComponent],
+ providers: [
+ { provide: AgentService, useValue: agent },
+ { provide: SystemService, useValue: { connectedDevices: signal([]) } },
+ { provide: HttpClient, useValue: http }
+ ]
+ }).compileComponents();
+ });
+
+ it('confirms deletion and removes the batch from the visible list', async () => {
+ const fixture = TestBed.createComponent(AutomationPanelComponent);
+ fixture.componentInstance.tab.set('batches');
+ await new Promise(resolve => setTimeout(resolve, 0));
+ fixture.componentInstance.batches.set([
+ { id: 'batch-1', name: '批量冒烟', status: 'completed', created_at: 100 }
+ ]);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('[aria-label="删除批次:批量冒烟"]')).not.toBeNull();
+
+ spyOn(window, 'confirm').and.returnValue(true);
+ const button = fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement;
+ button.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ fixture.detectChanges();
+ expect(http.delete).toHaveBeenCalledWith('/api/automation/batches/batch-1');
+ expect(fixture.componentInstance.batches()).toEqual([]);
+ });
+
+ it('keeps a running batch protected from the delete control', async () => {
+ const fixture = TestBed.createComponent(AutomationPanelComponent);
+ fixture.componentInstance.tab.set('batches');
+ await new Promise(resolve => setTimeout(resolve, 0));
+ fixture.componentInstance.batches.set([
+ { id: 'batch-2', name: '进行中', status: 'running', created_at: 100 }
+ ]);
+ fixture.detectChanges();
+ expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
+ });
+
+ it('shows the failure reason and renders Chinese report structure with line breaks', () => {
+ const fixture = TestBed.createComponent(AutomationPanelComponent);
+ fixture.detectChanges();
+ fixture.componentInstance.report.set({
+ id: 'task-1', name: '系统设置检查', status: 'failed', verdict: '未验证',
+ error: '模型连续 3 次未提供可执行工具调用', goal: '查看设置', expected: '',
+ content: '## 结果\n第一行\n第二行\n\n| 项目 | 状态 |\n| --- | --- |\n| 设置 | 未验证 |'
+ });
+ fixture.detectChanges();
+ const root = fixture.nativeElement as HTMLElement;
+ expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次');
+ expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
+ expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
+ expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
+ expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
+ });
+});
diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts
index 0ae3143..a4f852f 100644
--- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts
+++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts
@@ -1,10 +1,11 @@
-import { Component, DestroyRef, effect, inject, signal } from '@angular/core';
+import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
+import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
@Component({
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
@@ -16,8 +17,13 @@ export class AutomationPanelComponent {
batches = signal([]); preview = signal(null);
batch = signal(null); report = signal(null);
guidance = '';
- taskStatus = signal('');
- liveTask = signal(false);
+ taskStatus = computed(() => {
+ const sid = this.agent.currentSessionId();
+ if (!sid) return '';
+ return this.agent.sessions().find(s => s.session_id === sid)?.status
+ || (this.agent.runningSessionId() === sid ? this.agent.agentStatus() : '') || '';
+ });
+ liveTask = computed(() => ['running', 'paused', 'task_paused', 'resuming'].includes(this.taskStatus()));
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
private destroyed = false;
constructor() {
@@ -33,6 +39,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)[s] || s; }
+ renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
async action(fn: () => Promise) {
if (this.busy()) return;
this.busy.set(true); this.error.set(''); this.notice.set('');
@@ -46,11 +53,6 @@ export class AutomationPanelComponent {
this.batches.set(b);
const sid = this.agent.currentSessionId();
if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); }
- if (sid) {
- const current = this.agent.sessions().find(s => s.session_id === sid);
- this.taskStatus.set(current?.status || this.report()?.status || '');
- this.liveTask.set(['running','task_paused','resuming'].includes(current?.status || ''));
- } else { this.taskStatus.set(''); this.liveTask.set(false); }
const id = this.batch()?.id;
if (id) { const item = await firstValueFrom(this.http.get(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); }
} catch { /* User initiated actions expose errors; polling retries on the next interval. */ }
@@ -70,12 +72,25 @@ export class AutomationPanelComponent {
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
}); }
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;
+ 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('批次记录已删除;已执行任务与报告仍可在历史中查看');
+ });
+ }
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(); }); }
pauseTask(id: string) { void this.action(async () => { await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`,{})); this.notice.set('已请求暂停:当前步骤结束后释放设备'); await this.refresh(); }); }
guideTask(id: string) { void this.action(async () => {
if (!this.guidance.trim()) throw Error('请先填写指导内容');
+ const status = this.taskStatus();
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`,{instruction:this.guidance.trim()}));
- this.guidance = ''; this.notice.set('指导已提交;运行中将在下一步生效,已暂停的任务会重新排队恢复'); await this.refresh();
+ this.guidance = '';
+ this.agent.fetchGuidance(id);
+ if (status === 'paused') this.notice.set('模型请求暂停,请在执行日志中恢复模型调用');
+ await this.refresh();
}); }
continueTask(id: string) { void this.action(async () => {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`,{}));
diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html
index fedc937..37ed9c4 100644
--- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html
+++ b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html
@@ -17,7 +17,43 @@ limitations under the License.
-
+
+ @if (guidanceStatus(); as status) {
+
+
+ @if (guidanceOpenId() === agentService.currentSessionId()) {
+
+
+ 指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }}
+ {{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }}
+
+
+
+
+ {{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}
+ @if (status === 'running') {
+
+ }
+ @if (status === 'task_paused') {
+
+ }
+
+
+
+ }
+
+ }
+
' })
+class StreamStub {}
+@Component({ selector: 'app-chat-interface', standalone: true, template: '' })
+class ChatStub {}
+@Component({ selector: 'app-floating-video-player', standalone: true, template: '' })
+class VideoStub {}
+
+describe('Workspace task guidance', () => {
+ const currentId = signal('running-task');
+ const session = signal({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
+ const agent = {
+ currentSessionId: currentId,
+ currentSession: computed(() => currentId() === session().session_id ? session() : null),
+ runningSessionId: signal('running-task'),
+ agentStatus: signal('running'),
+ isCurrentSessionRunning: computed(() => ['running', 'paused'].includes(session().status)),
+ fetchStatus: jasmine.createSpy('fetchStatus'),
+ fetchSessions: jasmine.createSpy('fetchSessions'),
+ fetchGuidance: jasmine.createSpy('fetchGuidance')
+ };
+ const http = jasmine.createSpyObj('HttpClient', ['post']);
+
+ beforeEach(async () => {
+ currentId.set('running-task');
+ session.set({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
+ agent.runningSessionId.set('running-task');
+ http.post.and.returnValue(of({}));
+ http.post.calls.reset();
+ await TestBed.configureTestingModule({
+ imports: [WorkspaceComponent],
+ providers: [
+ { provide: AgentService, useValue: agent },
+ { provide: HttpClient, useValue: http }
+ ]
+ }).overrideComponent(WorkspaceComponent, {
+ remove: { imports: [AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent] },
+ add: { imports: [StreamStub, ChatStub, VideoStub] }
+ }).compileComponents();
+ });
+
+ it('shows pause and guidance for the selected running task, then hides them on cancellation', async () => {
+ const fixture = TestBed.createComponent(WorkspaceComponent);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
+ expect(fixture.nativeElement.querySelector('.stream-floating-toolbar .guidance-trigger')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('.workspace-floating-bar-wrapper .guidance-trigger')).toBeNull();
+ (fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
+ fixture.detectChanges();
+ const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
+ expect(panel.textContent).toContain('指导当前任务');
+ expect(panel.textContent).toContain('下方输入框用于开始新任务');
+
+ const pause = Array.from(panel.querySelectorAll('button')).find(b => b.textContent?.includes('暂停当前任务'))!;
+ pause.click();
+ await fixture.whenStable();
+ expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/pause', {});
+
+ fixture.detectChanges();
+ (fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
+ fixture.detectChanges();
+ const reopened = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
+ const input = reopened.querySelector('textarea')!;
+ input.value = '先确认设置首页';
+ input.dispatchEvent(new Event('input'));
+ fixture.detectChanges();
+ const submit = Array.from(reopened.querySelectorAll('button')).find(b => b.textContent?.includes('提交指导'))!;
+ submit.click();
+ await fixture.whenStable();
+ expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/guide', { instruction: '先确认设置首页' });
+ expect(agent.fetchGuidance).toHaveBeenCalledWith('running-task');
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
+ expect(fixture.nativeElement.textContent).not.toContain('指导已提交');
+
+ session.set({ session_id: 'running-task', status: 'cancelled', initial_goal: '观察设置' });
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
+ expect(fixture.nativeElement.querySelector('.guidance-trigger')).toBeNull();
+ });
+
+ it('continues a paused task without creating a new task', async () => {
+ session.set({ session_id: 'running-task', status: 'task_paused', initial_goal: '观察设置' });
+ const fixture = TestBed.createComponent(WorkspaceComponent);
+ fixture.detectChanges();
+ (fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
+ fixture.detectChanges();
+ const button = Array.from(fixture.nativeElement.querySelectorAll('.task-guidance-panel button'))
+ .find(b => b.textContent?.includes('直接继续'))!;
+ button.click();
+ await fixture.whenStable();
+ expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/continue', {});
+ });
+
+ it('does not offer task resume for a paused model request', () => {
+ session.set({ session_id: 'running-task', status: 'paused', initial_goal: '观察设置' });
+ const fixture = TestBed.createComponent(WorkspaceComponent);
+ fixture.detectChanges();
+ (fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
+ fixture.detectChanges();
+ const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
+ expect(panel.textContent).toContain('模型调用暂停');
+ expect(panel.textContent).toContain('请在执行日志中恢复模型调用');
+ expect(panel.textContent).not.toContain('直接继续');
+ expect(panel.textContent).not.toContain('提交并恢复');
+ });
+});
diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts b/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts
index e893e55..1df0855 100644
--- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts
+++ b/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts
@@ -21,6 +21,8 @@ import { AgentStreamComponent } from '../../components/agent-stream/agent-stream
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
import { AgentService } from '../../services/agent.service';
+import { HttpClient } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-workspace',
@@ -37,6 +39,7 @@ import { AgentService } from '../../services/agent.service';
})
export class WorkspaceComponent implements OnInit {
public agentService = inject(AgentService);
+ private http = inject(HttpClient);
private zone = inject(NgZone);
private destroyRef = inject(DestroyRef);
@@ -56,6 +59,33 @@ export class WorkspaceComponent implements OnInit {
public isSubmitting = signal(false);
public errorMessage = signal(null);
public selectedProfile = signal<'flash' | 'pro'>('flash');
+ private guidanceDrafts = new Map();
+ public guidanceBusy = signal(false);
+ public guidanceOpenId = signal(null);
+ public pauseRequestId = signal(null);
+
+ public get taskGuidance(): string {
+ const id = this.agentService.currentSessionId();
+ return id ? this.guidanceDrafts.get(id) || '' : '';
+ }
+
+ public set taskGuidance(value: string) {
+ const id = this.agentService.currentSessionId();
+ if (id) this.guidanceDrafts.set(id, value);
+ }
+
+ public guidanceStatus = computed(() => {
+ const id = this.agentService.currentSessionId();
+ if (!id) return null;
+ const status = this.agentService.currentSession()?.status
+ || (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
+ return ['running', 'paused', 'task_paused', 'resuming'].includes(status || '') ? status : null;
+ });
+
+ public toggleGuidance(): void {
+ const id = this.agentService.currentSessionId();
+ this.guidanceOpenId.set(id && this.guidanceOpenId() !== id ? id : null);
+ }
// Expand States (Signals for 0-latency reactivity)
public isHoveringCard = signal(false);
@@ -103,6 +133,69 @@ export class WorkspaceComponent implements OnInit {
return this.agentService.isCurrentSessionRunning();
});
+ public async pauseCurrentTask(): Promise {
+ const id = this.agentService.currentSessionId();
+ if (!id || this.guidanceStatus() !== 'running' || this.guidanceBusy()) return;
+ this.guidanceBusy.set(true);
+ this.errorMessage.set(null);
+ try {
+ await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`, {}));
+ this.pauseRequestId.set(id);
+ this.guidanceOpenId.set(null);
+ this.agentService.fetchStatus();
+ this.agentService.fetchSessions();
+ } catch (error) {
+ this.showGuidanceError(error);
+ } finally {
+ this.guidanceBusy.set(false);
+ }
+ }
+
+ public async submitGuidance(): Promise {
+ const id = this.agentService.currentSessionId();
+ const instruction = this.taskGuidance.trim();
+ const status = this.guidanceStatus();
+ if (!id || !status || !instruction || this.guidanceBusy()) return;
+ this.guidanceBusy.set(true);
+ this.errorMessage.set(null);
+ try {
+ await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`, { instruction }));
+ this.guidanceDrafts.delete(id);
+ this.pauseRequestId.set(null);
+ this.guidanceOpenId.set(null);
+ this.agentService.fetchGuidance(id);
+ this.agentService.fetchStatus();
+ this.agentService.fetchSessions();
+ } catch (error) {
+ this.showGuidanceError(error);
+ } finally {
+ this.guidanceBusy.set(false);
+ }
+ }
+
+ public async continueCurrentTask(): Promise {
+ const id = this.agentService.currentSessionId();
+ if (!id || this.guidanceStatus() !== 'task_paused' || this.guidanceBusy()) return;
+ this.guidanceBusy.set(true);
+ this.errorMessage.set(null);
+ try {
+ await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`, {}));
+ this.pauseRequestId.set(null);
+ this.guidanceOpenId.set(null);
+ this.agentService.fetchStatus();
+ this.agentService.fetchSessions();
+ } catch (error) {
+ this.showGuidanceError(error);
+ } finally {
+ this.guidanceBusy.set(false);
+ }
+ }
+
+ private showGuidanceError(error: unknown): void {
+ const response = error as { error?: { detail?: string }; message?: string };
+ this.errorMessage.set(response.error?.detail || response.message || '操作失败,请刷新任务状态后重试');
+ }
+
/**
* Dynamic tooltip and label indicating which task will be stopped
*/
diff --git a/apps/showcase_ui/src/app/services/agent.service.ts b/apps/showcase_ui/src/app/services/agent.service.ts
index 05066cf..2f78f9c 100644
--- a/apps/showcase_ui/src/app/services/agent.service.ts
+++ b/apps/showcase_ui/src/app/services/agent.service.ts
@@ -54,6 +54,15 @@ export interface StartupProgressEvent {
timestamp: number;
}
+export interface GuidanceEvent {
+ id: string;
+ instruction: string;
+ source: string;
+ timestamp: number;
+ status: 'pending' | 'consumed';
+ consumed_at?: number;
+}
+
interface SessionVideoResponse {
session_id: string;
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
@@ -201,6 +210,8 @@ export class AgentService {
public currentSessionId = signal(null);
public sessionLogs = signal([]); // Dynamic array of all raw events received
+ public guidanceEvents = signal([]);
+ private guidanceSignature = '';
public isSessionContentLoading = signal(false);
public agentStatus = signal('idle'); // Status of the agent runner process
public runningSessionId = signal(null);
@@ -725,6 +736,8 @@ export class AgentService {
this.isSessionContentLoading.set(false);
this.currentSessionId.set(null);
this.sessionLogs.set([]);
+ this.guidanceEvents.set([]);
+ this.guidanceSignature = '';
this.currentNotes.set({});
return;
}
@@ -740,6 +753,9 @@ export class AgentService {
this.isSessionContentLoading.set(true);
this.currentSessionId.set(sessionId);
this.sessionLogs.set([]); // Reset logs for new session selection
+ this.guidanceEvents.set([]);
+ this.guidanceSignature = '';
+ this.fetchGuidance(sessionId);
this.activePauseCardKey = null;
this.isPaused.set(false);
this.pausedError.set(null);
@@ -1279,6 +1295,21 @@ export class AgentService {
});
}
+ public fetchGuidance(sessionId: string): void {
+ if (this.currentSessionId() !== sessionId) return;
+ this.http.get(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).subscribe({
+ next: (items) => {
+ if (this.currentSessionId() !== sessionId) return;
+ const signature = JSON.stringify(items);
+ if (signature !== this.guidanceSignature) {
+ this.guidanceSignature = signature;
+ this.guidanceEvents.set(items);
+ }
+ },
+ error: () => { /* Older runners have no guidance history; steps remain authoritative. */ }
+ });
+ }
+
private restoreSessionsCache(): void {
try {
const cached = localStorage.getItem(SESSION_CACHE_KEY);
@@ -1488,6 +1519,8 @@ export class AgentService {
// 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);
});
diff --git a/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts b/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts
index 0fd04de..cad3e3a 100644
--- a/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts
+++ b/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts
@@ -67,4 +67,12 @@ describe('markdown-parser.util verification & check lines', () => {
expect(html).toContain('verify');
expect(html).toContain('status is ok');
});
+
+ it('renders Chinese report tables and preserves separate lines safely', () => {
+ const html = renderMarkdownToHtml('## 结论\n第一行\n第二行\n\n| 分类 | 结果 |\n| --- | --- |\n| 网络 | **通过** |');
+ expect(html).toContain('结论
');
+ expect(html).toContain('第一行
\n第二行
');
+ expect(html).toContain('分类 | ');
+ expect(html).toContain('通过 | ');
+ });
});
diff --git a/apps/showcase_ui/src/app/utils/markdown-parser.util.ts b/apps/showcase_ui/src/app/utils/markdown-parser.util.ts
index b7c4f25..dfc51db 100644
--- a/apps/showcase_ui/src/app/utils/markdown-parser.util.ts
+++ b/apps/showcase_ui/src/app/utils/markdown-parser.util.ts
@@ -272,7 +272,7 @@ export function renderMarkdownToHtml(text: string): string {
if (inCodeBlock) {
// End code block
const codeContent = codeBlockLines.join('\n');
- result.push(`${codeContent}
`);
+ result.push(`${codeContent}
`);
inCodeBlock = false;
codeBlockLines = [];
codeBlockLang = '';
@@ -290,6 +290,26 @@ export function renderMarkdownToHtml(text: string): string {
continue;
}
+ // Markdown result tables need real cells to remain readable in reports.
+ if (trimmed.includes('|') && i + 1 < rawLines.length
+ && /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)*\s*\|?\s*$/.test(rawLines[i + 1])) {
+ closeList();
+ const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
+ const headings = cells(rawLine);
+ result.push('');
+ i--;
+ continue;
+ }
+
// 2. Headings: # to ######
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
@@ -375,7 +395,7 @@ export function renderMarkdownToHtml(text: string): string {
if (inCodeBlock) {
const codeContent = codeBlockLines.join('\n');
- result.push(`${codeContent}
`);
+ result.push(`${codeContent}
`);
}
closeList();
diff --git a/artemis/agents/log_analyzer/log_analyzer.py b/artemis/agents/log_analyzer/log_analyzer.py
index 6f8f7cc..08a58ea 100644
--- a/artemis/agents/log_analyzer/log_analyzer.py
+++ b/artemis/agents/log_analyzer/log_analyzer.py
@@ -18,7 +18,7 @@ from pathlib import Path
from typing import Annotated, Any
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import BaseTool, StructuredTool
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, ValidationError
class _CyFunctionDetectorMeta(type):
@@ -89,6 +89,20 @@ class LogAnalyzerNode:
original_coroutine = tool.coroutine
async def cached_coroutine(**kwargs):
+ # The injection runner calls the coroutine directly, bypassing
+ # StructuredTool's normal Pydantic argument coercion. Normalize
+ # model-supplied numeric strings before caching or searching logs.
+ if tool.name in ("read_logs", "search_logs") and tool.args_schema:
+ try:
+ validated = tool.args_schema.model_validate(kwargs)
+ except ValidationError as exc:
+ return ToolFailure(f"Invalid {tool.name} arguments: {exc}")
+ kwargs.update(validated.model_dump(exclude_unset=True))
+ if kwargs.get("lines") is None:
+ kwargs["lines"] = 200 if tool.name == "read_logs" else 10000
+ if tool.name == "search_logs" and kwargs.get("context_lines") is None:
+ kwargs["context_lines"] = 0
+
if tool.name == "read_logs":
# Intercept read_logs to cache raw logs
lines = kwargs.get("lines", 200)
diff --git a/artemis/agents/operator/operator.py b/artemis/agents/operator/operator.py
index 0bc09f8..b947a61 100644
--- a/artemis/agents/operator/operator.py
+++ b/artemis/agents/operator/operator.py
@@ -13,6 +13,7 @@
# limitations under the License.
import json
+from collections.abc import Awaitable
from typing import Any
from uuid import uuid4
@@ -32,6 +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.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
from artemis.tools.command_tool import (
analyze_task_output_wrapper,
@@ -78,6 +80,8 @@ DEFERRING_TOOLS = {
"ask_explorer",
}
+INTERRUPTIBLE_HELPERS = {"ask_diagnoser", "ask_explorer", "video_analyzer", "analyze_task_output"}
+
# Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix).
SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH")
@@ -478,6 +482,16 @@ class OperatorNode:
except Exception as e:
logger.error(f"Failed to stage transcript turn: {e}")
+ async def _await_or_pause[T](self, operation: Awaitable[T]) -> tuple[bool, T | None]:
+ control = getattr(self.ctx, "task_control", None)
+ if isinstance(control, TaskControl):
+ return await control.run_interruptibly(
+ operation,
+ on_pause=getattr(self.ctx, "on_task_pause", None),
+ on_resume=getattr(self.ctx, "on_task_resume", None),
+ )
+ return False, await operation
+
async def _invoke_llm_loop(
self,
base_llm,
@@ -496,6 +510,13 @@ class OperatorNode:
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
for iteration in range(max_iterations):
+ control = getattr(self.ctx, "task_control", None)
+ if isinstance(control, TaskControl) and await control.checkpoint(
+ on_pause=getattr(self.ctx, "on_task_pause", None),
+ on_resume=getattr(self.ctx, "on_task_resume", None),
+ ):
+ # The device may have changed while another task held its lease.
+ return None, None, None, False
if iteration == max_iterations - 1:
logger.warning(
"Operator has reached the maximum number of tool calls. Adding reminder."
@@ -512,7 +533,12 @@ class OperatorNode:
)
bound_llm = base_llm.bind_tools(tools=traced_tools)
- response = await invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
+ interrupted, response = await self._await_or_pause(
+ invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
+ )
+ if interrupted:
+ return None, None, None, False
+ assert response is not None
usage = usage_from_message(response)
# The ledger's compaction thresholds read the operator's own
@@ -655,12 +681,42 @@ class OperatorNode:
"\n".join(native_thoughts) if native_thoughts else None
)
- result_obj = await invoke_tool_with_injection(
+ tool_call = invoke_tool_with_injection(
tool=tool_to_run,
args=args,
tool_call_id=tc["id"],
state=state,
)
+ if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS:
+ interrupted, result_obj = await self._await_or_pause(tool_call)
+ if interrupted:
+ span.result = "任务暂停;诊断已中断,恢复后重新观察屏幕"
+ results.append((span.result, "error"))
+ for remaining in other_calls[len(results) :]:
+ results.append(("任务暂停,未执行该工具", "error"))
+ for completed, (content, status) in zip(
+ other_calls, results
+ ):
+ current_messages.extend(
+ tool_result_messages(
+ completed["id"],
+ content,
+ name=normalize_name(completed["name"]),
+ status=status,
+ llm=base_llm,
+ )
+ )
+ for action in action_calls:
+ current_messages.append(
+ ToolMessage(
+ tool_call_id=action["id"],
+ content="任务暂停,未执行屏幕操作",
+ status="error",
+ )
+ )
+ return None, None, None, False
+ else:
+ result_obj = await tool_call
content = get_tool_result_content(result_obj)
# Failure is structural (a ToolMessage status or a
diff --git a/artemis/runtime/task_control.py b/artemis/runtime/task_control.py
index 53275b2..f7a3ee9 100644
--- a/artemis/runtime/task_control.py
+++ b/artemis/runtime/task_control.py
@@ -1,6 +1,7 @@
"""Cooperative, session-scoped task guidance and device lease parking."""
import asyncio
+from collections.abc import Awaitable
import json
import logging
import os
@@ -48,22 +49,39 @@ def request_resume(session_id: str) -> None:
def add_instruction(
session_id: str, instruction: str, *, release_loop: bool = False, source: str = "user"
-) -> None:
+) -> dict:
if not instruction.strip() or len(instruction) > 10000:
raise ValueError("指导内容必须在 1–10000 字之间")
directory = control_dir(session_id) / "instructions"
directory.mkdir(parents=True, exist_ok=True)
# Unique names make simultaneous web and MCP submissions additive, never last-write-wins.
path = directory / f"{time.time_ns():020d}-{uuid.uuid4().hex}.json"
- _write_atomic(
- path,
- {
- "instruction": instruction.strip(),
- "release_loop": bool(release_loop),
- "source": source,
- "timestamp": time.time(),
- },
- )
+ record = {
+ "id": path.stem,
+ "instruction": instruction.strip(),
+ "release_loop": bool(release_loop),
+ "source": source,
+ "timestamp": time.time(),
+ }
+ _write_atomic(path, record)
+ return record
+
+
+def list_instructions(session_id: str) -> list[dict]:
+ """Show submissions separately from guidance the runner actually consumed."""
+ directory = control_dir(session_id) / "instructions"
+ records = []
+ for folder, status in ((directory, "pending"), (directory / "consumed", "consumed")):
+ for path in folder.glob("*.json") if folder.exists() else []:
+ try:
+ record = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(record, dict) and record.get("instruction"):
+ records.append(
+ {**record, "id": record.get("id") or path.stem, "status": status}
+ )
+ except (OSError, ValueError):
+ continue
+ return sorted(records, key=lambda record: (record.get("timestamp", 0), record["id"]))
def consume_instructions(base_dir: str | Path) -> dict | None:
@@ -73,8 +91,11 @@ def consume_instructions(base_dir: str | Path) -> dict | None:
try:
claimed = path.with_suffix(".reading")
path.rename(claimed)
- found.append(json.loads(claimed.read_text(encoding="utf-8")))
+ record = json.loads(claimed.read_text(encoding="utf-8"))
+ record["consumed_at"] = time.time()
+ _write_atomic(directory / "consumed" / path.name, record)
claimed.unlink()
+ found.append(record)
except (OSError, ValueError):
continue
legacy = Path(base_dir) / "injected_instruction.json"
@@ -151,6 +172,31 @@ class TaskControl:
self._state("running")
return True
+ 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.
+
+ Only use for operations whose cancellation cannot leave a device action
+ half executed. The caller must return to fresh perception after resume.
+ """
+ running = asyncio.ensure_future(operation)
+ pause = self.directory / "pause.request"
+ try:
+ while True:
+ if pause.exists():
+ running.cancel()
+ await asyncio.gather(running, return_exceptions=True)
+ await self.checkpoint(on_pause=on_pause, on_resume=on_resume)
+ return True, None
+ if running.done():
+ return False, running.result()
+ await asyncio.wait({running}, timeout=0.2)
+ finally:
+ if not running.done():
+ running.cancel()
+ await asyncio.gather(running, return_exceptions=True)
+
def release(self) -> None:
self.device_lock.release()
(self.directory / "task_control_state.json").unlink(missing_ok=True)
diff --git a/docs/ai-automation.md b/docs/ai-automation.md
index 4351a64..6336022 100644
--- a/docs/ai-automation.md
+++ b/docs/ai-automation.md
@@ -22,10 +22,10 @@
| `mobile_workspace` | 设备列表与统一队列状态 |
| `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 |
| `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 |
-| `mobile_manage_task` | 单任务状态、安全暂停、排队恢复、下一步指导和停止 |
+| `mobile_manage_task` | 单任务状态、安全暂停、排队恢复、指导提交/领取记录和停止 |
| `mobile_get_device_state` | 设备截图、界面层级 |
| `mobile_task_file` | XLSX 模板与导入校验 |
-| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项 |
+| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项、删除非活跃批次 |
| `mobile_search_history` | 全库搜索、日期/状态筛选和分页 |
| `mobile_inspect_trace` | 只读步骤、截图和执行证据 |
| `mobile_export_report` | Word、Excel、步骤截图、录屏片段、单任务资料及批次 ZIP,返回本地绝对路径 |
@@ -42,6 +42,6 @@ MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块
报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
-模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停任务释放设备,AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复,或用 `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` 单独恢复。
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
diff --git a/docs/releases/1.1.0.md b/docs/releases/1.1.0.md
new file mode 100644
index 0000000..fa0065c
--- /dev/null
+++ b/docs/releases/1.1.0.md
@@ -0,0 +1,24 @@
+# ARTEMIS-Eapil 1.1.0
+
+## 新增与改进
+
+- 批量任务按新版 Excel 模板读取 `Artemis提示词` 和逐项 `任务配置(JSON)`;`前置约束提取` 用于记录元数据,不拼接到提示词。可在网页查看、暂停、恢复或删除非活跃批次。
+- 运行中的任务可提交指导;指导入口移至工作区顶部,默认收起。任务流程区区分“已提交、待领取”和“执行端已领取”,避免误把提交当作操作完成。
+- 报告页面改进失败原因与 Markdown 排版;Word/Excel 使用更适合中文阅读的样式。可分别下载有序截图、录屏片段或整份任务资料。
+- MCP 同步支持 `pausing` 状态、指导领取记录和批次删除;随源码分发的 Skill 说明如何核对实际暂停、指导效果及设备排队恢复。
+
+## 修复
+
+- Pro 的模型等待与只读诊断现在能响应暂停:取消当前等待,释放设备;恢复后重新观察页面,不执行暂停前规划的点击。已开始的设备动作仍须先安全结束。
+- 日志分析工具校验并归一化模型给出的数字字符串,避免 `read_logs`、`search_logs` 因参数类型错误反复失败。
+
+## 验证
+
+- 后端默认测试 2162 项通过、6 项跳过、8 项未纳入默认范围;前端 148 项通过,生产构建成功。
+- Ruff 格式及静态检查、质量基线、核心类型检查、Skill 校验通过。暂停/恢复与 MCP 状态进行了隔离回归;没有重新启动已取消的真实设备任务。
+
+## 安装与升级
+
+Windows 用户运行 `start.bat`,国内网络使用 `start-cn.bat`。Git 克隆安装在无本地冲突时按启动脚本快进更新;ZIP 安装继续使用解压目录。升级后刷新网页并重新加载 AI 客户端的 MCP 连接,新工具签名及 Skill 文件才会生效。已启动的任务不会热更新。
+
+源码附件不含 `.env`、虚拟环境、依赖目录、编译产物或任务数据,使用 `SHA256SUMS.txt` 校验。执行完成仍不等于测试验证通过,检查实际截图、步骤和验证摘要。
diff --git a/mcp_server/tools/automation.py b/mcp_server/tools/automation.py
index 8a610ef..5e5fdcb 100644
--- a/mcp_server/tools/automation.py
+++ b/mcp_server/tools/automation.py
@@ -176,7 +176,9 @@ async def mobile_task_file(
@mcp.tool()
async def mobile_batch(
- action: Literal["create", "list", "get", "lookup", "start", "pause", "resume", "retry_failed"],
+ action: Literal[
+ "create", "list", "get", "lookup", "start", "pause", "resume", "retry_failed", "delete"
+ ],
batch_id: str | None = None,
request_id: str | None = None,
batch: dict[str, Any] | None = None,
@@ -196,6 +198,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.
Restarted batches remain paused. No automatic failed-case retries or action replay.
"""
if action == "create":
@@ -210,6 +214,8 @@ async def mobile_batch(
route = f"/api/automation/batches/{_id(batch_id)}"
if action == "get":
return await _request("GET", route)
+ if action == "delete":
+ return await _request("DELETE", route)
if action in {"start", "resume"}:
current = await _request("GET", route)
if current["status"] == "running":
diff --git a/mcp_server/tools/task_manager.py b/mcp_server/tools/task_manager.py
index 899e58e..1c8284c 100644
--- a/mcp_server/tools/task_manager.py
+++ b/mcp_server/tools/task_manager.py
@@ -37,6 +37,7 @@ from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.task_control import (
add_instruction,
control_dir,
+ list_instructions,
read_state,
request_pause,
request_resume,
@@ -265,7 +266,7 @@ def mobile_manage_task(
can also steer a subagent that is stuck or off-track, or abort a task.
### Actions
- - **'status'**: Returns `trace_id`, `status` ('running'/'task_paused'/
+ - **'status'**: Returns `trace_id`, `status` ('running'/'pausing'/'task_paused'/
'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device
setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when
the run declared verification check items: machine-readable
@@ -274,7 +275,10 @@ def mobile_manage_task(
current turn, latest thought/action — intervene if turns climb without
progress or the thought indicates it is stuck; Pro: the active task plan
— check it still aligns with your goal).
- - **'pause'**: Requests a pause after the current action, releasing the device.
+ - **'pause'**: Requests a pause, returning `pausing` until the runner releases
+ the device. Pro can interrupt a pending model response or read-only diagnostic;
+ a device action already in progress must finish safely first. Check status
+ for `task_paused` before assuming the device is free.
- **'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
@@ -285,6 +289,8 @@ def mobile_manage_task(
`release_loop=True` — this is the only signal that unlocks the
milestone's completion; natural-language "please stop" phrasing in
`instruction` is NOT interpreted as a stop signal.
+ - **'guidance_history'**: Lists submitted instructions with `pending` or
+ `consumed` status; a submitted instruction is not proof it was applied.
- **'stop'**: Forcefully terminates the subagent, immediately halting
device interactions and releasing the device. Use when the task is done,
irreparably broken, or running out of control. Prefer
@@ -292,7 +298,8 @@ def mobile_manage_task(
should wind down cleanly instead of being killed.
Args:
- action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`, or `"stop"`.
+ action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`,
+ `"guidance_history"`, or `"stop"`.
trace_id: The task's session identifier from `mobile_run_task` or the web UI.
instruction: Guidance string; required for `inject_instruction`
(optional when `release_loop=True`), omit otherwise.
@@ -312,6 +319,12 @@ def mobile_manage_task(
control_status = read_state(trace_id).get("status") if is_alive else None
if control_status in ("task_paused", "resuming") and current_status in ("running", "pending"):
current_status = control_status
+ elif (
+ is_alive
+ and current_status in ("running", "pending")
+ and (control_dir(trace_id) / "pause.request").exists()
+ ):
+ current_status = "pausing"
trace_dir = trace_store.get_trace_dir(trace_id)
@@ -524,7 +537,16 @@ def mobile_manage_task(
return response
+ elif action == "guidance_history":
+ return {"trace_id": trace_id, "instructions": list_instructions(trace_id)}
+
elif action == "pause":
+ if current_status == "pausing":
+ return {
+ "trace_id": trace_id,
+ "status": "pausing",
+ "message": "Pause is still pending; check status for task_paused.",
+ }
if current_status not in ("running", "pending", "resuming") or not is_alive:
return {
"trace_id": trace_id,
@@ -535,7 +557,7 @@ def mobile_manage_task(
return {
"trace_id": trace_id,
"status": "pausing",
- "message": "Pauses after the current action and releases the device.",
+ "message": "Pause requested; wait for task_paused and device release.",
}
elif action == "resume":
@@ -555,7 +577,7 @@ def mobile_manage_task(
}
elif action == "stop":
- if current_status not in ("running", "pending", "task_paused", "resuming"):
+ if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"):
return {
"trace_id": trace_id,
"status": current_status,
@@ -650,7 +672,7 @@ def mobile_manage_task(
"message": "Missing required argument 'instruction' for action 'inject_instruction'.",
}
- if current_status not in ("running", "pending", "task_paused", "resuming"):
+ if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"):
return {
"trace_id": trace_id,
"status": current_status,
diff --git a/pyproject.toml b/pyproject.toml
index ee84350..0938eec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,7 +14,7 @@
[project]
name = "artemis"
-version = "1.0.1"
+version = "1.1.0"
description = "Autonomous AI Assistant for Mobile Automation"
authors = [
diff --git a/skills/artemis-automation/SKILL.md b/skills/artemis-automation/SKILL.md
index 6c04bbc..2e698cf 100644
--- a/skills/artemis-automation/SKILL.md
+++ b/skills/artemis-automation/SKILL.md
@@ -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` 在当前步骤后释放设备;`inject_instruction` 可运行中指导下一步,暂停中提交则恢复。无新指导用 `resume`。恢复进入设备 FIFO 队尾,并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
+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 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
## 结果、证据与交付
@@ -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_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,已执行的任务历史和报告会保留。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
diff --git a/skills/artemis-automation/references/batches.md b/skills/artemis-automation/references/batches.md
index d7edd79..52973f4 100644
--- a/skills/artemis-automation/references/batches.md
+++ b/skills/artemis-automation/references/batches.md
@@ -53,9 +53,10 @@
## 状态与异常
- 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。
-- 单项 `pending/queued/running/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。
-- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 在步骤后释放设备,指导后排队恢复,不阻塞后续项。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
+- 单项 `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`;正在执行、排队或等待指导的批次不能删除,已经运行过的任务历史及报告不会随批次记录一起消失。
## 报告
diff --git a/tests/unit/admin_console/test_automation.py b/tests/unit/admin_console/test_automation.py
index 280d860..a45a752 100644
--- a/tests/unit/admin_console/test_automation.py
+++ b/tests/unit/admin_console/test_automation.py
@@ -6,6 +6,7 @@ import time
from unittest.mock import AsyncMock
import zipfile
+from docx import Document
from fastapi import FastAPI
from fastapi.testclient import TestClient
from openpyxl import load_workbook
@@ -124,6 +125,39 @@ 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):
+ 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"})
+
+ 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(second["session_id"]) is None
+ assert reports.session_report(first["session_id"])["name"] == "用例甲"
+ assert (
+ client.post(
+ "/api/automation/batches", json=payload(request_id="delete-example")
+ ).status_code
+ == 409
+ )
+
+
+@pytest.mark.parametrize("status", ["queued", "running", "task_paused", "resuming", "finalizing"])
+def test_batch_delete_rejects_active_jobs(client, isolated, status):
+ batch = service.create_batch(payload())
+ first = isolated.jobs(batch["id"])[0]
+ isolated.save_job({**first, "status": status})
+
+ response = client.delete(f"/api/automation/batches/{batch['id']}")
+ assert response.status_code == 409
+ assert isolated.get("batch", batch["id"]) is not None
+ assert len(isolated.jobs(batch["id"])) == 2
+
+
def test_restart_never_repeats_started_work(isolated):
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
@@ -243,6 +277,50 @@ def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
assert "未验证".encode() in doc.read("word/document.xml")
+def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolated):
+ job = isolated.jobs(service.create_batch(payload())["id"])[0]
+ isolated.save_job({**job, "status": "failed"})
+ folder = reports.TRACES_PATH / job["session_id"]
+ (folder / "notes").mkdir(parents=True)
+ (folder / "stdout.log").write_text(
+ "❌ [run] Error running automation: 模型连续 3 次未提供可执行的工具调用\n",
+ encoding="utf-8",
+ )
+ (folder / "notes" / "output.md").write_text(
+ "## 结果\n- **网络**正常\n\n| 分类 | 状态 |\n| --- | --- |\n| 系统 | 未确认 |",
+ encoding="utf-8",
+ )
+ report = reports.session_report(job["session_id"])
+ assert "连续 3 次" in report["error"]
+ assert report["verdict"] == "未验证(任务异常终止)"
+ assert "abc123secret" not in reports.failure_reason(
+ folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
+ )
+ 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"
+ assert any("异常原因" in paragraph.text for paragraph in doc.paragraphs)
+ assert any(
+ run.bold and "网络" in run.text for paragraph in doc.paragraphs for run in paragraph.runs
+ )
+ assert any(
+ cell.text == "分类" for table in doc.tables for row in table.rows for cell in row.cells
+ )
+ wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
+ assert "连续 3 次" in wb["任务结果"]["B15"].value
+ assert wb["结论"]["B2"].value == "结果"
+ assert wb["结果表1"]["A2"].value == "系统"
+ assert wb["执行证据"]["A1"].font.name == "微软雅黑"
+ assert wb["执行证据"].page_setup.orientation == "landscape"
+ assert reports.execution_result_label({"status": "dispatched"}) == "动作已发出(需核对截图)"
+ assert reports.conclusion_rows("## 结果\n- **通过**检查\n```txt\n| 不是 | 表格 |\n```") == [
+ ("类型", "结论与发现"),
+ ("小节", "结果"),
+ ("要点", "通过检查"),
+ ("代码", "| 不是 | 表格 |"),
+ ]
+
+
def test_task_material_zip_contains_ordered_evidence_and_reports(
client, isolated, tmp_path, monkeypatch
):
diff --git a/tests/unit/agents/test_log_analyzer.py b/tests/unit/agents/test_log_analyzer.py
index a24386a..706dcc1 100644
--- a/tests/unit/agents/test_log_analyzer.py
+++ b/tests/unit/agents/test_log_analyzer.py
@@ -24,6 +24,8 @@ import pytest
from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode
from artemis.context import ArtemisContext
from artemis.core.tool_failure import ToolFailure
+from artemis.tools.mobile.read_logs import ReadLogsArgs
+from artemis.tools.mobile.search_logs import SearchLogsArgs
_STATUS_CASES = [
pytest.param(ToolFailure("Error searching logs: device gone"), "error", id="tool_failure"),
@@ -76,3 +78,43 @@ async def test_agent_loop_tool_message_status_is_structural(result, expected_sta
assert tool_msgs[0].tool_call_id == "c1"
assert tool_msgs[0].status == expected_status
assert tool_msgs[0].content == str(result)
+
+
+@pytest.mark.asyncio
+async def test_log_cache_normalizes_numeric_tool_arguments_before_running_tools():
+ node = LogAnalyzerNode(ctx=MagicMock(spec=ArtemisContext))
+ read_calls = []
+
+ async def read_raw_logs(lines=200, **kwargs):
+ read_calls.append(lines)
+ return "before\nE/Settings: failed\nafter"
+
+ async def unused_search(**kwargs):
+ pytest.fail("Cached raw logs should be searched locally")
+
+ read_tool = node._wrap_with_caching(
+ StructuredTool.from_function(
+ coroutine=read_raw_logs, name="read_logs", description="read", args_schema=ReadLogsArgs
+ )
+ )
+ search_tool = node._wrap_with_caching(
+ StructuredTool.from_function(
+ coroutine=unused_search,
+ name="search_logs",
+ description="search",
+ args_schema=SearchLogsArgs,
+ )
+ )
+
+ assert await read_tool.coroutine(lines="200") == "before\nE/Settings: failed\nafter"
+ assert await read_tool.coroutine(lines=200) == "before\nE/Settings: failed\nafter"
+ assert await read_tool.coroutine(lines=None) == "before\nE/Settings: failed\nafter"
+ assert read_calls == [200]
+
+ result = await search_tool.coroutine(keyword="Settings", lines="200", context_lines="1")
+ assert "before" in result and "after" in result
+ assert "E/Settings: failed" in result
+ assert "before" not in await search_tool.coroutine(
+ keyword="Settings", lines="200", context_lines=None
+ )
+ assert isinstance(await search_tool.coroutine(keyword="Settings", lines="many"), ToolFailure)
diff --git a/tests/unit/agents/test_operator_recovery.py b/tests/unit/agents/test_operator_recovery.py
index 93f7902..b0e32c4 100644
--- a/tests/unit/agents/test_operator_recovery.py
+++ b/tests/unit/agents/test_operator_recovery.py
@@ -1,5 +1,6 @@
-"""Regression coverage for quoted click targets and prose-only recovery loops."""
+"""Regression coverage for operator recovery and task pause handling."""
+import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -8,6 +9,7 @@ import pytest
from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError
from artemis.config.settings import settings
+from artemis.runtime import task_control
@pytest.fixture
@@ -135,3 +137,68 @@ async def test_iteration_cap_cannot_restart_no_progress_next_turn(operator, monk
with pytest.raises(OperatorNoProgressError, match="预算内未恢复"):
await node._invoke_llm_loop(Mock(), [], [], [], state)
assert invoke.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_pause_during_diagnosis_returns_to_fresh_observation(operator, monkeypatch, tmp_path):
+ node, state = operator
+ monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
+ started = asyncio.Event()
+ cancelled = asyncio.Event()
+ events = []
+
+ class Lock:
+ def __init__(self, device_id, **kwargs):
+ self.device_id = device_id
+ self.description = "test"
+ self.max_concurrency = None
+ self.concurrency_mode = "per_device"
+ self.ingress = "web"
+ self.lock_scope = "test"
+
+ def release(self):
+ events.append("released")
+
+ def acquire(self, cancel_event=None):
+ events.append("acquired")
+
+ monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
+ node.ctx.data_engine = None
+ node.ctx.task_control = task_control.TaskControl("pro-task", Lock("device"))
+ node.ctx.on_task_pause = AsyncMock()
+ node.ctx.on_task_resume = AsyncMock()
+
+ async def diagnose(**kwargs):
+ started.set()
+ try:
+ await asyncio.Event().wait()
+ finally:
+ cancelled.set()
+
+ monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", diagnose)
+ response = AIMessage(
+ content="Inspect the failure",
+ tool_calls=[
+ {"name": "ask_diagnoser", "args": {"query": "why"}, "id": "diagnosis"},
+ {"name": "click", "args": {"target": 13}, "id": "tap"},
+ ],
+ )
+ invoke = AsyncMock(return_value=response)
+ monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
+ tool = SimpleNamespace(name="ask_diagnoser")
+ messages = []
+ execution = asyncio.create_task(node._invoke_llm_loop(Mock(), messages, [tool], [], state))
+ await asyncio.wait_for(started.wait(), 1)
+ task_control.request_pause("pro-task")
+ for _ in range(40):
+ if task_control.read_state("pro-task").get("status") == "task_paused":
+ break
+ await asyncio.sleep(0.01)
+ assert task_control.read_state("pro-task")["status"] == "task_paused"
+ assert cancelled.is_set()
+ assert events == ["released"]
+ task_control.request_resume("pro-task")
+ assert (await asyncio.wait_for(execution, 2))[0] is None
+ assert events == ["released", "acquired"]
+ assert invoke.await_count == 1
+ assert [m.status for m in messages if isinstance(m, ToolMessage)] == ["error", "error"]
diff --git a/tests/unit/mcp/test_automation_tools.py b/tests/unit/mcp/test_automation_tools.py
index abb0b26..1ba7609 100644
--- a/tests/unit/mcp/test_automation_tools.py
+++ b/tests/unit/mcp/test_automation_tools.py
@@ -60,6 +60,15 @@ async def test_running_batch_start_is_idempotent(monkeypatch):
assert request.call_args.args[0] == "GET"
+@pytest.mark.asyncio
+async def test_delete_batch_uses_the_same_backend_as_the_web_ui(monkeypatch):
+ request = AsyncMock(return_value={"batch_id": "batch", "status": "deleted"})
+ monkeypatch.setattr(tools, "_request", request)
+
+ assert (await tools.mobile_batch("delete", batch_id="batch"))["status"] == "deleted"
+ request.assert_awaited_once_with("DELETE", "/api/automation/batches/batch")
+
+
@pytest.mark.asyncio
async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path)
diff --git a/tests/unit/mcp/test_mcp_tools.py b/tests/unit/mcp/test_mcp_tools.py
index b85c50f..4e2cbdf 100644
--- a/tests/unit/mcp/test_mcp_tools.py
+++ b/tests/unit/mcp/test_mcp_tools.py
@@ -299,11 +299,10 @@ def test_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch)
(trace_id, '{"device_id":"test-device"}'),
)
- assert (
- mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
- )
+ assert mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
assert mobile_manage_task(action="pause", trace_id=trace_id)["status"] == "pausing"
assert (Path(temp_trace_env) / trace_id / "pause.request").exists()
+ assert mobile_manage_task(action="status", trace_id=trace_id)["status"] == "pausing"
control._write_atomic(
control.control_dir(trace_id) / "task_control_state.json", {"status": "task_paused"}
)
@@ -316,6 +315,9 @@ def test_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch)
control.consume_instructions(control.control_dir(trace_id))["instruction"]
== "先确认当前画面"
)
+ guidance = mobile_manage_task(action="guidance_history", trace_id=trace_id)
+ assert guidance["instructions"][0]["status"] == "consumed"
+ assert guidance["instructions"][0]["instruction"] == "先确认当前画面"
def test_mobile_manage_task_status_and_stop(temp_trace_env):
diff --git a/tests/unit/runtime/test_task_control.py b/tests/unit/runtime/test_task_control.py
index babee03..1c22afe 100644
--- a/tests/unit/runtime/test_task_control.py
+++ b/tests/unit/runtime/test_task_control.py
@@ -52,3 +52,101 @@ async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_pa
async def _event(name, events):
events.append(name)
+
+
+@pytest.mark.asyncio
+async def test_pause_interrupts_long_helper_and_discards_stale_result(tmp_path, monkeypatch):
+ monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
+ events = []
+ started = asyncio.Event()
+
+ class Lock:
+ def __init__(self, device_id, **kwargs):
+ self.device_id = device_id
+ self.description = "test"
+ self.max_concurrency = None
+ self.concurrency_mode = "per_device"
+ self.ingress = "web"
+ self.lock_scope = "test"
+
+ def release(self):
+ events.append("released")
+
+ def acquire(self, cancel_event=None):
+ events.append("acquired")
+
+ monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
+ control = task_control.TaskControl("task-123", Lock("device"))
+
+ async def diagnostic():
+ started.set()
+ try:
+ await asyncio.Event().wait()
+ finally:
+ events.append("diagnostic_stopped")
+
+ result = asyncio.create_task(
+ control.run_interruptibly(
+ diagnostic(),
+ on_pause=lambda: _event("capture_stopped", events),
+ on_resume=lambda: _event("capture_started", events),
+ )
+ )
+ await asyncio.wait_for(started.wait(), 1)
+ task_control.request_pause("task-123")
+ for _ in range(30):
+ if task_control.read_state("task-123").get("status") == "task_paused":
+ break
+ await asyncio.sleep(0.01)
+ assert task_control.read_state("task-123")["status"] == "task_paused"
+ assert events == ["diagnostic_stopped", "capture_stopped", "released"]
+ task_control.request_resume("task-123")
+ assert await asyncio.wait_for(result, 2) == (True, None)
+ assert events[-2:] == ["acquired", "capture_started"]
+
+
+@pytest.mark.asyncio
+async def test_interruptible_call_returns_normal_result_without_pause(tmp_path, monkeypatch):
+ monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
+ control = task_control.TaskControl("task-123", object())
+
+ async def model_call():
+ return "current result"
+
+ assert await control.run_interruptibly(model_call()) == (False, "current result")
+
+
+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")
+ second = task_control.add_instruction("task-123", "不要更改设置", source="mcp")
+
+ assert [item["status"] for item in task_control.list_instructions("task-123")] == [
+ "pending",
+ "pending",
+ ]
+ consumed = task_control.consume_instructions(task_control.control_dir("task-123"))
+ assert consumed["instruction"] == "从快捷设置进入\n\n不要更改设置"
+ assert task_control.consume_instructions(task_control.control_dir("task-123")) is None
+ history = task_control.list_instructions("task-123")
+ assert [item["id"] for item in history] == [first["id"], second["id"]]
+ assert all(item["status"] == "consumed" and item["consumed_at"] for item in history)
+
+
+def test_guidance_history_endpoint_returns_pending_and_consumed(tmp_path, monkeypatch):
+ from fastapi import FastAPI
+ from fastapi.testclient import TestClient
+
+ from apps.admin_console.routers.tasks import router
+
+ monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
+ task_control.add_instruction("task-123", "从快捷设置进入")
+ app = FastAPI()
+ app.include_router(router)
+ client = TestClient(app)
+ response = client.get("/api/tasks/task-123/guidance")
+ assert response.status_code == 200
+ assert response.json()[0]["status"] == "pending"
+ task_control.consume_instructions(task_control.control_dir("task-123"))
+ response = client.get("/api/tasks/task-123/guidance")
+ assert response.json()[0]["status"] == "consumed"
diff --git a/uv.lock b/uv.lock
index 4c32736..965562a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -206,7 +206,7 @@ wheels = [
[[package]]
name = "artemis"
-version = "1.0.1"
+version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "adbutils" },