Compare commits

..
6 Commits
100 changed files with 4926 additions and 2998 deletions

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 不叠加重试。
+12 -2
View File
@@ -21,7 +21,7 @@
| 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) |
| 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md |
| 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 |
| 继续历史开发、确认版本或发布 | [迁入时的开发快照](docs/development-handoff.md)、[2.1.0 发行说明](docs/releases/2.1.0.md),再核对实时 Git 状态;`docs/plans/` 保留各自阶段的状态 |
| 继续历史开发、确认版本或发布 | [README 当前发行入口](README.md)、对应发行说明及实时 Git 状态;[开发快照](docs/development-handoff.md)与 `docs/plans/` 仅作为历史线索,不替代当前源码与发布包核验 |
## Quick Commands
@@ -60,7 +60,7 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
## Conventions
- Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档;默认值同步 `config/artemis.jsonc` 与 `artemis/resources/config/artemis.jsonc`。
- 新增可调参数集中进入配置层,按作用域更新环境示例、网页/MCP 入口和文档;明确仅供环境配置的参数不增加前端入口。JSON 默认值同步 `config/artemis.jsonc` 与 `artemis/resources/config/artemis.jsonc`。
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
- 同步 Codex MCP/Skill 时保留已有工具白名单、禁用状态和审批策略;异常时保留原配置并告警,不生成非法 TOML 或阻断网页启动。
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
@@ -75,6 +75,16 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
- 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 ARTEMIS 探索实际页面和操作路径。
- 提交前检查 `git diff --check` 和暂存范围,只暂存本次具体路径;报告实际执行的验证及未完成项,不将未运行的检查写成通过。
## Release Gates
- 主程序、SDK、插件和第三方依赖独立版本化。主程序升版只修改 `pyproject.toml` 的项目版本、`uv.lock` 的 artemis 条目及前端应用的根版本字段,发行文档另行更新。禁止对仓库或整个锁文件全局替换版本字符串。
- 单独审查锁文件差异。第三方条目的版本、下载地址、完整性校验和依赖声明必须相互匹配;主程序升版不应改变这些条目。真正升级依赖时由包管理器生成并核对上游声明;发生 EINTEGRITY 先核验来源,禁止关闭校验或只照错误提示改哈希。
- 发布前必须在隔离目录解压最终 ZIP,使用独立空 npm 缓存完成 `npm ci` 和生产构建,覆盖本次发布的官方/国内安装路径。包内安装或构建输入变化后必须重跑;不得清空用户全局缓存或改动用户运行目录来制造干净环境。
- 记录实际 Node/npm 版本、镜像、源码提交、ZIP SHA256 和验证范围。已有 node_modules、构建指纹、缓存命中或大量单元测试通过,均不能证明全新安装成功;某个 Node 版本通过也不能冒充其他版本实测。测试和构建命令见前端局部指南。
- 发布顺序:固定提交并打包 → 验证最终包 → 推送标签 → 创建草稿并上传包及校验文件 → 回读校验 → 正式发布 → 无认证下载核验。标签必须指向打包提交;推送标签后检查 Release 状态,本仓库曾出现 Gitea 将已有草稿自动发布的情况。失败时修复并重新验证,不提前宣布完成。
- 候选包与正式包目录明确区分。候选包失效后移出可分发路径并标明替代版本,不能留下同名旧 ZIP 供用户误取;对外只给已核验的正式发布链接或准确文件路径。正式版本不覆盖原标签或附件,后续修复发补丁版。
- 安装或市场版本不符时,先核对用户实际 ZIP 的 SHA256、运行源码提交及生效市场地址;不凭界面版本号判断,也不把未经验证的缓存假设当成原因。主程序与插件版本、索引兼容性分别验证。
## Boundaries
- 始终保护用户任务、历史库、环境配置和凭据。不要把个人设备序列号、绝对目录或 API Key 写入源码。
+12
View File
@@ -66,6 +66,10 @@ ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
设备保活是辅助服务。保活所属锁或旧保活清理目录不可访问时记录警告,不使设备连接、恢复或监控退出;未取得保活所属锁时不发送保活动作,也不创建替代锁绕过所属权。执行资源锁、主服务注册锁和队列写锁仍须成功取得。
运行时准备阶段失败记录 `initialization_failed`,批次保留详细原因并暂停未开始项,修复环境后由用户继续,已失败项不自动重跑。模型请求导致的批次暂停单独标记;模型暂停结束后恢复既定调度及失败即停止策略。用户暂停、停止全部任务和服务重启后的恢复暂停均需用户继续,不被模型恢复覆盖。Flash 与 Pro/探索 Operator 的生成失败复用统一备用模型与重试预算;不完整输出不会成为动作,已有动作不因模型切换而重放。
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
Responses 消息交给工具、下游解析器或正式裁定前,必须具有服务端 `completed` 状态;工具参数的原始字符串必须是完整 JSON 对象,不能使用库自动补全的半截参数。流结束或库补出的 `last` 标记不能替代完成状态。缺少终止事件、明确未完成或失败的流丢弃预览,进入现有无效输出恢复策略,最多重新请求一次,配置为零次重试时不重试;恢复保留已有工具证据,不重新提交业务任务。非流式、结构化输出和串接解析器遵守同一检查。
@@ -84,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 与主动探索的验收规则保持不变。
@@ -191,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.2**:批次任务增加人工已审标签,优化收藏、标题与状态标签布局。人工评审插件保持 1.1.0。见 [2.4.2 发行说明](docs/releases/2.4.2.md);安装修复见 [2.4.1](docs/releases/2.4.1.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",
+3 -1
View File
@@ -12,7 +12,7 @@ from apps.admin_console.services.exploration_listing import EXPLORATION_RUNS_CTE
from apps.admin_console.services.automation_store import identifier, store
from apps.admin_console.services import automation_service as service
from apps.admin_console.services import batch_archive as archive_service
from apps.admin_console.services import report_export, task_import
from apps.admin_console.services import report_export, task_extensions, task_import
from artemis.config.settings import settings
from apps.admin_console.services.case_review import read_reviewed_result
@@ -131,7 +131,9 @@ def batch_by_request(request_id: str):
@router.get("/batches/{batch_id}")
def batch_detail(batch_id: str):
jobs = store.jobs(batch_id)
reviewed = task_extensions.reviewed_batch_sessions(batch_id, store)
for job in jobs:
job["human_reviewed"] = job["session_id"] in reviewed
job["test_result"] = read_reviewed_result(
job["session_id"], job["status"], job.get("error") or "", storage=store
)
+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,
@@ -15,6 +15,7 @@ from artemis.runtime.task_limits import task_timeout
logger = logging.getLogger(__name__)
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
MODEL_PAUSE_REASON = "模型请求已暂停,请先恢复模型连接再继续批次"
background_tasks: set[asyncio.Task] = set()
@@ -123,7 +124,9 @@ def recover_batches():
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
continue
batch.update(
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
status="paused",
reason="服务已重启;请检查中断任务,点击继续执行未开始任务",
pause_source="recovery",
)
for job in store.jobs(batch["id"]):
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
@@ -164,9 +167,29 @@ async def on_terminal(item: dict, status: str):
}
):
job["status"] = "failed"
job["error"] = job.get("error") or "任务执行结束,但业务检查未通过"
job["error"] = (
job.get("error")
or report.get("error")
or (report.get("test_result") or {}).get("reason")
or "任务未完成或验证未通过,请查看报告中的具体原因"
)
elif job["status"] == "failed" and report.get("error") and not job.get("error"):
job["error"] = report["error"]
if job["status"] == "failed" and report["outcome"].get("code") == "initialization_failed":
job["failure_code"] = "initialization_failed"
batch = store.get("batch", job.get("batch_id")) if job.get("batch_id") else None
if (
batch
and (batch["status"] == "running" or batch.get("pause_source") == "model")
and any(j["status"] == "pending" for j in store.jobs(batch["id"]))
):
batch.update(
status="paused",
pause_source="initialization",
reason="任务初始化失败;请处理环境问题后继续剩余用例:"
+ (job.get("error") or "请查看任务报告"),
)
store.put("batch", batch["id"], batch)
# Persist terminal classification before export, but the coordinator waits for finalization.
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
await asyncio.to_thread(export_session, sid, job["formats"])
@@ -217,10 +240,18 @@ async def tick_batches():
if batch["status"] not in {"running", "paused"}:
continue
jobs = store.jobs(batch["id"])
if (
batch["status"] == "paused"
and batch.get("pause_source") == "model"
and not state.is_paused
):
batch.update(status="running", reason="")
batch.pop("pause_source", None)
store.put("batch", batch["id"], batch)
active = [j for j in jobs if j["status"] in {"running", "queued", "finalizing"}]
if active:
if state.is_paused:
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
if state.is_paused and batch["status"] == "running":
batch.update(status="paused", reason=MODEL_PAUSE_REASON, pause_source="model")
store.put("batch", batch["id"], batch)
for job in active:
# The queue records started_at at submission, then replaces it
@@ -284,7 +315,7 @@ async def tick_batches():
store.put("batch", batch["id"], batch)
continue
if state.is_paused:
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
batch.update(status="paused", reason=MODEL_PAUSE_REASON, pause_source="model")
store.put("batch", batch["id"], batch)
continue
job = pending[0]
@@ -438,11 +469,14 @@ def batch_action(batch_id: str, action: str) -> dict:
if action == "pause":
if batch["status"] not in {"running", "paused"}:
raise ValueError("只能暂停正在执行的批次")
batch.update(status="paused", reason="用户暂停;当前任务继续,后续任务等待")
batch.update(
status="paused", reason="用户暂停;当前任务继续,后续任务等待", pause_source="user"
)
elif action == "resume":
if batch["status"] not in {"draft", "paused"}:
raise ValueError("只能开始草稿或继续已暂停批次")
batch.update(status="running", reason="")
batch.pop("pause_source", None)
elif action == "retry_failed":
if batch["status"] == "running":
raise ValueError("请先暂停批次")
+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"}
@@ -35,16 +36,58 @@ def installation_signature() -> str:
)
def reviewed_batch_sessions(batch_id: str, store: AutomationStore) -> set[str]:
"""Read current human review values once per batch, without loading history."""
manifest = next((item for item in active_manifests() if item.id == "task-review"), None)
if manifest is None or manifest.workspace is None:
return set()
with store.connection() as conn:
rows = conn.execute(
"""SELECT j.session_id, json_extract(r.payload, '$."task-review".values') AS review_values
FROM automation_jobs j JOIN automation_records r
ON r.kind='task_extensions' AND r.id=j.session_id
WHERE j.batch_id=?""",
(batch_id,),
).fetchall()
reviewed = set()
for row in rows:
values = json.loads(row["review_values"] or "{}")
if any(
isinstance(value := values.get(field.id), str)
and value.strip()
and (field.kind == "text" or value in field.options)
for field in manifest.workspace.fields
):
reviewed.add(row["session_id"])
return reviewed
def task_context(session_id: str, store: AutomationStore) -> dict:
identifier(session_id)
session = SessionRepository(store.path).get_session_by_id(session_id) or {}
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:
@@ -1522,8 +1522,10 @@ class TaskQueueService:
from artemis.plugins.store import PluginStore
for batch in store.records("batch"):
if batch["status"] == "running":
batch.update(status="paused", reason="已停止全部任务,等待手动继续")
if batch["status"] in {"running", "paused"}:
batch.update(
status="paused", reason="已停止全部任务,等待手动继续", pause_source="user"
)
store.put("batch", batch["id"], batch)
# 1. Cancel local queue reservations
for item in state.queue_items:
+3
View File
@@ -30,6 +30,9 @@ npm run build
## Local Conventions
- 发版遵守根指南的 Release Gates。前端应用版本只改 package.json 顶层 `version` 和 package-lock.json 顶层及 `packages[""].version`;禁止把相同数字的第三方依赖一并替换。
- Node 支持范围以锁定的 Angular CLI/构建依赖实际 `engines` 为准,核对启动脚本的主、次、补丁版本边界和提示;不要只判断“Node 22/24/26”。
- 安装或依赖变更需在隔离目录、独立空 npm 缓存下运行 `npm ci`,再运行上述测试和构建;国内入口同时核验镜像安装。不得以关闭完整性校验、强制安装或删除锁文件掩盖不一致,也不得用已装依赖的构建结果宣称全新安装通过。
- 页面组件在 src/app/components,服务在 src/app/services;先复用已有组件、样式及 API 服务。
- 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。
- 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。
+1859 -2314
View File
File diff suppressed because it is too large. Load diff
+17 -12
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "2.3.0",
"version": "2.4.2",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -9,24 +9,29 @@
"test": "ng test"
},
"private": true,
"overrides": {
"@angular/build": {
"vite": "8.3.5"
}
},
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
"@angular/compiler": "^22.1.4",
"@angular/core": "^22.1.4",
"@angular/forms": "^22.1.4",
"@angular/platform-browser": "^22.1.4",
"@angular/platform-browser-dynamic": "^22.1.4",
"@angular/router": "^22.1.4",
"@angular/cdk": "^22.2.4",
"@angular/common": "^22.2.3",
"@angular/compiler": "^22.2.3",
"@angular/core": "^22.2.3",
"@angular/forms": "^22.2.3",
"@angular/platform-browser": "^22.2.3",
"@angular/platform-browser-dynamic": "^22.2.3",
"@angular/router": "^22.2.3",
"marked": "18.1.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^22.1.6",
"@angular/cli": "^22.1.6",
"@angular/compiler-cli": "^22.1.4",
"@angular/build": "^22.2.4",
"@angular/cli": "^22.2.4",
"@angular/compiler-cli": "^22.2.3",
"@types/jasmine": "~5.1.0",
"istanbul-lib-instrument": "^6.0.3",
"jasmine-core": "~5.6.0",
@@ -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
@@ -108,6 +108,19 @@
</div>
}
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><app-favorite-button [sourceId]="j.session_id" /><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
@for(j of b.jobs; track j.session_id){
<div class="job" [class.selected]="agent.currentSessionId() === j.session_id">
<div class="job-heading">
<app-favorite-button [sourceId]="j.session_id" />
<button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button>
</div>
<div class="job-statuses">
<span>{{label(j.status)}}</span>
<app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />
@if(j.human_reviewed){<span class="human-review-badge" title="人工评审已有保存内容,不代表测试通过">人工已审</span>}
</div>
@if(j.error){<small>{{j.error}}</small>}
</div>
}
</section>}
}
@@ -37,7 +37,15 @@ input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text,
@keyframes archive-spin{to{transform:rotate(360deg);}}
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:var(--app-surface-soft, #f8fafc);}.table-wrap{max-height:260px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid var(--app-border, #e2e8f0);text-align:left;overflow-wrap:anywhere;}th{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-secondary, #475569);font-weight:500;}
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:var(--app-text-control, #334155);background:var(--app-surface-soft, #f8fafc);border-radius:8px;padding:12px;border:1px solid var(--app-border, #e2e8f0);}
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid var(--app-border, #f1f5f9);}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569);border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:pointer;padding:8px 0;}
.job{display:flex;flex-direction:column;gap:8px;padding:12px 0;border-bottom:1px solid var(--app-border, #f1f5f9);}
.job:last-child{border-bottom:0;}
.job-heading{position:relative;min-width:0;}
.job-heading>app-favorite-button{position:absolute;left:6px;top:4px;z-index:1;}
.job-heading>button{box-sizing:border-box;width:100%;min-width:0;padding-left:40px;justify-content:flex-start;text-align:left;overflow-wrap:anywhere;}
.job-statuses{display:flex;flex-wrap:wrap;align-items:center;gap:8px;}
.job-statuses>span{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;padding:0 6px;border:1px solid transparent;border-radius:5px;font-size:11px;line-height:14px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569);white-space:nowrap;}
.job small{overflow-wrap:anywhere;}
details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:pointer;padding:8px 0;}
.task-guidance{margin-top:16px;padding:14px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
.task-guidance label{margin:8px 0 4px;}
.report-overview{padding:14px;border:1px solid var(--app-primary-border, #dbeafe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
@@ -58,3 +66,5 @@ input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text,
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid var(--app-border, #e2e8f0);vertical-align:top;}th{background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e3a8a);}
.md-para-gap{height:8px;}
}
.job .human-review-badge{white-space:nowrap;border:1px solid var(--app-primary-border, #bfdbfe);background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1d4ed8);font-weight:500;}
@@ -90,6 +90,30 @@ describe('AutomationPanelComponent batch deletion', () => {
expect(http.post).not.toHaveBeenCalled();
});
it('shows only saved human review badges and removes them on a batch refresh', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
const component = fixture.componentInstance;
await new Promise<void>(resolve => setTimeout(resolve, 0));
component.tab.set('batches');
const batch = { id: 'review-batch', name: '评审批次', status: 'completed', jobs: [
{ session_id: 'reviewed', status: 'completed', human_reviewed: true, case: { name: '已评用例' } },
{ session_id: 'empty', status: 'completed', human_reviewed: false, case: { name: '空白用例' } },
{ session_id: 'legacy', status: 'completed', case: { name: '旧版任务' } }
] };
component.batch.set(batch);
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.job');
expect(rows[0].querySelector('.human-review-badge').textContent).toBe('人工已审');
expect(rows[1].querySelector('.human-review-badge')).toBeNull();
expect(rows[2].querySelector('.human-review-badge')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('人工未审');
(http.get.and as any).callFake((url: string) => of(url.endsWith('/review-batch')
? { ...batch, jobs: batch.jobs.map(job => ({ ...job, human_reviewed: false })) } : []));
await component.refresh();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.human-review-badge')).toBeNull();
});
it('confirms deletion and removes the batch from the visible list', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches');
@@ -129,7 +153,7 @@ describe('AutomationPanelComponent batch deletion', () => {
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
]});
fixture.detectChanges();
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job > button')];
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job-heading > button')];
jobs[0].click(); jobs[1].click();
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
expect(component.tab()).toBe('batches');
@@ -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);
}
+20 -7
View File
@@ -96,6 +96,7 @@ from artemis.services.llm import (
get_openai_llm,
get_llm,
invoke_llm_with_timeout_message,
with_fallback,
)
from artemis.tools.history import history_tool_declarations
from artemis.tools.android_files import android_file
@@ -780,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"):
@@ -1506,20 +1507,32 @@ class FlashRunner:
turn_base = len(messages) - 1
current_tools = report_only_tools if is_final else self._get_tools()
async def invoke_turn():
if using_tool_fallback:
return await self._invoke_model(fallback_llm, current_tools, messages)
async def invoke_fallback():
nonlocal fallback_llm
if fallback_llm is None:
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
return await self._invoke_model(fallback_llm, current_tools, messages)
return await with_fallback(
lambda: self._invoke_model(llm, current_tools, messages),
invoke_fallback,
none_should_fallback=False,
)
if isinstance(control, TaskControl):
interrupted, response = await control.run_interruptibly(
self._invoke_model(
fallback_llm if using_tool_fallback else llm, current_tools, messages
),
invoke_turn(),
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
else:
interrupted, response = (
False,
await self._invoke_model(
fallback_llm if using_tool_fallback else llm, current_tools, messages
),
await invoke_turn(),
)
if interrupted or (
+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 -3
View File
@@ -35,7 +35,7 @@ from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
from artemis.llm.usage import usage_from_message
from artemis.llm.structured import content_to_text
from artemis.runtime.task_control import TaskControl, has_pending_instruction
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message, with_fallback
from artemis.tools.command_tool import (
analyze_task_output_wrapper,
get_adb_task_registry,
@@ -638,9 +638,19 @@ class OperatorNode:
and t.name not in {"plugin_load", "plugin_read_file", "plugin_read_result"}
]
traced_tools.extend(plugins.langchain_tools())
bound_llm = active_llm.bind_tools(tools=traced_tools)
async def invoke(model):
bound = model.bind_tools(tools=traced_tools)
return await invoke_llm_with_timeout_message(acomplete(bound, current_messages))
interrupted, response = await self._await_or_pause(
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
invoke(active_llm)
if using_tool_fallback
else with_fallback(
lambda: invoke(base_llm),
lambda: invoke(get_llm(ctx=self.ctx, name="operator", use_fallback=True)),
none_should_fallback=False,
)
)
if interrupted:
return None, None, None, 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")
+3 -1
View File
@@ -87,7 +87,7 @@ class Settings(BaseSettings):
"""Centralized ARTEMIS runtime settings loaded from environment and .env files."""
ARTEMIS_PLUGIN_MARKETPLACE_URL: str = (
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json"
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v4.json"
)
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600)
@@ -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)
+8
View File
@@ -164,6 +164,7 @@ async def execute_task(
config.with_graph_config_callbacks(graph_config_callbacks)
agent: Agent | None = None
task_started = False
try:
agent = Agent(config=config.build(), session_id=effective_sid)
await agent.init(
@@ -196,7 +197,14 @@ async def execute_task(
request.plugins = plugins
elif plugin_choices is not None:
request.plugins = TypeAdapter(list[PluginSelection]).validate_json(plugin_choices)
task_started = True
await agent.run_task(request=request)
except Exception as exc:
if not task_started and effective_sid:
from artemis.runtime.task_result import save_failure_result
save_failure_result(str(effective_sid), str(exc), code="initialization_failed")
raise
finally:
if agent is not None:
await agent.clean()
+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),
}
+6
View File
@@ -30,6 +30,7 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import random
import re
from functools import lru_cache
import threading
import time
@@ -124,6 +125,11 @@ def classify_failure(error: BaseException) -> Failure:
# Programmer/request errors: retrying is useless and falling back only
# hides the bug inside a different model's output.
return Failure(FailureCategory.BAD_REQUEST, False, False)
# langchain-openai 1.7 raises this ValueError for a response.failed event
# without provider error details, before our completion guard sees it.
# Preserve the bounded invalid-output retry policy for these failed streams.
if isinstance(error, ValueError) and re.fullmatch(r"Response \S+ failed\.", str(error)):
return Failure(FailureCategory.INVALID_OUTPUT, True, True)
return Failure(FailureCategory.UNKNOWN, True, 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:
+5 -4
View File
@@ -44,10 +44,11 @@ def download(url: str, progress: Callable[[str, int], None]) -> bytes:
def load_catalog() -> PluginCatalog:
url = settings.ARTEMIS_PLUGIN_MARKETPLACE_URL
# Older hosts reject schema-v3 entries before checking version constraints.
# Keep their index intact, and route only the exact former official URL.
if url == "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json":
url = url.removesuffix("catalog.json") + "catalog-v3.json"
# Strict older hosts cannot parse newer panel kinds, even if incompatible.
# Preserve their indexes; upgrade only the exact official saved addresses.
official = "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/"
if url in {official + "catalog.json", official + "catalog-v3.json"}:
url = official + "catalog-v4.json"
data = download(url, lambda *_: None)
if len(data) > 2 * 1024 * 1024:
raise ValueError("市场索引过大")
+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)
+17 -2
View File
@@ -101,7 +101,10 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
# Migration cleanup belongs to the long-lived host. A queued task worker
# must not wait on another process's optional legacy wake-lock cleanup.
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
ScreenAwakeLease(device_id).cleanup_unowned_references()
try:
ScreenAwakeLease(device_id).cleanup_unowned_references()
except OSError as exc:
logger.warning(f"Could not prepare optional legacy awake cleanup on {device_id}: {exc}")
commands = (
(
@@ -209,6 +212,7 @@ class ScreenAwakeService:
self._monitor_thread: threading.Thread | None = None
self._shutdown_requested = False
self._owners: dict[str, FileLock] = {}
self._ownership_warnings: set[str] = set()
def start(self, device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
if self._shutdown_requested:
@@ -241,13 +245,24 @@ class ScreenAwakeService:
existing = self._strategies.get(device_id)
if existing is not None:
return existing
owner = _owner_lock(device_id)
try:
owner = _owner_lock(device_id)
owner.acquire(timeout=0)
except Timeout:
# Do not cache a follower as owner: discovery can take over if
# the process holding the OS lock exits or releases the device.
return None
except OSError as exc:
# Awake is optional. Never bypass ownership or let an ACL / I/O
# failure abort device connection, resume, or the host monitor.
if device_id not in self._ownership_warnings:
logger.warning(
f"Could not acquire awake ownership on {device_id}; skipping optional "
f"stay-awake policy: {exc}"
)
self._ownership_warnings.add(device_id)
return None
self._ownership_warnings.discard(device_id)
try:
strategy = _configure_usb_stay_awake(device_id)
except BaseException:
+12 -1
View File
@@ -77,6 +77,8 @@ from typing import Any
import urllib.error
import urllib.request
from filelock import FileLock
from artemis.config.constants import ENV_ARTEMIS_HELPER_AUTO_INSTALL
from artemis.config.paths import get_temp_dir
from artemis.runtime.adb_endpoint import adb_command
@@ -323,7 +325,7 @@ class AccessibilityHelperManager:
if re.fullmatch(r"[0-9a-f]{32,128}", text):
self._token = text
return text
except OSError:
except FileNotFoundError:
pass
token = secrets.token_hex(24)
try:
@@ -550,6 +552,15 @@ class AccessibilityHelperManager:
root.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:16]
path = root / f"{digest}.mutex"
# An age-based lease alone can be stolen from a live, delayed owner.
# Retain the legacy lease for coexistence with older Artemis processes,
# but serialize current processes with a kernel lock released on exit.
with FileLock(path.with_suffix(".guard"), timeout=_PROVISION_MUTEX_TIMEOUT_SECONDS):
with self._legacy_provision_mutex(path, serial):
yield
@contextmanager
def _legacy_provision_mutex(self, path: Path, serial: str):
started = time.monotonic()
while True:
try:
+20 -9
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,
@@ -584,6 +587,7 @@ class Agent:
or getattr(getattr(task, "request", None), "task_name", None)
)
device_lock = None
execution_started = False
try:
# Construct the runtime only inside the task's cleanup boundary. A
# cancelled queue wait or setup failure must not leave version pins.
@@ -761,6 +765,7 @@ class Agent:
publish_startup_progress(
"environment_ready", "Device environment is ready", session_id=str(sess_id)
)
execution_started = True
try:
if request.profile and request.profile.lower() == "flash":
logger.info(f"[{task_name}] Invoking FlashRunner reactive loop...")
@@ -792,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")
@@ -907,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")
@@ -972,7 +977,11 @@ class Agent:
from artemis.runtime.task_result import save_failure_result
if sess_id:
save_failure_result(str(sess_id), err)
save_failure_result(
str(sess_id),
err,
code="execution_failed" if execution_started else "initialization_failed",
)
if context.data_engine:
context.data_engine.end_session("failed")
@@ -1681,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"
+32 -11
View File
@@ -317,12 +317,32 @@ async def _wait_for_resume(pause_file: Path) -> bool:
"""Wait for the pause file to be cleared. Returns False on deadline."""
deadline = float(getattr(settings, "LLM_PAUSE_TIMEOUT_SECONDS", 0.0) or 0.0)
waited = 0.0
while pause_file.exists():
if deadline > 0 and waited >= deadline:
return False
await asyncio.sleep(1)
waited += 1
return True
try:
owned = pause_file.stat()
except FileNotFoundError:
return True
try:
while pause_file.exists():
if deadline > 0 and waited >= deadline:
return False
await asyncio.sleep(1)
waited += 1
return True
finally:
# Cancellation (stop, guidance, outer deadline) also ends this wait.
# Do not leave a global pause behind or remove a newer request's pause.
try:
current = pause_file.stat()
if (current.st_ino, current.st_mtime_ns, current.st_size) == (
owned.st_ino,
owned.st_mtime_ns,
owned.st_size,
):
pause_file.unlink(missing_ok=True)
except FileNotFoundError:
pass
except OSError as exc:
llm_logger.warning("Could not clear finished LLM pause: %s", exc)
async def _wait_for_breaker(key: str) -> None:
@@ -510,10 +530,6 @@ async def _run_with_recovery[T](
"LLM pause deadline"
f" ({settings.LLM_PAUSE_TIMEOUT_SECONDS:.0f}s) exceeded; giving up."
)
try:
pause_file.unlink(missing_ok=True)
except OSError:
pass
raise LLMExhaustedError(
str(last_error), failure=last_failure, cause=last_error
) from last_error
@@ -670,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 提示词;左侧按用例编号和标题展示,右侧可用上一条/下一条评审同批次已结束任务。未启用该插件时仍提供并校验原七列模板。
+6
View File
@@ -1,5 +1,11 @@
# 开发交接: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)。以下各日期内容保留为历史快照,不代表当前待办。
2026-10-27 发布主线已整理为 **ARTEMIS 2.2.0**,配套插件为 **Cua 0.38.0+artemis.1**,Helper 为 **1.2.3**。最新升级内容、验证与未测范围以[2.2.0 发行说明](releases/2.2.0.md)为准;下方“尚未发布”、分叉计数和候选版本均属于对应日期快照。插件从独立工作树发布,原插件主目录的 OCU 开发现场保留。
2026-10-26 已再次接续开发会话,当前基线为主程序本地提交 `fac4ccb`(2.2.0)。两个仓库现场、已撤回机制与后续验收范围见[本次接续记录](plans/2026-10-26-session-handoff.md)。以下继续保留原始交接快照。
@@ -0,0 +1,87 @@
# 2026-10-28 依赖安全更新与兼容性验证
## 审计结果
范围为主仓库声明的 Python、前端 npm、后台原型依赖及可识别的构建依赖。使用当日 PyPI/pip-audit、npm audit、OSV 和上游安全公告;没有忽略漏洞或降低检查级别。
| 范围 | 更新前 | 更新后 |
| --- | --- | --- |
| `uv.lock` 中全部 169 个 registry 包,含开发依赖及非 Windows 平台包 | 11 个包命中,97 条公告记录 | 0 条,未跳过包 |
| 前端 npm 锁文件 | 17 个受影响包:3 critical、11 high、3 moderate | 0 个受影响包 |
| `playground/backend_manager/requirements.txt` 当日解析结果 | 54 个包,2 个包命中、3 条记录 | 52 个包,0 条,未跳过包 |
| Python 构建工具的当日解析结果 | — | setuptools 84.2.1、Cython 3.3.0,0 条 |
| Android Gradle Plugin 8.5.0 | — | OSV 直接包查询未检出公告;未解析完整 Gradle 构建工具传递依赖 |
公告可能含重复 ID 或别名,97 条不代表 97 个独立 CVE;npm 受影响包数也包含依赖链上的父包。零结果仅表示本次数据库未检出已知漏洞,不代表没有未知风险或业务安全问题。
远程 Python SDK 不含第三方运行时依赖;Android Helper 使用系统 API,不含第三方运行时库。独立 `Artemis-Plugins` 仓库、用户安装的插件、系统软件包和完整容器镜像不在上述零漏洞结论内。
## 版本调整
| 包 | 原版本 | 更新后 |
| --- | --- | --- |
| openai | 3.3.0 | 3.40.0,沿用同日 SDK 更新 |
| langchain / langchain-core | 1.3.15 / 1.5.6 | 1.4.7 / 1.7.0 |
| langchain-openai | 1.5.2 | 1.7.3 |
| langgraph / langgraph-sdk | 1.2.11 / 0.4.2 | 1.2.18 / 0.4.10 |
| httpx | 0.28.1 | 保持 0.28.1,当日最新版 |
| httpx2 / httpcore2 | 2.12.0 | 2.16.0 |
| mcp | 1.29.0 | 1.30.0 |
| aiohttp | 3.14.3 | 3.14.5 |
| certifi | 2026.7.22 | 2026.10.23 |
| filelock | 3.32.3 | 4.2.4 |
| multidict | 6.7.1 | 7.2.1 |
| pillow | 12.3.0 | 13.0.0 |
| pyjwt | 2.13.0 | 2.15.1 |
| tornado | 6.5.8 | 6.6 |
| urllib3 | 2.7.0 | 2.8.0 |
| virtualenv | 21.7.4 | 21.15.1 |
| Angular framework / compiler-cli | 22.1.4 | 22.2.3 |
| Angular CLI / build | 22.1.6 | 22.2.4 |
| Angular CDK | 22.1.5 | 22.2.4 |
| Vite | 8.1.5 | 8.3.5 |
| Piscina | 5.2.0 | 5.3.2 |
| 原型 Nginx 基础镜像 | 1.27-alpine | 1.30.6-alpine |
`langchain-community 0.4.2` 和 `langchain-mcp-adapters 0.3.2` 保持不变。后者要求 MCP `<2`,因此主项目声明 `mcp>=1.30.0,<2.0.0`,使用兼容的 1.x 安全修复版本。其余联动升级见 `uv.lock`。
Angular build 22.2.4 仍精确依赖存在漏洞的 Vite 8.3.0,通过限定在 `@angular/build` 下的 npm override 使用 8.3.5,未使用 `--force` 或 `--legacy-peer-deps`。以后上游采用安全版本时可移除 override,重新审计并验证构建。
后台原型的 `python-jose 3.5.0` 和间接 `ecdsa 0.19.2` 没有可用修复版,改为 `PyJWT[crypto]>=2.15.1,<3.0.0`,保持算法白名单与 HTTP 401 错误处理。原型仍按 requirements 动态解析;该扫描结果不是所有旧镜像或旧安装环境的审计证明。
## 必要的适配
- `langchain-openai 1.7.x` 对没有错误详情的 `response.failed` 抛出 `ValueError("Response <id> failed.")`。补充精确分类,保留 `INVALID_OUTPUT` 的有限重试;未把所有 ValueError 当成无效模型输出。
- 补充失败流重试回归,验证不完整输出不会成为正式结论或工具动作,重试保留已有工具证据。
- Responses 模拟传输改用 OpenAI 默认使用的 httpx2,并修正测试夹具中 `request` 同名遮蔽,确保超过 1 MiB 的中文工具参数确实经过分块 SSE/UTF-8 传输。
- 原型 JWT 回归覆盖签发、声明、当前用户、过期、错误签名、算法不匹配、无签名、畸形 token 和缺失 subject。
- MCP 的 audience 漏洞需要服务端配置配合,单纯升级不自动开启校验。本仓库没有配置 `AuthSettings`/`token_verifier`,当前不使用该 bearer-auth 路径;未来接入时必须同时设置 `validate_token_resource` 并提供正确的 token resource。
## 验证
- 隔离环境完整默认 Python 测试:3207 通过、6 跳过、8 排除;102 条现有 Pillow `getdata()` 弃用警告,该接口计划在 Pillow 14 移除。
- 随后改用 httpx2 的模型流、失败分类和 JWT 专项测试:41 通过;同步原 `.venv` 后同一组 41 项再次通过。
- Ruff 格式/静态检查、质量基线、核心 Pyright 类型检查通过;Pyright 额外显式使用了升级后的解释器。
- 锁文件校验、升级环境 `uv pip check`、`git diff --check` 通过。
- 前端 ChromeHeadless:336 通过;生产构建通过,生成产物不纳入提交。
- 主 Python、原型 Python、Python 构建工具及前端更新后复扫无已知漏洞。
- Nginx 1.30.6 为当日官方稳定修复版本,Docker Hub 标签存在且 active。已尝试构建原型镜像,但 Docker Hub registry/auth 连接超时,尚未完成容器构建和运行验证,也未扫描完整镜像内的 OS 包。
- 未调用付费模型、执行设备业务测试、发布提交或修改用户任务配置。
原始审计 JSON、解析清单和测试日志保存在本地被 Git 忽略的 `artifacts/dependency-audit-20261028/`。
## 生效
初次同步受到运行中服务占用 Pillow DLL 的阻止,已恢复旧环境并通过依赖一致性检查。用户随后明确表示已停止服务,原 `.venv` 已成功执行 `uv sync --dev --locked` 并再次通过版本核对与兼容性测试。
随后通过 `artemis restart --no-open` 恢复后台服务;`/api/system/server-status` 返回 running,首页 HTTP 200 且引用本次新前端构建。原环境全部 165 个已安装 distribution 的版本均与锁文件匹配。
本次沿用 ARTEMIS 2.3.0,按用户要求提交源码和锁文件,不发布。用户已明确无需构建 Docker,本次不再继续容器构建;修改 Dockerfile 不会更新已有容器。
## 上游依据
- [Pillow 13.0.0 安全修复](https://pillow.readthedocs.io/en/stable/releasenotes/13.0.0.html)
- [MCP bearer token audience 公告](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-w4fh-qvv9-3v23)
- [Vite/launch-editor Windows 命令执行公告](https://github.com/advisories/GHSA-587h-7wmj-h5pm)
- [Vite 8.3.5 发布说明](https://github.com/vitejs/vite/releases/tag/v8.3.5)
- [Nginx 安全公告](https://nginx.org/en/security_advisories.html)及[当前稳定版本](https://nginx.org/en/download.html)
+35
View File
@@ -0,0 +1,35 @@
# OpenAI Python SDK 3.40.0 适配
本文保留 SDK 单独升级阶段的结果。同日后续的 LangChain、HTTPX2 与安全依赖升级,以及最终验证与生效状态,见[依赖安全更新记录](2026-10-28-dependency-security.md)。
## 范围
- `pyproject.toml` 显式声明 `openai>=3.40.0,<4.0.0`;项目直接使用 SDK 的 HTTP 客户端,不再仅依赖 `langchain-openai` 间接带入。
- `uv.lock` 将 SDK 从 3.3.0 升至 3.40.0,保留既有 `langchain-openai 1.5.2`、`httpx2 2.12.0` 及其他包版本。锁文件记录发行包和 SHA-256。
- 继续使用 Responses API、`store=False` 与 ARTEMIS 的重试预算;SDK `max_retries=0`,不叠加 SDK 重试。
- 没有修改模型默认值、已有用户配置、设备调度或插件协议,主程序版本仍为 2.3.0。
## 兼容性核对
从 3.3.0 到 3.40.0 的更新涉及 SSE 解码、异步流关闭、传输异常归一化、重试验证、Responses 类型及缓存元数据。现有 Responses 调用与已锁定的 LangChain 适配层在专项回归中兼容,无需新增业务兼容分支。
3.40.0 增加显式缓存断点 TTL 类型。此次只升级 SDK,不自动启用显式缓存断点或改变 TTL;以后接入产品配置时,需要同时处理服务端与模型支持、参数入口和用量展示,不能将 SDK 类型支持当作当前端点支持。
新增回归覆盖超过 1 MiB 的中文工具参数,在 SSE 帧和 UTF-8 字符跨网络块时完整还原;即使参数完整,缺少 `response.completed` 仍拒绝输出正式结论。
## 本次验证
- 在 OpenAI SDK 3.40.0 下,模型调用专项回归 97 项通过;完整默认回归 3198 项通过、6 项跳过、8 项排除,保留 102 条已有 Pillow 弃用警告。
- Ruff 格式与静态检查、质量基线、核心 Pyright 类型检查、`uv pip check`、`uv lock --check` 通过。
- 本次未改前端,未运行前端测试或构建;未执行真实模型或设备业务验收。
## 生效与边界
开发环境已通过 `uv sync --dev --locked` 同步。已有长驻服务需正常重启后完整加载新 SDK,本次不主动重启。验证使用本地模拟传输,不调用付费模型或真实设备。
## 来源
- [OpenAI Python SDK 3.40.0 发布说明](https://github.com/openai/openai-python/releases/tag/v3.40.0)
- [SDK 变更记录](https://github.com/openai/openai-python/blob/v3.40.0/CHANGELOG.md)
- [官方 Python SDK 文档](https://developers.openai.com/api/reference/python)
- [官方提示词缓存说明](https://developers.openai.com/api/docs/guides/prompt-caching)
+33
View File
@@ -0,0 +1,33 @@
# 2026-10-28 运行时故障与批次恢复修复
## 已确认问题
- awake 所属锁的目录创建和获取只处理正常争用,`PermissionError` 可从 UIAutomator、无障碍连接、设备恢复和主程序监控的共享入口传播。
- 旧唤醒租约清理虽然处理了清理过程异常,构造时创建临时目录仍可能失败。
- Flash、Pro/探索 Operator 的工具无进展恢复有备用模型,但模型生成异常未进入该入口。
- 批次模型暂停与用户暂停未区分;模型等待超时后文件已清除,批次仍维持暂停。
- 模型等待被取消时可能残留暂停文件;终态报告的通用失败说明可能遮蔽真实初始化/模型错误。
## 修复范围
- 保活权限/I/O 异常降级为警告。未取得所属锁时不执行保活动作;后续可重新获取,不缓存为成功。同一设备持续获取失败只警告一次,成功后重置。
- 工作进程早期 Agent 准备及 SDK 运行时准备失败记录 `initialization_failed`。批次在报告导出前保存失败原因并暂停剩余项;导出失败不改变这一分类。
- Flash 和 Operator 接入现有 `with_fallback`。取消、永久请求错误和同端点重试预算仍遵守统一网关规则,不修改用户模型配置,不放宽工具参数/完整响应保护。
- 区分模型、初始化、用户、服务恢复暂停。模型恢复后按既定失败策略继续;停止全部任务覆盖自动恢复标记。重启后没有存活任务时仍要求人工继续。
- 模型等待结束或被取消时清理本次暂停;文件已被新请求改写时保留。
## 边界
- 不删除或改 ACL,不更换锁目录绕过互斥。设备执行锁、主程序注册锁和队列 I/O 锁保留失败即阻止执行的行为。
- 未重跑原批次、修改历史裁定或调用真实设备/付费模型;未构建 Docker。
- 原 Windows 锁文件的 ACL、占用者及异常发生时依赖版本尚无本机证据;本修复处理应用传播和恢复缺口,不宣称证明了操作系统拒绝访问的具体来源。
- 旧批次不自动迁移暂停原因,需用户显式继续;不会从历史零步骤数量推断并改写失败分类。
## 验证
针对性测试覆盖锁目录/文件权限拒绝后恢复、监控继续处理其他设备、旧保活清理、Flash/Operator 生成失败切换、暂停取消/超时、初始化错误保留与批次暂停、手动继续不重跑失败项、用户暂停与停止全部、服务重启恢复。
- 默认测试:3225 通过、6 跳过、8 按默认标记排除。102 条既有 Pillow `getdata()` 弃用警告。
- Ruff 格式检查与静态检查通过,质量基线通过,核心 Pyright 检查 0 错误、0 警告。
- 第一轮完整测试暴露新增 CLI 测试的环境变量隔离遗漏,影响两项录像测试;修正测试隔离后第二轮完整测试全部通过,未为此改变产品行为或放宽断言。
- 本次仅修改 Python 与文档,未运行前端构建、真实设备或付费模型测试。
+47
View File
@@ -0,0 +1,47 @@
# ARTEMIS 开发主线交接:2026-10-28
本次依据用户提供的《ARTEMIS开发主线-1027》完整会话记录、仓库发行说明与本地 Git 状态接续开发。agentmemory 未返回匹配本项目目录的会话,因此不采用其他项目的记忆。此次未联网刷新远端、重跑测试、重启服务或操作设备;发布和实测结论来自上一会话记录,不能视为本次重新验证。
## 当前基线
| 范围 | 本次本地核对结果 |
| --- | --- |
| 主程序 | `main` 与本地 `eapil/main` 位于 `bb873ec`,版本 2.3.0;迁入前已跟踪文件无修改,只有未跟踪的 `.angular/` 缓存 |
| 人工评审插件工作树 | 插件仓库 `.tmp/task-review-extension`,分离 HEAD `386f108`,工作树干净;`7bcdb2a` 增加插件,`386f108` 上架市场 |
| Cua 工作树 | 插件仓库 `.tmp/cua-shell-observation`,分支 `codex/cua-shell-observation`,提交 `b780a60`,工作树干净 |
| 插件原主目录 | `main` 位于 `757b829`,相对本地 `origin/main` 领先 4、落后 10;仍有 OCU、Cua 和测试脚本等既有修改与未跟踪文件,必须保留 |
上一会话已完成 ARTEMIS 2.2.0、Cua `0.38.0+artemis.1`、ARTEMIS 2.3.0 和任务人工评审 1.0.0 的推送及发布;Cua 继续标注预览版。最后已核验远端提交、发行附件下载校验值,并完成线上市场隔离安装和启用验证。早期记录中的“2.2.0 尚未发布”“Cua 尚未提交”已经过时。
## 已完成的开发与约定
- 2.2.0 接入仅 PC 测试,网页、MCP、参数 JSON 和 Python SDK 使用 `execution_target="computer"`;PC、安卓和跨端任务统一全局串行。
- 已修复安卓缩放截图的校验坐标、观测恢复预算、输入首次清空与空值确认、Helper 聚焦目标选择、Pro 收尾空转和重复计划、MCP 排队及运行状态、笔记截图路径和验收项展示标签匹配。Helper 升级至 1.2.3。动作卡片文字可复制,长 RESULT 默认折叠。
- 模型选项增加 `gpt-6.1-terra`,新安装探索默认 `gpt-6.1-terra / high`;保存配置优先。
- 2.3.0 提供 `schema_version: 3` 的声明式 `workspace` 插件接口。独立「任务人工评审」插件从市场安装启用,主程序不默认安装启用。
- 回放标题栏控制左右侧栏,普通浮窗和影院模式均支持;单侧展开仍位于对应左右侧,窄屏一次展开一侧。
- 左侧只展示本次运行最初提交的需求提示词,支持 Markdown,不混入模型拆解步骤或后续指导。
- 右侧由人工填写「AI执行准确性」「测试结果正确性」,使用中文「通过 / 失败」,两项及备注均选填,可以修改或清空。结果正确性评价 ARTEMIS 判断是否准确,与业务用例是否通过区分。
- 评审不调用 AI,不覆盖原执行结论或既有人工复验;运行中只读,结束后可编辑,保留修改历史,并防止并发覆盖和重复写入。
- 评审绑定单次运行,复跑不继承;禁用或卸载保留数据但隐藏界面及新导出字段,重新安装启用后恢复,删除任务时清理。
- 已启用插件的字段进入单任务 Word/Excel、批次汇总及资料包报告,空项留空。重新导出反映最新评审,已下载旧文件不变。
- 插件 SDK、工作区接口文档及随程序分发的 Skill 已同步。2.3.0 使用 `catalog-v3.json`,旧 `catalog.json` 保持兼容 2.2.x;执行插件更新需兼顾两个索引,工作区插件只加入新版。
- 已撤回的次数配额、内容指纹和 AI 二次动作否决不恢复;已移除的设备动作回放不恢复,历史步骤和截图回看保留。
## 历史验证与仍待覆盖范围
- 2.2.0 发布前记录:后端 3189 项、前端 326 项、SDK 24 项通过,构建及质量检查通过。集中回归 9/9 正式实测通过;后续对排队状态、截图路径、验收项标签另有修复和实测证据。
- 2.3.0 发布前记录:后端默认回归 3197 项、插件针对性回归 49 项、前端 336 项通过,构建及静态检查通过;Windows 隔离示例验证主题、左右布局、中文评审保存与 Word/Excel 导出。该轮未执行真实设备业务或付费模型测试,macOS/Linux 未实测。
- Cua 真实鼠标跟随问题按用户此前要求暂缓;原任务不足以证明具体误点根因,不能因驱动已发布就标为已解决。
- 真实断线恢复、小时级稳定性、多显示器及多 DPI 等扩大兼容性场景仍缺完整验证。发布完成不等于这些场景全部通过。
- 素材准备句柄、跨次任务取代关系仍只是产品候选,尚未立项。
当前没有尚未完成的发布步骤或待回答的产品确认。本次仅迁移上下文;后续按用户新需求继续,修改前重新核对相关仓库和工作树,不自动恢复旧设备任务或重复发布。
## 参考
- [2.2.0 发行说明](../releases/2.2.0.md)
- [2.3.0 发行说明](../releases/2.3.0.md)
- [工作区插件接口](../workspace-plugins.md)
- [插件开发 SDK](../plugin-development.md)
- [上次会话接续记录](2026-10-26-session-handoff.md)
+44
View File
@@ -0,0 +1,44 @@
# ARTEMIS 2.4.0
> 全新安装的 npm 校验失败已在 [2.4.1](2.4.1.md) 修复,请使用补丁版源码包或通过更新脚本升级。
本版改进批量用例人工评审与执行详情阅读,并包含 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 锁访问失败及所有权恢复,检查同类资源锁和运行时收尾。持久权限错误仍保留实际原因,不跳过互斥保护。
- Helper 初始化增加操作系统互斥锁,防止仍在执行的操作仅因锁文件超龄而被抢占;已有宿主令牌无法读取时保留原文件和错误,不生成另一份令牌覆盖它。
- 规划器明确区分规划阶段与执行器的插件能力,避免因本阶段没有动作工具而误判插件不可用;批次模型失败保留明确诊断。
2.4.0 使用独立的 `catalog-v4.json` 市场索引;保留 2.3.x 与 2.2.x 的旧索引,防止旧宿主因新面板类型无法解析而导致市场加载失败。已有官方地址自动路由,自定义地址保持原样。
## 验证范围
发布前最终默认后端测试 **3267 通过、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)。
+26
View File
@@ -0,0 +1,26 @@
# ARTEMIS 2.4.1
本补丁修复 2.4.0 全新安装时的 `npm ci / EINTEGRITY`,适用于 `start.bat` 和 `start-cn.bat`。人工评审插件版本保持 **1.1.0**。
## 修复
- 2.4.0 发版时误将锁文件中的 MCP 间接依赖版本从 2.3.1 改为 2.4.0,仍保留 2.3.1 的 SHA-512。现在恢复 Angular CLI 22.2.4 官方声明的 `@modelcontextprotocol/server@2.3.1` 及其 `core@2.3.1`,使依赖关系、下载地址和校验值一致。未关闭 npm 完整性验证,也不要求用户清空全局缓存。
- Windows 启动脚本准确检查 Angular 支持的 Node.js 范围:22.x 至少 22.22.3,24.x 至少 24.15.0,或 26.0.0 及以上;提示文案同步修正。
- 保留最终 2.4.0 中的独立 v4 市场索引,提供人工评审 1.1.0;旧版宿主继续使用旧索引。曾用于发版准备的早期 2.4.0 候选包仍使用 v3 索引,会显示人工评审 1.0.0,不应继续分发。
## 验证
- Windows、Node.js 24.22.1、npm 11.19.1,独立源码目录及空 npm 缓存,使用国内镜像执行 `npm ci`:448 个包安装成功。实际依赖树与 Angular CLI 官方声明一致。
- 同一全新环境下,前端 ChromeHeadless 347 项通过,生产构建通过;官方 npm 审计未发现已知漏洞。
- 默认后端回归 3268 通过、2 跳过、8 按标记排除;包含 Node 22/24/26 兼容边界与启动脚本回归。Ruff、质量基线和核心类型检查通过。
- 未启动用户服务,未执行设备或真实业务验收;以上为安装、构建和确定性测试结果。
## 升级
已有安装请先等待任务结束,再运行 `update-cn.bat`(国内镜像)或 `update.bat`,随后重新启动。建议使用更新脚本保留已有配置与数据,不要直接用源码 ZIP 覆盖安装目录。
如果此前已手动修改 `package-lock.json`,更新脚本可能因本地修改而停止;先备份这份文件,确认只包含该安装问题的临时修正后再处理冲突,不要对整个仓库执行重置或清理。
新安装请下载本版 `artemis-2.4.1-source.zip`,按 `SHA256SUMS.txt` 校验后使用。插件仍需在市场中单独安装或更新并启用,升级主程序不会自动授权插件。
本次不构建 Docker,不启动或重启用户服务,不操作真实设备或业务任务。
+24
View File
@@ -0,0 +1,24 @@
# ARTEMIS 2.4.2
本版为批次任务增加人工评审标记,并调整任务列表布局。人工评审插件保持 **1.1.0**,第三方依赖版本不变。
## 更新
- 安装并启用人工评审插件后,任务存在已保存的有效评审内容时,批次列表显示“人工已审”。评审结论或非空备注均计入;清空全部内容后隐藏,禁用或卸载插件后也不显示。
- 标记仅说明已有人工评审内容,不代表测试通过,不覆盖 AI 判定或改变任务执行流程。没有安装插件的用户不显示该标记。
- 批次任务标题与状态分行显示:收藏星标位于标题框内,执行状态、AI 判定和人工已审标签在下方等高、左对齐排列。此布局适用于所有用户。
- 按批次一次读取当前评审内容,沿用列表自动刷新,不逐条增加网络请求;重跑任务不继承旧任务的已审标记。
- 补充发版约束:应用与第三方依赖独立版本化,检查锁文件差异,并验证最终源码包的全新安装。
## 验证
- 默认 Python 回归:3269 通过,2 跳过,8 按标记排除;Ruff、质量基线和核心类型检查通过。
- 前端 ChromeHeadless:348 项通过;生产构建通过。
- 内置浏览器使用隔离数据检查收藏星标、两行布局及有评审/无评审标签显示。未写入真实任务评审数据。
- 最终源码包另行执行独立目录、空 npm 缓存的官方源和国内镜像安装及生产构建,结果随发布附件提供。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.2-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
人工评审插件仍需单独安装并启用;本次无需升级已安装的 1.1.0。发布过程不构建 Docker,不重启用户服务,不执行设备或真实业务任务。
+15 -11
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。安装、启用、填写、清空、导出、禁用、卸载和重新安装均应通过确定性测试;只检查清单不代表界面和导出已验证。
@@ -72,6 +76,6 @@ GET 成功返回完整贡献快照;PUT 成功返回同样结构和新 revision
人工结果只能由用户填写;AI 不自行调用 PUT 生成或推断评审。此接口未加入模型 MCP 工具或远程 Python SDK 的人工写入方法。插件没有设备目标时,无需为安装验证提交安卓/PC 测试任务;用隔离终态示例验证保存、清空、重装、导出与旧缓存失效即可。
市场发布时保持清单、ZIP、版本标签与 catalog-v3.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。
市场发布时保持清单、ZIP、版本标签与 catalog-v4.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。
官方市场为 2.3.0+ 提供 catalog-v3.json,catalog.json 保留给 2.2.x。宿主读取原官方地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
官方市场为 2.4.0+ 提供 catalog-v4.json,catalog-v3.json 保留给 2.3.x,catalog.json 保留给 2.2.x,避免旧宿主严格解析新面板类型时导致整个市场加载失败。2.4.0 读取原官方 catalog.json 或 catalog-v3.json 地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
@@ -15,7 +15,8 @@
from datetime import datetime, timedelta, timezone, UTC
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
import jwt
from jwt import InvalidTokenError
from app.config import settings
security = HTTPBearer(auto_error=False)
@@ -40,7 +41,7 @@ def decode_access_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
return payload
except JWTError as e:
except InvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid or expired credentials: {str(e)}",
+1 -1
View File
@@ -2,7 +2,7 @@ fastapi>=0.115.0
uvicorn[standard]>=0.32.0
pydantic>=2.9.0
pydantic-settings>=2.5.0
python-jose[cryptography]>=3.3.0
PyJWT[crypto]>=2.15.1,<3.0.0
passlib[bcrypt]>=1.7.4
docker>=7.1.0
google-cloud-bigquery>=3.26.0
+1 -1
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM nginx:1.27-alpine
FROM nginx:1.30.6-alpine
# Install openssl for auto-generating self-signed dev certificates and curl for healthchecks
RUN apk add --no-cache openssl curl bash
+9 -8
View File
@@ -14,7 +14,7 @@
[project]
name = "artemis"
version = "2.3.0"
version = "2.4.2"
description = "Autonomous AI Assistant for Mobile Automation"
authors = [
@@ -27,14 +27,15 @@ dependencies = [
# Remote Python SDK (kept as a zero-dependency workspace package)
"artemis-client>=0.1.0",
# Orchestration & Framework
"langgraph>=1.0.2,<2.0.0",
"langchain>=1.0.0",
"langchain-core>=1.0.0",
"langgraph>=1.2.18,<2.0.0",
"langchain>=1.4.7,<2.0.0",
"langchain-core>=1.7.0,<2.0.0",
"langchain-community>=0.4.1",
"langchain-mcp-adapters>=0.2.0",
"mcp>=1.26.0",
"mcp>=1.30.0,<2.0.0",
# LLM Providers & GenAI SDKs
"langchain-openai>=1.0.0",
"langchain-openai>=1.7.3,<2.0.0",
"openai>=3.40.0,<4.0.0",
# Configuration, Models & Validation
"pydantic>=2.7.0",
"pydantic-settings>=2.10.1",
@@ -59,7 +60,7 @@ dependencies = [
"websockets>=12.0",
"sseclient-py>=1.8.0",
# Image, Video & Multimodal Perception
"pillow>=10.0.0",
"pillow>=13.0.0",
"opencv-python>=4.9.0.80",
"imageio-ffmpeg>=0.5.1",
"matplotlib>=3.8.0",
@@ -67,7 +68,7 @@ dependencies = [
"scipy>=1.13.0",
# System, Utilities & Telemetry
"psutil>=5.9.0",
"filelock>=3.20.0",
"filelock>=4.2.4",
"retry>=0.9.2",
"posthog>=3.5.0",
"uuid-utils>=0.9.0",
+6 -10
View File
@@ -218,14 +218,10 @@ function Test-NodeCompatible {
$verRaw = (& node -v 2>$null)
if (-not $verRaw) { return $false }
$verStr = $verRaw.ToString().Trim().TrimStart('v')
$parts = $verStr.Split('.')
if ($parts.Count -ge 2) {
$major = [int]$parts[0]
$minor = [int]$parts[1]
if ($major -ge 26) { return $true }
if ($major -eq 24 -and $minor -ge 15) { return $true }
if ($major -eq 22 -and $minor -ge 22) { return $true }
}
$version = [version]$verStr
if ($version.Major -ge 26) { return $true }
if ($version.Major -eq 24 -and $version -ge [version]'24.15.0') { return $true }
if ($version.Major -eq 22 -and $version -ge [version]'22.22.3') { return $true }
} catch {
return $false
}
@@ -383,7 +379,7 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
if (-not (Test-NodeCompatible)) {
if (Test-CommandExists "node") {
$curVer = (& node -v 2>$null)
Write-Host (Get-LaunchText " [INFO] Detected Node.js $curVer, but Angular CLI requires Node.js >= v22.22.0." " [检查] 检测到 Node.js $curVer;前端需要 v22.22.0 或更新版本。") -ForegroundColor Yellow
Write-Host (Get-LaunchText " [INFO] Detected Node.js $curVer; Angular CLI requires 22.x >= 22.22.3, 24.x >= 24.15.0, or >= 26.0.0." " [检查] 检测到 Node.js $curVer;前端需要 22.x >= 22.22.3、24.x >= 24.15.0 或 >= 26.0.0。") -ForegroundColor Yellow
} else {
Write-Host (Get-LaunchText " [INFO] Node.js/npm not found (required for Showcase UI)." " [检查] 未找到 Node.js/npm,网页构建需要安装。") -ForegroundColor Cyan
}
@@ -401,7 +397,7 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
$useWinGetNode = $false
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
if ([Console]::IsInputRedirected -eq $false) {
Write-Host (Get-LaunchText " [INFO] Node.js >= v22.22.0 required for Showcase UI." " [检查] 网页需要 Node.js v22.22.0 或更新版本。") -ForegroundColor Cyan
Write-Host (Get-LaunchText " [INFO] Showcase UI requires Node.js 22.x >= 22.22.3, 24.x >= 24.15.0, or >= 26.0.0." " [检查] 网页需要 Node.js 22.x >= 22.22.3、24.x >= 24.15.0 或 >= 26.0.0。") -ForegroundColor Cyan
$ans = Read-Host (Get-LaunchText " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]" " 通过 WinGet 安装/升级 Node.js(可能需要管理员权限)?[Y/n]")
if ($ans -eq "" -or $ans -match "^[Yy]") {
$useWinGetNode = $true
@@ -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。安装、启用、填写、清空、导出、禁用、卸载和重新安装均应通过确定性测试;只检查清单不代表界面和导出已验证。
## 开发与兼容边界
@@ -73,6 +76,6 @@ GET 成功返回完整贡献快照;PUT 成功返回同样结构和新 revision
人工结果只能由用户填写;AI 不自行调用 PUT 生成或推断评审。此接口未加入模型 MCP 工具或远程 Python SDK 的人工写入方法。插件没有设备目标时,无需为安装验证提交安卓/PC 测试任务;用隔离终态示例验证保存、清空、重装、导出与旧缓存失效即可。
市场发布时保持清单、ZIP、版本标签与 catalog-v3.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。
市场发布时保持清单、ZIP、版本标签与 catalog-v4.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。
官方市场为 2.3.0+ 提供 catalog-v3.json,catalog.json 保留给 2.2.x。宿主读取原官方地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
官方市场为 2.4.0+ 提供 catalog-v4.json,catalog-v3.json 保留给 2.3.x,catalog.json 保留给 2.2.x,避免旧宿主严格解析新面板类型时导致整个市场加载失败。2.4.0 读取原官方 catalog.json 或 catalog-v3.json 地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
+188 -1
View File
@@ -417,13 +417,21 @@ def test_batch_deletion_preserves_shared_screenshot_until_last_task(client, isol
assert not image_file.exists()
def test_restart_never_repeats_started_work(isolated):
@pytest.mark.parametrize("model_paused", [False, True])
def test_restart_never_repeats_started_work(isolated, model_paused):
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
if model_paused:
isolated.put(
"batch",
batch["id"],
{**isolated.get("batch", batch["id"]), "status": "paused", "pause_source": "model"},
)
job = isolated.jobs(batch["id"])[0]
isolated.save_job({**job, "status": "queued"})
service.recover_batches()
assert isolated.get("batch", batch["id"])["status"] == "paused"
assert isolated.get("batch", batch["id"])["pause_source"] == "recovery"
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["interrupted", "pending"]
retry = service.batch_action(batch["id"], "retry_failed")
assert retry["status"] == "draft"
@@ -603,6 +611,106 @@ async def test_device_outage_pauses_without_consuming_pending_case(isolated, mon
assert all(job["status"] == "pending" for job in isolated.jobs(batch["id"]))
def test_stop_all_overrides_model_auto_resume(isolated, monkeypatch):
from apps.admin_console.services import task_queue_service as queue
batch = service.create_batch(payload())
isolated.put("batch", batch["id"], {**batch, "status": "paused", "pause_source": "model"})
monkeypatch.setattr("apps.admin_console.services.automation_store.store", isolated)
monkeypatch.setattr(queue, "state", Mock(queue_items=[], active_connections={}))
for method in (
"_terminate_all_device_owners",
"_kill_all_local_runs",
"_clear_pause_file",
"ensure_worker_running",
):
monkeypatch.setattr(queue.TaskQueueService, method, Mock())
assert queue.TaskQueueService._stop_all_tasks()
saved = isolated.get("batch", batch["id"])
assert saved["pause_source"] == "user"
assert "等待手动继续" in saved["reason"]
@pytest.mark.asyncio
@pytest.mark.parametrize("manual_pause", [False, True])
@pytest.mark.parametrize("stop_on_failure", [False, True])
async def test_model_pause_recovery_preserves_user_pause_and_failure_policy(
isolated, monkeypatch, manual_pause, stop_on_failure
):
from apps.admin_console import server
from artemis.runtime.device_pool import DeviceStatus
batch = service.create_batch(payload(stop_on_failure=stop_on_failure))
service.batch_action(batch["id"], "resume")
first, second = isolated.jobs(batch["id"])
isolated.save_job({**first, "status": "running", "started_at": time.time()})
pause_state = type("State", (), {"is_paused": True, "active_runs": {}})()
monkeypatch.setattr(service, "state", pause_state)
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
monkeypatch.setattr(
"artemis.runtime.device_pool.try_list_devices_async",
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
)
monkeypatch.setattr(reports, "export_batch", Mock())
await service.tick_batches()
assert isolated.get("batch", batch["id"])["pause_source"] == "model"
if manual_pause:
service.batch_action(batch["id"], "pause")
# Another paused-model tick must not overwrite the explicit user pause.
await service.tick_batches()
assert isolated.get("batch", batch["id"])["pause_source"] == "user"
isolated.save_job({**first, "status": "failed"})
pause_state.is_paused = False
await service.tick_batches()
assert isolated.job(first["session_id"])["status"] == "failed"
expected = "pending" if manual_pause else "skipped" if stop_on_failure else "queued"
assert isolated.job(second["session_id"])["status"] == expected
assert enqueue.await_count == (0 if manual_pause or stop_on_failure else 1)
@pytest.mark.asyncio
@pytest.mark.parametrize("code", ["initialization_failed", "execution_failed"])
async def test_failure_reason_retained_and_only_initialization_blocks_remaining_batch(
isolated, monkeypatch, code
):
from apps.admin_console import server
from artemis.runtime import task_result
from artemis.runtime.device_pool import DeviceStatus
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
first, second = isolated.jobs(batch["id"])
isolated.save_job({**first, "status": "running"})
monkeypatch.setattr(task_result, "TRACES_PATH", reports.TRACES_PATH)
task_result.save_failure_result(
first["session_id"], "PermissionError: awake lock denied", code=code
)
monkeypatch.setattr(reports, "export_session", Mock(side_effect=OSError("export unavailable")))
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
monkeypatch.setattr(
"artemis.runtime.device_pool.try_list_devices_async",
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
)
await service.on_terminal({"session_id": first["session_id"]}, "failed")
await service.tick_batches()
saved = isolated.job(first["session_id"])
assert saved["error"] == "PermissionError: awake lock denied"
assert saved["report_error"] == "export unavailable"
if code == "initialization_failed":
assert isolated.get("batch", batch["id"])["pause_source"] == "initialization"
assert isolated.job(second["session_id"])["status"] == "pending"
enqueue.assert_not_awaited()
service.batch_action(batch["id"], "resume")
await service.tick_batches()
assert enqueue.await_count == 1
assert enqueue.call_args.kwargs["session_id"] == second["session_id"]
assert isolated.job(first["session_id"])["status"] == "failed"
@pytest.mark.asyncio
async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
batch = service.create_batch(payload(stop_on_failure=True))
@@ -800,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,12 +57,16 @@ def manifest_payload():
}
def install(plugins):
def install(plugins, original_case=False, plugin_id="review"):
package = BytesIO()
manifest = manifest_payload()
manifest["id"] = plugin_id
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)
plugins.set_enabled(plugin_id, True)
@pytest.fixture
@@ -105,6 +109,151 @@ def save(client, values, revision=0, request_id=None):
)
def test_batch_review_badge_tracks_current_values_and_plugin_lifecycle(context, monkeypatch):
from apps.admin_console.routers import automation
store, plugins, client = context
monkeypatch.setattr(automation, "store", store)
result = {"status": "inconclusive", "source": "execution"}
monkeypatch.setattr(automation, "read_reviewed_result", lambda *args, **kwargs: result)
client.app.include_router(automation.router)
store.create_batch({"id": "review-batch", "status": "completed"}, [], {})
store.save_job(
{"session_id": "batch-task", "batch_id": "review-batch", "status": "completed", "case": {}}
)
store.save_job(
{"session_id": "new-run", "batch_id": "review-batch", "status": "completed", "case": {}}
)
def badge():
response = client.get("/api/automation/batches/review-batch")
assert response.status_code == 200
jobs = {job["session_id"]: job for job in response.json()["jobs"]}
assert "batch-task" in jobs, response.json()
assert jobs["batch-task"]["test_result"] == result
assert jobs["new-run"]["human_reviewed"] is False
return jobs["batch-task"]["human_reviewed"]
assert badge() is False
install(plugins)
assert (
client.put(
"/api/task-extensions/batch-task/review",
json={
"values": {"accuracy": "Pass"},
"expected_revision": 0,
"request_id": str(uuid4()),
},
).status_code
== 200
)
assert badge() is False # Other workspace plugins are not the human review plugin.
install(plugins, plugin_id="task-review")
assert badge() is False
for revision, (values, expected) in enumerate(
[
({}, False),
({"accuracy_note": " \n"}, False),
({"accuracy": "Pass"}, True),
({"accuracy": "Failed"}, True),
({"accuracy": "", "accuracy_note": ""}, False),
({"accuracy_note": "人工核对了截图"}, True),
]
):
response = client.put(
"/api/task-extensions/batch-task/task-review",
json={"values": values, "expected_revision": revision, "request_id": str(uuid4())},
)
assert response.status_code == 200
assert badge() is expected
plugins.set_enabled("task-review", False)
assert badge() is False
plugins.set_enabled("task-review", True)
assert badge() is True
plugins.uninstall("task-review")
assert badge() is False
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]"
@@ -429,6 +429,44 @@ def _report(status="completed", tc_id="tc-report", **extra):
_INITIAL_OBSERVATION = ("shot0.png", b"IMG0", "[1] Settings")
@pytest.mark.asyncio
async def test_generation_failure_uses_fallback_without_repeating_device_actions(mock_context):
from artemis.llm.response_guard import ResponseIntegrityError
from artemis.services import llm as gateway
click = AIMessage(
content="tap", tool_calls=[{"name": "click", "args": {"index": 1}, "id": "click"}]
)
with patch("artemis.controllers.unified_controller.get_driver"):
runner = _make_runner(mock_context, [])
primary = runner._init_llm.return_value
fallback = Mock()
calls = []
async def invoke(model, tools, messages):
calls.append(model)
if len(calls) == 1:
assert gateway._FALLBACK_AVAILABLE.get()
raise ResponseIntegrityError("missing completed response")
if model is fallback:
assert not gateway._FALLBACK_AVAILABLE.get()
return click
return _report()
runner._invoke_model = AsyncMock(side_effect=invoke)
with (
patch(
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
AsyncMock(return_value=_INITIAL_OBSERVATION),
),
patch("artemis.agents.flash.runner.get_llm", return_value=fallback),
):
result = await runner.run(State(initial_goal="Open Settings"))
assert result["status"] == "completed"
assert calls == [primary, fallback, primary]
assert runner.executor.execute.await_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("fallback_succeeds", [True, False])
async def test_failed_actions_have_finite_fallback_and_restore_primary(
@@ -82,6 +82,31 @@ async def run_loop(operator, monkeypatch, responses):
return result, messages, invoke
@pytest.mark.asyncio
async def test_generation_failure_hands_over_before_pausing(operator, monkeypatch):
from artemis.llm.response_guard import ResponseIntegrityError
from artemis.services import llm as gateway
node, state = operator
primary, fallback = Mock(), Mock()
contexts = []
async def invoke(model, messages):
contexts.append(gateway._FALLBACK_AVAILABLE.get())
if model is primary.bind_tools.return_value:
raise ResponseIntegrityError("missing completed response")
assert model is fallback.bind_tools.return_value
return click()
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", Mock(return_value=fallback))
messages = []
result = await node._invoke_llm_loop(primary, messages, [], [], state)
assert contexts == [True, False]
assert len(result[0]) == 1
assert len([m for m in messages if isinstance(m, AIMessage)]) == 1
@pytest.mark.asyncio
async def test_prose_retry_keeps_context_and_recovers_once(operator, monkeypatch):
prose = AIMessage(content="I will click the model menu.")
+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
+60
View File
@@ -24,6 +24,66 @@ def completed(stdout="", returncode=0, stderr=""):
return MagicMock(returncode=returncode, stdout=stdout, stderr=stderr)
@pytest.mark.parametrize("stage", ["directory", "acquire"])
def test_inaccessible_awake_lock_skips_actions_and_can_recover(stage, monkeypatch, tmp_path):
owner = FileLock(tmp_path / "recovered.lock", thread_local=False)
denied_owner = MagicMock()
denied_owner.acquire.side_effect = PermissionError("awake lock access denied")
factory = MagicMock(
side_effect=[
PermissionError("awake directory denied") if stage == "directory" else denied_owner,
owner,
]
)
configure = MagicMock(return_value=AWAKE_STRATEGY_USB)
monkeypatch.setattr("artemis.runtime.awake_service._owner_lock", factory)
monkeypatch.setattr("artemis.runtime.awake_service._configure_usb_stay_awake", configure)
service = ScreenAwakeService()
try:
assert service.ensure_device("device") is None
configure.assert_not_called()
assert service.device_ids == ()
assert service.ensure_device("device") == AWAKE_STRATEGY_USB
configure.assert_called_once_with("device")
finally:
service.shutdown()
def test_awake_monitor_continues_to_other_devices_after_access_denied(monkeypatch, tmp_path):
def owner(device):
if device == "denied":
raise PermissionError("denied")
return FileLock(tmp_path / f"{device}.lock", thread_local=False)
monkeypatch.setattr("artemis.runtime.awake_service._owner_lock", owner)
configure = MagicMock(return_value=AWAKE_STRATEGY_USB)
monkeypatch.setattr("artemis.runtime.awake_service._configure_usb_stay_awake", configure)
service = ScreenAwakeService()
try:
service._reconcile_connected_devices({"denied", "healthy"})
service._reconcile_connected_devices({"denied", "healthy"})
assert service.device_ids == ("healthy",)
configure.assert_called_once_with("healthy")
finally:
service.shutdown()
@patch(
"artemis.runtime.awake_service.ScreenAwakeLease",
side_effect=PermissionError("legacy directory denied"),
)
@patch("artemis.runtime.awake_service.subprocess.run")
def test_optional_legacy_directory_failure_does_not_abort_usb_policy(mock_run, _lease):
mock_run.side_effect = [
completed(),
completed(),
completed(),
completed("2"),
completed("mStayOn=true"),
]
assert _configure_usb_stay_awake("device") == AWAKE_STRATEGY_USB
@patch(
"artemis.runtime.awake_service._configure_usb_stay_awake", return_value=AWAKE_STRATEGY_HEARTBEAT
)
+48
View File
@@ -214,6 +214,54 @@ def test_concurrent_managers_share_the_first_host_token(tmp_path, monkeypatch):
assert token_path.read_text() == tokens[0]
def test_unreadable_host_token_is_not_replaced(tmp_path, monkeypatch):
monkeypatch.setattr(hm, "get_temp_dir", lambda _name: tmp_path / "mutex")
token_path = tmp_path / "session.token"
token_path.write_text("a" * 48)
original_read = Path.read_text
def denied(path, *args, **kwargs):
if path == token_path:
raise PermissionError("token temporarily inaccessible")
return original_read(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", denied)
manager = AccessibilityHelperManager(token_path=token_path)
with pytest.raises(PermissionError, match="temporarily inaccessible"):
manager.host_token()
assert original_read(token_path) == "a" * 48
monkeypatch.setattr(Path, "read_text", original_read)
assert manager.host_token() == "a" * 48
def test_live_provision_mutex_cannot_be_stolen_after_lease_ages(tmp_path, monkeypatch):
import hashlib
import os
import threading
import time
monkeypatch.setattr(hm, "get_temp_dir", lambda _name: tmp_path)
first = AccessibilityHelperManager()
second = AccessibilityHelperManager()
entered = threading.Event()
def contender():
with second._provision_mutex(SERIAL):
entered.set()
with ThreadPoolExecutor(max_workers=1) as executor:
with first._provision_mutex(SERIAL):
digest = hashlib.sha256(SERIAL.encode()).hexdigest()[:16]
path = tmp_path / f"{digest}.mutex"
old = time.time() - hm._PROVISION_MUTEX_STALE_SECONDS - 10
os.utime(path, (old, old))
future = executor.submit(contender)
stolen = entered.wait(0.2)
future.result(timeout=5)
assert not stolen
assert entered.is_set()
# --------------------------------------------------------------------------- #
# Provision
# --------------------------------------------------------------------------- #
@@ -64,7 +64,10 @@ async def test_device_context_uses_adb_size_without_starting_ui_client():
@pytest.mark.asyncio
async def test_task_that_never_acquires_queue_does_not_create_trace_session():
async def test_task_that_never_acquires_queue_does_not_create_trace_session(tmp_path, monkeypatch):
from artemis.runtime import task_result
monkeypatch.setattr(task_result, "TRACES_PATH", tmp_path)
agent = Agent()
agent._initialized = True
agent._device_context = DeviceContext(
@@ -86,6 +89,13 @@ async def test_task_that_never_acquires_queue_does_not_create_trace_session():
await agent.run_task(goal="queued task", profile="flash")
agent._prepare_tracing.assert_not_called()
outcomes = list(tmp_path.glob("*/run_outcome.json"))
assert len(outcomes) == 1
import json
outcome = json.loads(outcomes[0].read_text(encoding="utf-8"))
assert outcome["code"] == "initialization_failed"
assert "queue cancelled" in outcome["error"]
@pytest.mark.asyncio
+75
View File
@@ -0,0 +1,75 @@
"""Regression coverage for the prototype's python-jose to PyJWT migration."""
from datetime import UTC, datetime, timedelta
import importlib.util
from pathlib import Path
import sys
from types import ModuleType, SimpleNamespace
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
import jwt
import pytest
@pytest.fixture
def auth(monkeypatch):
# Load only the authentication module, without Docker, GCP or real settings.
config = ModuleType("app.config")
config.settings = SimpleNamespace(
JWT_SECRET_KEY="test-only-signing-key-with-at-least-32-bytes",
JWT_ALGORITHM="HS256",
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60,
)
monkeypatch.setitem(sys.modules, "app.config", config)
path = (
Path(__file__).resolve().parents[2] / "playground/backend_manager/app/auth/jwt_handler.py"
)
spec = importlib.util.spec_from_file_location("backend_manager_jwt_test", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.mark.asyncio
async def test_signed_token_preserves_claims_and_authenticated_user(auth):
token = auth.create_access_token("user@example.com", {"role": "tester"})
claims = auth.decode_access_token(token)
assert claims["sub"] == "user@example.com"
assert claims["role"] == "tester"
assert claims["iss"] == "artemis-backend-manager"
assert claims["exp"] > claims["iat"]
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
assert await auth.get_current_user(credentials) == "user@example.com"
@pytest.mark.parametrize("invalid", ["expired", "signature", "algorithm", "unsigned", "malformed"])
def test_invalid_tokens_remain_unauthorized(auth, invalid):
claims = {"sub": "user", "exp": datetime.now(UTC) + timedelta(minutes=5)}
key = auth.settings.JWT_SECRET_KEY
algorithm = "HS256"
if invalid == "expired":
claims["exp"] = datetime.now(UTC) - timedelta(minutes=5)
elif invalid == "signature":
key = "different-test-only-signing-key-with-32-bytes"
elif invalid == "algorithm":
algorithm = "HS384"
key = "another-test-only-key-long-enough-for-hs384-signatures"
elif invalid == "unsigned":
algorithm, key = "none", None
token = jwt.encode(claims, key, algorithm=algorithm)
if invalid == "malformed":
token = "not-a-jwt"
with pytest.raises(HTTPException) as error:
auth.decode_access_token(token)
assert error.value.status_code == 401
assert error.value.headers == {"WWW-Authenticate": "Bearer"}
@pytest.mark.asyncio
async def test_missing_subject_is_rejected(auth):
token = jwt.encode({}, auth.settings.JWT_SECRET_KEY, algorithm="HS256")
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
with pytest.raises(HTTPException) as error:
await auth.get_current_user(credentials)
assert error.value.status_code == 401
+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
+29
View File
@@ -163,6 +163,35 @@ def test_cli_trace_help():
assert "view" in result.output
@pytest.mark.asyncio
async def test_worker_initialization_failure_is_persisted_and_cleaned(monkeypatch):
from unittest.mock import AsyncMock, MagicMock
import artemis.interfaces.cli.commands.run as run_module
from artemis.runtime import task_result
monkeypatch.setenv("ARTEMIS_SESSION_ID", "test-session")
monkeypatch.setenv("ARTEMIS_TASK_INGRESS", "test")
fake_agent = MagicMock()
fake_agent.init = AsyncMock(side_effect=PermissionError("device initialization denied"))
fake_agent.clean = AsyncMock()
fake_agent.run_task = AsyncMock()
monkeypatch.setattr(run_module, "initialize_llm_config", MagicMock())
monkeypatch.setattr(run_module, "AgentProfile", MagicMock())
monkeypatch.setattr(run_module, "Builders", MagicMock())
monkeypatch.setattr(run_module, "Agent", MagicMock(return_value=fake_agent))
save = MagicMock()
monkeypatch.setattr(task_result, "save_failure_result", save)
with pytest.raises(PermissionError, match="initialization denied"):
await run_module.execute_task(
"inspect", execution_target="computer", session_id="test-session"
)
save.assert_called_once_with(
"test-session", "device initialization denied", code="initialization_failed"
)
fake_agent.clean.assert_awaited_once()
fake_agent.run_task.assert_not_awaited()
def test_cli_mcp_help():
"""Verify 'artemis mcp --help' lists server options."""
result = runner.invoke(app, ["mcp", "--help"])
+28
View File
@@ -75,6 +75,34 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry'
assert result.returncode == 0, result.stdout + result.stderr
def test_node_compatibility_matches_angular_supported_release_lines(tmp_path):
result = powershell(
tmp_path,
"""
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:LAUNCH_SCRIPT, [ref]$tokens, [ref]$errors)
if ($errors) { throw 'Launcher parse error' }
$checker = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Test-NodeCompatible' }, $true)
. ([scriptblock]::Create($checker.Extent.Text))
function Test-CommandExists { return $true }
function node { return $script:nodeVersion }
$cases = @{
'v20.19.0' = $false; 'v22.22.0' = $false; 'v22.22.2' = $false
'v22.22.3' = $true; 'v22.23.2' = $true; 'v23.0.0' = $false
'v24.14.0' = $false; 'v24.15.0' = $true; 'v24.22.1' = $true
'v24.15.0-rc.1' = $false; 'v25.0.0' = $false; 'v26.0.0' = $true
'not-a-version' = $false
}
foreach ($case in $cases.GetEnumerator()) {
$script:nodeVersion = $case.Key
if ((Test-NodeCompatible) -ne $case.Value) { throw "Wrong compatibility for $($case.Key)" }
}
""",
LAUNCH_SCRIPT=str(ROOT / "scripts/start.ps1"),
)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.parametrize("first_failure", ["http", "timeout", "hash", "all"])
def test_scrcpy_source_failover_checks_hash_before_installing(tmp_path, first_failure):
archive = tmp_path / "fixture.zip"
+34
View File
@@ -6,6 +6,40 @@ import pytest
from artemis.services import llm
@pytest.mark.asyncio
@pytest.mark.parametrize("replace_pause", [False, True])
async def test_cancelled_wait_clears_only_its_own_pause(tmp_path, monkeypatch, replace_pause):
pause = tmp_path / "pause"
pause.write_text("old request")
entered = asyncio.Event()
async def wait(_seconds):
entered.set()
await asyncio.Event().wait()
monkeypatch.setattr(llm.asyncio, "sleep", wait)
task = asyncio.create_task(llm._wait_for_resume(pause))
await entered.wait()
if replace_pause:
pause.write_text("a newer request owns this pause")
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert pause.exists() is replace_pause
@pytest.mark.asyncio
async def test_pause_deadline_clears_file(tmp_path, monkeypatch):
from unittest.mock import AsyncMock
pause = tmp_path / "pause"
pause.write_text("failed request")
monkeypatch.setattr(llm.settings, "LLM_PAUSE_TIMEOUT_SECONDS", 1)
monkeypatch.setattr(llm.asyncio, "sleep", AsyncMock())
assert await llm._wait_for_resume(pause) is False
assert not pause.exists()
@pytest.mark.asyncio
async def test_timeout_wrapper_cancels_llm_call_when_caller_is_cancelled():
started = asyncio.Event()
+53 -8
View File
@@ -5,7 +5,7 @@ import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import httpx2 as httpx
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
from langchain_core.runnables import RunnableLambda
from pydantic import BaseModel, SecretStr
@@ -59,26 +59,46 @@ def response_stream(status, arguments, name):
]
if status:
events.append({"type": "response." + status, "response": body})
return "".join("data: " + json.dumps(event) + "\n\n" for event in events) + "data: [DONE]\n\n"
return (
"".join("data: " + json.dumps(event, ensure_ascii=False) + "\n\n" for event in events)
+ "data: [DONE]\n\n"
)
class FragmentedEventStream(httpx.AsyncByteStream):
"""Split SSE frames and multibyte UTF-8 characters at transport boundaries."""
def __init__(self, text):
self.data = text.encode("utf-8")
async def __aiter__(self):
for offset in range(0, len(self.data), 4093):
yield self.data[offset : offset + 4093]
@pytest_asyncio.fixture
async def transport_case(monkeypatch, tmp_path):
async def transport_case(monkeypatch, tmp_path, request):
from langchain_openai import ChatOpenAI
import openai
requests, outputs, clients, events = [], [], [], []
def handle(request):
payload = json.loads(request.content)
def handle(http_request):
payload = json.loads(http_request.content)
requests.append(payload)
status, arguments = outputs.pop(0)
name = payload.get("tools", [{"name": "finish"}])[0]["name"]
if payload.get("stream"):
stream = response_stream(status, arguments, name)
body = (
{"stream": FragmentedEventStream(stream)}
if getattr(request, "param", None) == "fragmented"
else {"text": stream}
)
return httpx.Response(
200,
text=response_stream(status, arguments, name),
headers={"content-type": "text/event-stream"},
**body,
)
return httpx.Response(200, json=response_body(status, arguments, name))
@@ -175,15 +195,40 @@ async def test_completed_stream_returns_original_valid_arguments(transport_case)
assert result.tool_calls[0]["args"] == {"passed": True}
@pytest.mark.asyncio
@pytest.mark.parametrize("transport_case", ["fragmented"], indirect=True)
@pytest.mark.parametrize("status", ["completed", None])
async def test_large_unicode_tool_stream_requires_completion(transport_case, status):
case = transport_case
arguments = {"passed": True, "evidence": "截图已核验。\n" * 65536}
encoded = json.dumps(arguments, ensure_ascii=False)
assert len(encoded.encode("utf-8")) > 1024 * 1024
case.outputs.append((status, encoded))
if status == "completed":
result = await wrapped(case).complete([])
assert result.response_metadata["status"] == "completed"
assert result.tool_calls[0]["args"] == arguments
else:
with pytest.raises(LLMExhaustedError) as failure:
await wrapped(case).complete([])
assert failure.value.failure.category is FailureCategory.INVALID_OUTPUT
assert len(case.requests) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("recovers", [False, True])
@pytest.mark.parametrize("initial_status", [None, "failed"])
async def test_integrity_retry_is_bounded_and_keeps_existing_tool_evidence(
transport_case, recovers
transport_case, recovers, initial_status
):
case = transport_case
case.endpoint.max_retries = 5
case.outputs.extend(
[(None, '{"passed":false}'), ("completed" if recovers else None, '{"passed":true}')]
[
(initial_status, '{"passed":false}'),
("completed" if recovers else initial_status, '{"passed":true}'),
]
)
messages = [
HumanMessage(content="Inspect then verify"),
+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)
+17 -7
View File
@@ -23,14 +23,14 @@ from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
("configured", "expected"),
[
(
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json",
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
),
(
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
),
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/" + name,
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v4.json",
)
for name in ("catalog.json", "catalog-v3.json", "catalog-v4.json")
]
+ [
("https://example.test/catalog.json", "https://example.test/catalog.json"),
("https://example.test/catalog-v3.json", "https://example.test/catalog-v3.json"),
],
)
def test_workspace_catalog_routes_only_the_official_legacy_address(
@@ -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
+546 -391
View File
File diff suppressed because it is too large. Load diff