feat: release 2.4.0 with original cases and automatic human review

This commit is contained in:
chenmingxuan committed 2026-10-28 17:25:08 +08:00
1 parent baf069854b
commit 4dc2057d43
66 files changed
+1390 -206

No files matched your search

+3
View File
@@ -6,6 +6,9 @@ OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
OPENAI_MODEL=gpt-6-luna
# 单次模型 HTTP 请求超时(秒)
OPENAI_TIMEOUT_SECONDS=300
# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。
# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。
OPENAI_USE_SYSTEM_PROXY=false
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
+8
View File
@@ -88,6 +88,10 @@ Responses 消息交给工具、下游解析器或正式裁定前,必须具有
执行平台与证据来源分别记录。报告和证据索引从执行记录汇总来源及实际 step_id/call_id/source_id;界面、接口、文件和终端各自只能支持其观察范围,业务后台名称或网址不能推断平台。最终复核明确事实、矛盾和未验证边界;来源清单不生成新结论,不覆盖 AI 原判、有效结论或人工复验。
原生安卓动作在派发时记录所选设备来源。安卓截图来源也可从已保存的成功截图回执读取设备、采集时间、文件路径和校验值,并引用实际 trace_id;兼容 Flash 与 Pro 的回执封装,不从模型参数补造来源。空步骤、笔记和结束声明不计入缺失动作来源;窗口枚举等无单一目标的操作保留未关联状态,该计数不等于截图丢失。未生成独立输出正文时,报告展示当前有效判定及依据,保留人工复验优先级,不把正文缺失写成验收结论缺失。
规划器的笔记工具与执行器的设备、插件工具分别绑定。规划器依据任务的实际授权插件清单制定计划,由执行器加载并验证能力;规划阶段没有绑定动作工具,不表示任务缺少这些能力,也不构成重新申请授权的依据。
MCP 与单任务 HTTP 入参拒绝未知字段,校验失败不调度任务。MCP JSON 结果保留兼容文本并提供 structuredContent;任务摘要分页明确总数与 next_offset,引用实际存储编号。跨端阶段先完整初始化,再按既有阶段 id 更新状态与依据;补丁保留其他阶段、范围、资源和顺序,合并后统一校验并保存,失败不更新内存状态。
Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。
@@ -195,3 +199,7 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。
评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。
执行详情中的 Payload 默认折叠;其他超长字段也默认折叠,展开后保留完整可复制内容并限制显示高度。原始用例面板仅由启用的工作区插件声明,额外导入列不影响未启用插件的用户。评审支持所有执行模式和目标,用户修改评审后自动保存:结果选择立即保存,备注短暂防抖;切换任务前保存,失败时保留输入和当前任务。仅用户编辑触发写入,AI 不推断人工结论。
模型网络代理策略仅由环境项 `OPENAI_USE_SYSTEM_PROXY` 控制,默认 false。同步、异步模型客户端和模型凭据检查统一遵循;不出现在前端配置中,不改变 TLS 验证与自定义 CA 设置。
+1 -1
View File
@@ -2,7 +2,7 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
**2.3.0**:新增工作区插件接口,支持步骤回放左右侧栏、Markdown 初始需求、人工评审及报告字段扩展;配套「任务人工评审 1.0.0」从插件市场独立安装。新增 gpt-6.1-terra,探索默认 high。见 [2.3.0 发行说明](docs/releases/2.3.0.md);上一版本见 [2.2.0](docs/releases/2.2.0.md)。
**2.4.0**:人工评审支持原始用例、自动保存与上一条/下一条;执行详情默认折叠长字段。升级 OpenAI/LangChain 及安全依赖,修复 Windows 资源锁、报告证据与历史用量。配套「任务人工评审 1.1.0」独立安装。见 [2.4.0 发行说明](docs/releases/2.4.0.md);上一版本见 [2.3.0](docs/releases/2.3.0.md)。
## 主要功能
@@ -15,6 +15,7 @@
import json
import logging
import os
import re
import sqlite3
import time
from typing import Any
@@ -235,6 +236,47 @@ class SessionRepository:
rows = cursor.fetchall()
return [r[0] for r in rows if r and r[0]]
def get_recorded_model_info_map(self, session_ids: list[str]) -> dict[str, dict[str, str]]:
"""Read each session's own requested model from its earliest diagnostics.
Prefer operator/Flash requests over auxiliary roles. Older sessions may
have no model diagnostics; do not replace their missing data with the
current global configuration.
"""
result: dict[str, dict[str, str]] = {}
safe_payload = "CASE WHEN json_valid(t.payload) THEN t.payload ELSE '{}' END"
valid_model = f"json_type({safe_payload}, '$.model') = 'text'"
role = f"lower(json_extract({safe_payload}, '$.role'))"
with db_session(self.db_path) as conn:
for offset in range(0, len(session_ids), 500):
batch = session_ids[offset : offset + 500]
placeholders = ",".join("?" for _ in batch)
rows = conn.execute(
"SELECT s.session_id, COALESCE("
" (SELECT t.payload FROM traces t WHERE t.session_id = s.session_id"
f" AND t.type = 'llm_diagnostic' AND {valid_model}"
f" AND {role} IN ('operator', 'flashrunner')"
" ORDER BY t.timestamp ASC LIMIT 1),"
" (SELECT t.payload FROM traces t WHERE t.session_id = s.session_id"
f" AND t.type = 'llm_diagnostic' AND {valid_model}"
" ORDER BY t.timestamp ASC LIMIT 1)"
") AS model_payload FROM sessions s"
f" WHERE s.session_id IN ({placeholders})",
batch,
).fetchall()
for row in rows:
if not row[1]:
continue
payload = json.loads(row[1])
model = payload.get("model")
if isinstance(model, str) and model.strip():
provider = payload.get("provider")
result[str(row[0])] = {
"id": model,
"provider": provider if isinstance(provider, str) else "",
}
return result
def get_llm_traces_for_profiles_map(
self, session_ids: list[str] | None = None, limit: int = 3
) -> dict[str, list[str]]:
@@ -349,7 +391,22 @@ class SessionRepository:
total += t
cached += _int(payload.get("cached_tokens"))
node = str(payload.get("node") or "").strip().lower()
model_name = str(payload.get("model") or "unknown")
model_name = payload.get("model")
if not model_name:
# Older gateway traces carry the model in their own endpoint key.
source = str(payload.get("source") or "")
source_parts = source.split(":", 2)
if len(source_parts) == 3 and source_parts[0] == "openai":
# Model aliases can contain colons. The HTTP(S) base URL
# marks the boundary; an ambiguous key stays unknown.
match = re.match(r"^openai:(.+?):https?://", source)
model_name = match[1] if match else None
elif len(source_parts) == 3 and source_parts[:2] in (
["lens", "visual_transition"],
["lens", "step_capsule"],
):
model_name = source_parts[2] if source_parts[2] != "fallback" else None
model_name = str(model_name or "unknown")
group = by_node_model.setdefault(
(node, model_name),
{
@@ -220,6 +220,41 @@ class StepRepository:
# recovering, waiting, completed, and terminal outcomes. Other tools
# retain the historical compact payload to avoid sending large blobs.
res = payload_obj.get("result")
if trace_type == "tool" and trace_name == "android_file":
# Preserve host-written file receipts, not file contents. A file's
# checksum is not a screenshot-store image ID.
if isinstance(res, str):
try:
res = json.loads(res)
except ValueError:
res = None
if isinstance(res, dict) and "outcome" in res:
res = res["outcome"]
if isinstance(res, str):
try:
res = json.loads(res)
except ValueError:
res = None
if isinstance(res, dict):
result_payload["result"] = {
key: self._clean_value(res[key])
for key in (
"ok",
"action",
"path",
"trace_path",
"device_path",
"bytes",
"sha256",
"verified",
"width",
"height",
"captured_at",
"device_serial",
)
if key in res
}
return result_payload
is_video_analysis = str(trace_name or "").lower() in {
"video_analysis",
"video_analyzer",
+10
View File
@@ -84,6 +84,9 @@ def _list_sessions_sync():
with store.connection() as connection:
exploration_runs = exploration_metadata(connection)
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
recorded_models = session_repo.get_recorded_model_info_map(
[str(row.get("session_id")) for row in rows]
)
video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map()
@@ -217,6 +220,13 @@ def _list_sessions_sync():
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
for row in result:
# Session history must not inherit today's global model configuration.
row["model_info"] = {
**row["model_info"],
"id": "",
"provider": "",
**recorded_models.get(str(row.get("session_id")), {}),
}
if row.get("exploration_kind") == "validation" and row.get("exploration_case_name"):
row["display_name"] = row["exploration_case_name"]
model = row.pop("exploration_model", None)
+24
View File
@@ -441,6 +441,24 @@ async def get_status():
)
model_info = model_service.get_active_model_info(active_profile)
model_sid = running_sid if is_running else latest_session_id
if model_info and model_sid:
recorded_model = session_repo.get_recorded_model_info_map([str(model_sid)]).get(
str(model_sid)
)
if not isinstance(recorded_model, dict):
matching_task = next(
(
item
for item in state.queue_items
if isinstance(item, dict) and str(item.get("session_id")) == str(model_sid)
),
None,
)
override = (matching_task or {}).get("runtime_overrides") or {}
model = override.get("model")
recorded_model = {"id": model, "provider": "openai"} if model else {}
model_info = {**model_info, "id": "", "provider": "", **recorded_model}
# Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets
global_queued = DeviceExecutionLock.get_queued_tasks()
@@ -508,6 +526,12 @@ async def get_status():
is_paused = state.is_paused
conn_profile = conn_info.get("profile") or active_profile
conn_model_info = model_service.get_active_model_info(conn_profile)
if conn_model_info and model_info:
conn_model_info = {
**conn_model_info,
"id": model_info["id"],
"provider": model_info["provider"],
}
return {
"status": "paused" if is_paused else "running",
"paused_error": state.paused_error if is_paused else None,
+1 -1
View File
@@ -149,7 +149,7 @@ class MediaService:
return p
LIVE_RECORDING_GRACE_SECONDS = 10.0
TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_")
TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_", "_BLOCKED_", "_UNVERIFIED_")
@classmethod
def is_live_recording(cls, p: Path) -> bool:
+30 -11
View File
@@ -36,7 +36,7 @@ from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
from apps.admin_console.services.case_review import reviewed_result
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2"
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-3"
def batch_archive_signature(batch_id: str | None = None) -> str:
@@ -234,11 +234,14 @@ def session_report(session_id: str, db_path=None) -> dict:
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
verification = "未验证(任务异常终止)"
if not content:
content = (
"本任务未生成最终结论。请查看异常原因和已记录的执行步骤。"
if status in {"failed", "interrupted"}
else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
)
lines = ["本任务未保存独立输出正文。", f"当前有效判定:{verification}"]
if test_result:
if test_result.get("reason"):
lines.append(f"判定依据:{test_result['reason']}")
lines.extend(f"- {item}" for item in test_result.get("evidence") or [])
elif error:
lines.append(f"异常原因:{error}")
content = "\n\n".join(lines)
return {
"id": session_id,
**guidance_details(session_id, db_path=repo.db_path),
@@ -264,6 +267,7 @@ def session_report(session_id: str, db_path=None) -> dict:
"category": case.get("category", ""),
"subcategory": case.get("subcategory", ""),
"preconditions": case.get("preconditions", ""),
"original_case": case.get("original_case"),
"task_config": case.get("task_config") or {},
"task_extensions": task_extensions.report_sections(session_id, store),
}
@@ -330,17 +334,24 @@ def source_text(value: dict) -> str:
for item in value.get("sources", []):
references = ", ".join(
" / ".join(
f"{key}={ref[key]}" for key in ("step_id", "call_id", "source_id") if ref.get(key)
f"{key}={ref[key]}"
for key in ("step_id", "call_id", "source_id", "trace_id")
if ref.get(key)
)
or "编号未记录"
for ref in item.get("references", [])
)
variation = "(来源标签随步骤变化,详见原始步骤)" if item.get("label_varies") else ""
lines.append(
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']} · {references}"
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']}"
f"{variation} · {references}"
)
if value.get("sources_omitted"):
lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。")
lines.append(f"来源未完整记录的步骤:{value.get('unattributed_step_count', '未知')}。")
lines.append(
f"未关联到单一来源的步骤:{value.get('unattributed_step_count', '未知')}。"
"窗口枚举等操作可能没有单一目标;此计数不表示截图丢失。"
)
lines.append(value.get("note") or "来源清单不代表断言已验证。")
return "\n".join(lines)
@@ -367,6 +378,15 @@ def metadata(report: dict) -> list[tuple]:
("检测项目", report.get("category") or "—"),
("测试子项", report.get("subcategory") or "—"),
("前置约束提取", report.get("preconditions") or "—"),
*(
[
("原始用例 · 预置条件", report["original_case"].get("preconditions") or "未填写"),
("原始用例 · 操作步骤", report["original_case"].get("steps") or "未填写"),
("原始用例 · 预期结果", report["original_case"].get("expected") or "未填写"),
]
if report.get("original_case") is not None
else []
),
("任务配置", config_text(report.get("task_config"))),
("任务目标", report["goal"]),
("预期结果", report["expected"] or "未指定"),
@@ -949,8 +969,7 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
return []
source = Path(str(candidate)).resolve()
trace_root = TRACES_PATH.resolve()
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL
# folder. The recording row already ties that path to this session.
# The recording row already ties the finalized trace folder to this session.
if not source.is_relative_to(trace_root):
return []
# A resumed recording lives under resume_NNNN; the initial recording is
@@ -9,6 +9,7 @@ from apps.admin_console.services.automation_store import AutomationStore, identi
from apps.admin_console.database.repositories.session_repository import SessionRepository
from artemis.plugins.models import PluginManifest
from artemis.plugins.store import PluginStore
from apps.admin_console.services.task_import import case_title
TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
@@ -41,10 +42,26 @@ def task_context(session_id: str, store: AutomationStore) -> dict:
job = store.job(session_id) or {}
if not session and not job:
raise ValueError("任务记录不存在")
case = job.get("case") or {}
navigation = None
if batch_id := job.get("batch_id"):
jobs = [item for item in store.jobs(batch_id) if item.get("status") in TERMINAL]
ids = [item["session_id"] for item in jobs]
index = ids.index(session_id) if session_id in ids else -1
navigation = {
"scope": "batch",
"position": index + 1,
"total": len(ids),
"previous": ids[index - 1] if index > 0 else None,
"next": ids[index + 1] if 0 <= index < len(ids) - 1 else None,
}
return {
"session_id": session_id,
"initial_prompt": session.get("initial_goal") or (job.get("case") or {}).get("goal", ""),
"editable": (job.get("status") or session.get("status")) in TERMINAL,
"case_title": case_title(case),
"original_case": case.get("original_case"),
"navigation": navigation,
}
+63 -6
View File
@@ -21,6 +21,7 @@ HEADERS = (
"Artemis提示词",
"任务配置(JSON)",
)
CASE_HEADERS = ("预置条件", "操作步骤", "预期结果")
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
@@ -61,6 +62,15 @@ class TaskOptions(BaseModel):
return value.model_dump(exclude_unset=True)
class OriginalCase(BaseModel):
"""Imported review material, never appended to the execution prompt."""
model_config = ConfigDict(extra="forbid")
preconditions: str = Field(default="", max_length=10000)
steps: str = Field(default="", max_length=10000)
expected: str = Field(default="", max_length=10000)
class TaskCase(BaseModel):
model_config = ConfigDict(extra="forbid")
case_id: str = Field(default="", max_length=200)
@@ -70,16 +80,29 @@ class TaskCase(BaseModel):
preconditions: str = Field(default="", max_length=10000)
goal: str = Field(min_length=1, max_length=10000)
task_config: TaskOptions = Field(default_factory=TaskOptions)
original_case: OriginalCase | None = None
def original_case_enabled() -> bool:
from apps.admin_console.services.task_extensions import active_manifests
return any(
panel.kind == "original_case"
for manifest in active_manifests()
if manifest.workspace is not None
for panel in manifest.workspace.panels
)
def template() -> bytes:
headers = (*HEADERS, *CASE_HEADERS) if original_case_enabled() else HEADERS
wb = Workbook()
ws = wb.active
assert ws is not None
ws.title = "任务"
ws.append(HEADERS)
ws.append(headers)
ws.freeze_panes = "A2"
ws.auto_filter.ref = "A1:G1"
ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}1"
for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="245C78")
@@ -93,7 +116,7 @@ def template() -> bytes:
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
help_sheet.append(HEADERS)
help_sheet.append(headers)
help_sheet.append(
[
"示例-001",
@@ -130,7 +153,18 @@ def template() -> bytes:
json.dumps({"profile": "flash", "execution_target": "computer"}, ensure_ascii=False),
]
)
for column in range(1, 8):
if len(headers) > len(HEADERS):
help_sheet.append(
[
"人工评审插件附加列:预置条件、操作步骤、预期结果。仅保存原始用例,均不追加到 AI 提示词。"
]
)
for row_number in (6, 7):
for column, value in enumerate(
("填写原始用例的预置条件", "填写原始用例的操作步骤", "填写原始用例的预期结果"), 8
):
help_sheet.cell(row_number, column, value)
for column in range(1, len(headers) + 1):
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
for row in help_sheet:
for cell in row:
@@ -164,7 +198,10 @@ def parse_workbook(data: bytes) -> dict:
raise ValueError("缺少“任务”工作表,请使用下载的模板")
rows = iter(wb["任务"].iter_rows())
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
if headers != list(HEADERS):
supported_headers = [list(HEADERS)]
if original_case_enabled():
supported_headers.append([*HEADERS, *CASE_HEADERS])
if headers not in supported_headers:
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
tasks, errors = [], []
for number, row in enumerate(rows, 2):
@@ -190,9 +227,29 @@ def parse_workbook(data: bytes) -> dict:
"preconditions": values[4],
"goal": values[5],
"task_config": config,
"original_case": (
dict(
zip(
("preconditions", "steps", "expected"),
(
str(cell.value if cell.value is not None else "").strip()
for cell in row[7:10]
),
)
)
if len(headers) == 10
else None
),
}
)
tasks.append(
{
"row": number,
**case.model_dump(
exclude={"original_case"} if case.original_case is None else None
),
}
)
tasks.append({"row": number, **case.model_dump()})
except (ValueError, TypeError) as exc:
errors.append({"row": number, "message": str(exc)[:500]})
if not tasks and not errors:
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "artemis-showcase-ui",
"version": "2.3.0",
"version": "2.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artemis-showcase-ui",
"version": "2.3.0",
"version": "2.4.0",
"dependencies": {
"@angular/cdk": "^22.2.4",
"@angular/common": "^22.2.3",
@@ -253,7 +253,7 @@
"@angular-devkit/schematics": "22.2.4",
"@inquirer/prompts": "8.7.2",
"@listr2/prompt-adapter-inquirer": "4.2.7",
"@modelcontextprotocol/server": "2.3.1",
"@modelcontextprotocol/server": "2.4.0",
"@schematics/angular": "22.2.4",
"jsonc-parser": "3.3.1",
"listr2": "11.1.0",
@@ -2043,8 +2043,8 @@
]
},
"node_modules/@modelcontextprotocol/core": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz",
"integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==",
"dev": true,
"license": "Apache-2.0",
@@ -2056,13 +2056,13 @@
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz",
"integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/core": "2.3.1",
"@modelcontextprotocol/core": "2.4.0",
"zod": "^4.2.0"
},
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "2.3.0",
"version": "2.4.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -695,13 +695,13 @@ limitations under the License.
@if (getActionTargetText(item.data)) {
<div class="action-detail-row">
<span class="detail-label">目标:</span>
<span class="detail-value">"{{ getActionTargetText(item.data) }}"</span>
<app-expandable-value class="detail-value" label="目标" [value]="getActionTargetText(item.data)" />
</div>
}
@if (getActionInputText(item.data)) {
<div class="action-detail-row">
<span class="detail-label">{{ getActionInputLabel(item.data) }}:</span>
<span class="detail-value">"{{ getActionInputText(item.data) }}"</span>
<app-expandable-value class="detail-value" label="输入" [value]="getActionInputText(item.data)" />
</div>
}
@if (getActionCoords(item.data)) {
@@ -719,13 +719,13 @@ limitations under the License.
@if (getActionTargetText(item.data)) {
<div class="grid-item">
<span class="grid-label">目标</span>
<span class="grid-value">"{{ getActionTargetText(item.data) }}"</span>
<app-expandable-value label="目标" [value]="getActionTargetText(item.data)" />
</div>
}
@if (getActionInputText(item.data)) {
<div class="grid-item">
<span class="grid-label">{{ getActionInputLabel(item.data) }}</span>
<span class="grid-value">"{{ getActionInputText(item.data) }}"</span>
<app-expandable-value label="输入" [value]="getActionInputText(item.data)" />
</div>
}
@if (getActionCoords(item.data)) {
@@ -749,19 +749,7 @@ limitations under the License.
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item" [class.full-width]="param.key === 'Result'">
<span class="grid-label">{{ param.key }}</span>
@if (param.key === 'Result') {
<div class="action-result-preview">{{ param.value | slice:0:180 }}{{ param.value.length > 180 ? '…' : '' }}</div>
<details class="action-result-detail">
<summary>
<span class="result-expand-label">展开完整结果</span>
<span class="result-collapse-label">收起结果</span>
<span class="result-length">{{ param.value.length }} 字符</span>
</summary>
<pre tabindex="0" aria-label="完整操作结果,可选择复制">{{ param.value }}</pre>
</details>
} @else {
<span class="grid-value code-pill">{{ param.value }}</span>
}
<app-expandable-value [label]="param.key" [value]="param.value" />
</div>
}
<!-- Fallback item if all detail fields are empty -->
@@ -1008,7 +996,7 @@ limitations under the License.
@if (getAdbCommandLine(item.data)) {
<div class="action-detail-row">
<span class="detail-label">命令:</span>
<span class="detail-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span>
<app-expandable-value class="detail-value" label="命令" [value]="getAdbCommandLine(item.data)" />
</div>
}
@if (getAdbCwd(item.data)) {
@@ -1029,13 +1017,13 @@ limitations under the License.
@if (getToolTargetText(item.data)) {
<div class="action-detail-row">
<span class="detail-label">目标:</span>
<span class="detail-value">"{{ getToolTargetText(item.data) }}"</span>
<app-expandable-value class="detail-value" label="目标" [value]="getToolTargetText(item.data)" />
</div>
}
@if (getToolInputText(item.data)) {
<div class="action-detail-row">
<span class="detail-label">{{ getToolInputLabel(item.data) }}:</span>
<span class="detail-value">"{{ getToolInputText(item.data) }}"</span>
<app-expandable-value class="detail-value" label="输入" [value]="getToolInputText(item.data)" />
</div>
}
@if (getToolCoords(item.data)) {
@@ -1047,13 +1035,13 @@ limitations under the License.
@if (getToolAnalysisText(item.data)) {
<div class="action-detail-row">
<span class="detail-label">分析:</span>
<span class="detail-value">"{{ getToolAnalysisText(item.data) }}"</span>
<app-expandable-value class="detail-value" label="分析" [value]="getToolAnalysisText(item.data)" />
</div>
}
@if (getToolGenericDetails(item.data)) {
<div class="action-detail-row">
<span class="detail-label">详情:</span>
<span class="detail-value">"{{ getToolGenericDetails(item.data) }}"</span>
<app-expandable-value class="detail-value" label="详情" [value]="getToolGenericDetails(item.data)" />
</div>
}
}
@@ -1067,7 +1055,7 @@ limitations under the License.
@if (getAdbCommandLine(item.data)) {
<div class="grid-item full-width">
<span class="grid-label">命令</span>
<span class="grid-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span>
<app-expandable-value label="命令" [value]="getAdbCommandLine(item.data)" />
</div>
}
@if (getAdbCwd(item.data)) {
@@ -1087,7 +1075,7 @@ limitations under the License.
@for (param of plugin.args; track param.key) {
<div class="grid-item">
<span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span>
<app-expandable-value [label]="param.key" [value]="param.value" />
</div>
}
@if (plugin.result || plugin.rawResult || plugin.facts.length) {
@@ -1101,13 +1089,13 @@ limitations under the License.
@if (getToolTargetText(item.data)) {
<div class="grid-item">
<span class="grid-label">目标</span>
<span class="grid-value">"{{ getToolTargetText(item.data) }}"</span>
<app-expandable-value label="目标" [value]="getToolTargetText(item.data)" />
</div>
}
@if (getToolInputText(item.data)) {
<div class="grid-item">
<span class="grid-label">{{ getToolInputLabel(item.data) }}</span>
<span class="grid-value">"{{ getToolInputText(item.data) }}"</span>
<app-expandable-value label="输入" [value]="getToolInputText(item.data)" />
</div>
}
@if (getToolCoords(item.data)) {
@@ -1119,19 +1107,19 @@ limitations under the License.
@if (getToolAnalysisText(item.data)) {
<div class="grid-item">
<span class="grid-label">分析</span>
<span class="grid-value">"{{ getToolAnalysisText(item.data) }}"</span>
<app-expandable-value label="分析" [value]="getToolAnalysisText(item.data)" />
</div>
}
@if (getToolGenericDetails(item.data)) {
<div class="grid-item">
<span class="grid-label">详情</span>
<span class="grid-value">"{{ getToolGenericDetails(item.data) }}"</span>
<app-expandable-value label="详情" [value]="getToolGenericDetails(item.data)" />
</div>
}
@for (param of getToolExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item">
<span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span>
<app-expandable-value [label]="param.key" [value]="param.value" />
</div>
}
}
@@ -6,6 +6,7 @@ import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-sta
import { TaskLifecycleComponent } from '../task-lifecycle/task-lifecycle.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { PluginToolResultComponent } from '../plugin-tool-result/plugin-tool-result.component';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
/**
@@ -250,7 +251,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({
selector: 'app-agent-stream',
standalone: true,
imports: [PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
imports: [ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -0,0 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { ExpandableValueComponent } from './expandable-value.component';
describe('执行字段折叠', () => {
for (const [label, value] of [['Payload', '{"x":1}'], ['输入', '完整内容'.repeat(100)], ['输出', '行\n'.repeat(6)]]) {
it(`默认折叠 ${label} 并保留完整内容`, () => {
const fixture = TestBed.createComponent(ExpandableValueComponent);
fixture.componentRef.setInput('label', label);
fixture.componentRef.setInput('value', value);
fixture.detectChanges();
const details: HTMLDetailsElement = fixture.nativeElement.querySelector('details');
expect(details.open).toBeFalse();
details.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(details.querySelector('pre')!.textContent).toBe(value);
fixture.detectChanges();
expect(details.open).toBeTrue();
});
}
it('直接显示普通短字段', () => {
const fixture = TestBed.createComponent(ExpandableValueComponent);
fixture.componentRef.setInput('value', '点击设置'); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('点击设置');
});
});
@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
/** Visual folding only: the full receipt remains selectable and unchanged. */
@Component({
selector: 'app-expandable-value', standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (foldable()) {
<details>
<summary><span>{{ label() }} · {{ value().length }} 字符</span><span class="hint">展开查看</span></summary>
<pre tabindex="0" [attr.aria-label]="label() + '完整内容,可选择复制'">{{ value() }}</pre>
</details>
} @else { <span class="short-value">{{ value() }}</span> }
`,
styles: [`
:host { display:block; min-width:0; max-width:100%; }
details { border:1px solid var(--app-border, #e2e8f0); border-radius:8px; background:var(--app-surface-soft, #f8fafc); }
summary { cursor:pointer; padding:7px 10px; font-size:12px; color:var(--app-text-secondary, #475569); overflow-wrap:anywhere; }
.hint { margin-left:8px; color:var(--app-primary-text, #1a73e8); }
details[open] .hint { display:none; }
pre { margin:0; padding:10px; max-height:24rem; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; user-select:text; }
.short-value { white-space:pre-wrap; overflow-wrap:anywhere; }
summary:focus-visible, pre:focus-visible { outline:2px solid var(--app-focus, #a5b4fc); outline-offset:2px; }
`]
})
export class ExpandableValueComponent {
value = input('');
label = input('详情');
foldable = computed(() => /payload|原始|^result$/i.test(this.label())
|| this.value().length > 240 || this.value().split('\n').length > 4);
}
@@ -122,7 +122,8 @@ limitations under the License.
}
<!-- Video Main Content Area -->
@if (!agentService.isVideoMinimized()) {
<app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="agentService.playerMode() === 'steps'"
<app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="true"
[sessionIds]="reviewSessionIds()" (navigate)="agentService.selectSession($event, true)"
[taskStatus]="agentService.currentSession()?.status || ''" (expandedWidth)="extensionWidth.set($event)">
<div class="video-body">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
@@ -1,5 +1,5 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service';
@@ -41,6 +41,8 @@ describe('FloatingVideoPlayerComponent video markers', () => {
currentSessionStepFrames: signal([frame]),
currentSession: signal({ session_id: 'test', start_time: start }),
currentSessionId: signal('test'),
sessions: signal([{ session_id: 'test', status: 'completed' }]),
selectSession: jasmine.createSpy('selectSession'),
videoSeekRequest: signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null),
stepSeekRequest: signal(null),
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
@@ -66,6 +68,9 @@ describe('FloatingVideoPlayerComponent video markers', () => {
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: []
});
component = fixture.componentInstance;
video = component.videoRef!.nativeElement;
paused = true;
@@ -100,6 +105,23 @@ describe('FloatingVideoPlayerComponent video markers', () => {
fixture.detectChanges();
}
for (const mode of ['video', 'steps']) {
it(`shows review controls in ${mode} mode even without a recording`, () => {
agentService.playerMode.set(mode);
agentService.recordingPlaybackStatus.set('unavailable');
component.extensionPanels()!.refresh(); fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: [{
plugin_id: 'review', name: '原始用例', version: '1.1.0', revision: 0, values: {}, history: [],
workspace: { fields: [], panels: [{ id: 'case', title: '原始用例', side: 'left', kind: 'original_case', width: {minimum: 240, preferred: 340, maximum: 480} }] }
}]
}); fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button[aria-label="展开原始用例"]');
expect(button).not.toBeNull(); button.click(); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('aside').textContent).toContain('未记录原始用例');
});
}
it('removes the live image while hidden and reconnects it on return', () => {
const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
agentService.recordingPlaybackStatus.set('live');
@@ -51,6 +51,9 @@ import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-m
export class FloatingVideoPlayerComponent implements OnDestroy {
public agentService = inject(AgentService);
public extensionWidth = signal(0);
public reviewSessionIds = computed(() => this.agentService.sessions()
.filter(session => ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(session.status || ''))
.map(session => session.session_id));
public extensionPanels = viewChild(TaskExtensionPanelsComponent);
public windowLeft(): number {
const width = this.agentService.isVideoMinimized() ? 280 : 400 + this.extensionWidth();
@@ -1,15 +1,17 @@
import { Component, computed, input } from '@angular/core';
import { PluginToolView } from '../../utils/plugin-tool-view.util';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
/** Readable presentation only: the full receipt and model context stay intact. */
@Component({
selector: 'app-plugin-tool-result',
standalone: true,
imports: [ExpandableValueComponent],
template: `
@if (view().facts.length) {
<dl class="receipt-facts">
@for (fact of view().facts; track fact.key) {
<div><dt>{{ fact.key }}</dt><dd>{{ fact.value }}</dd></div>
<div><dt>{{ fact.key }}</dt><dd><app-expandable-value [label]="fact.key" [value]="fact.value" /></dd></div>
}
</dl>
}
@@ -44,14 +44,21 @@
<ng-template #panelContent let-plugin="plugin" let-panel="panel">
<section [id]="'extension-' + key(plugin, panel)" [attr.aria-label]="panel.title">
<header><h3>{{ panel.title }}</h3><span class="panel-kind">{{ panel.kind === 'initial_prompt' ? '原始提示词' : '人工填写 · 选填' }}</span></header>
@if (panel.kind === 'initial_prompt') {
<header><h3>{{ panel.kind === 'original_case' ? (data()?.case_title || panel.title) : panel.title }}</h3><span class="panel-kind">{{ panel.kind === 'original_case' ? '原始用例' : panel.kind === 'initial_prompt' ? '原始提示词' : '人工填写 · 选填' }}</span></header>
@if (panel.kind === 'original_case') {
@if (!data()?.original_case) {
<p class="muted">此任务未记录原始用例内容;旧任务不会从 AI 提示词推测补写。</p>
}
<div class="case-section"><h4>预置条件</h4><app-report-markdown [content]="data()?.original_case?.preconditions || '未填写'" /></div>
<div class="case-section"><h4>操作步骤</h4><app-report-markdown [content]="data()?.original_case?.steps || '未填写'" /></div>
<div class="case-section"><h4>预期结果</h4><app-report-markdown [content]="data()?.original_case?.expected || '未填写'" /></div>
} @else if (panel.kind === 'initial_prompt') {
@if (data()?.initial_prompt) {
<app-report-markdown [content]="data()!.initial_prompt" />
} @else { <p class="muted">该任务未记录初始需求提示词。</p> }
} @else {
@if (!data()?.editable) { <p class="muted">任务结束后可填写评审。</p> }
<fieldset [disabled]="!data()?.editable || !!saving()">
<fieldset [disabled]="!data()?.editable">
@for (field of plugin.workspace.fields; track field.id) {
<div class="field" [class.choice-field]="field.kind === 'choice'" [class.note-field]="field.kind === 'text'">
<span class="field-label">{{ field.label }}</span>
@@ -73,17 +80,26 @@
}
</div>
} @else {
<textarea rows="2" [attr.aria-label]="field.label" [maxLength]="field.max_length" placeholder="添加备注(选填)"
<textarea rows="1" [attr.aria-label]="field.label" [maxLength]="field.max_length" placeholder="添加备注(选填)"
[ngModel]="drafts[plugin.plugin_id].values[field.id] || ''" (ngModelChange)="edit(plugin.plugin_id, field.id, $event)"></textarea>
}
</div>
}
<div class="form-actions">
<button type="button" class="save" (click)="save(plugin)">{{ saving() === plugin.plugin_id ? '正在保存…' : '保存评审' }}</button>
<button type="button" class="save" [disabled]="!!saving()" (click)="save(plugin)">{{ saving() === plugin.plugin_id ? '正在保存…' : '保存评审' }}</button>
<button type="button" class="reload" [disabled]="!!saving()" (click)="reload(plugin)" title="放弃未保存内容,读取最新记录">重新加载</button>
@if (drafts[plugin.plugin_id].dirty) { <span class="muted unsaved">有未保存内容</span> }
<span class="muted unsaved">{{ saving() === plugin.plugin_id ? '正在保存…' : drafts[plugin.plugin_id].dirty ? '等待自动保存…' : '修改后自动保存' }}</span>
</div>
</fieldset>
<nav class="review-navigation" aria-label="切换评审任务">
<div class="navigation-buttons">
<button type="button" [disabled]="!navigation().previous || !!saving() || moving()" (click)="move('previous')"><span aria-hidden="true">‹</span>上一条</button>
<span class="review-position">{{ navigation().position }} / {{ navigation().total }}</span>
<button type="button" [disabled]="!navigation().next || !!saving() || moving()" (click)="move('next')">下一条<span aria-hidden="true">›</span></button>
</div>
<span class="muted">{{ navigation().scope === 'batch' ? '同批次已结束任务 · 按执行顺序' : '当前历史列表' }}</span>
@if (drafts[plugin.plugin_id].dirty) { <span class="muted">有待保存的修改,切换前会先保存。</span> }
</nav>
@if (notice()) { <p class="notice" role="status">{{ notice() }}</p> }
@if (plugin.history.length) {
<details class="history"><summary>修改记录({{ plugin.history.length }})</summary>
@@ -19,13 +19,19 @@ button:focus-visible, textarea:focus-visible { outline: 2px solid var(--app-prim
.left-column { border-right: 1px solid var(--app-border, #e2e8f0); }
.right-column { border-left: 1px solid var(--app-border, #e2e8f0); }
section + section { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 12px; margin-top: 12px; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 20px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 14px; overflow-wrap: anywhere; }
h3 { margin: 0; font-size: 14px; color: var(--app-text-strong, #0f172a); }
.panel-kind { font-size: 11px; color: var(--app-text-muted, #64748b); }
.panel-kind { flex-shrink: 0; font-size: 11px; color: var(--app-text-muted, #64748b); }
.case-section { margin: 18px 0; }
.case-section h4 { margin: 0 0 8px; font-size: 13px; color: var(--app-text-strong, #0f172a); }
.review-navigation { margin: 12px 0 0; padding: 12px 0 0; background: var(--app-surface, #fff); border-top: 1px solid var(--app-border, #e2e8f0); }
.navigation-buttons { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin-bottom: 4px; }
.navigation-buttons button { white-space: nowrap; }
.review-position { font-size: 11px; color: var(--app-text-muted, #64748b); white-space: nowrap; }
.muted { display: block; font-size: 12px; color: var(--app-text-secondary, #64748b); line-height: 1.6; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.field { display: grid; gap: 8px; margin: 0 0 14px; }
.choice-field:not(:first-child) { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 22px; margin-top: 22px; }
.choice-field:not(:first-child) { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 14px; margin-top: 14px; }
.field-label { font-size: 13px; font-weight: 600; }
.note-field .field-label { font-size: 11px; font-weight: 400; color: var(--app-text-muted, #64748b); }
.choice-group { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); }
@@ -36,7 +42,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.choice-button.selected { border-color: var(--app-primary-border, #93c5fd); background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); font-weight: 600; }
.choice-button.pass.selected { border-color: var(--app-success-border, #a7f3d0); background: var(--app-success-soft, #ecfdf5); color: var(--app-success-text, #047857); }
.choice-button.failed.selected { border-color: var(--app-error-border, #fecdd3); background: var(--app-error-soft, #fff1f2); color: var(--app-error-text, #be123c); }
textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 10px 12px; font: inherit; font-size: 12px; line-height: 1.6; color: inherit; border: 1px solid var(--app-border, #e2e8f0); border-radius: 8px; background: var(--app-surface-soft, #f8fafc); resize: vertical; min-height: 66px; max-height: 200px; }
textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 6px 10px; font: inherit; font-size: 12px; line-height: 1.6; color: inherit; border: 1px solid var(--app-border, #e2e8f0); border-radius: 8px; background: var(--app-surface-soft, #f8fafc); resize: vertical; min-height: 34px; max-height: 200px; }
textarea::placeholder { color: var(--app-text-muted, #94a3b8); }
.form-actions { display: grid; grid-template-columns: auto auto; justify-content: start; align-items: center; gap: 8px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--app-border, #e2e8f0); }
.form-actions button { height: 36px; padding: 0 16px; white-space: nowrap; }
@@ -1,4 +1,4 @@
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { TestBed, ComponentFixture, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TaskExtensionPanelsComponent } from './task-extension-panels.component';
@@ -50,7 +50,7 @@ describe('步骤回放工作区扩展', () => {
fixture.detectChanges();
}
it('does not request extensions in video mode and shows no controls without a plugin', () => {
it('does not request disabled extensions and shows no controls without a plugin', () => {
fixture.componentRef.setInput('sessionId', 'task-one');
fixture.detectChanges();
http.expectNone('/api/task-extensions/task-one');
@@ -87,87 +87,92 @@ describe('步骤回放工作区扩展', () => {
}
});
it('allows partial manual selection and keeps unfilled outcomes blank', () => {
it('automatically saves choices and clears while keeping unfilled outcomes blank', fakeAsync(() => {
open(); toggle('right');
expect(fixture.nativeElement.querySelector('select')).toBeNull();
const groups: NodeListOf<HTMLElement> = fixture.nativeElement.querySelectorAll('.choice-group');
expect(fixture.nativeElement.querySelector('[aria-pressed="true"]')).toBeNull();
const pass = groups[0].querySelector<HTMLButtonElement>('.pass')!;
const failed = groups[0].querySelector<HTMLButtonElement>('.failed')!;
const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass');
pass.click(); fixture.detectChanges();
expect(pass.getAttribute('aria-pressed')).toBe('true');
pass.click(); fixture.detectChanges();
expect(component.drafts['review'].values['accuracy']).toBe('');
failed.click(); pass.click(); fixture.detectChanges();
expect(failed.getAttribute('aria-pressed')).toBe('false');
expect(groups[1].querySelector('[aria-pressed="true"]')).toBeNull();
const note: HTMLTextAreaElement = fixture.nativeElement.querySelector('textarea');
note.value = '人工核对'; note.dispatchEvent(new Event('input'));
fixture.detectChanges();
fixture.nativeElement.querySelector('button.save').click();
const request = http.expectOne('/api/task-extensions/task-one/review');
expect(saved().values).toEqual({ accuracy: 'Pass', note: '', correctness: '' });
pass.click(); fixture.detectChanges(); expect(saved(2).values.accuracy).toBe('');
expect(fixture.nativeElement.textContent).toContain('人工评审已自动保存');
}));
function saved(revision = 1, id = 'task-one') {
const request = http.expectOne(`/api/task-extensions/${id}/review`);
expect(request.request.method).toBe('PUT');
expect(request.request.body.values).toEqual({ accuracy: 'Pass', note: '人工核对', correctness: '' });
expect(request.request.body.expected_revision).toBe(0);
const data = snapshot(); data.plugins[0].revision = 1; data.plugins[0].values = request.request.body.values;
request.flush(data); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('人工评审已保存');
});
const body = request.request.body, data = snapshot(id);
data.plugins[0].revision = revision; data.plugins[0].values = body.values;
request.flush(data); flushMicrotasks(); fixture.detectChanges(); return body;
}
it('preserves draft and request receipt after a failed save, and permits clearing a result', () => {
open(); toggle('right');
const plugin = component.data()!.plugins[0];
component.edit('review', 'accuracy', 'Failed'); component.save(plugin);
const first = http.expectOne('/api/task-extensions/task-one/review');
const requestId = first.request.body.request_id;
first.flush({}, { status: 503, statusText: 'Unavailable' });
component.save(plugin);
const retry = http.expectOne('/api/task-extensions/task-one/review');
expect(retry.request.body.request_id).toBe(requestId);
retry.flush({}, { status: 409, statusText: 'Conflict' });
it('debounces notes and never posts just from opening a task', fakeAsync(() => {
open(); toggle('right'); tick(700); http.expectNone('/api/task-extensions/task-one/review');
component.edit('review', 'note', '第一段'); tick(400);
component.edit('review', 'note', '完整备注'); tick(599);
http.expectNone('/api/task-extensions/task-one/review'); tick(1);
expect(saved().values.note).toBe('完整备注');
}));
it('does not overwrite an automatic save with an older read response', fakeAsync(() => {
open(); component.edit('review', 'note', '已保存的新内容'); tick(600); saved(2);
component.refresh(); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges();
expect(component.drafts['review'].values['note']).toBe('已保存的新内容');
expect(component.drafts['review'].revision).toBe(2);
}));
it('flushes a pending note when the review panel is closed', fakeAsync(() => {
open(); component.edit('review', 'note', '关闭前保存'); fixture.destroy();
const request = http.expectOne('/api/task-extensions/task-one/review');
expect(request.request.body.values.note).toBe('关闭前保存');
const response = snapshot(); response.plugins[0].revision = 1; response.plugins[0].values = request.request.body.values;
request.flush(response); flushMicrotasks();
}));
it('preserves draft and request receipt on failure, and retries only when requested', fakeAsync(() => {
open(); toggle('right'); const plugin = component.data()!.plugins[0];
component.edit('review', 'accuracy', 'Failed'); void component.save(plugin);
const first = http.expectOne('/api/task-extensions/task-one/review'), requestId = first.request.body.request_id;
first.flush({}, { status: 503, statusText: 'Unavailable' }); flushMicrotasks(); tick(700);
http.expectNone('/api/task-extensions/task-one/review'); void component.save(plugin);
const retry = http.expectOne('/api/task-extensions/task-one/review'); expect(retry.request.body.request_id).toBe(requestId);
retry.flush({}, { status: 409, statusText: 'Conflict' }); flushMicrotasks();
expect(component.drafts['review'].values['accuracy']).toBe('Failed');
component.edit('review', 'accuracy', ''); component.save(plugin);
const clear = http.expectOne('/api/task-extensions/task-one/review');
expect(clear.request.body.values.accuracy).toBe('');
expect(clear.request.body.request_id).not.toBe(requestId);
clear.flush(snapshot());
});
component.edit('review', 'accuracy', ''); void component.save(plugin);
const clear = saved(); expect(clear.values.accuracy).toBe(''); expect(clear.request_id).not.toBe(requestId);
}));
it('renders Chinese outcomes as mutually exclusive buttons and saves their declared values', () => {
const data = snapshot(); data.plugins[0].workspace.fields[0].options = ['通过', '失败'];
open(data); toggle('right');
it('serializes rapid edits and saves the latest choice using the returned revision', fakeAsync(() => {
const data = snapshot(); data.plugins[0].workspace.fields[0].options = ['通过', '失败']; open(data); toggle('right');
const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass');
const failed: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.failed');
expect(pass.textContent).toContain('通过'); expect(failed.textContent).toContain('失败');
pass.click(); failed.click(); fixture.detectChanges();
expect(component.drafts['review'].values['accuracy']).toBe('失败');
expect(pass.getAttribute('aria-pressed')).toBe('false');
expect(failed.getAttribute('aria-pressed')).toBe('true');
});
expect(component.drafts['review'].values['accuracy']).toBe('失败'); expect(saved().values.accuracy).toBe('通过');
const second = saved(2); expect(second.values.accuracy).toBe('失败'); expect(second.expected_revision).toBe(1);
expect(component.drafts['review'].dirty).toBeFalse();
}));
it('does not apply a delayed save response to the task now displayed', () => {
open(); toggle('right');
component.edit('review', 'note', '只属于第一个任务'); component.save(component.data()!.plugins[0]);
it('does not apply a delayed save response to the task now displayed', fakeAsync(() => {
open(); toggle('right'); component.edit('review', 'note', '只属于第一个任务'); void component.save(component.data()!.plugins[0]);
const oldSave = http.expectOne('/api/task-extensions/task-one/review');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges();
oldSave.flush(snapshot('task-one')); fixture.detectChanges();
expect(component.data()!.session_id).toBe('task-two');
expect(component.drafts['review'].values['note']).toBe('');
});
oldSave.flush(snapshot('task-one')); flushMicrotasks(); fixture.detectChanges();
expect(component.data()!.session_id).toBe('task-two'); expect(component.drafts['review'].values['note']).toBe('');
}));
it('keeps unsaved drafts across task switches and removes panels on plugin disable', () => {
open(); toggle('right'); component.edit('review', 'note', '保留草稿');
it('flushes a pending note on direct task switches and hides disabled plugins', fakeAsync(() => {
open(); toggle('right'); component.edit('review', 'note', '自动保存草稿');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two'));
const body = saved(1, 'task-one'); expect(body.values.note).toBe('自动保存草稿');
fixture.componentRef.setInput('sessionId', 'task-one'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges();
expect(component.drafts['review'].values['note']).toBe('保留草稿');
const first = snapshot(); first.plugins[0].revision = 1; first.plugins[0].values = body.values;
http.expectOne('/api/task-extensions/task-one').flush(first); fixture.detectChanges();
expect(component.drafts['review'].values['note']).toBe('自动保存草稿');
window.dispatchEvent(new Event('artemis-plugin-change')); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush({ ...snapshot(), plugins: [] }); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('nav')).toBeNull();
http.expectOne('/api/task-extensions/task-one').flush({ ...first, plugins: [] }); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('aside')).toBeNull();
});
}));
it('keeps running tasks read-only then enables editing when the task finishes', () => {
open({ ...snapshot(), editable: false }); toggle('right');
@@ -187,4 +192,30 @@ describe('步骤回放工作区扩展', () => {
expect(component.panels('right').length).toBe(1);
} finally { Object.defineProperty(window, 'innerWidth', original); }
});
it('shows original case sections in order without substituting the AI prompt', () => {
const data = snapshot();
data.plugins[0].workspace.panels[0].kind = 'original_case';
data.case_title = 'CASE-001 检查设置';
data.original_case = { preconditions: '已登录', steps: '1. 打开设置\n2. 核对版本', expected: '版本可见' };
open(data); toggle('left');
const text = fixture.nativeElement.textContent;
expect(text).toContain('CASE-001 检查设置');
expect(text).toMatch(/预置条件[\s\S]*已登录[\s\S]*操作步骤[\s\S]*核对版本[\s\S]*预期结果[\s\S]*版本可见/);
expect(text).not.toContain('原始需求');
});
it('saves before navigation and keeps the current task on save failure', fakeAsync(() => {
const first = snapshot(); first.navigation = { scope: 'batch', position: 1, total: 2, previous: null, next: 'task-two' };
open(first); toggle('right'); const emitted = jasmine.createSpy('navigate'); component.navigate.subscribe(emitted);
const saveButton = fixture.nativeElement.querySelector('button.save'), navigation = fixture.nativeElement.querySelector('.review-navigation');
expect(saveButton.compareDocumentPosition(navigation) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
component.edit('review', 'note', '切换前保存'); void component.move('next'); expect(emitted).not.toHaveBeenCalled();
http.expectOne('/api/task-extensions/task-one/review').flush({}, {status: 409, statusText: 'Conflict'});
flushMicrotasks(); expect(emitted).not.toHaveBeenCalled(); expect(component.drafts['review'].dirty).toBeTrue();
void component.move('next'); saved(); flushMicrotasks(); expect(emitted).toHaveBeenCalledOnceWith('task-two');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges();
expect(component.panels('right').length).toBe(1);
}));
});
@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, HostListener, TemplateRef, effect, inject, input, output, signal, viewChild } from '@angular/core';
import { Component, ChangeDetectionStrategy, HostListener, TemplateRef, DestroyRef, computed, effect, inject, input, output, signal, viewChild } from '@angular/core';
import { DatePipe, NgTemplateOutlet } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { ExtensionDraft, ExtensionPanel, TaskExtension, TaskExtensions, TaskExtensionsService } from '../../services/task-extensions.service';
@@ -16,11 +17,25 @@ export class TaskExtensionPanelsComponent {
sessionId = input<string | null>(null);
enabled = input(false);
taskStatus = input('');
sessionIds = input<string[]>([]);
navigate = output<string>();
navigation = computed(() => {
if (this.data()?.navigation) return this.data()!.navigation!;
const ids = this.sessionIds(), index = ids.indexOf(this.sessionId() || '');
return { scope: 'history', position: index + 1, total: ids.length,
previous: index > 0 ? ids[index - 1] : null,
next: index >= 0 && index < ids.length - 1 ? ids[index + 1] : null };
});
expandedWidth = output<number>();
headerControls = viewChild<TemplateRef<unknown>>('headerControls');
data = signal<TaskExtensions | null>(null);
error = signal('');
saving = signal<string | null>(null);
private destroyRef = inject(DestroyRef);
private savingKeys = signal<string[]>([]);
saving = computed(() => this.savingKeys().find(key => key.startsWith(`${this.sessionId()}/`))?.split('/')[1] || null);
moving = signal(false);
private scheduled = new Map<string, { timer: ReturnType<typeof setTimeout>; run: () => Promise<boolean> }>();
private inFlight = new Map<string, Promise<boolean>>();
notice = signal('');
opened = signal<Record<string, boolean>>({});
private refreshToken = signal(0);
@@ -29,6 +44,7 @@ export class TaskExtensionPanelsComponent {
drafts: Record<string, ExtensionDraft> = {};
constructor() {
this.destroyRef.onDestroy(() => this.flushScheduled());
effect(onCleanup => {
const id = this.sessionId();
const enabled = this.enabled();
@@ -37,14 +53,14 @@ export class TaskExtensionPanelsComponent {
this.generation++;
const identity = enabled ? id || '' : '';
if (identity !== this.identity) {
this.flushScheduled();
this.identity = identity;
this.data.set(null);
this.opened.set({});
if (!identity) this.opened.set({});
this.expandedWidth.emit(0);
}
this.error.set('');
this.notice.set('');
this.saving.set(null);
if (!id || !enabled) return;
const subscription = this.service.read(id).subscribe({
next: data => {
@@ -61,6 +77,27 @@ export class TaskExtensionPanelsComponent {
@HostListener('window:artemis-plugin-change')
refresh(): void { this.refreshToken.update(value => value + 1); }
async move(direction: 'previous' | 'next'): Promise<void> {
const destination = this.navigation()[direction], id = this.sessionId();
if (!destination || !id || !this.data() || this.moving()) return;
this.moving.set(true);
try {
for (const plugin of this.data()!.plugins) {
if (this.drafts[plugin.plugin_id]?.dirty && !await this.save(plugin)) return;
}
if (id === this.sessionId() && !this.destroyRef.destroyed) this.navigate.emit(destination);
} finally { this.moving.set(false); }
}
private cancelScheduled(key: string): void {
const pending = this.scheduled.get(key);
if (pending) clearTimeout(pending.timer);
this.scheduled.delete(key);
}
private flushScheduled(): void {
for (const pending of [...this.scheduled.values()]) void pending.run();
}
key(plugin: TaskExtension, panel: ExtensionPanel): string { return `${plugin.plugin_id}/${panel.id}`; }
panels(side: 'left' | 'right') {
return (this.data()?.plugins || []).flatMap(plugin => plugin.workspace.panels
@@ -94,41 +131,71 @@ export class TaskExtensionPanelsComponent {
}
}
edit(pluginId: string, field: string, value: string): void {
const draft = this.drafts[pluginId];
const id = this.sessionId(), draft = this.drafts[pluginId];
if (!id || !draft || !this.data()?.editable || draft.values[field] === value) return;
draft.values[field] = value;
draft.dirty = true;
draft.requestId = undefined;
this.notice.set('');
const key = `${id}/${pluginId}`;
this.cancelScheduled(key);
const run = () => this.saveFor(id, pluginId, draft);
this.scheduled.set(key, { run, timer: setTimeout(() => void run(), 600) });
}
choose(pluginId: string, field: string, value: string): void {
this.edit(pluginId, field, this.drafts[pluginId].values[field] === value ? '' : value);
const plugin = this.data()?.plugins.find(item => item.plugin_id === pluginId);
if (plugin) void this.save(plugin);
}
reload(plugin: TaskExtension): void {
const id = this.sessionId();
if (!id) return;
this.cancelScheduled(`${id}/${plugin.plugin_id}`);
this.service.discard(id, plugin.plugin_id);
this.refresh();
}
save(plugin: TaskExtension): void {
save(plugin: TaskExtension): Promise<boolean> {
const id = this.sessionId();
if (!id || !this.data()?.editable || this.saving()) return;
this.saving.set(plugin.plugin_id);
const generation = this.generation;
this.notice.set('');
this.service.save(id, plugin.plugin_id, this.drafts[plugin.plugin_id]).subscribe({
next: data => {
this.service.discard(id, plugin.plugin_id);
if (id !== this.sessionId() || generation !== this.generation) return;
for (const item of data.plugins) this.drafts[item.plugin_id] = this.service.draft(id, item);
if (!id || !this.data()?.editable) return Promise.resolve(false);
return this.saveFor(id, plugin.plugin_id, this.drafts[plugin.plugin_id]);
}
private async saveFor(id: string, pluginId: string, draft: ExtensionDraft): Promise<boolean> {
const key = `${id}/${pluginId}`;
this.cancelScheduled(key);
const pending = this.inFlight.get(key);
if (pending) {
if (!await pending) return false;
return draft.dirty ? this.saveFor(id, pluginId, draft) : true;
}
if (!draft.dirty) return true;
const submitted = { ...draft.values }, generation = this.generation;
const visible = () => !this.destroyRef.destroyed && id === this.sessionId() && generation === this.generation;
this.savingKeys.update(keys => [...keys, key]);
const request = firstValueFrom(this.service.save(id, pluginId, draft)).then(data => {
const saved = data.plugins.find(plugin => plugin.plugin_id === pluginId);
if (!saved) throw new Error('未收到保存回执');
draft.revision = saved.revision;
draft.dirty = JSON.stringify(draft.values) !== JSON.stringify(submitted);
draft.requestId = undefined;
if (!draft.dirty) draft.values = { ...saved.values };
if (visible()) {
this.data.set(data);
this.saving.set(null);
this.notice.set('人工评审已保存,下次导出将包含最新内容');
},
error: error => {
if (id !== this.sessionId() || generation !== this.generation) return;
this.saving.set(null);
this.notice.set(typeof error.error?.detail === 'string' ? error.error.detail : '保存失败,已保留输入,请重试');
this.notice.set(draft.dirty ? '正在保存后续修改…' : '人工评审已自动保存');
}
return true;
}).catch(error => {
this.cancelScheduled(key);
if (visible()) this.notice.set(typeof error.error?.detail === 'string'
? `保存失败:${error.error.detail};输入已保留`
: '保存失败,输入已保留,请点击保存评审重试');
return false;
}).finally(() => {
this.inFlight.delete(key);
this.savingKeys.update(keys => keys.filter(item => item !== key));
});
this.inFlight.set(key, request);
const succeeded = await request;
return succeeded && draft.dirty ? this.saveFor(id, pluginId, draft) : succeeded;
}
}
@@ -6,7 +6,7 @@ export interface ExtensionField {
options: string[]; max_length: number; report: boolean;
}
export interface ExtensionPanel {
id: string; title: string; side: 'left' | 'right'; kind: 'initial_prompt' | 'form';
id: string; title: string; side: 'left' | 'right'; kind: 'initial_prompt' | 'original_case' | 'form';
width: { preferred: number; minimum: number; maximum: number };
}
export interface TaskExtension {
@@ -17,6 +17,9 @@ export interface TaskExtension {
}
export interface TaskExtensions {
session_id: string; initial_prompt: string; editable: boolean; plugins: TaskExtension[];
case_title?: string;
original_case?: { preconditions: string; steps: string; expected: string } | null;
navigation?: { scope: 'batch'; position: number; total: number; previous: string | null; next: string | null } | null;
}
export interface ExtensionDraft {
values: Record<string, string>; revision: number; dirty: boolean; requestId?: string;
@@ -32,7 +35,7 @@ export class TaskExtensionsService {
draft(sessionId: string, plugin: TaskExtension): ExtensionDraft {
const key = `${sessionId}/${plugin.plugin_id}`;
let draft = this.drafts.get(key);
if (!draft?.dirty) {
if (!draft || (!draft.dirty && plugin.revision >= draft.revision)) {
draft = { values: { ...plugin.values }, revision: plugin.revision, dirty: false };
this.drafts.set(key, draft);
}
+1 -1
View File
@@ -781,7 +781,7 @@ class FlashRunner:
action_dict["normalized_start_coordinates"] = norm_start
action_dict["normalized_end_coordinates"] = norm_end
action_dict.update(metadata.get("target_semantics") or {})
for key in ("input_verification", "execution", "side_effect"):
for key in ("input_verification", "execution", "side_effect", "source"):
if key in metadata:
action_dict[key] = metadata[key]
if metadata.get("plugin_id"):
+1
View File
@@ -412,6 +412,7 @@ class VisualStepSummarizer(StepMemoryService):
engine,
response,
source=f"lens:visual_transition:{self._model_name}",
model=self._model_name,
update_last_prompt=False,
)
+13 -2
View File
@@ -303,8 +303,19 @@ class PlannerNode:
screen = await UnifiedMobileController(self.ctx).get_screen_data()
human_message_content.append({"type": "text", "text": screen.observation_text})
plugins = getattr(self.ctx, "plugins", None)
if plugins:
human_message_content.append({"type": "text", "text": plugins.prompt()})
plugin_catalog = plugins.prompt() if plugins else ""
if plugin_catalog:
human_message_content.append(
{
"type": "text",
"text": "规划与执行的工具分工:规划器只使用本阶段绑定的笔记工具制定计划;"
"下列插件清单描述执行器获授权的能力。执行器可以调用 plugin_load 加载插件。"
"规划器当前工具列表里没有 plugin_load 或设备动作,不表示执行器缺少这些能力。"
"将需要的插件、待观察的目标和验收条件写入计划,由执行器根据实际加载与操作回执"
"确认可用性;未取得回执时保留待确认,不预先记录为插件不可用或要求用户补授权。",
}
)
human_message_content.append({"type": "text", "text": plugin_catalog})
if screenshot_b64:
human_message_content.append(
{
@@ -92,6 +92,11 @@ async def exec_action(
return False, str(e)
try:
from artemis.data_engine.evidence_sources import android_action_source
source = android_action_source(getattr(getattr(ctx, "device", None), "device_id", None))
if source:
action_item["source"] = source
res = await session.call(tool_name, wire_args)
except Exception as e:
action_item.update(execution="unknown", side_effect="possible")
+2
View File
@@ -109,6 +109,8 @@ class Settings(BaseSettings):
OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
# Environment only; deliberately absent from the saved UI/task configuration.
OPENAI_USE_SYSTEM_PROXY: bool = False
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600)
+98 -4
View File
@@ -8,6 +8,58 @@ import json
SOURCE_KINDS = {"android", "computer", "terminal", "api", "file", "other"}
def android_action_source(device_id: object) -> dict | None:
"""Stamp a native action at dispatch, using its actual selected device."""
if not isinstance(device_id, str) or not device_id or device_id == "computer:local":
return None
return {"kind": "android", "target_id": device_id, "label": "安卓屏幕"}
def _android_screenshot_records(step: dict) -> list[dict]:
"""Project explicit capture receipts; never infer provenance from tool args."""
records = []
seen = set()
for tool in step.get("generic_tools") or []:
if not isinstance(tool, dict) or (
tool.get("type"),
tool.get("name"),
tool.get("status"),
) != ("tool", "android_file", "success"):
continue
payload = tool.get("payload")
receipt = payload.get("result") if isinstance(payload, dict) else None
if not isinstance(receipt, dict) or (
receipt.get("ok") is not True
or receipt.get("verified") is not True
or receipt.get("action") != "screenshot"
):
continue
serial, path, digest = (
receipt.get("device_serial"),
receipt.get("trace_path"),
receipt.get("sha256"),
)
if not all(isinstance(value, str) and value for value in (serial, path, digest)):
continue
captured_at = receipt.get("captured_at")
captured_at = captured_at if isinstance(captured_at, str) else None
key = (serial, path, digest, captured_at)
if key in seen:
# Nested tool decorators can record the same completed capture twice.
continue
seen.add(key)
records.append(
{
"source": {"kind": "android", "target_id": serial, "label": "安卓屏幕"},
"trace_id": tool.get("trace_id"),
"artifact_path": path,
"sha256": digest,
"captured_at": captured_at,
}
)
return records
def _step_reference(step: dict) -> dict:
value = step.get("step_id")
return {
@@ -16,6 +68,19 @@ def _step_reference(step: dict) -> dict:
}
def _represented_capture(action: dict, captures: list[dict]) -> bool:
if action.get("action") != "android_file":
return False
args = action.get("args") or {}
if not isinstance(args, dict) or args.get("action") != "screenshot":
return False
filename = args.get("filename")
return any(
capture["artifact_path"].replace("\\", "/").rsplit("/", 1)[-1] == filename
for capture in captures
)
def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
sources = {}
unattributed = []
@@ -23,7 +88,23 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
result = step.get("last_execution_result")
execution = result.get("execution") if isinstance(result, dict) else None
records = execution if isinstance(execution, list) else step.get("action_taken")
records = records if isinstance(records, list) else [records]
records = records if isinstance(records, list) else [records] if records else []
captures = _android_screenshot_records(step)
records = [
record
for record in records
if not isinstance(record, dict)
or (
record.get("action")
not in {"save_note", "read_note", "list_notes", "report_task_status"}
and not _represented_capture(record, captures)
)
]
records = [*records, *captures]
if not records:
# A planning or completion turn with no recorded action is not a
# missing action source. Screenshots alone do not supply provenance.
continue
found = False
missing = False
for record in records:
@@ -39,6 +120,7 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
continue
found = True
plugin = str(record.get("plugin_id") or "")[:100]
label = str(source.get("label") or "")[:500]
key = (plugin, kind, target[:256])
if key not in sources:
sources[key] = {
@@ -46,18 +128,30 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
"kind": kind,
"plugin_id": plugin,
"target_id": target[:256],
"label": str(source.get("label") or "")[:500],
"label": label,
"category": "interface" if kind in {"android", "computer"} else "auxiliary",
"references": [],
"reference_count": 0,
}
reference = _step_reference(step)
for field in ("call_id", "source_id"):
for field in (
"call_id",
"source_id",
"trace_id",
"artifact_path",
"sha256",
"captured_at",
):
if isinstance(record.get(field), str) and record[field]:
reference[field] = record[field][:256]
reference[field] = record[field][: 2048 if field == "artifact_path" else 256]
if isinstance(record.get("read_only"), bool):
reference["read_only"] = record["read_only"]
entry = sources[key]
if not entry.get("label_varies") and entry["label"] != label:
# The same window can navigate between tabs/pages. Its first
# title is not a valid label for all later evidence.
entry["label"] = ""
entry["label_varies"] = True
entry["reference_count"] += 1
if len(entry["references"]) < 8:
entry["references"].append(reference)
+15
View File
@@ -0,0 +1,15 @@
"""Environment-only network policy shared by model calls and credential checks."""
from typing import Any
import httpx
from artemis.config.settings import settings
def model_http_options() -> dict[str, Any]:
return {
"trust_env": settings.OPENAI_USE_SYSTEM_PROXY,
# Proxy opt-out must not disable configured CA certificates or TLS checks.
"verify": httpx.create_ssl_context(trust_env=True),
}
+9 -1
View File
@@ -62,6 +62,7 @@ class ModelEndpoint(BaseModel):
self.reasoning_effort,
self.enable_grounding,
self.api_base or settings.OPENAI_BASE_URL,
settings.OPENAI_USE_SYSTEM_PROXY,
hashlib.sha256(key.encode()).hexdigest(),
)
@@ -87,9 +88,10 @@ class ModelFactory:
)
from langchain_openai import ChatOpenAI
from openai import DefaultAsyncHttpxClient
from openai import DefaultAsyncHttpxClient, DefaultHttpxClient
from artemis.llm.diagnostics import observe_request_headers, observe_response_headers
from artemis.llm.http import model_http_options
api_key = endpoint.api_key or (
settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None
@@ -112,8 +114,14 @@ class ModelFactory:
"use_responses_api": True,
"output_version": "responses/v1",
"store": False,
# The explicit clients own proxy policy, including the synchronous path.
"openai_proxy": "",
"http_client": DefaultHttpxClient(
timeout=endpoint.timeout_seconds, **model_http_options()
),
"http_async_client": DefaultAsyncHttpxClient(
timeout=endpoint.timeout_seconds,
**model_http_options(),
event_hooks={
"request": [observe_request_headers],
"response": [observe_response_headers],
+9
View File
@@ -280,6 +280,11 @@ class McpActionExecutor:
) -> ToolExecutionResult:
span = TraceSpan(name=raw_name, trace_type="action", ctx=self.ctx)
span.payload = {"args": args}
from artemis.data_engine.evidence_sources import android_action_source
source = android_action_source(
getattr(getattr(self.ctx, "device", None), "device_id", None)
)
message = ""
res: ActionResult | None = None
@@ -291,6 +296,7 @@ class McpActionExecutor:
observation_settled: bool | None = None
input_verification: str | None = None
expected_input: str | None = None
dispatched = False
with span:
try:
session = await self._session_or_start()
@@ -315,6 +321,7 @@ class McpActionExecutor:
or observation_anchor(state, index_elements)
)
extension = raw_name not in (REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
dispatched = True
if extension:
raw_result = await session.call_raw(wire_name, wire_args)
message = next(
@@ -386,6 +393,7 @@ class McpActionExecutor:
"post_image_name": post_image_name,
"has_xml": bool(xml_list),
"status": status,
**({"source": source} if source and dispatched else {}),
}
if status == "error":
span.status = "failed"
@@ -400,6 +408,7 @@ class McpActionExecutor:
screenshot_path=shot_path,
ui_elements_text=xml_list,
metadata={
**({"source": source} if source and dispatched else {}),
"code": res.code.value,
"normalized_coordinates": res.normalized_coordinates,
"target_semantics": target_semantics,
+1
View File
@@ -532,6 +532,7 @@ class StepCapsuleLens(StepLens):
engine,
response,
source=f"lens:step_capsule:{model_label}",
model=model_label if model_label != "fallback" else None,
update_last_prompt=False,
)
except Exception as exc:
+1 -1
View File
@@ -47,7 +47,7 @@ class WorkspacePanel(WorkspaceModel):
id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,47}$")
title: str = Field(min_length=1, max_length=100)
side: Literal["left", "right"]
kind: Literal["initial_prompt", "form"]
kind: Literal["initial_prompt", "original_case", "form"]
width: PanelWidth = Field(default_factory=PanelWidth)
+13 -8
View File
@@ -209,7 +209,10 @@ class Agent:
raise ValueError("任务目标必须是 android、computer 或 mixed")
self._execution_target = execution_target
if execution_target == "computer":
from artemis.platform import platform as pal_platform
self._device_context = DeviceContext(
host_platform=pal_platform.os_type.name,
device_id="computer:local",
mobile_platform=DevicePlatform.COMPUTER,
device_width=0,
@@ -794,7 +797,7 @@ class Agent:
status = flash_result.get("status")
if status == "completed":
logger.info(f"✅ Automation '{task_name}' is success ✅")
logger.info(f"Automation '{task_name}' execution completed.")
await task.finalize(content=output, state=last_state_snapshot)
if context.data_engine:
context.data_engine.end_session("completed")
@@ -909,7 +912,7 @@ class Agent:
" see the test summary."
)
else:
logger.info(f"✅ Automation '{task_name}' is success ✅")
logger.info(f"Automation '{task_name}' execution completed.")
await task.finalize(content=output, state=last_state_snapshot)
if context.data_engine:
context.data_engine.end_session("completed")
@@ -1687,13 +1690,15 @@ def attach_test_summary(output, run_outcome: dict | None):
def resolve_trace_suffix(task_status: str, run_outcome: dict | None) -> str:
"""Trace naming: assertion failures must be distinguishable from _PASS."""
"""Keep execution and the independently verified test verdict distinct."""
if task_status != "completed":
return "_FAIL"
tests = (run_outcome or {}).get("tests") or {}
if (
int(tests.get("failed", 0)) > 0
or ((run_outcome or {}).get("test_result") or {}).get("status") == "failed"
):
verdict = ((run_outcome or {}).get("test_result") or {}).get("status")
if verdict == "blocked":
return "_BLOCKED"
if verdict == "inconclusive":
return "_UNVERIFIED"
if verdict == "failed" or int(tests.get("failed", 0)) > 0:
return "_TESTFAIL"
return "_PASS"
return "_PASS" if verdict == "passed" else "_UNVERIFIED"
+6 -1
View File
@@ -686,7 +686,12 @@ class RobustChatModelWrapper:
engine = self.ctx.data_engine if self.ctx else None
if engine is None:
engine = _get_current_data_engine()
record_llm_usage(engine, response, source=self._endpoint_key())
record_llm_usage(
engine,
response,
source=self._endpoint_key(),
model=self.endpoint.model_name if self.endpoint else None,
)
def _diagnostics(self, args, kwargs):
prefix = None
+3
View File
@@ -224,6 +224,7 @@ def record_llm_usage(
response: Any,
*,
source: str | None = None,
model: str | None = None,
update_last_prompt: bool = True,
) -> dict | None:
"""Meters one LLM response and records an ``llm_usage`` trace, best-effort.
@@ -251,6 +252,8 @@ def record_llm_usage(
}
if source:
payload["source"] = source
if model:
payload["model"] = model
node = CURRENT_NODE_NAME.get()
if node:
payload["node"] = str(node)
+3 -1
View File
@@ -17,6 +17,7 @@
import httpx
from artemis.config.settings import settings
from artemis.llm.http import model_http_options
from artemis.utils.logger import get_logger
@@ -91,7 +92,8 @@ async def validate_api_key(
try:
async with httpx.AsyncClient(
timeout=timeout if timeout is not None else settings.ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS
timeout=timeout if timeout is not None else settings.ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS,
**(model_http_options() if clean_provider == "openai" else {}),
) as client:
if clean_provider in ("ocr", "vision", "google_vision"):
# Test Google Cloud Vision OCR API
+2
View File
@@ -112,3 +112,5 @@ Flash 跨端任务由模型通过内部 `update_task_progress` 记录短阶段
批次保留原顺序、循环次数和失败停止策略,默认保留各项配置,也可明确选择统一模型和思考强度。探索用例固定收藏版本,首次无复跑配置时默认 Flash;“收藏为重新探索”会创建新的探索会话并沿用原探索配置。
单任务下载全部资料时,未变化的终态报告和资料包会复用;人工复验、结果和资料变化会重新生成。批次沿用已生成的报告包,人工复验后重新打包;旧目录格式的包首次下载时更新。运行中的下载是当前快照。录像分为“安卓”“电脑”“人工示范”目录,索引路径与实际文件一致。打包优先速度,对已压缩的图片、视频和 Office 文件直接存储,保持原画质。
安装并启用任务人工评审 1.1.0 后,批量模板在原七列之后增加“预置条件、操作步骤、预期结果”。三列独立保存原始用例,不追加到 Artemis 提示词;左侧按用例编号和标题展示,右侧可用上一条/下一条评审同批次已结束任务。未启用该插件时仍提供并校验原七列模板。
+2
View File
@@ -1,5 +1,7 @@
# 开发交接:2026-10-19
2026-10-28 发布主线整理为 **ARTEMIS 2.4.0**,配套人工评审插件 **1.1.0**。本轮改动、配置和验证边界见 [2.4.0 发行说明](releases/2.4.0.md);下方记录为各阶段历史快照。
2026-10-28 配套升级 OpenAI/LangChain 与安全依赖,审计范围、版本、验证及容器构建限制见[依赖安全更新记录](plans/2026-10-28-dependency-security.md)。
2026-10-28 会话已接续至 **ARTEMIS 2.3.0**,最新提交、插件发布状态、产品约定和待验证范围见[本次交接记录](plans/2026-10-28-session-handoff.md)及[2.3.0 发行说明](releases/2.3.0.md)。以下各日期内容保留为历史快照,不代表当前待办。
+39
View File
@@ -0,0 +1,39 @@
# ARTEMIS 2.4.0
本版改进批量用例人工评审与执行详情阅读,并包含 OpenAI SDK 适配、安全依赖更新、Windows 资源锁和报告证据修复。配套「任务人工评审」独立发布 **1.1.0**。
## 人工评审与原始用例
- 安装并启用人工评审 1.1.0 后,批量模板在原七列后增加「预置条件、操作步骤、预期结果」,仍接受七列旧模板。未启用插件时保持原有七列模板和校验;新增内容不进入 AI 提示词。
- 左侧标题为用例编号与用例标题,下面依次展示三项原始内容;历史任务未保存的内容显示缺失,不用 AI 提示词替代。
- 「AI执行准确性」改为「用例执行准确性」,保留字段 ID 和既有评审;备注框缩短,可自行拉高。
- 「上一条/下一条」放在保存评审下方。同批次按已结束任务顺序切换,独立任务使用当前历史列表。
- 选择结果立即保存,备注停止输入 600 毫秒后保存,切换前也先保存。失败时保留输入和当前用例,允许手动重试;连续修改不会被较早回执覆盖。打开任务不会自行填写或保存评审。
- Flash、Pro、探索以及安卓、电脑、跨端任务均可显示面板,步骤与视频视图均支持。
## 执行详情与报告
- Payload、原始结果及超长、多行调试字段默认折叠;展开后限高滚动,全文可读,不截断保存的证据。
- 修复正式验收通过但没有单独输出正文时出现「没有保存最终结论」的误导提示,报告按有效结论生成说明。
- 安卓截图的真实保存回执进入结构化来源汇总,保留路径与哈希;窗口枚举没有单一目标时说明原因,不误报截图丢失。
- 电脑任务记录真实宿主平台;来源窗口标题变化时提示查看原始步骤。结果目录区分通过、不通过、阻塞与未验证。
- 历史模型和思考强度优先读取任务自身诊断,用量统计保留实际请求模型,减少历史条目被当前配置误标;修复步骤、队列与录屏收尾相关读取问题。
## 模型、依赖与运行稳定性
- OpenAI Python 更新为 3.40.0,配套 LangChain 1.4.7、langchain-core 1.7.0、langchain-openai 1.7.3;httpx 保持兼容的 0.28.1。Python 与前端安全依赖更新写入锁文件。
- 新增仅环境配置 `OPENAI_USE_SYSTEM_PROXY=false`,模型请求默认不采用系统代理。需要代理时在 `.env` 设为 `true` 并重启;同步、异步请求和 OpenAI 连接检查一致,TLS 与自定义 CA 校验继续保留。
- 修复 Windows 临时目录 awake 锁访问失败及所有权恢复,检查同类资源锁和运行时收尾。持久权限错误仍保留实际原因,不跳过互斥保护。
- 规划器明确区分规划阶段与执行器的插件能力,避免因本阶段没有动作工具而误判插件不可用;批次模型失败保留明确诊断。
## 验证范围
本轮开发默认后端测试 **3263 通过、2 跳过、8 按标记排除**,前端 **347 项通过**;生产构建、Ruff 格式与静态检查、质量基线和核心类型检查通过。Windows 浏览器隔离夹具覆盖深浅色、宽窄布局、视频视图面板、自动保存与切换,以及长 Payload 折叠。未写入真实任务人工评审。
此前用户重启后的实际复测中,5 项正式用例通过 AI 验收,另有 1 项电脑路径探索通过,覆盖安卓、电脑与跨端;该样本没有发现 awake 锁访问失败。此结果不等于重跑原 192 项业务批次,未完成大批次压力及 macOS/Linux 实测。本次发版不构建 Docker,不重启用户服务。
## 升级与下载
等待当前任务结束后运行 `update.bat`(国内镜像用 `update-cn.bat`),完成后运行 `start.bat` / `start-cn.bat` 并刷新网页。需要人工评审新功能时,再从插件市场升级并启用 [任务人工评审 1.1.0](https://git.playones.com/chenmingxuan/Artemis-Plugins/releases/tag/task-review-v1.1.0)。插件要求 ARTEMIS 2.4.0+;升级主程序不会自动升级或授权已安装插件。
源码附件为 `artemis-2.4.0-source.zip` 和 `SHA256SUMS.txt`,保留浅层 Git 更新能力,不包含用户配置、凭据、历史任务、依赖目录或预构建网页。工作区扩展接口见 [插件文档](../workspace-plugins.md)。
+13 -9
View File
@@ -1,6 +1,6 @@
# 工作区插件:侧栏、人工字段与报告
工作区扩展使用 `plugin.json` 的 `schema_version: 3` 和 `workspace` 声明。旧版 1/2 执行插件继续兼容。纯工作区插件无需 MCP、Skill、模型、设备或任务资源,不出现在任务执行插件选择器中;安装后手动启用即可在历史任务上使用。此接口从 ARTEMIS 2.3.0 起提供;插件必须声明 `artemis_version: ">=2.3.0,<3.0.0"`,2.2.x 不支持。
工作区扩展使用 `plugin.json` 的 `schema_version: 3` 和 `workspace` 声明。旧版 1/2 执行插件继续兼容。纯工作区插件无需 MCP、Skill、模型、设备或任务资源,不出现在任务执行插件选择器中;安装后手动启用即可在历史任务上使用。此接口从 ARTEMIS 2.3.0 起提供;插件必须声明 `artemis_version: ">=2.3.0,<3.0.0"`,2.2.x 不支持。`original_case` 面板从 2.4.0 起提供,使用它的插件须声明 `>=2.4.0,<3.0.0`。
## 声明接口
@@ -14,31 +14,35 @@
"id": "review-example",
"name": "评审扩展示例",
"version": "1.0.0",
"artemis_version": ">=2.3.0,<3.0.0",
"artemis_version": ">=2.4.0,<3.0.0",
"platforms": ["win32", "linux", "darwin"],
"targets": [],
"capabilities": {"kind": "other", "tools": {}},
"workspace": {
"panels": [
{"id":"requirements","title":"任务需求","side":"left","kind":"initial_prompt","width":{"preferred":340,"minimum":240,"maximum":480}},
{"id":"requirements","title":"原始用例","side":"left","kind":"original_case","width":{"preferred":340,"minimum":240,"maximum":480}},
{"id":"review","title":"人工评审","side":"right","kind":"form","width":{"preferred":360,"minimum":260,"maximum":520}}
],
"fields": [
{"id":"execution_accuracy","label":"AI执行准确性","kind":"choice","options":["通过","失败"]},
{"id":"execution_note","label":"AI执行准确性备注","kind":"text","max_length":2000}
{"id":"execution_accuracy","label":"用例执行准确性","kind":"choice","options":["通过","失败"]},
{"id":"execution_note","label":"用例执行准确性备注","kind":"text","max_length":2000}
]
}
}
```
- `panels`:最多两个,`id` 和 `kind` 各自唯一;`side` 为 left/right。`initial_prompt` 展示该次运行最初收到的任务需求提示词,复用宿主 Markdown 渲染及 HTML 清理;不展示模型拆解步骤或后续指导。`form` 渲染本插件声明的字段。
- `width`:CSS 像素,满足 minimum ≤ preferred ≤ maximum。minimum 范围 160–800,preferred 200–1000,maximum 200–1200。宿主优先采用首选宽度,并根据播放器、窗口空间调整;窄屏可低于 minimum 防止溢出。开关位于回放标题栏,以左右方向表示展开与收起。普通浮窗、影院均支持,默认收起;宽屏左右独立展开,窄屏一次展开一侧;单侧也始终位于回放左侧或右侧,不切换为上下排列。
- `panels`:最多两个,`id` 和 `kind` 各自唯一;`side` 为 left/right。`initial_prompt` 展示该次运行最初收到的任务需求提示词,复用宿主 Markdown 渲染及 HTML 清理;不展示模型拆解步骤或后续指导。`original_case` 以用例编号与标题为标题,依次展示导入表中的预置条件、操作步骤、预期结果;缺失时明确提示,不从提示词推测。`form` 渲染本插件声明的字段。
- `width`:CSS 像素,满足 minimum ≤ preferred ≤ maximum。minimum 范围 160–800,preferred 200–1000,maximum 200–1200。宿主优先采用首选宽度,并根据播放器、窗口空间调整;窄屏可低于 minimum 防止溢出。开关位于回放标题栏,以左右方向表示展开与收起。Flash/Pro/探索以及安卓、电脑、跨端任务的步骤、视频或实时视图均可使用;普通浮窗、影院均支持,默认收起;宽屏左右独立展开,窄屏一次展开一侧;单侧也始终位于回放左侧或右侧,不切换为上下排列。
- `fields`:最多 20 个,`id` 唯一;`kind` 为 choice/text。choice 声明非空、不重复的 options,显示为互斥分段按钮,再次点击已选项可清空;text 使用多行输入。description 可选。max_length 默认 2000、最高 10000;report 默认 true。所有字段均选填,空字符串表示未填写,用户可清空。
- 两项人工评审的标签和说明由插件提供;宿主不推断结果、不调用模型、不覆盖执行状态、AI 原判或既有人工复验。
## 任务数据接口
`GET /api/task-extensions/{session_id}` 返回 `session_id`、`initial_prompt`、`editable` 和已安装且启用的插件贡献列表。每项包含 plugin_id/name/version/workspace/revision/values/history。无插件时列表为空。
`GET /api/task-extensions/{session_id}` 返回 `session_id`、`initial_prompt`、`case_title`、`original_case`、`navigation`、`editable` 和已安装且启用的插件贡献列表。每项包含 plugin_id/name/version/workspace/revision/values/history。无插件时列表为空。
`original_case` 为 `{preconditions, steps, expected}` 或 null。只有安装并启用声明 `original_case` 面板的插件时,批量模板才附加“预置条件、操作步骤、预期结果”三列并接受十列表格;否则仍只提供和接受原七列。三列不进入模型提示词。启用时也兼容七列表格,历史缺失字段不补写。
批次任务的 `navigation` 含 scope=batch、position、total、previous、next,按执行顺序在同批次已结束任务之间切换;独立任务使用当前历史列表。上一条/下一条位于保存评审按钮下方。用户选择结果后立即自动保存,备注停止输入 600 毫秒后保存;连续编辑按修订串行写入,旧回执不覆盖新输入。切换前先保存,失败或冲突时留在当前任务并保留输入;手动保存可重试,使用原 request_id 防止重复。切换保留面板展开状态。打开任务或读取已有记录不触发写入。
`PUT /api/task-extensions/{session_id}/{plugin_id}` 接收:
@@ -58,7 +62,7 @@ values 是本次提交的字段;未提交的字段保持原值。保存仅允
## 独立人工评审插件
插件源代码位于独立 Artemis-Plugins 仓库的 `plugins/task-review/`,包名 task-review,版本 1.0.0,可从内置插件市场安装。它声明需求提示词面板,以及「AI执行准确性」「测试结果正确性」两个 通过/失败 字段和各自可选备注。
插件源代码位于独立 Artemis-Plugins 仓库的 `plugins/task-review/`,包名 task-review,1.1.0 要求宿主 2.4.0,可从插件市场安装,或通过高级接入导入发行 ZIP。它声明原始用例面板,以及「用例执行准确性」「测试结果正确性」两个 通过/失败 字段和各自可选备注。
在已更新宿主的环境,用 `uv run python scripts/check_plugin.py <插件目录>` 检查清单,再在「插件 → 高级接入」导入目录或 ZIP。安装、启用、填写、清空、导出、禁用、卸载和重新安装均应通过确定性测试;只检查清单不代表界面和导出已验证。
+1 -1
View File
@@ -14,7 +14,7 @@
[project]
name = "artemis"
version = "2.3.0"
version = "2.4.0"
description = "Autonomous AI Assistant for Mobile Automation"
authors = [
@@ -1,6 +1,6 @@
# 工作区插件:侧栏、人工字段与报告
工作区扩展使用 `plugin.json` 的 `schema_version: 3` 和 `workspace` 声明。旧版 1/2 执行插件继续兼容。纯工作区插件无需 MCP、Skill、模型、设备或任务资源,不出现在任务执行插件选择器中;安装后手动启用即可在历史任务上使用。此接口从 ARTEMIS 2.3.0 起提供;插件必须声明 `artemis_version: ">=2.3.0,<3.0.0"`,2.2.x 不支持。
工作区扩展使用 `plugin.json` 的 `schema_version: 3` 和 `workspace` 声明。旧版 1/2 执行插件继续兼容。纯工作区插件无需 MCP、Skill、模型、设备或任务资源,不出现在任务执行插件选择器中;安装后手动启用即可在历史任务上使用。此接口从 ARTEMIS 2.3.0 起提供;插件必须声明 `artemis_version: ">=2.3.0,<3.0.0"`,2.2.x 不支持。`original_case` 面板从 2.4.0 起提供,使用它的插件须声明 `>=2.4.0,<3.0.0`。
## 声明接口
@@ -14,31 +14,35 @@
"id": "review-example",
"name": "评审扩展示例",
"version": "1.0.0",
"artemis_version": ">=2.3.0,<3.0.0",
"artemis_version": ">=2.4.0,<3.0.0",
"platforms": ["win32", "linux", "darwin"],
"targets": [],
"capabilities": {"kind": "other", "tools": {}},
"workspace": {
"panels": [
{"id":"requirements","title":"任务需求","side":"left","kind":"initial_prompt","width":{"preferred":340,"minimum":240,"maximum":480}},
{"id":"requirements","title":"原始用例","side":"left","kind":"original_case","width":{"preferred":340,"minimum":240,"maximum":480}},
{"id":"review","title":"人工评审","side":"right","kind":"form","width":{"preferred":360,"minimum":260,"maximum":520}}
],
"fields": [
{"id":"execution_accuracy","label":"AI执行准确性","kind":"choice","options":["通过","失败"]},
{"id":"execution_note","label":"AI执行准确性备注","kind":"text","max_length":2000}
{"id":"execution_accuracy","label":"用例执行准确性","kind":"choice","options":["通过","失败"]},
{"id":"execution_note","label":"用例执行准确性备注","kind":"text","max_length":2000}
]
}
}
```
- `panels`:最多两个,`id` 和 `kind` 各自唯一;`side` 为 left/right。`initial_prompt` 展示该次运行最初收到的任务需求提示词,复用宿主 Markdown 渲染及 HTML 清理;不展示模型拆解步骤或后续指导。`form` 渲染本插件声明的字段。
- `width`:CSS 像素,满足 minimum ≤ preferred ≤ maximum。minimum 范围 160–800,preferred 200–1000,maximum 200–1200。宿主优先采用首选宽度,并根据播放器、窗口空间调整;窄屏可低于 minimum 防止溢出。开关位于回放标题栏,以左右方向表示展开与收起。普通浮窗、影院均支持,默认收起;宽屏左右独立展开,窄屏一次展开一侧;单侧也始终位于回放左侧或右侧,不切换为上下排列。
- `panels`:最多两个,`id` 和 `kind` 各自唯一;`side` 为 left/right。`initial_prompt` 展示该次运行最初收到的任务需求提示词,复用宿主 Markdown 渲染及 HTML 清理;不展示模型拆解步骤或后续指导。`original_case` 以用例编号与标题为标题,依次展示导入表中的预置条件、操作步骤、预期结果;缺失时明确提示,不从提示词推测。`form` 渲染本插件声明的字段。
- `width`:CSS 像素,满足 minimum ≤ preferred ≤ maximum。minimum 范围 160–800,preferred 200–1000,maximum 200–1200。宿主优先采用首选宽度,并根据播放器、窗口空间调整;窄屏可低于 minimum 防止溢出。开关位于回放标题栏,以左右方向表示展开与收起。Flash/Pro/探索以及安卓、电脑、跨端任务的步骤、视频或实时视图均可使用;普通浮窗、影院均支持,默认收起;宽屏左右独立展开,窄屏一次展开一侧;单侧也始终位于回放左侧或右侧,不切换为上下排列。
- `fields`:最多 20 个,`id` 唯一;`kind` 为 choice/text。choice 声明非空、不重复的 options,显示为互斥分段按钮,再次点击已选项可清空;text 使用多行输入。description 可选。max_length 默认 2000、最高 10000;report 默认 true。所有字段均选填,空字符串表示未填写,用户可清空。
- 两项人工评审的标签和说明由插件提供;宿主不推断结果、不调用模型、不覆盖执行状态、AI 原判或既有人工复验。
## 任务数据接口
`GET /api/task-extensions/{session_id}` 返回 `session_id`、`initial_prompt`、`editable` 和已安装且启用的插件贡献列表。每项包含 plugin_id/name/version/workspace/revision/values/history。无插件时列表为空。
`GET /api/task-extensions/{session_id}` 返回 `session_id`、`initial_prompt`、`case_title`、`original_case`、`navigation`、`editable` 和已安装且启用的插件贡献列表。每项包含 plugin_id/name/version/workspace/revision/values/history。无插件时列表为空。
`original_case` 为 `{preconditions, steps, expected}` 或 null。只有安装并启用声明 `original_case` 面板的插件时,批量模板才附加“预置条件、操作步骤、预期结果”三列并接受十列表格;否则仍只提供和接受原七列。三列不进入模型提示词。启用时也兼容七列表格,历史缺失字段不补写。
批次任务的 `navigation` 含 scope=batch、position、total、previous、next,按执行顺序在同批次已结束任务之间切换;独立任务使用当前历史列表。上一条/下一条位于保存评审按钮下方。用户选择结果后立即自动保存,备注停止输入 600 毫秒后保存;连续编辑按修订串行写入,旧回执不覆盖新输入。切换前先保存,失败或冲突时留在当前任务并保留输入;手动保存可重试,使用原 request_id 防止重复。切换保留面板展开状态。打开任务或读取已有记录不触发写入。
`PUT /api/task-extensions/{session_id}/{plugin_id}` 接收:
@@ -58,10 +62,9 @@ values 是本次提交的字段;未提交的字段保持原值。保存仅允
## 独立人工评审插件
插件源代码位于独立 Artemis-Plugins 仓库的 `plugins/task-review/`,包名 task-review,版本 1.0.0,可从内置插件市场安装。它声明需求提示词面板,以及「AI执行准确性」「测试结果正确性」两个 通过/失败 字段和各自可选备注。
在已更新宿主的环境,用 `<ARTEMIS_PYTHON> <本Skill目录>/scripts/check_plugin.py <插件目录>` 检查清单,再在「插件 → 高级接入」导入目录或 ZIP。安装、启用、填写、清空、导出、禁用、卸载和重新安装均应通过确定性测试;只检查清单不代表界面和导出已验证。
插件源代码位于独立 Artemis-Plugins 仓库的 `plugins/task-review/`,包名 task-review,1.1.0 要求宿主 2.4.0,可从插件市场安装,或通过高级接入导入发行 ZIP。它声明原始用例面板,以及「用例执行准确性」「测试结果正确性」两个 通过/失败 字段和各自可选备注。
在已更新宿主的环境,用 `uv run python scripts/check_plugin.py <插件目录>` 检查清单,再在「插件 → 高级接入」导入目录或 ZIP。安装、启用、填写、清空、导出、禁用、卸载和重新安装均应通过确定性测试;只检查清单不代表界面和导出已验证。
## 开发与兼容边界
@@ -908,6 +908,85 @@ def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
assert "未验证".encode() in doc.read("word/document.xml")
@pytest.mark.parametrize("result_status", ["passed", "failed", "blocked", "inconclusive"])
def test_report_without_output_body_uses_effective_verdict_and_evidence(isolated, result_status):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
isolated.save_job({**job, "status": "completed"})
folder = reports.TRACES_PATH / job["session_id"]
folder.mkdir(parents=True)
(folder / "run_outcome.json").write_text(
json.dumps(
{
"task_status": "completed",
"test_result": {
"status": result_status,
"reason": "实际验收依据",
"evidence": ["记录中的证据"],
},
}
),
encoding="utf-8",
)
report = reports.session_report(job["session_id"])
assert report["verdict"] in report["content"]
assert "实际验收依据" in report["content"]
assert "记录中的证据" in report["content"]
assert "未保存独立输出正文" in report["content"]
assert "没有保存最终结论" not in report["content"]
assert report["test_result"]["status"] == result_status
def test_report_without_output_body_keeps_human_review_authoritative(isolated):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
isolated.save_job({**job, "status": "completed"})
folder = reports.TRACES_PATH / job["session_id"]
folder.mkdir(parents=True)
(folder / "run_outcome.json").write_text(
json.dumps(
{
"test_result": {"status": "passed", "reason": "AI 原判通过"},
}
),
encoding="utf-8",
)
isolated.save_case_review(job["session_id"], "failed", "用户确认实际结果不符合预期")
report = reports.session_report(job["session_id"])
assert "人工确认" in report["content"]
assert "用户确认实际结果不符合预期" in report["content"]
assert "AI 原判通过" not in report["content"]
assert report["test_result"]["status"] == "failed"
def test_report_projects_stored_android_receipts_without_rewriting_history(isolated):
from tests.unit.data_engine.test_evidence_sources import android_receipt
sid = isolated.jobs(service.create_batch(payload())["id"])[0]["session_id"]
receipt = android_receipt()["payload"]["result"]
raw_payload = json.dumps({"args": {"action": "screenshot"}, "result": json.dumps(receipt)})
with sqlite3.connect(isolated.path) as conn:
conn.execute(
"INSERT INTO sessions(session_id,initial_goal,start_time,status) VALUES (?,?,?,?)",
(sid, "跨端截图", 1, "completed"),
)
conn.execute(
"INSERT INTO steps(step_id,session_id,step_number) VALUES (?,?,?)", ("s", sid, 1)
)
conn.execute(
"INSERT INTO traces(trace_id,session_id,step_id,type,name,timestamp,status,payload) VALUES (?,?,?,?,?,?,?,?)",
("capture", sid, "s", "tool", "android_file", 2, "success", raw_payload),
)
report = reports.session_report(sid)
source = report["evidence_sources"]["sources"][0]
assert source["kind"] == "android"
assert source["references"][0]["trace_id"] == "capture"
assert report["evidence_sources"]["unattributed_step_count"] == 0
with sqlite3.connect(isolated.path) as conn:
assert (
conn.execute("SELECT payload FROM traces WHERE trace_id='capture'").fetchone()[0]
== raw_payload
)
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"})
@@ -110,6 +110,8 @@ def test_strip_trace_status_suffix_handles_every_terminal_marker():
assert MediaService.strip_trace_status_suffix("web_1_PASS_2026") == "web_1"
assert MediaService.strip_trace_status_suffix("web_1_FAIL_2026") == "web_1"
assert MediaService.strip_trace_status_suffix("web_1_TESTFAIL_2026") == "web_1"
assert MediaService.strip_trace_status_suffix("web_1_BLOCKED_2026") == "web_1"
assert MediaService.strip_trace_status_suffix("web_1_UNVERIFIED_2026") == "web_1"
assert MediaService.strip_trace_status_suffix("web_1") == "web_1"
@@ -44,6 +44,10 @@ async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
repo.get_llm_traces_for_profiles_map.return_value = {
"session-3": ['{"model": "gemini-3.7-pro"}']
}
repo.get_recorded_model_info_map.return_value = {
"session-1": {"id": "gpt-6.1-terra", "provider": "openai"},
"session-2": {"id": "gpt-6-luna", "provider": "openai"},
}
repo.get_agent_trace_names_map.return_value = {}
monkeypatch.setattr(sessions_router, "session_repo", repo, raising=False)
@@ -57,6 +61,10 @@ async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
result = await sessions_router.list_sessions()
assert len(result) == 3
assert [row["model_info"]["id"] for row in result] == ["gpt-6.1-terra", "gpt-6-luna", ""]
repo.get_recorded_model_info_map.assert_called_once_with(
["session-1", "session-2", "session-3"]
)
repo.get_latest_video_recordings_map.assert_called_once_with()
repo.get_llm_traces_for_profiles_map.assert_called_once_with(["session-3"])
repo.get_agent_trace_names_map.assert_called_once_with()
@@ -1,3 +1,4 @@
import json
from unittest.mock import patch
from apps.admin_console.database.connection import db_session
@@ -37,3 +38,31 @@ def test_session_list_harvest_rechecks_worker_liveness(tmp_path):
assert repository.harvest_orphaned_sessions(["external-live"]) == 0
assert repository.get_session_status("external-live") == "running"
def test_recorded_model_metadata_is_per_session_and_prefers_operator(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
for session_id in ("one", "two", "no-record"):
_insert_running_session(db_path, session_id, 1)
with db_session(db_path) as conn:
for trace_id, session_id, timestamp, payload in (
("invalid", "one", 0, "{"),
("planner", "one", 1, json.dumps({"role": "planner", "model": "other-model"})),
(
"operator",
"one",
2,
json.dumps({"role": "operator", "model": "gpt-6.1-terra", "provider": "openai"}),
),
("flash", "two", 1, json.dumps({"role": "FlashRunner", "model": "gpt-6-luna"})),
):
conn.execute(
"INSERT INTO traces(trace_id,session_id,type,timestamp,payload) VALUES (?,?,?,?,?)",
(trace_id, session_id, "llm_diagnostic", timestamp, payload),
)
conn.commit()
assert repository.get_recorded_model_info_map(["one", "two", "no-record"]) == {
"one": {"id": "gpt-6.1-terra", "provider": "openai"},
"two": {"id": "gpt-6-luna", "provider": ""},
}
@@ -99,6 +99,34 @@ def test_usage_flash_runner_counts_as_operator_and_has_no_tuning(tmp_path):
assert usage["run_tuning"] is None
def test_usage_uses_its_recorded_model_and_supports_legacy_endpoint_keys(tmp_path):
db_path = tmp_path / "sessions.db"
repo = SessionRepository(db_path)
_insert_session(db_path, "models", {"profile": "flash"})
_insert_usage(db_path, "models", "new", 1.0, {**_usage(100, 10), "model": "terra"})
_insert_usage(
db_path,
"models",
"legacy",
2.0,
{**_usage(200, 20), "source": "openai:terra:https://example.test:high"},
)
_insert_usage(
db_path, "models", "lens", 3.0, {**_usage(300, 30), "source": "lens:step_capsule:terra"}
)
_insert_usage(db_path, "models", "unknown", 4.0, {**_usage(300, 30), "source": "other"})
_insert_usage(
db_path,
"models",
"fallback",
5.0,
{**_usage(300, 30), "source": "lens:step_capsule:fallback"},
)
models = {item["model"]: item for item in repo.get_session_usage("models")["by_node_model"]}
assert models["terra"]["llm_calls"] == 3
assert models["unknown"]["llm_calls"] == 2
def test_usage_for_unknown_or_empty_session_is_zeroed(tmp_path):
db_path = tmp_path / "sessions.db"
repo = SessionRepository(db_path)
@@ -112,6 +140,26 @@ def test_usage_for_unknown_or_empty_session_is_zeroed(tmp_path):
assert usage["run_tuning"] is None
def test_legacy_usage_preserves_model_colons_and_keeps_models_separate(tmp_path):
db_path = tmp_path / "sessions.db"
repo = SessionRepository(db_path)
_insert_session(db_path, "models", {"profile": "flash"})
sources = {
"team/model:stable": "openai:team/model:stable:https://example.test:8443/v1:high",
"team/model:preview": "openai:team/model:preview:http://[::1]:8080/v1:high",
"unknown": "openai:team/model:preview:unparseable:high",
}
for index, source in enumerate(sources.values()):
_insert_usage(
db_path, "models", str(index), float(index), {**_usage(100, 10), "source": source}
)
groups = {item["model"]: item for item in repo.get_session_usage("models")["by_node_model"]}
assert groups.keys() == sources.keys()
assert all(item["llm_calls"] == 1 for item in groups.values())
assert all(item["prompt_tokens"] == 100 for item in groups.values())
def test_usage_route_is_registered():
from apps.admin_console.routers import sessions
@@ -3,6 +3,30 @@ import json
from apps.admin_console.database.repositories.step_repository import StepRepository
def test_android_capture_receipt_survives_cleaning_without_becoming_image_store_id():
receipt = {
"ok": True,
"action": "screenshot",
"verified": True,
"device_serial": "device-under-test",
"trace_path": "artifacts/android/screen.png",
"sha256": "a" * 64,
"captured_at": "2026-10-28T00:00:00Z",
"content": "large file content must not be copied into the task stream",
}
cleaned = StepRepository()._clean_tool_payload(
{"args": {"action": "screenshot"}, "result": json.dumps(receipt)},
"tool",
"android_file",
)
assert cleaned["result"] == {key: value for key, value in receipt.items() if key != "content"}
assert "post_image_name" not in cleaned
wrapped = StepRepository()._clean_tool_payload(
{"result": json.dumps({"outcome": json.dumps(receipt)})}, "tool", "android_file"
)
assert wrapped["result"] == cleaned["result"]
def test_legacy_pause_log_is_normalized_to_failed_llm_trace():
repository = StepRepository()
trace = {
@@ -57,10 +57,13 @@ def manifest_payload():
}
def install(plugins):
def install(plugins, original_case=False):
package = BytesIO()
manifest = manifest_payload()
if original_case:
manifest["workspace"]["panels"][0]["kind"] = "original_case"
with zipfile.ZipFile(package, "w") as archive:
archive.writestr("plugin.json", json.dumps(manifest_payload()))
archive.writestr("plugin.json", json.dumps(manifest))
install_archive(plugins, package.getvalue())
plugins.set_enabled("review", True)
@@ -105,6 +108,86 @@ def save(client, values, revision=0, request_id=None):
)
def test_original_case_import_requires_enabled_plugin_and_keeps_execution_prompt(context):
from apps.admin_console.services import task_import
store, plugins, client = context
legacy = load_workbook(BytesIO(task_import.template()))
assert legacy["任务"].max_column == 7
legacy["任务"].append(["OLD", "", "", "旧用例", "", "原样执行", ""])
legacy_bytes = BytesIO()
legacy.save(legacy_bytes)
assert "original_case" not in task_import.parse_workbook(legacy_bytes.getvalue())["tasks"][0]
install(plugins, original_case=True)
workbook = load_workbook(BytesIO(task_import.template()))
assert [cell.value for cell in workbook["任务"][1]] == [
*task_import.HEADERS,
*task_import.CASE_HEADERS,
]
workbook["任务"].append(
[
"CASE-001",
"检测项目",
"测试子项",
"测试标题",
"提取的约束",
"只执行此提示词",
"{}",
"原始预置条件",
"1. 第一步\n2. 第二步",
"原始预期结果",
]
)
stream = BytesIO()
workbook.save(stream)
case = task_import.parse_workbook(stream.getvalue())["tasks"][0]
assert task_import.case_goal(case) == "只执行此提示词"
assert case["preconditions"] == "提取的约束"
assert case["original_case"] == {
"preconditions": "原始预置条件",
"steps": "1. 第一步\n2. 第二步",
"expected": "原始预期结果",
}
assert task_import.parse_workbook(legacy_bytes.getvalue())["valid"]
store.save_job({"session_id": "task-one", "status": "completed", "case": case})
result = client.get("/api/task-extensions/task-one").json()
assert "CASE-001" in result["case_title"] and "测试标题" in result["case_title"]
assert result["original_case"] == case["original_case"]
assert result["initial_prompt"] == "只执行此提示词"
assert report_export.session_report("task-one")["original_case"] == case["original_case"]
plugins.set_enabled("review", False)
assert load_workbook(BytesIO(task_import.template()))["任务"].max_column == 7
with pytest.raises(ValueError, match="列名或顺序"):
task_import.parse_workbook(stream.getvalue())
def test_review_navigation_uses_batch_order_and_excludes_unfinished_jobs(context):
store, _, client = context
store.create_batch({"id": "review-batch", "status": "completed"}, [], {})
for position, status in [(3, "completed"), (1, "failed"), (2, "pending")]:
store.save_job(
{
"session_id": f"batch-task-{position}",
"batch_id": "review-batch",
"position": position,
"status": status,
"case": {"name": f"用例{position}", "goal": "测试"},
}
)
first = client.get("/api/task-extensions/batch-task-1").json()["navigation"]
assert first == {
"scope": "batch",
"position": 1,
"total": 2,
"previous": None,
"next": "batch-task-3",
}
last = client.get("/api/task-extensions/batch-task-3").json()["navigation"]
assert last["previous"] == "batch-task-1" and last["next"] is None
def test_workspace_requires_v3_and_valid_bounded_declarations():
for change in (
{"schema_version": 2},
@@ -601,6 +601,45 @@ async def test_status_reports_external_global_owner_without_ipc_connection():
assert result["goal"] == "CLI task: inspect settings"
@pytest.mark.asyncio
@pytest.mark.parametrize("recorded", [True, False])
async def test_status_uses_the_active_sessions_model(recorded):
owner = DeviceLockOwner(
pid=24680,
process_created_at=1234.5,
token="active-owner-token",
device_id="emulator-5554",
description="active task",
acquired_at="2026-08-24T00:00:00+00:00",
session_id="active",
ingress="web",
)
state.queue_items.append(
{"session_id": "active", "status": "running", "runtime_overrides": {"model": "terra"}}
)
with (
patch.object(TaskQueueService, "ensure_worker_running"),
patch(
"apps.admin_console.routers.tasks.DeviceExecutionLock.get_active_owner",
return_value=owner,
),
patch(
"apps.admin_console.routers.tasks.DeviceExecutionLock.get_active_owners",
return_value={},
),
patch("apps.admin_console.routers.tasks.session_repo") as repo,
patch("apps.admin_console.routers.tasks.model_service") as models,
):
repo.get_latest_session.return_value = None
repo.get_session_by_id.return_value = None
repo.get_recorded_model_info_map.return_value = (
{"active": {"id": "terra-recorded", "provider": "openai"}} if recorded else {}
)
models.get_active_model_info.return_value = {"id": "global", "provider": "openai"}
result = await get_status()
assert result["model_info"]["id"] == ("terra-recorded" if recorded else "terra")
@pytest.mark.asyncio
async def test_cancel_task_triggers_next_pending_task():
executed_goals = []
+5 -1
View File
@@ -379,9 +379,13 @@ def test_flash_records_the_executors_target_semantics(mock_context):
runner,
"click",
click_args,
metadata={"target_semantics": {"target_description": "play button"}},
metadata={
"target_semantics": {"target_description": "play button"},
"source": {"kind": "android", "target_id": "selected-phone", "label": "安卓屏幕"},
},
)
assert click["target_description"] == "play button"
assert click["source"]["target_id"] == "selected-phone"
assert "target_text" not in click
assert format_actions_clean(click) == "Tapped 'play button' (self-described) at [320, 399]"
+5
View File
@@ -124,6 +124,11 @@ async def test_planner_knows_computer_plugins_with_mobile_primary_screen(mock_co
content = llm.astream.call_args.args[0][1].content
text = "\n".join(part["text"] for part in content if part["type"] == "text")
assert text.count("电脑插件:cua-driver") == 1
assert "规划器只使用本阶段绑定的笔记工具" in text
assert "不表示执行器缺少这些能力" in text
assert "实际加载与操作回执" in text
bound_names = {tool.name for tool in llm.bind_tools.call_args.args[0]}
assert "plugin_load" not in bound_names
assert f"当前主设备截图({'电脑' if target == 'computer' else '安卓'})" in text
assert "不决定其他步骤的执行平台" in text
+6
View File
@@ -122,6 +122,7 @@ def mock_mcp():
async def test_validator_success(mock_mcp, mock_context, temp_screenshot):
"""Test that ValidatorNode executes actions successfully."""
decisions = json.dumps([{"action": "tap", "coordinates": [105, 205]}])
mock_context.device.device_id = "selected-phone"
state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot)
state.initial_goal = "在真实审批表单上 Approve once,不要普通聊天代答或重复提交。"
@@ -142,6 +143,11 @@ async def test_validator_success(mock_mcp, mock_context, temp_screenshot):
assert len(report["execution"]) == 1
assert report["execution"][0]["action"] == "tap"
assert "attempts" not in report["execution"][0]
assert report["execution"][0]["source"] == {
"kind": "android",
"target_id": "selected-phone",
"label": "安卓屏幕",
}
# The Operator's tap verb was translated to the canonical click tool.
assert len(mock_mcp.calls_for("click")) == 1
@@ -1,5 +1,7 @@
from copy import deepcopy
import pytest
from artemis.data_engine.evidence_sources import recorded_sources
@@ -59,6 +61,21 @@ def test_sources_remain_separate_and_references_use_host_ids():
assert value["sources"][1]["category"] == "interface"
def test_window_label_change_does_not_attribute_all_steps_to_the_first_title():
first = record("computer", "window", call_id="c1")
second = record("computer", "window", call_id="c2")
first["source"]["label"] = "first page"
second["source"]["label"] = "second page"
result = recorded_sources(
[{"step_id": "s1", "action_taken": first}, {"step_id": "s2", "action_taken": second}]
)
source = result["sources"][0]
assert result["total_sources"] == 1
assert source["reference_count"] == 2
assert source["label"] == ""
assert source["label_varies"] is True
def test_model_arguments_and_unsourced_images_do_not_fabricate_source_metadata():
value = recorded_sources(
[
@@ -103,3 +120,98 @@ def test_partial_source_metadata_does_not_hide_unknown_actions_in_the_same_step(
)
assert value["total_sources"] == 1
assert value["unattributed_step_count"] == 1
def android_receipt():
return {
"trace_id": "capture-trace",
"type": "tool",
"name": "android_file",
"status": "success",
"payload": {
"result": {
"ok": True,
"action": "screenshot",
"verified": True,
"device_serial": "test-phone",
"trace_path": "artifacts/android/home.png",
"sha256": "a" * 64,
"captured_at": "2026-10-28T00:00:00Z",
}
},
}
def test_android_receipts_join_computer_sources_and_deduplicate_nested_tool_traces():
tool = android_receipt()
duplicate = {**deepcopy(tool), "trace_id": "outer-wrapper"}
steps = [
{
"step_id": "mixed",
"action_taken": record("computer", "window"),
"generic_tools": [tool, duplicate],
},
{"step_id": "final", "generic_tools": [tool]},
{"step_id": "empty", "action_taken": None, "generic_tools": []},
]
before = deepcopy(steps)
result = recorded_sources(steps)
sources = {source["kind"]: source for source in result["sources"]}
assert set(sources) == {"android", "computer"}
assert sources["android"]["target_id"] == "test-phone"
assert sources["android"]["reference_count"] == 2
assert sources["android"]["references"][0]["trace_id"] == "capture-trace"
assert sources["android"]["references"][0]["artifact_path"] == "artifacts/android/home.png"
assert result["unattributed_step_count"] == 0
assert steps == before
@pytest.mark.parametrize("invalid", ["failed", "unverified", "different_tool", "arguments_only"])
def test_only_successful_android_capture_receipts_supply_provenance(invalid):
tool = android_receipt()
if invalid == "failed":
tool["status"] = "failed"
elif invalid == "unverified":
tool["payload"]["result"]["verified"] = False
elif invalid == "different_tool":
tool["name"] = "read_note"
else:
tool["payload"] = {"args": tool["payload"]["result"]}
value = recorded_sources(
[{"step_id": "s", "action_taken": {"action": "tap"}, "generic_tools": [tool]}]
)
assert value["sources"] == []
assert value["unattributed_step_count"] == 1
def test_capture_receipt_does_not_cover_an_unattributed_action_in_the_same_step():
value = recorded_sources(
[{"step_id": "s", "action_taken": {"action": "tap"}, "generic_tools": [android_receipt()]}]
)
assert value["sources"][0]["kind"] == "android"
assert value["unattributed_step_count"] == 1
def test_flash_capture_receipt_replaces_only_its_matching_action_record():
tool = android_receipt()
action = {"action": "android_file", "args": {"action": "screenshot", "filename": "home.png"}}
value = recorded_sources([{"step_id": "s", "action_taken": action, "generic_tools": [tool]}])
assert value["sources"][0]["reference_count"] == 1
assert value["unattributed_step_count"] == 0
action["args"]["filename"] = "missing.png"
value = recorded_sources([{"step_id": "s", "action_taken": action, "generic_tools": [tool]}])
assert value["unattributed_step_count"] == 1
def test_note_and_final_report_actions_do_not_claim_device_evidence_or_missing_sources():
value = recorded_sources(
[
{
"step_id": "note",
"action_taken": {"action": "save_note", "args": {"content": "device claim"}},
},
{"step_id": "final", "action_taken": {"action": "report_task_status"}},
]
)
assert value["sources"] == []
assert value["unattributed_step_count"] == 0
+1 -1
View File
@@ -532,7 +532,7 @@ async def test_assert_failure_yields_failed_tests_but_completed_status(tmp_path)
# SDK wrap-up: trace suffix distinguishes assertion failures from _PASS
assert resolve_trace_suffix("completed", outcome) == "_TESTFAIL"
assert resolve_trace_suffix("completed", {"tests": {"failed": 0}}) == "_PASS"
assert resolve_trace_suffix("completed", {"tests": {"failed": 0}}) == "_UNVERIFIED"
assert resolve_trace_suffix("failed", outcome) == "_FAIL"
# And the return structure surfaces the machine-readable summary
@@ -22,14 +22,40 @@ sequence must be described too. Nothing is inferred, the description never
reaches the wire, and the wire only ever sees coordinates.
"""
from unittest.mock import Mock
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from artemis.mcp.action_executor import McpActionExecutor, _ArgError
from artemis.mcp.action_types import ActionCode, ActionResult, ObserveResult
from artemis.utils.action_target import observation_anchor
@pytest.mark.asyncio
@pytest.mark.parametrize("valid", [True, False])
async def test_native_action_source_is_stamped_only_after_dispatch(valid):
ctx = SimpleNamespace(
device=SimpleNamespace(device_id="selected-phone", device_width=1080, device_height=2400),
data_engine=None,
)
executor = McpActionExecutor(ctx, actuator=Mock())
session = Mock()
session.call = AsyncMock(
return_value=ActionResult(ok=True, code=ActionCode.OK, action="click", message="clicked")
)
executor._session_or_start = AsyncMock(return_value=session)
executor._observe_after_action = AsyncMock(return_value=(ObserveResult(ok=False), None))
args = {"target": [100, 200], "target_description": "button"} if valid else {}
result = await executor._execute_device_action("click", "click", args, "call", _state())
if valid:
assert result.metadata["source"]["target_id"] == "selected-phone"
session.call.assert_awaited_once()
else:
assert "source" not in result.metadata
session.call.assert_not_awaited()
def _make_executor(width=1080, height=2400):
ctx = Mock()
ctx.device.device_width = width
+14
View File
@@ -44,6 +44,20 @@ def test_completed_execution_and_failed_test_are_independent():
assert resolve_trace_suffix("completed", outcome("failed")) == "_TESTFAIL"
@pytest.mark.parametrize(
("status", "suffix"),
[
("passed", "_PASS"),
("failed", "_TESTFAIL"),
("blocked", "_BLOCKED"),
("inconclusive", "_UNVERIFIED"),
],
)
def test_completed_execution_trace_names_use_the_formal_verdict(status, suffix):
assert resolve_trace_suffix("completed", outcome(status)) == suffix
assert resolve_trace_suffix("completed", None) == "_UNVERIFIED"
def test_skipped_review_metadata_survives_reload_without_changing_model_schema(tmp_path):
result = CaseTestResult(
status="inconclusive", reason="执行完成 · 未做 AI 判定", verification_skipped=True
+49
View File
@@ -0,0 +1,49 @@
"""Model HTTP clients consistently opt out of ambient proxies by default."""
import ssl
import pytest
from artemis.config.settings import Settings, settings
from artemis.llm.router import ModelEndpoint, ModelFactory
@pytest.mark.asyncio
@pytest.mark.parametrize("use_proxy", [False, True])
async def test_sync_and_async_clients_follow_env_only_proxy_policy(monkeypatch, use_proxy):
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:9")
monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:9")
monkeypatch.setenv("OPENAI_PROXY", "http://127.0.0.1:9")
monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False)
monkeypatch.setattr(settings, "OPENAI_USE_SYSTEM_PROXY", use_proxy)
model = ModelFactory.create_model(
ModelEndpoint(api_key="test-only", api_base="https://example.invalid/v1")
)
try:
for client in (model.http_client, model.http_async_client):
assert client.trust_env is use_proxy
assert bool([mount for mount in client._mounts.values() if mount]) is use_proxy
assert client._transport._pool._ssl_context.verify_mode == ssl.CERT_REQUIRED
assert model.openai_proxy == ""
finally:
model.http_client.close()
await model.http_async_client.aclose()
def test_proxy_setting_is_env_only_and_part_of_client_cache_identity(monkeypatch):
from apps.admin_console.services.model_settings_service import (
ModelSettingsUpdate,
get_model_settings,
)
monkeypatch.delenv("OPENAI_USE_SYSTEM_PROXY", raising=False)
assert Settings(_env_file=None).OPENAI_USE_SYSTEM_PROXY is False
monkeypatch.setenv("OPENAI_USE_SYSTEM_PROXY", "true")
assert Settings(_env_file=None).OPENAI_USE_SYSTEM_PROXY is True
endpoint = ModelEndpoint(api_key="test-only")
monkeypatch.setattr(settings, "OPENAI_USE_SYSTEM_PROXY", False)
direct = endpoint.cache_key()
monkeypatch.setattr(settings, "OPENAI_USE_SYSTEM_PROXY", True)
assert endpoint.cache_key() != direct
assert not any("proxy" in key.lower() for key in get_model_settings())
assert not any("proxy" in key.lower() for key in ModelSettingsUpdate.model_fields)
+10
View File
@@ -892,6 +892,16 @@ async def test_workspace_computer_discovery_never_queries_adb(monkeypatch):
assert set(routes) == {"/api/status", "/api/plugins"}
@pytest.mark.asyncio
async def test_computer_task_uses_the_actual_host_platform():
from artemis.platform import platform
from artemis.sdk.agent import Agent
agent = Agent()
await agent.init(execution_target="computer")
assert agent._device_context.host_platform == platform.os_type.name
@pytest.mark.asyncio
async def test_sdk_setup_failure_releases_plugin_version_pin(store, monkeypatch):
from artemis.sdk.agent import Agent
+8
View File
@@ -126,6 +126,14 @@ def test_record_llm_usage_accumulates_across_calls_same_session():
assert meter.last_prompt_tokens == 250
def test_record_llm_usage_preserves_the_call_model():
engine = Mock()
engine.current_session_id = uuid4()
engine.current_step_id = None
payload = record_llm_usage(engine, _msg_with_usage(), model="gpt-6.1-terra")
assert payload["model"] == "gpt-6.1-terra"
def test_record_llm_usage_never_raises_and_skips_gracefully():
# No engine
assert record_llm_usage(None, _msg_with_usage()) is None
Generated
+1 -1
View File
@@ -248,7 +248,7 @@ wheels = [
[[package]]
name = "artemis"
version = "2.3.0"
version = "2.4.0"
source = { editable = "." }
dependencies = [
{ name = "adbutils" },