Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3ca69c1d | ||
|
|
486e6d550d | ||
|
|
b53ca18595 | ||
|
|
ac3d8cf54a | ||
|
|
e90da727d4 | ||
|
|
4dc2057d43 | ||
|
|
baf069854b | ||
|
|
6b2c0f199d |
No files matched your search
@@ -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 不叠加重试。
|
||||
|
||||
@@ -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 写入源码。
|
||||
|
||||
@@ -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 设置。
|
||||
@@ -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.3**:异常模型输出耗尽重试后只失败当前任务,避免触发全局暂停;补充请求关联和有界参数摘要,便于定位流式异常。见 [2.4.3 发行说明](docs/releases/2.4.3.md)。人工评审插件保持 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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("请先暂停批次")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 服务。
|
||||
- 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。
|
||||
- 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。
|
||||
|
||||
Generated
+1858
-2313
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.3",
|
||||
"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
|
||||
|
||||
+14
-1
@@ -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>}
|
||||
}
|
||||
+11
-1
@@ -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;}
|
||||
+25
-1
@@ -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');
|
||||
|
||||
+26
@@ -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);
|
||||
}
|
||||
+2
-1
@@ -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) -->
|
||||
|
||||
+23
-1
@@ -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');
|
||||
|
||||
+3
@@ -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();
|
||||
|
||||
+3
-1
@@ -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>
|
||||
}
|
||||
|
||||
+22
-6
@@ -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>
|
||||
|
||||
+10
-4
@@ -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; }
|
||||
|
||||
+95
-64
@@ -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);
|
||||
}));
|
||||
});
|
||||
+89
-22
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Bounded fingerprints of received argument deltas, without retaining text."""
|
||||
|
||||
import hashlib
|
||||
|
||||
|
||||
PREFIX_BYTES = 4096
|
||||
MAX_PREFIXES = 16
|
||||
MAX_TRACKED_TOOLS = 64
|
||||
|
||||
|
||||
class ArgumentFingerprint:
|
||||
def __init__(self):
|
||||
self.characters = 0
|
||||
self.bytes = 0
|
||||
self.deltas = 0
|
||||
self.encoding_valid = True
|
||||
self.digest = hashlib.sha256()
|
||||
self.prefixes: list[dict] = []
|
||||
|
||||
def observe(self, arguments: str) -> None:
|
||||
self.characters += len(arguments)
|
||||
if not arguments:
|
||||
return
|
||||
self.deltas += 1
|
||||
if not self.encoding_valid:
|
||||
return
|
||||
try:
|
||||
encoded = arguments.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
# Diagnostics must not repair, reject or otherwise alter the stream.
|
||||
self.encoding_valid = False
|
||||
return
|
||||
offset = 0
|
||||
while offset < len(encoded) and len(self.prefixes) < MAX_PREFIXES:
|
||||
size = min(PREFIX_BYTES - self.bytes % PREFIX_BYTES, len(encoded) - offset)
|
||||
self.digest.update(encoded[offset : offset + size])
|
||||
self.bytes += size
|
||||
offset += size
|
||||
if self.bytes % PREFIX_BYTES == 0:
|
||||
self.prefixes.append({"bytes": self.bytes, "sha256": self.digest.hexdigest()})
|
||||
if offset < len(encoded):
|
||||
self.digest.update(encoded[offset:])
|
||||
self.bytes += len(encoded) - offset
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
return {
|
||||
"argument_chars": self.characters,
|
||||
"argument_bytes": self.bytes if self.encoding_valid else None,
|
||||
"argument_sha256": self.digest.hexdigest() if self.encoding_valid else None,
|
||||
"argument_deltas": self.deltas,
|
||||
"argument_prefixes": [dict(prefix) for prefix in self.prefixes],
|
||||
"argument_encoding_valid": self.encoding_valid,
|
||||
}
|
||||
@@ -13,6 +13,7 @@ from uuid import uuid4
|
||||
from PIL import Image
|
||||
|
||||
from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID
|
||||
from artemis.llm.argument_fingerprint import ArgumentFingerprint, MAX_TRACKED_TOOLS
|
||||
from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExceeded
|
||||
from artemis.llm.reliability import LLMExhaustedError
|
||||
from artemis.llm.usage import usage_from_message
|
||||
@@ -107,6 +108,7 @@ class RequestAttempt:
|
||||
self.clock = time.monotonic()
|
||||
self.last_publish = self.clock
|
||||
self.tools: dict[int | str, dict] = {}
|
||||
self.tool_fingerprints: dict[int | str, ArgumentFingerprint] = {}
|
||||
self.review = ACTIVE_FINAL_REVIEW.get()
|
||||
if self.review:
|
||||
self.review.request_started(request_id, attempt)
|
||||
@@ -126,6 +128,9 @@ class RequestAttempt:
|
||||
"usage": None,
|
||||
"output_chunks": 0,
|
||||
"tool_argument_chars": 0,
|
||||
"tool_observation_layer": "langchain_tool_call_chunks",
|
||||
"argument_fingerprint_version": 1,
|
||||
"tool_stats_truncated": False,
|
||||
"tools": [],
|
||||
"step_id": str(self.step_id) if self.step_id else None,
|
||||
"parent_trace_id": str(self.parent_id) if self.parent_id else None,
|
||||
@@ -167,9 +172,10 @@ class RequestAttempt:
|
||||
self.data.update(
|
||||
headers_at=time.time(),
|
||||
http_status=response.status_code,
|
||||
server_request_id=_identifier(
|
||||
response.headers.get("x-request-id") or response.headers.get("request-id")
|
||||
),
|
||||
server_request_id=_identifier(response.headers.get("x-oneapi-request-id"))
|
||||
or _identifier(response.headers.get("x-request-id"))
|
||||
or _identifier(response.headers.get("request-id")),
|
||||
response_x_request_id=_identifier(response.headers.get("x-request-id")),
|
||||
server_trace_id=_identifier(response.headers.get("x-trace-id")),
|
||||
phase="waiting_output",
|
||||
)
|
||||
@@ -196,18 +202,31 @@ class RequestAttempt:
|
||||
self.data["usage"] = usage
|
||||
tool_chunks = getattr(chunk, "tool_call_chunks", None) or []
|
||||
# Responses exposes the same argument delta in content and tool_call_chunks.
|
||||
# Count only the latter; retain lengths/names, never argument bodies.
|
||||
# Count only the latter; retain bounded fingerprints, never argument bodies.
|
||||
for tool in tool_chunks:
|
||||
arguments = tool.get("args")
|
||||
if isinstance(arguments, str):
|
||||
self.data["tool_argument_chars"] += len(arguments)
|
||||
index = tool.get("index")
|
||||
key = index if index is not None else tool.get("id") or "unknown"
|
||||
stats = self.tools.setdefault(key, {"name": None, "argument_chars": 0})
|
||||
call_id = _identifier(tool.get("id"))
|
||||
key = index if index is not None else call_id or "unknown"
|
||||
if key not in self.tools and len(self.tools) >= MAX_TRACKED_TOOLS:
|
||||
self.data["tool_stats_truncated"] = True
|
||||
continue
|
||||
stats = self.tools.setdefault(key, {"name": None})
|
||||
if call_id:
|
||||
stats["call_id"] = call_id
|
||||
if index is not None:
|
||||
stats["chunk_index"] = index
|
||||
name = tool.get("name")
|
||||
if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name):
|
||||
stats["name"] = name
|
||||
arguments = tool.get("args")
|
||||
fingerprint = self.tool_fingerprints.get(key)
|
||||
if fingerprint is None:
|
||||
fingerprint = self.tool_fingerprints[key] = ArgumentFingerprint()
|
||||
if isinstance(arguments, str):
|
||||
stats["argument_chars"] += len(arguments)
|
||||
self.data["tool_argument_chars"] += len(arguments)
|
||||
fingerprint.observe(arguments)
|
||||
stats.update(fingerprint.snapshot())
|
||||
self.data["tools"] = list(self.tools.values())
|
||||
content = getattr(chunk, "content", None)
|
||||
substantive = isinstance(content, str) and bool(content)
|
||||
@@ -252,6 +271,8 @@ class RequestAttempt:
|
||||
if isinstance(status, int):
|
||||
self.data["http_status"] = status
|
||||
if request_id := _identifier(getattr(error, "request_id", None)):
|
||||
self.data["error_request_id"] = request_id
|
||||
if not self.data.get("server_request_id"):
|
||||
self.data["server_request_id"] = request_id
|
||||
if category == "timeout":
|
||||
self.data.setdefault("timeout_scope", "transport")
|
||||
@@ -301,6 +322,7 @@ async def observe_request_headers(request):
|
||||
if attempt is None:
|
||||
return
|
||||
request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"])
|
||||
request.headers.setdefault("X-Client-Attempt-Id", attempt.data["attempt_id"])
|
||||
routing = {}
|
||||
for name in ("thread-id", "thread_id", "session-id", "session_id"):
|
||||
value = request.headers.get(name)
|
||||
@@ -309,9 +331,12 @@ async def observe_request_headers(request):
|
||||
attempt.data.update(
|
||||
request_sent_at=time.time(),
|
||||
client_request_id=_identifier(request.headers.get("X-Client-Request-Id")),
|
||||
client_attempt_id=_identifier(request.headers.get("X-Client-Attempt-Id")),
|
||||
routing_header_fingerprints=routing,
|
||||
client_request_id_matches=request.headers.get("X-Client-Request-Id")
|
||||
== attempt.data["request_id"],
|
||||
client_attempt_id_matches=request.headers.get("X-Client-Attempt-Id")
|
||||
== attempt.data["attempt_id"],
|
||||
)
|
||||
attempt.publish()
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -147,7 +153,7 @@ class LLMPermanentError(LLMCallError):
|
||||
|
||||
|
||||
class LLMExhaustedError(LLMCallError):
|
||||
"""Retryable failures exhausted every attempt and the pause deadline."""
|
||||
"""Retryable failures exhausted attempts and any applicable pause deadline."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -23,7 +23,7 @@ class ToolArgumentOverflow(ValueError):
|
||||
"limit_chars": SMALL_ACTION_ARGUMENT_LIMIT,
|
||||
}
|
||||
super().__init__(
|
||||
f"模型生成的 {name} 参数异常膨胀({characters} 字符,"
|
||||
f"收到的 {name} 参数流超出安全限制({characters} 字符,"
|
||||
f"保护上限 {SMALL_ACTION_ARGUMENT_LIMIT});已关闭输出流,未执行本次动作。"
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("市场索引过大")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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":
|
||||
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:
|
||||
|
||||
@@ -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
@@ -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"
|
||||
+39
-10
@@ -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
|
||||
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:
|
||||
@@ -361,7 +381,7 @@ async def _run_with_recovery[T](
|
||||
immediately: retrying cannot help and pausing would hang the task.
|
||||
- When retryable attempts are exhausted: if a fallback model is waiting
|
||||
(with_fallback), raise LLMExhaustedError so it takes over immediately;
|
||||
final acceptance also raises instead of pausing an already-finished task.
|
||||
final acceptance and invalid output also raise instead of globally pausing.
|
||||
Other calls pause (bounded by settings.LLM_PAUSE_TIMEOUT_SECONDS) and
|
||||
retry the same request on resume.
|
||||
"""
|
||||
@@ -450,7 +470,7 @@ async def _run_with_recovery[T](
|
||||
)
|
||||
if failure.category is FailureCategory.INVALID_OUTPUT:
|
||||
# One fresh response can recover a generation loop. Repeated
|
||||
# runaway output must reach the existing fallback/pause path,
|
||||
# runaway output must reach fallback or fail this request,
|
||||
# even when ordinary network retries are configured higher.
|
||||
max_attempts = min(
|
||||
max_attempts, total_attempts + max(0, policy.max_attempts - attempt)
|
||||
@@ -485,7 +505,11 @@ async def _run_with_recovery[T](
|
||||
ACTIVE_ATTEMPT.reset(observation_token)
|
||||
|
||||
# Retryable attempts exhausted.
|
||||
if _FALLBACK_AVAILABLE.get() or ACTIVE_FINAL_REVIEW.get() is not None:
|
||||
if (
|
||||
_FALLBACK_AVAILABLE.get()
|
||||
or ACTIVE_FINAL_REVIEW.get() is not None
|
||||
or last_failure.category is FailureCategory.INVALID_OUTPUT
|
||||
):
|
||||
if failed_endpoints is not None and endpoint_key:
|
||||
failed_endpoints[endpoint_key] = last_error
|
||||
_record_llm_event(
|
||||
@@ -494,7 +518,11 @@ async def _run_with_recovery[T](
|
||||
"error": str(last_error)[:1000],
|
||||
"category": last_failure.category.value,
|
||||
"retryable": True,
|
||||
"handover": "fallback" if _FALLBACK_AVAILABLE.get() else "final_review_end",
|
||||
"handover": "fallback"
|
||||
if _FALLBACK_AVAILABLE.get()
|
||||
else "final_review_end"
|
||||
if ACTIVE_FINAL_REVIEW.get() is not None
|
||||
else "task_failed",
|
||||
},
|
||||
status="failed",
|
||||
)
|
||||
@@ -510,10 +538,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 +694,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
|
||||
@@ -1232,7 +1261,7 @@ async def _with_fallback[T](
|
||||
)
|
||||
|
||||
# The contextvar must be reset BEFORE fallback_call runs: the fallback has
|
||||
# no further fallback behind it, so its own exhaustion should pause.
|
||||
# no further fallback behind it, so apply its category's terminal policy.
|
||||
fallback_token = _FALLBACK_AVAILABLE.set(True)
|
||||
try:
|
||||
result = await main_call()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -112,3 +112,5 @@ Flash 跨端任务由模型通过内部 `update_task_progress` 记录短阶段
|
||||
批次保留原顺序、循环次数和失败停止策略,默认保留各项配置,也可明确选择统一模型和思考强度。探索用例固定收藏版本,首次无复跑配置时默认 Flash;“收藏为重新探索”会创建新的探索会话并沿用原探索配置。
|
||||
|
||||
单任务下载全部资料时,未变化的终态报告和资料包会复用;人工复验、结果和资料变化会重新生成。批次沿用已生成的报告包,人工复验后重新打包;旧目录格式的包首次下载时更新。运行中的下载是当前快照。录像分为“安卓”“电脑”“人工示范”目录,索引路径与实际文件一致。打包优先速度,对已压缩的图片、视频和 Office 文件直接存储,保持原画质。
|
||||
|
||||
安装并启用任务人工评审 1.1.0 后,批量模板在原七列之后增加“预置条件、操作步骤、预期结果”。三列独立保存原始用例,不追加到 Artemis 提示词;左侧按用例编号和标题展示,右侧可用上一条/下一条评审同批次已结束任务。未启用该插件时仍提供并校验原七列模板。
|
||||
@@ -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,39 @@
|
||||
# 模型流式参数诊断
|
||||
|
||||
小型内置动作的参数流仍受 16384 字符保护。超限时关闭输出流、丢弃不完整参数,不执行该次动作;一次新响应重试和可用备用端点耗尽后,只结束当前任务,不进入共享的模型暂停。批次仍遵循“失败即停止”、人工暂停和取消设置。网络故障、认证错误和最终验收保留各自的恢复规则。
|
||||
|
||||
## 请求关联
|
||||
|
||||
每个模型请求尝试沿用现有 `llm_diagnostic` 记录,不另建日志数据库。
|
||||
|
||||
| 字段 / 请求头 | 含义 |
|
||||
| --- | --- |
|
||||
| `request_id` / `X-Client-Request-Id` | 同一次恢复循环内保持稳定的逻辑请求编号;切换备用调用或恢复暂停后可能建立新的逻辑请求 |
|
||||
| `attempt_id` / `X-Client-Attempt-Id` | 本次客户端尝试编号;重试时变化,`previous_attempt_id` 关联前次尝试 |
|
||||
| `client_*_id_matches` | 实际发送的请求头是否与本地编号一致;调用方已指定的头保留原值 |
|
||||
| `server_request_id` | 首个有效的 `X-Oneapi-Request-Id`、`X-Request-Id` 或 `Request-Id`,按此前后顺序选择 |
|
||||
| `response_x_request_id` | 响应中的原始 `X-Request-Id`,不推定它来自模型上游 |
|
||||
| `error_request_id` | SDK 异常带回的编号,不覆盖已经从响应头读取的编号 |
|
||||
| `response_id` | SDK 实际返回的 Responses 响应编号 |
|
||||
|
||||
编号只接收有长度限制的可打印白名单字符。请求头不控制会话亲和性,不替换 `thread-id` / `session-id`。网关自身的重试应使用独立编号或序号,不能假装是一次新的客户端尝试。
|
||||
|
||||
## 参数指纹 v1
|
||||
|
||||
`tool_observation_layer=langchain_tool_call_chunks` 明确表示客户端观测的是 SDK 转换后的参数片段,不是原始 SSE 帧。每工具记录实际可得的 `call_id`、`chunk_index`、工具名,以及:
|
||||
|
||||
- `argument_chars`:参数片段的 Unicode 字符数之和。
|
||||
- `argument_bytes` / `argument_sha256`:按原顺序连接片段后,其 UTF-8 字节长度和 SHA256;不解析、重排参数 JSON,也不规范化空白或 Unicode。
|
||||
- `argument_deltas`:非空参数片段数,不能直接与网关原始事件数比较。
|
||||
- `argument_prefixes`:每 4096 字节的累计 SHA256,最多保留前 16 个。可以在分片大小不同、上游预读或中途取消时比较相同字节位置。
|
||||
- `argument_encoding_valid`:遇到不能编码为 UTF-8 的片段时为 false,当前总字节数和总哈希留空,避免伪造可比结果;诊断本身不改变执行流程。
|
||||
|
||||
最多记录 64 个工具;超过时设置 `tool_stats_truncated=true`,总参数字符计数仍继续。诊断不保留参数正文。哈希仅证明已收到的字节前缀,不能证明输出完整或动作成功;完整性仍由响应终止状态和解析校验决定。
|
||||
|
||||
跨三端优先使用 `response_id + call_id` 关联。LangChain 的 `chunk_index` 可能因文本或推理子块不同于原始 `output_index`,缺失编号时不能按数组位置猜测。正常的 `arguments.done`、`output_item.done`、`response.completed` 快照不重复计入参数;自定义工具转换、非空初始参数或协议异常需要先核对 SDK 映射,不能仅凭跨层哈希不同认定某一端重复发送。
|
||||
|
||||
历史日志没有保存的原始帧无法由新增诊断恢复。HTTP 200 只说明响应头已到达;客户端保护后出现的 499 或 Broken pipe 也不能证明最初原因。网关写入本机下游 writer 成功不等于客户端已消费或执行。
|
||||
|
||||
## 离线验证
|
||||
|
||||
`tests/unit/test_llm_stream_guard.py` 验证保护、有限重试、相同/不同备用端点,以及不会创建或清除其他请求的全局暂停。`tests/unit/test_request_diagnostics.py` 验证请求编号、超时重试、响应头及单次参数计数。`tests/unit/test_argument_fingerprint.py` 验证不同分片、中文和 emoji、固定字节前缀、有界统计与异常编码。这些测试不调用真实模型或设备。
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 与文档,未运行前端构建、真实设备或付费模型测试。
|
||||
@@ -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)
|
||||
@@ -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)。
|
||||
@@ -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,不启动或重启用户服务,不操作真实设备或业务任务。
|
||||
@@ -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,不重启用户服务,不执行设备或真实业务任务。
|
||||
@@ -0,0 +1,25 @@
|
||||
# ARTEMIS 2.4.3
|
||||
|
||||
本版修复异常模型输出影响整个批次的问题,并增加流式参数诊断。人工评审插件保持 **1.1.0**,SDK、其他插件及第三方依赖版本不变。
|
||||
|
||||
## 更新
|
||||
|
||||
- 已识别的异常输出(包括工具参数溢出、响应完整性校验失败)在有限重试和可用备用端点耗尽后,只结束当前任务,不再创建全局模型暂停。批次未开启“失败即停止”时可继续下一条;人工暂停、失败即停止、网络恢复及最终验收的原有规则保留。
|
||||
- 保留 16384 字符工具参数保护,关闭异常输出流,丢弃未完成内容,不执行本次异常动作。报错改为描述“收到的参数流”,避免在证据不足时直接归责模型。
|
||||
- 增加单次尝试请求头,区分逻辑请求、客户端尝试、网关响应与 SDK 异常中的请求编号,便于跨服务核对。
|
||||
- 对收到的工具参数片段记录字符数、UTF-8 字节数及有界 SHA256 前缀摘要,默认不新增参数正文记录。最多跟踪 64 个工具,每工具最多保留 16 个固定位置前缀。SDK 的 chunk_index 与原始协议 output_index 分开命名。
|
||||
|
||||
诊断字段及边界见 [流式参数诊断](../llm-stream-diagnostics.md)。本次主程序安装不会部署或升级 NEWAPI、CodexManager;服务端修复独立交付。
|
||||
|
||||
## 验证
|
||||
|
||||
- 默认 Python 回归:3284 通过,6 跳过,8 按标记排除;Ruff 格式和静态检查、质量基线、核心类型检查通过。
|
||||
- 五类合成输入经过实际 CodexManager、NEWAPI 转发函数及原 OpenAI/LangChain SDK 的离线文件串接:正常输入有效;超长、累计全文、重复及半关闭输入被拒绝;各层参数摘要一致,设备动作执行数为 0。
|
||||
- 最终源码 ZIP 的隔离解压、独立空 npm 缓存、官方源与国内镜像安装和生产构建结果随发布附件 `INSTALL-VERIFICATION.md` 提供。
|
||||
- 未重跑用户原业务批次、未做高并发压力测试或真实模型端到端验证。历史原始参数帧未保存,不能据此认定原事故参数膨胀首先发生在哪一层。
|
||||
|
||||
## 升级
|
||||
|
||||
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.3-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
|
||||
|
||||
人工评审插件 1.1.0 无需更新。本次发布不构建 Docker,不重启用户服务,不执行设备或真实业务任务。
|
||||
+15
-11
@@ -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)}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "2.3.0"
|
||||
version = "2.4.3"
|
||||
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
@@ -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 地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessageChunk
|
||||
|
||||
from artemis.llm.argument_fingerprint import ArgumentFingerprint, MAX_PREFIXES, PREFIX_BYTES
|
||||
from artemis.llm.diagnostics import RequestAttempt
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fragment_size", [1, 37, 4096, 100000])
|
||||
def test_fingerprints_match_at_byte_offsets_despite_unicode_and_different_fragmentation(
|
||||
fragment_size,
|
||||
):
|
||||
arguments = '{"target":"' + "私人内容🙂" * 8000 + '"}'
|
||||
expected = arguments.encode("utf-8")
|
||||
fingerprint = ArgumentFingerprint()
|
||||
for offset in range(0, len(arguments), fragment_size):
|
||||
fingerprint.observe(arguments[offset : offset + fragment_size])
|
||||
result = fingerprint.snapshot()
|
||||
assert result["argument_chars"] == len(arguments)
|
||||
assert result["argument_bytes"] == len(expected)
|
||||
assert result["argument_sha256"] == hashlib.sha256(expected).hexdigest()
|
||||
assert result["argument_encoding_valid"] is True
|
||||
assert result["argument_prefixes"] == [
|
||||
{"bytes": size, "sha256": hashlib.sha256(expected[:size]).hexdigest()}
|
||||
for size in range(PREFIX_BYTES, PREFIX_BYTES * MAX_PREFIXES + 1, PREFIX_BYTES)
|
||||
]
|
||||
assert "私人" not in json.dumps(result, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_invalid_utf8_is_marked_without_failing_or_forging_a_digest():
|
||||
fingerprint = ArgumentFingerprint()
|
||||
for value in ["safe", "\ud800", "later"]:
|
||||
fingerprint.observe(value)
|
||||
assert fingerprint.snapshot() == {
|
||||
"argument_chars": 10,
|
||||
"argument_bytes": None,
|
||||
"argument_sha256": None,
|
||||
"argument_deltas": 3,
|
||||
"argument_prefixes": [],
|
||||
"argument_encoding_valid": False,
|
||||
}
|
||||
|
||||
|
||||
def test_tool_tracking_is_bounded_but_total_count_keeps_all_received_arguments():
|
||||
attempt = RequestAttempt(
|
||||
None, request_id="bounded", attempt=1, timeout=300, max_retries=0, metadata={}
|
||||
)
|
||||
for index in range(65):
|
||||
attempt.chunk(
|
||||
AIMessageChunk(
|
||||
content=[],
|
||||
tool_call_chunks=[{"name": "click", "args": "{}", "index": index}],
|
||||
)
|
||||
)
|
||||
assert len(attempt.data["tools"]) == len(attempt.tool_fingerprints) == 64
|
||||
assert attempt.data["tool_stats_truncated"] is True
|
||||
assert attempt.data["tool_argument_chars"] == 130
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"
|
||||
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user