diff --git a/AGENTS.md b/AGENTS.md index 8caaec5..aa9fb6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.0.0 发行说明](docs/releases/2.0.0.md),再核对实时 Git 状态;`docs/plans/` 保留各自阶段的状态 | +| 继续历史开发、确认版本或发布 | [迁入时的开发快照](docs/development-handoff.md)、[2.0.1 发行说明](docs/releases/2.0.1.md),再核对实时 Git 状态;`docs/plans/` 保留各自阶段的状态 | ## Quick Commands diff --git a/DESIGN.md b/DESIGN.md index 90ef54a..3e37dd3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -52,7 +52,7 @@ components: ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产品包含 Flash、Pro、探索三种入口,按需调用安卓设备和电脑插件,提供批次、精选任务、历史、报告及证据。用户应能看懂当前在做什么、何时需要回答、哪些结果已有证据。 -本文记录主程序的职责边界及 `apps/showcase_ui` 的现有视觉与交互规则,不提出新的主题改版。远程 SDK 的协议边界见[局部设计](packages/artemis-client/DESIGN.md);独立插件仓库维护适配器设计和构建规则。阶段性进度见[开发快照](docs/development-handoff.md),发行状态见[2.0.0 说明](docs/releases/2.0.0.md)。 +本文记录主程序的职责边界及 `apps/showcase_ui` 的现有视觉与交互规则,不提出新的主题改版。远程 SDK 的协议边界见[局部设计](packages/artemis-client/DESIGN.md);独立插件仓库维护适配器设计和构建规则。阶段性进度见[开发快照](docs/development-handoff.md),发行状态见[2.0.1 说明](docs/releases/2.0.1.md)。 ## System architecture diff --git a/README.md b/README.md index dec0cf5..ce926ea 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 -**2.0.0 已发布**:新增交互探索、插件扩展和精选任务,增强跨端证据与 AI 接入。完整变化及升级步骤见 [2.0.0 发行说明](docs/releases/2.0.0.md)。 +**2.0.1 已发布**:修复 Python 3.12 全新安装时网页服务无法启动的问题,并统一不同 Python 版本的 MCP 工具描述。修复与升级步骤见 [2.0.1 发行说明](docs/releases/2.0.1.md);探索、插件和精选任务等功能见 [2.0.0 发行说明](docs/releases/2.0.0.md)。 ## 主要功能 diff --git a/apps/admin_console/services/favorites.py b/apps/admin_console/services/favorites.py index 4d8f4aa..5518372 100644 --- a/apps/admin_console/services/favorites.py +++ b/apps/admin_console/services/favorites.py @@ -1,5 +1,7 @@ """Independent favorite snapshots dispatched through the existing task services.""" +from __future__ import annotations + from copy import deepcopy import asyncio import hashlib diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json index 3f3e5b2..760674c 100644 --- a/apps/showcase_ui/package-lock.json +++ b/apps/showcase_ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis-showcase-ui", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis-showcase-ui", - "version": "2.0.0", + "version": "2.0.1", "dependencies": { "@angular/cdk": "^22.1.5", "@angular/common": "^22.1.4", diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json index d20e298..a07a0d9 100644 --- a/apps/showcase_ui/package.json +++ b/apps/showcase_ui/package.json @@ -1,6 +1,6 @@ { "name": "artemis-showcase-ui", - "version": "2.0.0", + "version": "2.0.1", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.json", diff --git a/artemis/core/diagnostics/engine.py b/artemis/core/diagnostics/engine.py index c1f22b7..3066f6e 100644 --- a/artemis/core/diagnostics/engine.py +++ b/artemis/core/diagnostics/engine.py @@ -145,16 +145,16 @@ class ReadinessEngine: device check. """ cacheable = categories is None - request_started = time.monotonic() + cache_at_request = self._report_cache if cacheable and not force_refresh: cached = self._cached_report(self._REPORT_CACHE_TTL_SECONDS) if cached is not None: return cached async with self._report_lock: - # A refresh that completed while this caller waited satisfies even a - # forced request that began before it, coalescing concurrent clicks. - if cacheable and self._report_cache_time >= request_started: + # A replacement report completed while this caller waited. Compare + # the snapshot itself: sequential requests can share a clock tick. + if cacheable and self._report_cache is not cache_at_request: cached = self._cached_report(float("inf")) if cached is not None: return cached diff --git a/artemis/exploration/demonstrations.py b/artemis/exploration/demonstrations.py index 595a424..ac4790a 100644 --- a/artemis/exploration/demonstrations.py +++ b/artemis/exploration/demonstrations.py @@ -1,5 +1,7 @@ """Durable manual-demonstration commands, sharing the automation transaction.""" +from __future__ import annotations + import time from uuid import uuid4 diff --git a/artemis/exploration/store.py b/artemis/exploration/store.py index 45f955e..6958709 100644 --- a/artemis/exploration/store.py +++ b/artemis/exploration/store.py @@ -4,6 +4,8 @@ The queue remains the sole scheduler. These records describe a conversation and its immutable evidence/case versions, not a second execution state machine. """ +from __future__ import annotations + from collections.abc import Callable from contextlib import contextmanager import hashlib diff --git a/artemis/mcp/adb_server.py b/artemis/mcp/adb_server.py index fadbcda..5fdedcf 100644 --- a/artemis/mcp/adb_server.py +++ b/artemis/mcp/adb_server.py @@ -27,7 +27,9 @@ if _REPO_ROOT not in sys.path: # pylint: disable=wrong-import-position from adbutils import AdbClient -from mcp.server.fastmcp import Context, FastMCP +from mcp.server.fastmcp import Context + +from artemis.mcp.tool_server import ArtemisFastMCP logger = logging.getLogger(__name__) @@ -86,7 +88,7 @@ def configure_stdio_mode() -> None: # Create minimal MCP server -mcp = FastMCP("Android_ADB_Controller") +mcp = ArtemisFastMCP("Android_ADB_Controller") _GLOBAL_CONTROLLER = None _CONTROLLERS: dict[str, Any] = {} diff --git a/artemis/mcp/tool_server.py b/artemis/mcp/tool_server.py new file mode 100644 index 0000000..74a4778 --- /dev/null +++ b/artemis/mcp/tool_server.py @@ -0,0 +1,37 @@ +"""Keep ARTEMIS tool descriptions stable across supported Python versions.""" + +from textwrap import dedent + +from mcp.server.fastmcp import FastMCP + + +class ArtemisFastMCP(FastMCP): + """Normalize docstring indentation before FastMCP publishes tool metadata.""" + + def add_tool( + self, + fn, + name=None, + title=None, + description=None, + annotations=None, + icons=None, + meta=None, + structured_output=None, + ): + if not description: + # Python 3.13+ removes this indentation at compilation; 3.12 leaves + # it in __doc__. Retain blank lines, including the final newline, + # so the existing catalog and pinned schemas stay byte-for-byte equal. + first, separator, body = (fn.__doc__ or "").expandtabs().partition("\n") + description = first.lstrip() + separator + dedent(body) + super().add_tool( + fn, + name=name, + title=title, + description=description, + annotations=annotations, + icons=icons, + meta=meta, + structured_output=structured_output, + ) diff --git a/docs/releases/2.0.1.md b/docs/releases/2.0.1.md new file mode 100644 index 0000000..07a2a9f --- /dev/null +++ b/docs/releases/2.0.1.md @@ -0,0 +1,27 @@ +# ARTEMIS-Eapil 2.0.1 + +2.0.1 修复 Windows 全新安装使用 Python 3.12 时,网页构建成功后管理服务立即退出的问题。已有 2.0.0 安装也建议升级;探索、插件和精选任务等功能继续沿用 [2.0.0](2.0.0.md)。 + +## 修复内容 + +- **全新安装可以启动网页服务**:探索存储、人工示范和收藏服务中的 `list` 方法遮蔽了同名内置类型,导致 Python 3.12 在加载后续类型注解时抛出 `TypeError: 'function' object is not subscriptable`。统一延迟求值,并增加冷启动与运行时类型注解回归,覆盖较新 Python 延迟暴露同一问题的情况。 +- **诊断强制刷新按请求生效**:连续请求落在同一个时钟刻度时,仍会重新扫描;并发请求继续合并,避免重复检查。 +- **MCP 工具描述跨版本一致**:统一 Python 3.12 与较新版本编译后文档字符串的缩进差异,保持工具目录、固定协议快照及现有显式描述一致。 + +本版不要求升级 Python,也不更改已有配置、任务历史或插件版本。 + +## 升级与安装 + +1. 先结束正在运行的任务,再运行 `update.bat`;国内网络使用 `update-cn.bat`。升级程序会停止当前项目的服务与任务进程,并同步依赖环境。 +2. 完成后运行 `start.bat` / `start-cn.bat`,等待网页构建并打开页面。启动脚本只检查更新,不代替升级脚本。 +3. 全新安装下载 `artemis-eapil-2.0.1-source.zip`,解压后运行上述启动脚本。用附件 `SHA256SUMS.txt` 校验下载完整性。 + +源码包保留浅层 Git 更新能力,不包含依赖环境、用户密钥、任务历史或预编译网页。升级主程序不会自动升级已安装插件。 + +## 发布验证 + +- Python 3.12 与 3.14 的默认后端测试分别为 **2952 项通过、6 项跳过、8 项按标记排除**。新增启动回归、同一时钟刻度下的诊断刷新回归及跨版本 MCP 描述回归;现有工具目录与协议快照无需修改。 +- 在 Python 3.12 隔离环境运行真实服务生命周期,首页、API 文档、收藏和探索接口均返回 HTTP 200,服务正常关闭。仅替代硬件预热和防休眠入口,数据库、队列、IPC、MCP 会话及 HTTP 服务沿用真实实现。 +- 前端 **290 项测试通过**,2.0.1 生产构建成功。Ruff 格式与静态检查、质量基线和核心类型检查通过。 + +本次未调用付费模型或执行设备业务任务,未替用户升级或重启现有安装。 diff --git a/mcp_server/base.py b/mcp_server/base.py index b3c3901..20d31fd 100644 --- a/mcp_server/base.py +++ b/mcp_server/base.py @@ -16,7 +16,7 @@ import logging -from mcp.server.fastmcp import FastMCP +from artemis.mcp.tool_server import ArtemisFastMCP logger = logging.getLogger(__name__) @@ -30,7 +30,7 @@ except Exception as exc: logger.debug("FastMCP Settings.model_rebuild skipped: %s", exc, exc_info=True) # Define the shared FastMCP instance for external IDE and agent clients. -mcp = FastMCP( +mcp = ArtemisFastMCP( "artemis", instructions=( "ARTEMIS executes Android, computer and mixed tests with Flash, Pro or interactive exploration. The user starts the service; MCP only connects. Use the official artemis-use Skill for workflows and plugin development. " diff --git a/mcp_server/shared.py b/mcp_server/shared.py index 325419b..4b15212 100644 --- a/mcp_server/shared.py +++ b/mcp_server/shared.py @@ -11,9 +11,9 @@ import logging import threading from typing import get_type_hints -from mcp.server.fastmcp import FastMCP from starlette.responses import JSONResponse +from artemis.mcp.tool_server import ArtemisFastMCP from artemis.service_registry import TOKEN_HEADER @@ -85,7 +85,7 @@ def create_shared_mcp(): from mcp_server import tools executor = ToolExecutor() - shared = FastMCP( + shared = ArtemisFastMCP( "artemis", instructions=definitions.instructions, streamable_http_path="/", diff --git a/pyproject.toml b/pyproject.toml index a1a9292..d47fb76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ [project] name = "artemis" -version = "2.0.0" +version = "2.0.1" description = "Autonomous AI Assistant for Mobile Automation" authors = [ diff --git a/tests/unit/core/test_diagnostics.py b/tests/unit/core/test_diagnostics.py index f8cef93..171f20f 100644 --- a/tests/unit/core/test_diagnostics.py +++ b/tests/unit/core/test_diagnostics.py @@ -15,6 +15,7 @@ """Unit tests for Artemis System Diagnostics & Readiness Engine.""" import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest @@ -173,7 +174,13 @@ async def test_dashboard_reuses_recent_confirmed_state_on_one_timeout(monkeypatc @pytest.mark.asyncio -async def test_readiness_engine_coalesces_concurrent_full_scans(): +@pytest.mark.parametrize("same_clock_tick", [False, True]) +async def test_readiness_engine_coalesces_concurrent_full_scans(same_clock_tick, monkeypatch): + if same_clock_tick: + monkeypatch.setattr( + "artemis.core.diagnostics.engine.time", + SimpleNamespace(monotonic=lambda: 100.0, time=lambda: 100.0), + ) engine = ReadinessEngine() result = ProbeResult( id="test_probe", @@ -200,7 +207,13 @@ async def test_readiness_engine_coalesces_concurrent_full_scans(): @pytest.mark.asyncio -async def test_readiness_engine_reuses_cache_until_forced(): +@pytest.mark.parametrize("same_clock_tick", [False, True]) +async def test_readiness_engine_reuses_cache_until_forced(same_clock_tick, monkeypatch): + if same_clock_tick: + monkeypatch.setattr( + "artemis.core.diagnostics.engine.time", + SimpleNamespace(monotonic=lambda: 100.0, time=lambda: 100.0), + ) engine = ReadinessEngine() result = ProbeResult( id="test_probe", diff --git a/tests/unit/mcp/test_tool_descriptions.py b/tests/unit/mcp/test_tool_descriptions.py new file mode 100644 index 0000000..e1cdc02 --- /dev/null +++ b/tests/unit/mcp/test_tool_descriptions.py @@ -0,0 +1,57 @@ +from mcp.types import ToolAnnotations +import pytest + +from artemis.mcp.tool_server import ArtemisFastMCP + + +@pytest.mark.asyncio +@pytest.mark.parametrize("registration", ["decorator", "add_tool"]) +@pytest.mark.parametrize( + ("docstring", "expected"), + [ + ("Summary.\n\n Details.\n Nested.\n ", "Summary.\n\nDetails.\n Nested.\n"), + ("Summary.\n\nDetails.\n Nested.\n", "Summary.\n\nDetails.\n Nested.\n"), + ("One line.", "One line."), + ], +) +async def test_tool_descriptions_match_across_docstring_formats(registration, docstring, expected): + server = ArtemisFastMCP("description-test") + + def probe(count: int = 1) -> str: + return str(count) + + probe.__doc__ = docstring + if registration == "decorator": + assert server.tool()(probe) is probe + else: + server.add_tool(probe) + (tool,) = await server.list_tools() + assert tool.description == expected + assert tool.inputSchema["properties"]["count"]["type"] == "integer" + assert probe.__doc__ == docstring + + +@pytest.mark.asyncio +async def test_explicit_tool_metadata_is_preserved(): + server = ArtemisFastMCP("description-test") + + def probe() -> str: + """The fallback docstring.""" + return "ok" + + description = "Explicit description.\n Keep this indentation.\n" + server.add_tool( + probe, + "renamed_probe", + "Probe title", + description, + annotations=ToolAnnotations(readOnlyHint=True), + meta={"source": "test"}, + structured_output=False, + ) + (tool,) = await server.list_tools() + assert tool.name == "renamed_probe" + assert tool.title == "Probe title" + assert tool.description == description + assert tool.annotations.readOnlyHint is True + assert tool.meta == {"source": "test"} diff --git a/tests/unit/test_startup_imports.py b/tests/unit/test_startup_imports.py new file mode 100644 index 0000000..5218850 --- /dev/null +++ b/tests/unit/test_startup_imports.py @@ -0,0 +1,61 @@ +"""Cold imports and runtime annotations must work on the installer's Python.""" + +from importlib import import_module +import inspect +from pathlib import Path +import subprocess +import sys +from textwrap import dedent +from typing import get_type_hints + +import pytest + + +@pytest.mark.parametrize( + ("module_name", "class_name"), + [ + ("artemis.exploration.store", "ExplorationStore"), + ("artemis.exploration.demonstrations", "Demonstrations"), + ("apps.admin_console.services.favorites", "FavoriteService"), + ], +) +def test_startup_service_annotations_do_not_shadow_builtin_collections(module_name, class_name): + service = getattr(import_module(module_name), class_name) + # Python 3.14 defers these errors until annotations are read. Evaluating them + # also detects the import failure seen immediately on Python 3.12 and 3.13. + assert get_type_hints(service.list)["return"] == list[dict] + for method in vars(service).values(): + if inspect.isfunction(method): + get_type_hints(method) + + +def test_ui_server_cold_import_serves_empty_workspace(tmp_path, monkeypatch): + monkeypatch.setenv("ARTEMIS_APP_DIR", str(tmp_path / "app")) + monkeypatch.setenv("ARTEMIS_TRACES_DIR", str(tmp_path / "traces")) + code = dedent(""" + from fastapi.testclient import TestClient + from apps.admin_console.server import app + + # No lifespan: validate startup imports and HTTP routes without device + # discovery, background workers, or a running user's service. + client = TestClient(app, base_url="http://localhost") + try: + for path in ("/api/automation/favorites", "/api/explorations"): + response = client.get(path) + assert response.status_code == 200, response.text + assert response.json() == [], response.text + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert "/api/explorations" in response.json()["paths"] + finally: + client.close() + """) + result = subprocess.run( + [sys.executable, "-X", "utf8", "-c", code], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/uv.lock b/uv.lock index 845d221..56765b6 100644 --- a/uv.lock +++ b/uv.lock @@ -206,7 +206,7 @@ wheels = [ [[package]] name = "artemis" -version = "2.0.0" +version = "2.0.1" source = { editable = "." } dependencies = [ { name = "adbutils" },