{{demo.error}}
} + @for(track of demo.media; track $index){@for(segment of track.segments; track segment.file){尚未选择执行记录
@@ -550,6 +561,19 @@ limitations under the License.{{ parsedOut.title }}
- } - @if (parsedOut.milestones.length > 0) { -{{ seg.text }}
- }
- @if (!seg.bold && !seg.code) {
- {{ seg.text }}
- }
- }
-
-
- @if (line.type === 'verify') {
- {{ line.checkKind || 'verify' }}{{ line.atEnd ? '@end' : '' }}
- }
- @if (line.type === 'finding') {
- finding
- }
- @for (seg of line.segments; track seg) {
- @if (seg.bold) {
- {{ seg.text }}
- }
- @if (seg.code) {
- {{ seg.text }}
- }
- @if (!seg.bold && !seg.code) {
- {{ seg.text }}
- }
- }
-
当前任务报告
@if (report(); as r) {{{r.error}}
报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。
-结论与发现
结论与发现
从任务队列或历史记录中选择任务,即可下载报告。
} @if (liveTask() && agent.currentSessionId()) {导入任务
每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。
@@ -70,12 +70,13 @@ @if(!validRepetitions()){循环次数须为正整数,执行总数不超过 {{maxExecutions}}。
} - + }批次记录
@for(b of batches(); track b.id){报告生成失败:{{b.report_error}}
} - @for(j of b.jobs; track j.session_id){描述大概流程,边执行边交流。可以纠正方向或亲自示范,结束后生成可复用的用例。
等待回答和人工示范不占执行时间。这组设置只用于探索。
+ @if(!explorationConfigValid()){请填写模型,并将提前提醒设为小于示范时限。
} + +{{ item.title }}
-{{ item.description }}
-插件
为安卓测试扩展电脑等操作能力,AI 按任务需要自主调用。
正在读取插件…
} + @else if (!installed().length) {还没有安装插件
从市场安装经过验证的扩展,或在下方导入自己的插件。
{{plugin.manifest.name}}
{{plugin.manifest.description || '暂无描述'}}
+ +高级接入 · 本地插件与自定义 MCP
本地插件需要包含 plugin.json;导入后点击启用,按提示完成设置。
+ +添加自定义 MCP / Skill
正在加载插件市场
正在获取插件与验证记录…
暂无可安装插件
当前市场尚未收录安装包,可稍后刷新。
{{entry.manifest.name}}
{{entry.manifest.description}}
验证范围与限制
{{entry.verification}}
启用{{plugin.manifest.name}}?
+ @if (plugin.manifest.resources.includes('desktop:local')) { +允许 ARTEMIS 在测试需要时查看本机窗口,并操作桌面应用和浏览器。
+ } @else {允许 ARTEMIS 在测试需要时使用此插件提供的工具。
} + + @if (error()) { } + +确认 {{resource}} 已停止操作
请先核对目标设备或远程服务,确认之前的插件动作已经停止,并检查实际状态。解除后任务才可再次操作此资源。
{{plugin.manifest.name}} · 设置
{{hasSettings(plugin) ? '填写插件需要的信息,高级选项通常无需修改。' : '此插件无需填写参数,高级选项通常无需修改。'}}
高级设置 · 多套参数与控制范围
+仅在需要切换不同设备、账号或限制操作范围时使用。
+ @if (enableAfterSave && !defaultProfile(plugin)) {请选择一套设置,并勾选「默认使用这套设置」,以便 AI 自动调用。
} + + + + +卸载 {{plugin.manifest.name}}?
此插件及其保存的设置将不再供新任务使用。
loading
+""" + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def adb(*arguments, check=True): + return subprocess.run( + adb_command(["-s", serial, *arguments]), + capture_output=True, + check=check, + timeout=15, + ) + + lease = DeviceExecutionLock(serial, description="Dynamic helper observation regression") + lease.acquire(blocking=False) + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + client = AccessibilityClient(serial, provision_on_connect=False) + device_port = None + try: + client.connect() + # Let ADB allocate an unused reverse port; never replace a user's mapping. + device_port = adb("reverse", "tcp:0", f"tcp:{server.server_port}").stdout.decode().strip() + assert device_port.isdigit(), "ADB did not allocate a reverse port" + adb( + "shell", + "am", + "start", + "-a", + "android.intent.action.VIEW", + "-d", + f"http://127.0.0.1:{device_port}/", + "-p", + "com.android.chrome", + ) + + def await_observation(expected, green): + deadline = time.monotonic() + 15 + observed_text = [] + while time.monotonic() < deadline: + observation = client.get_screen_data() + observed_text = [element.get("text") for element in observation.elements] + raw = base64.b64decode(observation.base64) + with Image.open(BytesIO(raw)) as picture: + pixels = list(picture.convert("RGB").resize((32, 32)).getdata()) + count = sum( + g > 150 and r < 100 if green else r > 150 and g < 100 for r, g, _ in pixels + ) + if expected in observed_text and count > len(pixels) // 4: + (tmp_path / f"{expected}.jpg").write_bytes(raw) + return + time.sleep(0.5) + pytest.fail(f"Pixels and UI hierarchy did not converge to {expected}: {observed_text}") + + await_observation("baseline", False) + state["value"] = marker + await_observation(marker, True) + state["value"] = "baseline" + await_observation("baseline", False) + finally: + client.disconnect() + if device_port and device_port.isdigit(): + adb("reverse", "--remove", f"tcp:{device_port}", check=False) + server.shutdown() + server.server_close() + worker.join(timeout=5) + lease.release() diff --git a/tests/unit/admin_console/test_automation.py b/tests/unit/admin_console/test_automation.py index f701d98..4a3a508 100644 --- a/tests/unit/admin_console/test_automation.py +++ b/tests/unit/admin_console/test_automation.py @@ -158,6 +158,7 @@ def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch): path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip" path.parent.mkdir(parents=True) path.write_bytes(b"existing archive") + reports.remember(path, reports.BATCH_ARCHIVE_SIGNATURE) exporter = Mock(side_effect=AssertionError("archive must not be rebuilt")) monkeypatch.setattr(reports, "export_batch", exporter) base = f"/api/automation/batches/{batch_id}/archive" @@ -166,6 +167,21 @@ def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch): exporter.assert_not_called() +def test_legacy_batch_archive_is_rebuilt_with_current_recording_layout(client, isolated): + batch = service.create_batch(payload()) + batch_id = batch["id"] + isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True}) + path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip" + path.parent.mkdir(parents=True) + path.write_bytes(b"legacy archive") + base = f"/api/automation/batches/{batch_id}/archive" + assert client.get(base + "/status").json()["status"] == "idle" + response = client.get(base) + assert response.status_code == 200 + assert zipfile.is_zipfile(BytesIO(response.content)) + assert client.get(base + "/status").json()["status"] == "ready" + + def test_preview_does_not_enqueue(client, isolated): response = client.post( "/api/automation/import/preview", @@ -583,7 +599,12 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch): assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"] archive = reports.export_batch(batch["id"]) with zipfile.ZipFile(archive) as z: - assert len(z.namelist()) == 6 # two summary files + two files per task + assert len(z.namelist()) == 10 # summaries + reports, diagnostics and evidence per task + diagnostics = [name for name in z.namelist() if name.endswith("模型请求诊断.json")] + assert len(diagnostics) == 2 + for name in diagnostics: + assert json.loads(z.read(name))["schema_version"] == 1 + assert len([name for name in z.namelist() if name.endswith("证据索引.json")]) == 2 assert sum(n.endswith(".docx") for n in z.namelist()) == 3 @@ -988,6 +1009,10 @@ def test_task_material_zip_contains_ordered_evidence_and_reports( ): job = isolated.jobs(service.create_batch(payload())["id"])[0] isolated.save_job({**job, "case": {**job["case"], "case_id": "1.1"}}) + artifact_dir = reports.TRACES_PATH / job["session_id"] / "artifacts" / "android" + artifact_dir.mkdir(parents=True) + (artifact_dir / "安卓截图.png").write_bytes(b"native png") + (artifact_dir / ".artemis-partial.tmp").write_bytes(b"incomplete") evidence = tmp_path / "evidence" evidence.mkdir() first = evidence / "first.png" @@ -1012,12 +1037,97 @@ def test_task_material_zip_contains_ordered_evidence_and_reports( assert files.index("1.1/步骤截图/0001_步骤0001_操作前.png") < files.index( "1.1/步骤截图/0002_步骤0002_操作后.png" ) - assert archive.read("1.1/任务录屏/0001_recording.mp4") == b"recording.mp4" + assert archive.read("1.1/任务录屏/安卓/0001_recording.mp4") == b"recording.mp4" manifest = json.loads(archive.read("1.1/文件清单.json")) assert manifest["session_id"] == job["session_id"] + assert manifest["artifacts"] == ["安卓截图.png"] + assert archive.read("1.1/任务文件/安卓/安卓截图.png") == b"native png" + assert not any("partial" in name for name in files) assert "1.1-all.zip" in response.headers["content-disposition"] +def test_batch_zip_contains_each_tasks_android_artifacts(isolated): + batch = service.create_batch(payload()) + for index, job in enumerate(isolated.jobs(batch["id"])): + root = reports.TRACES_PATH / job["session_id"] / "artifacts" / "android" + root.mkdir(parents=True) + (root / "结果.txt").write_text(f"任务{index}", encoding="utf-8") + with zipfile.ZipFile(reports.export_batch(batch["id"])) as archive: + names = [name for name in archive.namelist() if name.endswith("任务文件/安卓/结果.txt")] + assert len(names) == 2 + assert {archive.read(name).decode() for name in names} == {"任务0", "任务1"} + + +def test_all_material_cache_reuses_reports_and_invalidates_on_review_or_new_files( + isolated, monkeypatch +): + job = isolated.jobs(service.create_batch(payload())["id"])[0] + job["status"] = "completed" + isolated.save_job(job) + word = Mock(wraps=reports.word_report) + monkeypatch.setattr(reports, "word_report", word) + monkeypatch.setattr( + reports, + "diagnostics_json", + lambda sid: json.dumps({"requests": [], "collected_at": time.time()}), + ) + first = reports.export_session_media(job["session_id"], "all") + first_bytes = first.read_bytes() + reports.export_session_media(job["session_id"], "all") + assert word.call_count == 1 + assert first.read_bytes() == first_bytes + isolated.save_case_review(job["session_id"], "passed", "人工核对") + reports.export_session_media(job["session_id"], "all") + assert word.call_count == 2 + folder = reports.TRACES_PATH / job["session_id"] / "artifacts" / "android" + folder.mkdir(parents=True) + (folder / "新资料.txt").write_text("new evidence", encoding="utf-8") + reports.export_session_media(job["session_id"], "all") + with zipfile.ZipFile(first) as archive: + assert any(name.endswith("任务文件/安卓/新资料.txt") for name in archive.namelist()) + assert not json.loads( + archive.read(next(n for n in archive.namelist() if n.endswith("文件清单.json"))) + )["snapshot"] + # File-only changes rebuild the ZIP while keeping unchanged documents. + assert word.call_count == 2 + + +def test_different_tasks_export_concurrently_and_running_exports_are_snapshots( + isolated, monkeypatch +): + from concurrent.futures import ThreadPoolExecutor + + jobs = isolated.jobs(service.create_batch(payload())["id"]) + barrier = threading.Barrier(2) + + def generate(report, path): + barrier.wait(timeout=5) + path.write_bytes(b"generated report") + + monkeypatch.setattr(reports, "word_report", generate) + with ThreadPoolExecutor(max_workers=2) as pool: + exports = [pool.submit(reports.export_session, j["session_id"], ["docx"]) for j in jobs] + assert all(result.result(timeout=10)[0].is_file() for result in exports) + count = Mock(side_effect=lambda report, path: path.write_bytes(b"snapshot")) + monkeypatch.setattr(reports, "word_report", count) + for _ in range(2): + reports.export_session(jobs[0]["session_id"], ["docx"]) + assert count.call_count == 2 + + +def test_android_artifact_export_does_not_follow_other_session_links(isolated): + root = reports.TRACES_PATH / "session" / "artifacts" + root.mkdir(parents=True) + other = reports.TRACES_PATH / "other-session" + other.mkdir() + (other / "private.txt").write_text("other task") + try: + (root / "android").symlink_to(other, target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + assert reports.session_artifacts({"id": "session"}) == [] + + def test_video_export_accepts_finalized_worker_trace_and_rejects_external_path( isolated, tmp_path, monkeypatch ): @@ -1041,8 +1151,8 @@ def test_video_export_accepts_finalized_worker_trace_and_rejects_external_path( with zipfile.ZipFile(archive_path) as archive: names = archive.namelist() assert names == [ - "任务录屏/0001_web_123_" + session_id[:8] + "_PASS_2026-09-28_recording.mp4", - "任务录屏/0002_resume_0001_recording.mp4", + "任务录屏/安卓/0001_web_123_" + session_id[:8] + "_PASS_2026-09-28_recording.mp4", + "任务录屏/安卓/0002_resume_0001_recording.mp4", "文件清单.json", ] assert archive.read(names[0]) == b"first segment" @@ -1086,6 +1196,34 @@ def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated): ) +def test_history_returns_persisted_execution_mode_without_inventing_legacy_mode(client, isolated): + with isolated.connection() as connection: + for sid, device_info in [ + ("flash", '{"profile":"flash"}'), + ("pro", '{"profile":"pro"}'), + ("legacy", "not json"), + ]: + connection.execute( + "INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info) VALUES (?,?,?,?,?)", + (sid, sid, 100, "completed", device_info), + ) + connection.commit() + isolated.save_job( + { + "session_id": "queued-only", + "status": "failed", + "case": {"name": "no device"}, + "task_config": {"profile": "pro"}, + "created_at": 100, + } + ) + rows = {row["session_id"]: row for row in client.get("/api/automation/history").json()["items"]} + assert rows["flash"]["profile"] == "flash" + assert rows["pro"]["profile"] == "pro" + assert rows["legacy"]["profile"] is None + assert rows["queued-only"]["profile"] == "pro" + + def test_batch_submission_is_atomic_and_idempotent(client, isolated): value = payload(request_id="same-request") first = client.post("/api/automation/batches", json=value) @@ -1112,3 +1250,131 @@ def test_removed_replay_is_inert_but_history_survives(client, isolated): assert client.post(f"/api/automation/batches/{batch['id']}/retry_failed").status_code == 409 assert client.get("/api/automation/workflows").status_code == 404 assert client.get("/api/automation/history").json()["total"] == 2 + + +def test_exploration_history_groups_before_pagination_and_preserves_validations(client, isolated): + with isolated.connection() as conn: + for i, kind in enumerate(["exploration", "exploration", "validation", "validation"]): + sid = f"run-{i}" + conn.execute( + "INSERT INTO sessions(session_id,initial_goal,start_time,status) VALUES (?,?,?,?)", + (sid, "目标", i + 1, "completed"), + ) + record = { + "exploration_id": "conversation", + "kind": kind, + "created_at": i + 1, + "status": "completed", + "case_version": 1, + } + conn.execute( + "INSERT INTO automation_records VALUES (?,?,?,?)", + ("exploration_run", sid, json.dumps(record), i + 1), + ) + conn.commit() + all_rows = client.get("/api/automation/history").json() + assert all_rows["total"] == 3 + assert {r["session_id"] for r in all_rows["items"]} == {"run-1", "run-2", "run-3"} + page = client.get("/api/automation/history?batch_id=__exploration__&size=1").json() + assert page["total"] == 1 + assert page["items"][0]["session_id"] == "run-1" + assert page["items"][0]["exploration_run_count"] == 2 + ordinary = client.get("/api/automation/history?batch_id=__single__").json() + assert ordinary["total"] == 2 + with isolated.connection() as conn: + conn.execute( + "INSERT INTO automation_records VALUES (?,?,?,?)", + ( + "exploration", + "conversation", + json.dumps({"status": "paused", "active_run_id": None}), + 5, + ), + ) + conn.commit() + assert client.get("/api/automation/history?batch_id=__exploration__").json()["total"] == 0 + + +def test_validation_history_uses_case_title_after_loading_its_job(client, isolated): + from artemis.config.exploration import ExplorationConfig + from artemis.exploration.models import CaseDraft + from artemis.exploration.store import ExplorationStore + + repository = ExplorationStore(isolated) + session = repository.create("探索设置", ExplorationConfig(), {}, "create") + repository.save_case( + session["id"], CaseDraft(name="深色主题检查", goal="完整执行提示词"), 0, "save" + ) + run = repository.reserve_run( + session["id"], + "validate", + kind="validation", + goal="完整执行提示词", + task_config={}, + case_version=1, + ) + isolated.save_job( + { + "session_id": run["id"], + "status": "completed", + "name": "完整执行提示词", + "goal": "完整执行提示词", + } + ) + with isolated.connection() as conn: + conn.execute( + "INSERT INTO sessions(session_id,initial_goal,start_time,status) VALUES (?,?,?,?)", + (run["id"], "完整执行提示词", 1, "completed"), + ) + conn.commit() + rows = client.get("/api/automation/history?q=深色主题检查").json()["items"] + assert len(rows) == 1 + assert rows[0]["name"] == "深色主题检查" + assert rows[0]["initial_goal"] == "完整执行提示词" + assert rows[0]["case_version"] == 1 + + +@pytest.mark.asyncio +async def test_queued_exploration_keeps_identity_without_a_session_row(isolated, monkeypatch): + from apps.admin_console.routers import tasks + from artemis.exploration.store import ExplorationStore + from artemis.exploration.models import CreateExploration + from apps.admin_console.services.exploration_service import ExplorationService + + service = ExplorationService(ExplorationStore(isolated)) + session = service.create(CreateExploration(goal="排队探索", request_id="create")) + run = service.repository.reserve_run( + session["id"], + "start", + kind="exploration", + goal="排队探索", + task_config={"model": "gpt-6.1-sol"}, + ) + queue_item = {"session_id": run["id"], "goal": run["goal"], "status": "pending"} + from types import SimpleNamespace + + monkeypatch.setattr( + tasks, + "state", + SimpleNamespace( + is_running=False, + queue_items=[], + queue_tasks=[queue_item], + active_runs={}, + active_connections={}, + active_session_id=None, + current_goal=None, + current_profile=None, + ipc_port=0, + ), + ) + monkeypatch.setattr(tasks, "store", isolated) + monkeypatch.setattr(tasks.task_queue_service, "ensure_worker_running", lambda: None) + monkeypatch.setattr(tasks, "session_repo", Mock(get_latest_session=lambda: None)) + monkeypatch.setattr(tasks.DeviceExecutionLock, "get_active_owner", lambda: None) + monkeypatch.setattr(tasks.DeviceExecutionLock, "get_active_owners", lambda: {}) + monkeypatch.setattr(tasks.DeviceExecutionLock, "get_queued_tasks", lambda: []) + result = await tasks.get_status() + assert result["queue"][0]["exploration_id"] == session["id"] + assert result["queue"][0]["exploration_kind"] == "exploration" + assert "exploration_id" not in queue_item # Do not mutate scheduler state. diff --git a/tests/unit/admin_console/test_favorites.py b/tests/unit/admin_console/test_favorites.py new file mode 100644 index 0000000..25413a5 --- /dev/null +++ b/tests/unit/admin_console/test_favorites.py @@ -0,0 +1,379 @@ +"""Favorite snapshots and transport retries never replay an old result or task.""" + +import asyncio +from copy import deepcopy +import json +from unittest.mock import AsyncMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import ValidationError +import pytest + +from apps.admin_console.services import automation_service +from apps.admin_console.services.automation_store import AutomationStore +from apps.admin_console.services.favorites import ( + FavoriteCreate, + FavoriteRun, + FavoriteService, + FavoriteUpdate, +) +from artemis.config.exploration import ExplorationConfig +from artemis.data_engine.storage import StorageManager +from artemis.exploration.models import CaseDraft +from artemis.exploration.store import ExplorationStore + + +@pytest.fixture +def favorites(tmp_path, monkeypatch): + storage = AutomationStore(tmp_path / "history.db") + StorageManager(storage.path, tmp_path / "traces") + monkeypatch.setattr(automation_service, "store", storage) + return FavoriteService(storage) + + +def task(service, session_id="task", **options): + config = { + "profile": "pro", + "model": "original-model", + "reasoning_effort": "high", + "device_serial": "original-device", + "record_video": True, + **options, + } + job = { + "session_id": session_id, + "status": "completed", + "case": {"name": "原用例", "goal": "检查真实页面"}, + "execution_config": { + "runtime_overrides": config, + "expected_output": "保留证据", + "enable_outputter": True, + }, + } + service.store.save_job(job) + return service.create(FavoriteCreate(source_id=session_id)) + + +def test_snapshot_survives_source_deletion_and_edits_do_not_modify_source(favorites): + item = task(favorites) + updated = favorites.update( + item["id"], FavoriteUpdate(name="常用检查", task_config={"model": "new-model"}) + ) + assert updated["snapshot"]["runtime_overrides"]["model"] == "new-model" + assert ( + favorites.store.job("task")["execution_config"]["runtime_overrides"]["model"] + == "original-model" + ) + favorites.store.delete_history() + assert favorites.get(item["id"])["snapshot"]["goal"] == "检查真实页面" + assert favorites.store.job("task") is None + + +@pytest.mark.asyncio +async def test_rerun_preserves_settings_and_is_durably_idempotent(favorites, monkeypatch): + item = task(favorites) + enqueue = AsyncMock(return_value={"tasks": [{"session_id": "new-task"}], "status": "queued"}) + monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) + value = FavoriteRun(request_id="same-submission", task_config={"reasoning_effort": "max"}) + first, second = await asyncio.gather( + favorites.run(item["id"], value), favorites.run(item["id"], value) + ) + assert first == second + enqueue.assert_awaited_once() + request = enqueue.call_args.args[0] + assert request.session_id != "task" + assert request.runtime_overrides.model == "original-model" + assert request.runtime_overrides.reasoning_effort == "max" + assert request.runtime_overrides.record_video is True + assert request.runtime_overrides.device_serial == "original-device" + assert request.expected_output == "保留证据" + assert favorites.get(item["id"])["snapshot"]["runtime_overrides"]["reasoning_effort"] == "high" + favorites.delete(item["id"]) + assert await FavoriteService(favorites.store).run(item["id"], value) == first + enqueue.assert_awaited_once() + with pytest.raises(ValueError, match="request_id"): + await favorites.run( + item["id"], + FavoriteRun(request_id="same-submission", task_config={"model": "different"}), + ) + + +@pytest.mark.asyncio +async def test_lost_admission_reply_reuses_snapshot_and_session(favorites, monkeypatch): + item = task(favorites) + enqueue = AsyncMock(side_effect=[ConnectionError("reply lost"), {"tasks": [{}]}]) + monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) + value = FavoriteRun(request_id="retry") + with pytest.raises(ConnectionError): + await favorites.run(item["id"], value) + favorites.update(item["id"], FavoriteUpdate(task_config={"model": "changed"})) + await FavoriteService(favorites.store).run(item["id"], value) + requests = [call.args[0] for call in enqueue.call_args_list] + assert requests[0].session_id == requests[1].session_id + assert requests[1].runtime_overrides.model == "original-model" + + +@pytest.mark.asyncio +async def test_batch_favorite_keeps_one_round_and_original_repetitions(favorites): + batch = automation_service.create_batch( + { + "name": "压测", + "repeat_count": 3, + "profile": "flash", + "device_serial": "device", + "cases": [ + {"name": "甲", "goal": "检查甲", "task_config": {"model": "model-a"}}, + { + "name": "乙", + "goal": "检查乙", + "task_config": {"profile": "pro", "model": "model-b"}, + }, + ], + } + ) + item = favorites.create(FavoriteCreate(source_kind="batch", source_id=batch["id"])) + assert len(item["snapshot"]["cases"]) == 2 + assert item["snapshot"]["repeat_count"] == 3 + result = await favorites.run( + item["id"], + FavoriteRun( + request_id="batch-rerun", + task_config={"reasoning_effort": "max"}, + case_configs={1: {"model": "changed-b"}}, + ), + ) + jobs = favorites.store.jobs(result["batch_id"]) + assert len(jobs) == 6 + assert [j["case"]["name"] for j in jobs] == ["甲", "乙"] * 3 + assert [j["case"]["task_config"]["model"] for j in jobs] == ["model-a", "changed-b"] * 3 + assert all(j["case"]["task_config"]["reasoning_effort"] == "max" for j in jobs) + assert favorites.store.get("batch", result["batch_id"])["status"] == "running" + again = await favorites.run( + item["id"], + FavoriteRun( + request_id="batch-rerun", + task_config={"reasoning_effort": "max"}, + case_configs={1: {"model": "changed-b"}}, + ), + ) + assert again == result + assert len(favorites.store.records("batch")) == 2 + + +def test_exploration_case_pins_version_without_inheriting_expensive_model(favorites): + repo = ExplorationStore(favorites.store) + session = repo.create( + "探索目标", + ExplorationConfig(model="exploration-model", reasoning_effort="max"), + {"model": "exploration-model", "device_serial": "device"}, + "create", + ) + repo.save_case( + session["id"], + CaseDraft(name="完成用例", goal="验证主页", preconditions="已登录"), + 0, + "case-v1", + ) + item = favorites.create(FavoriteCreate(source_kind="exploration_case", source_id=session["id"])) + assert item["case_version"] == 1 + assert item["snapshot"]["goal"] == "前置条件:\n已登录\n\n验证主页" + assert item["snapshot"]["runtime_overrides"]["profile"] == "flash" + assert item["snapshot"]["runtime_overrides"]["model"] != "exploration-model" + repo.save_case(session["id"], CaseDraft(name="新用例", goal="新目标"), 1, "case-v2") + assert favorites.get(item["id"])["snapshot"]["goal"].endswith("验证主页") + exploration = favorites.create( + FavoriteCreate(source_kind="exploration", source_id=session["id"]) + ) + assert exploration["snapshot"]["config"]["model"] == "exploration-model" + + +def test_old_task_recovers_real_model_and_marks_missing_settings(favorites): + with favorites.store.connection() as conn: + conn.execute( + "INSERT INTO sessions(session_id,initial_goal,status,device_info) VALUES (?,?,?,?)", + ( + "legacy", + "检查浏览器", + "completed", + json.dumps({"profile": "flash", "serial": "computer:local"}), + ), + ) + conn.execute( + "INSERT INTO traces(trace_id,session_id,type,timestamp,payload) VALUES (?,?,?,?,?)", + ( + "request", + "legacy", + "llm_diagnostic", + 1, + json.dumps( + {"role": "FlashRunner", "model": "actual-model", "reasoning_effort": "max"} + ), + ), + ) + conn.commit() + item = favorites.create(FavoriteCreate(source_id="legacy")) + options = item["snapshot"]["runtime_overrides"] + assert options["model"] == "actual-model" + assert options["reasoning_effort"] == "max" + assert options["execution_target"] == "computer" + assert "model" not in item["inherited_fields"] + assert "recording" in item["inherited_fields"] + + +def test_api_rejects_credentials_and_arbitrary_snapshots(favorites, monkeypatch): + from apps.admin_console.routers import favorites as api + + monkeypatch.setattr(api, "favorites", favorites) + app = FastAPI() + app.include_router(api.router) + client = TestClient(app) + item = task(favorites) + assert len(client.get("/api/automation/favorites").json()) == 1 + assert ( + client.patch( + f"/api/automation/favorites/{item['id']}", json={"task_config": {"api_key": "never"}} + ).status_code + == 422 + ) + assert ( + client.post( + "/api/automation/favorites", json={"source_id": "task", "snapshot": {}} + ).status_code + == 422 + ) + assert ( + client.patch(f"/api/automation/favorites/{item['id']}", json={"name": " "}).status_code + == 409 + ) + assert client.delete(f"/api/automation/favorites/{item['id']}").status_code == 200 + assert favorites.store.job("task") + with pytest.raises(ValidationError): + FavoriteRun(request_id="run", task_config={"password": "never"}) + + +def test_collection_defaults_cannot_modify_original_snapshot(favorites): + item = task(favorites) + original = deepcopy(item) + favorites.updated(item, FavoriteUpdate(task_config={"model": "temporary"})) + assert item == original + + +def test_partial_recording_update_keeps_other_favorite_settings(favorites): + item = task(favorites, recording={"codec": "h264", "fps": 10, "max_long_edge": 720}) + updated = favorites.updated(item, FavoriteUpdate(task_config={"recording": {"codec": "h265"}})) + assert updated["snapshot"]["runtime_overrides"]["recording"] == { + **item["snapshot"]["runtime_overrides"]["recording"], + "codec": "h265", + } + assert item["snapshot"]["runtime_overrides"]["recording"]["codec"] == "h264" + + +def test_legacy_empty_recording_configuration_uses_current_defaults(): + from apps.admin_console.services.execution_config import execution_config + from artemis.config.agent import load_agent_config + + snapshot = execution_config({"profile": "flash", "runtime_overrides": {"recording": None}}) + assert snapshot["runtime_overrides"]["recording"] == load_agent_config().recording.model_dump() + + +def test_legacy_favorite_is_completed_once_without_changing_actual_settings(favorites, monkeypatch): + from artemis.config.settings import settings + + item = task(favorites, max_retries=0, record_video=False, recording={"fps": 10}) + item.pop("config_version") + original = deepcopy(item) + favorites.store.put("favorite", item["id"], item) + completed = favorites.list()[0] + options = completed["snapshot"]["runtime_overrides"] + assert options["model"] == "original-model" + assert options["max_retries"] == 0 + assert options["record_video"] is False + assert options["recording"]["fps"] == 10 + assert options["recording"]["max_long_edge"] > 0 + assert completed["created_at"] == original["created_at"] + assert completed["updated_at"] == original["updated_at"] + assert favorites.store.get("favorite", item["id"]) == completed + monkeypatch.setattr(settings, "OPENAI_MODEL", "new-global-model") + assert FavoriteService(favorites.store).get(item["id"]) == completed + + +def test_partial_legacy_snapshot_and_batch_complete_missing_fields(favorites): + from apps.admin_console.services.execution_config import execution_config + + expected = execution_config({})["runtime_overrides"] + item = task(favorites) + item.pop("config_version") + item["snapshot"]["runtime_overrides"] = {"profile": "pro", "model": "kept-model"} + favorites.store.put("favorite", item["id"], item) + options = favorites.get(item["id"])["snapshot"]["runtime_overrides"] + assert options["model"] == "kept-model" + assert options["reasoning_effort"] == expected["reasoning_effort"] + assert options["recording"] == expected["recording"] + item["id"] = "legacy-batch" + item["kind"] = "batch" + item["snapshot"] = { + "profile": "pro", + "device_serial": "device", + "cases": [ + { + "name": "一", + "goal": "检查", + "task_config": {"model": "case-model", "record_video": False}, + } + ], + } + favorites.store.put("favorite", item["id"], item) + options = favorites.get(item["id"])["snapshot"]["cases"][0]["task_config"] + assert options["model"] == "case-model" + assert options["profile"] == "pro" + assert options["device_serial"] == "device" + assert options["record_video"] is False + assert options["request_timeout_seconds"] == expected["request_timeout_seconds"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("rejected", [False, True]) +async def test_restart_exploration_creates_new_conversation_and_reports_rejection( + favorites, monkeypatch, rejected +): + from apps.admin_console.services.exploration_service import ExplorationService + + repo = ExplorationStore(favorites.store) + original = repo.create( + "探索目标", + ExplorationConfig(model="explore-model", reasoning_effort="max"), + {"device_serial": "device", "record_video": True}, + "source", + ) + item = favorites.create(FavoriteCreate(source_kind="exploration", source_id=original["id"])) + monkeypatch.setattr( + "apps.admin_console.services.exploration_service.exploration_service", + ExplorationService(repo), + ) + enqueue = AsyncMock( + return_value=( + {"status": "rejected", "error": "设备离线"} + if rejected + else {"status": "queued", "tasks": [{}]} + ) + ) + monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) + value = FavoriteRun(request_id="new-exploration", task_config={"model": "selected-model"}) + if rejected: + with pytest.raises(ValueError, match="设备离线"): + await favorites.run(item["id"], value) + with pytest.raises(ValueError, match="设备离线"): + await favorites.run(item["id"], value) + else: + result = await favorites.run(item["id"], value) + assert result["exploration_id"] != original["id"] + assert repo.run(result["session_id"])["exploration_id"] == result["exploration_id"] + assert await favorites.run(item["id"], value) == result + request = enqueue.call_args.args[0] + assert request.conversation_id != original["id"] + assert request.runtime_overrides.model == "selected-model" + assert request.runtime_overrides.reasoning_effort == "max" + assert request.runtime_overrides.record_video is True + enqueue.assert_awaited_once() + assert repo.get(original["id"]) == original diff --git a/tests/unit/admin_console/test_model_settings.py b/tests/unit/admin_console/test_model_settings.py index a0623da..c23565d 100644 --- a/tests/unit/admin_console/test_model_settings.py +++ b/tests/unit/admin_console/test_model_settings.py @@ -91,12 +91,27 @@ def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeyp assert service.get_model_settings()["base_url"] == "https://saved.example/v1" -def test_model_presets_include_flash_pro_and_luna_without_restricting_custom_names(): +def test_model_presets_keep_supported_choices_without_restricting_custom_names(): presets = service.get_model_settings()["model_presets"] - assert presets == ["gpt-5.6-luna", "gpt-6-luna", "mimo-v2.6-flash", "mimo-v2.6-pro"] + assert presets == [ + "gpt-6-luna", + "gpt-6.1-sol", + "gpt-6-astra", + "mimo-v2.6-pro", + ] assert service.ModelSettingsUpdate(model="custom/model-v2").model == "custom/model-v2" +def test_new_install_defaults_to_luna_high_and_preserves_saved_model(isolated_settings): + from artemis.config.constants import DEFAULT_MODEL + + fresh = Settings(_env_file=None) + assert DEFAULT_MODEL == fresh.OPENAI_MODEL == "gpt-6-luna" + assert fresh.OPENAI_REASONING_EFFORT == "high" + isolated_settings.write_text("OPENAI_MODEL=custom/existing\n", encoding="utf-8") + assert Settings(_env_file=isolated_settings).OPENAI_MODEL == "custom/existing" + + @pytest.mark.parametrize( "url", [ diff --git a/tests/unit/admin_console/test_plugin_media.py b/tests/unit/admin_console/test_plugin_media.py new file mode 100644 index 0000000..2194ecd --- /dev/null +++ b/tests/unit/admin_console/test_plugin_media.py @@ -0,0 +1,82 @@ +import json +from unittest.mock import MagicMock + +from apps.admin_console.routers import media +from apps.admin_console.services import evidence_service as evidence +from apps.admin_console.services import report_export as exports + + +def fixture_files(tmp_path, monkeypatch): + monkeypatch.setattr(evidence, "TRACES_PATH", tmp_path) + root = tmp_path / "task" / "evidence" / "recordings" + root.mkdir(parents=True) + video = root / "clip.mp4" + video.write_bytes(b"fixture video") + (root / "recordings.json").write_text( + json.dumps( + { + "version": 1, + "sources": [ + { + "id": "pc", + "kind": "computer", + "target_ids": ["window"], + "status": "ready", + "segments": [ + { + "file": "evidence/recordings/clip.mp4", + "offset_ms": 5000, + "duration": 2, + } + ], + } + ], + } + ), + "utf-8", + ) + return video + + +def test_plugin_media_preserves_session_offsets_and_exports_relative_files(tmp_path, monkeypatch): + fixture_files(tmp_path, monkeypatch) + monkeypatch.setattr(exports, "session_screenshots", lambda _: []) + monkeypatch.setattr(exports, "session_artifacts", lambda _: []) + source = evidence.plugin_sources("task")[0] + assert source["segments"][0]["start"] == 5 + index = evidence.export_index( + "task", {"name": "case", "status": "completed", "verdict": "未验证"}, [] + ) + clip = index["recordings"][0]["segments"][0] + assert clip["file"] == "任务录屏/电脑/clip.mp4" and "url" not in clip + assert clip["file"] in {item["path"] for item in index["files"]} + + +def test_legacy_url_only_does_not_enable_strict_timeline(monkeypatch): + monkeypatch.setattr( + media, + "_get_legacy_session_video_sync", + lambda _: {"status": "ready", "video_url": "/videos/old.mp4", "video_segments": []}, + ) + monkeypatch.setattr(evidence, "plugin_sources", lambda _: []) + monkeypatch.setattr(media, "session_repo", MagicMock(get_session_by_id=lambda _: {})) + response = media._get_session_video_sync("task") + assert response["session_timeline"] is False + assert response["video_url"] == "/videos/old.mp4" + + +def test_computer_only_recording_can_be_downloaded(tmp_path, monkeypatch): + import zipfile + + fixture_files(tmp_path, monkeypatch) + monkeypatch.setattr(exports, "EXPORT_ROOT", tmp_path / "exports") + monkeypatch.setattr( + exports, + "session_report", + lambda _: {"id": "task", "name": "case", "status": "completed", "verdict": "未验证"}, + ) + monkeypatch.setattr(exports, "session_videos", lambda _: []) + with zipfile.ZipFile(exports.export_session_media("task", "video")) as archive: + index = json.loads(archive.read("文件清单.json")) + assert index["videos"] == ["任务录屏/电脑/clip.mp4"] + assert archive.getinfo(index["videos"][0]).compress_type == zipfile.ZIP_STORED diff --git a/tests/unit/admin_console/test_session_usage_endpoint.py b/tests/unit/admin_console/test_session_usage_endpoint.py index 70a2f91..e407ce0 100644 --- a/tests/unit/admin_console/test_session_usage_endpoint.py +++ b/tests/unit/admin_console/test_session_usage_endpoint.py @@ -80,7 +80,7 @@ def test_usage_sums_all_agents_and_tracks_latest_operator_context(tmp_path): assert usage["cached_tokens"] == 90_000 assert usage["operator_context_tokens"] == 150_000 assert usage["operator_context_updated_at"] == 13.0 - assert usage["operator_context_window_tokens"] == 1_000_000 + assert usage["operator_context_window_tokens"] == 300_000 assert usage["profile"] == "pro" assert usage["run_tuning"] == {"verification_level": "checkpoints", "explorer_mode": "pro"} diff --git a/tests/unit/agents/test_checker.py b/tests/unit/agents/test_checker.py index d992b35..6dbd9c1 100644 --- a/tests/unit/agents/test_checker.py +++ b/tests/unit/agents/test_checker.py @@ -673,7 +673,7 @@ def test_format_history_uses_session_offsets_and_skips_empty_steps(): assert "'Login'" in lines[1] -async def _final_check_human_text(ctx) -> str: +async def _final_check_human_text(ctx, **kwargs) -> str: report = CheckReport(verdicts=[]) structured = MagicMock() structured.ainvoke = AsyncMock(return_value=report) @@ -691,7 +691,9 @@ async def _final_check_human_text(ctx) -> str: new=AsyncMock(return_value=(None, "elements")), ), ): - await run_final_check(ctx, goal="the goal", plan_text="- [x] G", ledger=[], check_items=[]) + await run_final_check( + ctx, goal="the goal", plan_text="- [x] G", ledger=[], check_items=[], **kwargs + ) messages = acomplete_mock.await_args.args[1] human = messages[-1] @@ -730,6 +732,77 @@ async def test_final_review_reports_no_guidance_explicitly(): assert "# User guidance received during the run\n(none)\n" in text +@pytest.mark.asyncio +async def test_final_review_receives_verified_file_receipts_without_history_tool_call(): + ctx = _mock_ctx() + ctx.data_engine.get_agent_friendly_steps.return_value = [ + { + "step_number": 44, + "summary": "已写入报告", + "action_taken": {"action": "android_file"}, + "last_execution_result": { + "result": '{"ok":true,"verified":true,"device_path":"/sdcard/report.md","bytes":8022}' + }, + } + ] + text = await _final_check_human_text(ctx, execution_report="报告保存情况未知") + assert "Recorded File Receipts (tool evidence, not report claims)" in text + assert '"step": 44' in text and '"verified": true' in text + assert "/sdcard/report.md" in text + + +@pytest.mark.parametrize("entry", ["checkpoint", "final"]) +def test_visual_acceptance_requires_counter_evidence_without_exempting_real_defects(entry): + text = assemble_checker_prompt_segments(entry, [], probe_tool_registered=False) + assert "compare recorded pre/post screenshots and nearby steps" in text + assert "user permits this behavior" in text + assert "fixed navigation is not a blanket exemption" in text + assert "explicit visual standard" in text + assert "inconclusive, not failed or automatically passed" in text + + +@pytest.mark.asyncio +async def test_final_review_distinguishes_actual_delivery_from_report_claims(): + text = await _final_check_human_text( + _mock_ctx(), + execution_report="# 完整报告\n实际发现与未测范围", + report_delivery={"report_path": "notes/output.md", "exploration_message_sequences": [12]}, + ) + assert "# 完整报告" in text + assert "Report Delivery Receipt (recorded by ARTEMIS, not a model claim)" in text + assert '"exploration_message_sequences": [12]' in text + assert "confirms delivery only" in text + assert "against actual device/tool evidence" in text + without_receipt = await _final_check_human_text(_mock_ctx(), execution_report="模型自述已交付") + assert "Report Delivery Receipt" not in without_receipt + failed = await _final_check_human_text( + _mock_ctx(), report_delivery={"ok": False, "verified": False, "error": "disk full"} + ) + assert "whether the executor's report was saved" in failed + assert '"verified": false' in failed and "disk full" in failed + + +@pytest.mark.asyncio +async def test_exploration_review_sees_conversation_and_can_read_earlier_messages(): + ctx = _mock_ctx() + ctx.exploration_id = "exploration" + conversation = HumanMessage(content="用户已确认系统设置;示范不代表独立复跑") + history = _text_tool("read_exploration_messages", "历史回复") + review = AsyncMock(return_value=CheckReport()) + with ( + patch("artemis.agents.checker.checker._run_check_loop", review), + patch( + "artemis.agents.checker.checker._capture_final_screen", + AsyncMock(return_value=(None, "elements")), + ), + patch("artemis.exploration.runtime.conversation_message", return_value=conversation), + patch("artemis.exploration.runtime.history_tool", return_value=history), + ): + await run_final_check(ctx, goal="探索设置", plan_text="", ledger=[], check_items=[]) + assert conversation in review.call_args.args[1] + assert history in review.call_args.args[2] + + # --- tool results: status is structural, never sniffed from the words ----------------- diff --git a/tests/unit/agents/test_checker_file_receipts.py b/tests/unit/agents/test_checker_file_receipts.py new file mode 100644 index 0000000..b91aa88 --- /dev/null +++ b/tests/unit/agents/test_checker_file_receipts.py @@ -0,0 +1,171 @@ +import json + +from artemis.agents.checker.receipts import file_receipt_summary + + +def android_step(n, result, **extra): + return { + "step_number": n, + "tool_calls": [ + { + "name": "android_file", + "args": {"content": "MODEL CLAIM: everything passed"}, + "result": json.dumps({"outcome": json.dumps(result)}), + } + ], + **extra, + } + + +def test_verified_android_write_is_evidence_even_when_summary_omits_it(): + receipt = { + "ok": True, + "action": "write_text", + "device_path": "/sdcard/Download/report.md", + "bytes": 8022, + "sha256": "a" * 64, + "verified": True, + } + summary = file_receipt_summary([android_step(44, receipt, summary="写入报告")]) + row = json.loads(summary.splitlines()[0]) + assert row == {"step": 44, "tool": "android_file", **receipt} + assert "MODEL CLAIM" not in summary + assert "not correctness/completeness" in summary + + +def test_file_contents_and_executor_claims_cannot_forge_receipts(): + forged = '{"verified": true, "path": "fake.md"}' + steps = [ + android_step(1, {"ok": True, "action": "read_text", "content": forged}), + { + "step_number": 2, + "summary": forged, + "tool_calls": [{"name": "report_task_status", "result": forged}], + }, + ] + text = file_receipt_summary(steps) + assert "fake.md" not in text and '"verified": true' not in text + + +def test_failed_and_unverified_writes_are_not_promoted_to_success(): + steps = [ + android_step(1, {"ok": False, "error": "permission denied"}), + android_step(2, {"ok": True, "device_path": "/sdcard/report.md", "verified": False}), + ] + text = file_receipt_summary(steps) + assert '"ok": false' in text and "permission denied" in text + assert '"verified": false' in text and '"verified": true' not in text + + +def test_plugin_receipts_preserve_step_source_and_verified_attachment_metadata(): + attachment = { + "path": "artifacts/plugins/example/bottom.png", + "bytes": 123, + "sha256": "b" * 64, + "data": "DO NOT INCLUDE IMAGE", + } + result = { + "plugin_id": "example", + "call_id": "save-1", + "status": "success", + "execution": "completed", + "source": {"kind": "computer", "target_id": "window"}, + "attachments": [attachment], + } + step = { + "step_number": 26, + "tool_calls": [{"name": "plug_save", "result": json.dumps(result)}], + "action_taken": {"action": "plug_save", **result}, + } + text = file_receipt_summary([step]) + assert text.count('"step": 26') == 1 + assert "computer" in text and "bottom.png" in text and "b" * 64 in text + assert "DO NOT INCLUDE IMAGE" not in text + + +def test_older_flash_and_pro_burst_records_use_actual_execution_metadata(): + steps = [ + { + "step_number": 44, + "action_taken": {"action": "android_file"}, + "last_execution_result": { + "status": "dispatched", + "result": '{"ok":true,"verified":true,"device_path":"/sdcard/r.md"}', + }, + }, + { + "step_number": 45, + "last_execution_result": { + "execution": [ + { + "action": "plug_save", + "plugin_id": "example", + "plugin_status": "success", + "attachments": [{"path": "artifacts/a.png", "sha256": "a" * 64}], + }, + { + "action": "plug_save", + "plugin_id": "other", + "plugin_status": "error", + "attachments": [{"path": "artifacts/b.png", "sha256": "b" * 64}], + }, + ] + }, + }, + ] + text = file_receipt_summary(steps) + assert "/sdcard/r.md" in text and '"verified": true' in text + assert "artifacts/a.png" in text and "artifacts/b.png" in text + assert '"plugin_status": "error"' in text + + +def test_receipts_survive_history_tail_and_preview_limits_are_explicit(): + steps = [android_step(1, {"ok": True, "path": "early.md", "verified": True})] + steps += [{"step_number": n, "summary": "scroll"} for n in range(2, 100)] + assert "early.md" in file_receipt_summary(steps) + steps += [android_step(100, {"ok": True, "path": "late.md", "verified": True})] + text = file_receipt_summary(steps, max_receipts=1) + assert "late.md" in text and "early.md" not in text + assert "1 receipt(s) omitted" in text + assert "not proof of a missing file" in text + tiny = file_receipt_summary(steps, max_chars=1) + assert "2 receipt(s) omitted" in tiny + + +def test_malformed_or_absent_receipt_is_not_silently_verified(): + steps = [ + { + "step_number": 1, + "tool_calls": [ + {"name": "android_file", "result": "安卓文件操作失败:permission denied"}, + {"name": "plug_save", "result": "invalid JSON"}, + ], + } + ] + assert "permission denied" in file_receipt_summary(steps) + assert '"verified": true' not in file_receipt_summary(steps) + assert "unavailable" in file_receipt_summary("read failed") + + +def test_flash_saved_note_and_legacy_pro_note_preserve_actual_receipt_semantics(): + steps = [ + { + "step_number": 30, + "tool_calls": [ + { + "name": "save_note", + "args": {"content": "DO NOT COPY THE REPORT BODY"}, + "result": {"outcome": '{"ok":true,"path":"notes/report.md","verified":true}'}, + }, + {"name": "save_note", "result": "Note 'legacy' saved successfully."}, + {"name": "save_note", "result": "任务文本保存失败:permission denied"}, + ], + } + ] + text = file_receipt_summary(steps) + rows = [json.loads(line) for line in text.splitlines()[:3]] + assert rows[0]["verified"] is True and rows[0]["path"] == "notes/report.md" + assert rows[1]["recorded_result"] == "Note 'legacy' saved successfully." + assert "error" not in rows[1] and "verified" not in rows[1] + assert "permission denied" in rows[2]["recorded_result"] + assert "verified" not in rows[2] and "DO NOT COPY" not in text diff --git a/tests/unit/agents/test_checker_structured_report.py b/tests/unit/agents/test_checker_structured_report.py index 3eaa878..201e6d9 100644 --- a/tests/unit/agents/test_checker_structured_report.py +++ b/tests/unit/agents/test_checker_structured_report.py @@ -16,10 +16,13 @@ rejects such requests with 400 "Requests ending with a model turn are not supported"), which is the natural state after the loop's tool-free reply.""" +import json +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from pydantic import ValidationError from artemis.agents.checker.checker import CheckReport, _structured_report @@ -53,3 +56,145 @@ async def test_structured_report_keeps_user_ending_conversation_as_is(): await _structured_report(llm, messages) assert structured.ainvoke.call_args.args[0] is messages + + +def _result(status="passed", evidence=None): + return { + "status": status, + "reason": "双端检查与报告已完成,已知风险不代表所有布局无缺陷。", + "evidence": ["步骤 18、27:追加下滚后位置不变,双端已到页底。"] + if evidence is None + else evidence, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["passed", "failed", "inconclusive", "blocked"]) +async def test_complete_checker_json_does_not_request_another_verdict(status): + payload = {"test_result": _result(status)} + llm, structured = _llm(CheckReport()) + answer = AIMessage( + content="核查步骤 [18]、[27],控件 [28]。最终裁定\n```json\n" + + json.dumps(payload) + + "\n```\n范围仅限本次检查。" + ) + + result = await _structured_report(llm, [HumanMessage(content="review"), answer]) + + assert result.test_result.status == status + assert result.test_result.evidence == payload["test_result"]["evidence"] + structured.ainvoke.assert_not_awaited() + llm.with_structured_output.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["passed", "failed", "inconclusive"]) +async def test_complete_verdict_alias_and_evidence_list_are_not_sent_for_reformatting(status): + payload = { + "verdicts": [ + { + "kind": "assert", + "item_text": "检查本轮页面", + "verdict": status, + "evidence": ["步骤 2 的实际页面", "步骤 3 的对照截图"], + } + ], + "test_result": _result(status), + } + llm, structured = _llm(CheckReport()) + report = await _structured_report( + llm, + [AIMessage(content=json.dumps(payload))], + [SimpleNamespace(kind="assert", text="检查本轮页面")], + ) + assert report.verdicts[0].status == status + assert report.verdicts[0].evidence == "步骤 2 的实际页面\n步骤 3 的对照截图" + assert report.test_result.model_dump() == payload["test_result"] + structured.ainvoke.assert_not_awaited() + llm.with_structured_output.assert_not_called() + + +@pytest.mark.parametrize( + "fields", + [ + {"status": "failed", "verdict": "passed", "evidence": ["图 1"]}, + {"verdict": "unknown", "evidence": ["图 1"]}, + {"verdict": "passed", "evidence": [{"text": "图 1"}]}, + {"verdict": "passed", "evidence": [1]}, + ], +) +def test_conflicting_or_invalid_verdict_shapes_are_not_silently_accepted(fields): + with pytest.raises(ValidationError): + CheckReport.model_validate( + {"verdicts": [{"kind": "assert", "item_text": "页面", **fields}]} + ) + + +def test_empty_evidence_list_does_not_turn_an_unsupported_failure_into_evidence(): + from artemis.agents.checker.checker import _normalize_report + + report = CheckReport.model_validate( + {"verdicts": [{"kind": "assert", "item_text": "页面", "verdict": "failed", "evidence": []}]} + ) + assert _normalize_report(report, []).verdicts[0].status == "inconclusive" + + +def test_nested_json_string_result_is_decoded_and_still_validated(): + result = _result() + assert ( + CheckReport.model_validate({"test_result": json.dumps(result)}).test_result.model_dump() + == result + ) + for invalid in ("passed", "null", "[]", '{"status":"passed"}', json.dumps(_result("unknown"))): + with pytest.raises(ValidationError): + CheckReport.model_validate({"test_result": invalid}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "kind", ["empty", "malformed", "truncated", "tool_call", "missing_check", "ambiguous", "prose"] +) +async def test_incomplete_or_ambiguous_answer_requires_structured_verdict(kind): + text = json.dumps({"test_result": _result()}) + items = [] + kwargs = {} + if kind == "empty": + text = "{}" + elif kind == "malformed": + text = '{"test_result":{"status":"passed"}}' + elif kind == "truncated": + kwargs["response_metadata"] = {"finish_reason": "length"} + elif kind == "tool_call": + kwargs["tool_calls"] = [{"id": "read-1", "name": "search_history", "args": {}}] + elif kind == "missing_check": + items = [SimpleNamespace(kind="assert", text="must pass")] + elif kind == "ambiguous": + text += "\n" + json.dumps({"test_result": _result("failed")}) + elif kind == "prose": + text = "测试通过。" + llm, structured = _llm(CheckReport()) + + await _structured_report(llm, [AIMessage(content=text, **kwargs)], items) + + structured.ainvoke.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_local_verdict_preserves_evidence_normalization(monkeypatch): + from artemis.agents.checker import checker + + llm, structured = _llm(CheckReport()) + monkeypatch.setattr(checker, "get_llm", lambda **_: llm) + monkeypatch.setattr( + checker, + "acomplete", + AsyncMock( + return_value=AIMessage( + content=json.dumps({"test_result": _result(evidence=[])}), + ) + ), + ) + ctx = SimpleNamespace(execution_setup=SimpleNamespace(checker_max_iterations=3)) + report = await checker._run_check_loop(ctx, [HumanMessage(content="review")], [], []) + assert report.test_result.status == "inconclusive" + structured.ainvoke.assert_not_awaited() diff --git a/tests/unit/agents/test_final_resource_evidence.py b/tests/unit/agents/test_final_resource_evidence.py new file mode 100644 index 0000000..2f9dab3 --- /dev/null +++ b/tests/unit/agents/test_final_resource_evidence.py @@ -0,0 +1,137 @@ +"""Mixed-task acceptance must see each resource's actual recorded captures.""" + +import hashlib +from types import SimpleNamespace +from unittest.mock import Mock + +from artemis.agents.checker.evidence import final_evidence + + +def engine(tmp_path, steps): + return SimpleNamespace( + storage=SimpleNamespace(get_steps=lambda _: steps), + current_session_id="task", + get_image_path=lambda name: tmp_path / f"{name}.jpg", + ) + + +def step(n, pre=None, post=None, action=None, execution=None): + return SimpleNamespace( + step_number=n, + timestamp=float(n), + pre_image_name=pre, + post_image_name=post, + action_taken=action or {"action": "swipe"}, + last_execution_result={"execution": execution} if execution else {}, + ) + + +def image(tmp_path, value): + name = hashlib.sha256(value).hexdigest() + (tmp_path / f"{name}.jpg").write_bytes(value) + return name + + +def pc(post, pre=None, target="browser", width=1280): + return { + "action": "plug_scroll", + "plugin_id": "cua-driver", + "source": { + "kind": "computer", + "target_id": target, + "width": width, + "captured_at": 7, + }, + "pre_image_name": pre, + "post_image_name": post, + "pre_image_valid": bool(pre), + } + + +def test_phone_and_unchanged_pc_captures_are_both_sent_with_origin(tmp_path): + phone = image(tmp_path, b"phone") + browser = image(tmp_path, b"pc") + # Old records deduplicated the step's post ref; per-call receipt still has it. + steps = [ + step(15, post=phone), + step(27, action=pc(browser, browser)), + step(28, action=pc(browser, browser)), + ] + blocks = final_evidence(engine(tmp_path, steps)) + assert sum(b["type"] == "image_url" for b in blocks) == 2 + text = str(blocks) + assert "android" in text and "computer" in text and "cua-driver" in text + assert "Step 27" in text and "Step 28" in text + assert "identical to pre-capture" in text + assert "not proof of success" in text + assert "saved screenshot file=" in text + assert "Distinct capture events may share one file" in text + + +def test_unchanged_android_boundary_capture_is_saved_evidence_not_missing_export(tmp_path): + footer = image(tmp_path, b"same-footer") + blocks = final_evidence( + engine(tmp_path, [step(26, post=footer), step(28, pre=footer, post=footer)]) + ) + text = str(blocks) + assert "Step 26" in text and "Step 28" in text + assert str(tmp_path / f"{footer}.jpg") in "\n".join(b.get("text", "") for b in blocks) + assert "automatic trace screenshot is recorded evidence" in text + assert sum(b["type"] == "image_url" for b in blocks) == 1 + + +def test_native_resolution_change_does_not_hide_previous_same_window_capture(tmp_path): + a = image(tmp_path, b"native") + b = image(tmp_path, b"scaled") + blocks = final_evidence( + engine(tmp_path, [step(1, action=pc(a, width=2560)), step(2, action=pc(b))]), max_sources=1 + ) + assert sum(x["type"] == "image_url" for x in blocks) == 2 + assert "Step 1" in str(blocks) and "Step 2" in str(blocks) + + +def test_burst_uses_per_call_sources_and_never_phone_step_fallback(tmp_path): + a = image(tmp_path, b"A") + b = image(tmp_path, b"B") + blocks = final_evidence( + engine(tmp_path, [step(4, post="phone", execution=[pc(a, target="A"), pc(b, target="B")])]) + ) + assert sum(x["type"] == "image_url" for x in blocks) == 2 + assert "android" not in str(blocks) + + +def test_missing_images_and_untrusted_paths_are_not_fabricated(tmp_path): + e = engine( + tmp_path, [step(1, action=pc("a" * 64)), step(2, action=pc("../private", target="B"))] + ) + e.get_image_path = Mock(side_effect=e.get_image_path) + blocks = final_evidence(e) + assert "unavailable" in str(blocks) + assert not any(x["type"] == "image_url" for x in blocks) + e.get_image_path.assert_called_once_with("a" * 64) + + +def test_last_sources_are_selected_by_execution_order_not_string_step_number(tmp_path): + name = image(tmp_path, b"frame") + steps = [step(9, action=pc(name, target="old")), step(10, action=pc(name, target="new"))] + blocks = final_evidence(engine(tmp_path, steps), max_sources=1) + assert "Step 10" in str(blocks) and "Step 9" not in str(blocks) + assert "Additional resources omitted" in str(blocks) + + +def test_later_call_without_observation_marks_previous_picture_historical(tmp_path): + name = image(tmp_path, b"old") + blocks = final_evidence(engine(tmp_path, [step(1, action=pc(name)), step(2, action=pc(None))])) + assert "Step 2" in str(blocks) and "no post-action image" in str(blocks) + assert "Earlier captures do not prove" in str(blocks) + assert sum(x["type"] == "image_url" for x in blocks) == 1 + + +def test_file_receipt_is_linked_to_its_resource_image_without_an_extra_image(tmp_path): + name = image(tmp_path, b"pc") + action = pc(name) + action["attachments"] = [{"path": "artifacts/plugins/cua-driver/pc.png", "sha256": name}] + blocks = final_evidence(engine(tmp_path, [step(35, action=action)])) + assert "runtime-verified saved attachments" in str(blocks) + assert "artifacts/plugins/cua-driver/pc.png" in str(blocks) + assert sum(x["type"] == "image_url" for x in blocks) == 1 diff --git a/tests/unit/agents/test_flash_acceptance.py b/tests/unit/agents/test_flash_acceptance.py index a8ef22d..c7f07b5 100644 --- a/tests/unit/agents/test_flash_acceptance.py +++ b/tests/unit/agents/test_flash_acceptance.py @@ -1,15 +1,69 @@ import asyncio import json from unittest.mock import AsyncMock, MagicMock +from langchain_core.messages import AIMessage import pytest from artemis.agents.checker import flash -from artemis.agents.checker.checker import CheckReport +from artemis.agents.checker.checker import CheckReport, ReportCorrection from artemis.context import ArtemisContext, ExecutionSetup from artemis.runtime.case_result import CaseTestResult +@pytest.mark.asyncio +async def test_checker_corrects_report_with_evidence_without_changing_product_failure( + tmp_path, monkeypatch +): + from artemis.utils.notes import save_verified_note + + body = "# 双端报告\nPC 为浅色;PC 截图没有保存;安卓导航遮挡。" + save_verified_note(tmp_path, "report", body) + corrections = [ + ReportCorrection( + original="PC 为浅色", replacement="PC 为深色", evidence="步骤 35 电脑截图" + ), + ReportCorrection( + original="PC 截图没有保存", + replacement="PC 截图已保存", + evidence="步骤 35 已验证 path/hash 回执", + ), + ] + check = AsyncMock( + return_value=CheckReport( + test_result=CaseTestResult( + status="failed", reason="安卓导航遮挡正文", evidence=["步骤 40"] + ), + report_corrections=corrections, + ) + ) + monkeypatch.setattr(flash, "run_final_check", check) + execution = {"status": "completed", "explanation": body, "report_note_key": "report"} + data = await flash.settle_flash_outcome(context(tmp_path), "双端视觉检查", execution) + assert data["test_result"]["status"] == "failed" + assert data["report_review"]["applied"] is True + assert execution["explanation"] == (tmp_path / "notes/output.md").read_text(encoding="utf-8") + assert execution["explanation"] == (tmp_path / "notes/report.md").read_text(encoding="utf-8") + assert "PC 为深色" in execution["explanation"] + assert (tmp_path / "notes/output_before_review.md").read_text(encoding="utf-8") == body + stored = json.loads((tmp_path / "run_outcome.json").read_text(encoding="utf-8")) + assert stored["report_review"]["corrections"][0]["evidence"] == "步骤 35 电脑截图" + + +def test_report_correction_requires_exact_unique_quote(tmp_path): + from artemis.agents.checker.report_review import apply_report_review + + execution = {"explanation": "same same"} + result = apply_report_review( + tmp_path, + execution, + [ReportCorrection(original="same", replacement="changed", evidence="步骤 1")], + ) + assert result["applied"] is False + assert execution["explanation"] == "same same" + assert not (tmp_path / "notes/output.md").exists() + + def context(tmp_path, **setup): ctx = MagicMock(spec=ArtemisContext) ctx.execution_setup = ExecutionSetup(**setup) @@ -29,6 +83,17 @@ async def test_flash_reuses_checker_with_original_prompt_and_final_report( check = AsyncMock(return_value=CheckReport(test_result=result)) monkeypatch.setattr(flash, "run_final_check", check) ctx = context(tmp_path) + ctx.on_execution_review = AsyncMock() + + async def review_after_capture_stops(*args, **kwargs): + ctx.on_execution_review.assert_awaited_once() + receipt = kwargs["report_delivery"] + assert receipt["verified"] is True + assert receipt["report_path"] == "notes/output.md" + assert (tmp_path / receipt["report_path"]).read_text(encoding="utf-8") == "操作已执行" + return CheckReport(test_result=result) + + check.side_effect = review_after_capture_stops execution = {"status": "completed", "explanation": "操作已执行"} data = await flash.settle_flash_outcome(ctx, "打开设置,预期看到应用入口", execution) assert check.call_count == 1 @@ -46,16 +111,36 @@ async def test_flash_reuses_checker_with_original_prompt_and_final_report( @pytest.mark.asyncio async def test_acceptance_timeout_finishes_with_unknown_verdict(tmp_path, monkeypatch): + from artemis.llm import final_review + async def hanging_check(*args, **kwargs): await asyncio.Event().wait() monkeypatch.setattr(flash, "run_final_check", hanging_check) + monkeypatch.setattr(final_review, "MAX_FINAL_REVIEW_SECONDS", 0.01) data = await flash.settle_flash_outcome( context(tmp_path, checkpoint_timeout=0.01), "任务", {"status": "completed"} ) assert data["task_status"] == "completed" assert data["test_result"]["status"] == "inconclusive" - assert "0.01 秒" in data["test_result"]["reason"] + assert "总预算已用尽(0.01 秒)" in data["test_result"]["reason"] + + +@pytest.mark.asyncio +async def test_flash_final_review_can_outlast_midway_checkpoint_timeout(tmp_path, monkeypatch): + async def slower_review(*args, **kwargs): + await asyncio.sleep(0.02) + return CheckReport( + test_result=CaseTestResult( + status="passed", reason="完整核对实际证据", evidence=["步骤 1 截图"] + ) + ) + + monkeypatch.setattr(flash, "run_final_check", slower_review) + data = await flash.settle_flash_outcome( + context(tmp_path, checkpoint_timeout=0.001), "任务", {"status": "completed"} + ) + assert data["test_result"]["status"] == "passed" @pytest.mark.asyncio @@ -72,6 +157,38 @@ async def test_disabled_acceptance_does_not_call_a_model_or_claim_pass( assert data["test_result"]["status"] == "inconclusive" +@pytest.mark.asyncio +async def test_report_is_saved_without_checker_or_another_model(tmp_path, monkeypatch): + check = AsyncMock() + monkeypatch.setattr(flash, "run_final_check", check) + text = "# 双端报告\n安卓与电脑证据分开列出。\n## 未测\n没有验证播放。" + await flash.settle_flash_outcome( + context(tmp_path, disable_checker=True), + "报告", + {"status": "completed", "explanation": text}, + ) + assert (tmp_path / "notes/output.md").read_text(encoding="utf-8") == text + check.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_report_write_failure_is_visible_to_checker_not_claimed_saved(tmp_path, monkeypatch): + (tmp_path / "notes").write_text("not a directory", encoding="utf-8") + check = AsyncMock( + return_value=CheckReport( + test_result=CaseTestResult(status="blocked", reason="报告无法保存") + ) + ) + monkeypatch.setattr(flash, "run_final_check", check) + outcome = await flash.settle_flash_outcome( + context(tmp_path), "报告", {"status": "completed", "explanation": "实际报告正文"} + ) + receipt = check.call_args.kwargs["report_delivery"] + assert receipt["ok"] is False and receipt["verified"] is False + assert "report_path" not in receipt + assert outcome["test_result"]["status"] == "blocked" + + @pytest.mark.asyncio async def test_execution_failure_is_blocked_without_another_model_retry(tmp_path, monkeypatch): check = AsyncMock() @@ -116,3 +233,126 @@ async def test_cancellation_of_acceptance_propagates(tmp_path, monkeypatch): with pytest.raises(asyncio.CancelledError): await flash.settle_flash_outcome(context(tmp_path), "任务", {"status": "completed"}) assert not (tmp_path / "run_outcome.json").exists() + + +@pytest.mark.asyncio +async def test_complete_checker_answer_is_persisted_without_reformatting(tmp_path, monkeypatch): + from artemis.agents.checker import checker + + ctx = context(tmp_path) + ctx.exploration_id = None + ctx.on_execution_review = None + result = CaseTestResult( + status="passed", + reason="双端布局探索和报告达标;不代表网站无缺陷。", + evidence=["步骤 18、27:双端真实页底;报告列出待核实风险。"], + ) + answer = AIMessage( + content="最终裁定\n```json\n" + json.dumps({"test_result": result.model_dump()}) + "\n```" + ) + llm = MagicMock() + monkeypatch.setattr(checker, "get_llm", lambda **_: llm) + monkeypatch.setattr(checker, "acomplete", AsyncMock(return_value=answer)) + monkeypatch.setattr(checker, "build_checker_tools", lambda *args: []) + monkeypatch.setattr(checker, "_capture_final_screen", AsyncMock(return_value=(None, "footer"))) + monkeypatch.setattr(checker, "_load_steps", lambda *args: []) + + outcome = await flash.settle_flash_outcome( + ctx, "如实报告布局风险,不要求网站无缺陷", {"status": "completed"} + ) + + llm.with_structured_output.assert_not_called() + assert outcome["test_result"] == result.model_dump() + saved = json.loads((tmp_path / "run_outcome.json").read_text(encoding="utf-8")) + assert saved["test_result"] == result.model_dump() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("execution_target", ["android", "computer"]) +async def test_flash_text_delivery_reaches_checker_through_real_trace( + tmp_path, monkeypatch, execution_target +): + from artemis.agents.checker import checker + from artemis.agents.flash.runner import FlashRunner + from artemis.data_engine import engine as engine_module + from artemis.data_engine.engine import DataEngine + from artemis.graph.state import State + from artemis.memory.transcript import TranscriptLedger + from artemis.mcp.action_executor import McpActionExecutor + + monkeypatch.setattr(engine_module, "PAUSE_FILE", tmp_path / "pause") + monkeypatch.setattr(engine_module, "_CURRENT_DATA_ENGINE", None) + ctx = context(tmp_path, traces_path=str(tmp_path)) + ctx.device = None + ctx.exploration_id = None + ctx.on_execution_review = None + ctx.execution_target = execution_target + ctx.task_control = None + engine = DataEngine(ctx) + ctx.data_engine = engine + engine.start_session("保存交付文件并验收") + actuator = MagicMock() + actuator.extensions.return_value = [] + executor = McpActionExecutor(ctx, actuator=actuator, agent_name="flash") + report_body = "# 双端报告\n实际发现及未测范围。" + verdict = CaseTestResult(status="inconclusive", reason="交付已保存,业务证据不足") + + async def inspect_request(llm, messages, **kwargs): + text = "\n".join(block.get("text", "") for block in messages[-1].content) + assert '"tool": "save_note"' in text + assert '"path": "notes/findings.md"' in text and '"verified": true' in text + assert '"report_path": "notes/output.md"' in text + assert (engine.base_dir / "notes/output.md").read_text(encoding="utf-8") == report_body + assert (engine.base_dir / "notes/findings.md").read_text(encoding="utf-8") == "业务观察记录" + return AIMessage(content=json.dumps({"test_result": verdict.model_dump()})) + + model = AsyncMock(side_effect=inspect_request) + monkeypatch.setattr(checker, "get_llm", lambda **_: MagicMock()) + monkeypatch.setattr(checker, "acomplete", model) + monkeypatch.setattr(checker, "_capture_final_screen", AsyncMock(return_value=(None, ""))) + monkeypatch.setattr("artemis.controllers.unified_controller.get_driver", lambda *_: MagicMock()) + runner = FlashRunner(ctx, goal="核实双端布局并保存报告", max_turns=0) + runner.executor = executor + runner.summarizer = None + runner._init_llm = MagicMock(return_value=MagicMock()) + runner._prepare_conversation = AsyncMock(return_value=(TranscriptLedger(), None, "")) + runner._refresh_observation = AsyncMock(side_effect=AssertionError("note must not observe")) + runner._android_observation_stale = True + runner._invoke_model = AsyncMock( + side_effect=[ + AIMessage( + content="保存已观察结果。", + tool_calls=[ + { + "name": "save_note", + "args": {"key": "findings", "content": "业务观察记录"}, + "id": "save-1", + } + ], + ), + AIMessage( + content="提交报告。", + tool_calls=[ + { + "name": "report_task_status", + "args": {"status": "completed", "explanation": report_body}, + "id": "done", + } + ], + ), + ] + ) + try: + execution = await runner.run(State(initial_goal=runner.goal)) + await engine.shutdown() + steps = engine.get_agent_friendly_steps() + assert steps[0]["action_taken"]["action"] == "save_note" + assert steps[0]["tool_calls"][0]["name"] == "save_note" + assert not steps[0].get("pre_image_name") and not steps[0].get("post_image_name") + runner._refresh_observation.assert_not_awaited() + outcome = await flash.settle_flash_outcome(ctx, runner.goal, execution) + model.assert_awaited_once() + assert outcome["test_result"] == verdict.model_dump() + actuator.execute.assert_not_called() + finally: + await engine.shutdown() diff --git a/tests/unit/agents/test_flash_cross_target_observation.py b/tests/unit/agents/test_flash_cross_target_observation.py new file mode 100644 index 0000000..2fb97ca --- /dev/null +++ b/tests/unit/agents/test_flash_cross_target_observation.py @@ -0,0 +1,244 @@ +"""Cross-target observations save work without executing stale phone decisions.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from langchain_core.messages import AIMessage, ToolMessage + +from artemis.agents.flash import runner as flash +from artemis.agents.validator.tool_declarations import ToolExecutionResult +from artemis.mcp.observation import DeviceObservationError +from artemis.memory.transcript import TranscriptLedger +from artemis.runtime.task_control import TaskControl + + +PC = "plug_pc_action" + + +@pytest.fixture +def cross_target(monkeypatch): + plugins = SimpleNamespace( + bindings={PC: ("pc", None)}, + tool_names={PC}, + previous_image=lambda *_: b"pc-before", + # This fixture only returns a PC observation; stage-write guards have + # dedicated coverage in test_flash_progress.py. + is_read_only=lambda _: True, + ) + monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins) + runner = flash.FlashRunner.__new__(flash.FlashRunner) + runner.ctx = SimpleNamespace(execution_target="mixed", data_engine=None, task_control=None) + runner._android_observation_stale = False + runner.controller, runner.summarizer, runner._llm = Mock(), None, None + state = SimpleNamespace( + latest_screenshot="old-phone.png", + indexed_elements=[{"index": 1, "text": "Old button"}], + indexed_points=[[1, 2]], + ) + + async def execute(name, args, tc_id, current, **kwargs): + if name == PC: + return ToolExecutionResult( + tool_name=name, + tool_call_id=tc_id, + status="success", + screenshot_bytes=b"pc-after", + ui_elements_text="PC controls", + text_summary="PC result", + metadata={"plugin_id": "pc"}, + raw_result=[ + {"type": "text", "text": "PC controls"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,cGM="}, + }, + ], + ) + current.indexed_elements = [{"index": 1, "text": "Fresh button"}] + current.indexed_points = [[10, 20]] + current.latest_screenshot = "fresh-phone.png" + return ToolExecutionResult( + tool_name=name, + tool_call_id=tc_id, + status="success", + text_summary="OK", + screenshot_bytes=b"fresh-phone", + ui_elements_text="Fresh button", + ) + + runner.executor = SimpleNamespace( + action_tool_names={PC, "click", "wait_for_delay"}, + execute=AsyncMock(side_effect=execute), + ) + + async def capture(ctx, current, controller, **kwargs): + current.indexed_elements = [{"index": 1, "text": "Fresh button"}] + current.indexed_points = [[10, 20]] + return "fresh-phone.png", b"fresh-phone", "Fresh button" + + capture_mock = AsyncMock(side_effect=capture) + monkeypatch.setattr(flash, "capture_screenshot_and_parse_ui", capture_mock) + return runner, state, capture_mock + + +def _call(name, call_id, **args): + return {"name": name, "id": call_id, "args": args} + + +async def _dispatch(runner, state, calls, image=None, tree=""): + messages = [] + turn = flash._TurnRecord() + result = await runner._process_tool_calls(calls, state, messages, "", {}, image, tree, 0, turn) + return result, messages, turn + + +@pytest.mark.asyncio +async def test_continuous_pc_calls_do_not_capture_or_resend_the_phone(cross_target): + runner, state, capture = cross_target + for i in range(3): + result, messages, _ = await _dispatch(runner, state, [_call(PC, str(i))]) + assert result[1] is None + assert "不是安卓画面" in result[2] + assert "data:image/png;base64,cGM=" in str(messages) + assert not state.indexed_elements and not state.indexed_points + assert state.latest_screenshot is None + capture.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "args", [{"target": 1}, {"target": [100, 200], "target_description": "Button"}] +) +async def test_stale_phone_action_and_remainder_are_deferred_until_new_decision(cross_target, args): + runner, state, capture = cross_target + calls = [ + _call(PC, "pc"), + _call("click", "tap", **args), + _call("report_task_status", "end", status="completed"), + ] + result, messages, turn = await _dispatch(runner, state, calls, b"old-phone", "Old button") + assert result[0] is None + assert result[1:3] == (b"fresh-phone", "Fresh button") + assert [c.args[0] for c in runner.executor.execute.call_args_list] == [PC] + assert [m.tool_call_id for m in messages if isinstance(m, ToolMessage)] == ["pc", "tap", "end"] + assert all("未执行" in str(m.content) for m in messages[-2:]) + assert [action[0] for action in turn.actions] == [PC] + capture.assert_awaited_once() + + # A subsequent model decision sees the new indices; the old index snapshot + # from the mixed batch must not leak into this new click. + await _dispatch(runner, state, [_call("click", "fresh-tap", target=1)], *result[1:3]) + assert runner.executor.execute.call_args.kwargs["index_elements"][0]["text"] == "Fresh button" + capture.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_explicit_refresh_executes_wait_once_but_defers_same_turn_click(cross_target): + runner, state, capture = cross_target + await _dispatch(runner, state, [_call(PC, "pc")]) + result, messages, _ = await _dispatch( + runner, + state, + [ + _call("wait_for_delay", "observe", time_in_ms=0), + _call("click", "tap", target=1), + ], + ) + assert [c.args[0] for c in runner.executor.execute.call_args_list] == [PC, "wait_for_delay"] + assert result[1:3] == (b"fresh-phone", "Fresh button") + assert messages[-1].status == "error" and messages[-1].tool_call_id == "tap" + assert not runner._android_observation_stale + capture.assert_not_awaited() # The wait result already contains a fresh phone observation. + + +@pytest.mark.asyncio +async def test_pc_loading_wait_keeps_phone_stale_and_next_phone_click_is_deferred( + cross_target, monkeypatch +): + runner, state, capture = cross_target + sleep = AsyncMock() + monkeypatch.setattr(flash.asyncio, "sleep", sleep) + await _dispatch(runner, state, [_call(PC, "pc")]) + result, messages, turn = await _dispatch( + runner, state, [_call("wait_for_delay", "wait", time_in_ms=5000)] + ) + sleep.assert_awaited_once_with(5) + assert runner._android_observation_stale and result[1] is None + assert "未采集新画面" in messages[-1].content + assert turn.actions[0][0] == "wait_for_delay" + capture.assert_not_awaited() + assert runner.executor.execute.await_count == 1 + await _dispatch(runner, state, [_call("click", "tap", target=1)]) + capture.assert_awaited_once() + assert runner.executor.execute.await_count == 1 + + +@pytest.mark.asyncio +async def test_failed_pc_call_also_invalidates_the_phone_without_a_capture(cross_target): + runner, state, capture = cross_target + runner.executor.execute.side_effect = RuntimeError("PC connection lost") + result, messages, _ = await _dispatch(runner, state, [_call(PC, "pc")]) + assert result[1] is None and runner._android_observation_stale + assert state.latest_screenshot is None and state.indexed_elements == [] + assert messages[0].status == "error" + capture.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_failed_phone_refresh_never_dispatches_stale_click(cross_target): + runner, state, capture = cross_target + await _dispatch(runner, state, [_call(PC, "pc")]) + capture.side_effect = DeviceObservationError("phone disconnected") + with pytest.raises(DeviceObservationError): + await _dispatch(runner, state, [_call("click", "tap", target=1)]) + assert runner.executor.execute.await_count == 1 + assert runner._android_observation_stale + + +@pytest.mark.asyncio +@pytest.mark.parametrize("intervention", ["guidance", "resume", "resume_with_guidance"]) +async def test_user_intervention_refreshes_phone_before_next_model_decision( + cross_target, tmp_path, intervention +): + runner, state, capture = cross_target + runner.max_turns, runner.goal = 3, "cross-target task" + runner._init_llm = Mock(return_value=Mock()) + runner._get_tools = Mock(return_value=[SimpleNamespace(name="report_task_status")]) + runner._build_ledger = Mock(return_value=TranscriptLedger()) + runner._render_system_prompt = Mock(return_value="test") + runner._read_injected_instruction = AsyncMock( + side_effect=[ + (None, None), + ("按新指导继续", None) if intervention != "resume" else (None, None), + ] + ) + if intervention.startswith("resume"): + control = Mock(spec=TaskControl) + control.directory = tmp_path + control.checkpoint = AsyncMock(side_effect=[False, True]) + + async def uninterrupted(operation, **kwargs): + return False, await operation + + control.run_interruptibly = AsyncMock(side_effect=uninterrupted) + runner.ctx.task_control = control + seen = [] + answers = [ + AIMessage(content="Observe PC", tool_calls=[_call(PC, "pc")]), + AIMessage( + content="Done", tool_calls=[_call("report_task_status", "end", status="completed")] + ), + ] + + async def model(llm, tools, messages): + seen.append(messages[-1]) + return answers.pop(0) + + runner._invoke_model = AsyncMock(side_effect=model) + result = await runner._run(state) + assert result["status"] == "completed" + assert capture.await_count == 2 # Initial phone observation + one refresh for the intervention. + assert "Fresh button" in str(seen[-1].content) + assert "不是安卓画面" not in str(seen[-1].content) + assert not runner._android_observation_stale diff --git a/tests/unit/agents/test_flash_progress.py b/tests/unit/agents/test_flash_progress.py new file mode 100644 index 0000000..dfd2e0b --- /dev/null +++ b/tests/unit/agents/test_flash_progress.py @@ -0,0 +1,138 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from pydantic import ValidationError + +from artemis.agents.flash.progress import TaskProgress +from artemis.agents.flash.runner import FlashRunner, _TurnRecord + + +def plan(phone="active", pc="pending"): + return { + "reason": "按用户要求先手机页脚,再电脑独立检查", + "stages": [ + { + "id": "phone", + "scope": "Android 主体、页脚及最后滚动", + "resources": ["android"], + "status": phone, + "evidence": "步骤 12 页脚及 13 滚动截图" if phone in {"done", "blocked"} else "", + }, + { + "id": "pc", + "scope": "电脑检查", + "resources": ["cua-driver"], + "status": pc, + "evidence": "步骤 20 电脑截图" if pc == "done" else "", + }, + ], + } + + +def test_ordered_stages_require_evidence_and_keep_outstanding_scope(tmp_path): + progress = TaskProgress() + progress.update(plan(), resources={"android", "cua-driver"}, directory=tmp_path) + assert progress.guard("cua-driver") + assert progress.guard("android") is None + assert progress.guard(None) is None # read-only preflight + assert progress.guard(None, completed=True) + assert (tmp_path / "notes/task_progress.md").exists() + with pytest.raises(ValidationError): + progress.update(plan("pending", "active"), resources={"android", "cua-driver"}) + omitted = plan() + omitted["stages"] = omitted["stages"][1:] + with pytest.raises(ValueError, match="不能删除"): + progress.update(omitted, resources={"android", "cua-driver"}) + progress.update(plan("done", "active"), resources={"android", "cua-driver"}) + assert progress.guard("cua-driver") is None + assert progress.guard("android") + progress.update(plan("done", "done"), resources={"android", "cua-driver"}) + assert progress.guard(None, completed=True) is None + + +def test_pc_first_and_intentional_interleaving_are_supported(): + progress = TaskProgress() + value = { + "reason": "路由器调整后在手机验证", + "stages": [ + { + "id": "mixed", + "scope": "电脑改参数,手机核对,循环验证", + "resources": ["android", "cua-driver"], + "status": "active", + } + ], + } + progress.update(value, resources={"android", "cua-driver"}) + assert progress.guard("cua-driver") is None + assert progress.guard("android") is None + value["stages"][0]["resources"] = ["cua-driver"] + progress.update(value, resources={"cua-driver"}) + assert progress.guard("cua-driver") is None + + +@pytest.mark.asyncio +async def test_flash_preflight_does_not_authorize_pc_mutation(monkeypatch): + plugins = SimpleNamespace( + packages={"cua-driver": None}, + bindings={"plug_pc_look": ("cua-driver", None), "plug_pc_click": ("cua-driver", None)}, + tool_names={"plug_pc_look", "plug_pc_click"}, + is_read_only=lambda name: name == "plug_pc_look", + ) + monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins) + runner = FlashRunner.__new__(FlashRunner) + runner.ctx = SimpleNamespace(data_engine=None, execution_target="android") + runner.executor = SimpleNamespace(action_tool_names={"click"}) + runner._progress = TaskProgress() + runner._progress.update(plan(), resources={"android", "cua-driver"}) + runner._execute_and_record_action = AsyncMock(return_value=(None, "", 0)) + runner._refresh_observation = AsyncMock(return_value=(None, "")) + messages = [] + await runner._process_tool_calls( + [ + {"name": "plug_pc_look", "id": "look", "args": {}}, + {"name": "plug_pc_click", "id": "click", "args": {}}, + ], + SimpleNamespace(indexed_elements=[]), + messages, + "预检", + {}, + None, + "", + 0, + _TurnRecord(), + ) + runner._execute_and_record_action.assert_awaited_once() + assert runner._execute_and_record_action.call_args.args[0] == "plug_pc_look" + assert messages[-1].tool_call_id == "click" and messages[-1].status == "error" + runner._refresh_observation.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_final_report_reuses_exact_saved_body_and_rejects_missing_note(tmp_path): + runner = FlashRunner.__new__(FlashRunner) + runner.ctx = SimpleNamespace( + data_engine=SimpleNamespace(base_dir=tmp_path, current_step_id="s", record_step=Mock()) + ) + runner.summarizer = None + runner.final_report_received = False + from artemis.utils.notes import save_verified_note + + body = "# 报告\nPC 是深色;手机检查未全部覆盖。" + save_verified_note(tmp_path, "report", body) + messages = [] + args = {"status": "completed", "explanation": "全部完成", "report_note_key": "missing"} + result = await runner._finalize_task_report( + "report_task_status", args, "r", "", {}, None, "", messages + ) + assert result is None and not runner.final_report_received + args["report_note_key"] = "report" + result = await runner._finalize_task_report( + "report_task_status", args, "r2", "", {}, None, "", messages + ) + assert result["explanation"] == body + assert ( + runner.ctx.data_engine.record_step.call_args.kwargs["action_taken"]["args"]["explanation"] + == body + ) diff --git a/tests/unit/agents/test_flash_runner_ledger.py b/tests/unit/agents/test_flash_runner_ledger.py index 29c6f8f..31d17dd 100644 --- a/tests/unit/agents/test_flash_runner_ledger.py +++ b/tests/unit/agents/test_flash_runner_ledger.py @@ -99,6 +99,26 @@ def test_video_analyzer_bound_only_when_recording_tools_enabled(mock_context): HISTORY_TOOLS = {"search_history", "replay_steps", "get_step_screenshot"} +@pytest.mark.parametrize("target", ["android", "computer"]) +def test_flash_binds_task_text_delivery_only_when_trace_available(mock_context, tmp_path, target): + from types import SimpleNamespace + + mock_context.execution_target = target + with patch("artemis.controllers.unified_controller.get_driver"): + runner = FlashRunner(mock_context, goal="写报告") + tools = runner._get_tools() + assert "save_note" not in {t.name for t in tools} + assert "`save_note`" not in runner._render_system_prompt(tools) + mock_context.data_engine = SimpleNamespace(base_dir=tmp_path) + tools = runner._get_tools() + names = {t.name for t in tools} + assert {"save_note", "read_note", "list_notes"} <= names + save = next(t for t in tools if t.name == "save_note") + assert save.parameters["required"] == ["key", "content"] + prompt = runner._render_system_prompt(tools) + assert "notes/output.md" in prompt and "`save_note`" in prompt + + def test_history_tools_bound_only_with_a_data_engine_session(mock_context): with patch("artemis.controllers.unified_controller.get_driver"): # No DataEngine: nothing to read, the tools stay out (as in Pro). @@ -283,13 +303,13 @@ def test_injected_instruction_body_outlives_its_wrapper(mock_context): assert guidance.wrapper.startswith(USER_GUIDANCE_MARKER) assert "don't send messages" not in guidance.wrapper - # Scrub edge parity: once the turn has left the live position (a newer - # committed observation exists) the wrapper is gone and the body is - # still in the committed turn. + # Normal turns preserve guidance verbatim; an explicit compaction removes + # the old wrapper but preserves the user's standing instruction. ledger.stage_turn([tail, AIMessage(content="ok")]) ledger.commit_staged(step_key="s1") ledger.stage_turn([runner._build_tail(ledger, 3, b"IMG", "[1] Send"), AIMessage(content="ok")]) ledger.commit_staged(step_key="s2") + ledger.compact_active() rendered = ledger.render([runner._build_tail(ledger, 4, b"IMG", "[1] Send")]) committed = next( m @@ -679,8 +699,8 @@ async def test_run_calibrates_the_ledger_only_from_measured_usage(mock_context): assert record.call_count == 2 first_call, second_call = record.call_args_list assert first_call.args[0] == 1200 and first_call.kwargs["messages"] is not None - # The report turn carries no usage metadata: estimated, recorded, not calibrated. - assert second_call.args[0] >= 1 and second_call.kwargs["messages"] is None + # Missing usage stays unknown; the next render computes its own estimate. + assert second_call.args[0] is None and second_call.kwargs["messages"] is None @pytest.mark.asyncio diff --git a/tests/unit/agents/test_flash_step_summarizer.py b/tests/unit/agents/test_flash_step_summarizer.py index 5945457..f1b23aa 100644 --- a/tests/unit/agents/test_flash_step_summarizer.py +++ b/tests/unit/agents/test_flash_step_summarizer.py @@ -47,6 +47,103 @@ def _user_role_prompt(messages): return blocks[0]["text"], blocks[1:] +@pytest.mark.asyncio +async def test_identical_fresh_captures_are_recorded_without_model_call(mock_context): + summarizer = VisualStepSummarizer(mock_context) + summarizer._llm = Mock(ainvoke=AsyncMock()) + summarizer.dispatch( + step_number=27, + action_name="plug_scroll", + action_args={}, + pre_img_bytes=b"same", + post_img_bytes=b"same", + exec_outcome="input dispatched", + ) + await summarizer.flush() + summarizer._llm.ainvoke.assert_not_awaited() + assert "重新采集" in summarizer.get_summary(27) + assert "不能单凭此" in summarizer.get_summary(27) + assert summarizer._step_inputs[27]["post_img_bytes"] is None + + +@pytest.mark.asyncio +async def test_summary_rules_and_goal_prefix_stay_identical_across_steps(mock_context): + summarizer = VisualStepSummarizer(mock_context) + summarizer._llm = Mock( + ainvoke=AsyncMock( + return_value=AIMessage(content="我观察到页面文字从旧内容变成新内容,其他状态未确认。") + ) + ) + for number in (1, 2): + summarizer.dispatch( + step_number=number, + action_name="swipe", + action_args={}, + pre_img_bytes=b"before", + post_img_bytes=b"after", + exec_outcome="dispatched", + focus={"goal": "Inspect both devices", "intent": f"continue step {number}"}, + ) + await summarizer.flush() + calls = summarizer._llm.ainvoke.call_args_list + prefix1, blocks1 = _user_role_prompt(calls[0].args[0]) + prefix2, blocks2 = _user_role_prompt(calls[1].args[0]) + assert prefix1 == prefix2 and "Inspect both devices" in prefix1 + assert "Step 1 Physical Action" in str(blocks1) + assert "Step 2 Physical Action" in str(blocks2) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("duplicate", [True, False]) +async def test_summary_keeps_ui_once_without_losing_controller_errors(mock_context, duplicate): + summarizer = VisualStepSummarizer(mock_context) + summarizer._llm = Mock( + ainvoke=AsyncMock(return_value=AIMessage(content="我观察到当前设置,状态变化仍待确认。")) + ) + ui = 'Window Brave\n[1] Text "未保存"\n[2] Button "重试"' + outcome = "控制器错误: input rejected\n" + (ui if duplicate else "different earlier tree") + outcome += "\n恢复状态: unknown" + summarizer.dispatch( + step_number=1, + action_name="plug_click", + action_args={}, + pre_img_bytes=b"before", + post_img_bytes=b"after", + exec_outcome=outcome, + post_ui_elements_text=ui, + observation_settled=False, + ) + await summarizer.flush() + _, blocks = _user_role_prompt(summarizer._llm.ainvoke.call_args.args[0]) + text = "\n".join(b.get("text", "") for b in blocks) + assert text.count(ui) == 1 + assert "控制器错误: input rejected" in text and "恢复状态: unknown" in text + assert "screen still changing" in text + assert "AFTER ACTION UI ELEMENTS" in text + if not duplicate: + assert "different earlier tree" in text + + +@pytest.mark.asyncio +async def test_receipt_summary_retains_tree_even_when_duplicate_is_removed_for_model(mock_context): + summarizer = VisualStepSummarizer(mock_context) + summarizer._llm = Mock(ainvoke=AsyncMock()) + ui = 'Window Brave\n[1] Text "未保存"' + summarizer.dispatch( + step_number=1, + action_name="plug_get_window_state", + action_args={}, + pre_img_bytes=b"same", + post_img_bytes=b"same", + exec_outcome="来源: PC\n" + ui, + post_ui_elements_text=ui, + receipt_only=True, + ) + await summarizer.flush() + summarizer._llm.ainvoke.assert_not_awaited() + assert ui in summarizer.get_summary(1) + + @pytest.mark.asyncio async def test_summarizer_dispatch_and_caching(mock_context): """Verify non-blocking dispatch and summary generation.""" @@ -654,7 +751,8 @@ async def test_focus_block_and_provenance_marked_action_phrase_reach_the_lens(mo ) assert "Action arguments (verbatim)" not in lead_text assert FOCUS_BLOCK_HEADER in lead_text - assert "Task goal: Turn on Wi-Fi" in lead_text + assert "Task goal (attention context, not observed evidence):\nTurn on Wi-Fi" in system_text + assert "Turn on Wi-Fi" not in lead_text assert "Active sub-goal: Enable Wi-Fi > Tap the Wi-Fi switch" in lead_text assert "User instruction at this step: Do not join any network yet." in lead_text assert "expect it to read On." in lead_text diff --git a/tests/unit/agents/test_historical_state_hint.py b/tests/unit/agents/test_historical_state_hint.py index 8a30248..0664599 100644 --- a/tests/unit/agents/test_historical_state_hint.py +++ b/tests/unit/agents/test_historical_state_hint.py @@ -163,7 +163,8 @@ async def test_steps_without_hashes_are_skipped(): assert await _run(steps) == [] -def test_record_step_stamps_perceptual_hashes(tmp_path): +@pytest.mark.asyncio +async def test_record_step_stamps_perceptual_hashes(tmp_path): from artemis.context import ArtemisContext from artemis.data_engine.engine import DataEngine @@ -187,8 +188,7 @@ def test_record_step_stamps_perceptual_hashes(tmp_path): post_screenshot_bytes=SCREEN_A, summary="screen unchanged", ) - for t in list(engine._pending_threads): - t.join() + await engine.shutdown() steps = engine.storage.get_steps(engine.current_session_id) meta1 = steps[0].extra_metadata diff --git a/tests/unit/agents/test_operator_prompt_estimate.py b/tests/unit/agents/test_operator_prompt_estimate.py index 7367320..30de3fc 100644 --- a/tests/unit/agents/test_operator_prompt_estimate.py +++ b/tests/unit/agents/test_operator_prompt_estimate.py @@ -30,7 +30,7 @@ from artemis.agents.operator.operator import OperatorNode from artemis.config.agent import MemoryTranscriptConfig from artemis.context import ArtemisContext from artemis.memory.step_memory import StepMemoryService -from artemis.memory.transcript import TranscriptLedger, estimate_prompt_tokens +from artemis.memory.transcript import TranscriptLedger SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8") @@ -92,7 +92,7 @@ def _llm(captured: list, usage_metadata=None): @pytest.mark.asyncio -async def test_operator_records_an_estimate_when_the_provider_reports_no_usage(): +async def test_operator_uses_projected_context_without_fabricating_provider_usage(): ctx = _ctx() captured: list = [] with patch("artemis.agents.operator.operator.get_llm", return_value=_llm(captured)): @@ -102,8 +102,8 @@ async def test_operator_records_an_estimate_when_the_provider_reports_no_usage() ledger = ctx.transcript_ledger assert isinstance(ledger, TranscriptLedger) assert captured, "the operator made no model call" - assert ledger.last_prompt_tokens == estimate_prompt_tokens(captured[-1]) - assert ledger.last_prompt_tokens > 0 + assert ledger.last_prompt_tokens is None + assert ledger.projected_prompt_tokens > 0 # No measurement, no calibration. assert ledger.chars_per_token == 4.0 diff --git a/tests/unit/agents/test_operator_recovery.py b/tests/unit/agents/test_operator_recovery.py index 7820ea3..234c66f 100644 --- a/tests/unit/agents/test_operator_recovery.py +++ b/tests/unit/agents/test_operator_recovery.py @@ -93,6 +93,108 @@ async def test_prose_retry_keeps_context_and_recovers_once(operator, monkeypatch assert any(isinstance(m, SystemMessage) and "No tool call" in m.content for m in messages) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "scenario", ["complete", "pending", "unfinished", "unchanged", "failed", "continuous", "pro"] +) +async def test_completed_exploration_plan_does_not_force_an_extra_action( + operator, monkeypatch, scenario +): + node, state = operator + node.ctx.exploration_id = None if scenario == "pro" else "exploration" + node.ctx.data_engine = None + plan = {"text": "- [ ] Return home"} + if scenario == "unchanged": + plan["text"] = "- [x] Return home" + node._read_task_plan_text = lambda: plan["text"] + monkeypatch.setattr("artemis.exploration.runtime.publish_response", Mock()) + monkeypatch.setattr( + "artemis.exploration.runtime.needs_exploration_turn", lambda ctx: scenario == "pending" + ) + + async def save_note(**kwargs): + plan["text"] = "- [ ] Return home" if scenario == "unfinished" else "- [x] Return home" + if scenario == "continuous": + plan["text"] += " [Loop:continuous]" + return ToolMessage( + content="Saved plan", + tool_call_id="plan", + status="error" if scenario == "failed" else "success", + ) + + monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", save_note) + response = AIMessage( + content="Confirmed home; recorded evidence.", + tool_calls=[{"name": "save_note", "args": {}, "id": "plan"}], + ) + final = AIMessage(content="# 完整调查报告\n已回主页,证据见步骤 3。未检查其他页面。") + finishing = scenario in {"complete", "unchanged"} + invoke = AsyncMock(side_effect=[response, final if finishing else click()]) + monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke) + messages = [] + result = await node._invoke_llm_loop( + Mock(), messages, [SimpleNamespace(name="save_note")], [], state + ) + if finishing: + assert invoke.await_count == 2 + assert result[0] is None + assert final.content in result[1] + assert state.execution_report == final.content + assert any("完整报告正文" in m.content for m in messages if isinstance(m, SystemMessage)) + assert result[3] is False + elif scenario == "pending": + assert invoke.await_count == 1 + assert result[0] is None + assert state.execution_report is None + return + else: + assert invoke.await_count == 2 + assert result[0] + assert state.execution_report is None + assert any(isinstance(m, ToolMessage) and m.tool_call_id == "plan" for m in messages) + + +@pytest.mark.asyncio +async def test_final_report_is_discarded_when_guidance_arrives(operator, monkeypatch): + node, state = operator + state.execution_report = "旧报告" + node.ctx.exploration_id = "exploration" + node._read_task_plan_text = lambda: "- [x] 调查完成" + pending = {"value": False} + monkeypatch.setattr( + "artemis.exploration.runtime.needs_exploration_turn", lambda ctx: pending["value"] + ) + publish = Mock() + monkeypatch.setattr("artemis.exploration.runtime.publish_response", publish) + + async def answer(*args): + pending["value"] = True + return AIMessage(content="这是过时的最终报告") + + monkeypatch.setattr("artemis.agents.operator.operator.acomplete", answer) + result = await node._invoke_llm_loop(Mock(), [], [], [], state) + assert result[0] is None + assert state.execution_report is None + publish.assert_not_called() + + +@pytest.mark.asyncio +async def test_empty_final_report_uses_bounded_recovery(operator, monkeypatch): + node, state = operator + node.ctx.exploration_id = "exploration" + node._read_task_plan_text = lambda: "- [x] 调查完成" + monkeypatch.setattr("artemis.exploration.runtime.needs_exploration_turn", lambda ctx: False) + monkeypatch.setattr("artemis.exploration.runtime.publish_response", Mock()) + monkeypatch.setattr("artemis.agents.operator.operator.get_llm", Mock(return_value=Mock())) + monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 2) + invoke = AsyncMock(return_value=AIMessage(content=" ")) + monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke) + with pytest.raises(OperatorNoProgressError): + await node._invoke_llm_loop(Mock(), [], [], [], state) + assert state.execution_report is None + assert invoke.await_count == 5 + + @pytest.mark.asyncio async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatch): result, messages, invoke = await run_loop(operator, monkeypatch, [click("14"), click("13")]) diff --git a/tests/unit/agents/test_operator_transcript.py b/tests/unit/agents/test_operator_transcript.py index d389143..84bf207 100644 --- a/tests/unit/agents/test_operator_transcript.py +++ b/tests/unit/agents/test_operator_transcript.py @@ -30,7 +30,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest -from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from artemis.agents.operator.operator import OperatorNode from artemis.agents.operator.prompts import load_operator_prompts @@ -54,8 +54,8 @@ from artemis.memory.transcript import ( # SHA-256 snapshots of the legacy system message with the fixed inputs below. # Update these when an intentional template change alters the rendered prompt. -GOLDEN_EMPTY_PLAN = "d715128625911aec46c83138e15aabfa967b385ab4c878509fe36ab4304fae2a" -GOLDEN_SENTINEL_PLAN = "e2cf4ede75ffd9e283159f70dcd5c94a169c18e9f6dd4d1e7aacf460e956eaf3" +GOLDEN_EMPTY_PLAN = "e83c2c6cc1e28b04898e6602194cb2fbac67fe5a3aec8e109872c375f4e59944" +GOLDEN_SENTINEL_PLAN = "dba4eb2cdcd9389148ac3228c6e9e69fdbe8d407dec76b58aa65509ad768c392" SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8") @@ -141,6 +141,135 @@ def _transcript_state(**overrides): return state +@pytest.mark.asyncio +@pytest.mark.parametrize("exploration", [False, True]) +@pytest.mark.parametrize("transcript", [False, True]) +async def test_plugin_result_is_delivered_with_pc_image_and_separate_phone_observation( + tmp_path, exploration, transcript, monkeypatch +): + from langchain_openai import ChatOpenAI + from PIL import Image + + ctx = _transcript_ctx() + ctx.exploration_id = "explore" if exploration else None + monkeypatch.setattr( + "artemis.exploration.runtime.exploration_messages", + lambda _: [SystemMessage(content="Explore"), HumanMessage(content="Guidance")], + ) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + pc = tmp_path / "pc.jpg" + Image.new("RGB", (200, 100), "blue").save(pc) + ctx.data_engine.get_image_path.return_value = pc + node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=transcript)) + ledger = node._ensure_transcript_ledger() + tool = "plug_test_get_app_state" + ledger.stage_turn( + [ + AIMessage(content="", tool_calls=[{"name": tool, "args": {}, "id": "pc_call"}]), + ToolMessage(content="Action Recorded", tool_call_id="pc_call"), + ] + ) + report = { + "status": "dispatched", + "execution": [ + { + "action": tool, + "tool_call_id": "pc_call", + "plugin_id": "test", + "plugin_status": "success", + "result": "PC Address bar", + "post_image_name": "pc", + } + ], + } + state = _transcript_state(structured_decisions="[]", last_execution_result=report) + messages = await node._build_prompt(state, SCREENSHOT_B64, [], "PHONE_ELEMENTS", 2, [], "") + if transcript: + output = next(m for m in messages if isinstance(m, ToolMessage)) + assert output.tool_call_id == "pc_call" and "PC Address bar" in str(output.content) + else: + assert "PC Address bar" in str(messages) + assert base64.b64encode(pc.read_bytes()).decode() in str(messages) + assert SCREENSHOT_B64 not in str(messages) and "PHONE_ELEMENTS" in str(messages) + wire = ChatOpenAI(model="fixture", api_key="fixture", use_responses_api=True, store=False) + payload = wire._get_request_payload(messages)["input"] + assert len([x for x in payload if x.get("type") == "function_call_output"]) == int(transcript) + guided = await node._build_prompt( + _transcript_state( + structured_decisions="[]", last_execution_result=report, injected_instruction="回到手机" + ), + SCREENSHOT_B64, + [], + "PHONE_ELEMENTS", + 3, + [], + "", + ) + assert SCREENSHOT_B64 in str(guided) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "exploration,plugins", [(False, False), (True, False), (False, True), (True, True)] +) +async def test_injected_prefix_and_compression_preserve_tool_pairs( + monkeypatch, exploration, plugins +): + from langchain_openai import ChatOpenAI + + ctx = _transcript_ctx() + ctx.exploration_id = "test-exploration" if exploration else None + ctx.plugins = SimpleNamespace(prompt=lambda: "Plugin instructions") if plugins else None + monkeypatch.setattr( + "artemis.exploration.runtime.exploration_messages", + lambda _: [ + SystemMessage(content="Exploration instructions"), + HumanMessage(content="User guidance"), + ], + ) + monkeypatch.setattr("artemis.agents.operator.operator.get_llm", lambda **_: MagicMock()) + node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True)) + requests = [] + wire_model = ChatOpenAI(model="test", api_key="test", use_responses_api=True, store=False) + + async def tool_loop(current_messages, **_kwargs): + messages = current_messages + payload = wire_model._get_request_payload(messages) + requests.append(payload["input"]) + call_id = f"call_{len(requests)}" + messages.extend( + [ + AIMessage( + content="", tool_calls=[{"name": "click", "args": {"target": 1}, "id": call_id}] + ), + ToolMessage(content="Action Recorded", tool_call_id=call_id), + ] + ) + return [{"action": "tap"}], None, None, False + + monkeypatch.setattr(node, "_invoke_llm_loop", tool_loop) + for turn in range(8): + await node(_transcript_state(current_step_id=f"step-{turn}")) + if turn == 4: + ctx.transcript_ledger.freeze_turns(2, [HumanMessage(content="Summarized first turns")]) + + for items in requests: + pending, seen = set(), set() + for item in items: + if item.get("type") == "function_call": + assert item["call_id"] not in seen + pending.add(item["call_id"]) + seen.add(item["call_id"]) + elif item.get("type") == "function_call_output": + assert item["call_id"] in pending + pending.remove(item["call_id"]) + assert not pending + assert {item["call_id"] for item in requests[-1] if item.get("type") == "function_call"} == { + f"call_{n}" for n in range(3, 8) + } + + def _action_llm(captured: list): mock_llm = MagicMock() mock_response = MagicMock() @@ -226,7 +355,7 @@ async def test_transcript_mode_two_turns_build_four_regions(tmp_path): # Text edge at depth 1: the committed observation has lost its plan # recitation as soon as the next tail exists (its screenshot stays # until the screenshot edge). - assert PLAN_RECITATION_MARKER not in str(committed_obs.content) + assert PLAN_RECITATION_MARKER in str(committed_obs.content) # retained until compaction # The live tail still recites the plan. assert PLAN_RECITATION_MARKER in str(turn2_messages[-1].content) # Message layout is S + A + tail. @@ -354,3 +483,38 @@ async def test_flag_off_keeps_legacy_two_message_build_and_no_ledger(): assert isinstance(captured[0][0], SystemMessage) assert isinstance(captured[0][1], HumanMessage) assert ctx.transcript_ledger is None + + +@pytest.mark.asyncio +async def test_exploration_guidance_appends_without_rewriting_static_prefix(monkeypatch): + ctx = _transcript_ctx() + ctx.exploration_id = "explore" + guidance = ["initial guidance"] + monkeypatch.setattr( + "artemis.exploration.runtime.exploration_messages", + lambda _: [SystemMessage(content="rules"), HumanMessage(content=guidance[0])], + ) + node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True)) + state = _transcript_state() + first = await node._build_prompt_transcript(state, "", "", 1, [], "") + prefix = first[0].content + assert "initial guidance" not in prefix + assert "initial guidance" in str(first[-1].content) + node._stage_transcript_turn(first) + second = await node._build_prompt_transcript(state, "", "", 2, [], "") + assert second[0].content == prefix + assert "initial guidance" not in str(second[-1].content) + node._stage_transcript_turn(second) + guidance[0] = "new direction" + third = await node._build_prompt_transcript(state, "", "", 3, [], "") + assert third[0].content == prefix + assert "new direction" in str(third[-1].content) + # An interrupted/uncommitted build must not acknowledge away the guidance. + retried = await node._build_prompt_transcript(state, "", "", 3, [], "") + assert "new direction" in str(retried[-1].content) + node._stage_transcript_turn(retried) + await node._build_prompt_transcript(state, "", "", 4, [], "") + ctx.transcript_ledger.freeze_turns(3, [HumanMessage(content="compressed history")]) + compacted = await node._build_prompt_transcript(state, "", "", 4, [], "") + assert compacted[0].content == prefix + assert "new direction" in str(compacted[-1].content) diff --git a/tests/unit/agents/test_prompt_assembly.py b/tests/unit/agents/test_prompt_assembly.py index f924f3d..78ee3c9 100644 --- a/tests/unit/agents/test_prompt_assembly.py +++ b/tests/unit/agents/test_prompt_assembly.py @@ -151,7 +151,7 @@ def test_full_set_enum_slots_render_verbatim(): " `press_key`, `manage_app`, or `wait_for_delay`)" in out ) assert "Helper/Subagent tools (`ask_explorer`, `ask_diagnoser`, `video_analyzer`)" in out - assert "ADB/task tools (`run_adb_command`, `manage_task`)" in out + assert "ADB/task tools (`run_adb_command`, `manage_task`, `android_file`)" in out assert ( "(`read_note`, `list_notes`, `search_history`, `replay_steps`, `get_step_screenshot`)" in out diff --git a/tests/unit/agents/test_summarizer.py b/tests/unit/agents/test_summarizer.py index 9166b74..b640a8a 100644 --- a/tests/unit/agents/test_summarizer.py +++ b/tests/unit/agents/test_summarizer.py @@ -106,6 +106,7 @@ async def test_summarizer_dispatches_visual_lens(mock_context): exec_outcome="dispatched", data_engine_step_id="12345678-1234-5678-1234-567812345678", focus=None, + receipt_only=False, ) # No direct capsule write from the node: the lens owns the versioned # summary write when its background job completes. @@ -217,3 +218,48 @@ async def test_summarizer_noop_without_step_id(mock_context): assert result == {} mock_context.step_memory.dispatch.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target", ["android", "mixed", "computer"]) +async def test_plugin_summary_uses_actual_result_and_pc_evidence(mock_context, tmp_path, target): + pc = tmp_path / "pc.jpg" + pc.write_bytes(b"PC_IMAGE") + mock_context.execution_target = target + mock_context.data_engine.get_image_path.return_value = pc + state = DummyState( + current_step_id="step", + last_execution_result={ + "status": "dispatched", + "execution": [ + { + "action": "plug_test_get_app_state", + "plugin_id": "test", + "result": "来源:电脑插件 / Browser window", + "post_image_name": "pc", + } + ], + }, + ) + await SummarizerNode(mock_context)(state) + kwargs = mock_context.step_memory.dispatch.call_args.kwargs + assert kwargs["pre_img_bytes"] is None + assert kwargs["post_img_bytes"] == b"PC_IMAGE" + assert "Browser window" in kwargs["exec_outcome"] + + # Cancelled/invalid calls can lack plugin metadata; the tool name still + # identifies the resource boundary and must not fall back to the phone. + mock_context.step_memory.reset_mock() + state.last_execution_result = { + "status": "failed", + "execution": [ + { + "action": "plug_test_get_app_state", + "result": "操作已打断,结果不确定。", + } + ], + } + await SummarizerNode(mock_context)(state) + kwargs = mock_context.step_memory.dispatch.call_args.kwargs + assert kwargs["pre_img_bytes"] is None and kwargs["post_img_bytes"] is None + assert "打断" in kwargs["exec_outcome"] diff --git a/tests/unit/core/test_platform.py b/tests/unit/core/test_platform.py index b5f50bb..7806ad9 100644 --- a/tests/unit/core/test_platform.py +++ b/tests/unit/core/test_platform.py @@ -37,8 +37,9 @@ def test_platform_singleton(): assert p.process.path_separator in (":", ";") -def test_linux_platform_paths(): +def test_linux_platform_paths(monkeypatch): """Verify Linux platform paths conform to XDG Base Directory specification.""" + monkeypatch.delenv("ARTEMIS_APP_DIR", raising=False) with patch.dict( "os.environ", {"XDG_CONFIG_HOME": "/tmp/test_xdg_config", "XDG_DATA_HOME": "/tmp/test_xdg_data"}, @@ -61,8 +62,9 @@ def test_darwin_platform_paths(): assert darwin_p.process.path_separator == ":" -def test_windows_platform_paths(): +def test_windows_platform_paths(monkeypatch): """Verify Windows platform paths adhere to AppData standards.""" + monkeypatch.delenv("ARTEMIS_APP_DIR", raising=False) with patch.dict( "os.environ", { diff --git a/tests/unit/data_engine/test_engine.py b/tests/unit/data_engine/test_engine.py index f4bf78c..ba2c3c3 100644 --- a/tests/unit/data_engine/test_engine.py +++ b/tests/unit/data_engine/test_engine.py @@ -229,7 +229,7 @@ def test_video_recording_persistence(tmp_path): assert updated.status == "ready" -def test_record_step_suppresses_identical_post_screenshot(tmp_path): +def test_record_step_retains_identical_post_capture_without_duplicating_file(tmp_path): mock_ctx = MagicMock(spec=ArtemisContext) mock_execution_setup = MagicMock() mock_execution_setup.traces_path = str(tmp_path) @@ -251,11 +251,11 @@ def test_record_step_suppresses_identical_post_screenshot(tmp_path): step_record = engine.storage.get_steps(engine.current_session_id)[0] assert step_record is not None assert step_record.pre_image_name is not None - # post_image_name must be None when no visual transition occurred - assert step_record.post_image_name is None + assert step_record.post_image_name == step_record.pre_image_name + assert len(list((tmp_path / "images").iterdir())) == 1 -def test_update_step_execution_result_suppresses_identical_post_screenshot(tmp_path): +def test_update_step_execution_result_retains_identical_post_capture(tmp_path): mock_ctx = MagicMock(spec=ArtemisContext) mock_execution_setup = MagicMock() mock_execution_setup.traces_path = str(tmp_path) @@ -284,7 +284,7 @@ def test_update_step_execution_result_suppresses_identical_post_screenshot(tmp_p ) updated_step = engine.storage.get_steps(engine.current_session_id)[0] - assert updated_step.post_image_name is None + assert updated_step.post_image_name == updated_step.pre_image_name # Update with distinct post_image_name engine.storage.update_step_execution_result( diff --git a/tests/unit/data_engine/test_latency_optimizations_deep.py b/tests/unit/data_engine/test_latency_optimizations_deep.py index ee8cacc..89ad29e 100644 --- a/tests/unit/data_engine/test_latency_optimizations_deep.py +++ b/tests/unit/data_engine/test_latency_optimizations_deep.py @@ -197,6 +197,15 @@ async def test_perception_node_async_offloading(mock_artemis_ctx, tmp_path): engine = DataEngine(mock_artemis_ctx) engine.start_session("Test Perception Node Offloading") mock_artemis_ctx.data_engine = engine + image_saved = asyncio.Event() + loop = asyncio.get_running_loop() + save_image = engine.get_or_create_image + + def save_image_and_signal(*args, **kwargs): + try: + return save_image(*args, **kwargs) + finally: + loop.call_soon_threadsafe(image_saved.set) # Create an injected_instruction.json file instruction_file = Path(engine.base_dir) / "injected_instruction.json" @@ -214,6 +223,7 @@ async def test_perception_node_async_offloading(mock_artemis_ctx, tmp_path): mock_screen_data.height = 2400 with ( + patch.object(engine, "get_or_create_image", side_effect=save_image_and_signal), patch("artemis.graph.perception.UnifiedMobileController") as mock_controller_cls, patch("artemis.graph.perception.perform_ocr", new_callable=AsyncMock) as mock_ocr, patch("artemis.graph.perception._detect_status_bar_height", return_value=0), @@ -240,10 +250,9 @@ async def test_perception_node_async_offloading(mock_artemis_ctx, tmp_path): # Verify injected_instruction.json was unlinked assert not instruction_file.exists() - # Await background tasks triggered by perception_node - await asyncio.sleep(0.1) - if engine._pending_tasks: - await asyncio.gather(*engine._pending_tasks) + # perception_node creates its own task, outside engine._pending_tasks. + # Wait for actual persistence instead of assuming the disk finishes in 100 ms. + await asyncio.wait_for(image_saved.wait(), timeout=5) # Verify image record and file were created in background by perception_node img_record = engine.storage.get_image(expected_hash) diff --git a/tests/unit/graph/test_exit_settlement.py b/tests/unit/graph/test_exit_settlement.py index 80a897e..abcec41 100644 --- a/tests/unit/graph/test_exit_settlement.py +++ b/tests/unit/graph/test_exit_settlement.py @@ -17,6 +17,7 @@ settlement timeout, when-semantics at the exit, run outcome, and the four switch-combination matrix.""" import asyncio +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -51,6 +52,206 @@ DONE_PLAN_WITH_CHECKS = ( DONE_PLAN_NO_CHECKS = "- [x] Create alarm\n" +@pytest.fixture +def exploration_exit(tmp_path, monkeypatch): + from apps.admin_console.services.automation_store import AutomationStore + from artemis.config.exploration import ExplorationConfig + from artemis.exploration.store import ExplorationStore + from artemis.runtime import task_control + + repo = ExplorationStore(AutomationStore(tmp_path / "history.db")) + session = repo.create("逐步探索,听用户指导", ExplorationConfig(), {}, "create") + run = repo.reserve_run(session["id"], "run", kind="exploration", goal="探索", task_config={}) + repo.set_run_state(run["id"], "running") + monkeypatch.setattr("artemis.exploration.runtime.explorations", repo) + monkeypatch.setattr("artemis.exploration.store.explorations", repo) + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) + ctx = _make_ctx(tmp_path) + ctx.exploration_id = session["id"] + ctx.exploration_run_id = run["id"] + ctx.exploration_guidance_count = 0 + ctx.on_execution_review = AsyncMock() + ctx.on_execution_resume = AsyncMock() + _write_plan(tmp_path, DONE_PLAN_NO_CHECKS) + return ctx, repo, session, run + + +@pytest.mark.asyncio +async def test_exploration_question_prevents_completed_plan_from_starting_final_review( + exploration_exit, +): + ctx, repo, session, run = exploration_exit + repo.ask(run["id"], "下一步做什么?", "返回主页", "ask") + with patch("artemis.graph.graph.run_final_check", new=AsyncMock()) as reviewer: + assert convergence_gate(_make_state(), ctx) == "continue" + result = await exit_settlement_node(_make_state(execution_report="本次调查报告"), ctx) + assert result == {"exit_settlement_route": "continue"} + reviewer.assert_not_called() + assert repo.get(session["id"])["pending_question"] + + +@pytest.mark.asyncio +async def test_exploration_late_return_home_guidance_invalidates_final_review(exploration_exit): + ctx, repo, session, run = exploration_exit + + async def review(*args, **kwargs): + repo.add_message( + session["id"], "先回主页,然后结束", "home", metadata={"run_id": run["id"]} + ) + return CheckReport(verdicts=[], unmet_subgoals=[]) + + with patch("artemis.graph.graph.run_final_check", new=review): + result = await exit_settlement_node(_make_state(execution_report="本次调查报告"), ctx) + assert result == {"exit_settlement_route": "continue"} + assert repo.run(run["id"])["status"] == "running" + assert repo.run(run["id"])["user_guidance_count"] == 1 + assert not (ctx.data_engine.base_dir / "run_outcome.json").exists() + ctx.on_execution_review.assert_awaited_once() + ctx.on_execution_resume.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_review_stops_capture_before_waiting_and_seals_exploration(exploration_exit): + ctx, repo, session, run = exploration_exit + + async def review(*args, **kwargs): + ctx.on_execution_review.assert_awaited_once() + return CheckReport(verdicts=[], unmet_subgoals=[]) + + with patch("artemis.graph.graph.run_final_check", new=review): + result = await exit_settlement_node(_make_state(execution_report="本次调查报告"), ctx) + assert result["exit_settlement_route"] == "end" + assert repo.get(session["id"])["status"] == "finalizing" + ctx.on_execution_resume.assert_not_called() + + +@pytest.mark.asyncio +async def test_exploration_guidance_can_interrupt_shared_final_review_budget(exploration_exit): + from types import SimpleNamespace + + ctx, _, _, _ = exploration_exit + started = asyncio.Event() + closed = asyncio.Event() + + async def review(*args, **kwargs): + started.set() + try: + await asyncio.Event().wait() + finally: + closed.set() + + async def interrupt(operation, **kwargs): + task = asyncio.create_task(operation) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + return True, None + + ctx.task_control = SimpleNamespace( + run_interruptibly=interrupt, directory=ctx.data_engine.base_dir / "control" + ) + with patch("artemis.graph.graph.run_final_check", new=review): + result = await exit_settlement_node(_make_state(execution_report="调查报告"), ctx) + assert result == {"exit_settlement_route": "continue"} + assert closed.is_set() + assert ctx.final_check_attempts == 0 + assert not (ctx.data_engine.base_dir / "run_outcome.json").exists() + diagnostics = [ + call.kwargs["payload"] + for call in ctx.data_engine.record_trace.call_args_list + if call.kwargs.get("type") == "final_review_diagnostic" + ] + assert diagnostics[-1]["status"] == "cancelled" + + +@pytest.mark.asyncio +async def test_completed_exploration_waits_for_report_before_review(exploration_exit): + ctx, repo, session, run = exploration_exit + state = State.initial("保存并交付完整报告") + with patch("artemis.graph.graph.run_final_check", new=AsyncMock()) as review: + assert convergence_gate(state, ctx) == "continue" + assert await exit_settlement_node(state, ctx) == {"exit_settlement_route": "continue"} + review.assert_not_called() + ctx.on_execution_review.assert_not_called() + assert repo.run(run["id"])["status"] == "running" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("verdict_status", ["passed", "failed", "inconclusive"]) +async def test_report_is_saved_and_fully_delivered_before_independent_review( + exploration_exit, verdict_status +): + from artemis.runtime.case_result import CaseTestResult + + ctx, repo, session, run = exploration_exit + body = "# 本次调查报告\n" + "实际证据及未测范围。" * 2400 + state = State(initial_goal="交付完整报告", execution_report=body) + _write_plan(ctx.data_engine.base_dir, "- [x] 调查并交付报告\n - assert: 实际页面结果\n") + + async def review(*args, **kwargs): + directory = ctx.data_engine.base_dir + assert (directory / "notes" / "output.md").read_text(encoding="utf-8") == body + assert json.loads((directory / "result.json").read_text(encoding="utf-8")) == { + "result": body + } + delivered = [m for m in repo.messages(session["id"]) if m.get("kind") == "execution_report"] + assert len(delivered) == 3 + assert "".join(m["text"] for m in delivered) == body + assert kwargs["execution_report"] == body + assert kwargs["report_delivery"]["exploration_message_sequences"] == [ + m["sequence"] for m in delivered + ] + return CheckReport( + verdicts=[ + CheckVerdict( + item_text="实际页面结果", + kind="assert", + status=verdict_status, + evidence="步骤 1", + ) + ], + test_result=CaseTestResult( + status=verdict_status, reason="按本次实际证据裁定", evidence=["步骤 1 实际页面"] + ), + ) + + with patch("artemis.graph.graph.run_final_check", new=review): + result = await exit_settlement_node(state, ctx) + assert result["run_outcome"]["test_result"]["status"] == verdict_status + assert repo.run(run["id"])["status"] == "finalizing" + + +@pytest.mark.asyncio +async def test_report_delivery_is_idempotent_with_review_disabled(exploration_exit): + ctx, repo, session, run = exploration_exit + ctx.execution_setup.disable_checker = True + state = State(initial_goal="调查", execution_report="完整报告正文") + assert convergence_gate(state, ctx) == "exit_settlement" + with patch("artemis.graph.graph.run_final_check", new=AsyncMock()) as review: + await exit_settlement_node(state, ctx) + await exit_settlement_node(state, ctx) + review.assert_not_called() + delivered = [m for m in repo.messages(session["id"]) if m.get("kind") == "execution_report"] + assert len(delivered) == 1 + assert delivered[0]["text"] == "完整报告正文" + + +@pytest.mark.asyncio +async def test_report_write_failure_cannot_release_as_completed(exploration_exit, monkeypatch): + from pathlib import Path + + ctx, repo, session, run = exploration_exit + state = State(initial_goal="调查", execution_report="完整报告正文") + monkeypatch.setattr(Path, "replace", MagicMock(side_effect=OSError("disk full"))) + with patch("artemis.graph.graph.run_final_check", new=AsyncMock()) as review: + with pytest.raises(OSError, match="disk full"): + await exit_settlement_node(state, ctx) + review.assert_not_called() + assert repo.run(run["id"])["status"] == "running" + assert not list(ctx.data_engine.base_dir.rglob("*.tmp")) + + def _make_ctx(tmp_path, **setup_kwargs): setup_kwargs.setdefault("disable_checker", False) ctx = MagicMock(spec=ArtemisContext) @@ -76,6 +277,32 @@ def _make_state(**overrides): return state +@pytest.mark.asyncio +async def test_pro_final_review_has_its_own_budget_and_reports_exhaustion(tmp_path, monkeypatch): + from artemis.llm import final_review + from artemis.runtime.case_result import CaseTestResult + + _write_plan(tmp_path, DONE_PLAN_NO_CHECKS) + ctx = _make_ctx(tmp_path, checkpoint_timeout=0.001) + + async def slow_review(*args, **kwargs): + await asyncio.sleep(0.02) + return CheckReport( + test_result=CaseTestResult(status="passed", reason="证据齐全", evidence=["步骤 1 截图"]) + ) + + with patch("artemis.graph.graph.run_final_check", new=slow_review): + result = await exit_settlement_node(_make_state(), ctx) + assert result["run_outcome"]["test_result"]["status"] == "passed" + + monkeypatch.setattr(final_review, "MAX_FINAL_REVIEW_SECONDS", 0.001) + with patch("artemis.graph.graph.run_final_check", new=slow_review): + result = await exit_settlement_node(_make_state(), ctx) + assert result["exit_settlement_route"] == "end" + assert result["run_outcome"]["test_result"]["status"] == "inconclusive" + assert "总预算已用尽" in result["run_outcome"]["test_result"]["reason"] + + def _write_plan(tmp_path, content): notes = tmp_path / "notes" notes.mkdir(parents=True, exist_ok=True) @@ -238,7 +465,16 @@ async def test_final_check_receives_ledger_and_at_end_items(tmp_path): captured = {} - async def fake_final(ctx_arg, goal, plan_text, ledger, check_items, attempt_id=None): + async def fake_final( + ctx_arg, + goal, + plan_text, + ledger, + check_items, + attempt_id=None, + execution_report=None, + report_delivery=None, + ): captured["goal"] = goal captured["ledger"] = ledger captured["items"] = list(check_items) diff --git a/tests/unit/mcp/conftest.py b/tests/unit/mcp/conftest.py new file mode 100644 index 0000000..560c11b --- /dev/null +++ b/tests/unit/mcp/conftest.py @@ -0,0 +1,99 @@ +"""Local protocol fixtures; no devices, model calls or user data.""" + +import asyncio +from contextlib import asynccontextmanager +import os +import socket +import tempfile +import threading +import time + +from fastapi import FastAPI, HTTPException, Request +import pytest +import uvicorn + +from artemis.service_registry import ServiceRegistration, TOKEN_HEADER +from mcp_server.shared import LocalMcpBoundary, create_shared_mcp + + +@pytest.fixture +def shared_mcp_backend(tmp_path, monkeypatch): + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setenv("TEMP", str(tmp_path)) + monkeypatch.setenv("TMP", str(tmp_path)) + monkeypatch.setenv("TMPDIR", str(tmp_path)) + monkeypatch.setenv("ARTEMIS_APP_DIR", str(tmp_path / "app")) + monkeypatch.setenv("ARTEMIS_TRACES_DIR", str(tmp_path / "traces")) + monkeypatch.delenv("ARTEMIS_DAEMON_PORT", raising=False) + monkeypatch.delenv("ARTEMIS_DAEMON_HOST", raising=False) + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + registration = ServiceRegistration( + port=port, token="fixture-token", app_dir=tmp_path / "app", traces_dir=tmp_path / "traces" + ) + shared, executor = create_shared_mcp() + calls = [] + + async def record(delay: float): + calls.append(delay) + await asyncio.sleep(delay) + return {"pid": os.getpid(), "thread": threading.get_ident(), "count": len(calls)} + + @shared.tool() + async def shared_probe(delay: float = 0.0) -> dict[str, int]: + return await executor.invoke(record, delay) + + @asynccontextmanager + async def lifespan(app): + registration.acquire() + executor.start() + try: + async with shared.session_manager.run(): + yield + finally: + await executor.stop() + registration.close() + + app = FastAPI(lifespan=lifespan) + app.mount("/api/mcp", LocalMcpBoundary(shared.streamable_http_app(), "fixture-token")) + + @app.get("/api/system/shared-service") + async def identity(request: Request): + if request.headers.get(TOKEN_HEADER) != "fixture-token": + raise HTTPException(403) + return { + key: registration.info[key] for key in ("protocol", "installation", "instance", "pid") + } + + server = uvicorn.Server( + uvicorn.Config( + app, + host="127.0.0.1", + port=port, + log_level="error", + timeout_graceful_shutdown=5, # Match the user-started service's SSE shutdown budget. + ) + ) + thread = threading.Thread(target=server.run, kwargs={"sockets": [listener]}, daemon=True) + thread.start() + deadline = time.monotonic() + 8 + while not server.started and thread.is_alive() and time.monotonic() < deadline: + time.sleep(0.01) + assert server.started + try: + yield { + "port": port, + "registration": registration, + "server": server, + "calls": calls, + "env": { + key: os.environ[key] + for key in ("TEMP", "TMP", "TMPDIR", "ARTEMIS_APP_DIR", "ARTEMIS_TRACES_DIR") + }, + } + finally: + server.should_exit = True + thread.join(timeout=8) + listener.close() + assert not thread.is_alive() diff --git a/tests/unit/mcp/test_action_executor_helper_tools.py b/tests/unit/mcp/test_action_executor_helper_tools.py index adb9a83..bb8cea4 100644 --- a/tests/unit/mcp/test_action_executor_helper_tools.py +++ b/tests/unit/mcp/test_action_executor_helper_tools.py @@ -48,6 +48,46 @@ def test_helper_tools_are_not_device_actions(): assert not (AGENT_TOOL_NAMES & executor.action_tool_names) +@pytest.mark.asyncio +async def test_flash_saves_and_reads_text_with_verified_receipt_without_device_actions(tmp_path): + import hashlib + import json + + executor = _make_executor(agent_name="flash") + executor.ctx.data_engine = Mock(base_dir=tmp_path, current_step_id=None) + state = _state() + result = await executor.execute( + "save_note", {"key": "reusable_case", "content": "# 用例\n检查标题"}, "save", state + ) + assert result.status == "success" + receipt = json.loads(result.text_summary) + data = (tmp_path / receipt["path"]).read_bytes() + assert receipt["verified"] is True and receipt["sha256"] == hashlib.sha256(data).hexdigest() + assert receipt["bytes"] == len(data) + read = await executor.execute("read_note", {"key": "reusable_case"}, "read", state) + assert read.status == "success" and "检查标题" in read.text_summary + assert result.screenshot_bytes is None and result.ui_elements_text is None + executor.actuator.execute.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("key", ["../outside", "C:\\outside", "output", "output.MD"]) +async def test_flash_note_rejects_paths_and_reserved_final_report(tmp_path, key): + executor = _make_executor(agent_name="flash") + executor.ctx.data_engine = Mock(base_dir=tmp_path, current_step_id=None) + result = await executor.execute("save_note", {"key": key, "content": "data"}, "save", _state()) + assert result.status == "error" + assert not (tmp_path / "notes").exists() + + +@pytest.mark.asyncio +async def test_flash_note_does_not_fall_back_to_working_directory_without_trace(): + result = await _make_executor(agent_name="flash").execute( + "save_note", {"key": "report", "content": "data"}, "save", _state() + ) + assert result.status == "error" and "trace" in result.text_summary + + # --------------------------------------------------------------------------- # ask_explorer # --------------------------------------------------------------------------- diff --git a/tests/unit/mcp/test_action_manifest.py b/tests/unit/mcp/test_action_manifest.py index f8eb53a..e1d5cdd 100644 --- a/tests/unit/mcp/test_action_manifest.py +++ b/tests/unit/mcp/test_action_manifest.py @@ -56,7 +56,9 @@ SCOPED_PROMPT_FILES = [ # apply_operator_prompt_contract. Anything new landing here should be a conscious # decision. #: Backticked identifiers in prompts that are parameters or note keys, not tools. -NON_TOOL_IDENTIFIERS = frozenset({"analysis", "task_plan", "tool_enum", "target_description"}) +NON_TOOL_IDENTIFIERS = frozenset( + {"analysis", "task_plan", "tool_enum", "target_description", "report_note_key"} +) _TOOL_REF = re.compile(r"(?:`([a-z][a-z0-9_]{2,})`|\b([a-z][a-z0-9_]{2,})\()") diff --git a/tests/unit/mcp/test_automation_tools.py b/tests/unit/mcp/test_automation_tools.py index 56a75af..aa30a0c 100644 --- a/tests/unit/mcp/test_automation_tools.py +++ b/tests/unit/mcp/test_automation_tools.py @@ -7,6 +7,39 @@ import pytest from mcp_server.tools import automation as tools +@pytest.mark.asyncio +async def test_favorites_share_backend_preserve_request_id_and_refuse_secrets(monkeypatch): + request = AsyncMock(return_value={"id": "favorite"}) + monkeypatch.setattr(tools, "_request", request) + await tools.mobile_favorites("create", source_kind="batch", source_id="source") + request.assert_awaited_once_with( + "POST", + "/api/automation/favorites", + json={"source_kind": "batch", "source_id": "source", "version": None}, + ) + request.reset_mock() + with pytest.raises(ValueError): + await tools.mobile_favorites("run", favorite_id="favorite") + with pytest.raises(ValueError, match="密钥"): + await tools.mobile_favorites( + "update", + favorite_id="favorite", + changes={"case_configs": {"0": {"api_key": "never-send"}}}, + ) + request.assert_not_awaited() + await tools.mobile_favorites( + "run", + favorite_id="favorite", + request_id="stable", + changes={"task_config": {"model": "original"}}, + ) + request.assert_awaited_once_with( + "POST", + "/api/automation/favorites/favorite/run", + json={"task_config": {"model": "original"}, "request_id": "stable"}, + ) + + @pytest.mark.asyncio async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeypatch): request = AsyncMock( diff --git a/tests/unit/mcp/test_exploration_tools.py b/tests/unit/mcp/test_exploration_tools.py new file mode 100644 index 0000000..5bec563 --- /dev/null +++ b/tests/unit/mcp/test_exploration_tools.py @@ -0,0 +1,52 @@ +from unittest.mock import AsyncMock + +import pytest + +from mcp_server.tools import exploration + + +@pytest.mark.asyncio +async def test_queries_never_start_exploration_and_guidance_uses_shared_route(monkeypatch): + request = AsyncMock(return_value={"status": "running"}) + monkeypatch.setattr(exploration, "_request", request) + await exploration.mobile_exploration("get", exploration_id="conversation", after=15) + request.assert_awaited_once_with("GET", "/api/explorations/conversation", params={"after": 15}) + with pytest.raises(ValueError): + await exploration.mobile_exploration( + "get", exploration_id="conversation", payload={"goal": "不该执行"} + ) + await exploration.mobile_exploration( + "message", + exploration_id="conversation", + request_id="guidance", + payload={"text": "先点击设置"}, + ) + assert request.call_args.args == ("POST", "/api/explorations/conversation/messages") + assert request.call_args.kwargs["json"] == {"text": "先点击设置", "request_id": "guidance"} + + +@pytest.mark.asyncio +async def test_demo_requires_stable_request_and_valid_identifiers(monkeypatch): + request = AsyncMock(return_value={}) + monkeypatch.setattr(exploration, "_request", request) + with pytest.raises(ValueError): + await exploration.mobile_exploration("demonstrate", exploration_id="session") + with pytest.raises(ValueError): + await exploration.mobile_exploration( + "demo_finish", + exploration_id="session", + demonstration_id="../foreign", + request_id="finish", + ) + request.assert_not_awaited() + await exploration.mobile_exploration( + "demo_extend", + exploration_id="session", + demonstration_id="demo", + request_id="extend", + payload={"seconds": 900}, + ) + assert request.call_args.args == ( + "POST", + "/api/explorations/session/demonstrations/demo/extend", + ) diff --git a/tests/unit/mcp/test_mcp_stdio_lifecycle.py b/tests/unit/mcp/test_mcp_stdio_lifecycle.py index 7861f7a..1529017 100644 --- a/tests/unit/mcp/test_mcp_stdio_lifecycle.py +++ b/tests/unit/mcp/test_mcp_stdio_lifecycle.py @@ -61,7 +61,7 @@ def _readline_with_timeout(pipe, timeout: float) -> str | None: return line.decode("utf-8").strip() -def test_mcp_stdio_handshake_immediate_input(): +def test_mcp_stdio_handshake_immediate_input(shared_mcp_backend): """Verify that MCP server over stdio responds to an immediate initialize request without hanging, even when stdin data is pushed concurrently with process startup. @@ -98,6 +98,7 @@ def test_mcp_stdio_handshake_immediate_input(): ) if key in os.environ }, + **shared_mcp_backend["env"], }, cwd=project_root, ) @@ -139,6 +140,7 @@ def test_mcp_stdio_handshake_immediate_input(): assert tools_resp_line is not None, "MCP server failed to respond to tools/list request!" tools_data = json.loads(tools_resp_line) assert tools_data.get("id") == 2 + assert "result" in tools_data, tools_data tools = tools_data.get("result", {}).get("tools", []) tool_names = {t["name"] for t in tools} assert { @@ -155,6 +157,7 @@ def test_mcp_stdio_handshake_immediate_input(): p.wait(timeout=3) except subprocess.TimeoutExpired: p.kill() + print(p.stderr.read().decode("utf-8", errors="replace")) def test_awake_service_adb_command_isolates_stdin(): diff --git a/tests/unit/mcp/test_mcp_tools.py b/tests/unit/mcp/test_mcp_tools.py index c3c3085..dd455ae 100644 --- a/tests/unit/mcp/test_mcp_tools.py +++ b/tests/unit/mcp/test_mcp_tools.py @@ -88,6 +88,17 @@ def test_mobile_run_task_invalid_model(): mobile_run_task(task_desc="test", conversation_id="conv-1", model="invalid_model") +def test_auto_plugins_do_not_silently_fall_back_to_a_different_executor(temp_trace_env): + with ( + patch("artemis.plugins.store.PluginStore.selections", return_value=["desktop"]), + patch("mcp_server.tools.task_runner.subprocess.Popen") as popen, + ): + result = mobile_run_task(task_desc="手机测试中操作电脑", model="flash") + assert result["status"] == "failed" + assert "统一任务服务" in result["error"] + popen.assert_not_called() + + def test_mobile_run_task_reserves_and_passes_global_queue_ticket(temp_trace_env): process = MagicMock(pid=43210) with ( diff --git a/tests/unit/mcp/test_shared_backend.py b/tests/unit/mcp/test_shared_backend.py new file mode 100644 index 0000000..9a51ef4 --- /dev/null +++ b/tests/unit/mcp/test_shared_backend.py @@ -0,0 +1,300 @@ +import asyncio +from contextlib import AsyncExitStack +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +from unittest.mock import patch + +import httpx +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client +import pytest + +from artemis import service_registry as registry +from mcp_server.bridge import backend_session, create_bridge, load_catalog +from mcp_server.shared import ToolExecutor, create_shared_mcp + + +@pytest.mark.asyncio +async def test_catalog_and_shared_tools_match_original_schemas(): + from mcp_server import mcp + + shared, _ = create_shared_mcp() + original = [tool.model_dump(exclude_none=True) for tool in await mcp.list_tools()] + + def by_name(tools): + return {tool["name"]: tool for tool in tools} + + assert by_name(load_catalog()["tools"]) == by_name(original) + assert load_catalog()["instructions"] == mcp.instructions + assert by_name( + [tool.model_dump(exclude_none=True) for tool in await shared.list_tools()] + ) == by_name(original) + + +@pytest.mark.asyncio +async def test_stdio_survives_offline_backend_and_recovers_without_restart(shared_mcp_backend): + registration = shared_mcp_backend["registration"] + registration.close() + async with AsyncExitStack() as stack: + streams = await stack.enter_async_context( + stdio_client( + StdioServerParameters( + command=sys.executable, + args=["-m", "mcp_server"], + env={**os.environ, **shared_mcp_backend["env"], "PYTHONUTF8": "1"}, + ) + ) + ) + session = await stack.enter_async_context(ClientSession(*streams)) + initialized = await asyncio.wait_for(session.initialize(), 6) + assert initialized.capabilities.tools is not None + tools = await session.list_tools() + assert "mobile_run_task" in {tool.name for tool in tools.tools} + offline = await session.call_tool("mobile_workspace", {"action": "queue"}) + assert offline.isError + assert "不会自动启动" in offline.content[0].text + assert shared_mcp_backend["calls"] == [] + registration.acquire() + connected = await session.call_tool("shared_probe", {}) + assert connected.structuredContent["pid"] == os.getpid() + assert connected.structuredContent["count"] == 1 + registration.close() + assert (await session.call_tool("shared_probe", {})).isError + registration.acquire() + assert (await session.call_tool("shared_probe", {})).structuredContent["count"] == 2 + + +@pytest.mark.asyncio +async def test_multiple_stdio_processes_share_backend_and_disconnect_independently( + shared_mcp_backend, +): + async def probe(): + async with ( + stdio_client( + StdioServerParameters( + command=sys.executable, + args=["-m", "mcp_server"], + env={**os.environ, **shared_mcp_backend["env"], "PYTHONUTF8": "1"}, + ) + ) as streams, + ClientSession(*streams) as session, + ): + await session.initialize() + return await session.call_tool("shared_probe", {"delay": 0.05}) + + results = await asyncio.gather(*(probe() for _ in range(3))) + assert {result.structuredContent["pid"] for result in results} == {os.getpid()} + assert len({result.structuredContent["thread"] for result in results}) == 1 + assert len(shared_mcp_backend["calls"]) == 3 + assert (await probe()).structuredContent["count"] == 4 + + +def test_bridge_import_does_not_load_runtime(): + result = subprocess.run( + [ + sys.executable, + "-c", + "import mcp_server.bridge,sys; print([n for n in ('artemis.sdk.agent','artemis.runtime','mcp_server.tools','cv2','torch') if n in sys.modules])", + ], + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "[]" + + +def test_missing_backend_never_spawns_or_imports_execution(tmp_path, monkeypatch): + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + with patch("subprocess.Popen") as spawn: + with pytest.raises(registry.ServiceUnavailableError, match="不会自动启动"): + registry.discover_service() + spawn.assert_not_called() + + +def test_registration_excludes_second_server_and_releases(tmp_path, monkeypatch): + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + args = dict(port=8100, token="test", app_dir=tmp_path, traces_dir=tmp_path / "traces") + first = registry.ServiceRegistration(**args) + second = registry.ServiceRegistration(**args) + first.acquire() + try: + with pytest.raises(registry.ServiceUnavailableError, match="已有主程序"): + second.acquire() + finally: + first.close() + second.acquire() + second.close() + + +def test_registration_excludes_another_process(tmp_path, monkeypatch): + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + registration = registry.ServiceRegistration( + port=8100, token="test", app_dir=tmp_path, traces_dir=tmp_path + ) + registration.acquire() + try: + code = "from artemis.service_registry import ServiceRegistration; import pathlib,sys; ServiceRegistration(port=8101,token='test',app_dir=pathlib.Path(sys.argv[1]),traces_dir=pathlib.Path(sys.argv[1])).acquire()" + result = subprocess.run( + [sys.executable, "-c", code, str(tmp_path)], + env={ + **os.environ, + "TEMP": str(tmp_path), + "TMP": str(tmp_path), + "TMPDIR": str(tmp_path), + "PYTHONUTF8": "1", + }, + capture_output=True, + text=True, + encoding="utf-8", + timeout=10, + ) + assert result.returncode != 0 + assert "已有主程序" in result.stderr + finally: + registration.close() + + +def test_discovery_nondefault_port_and_scope(shared_mcp_backend, monkeypatch, tmp_path): + info = registry.discover_service() + assert info["port"] == shared_mcp_backend["port"] + assert info["pid"] == os.getpid() + monkeypatch.setenv("ARTEMIS_TRACES_DIR", str(tmp_path / "other")) + with pytest.raises(registry.ServiceUnavailableError): + registry.discover_service() + + +def test_explicit_port_is_not_silently_ignored(shared_mcp_backend, monkeypatch): + monkeypatch.setenv("ARTEMIS_DAEMON_PORT", "1") + with pytest.raises(registry.ServiceUnavailableError): + registry.discover_service() + + +def test_mcp_endpoint_rejects_unauthenticated_call(shared_mcp_backend): + response = httpx.post( + f"http://127.0.0.1:{shared_mcp_backend['port']}/api/mcp/", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + trust_env=False, + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_connections_share_executor_and_preserve_structured_results(shared_mcp_backend): + async def call(): + async with backend_session() as (session, _): + return await session.call_tool("shared_probe", {"delay": 0.01}) + + results = await asyncio.gather(*(call() for _ in range(5))) + assert all(not result.isError for result in results) + assert all(result.structuredContent is not None for result in results), results + assert {result.structuredContent["pid"] for result in results} == {os.getpid()} + assert len({result.structuredContent["thread"] for result in results}) == 1 + assert len(shared_mcp_backend["calls"]) == 5 + # Closing five bridge sessions must not stop the common backend. + assert registry.discover_service()["pid"] == os.getpid() + + +@pytest.mark.asyncio +async def test_bridge_preserves_tool_errors_and_never_replays(shared_mcp_backend): + bridge = create_bridge() + handler = bridge.request_handlers[types.CallToolRequest] + result = await handler( + types.CallToolRequest( + method="tools/call", params=types.CallToolRequestParams(name="missing", arguments={}) + ) + ) + assert result.root.isError + assert shared_mcp_backend["calls"] == [] + result = await handler( + types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams(name="shared_probe", arguments={}), + ) + ) + assert result.root.structuredContent["count"] == 1 + + +@pytest.mark.asyncio +async def test_executor_cancellation_reaches_coroutine(): + executor = ToolExecutor() + executor.start() + import threading + + started, stopped = threading.Event(), threading.Event() + + async def operation(): + started.set() + try: + await asyncio.sleep(60) + finally: + stopped.set() + + try: + task = asyncio.create_task(executor.invoke(operation)) + assert await asyncio.to_thread(started.wait, 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert await asyncio.to_thread(stopped.wait, 2) + finally: + await executor.stop() + + +@pytest.mark.asyncio +async def test_sync_tool_runs_once_off_both_event_loops(): + import threading + + executor = ToolExecutor() + executor.start() + caller = threading.get_ident() + calls = [] + + def operation(value): + calls.append(threading.get_ident()) + return {"accepted": value} + + try: + assert await executor.invoke(operation, "job") == {"accepted": "job"} + assert len(calls) == 1 + assert calls[0] not in {caller, executor.thread.ident} + finally: + await executor.stop() + + +@pytest.mark.asyncio +async def test_sync_tool_error_does_not_repeat_the_side_effect(): + executor = ToolExecutor() + executor.start() + calls = [] + + def operation(): + calls.append("submitted") + raise ValueError("reply lost") + + try: + with pytest.raises(ValueError, match="reply lost"): + await executor.invoke(operation) + assert calls == ["submitted"] + finally: + await executor.stop() + + +def test_stale_pid_and_wrong_installation_are_not_reused(tmp_path, monkeypatch): + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + directory = registry.registry_directory() + directory.mkdir(parents=True) + (directory / "stale.json").write_text( + json.dumps( + {"installation": registry.installation_id(), "pid": -1, "port": 8100, "token": "x"} + ) + ) + (directory / "other.json").write_text( + json.dumps({"installation": "another", "pid": os.getpid(), "port": 8100, "token": "x"}) + ) + with pytest.raises(registry.ServiceUnavailableError): + registry.discover_service() diff --git a/tests/unit/mcp/test_task_request_receipts.py b/tests/unit/mcp/test_task_request_receipts.py new file mode 100644 index 0000000..84c653b --- /dev/null +++ b/tests/unit/mcp/test_task_request_receipts.py @@ -0,0 +1,55 @@ +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import Mock + +import pytest + +from apps.admin_console.services.automation_store import AutomationStore +from mcp_server import task_requests +from mcp_server.tools import task_runner + + +@pytest.fixture +def receipts(tmp_path, monkeypatch): + store = AutomationStore(tmp_path / "automation.db") + monkeypatch.setattr(task_requests, "store", store) + return store + + +def test_concurrent_retries_dispatch_once(receipts): + execute = Mock(side_effect=lambda sid: {"trace_id": sid, "status": "queued"}) + with ThreadPoolExecutor(max_workers=4) as pool: + results = list( + pool.map( + lambda _: task_requests.run_once("one", {"goal": "observe"}, execute), range(8) + ) + ) + assert execute.call_count == 1 + assert len({result["trace_id"] for result in results}) == 1 + assert task_requests.lookup("one")["status"] == "queued" + receipts.save_job({"session_id": results[0]["trace_id"], "status": "completed", "position": 1}) + assert task_requests.lookup("one")["status"] == "completed" + assert task_requests.lookup("one")["status_scope"] == "task_queue" + + +def test_conflicting_content_and_lost_reply_never_repeat(receipts): + execute = Mock(side_effect=ConnectionError("after admission")) + with pytest.raises(ConnectionError): + task_requests.run_once("one", {"goal": "observe"}, execute) + receipt = task_requests.run_once("one", {"goal": "observe"}, execute) + assert receipt["submission_state"] == "unknown" + assert receipt["trace_id"] + assert execute.call_count == 1 + with pytest.raises(ValueError, match="不同任务内容"): + task_requests.run_once("one", {"goal": "write"}, execute) + assert task_requests.lookup("missing")["status"] == "not_found" + + +def test_public_task_entry_uses_receipts_before_dispatch(receipts, monkeypatch): + execute = Mock( + side_effect=lambda **kwargs: {"trace_id": kwargs["trace_id"], "status": "queued"} + ) + monkeypatch.setattr(task_runner, "_run_task", execute) + first = task_runner.mobile_run_task("observe", request_id="public-one") + second = task_runner.mobile_run_task("observe", request_id="public-one") + assert first["trace_id"] == second["trace_id"] + execute.assert_called_once() diff --git a/tests/unit/memory/test_history_chunking.py b/tests/unit/memory/test_history_chunking.py index 0ba7175..418ec95 100644 --- a/tests/unit/memory/test_history_chunking.py +++ b/tests/unit/memory/test_history_chunking.py @@ -72,6 +72,49 @@ def _step(i: int, subgoal: str = "hash-a", **overrides) -> dict: return step +@pytest.mark.parametrize("minimal", [False, True]) +def test_runtime_file_paths_survive_even_minimal_history_folding(minimal): + from artemis.memory.chunking import build_action_ledger + + action = { + "action": "plug_pc_save", + "plugin_id": "cua-driver", + "attachments": [ + {"path": "artifacts/plugins/cua-driver/footer.png", "sha256": "a" * 64, "bytes": 123} + ], + } + text = build_action_ledger([_step(35, action_taken=action)], SESSION_START, minimal=minimal) + assert "Step 35" in text and "footer.png" in text and "a" * 64 in text + + +def test_default_larger_batches_reduce_capsule_calls_without_losing_step_index(): + from artemis.config import MemoryChunkingConfig + + current = MemoryChunkingConfig() + steps = [_step(i) for i in range(1, 48)] + counts = [] + for minimum, target in [(3, 2000), (current.min_steps, current.target_source_tokens)]: + ledger, chunker, _, capsule = _make( + steps, + min_active=5, + min_steps=minimum, + target_tokens=target, + auto_capsule=True, + meter=lambda: 75_000, + ) + _run_turns(ledger, chunker, 1, 47, lambda _: "same-stage") + counts.append(len(capsule.submitted)) + consumed = {sid for c in chunker.chunks for sid in c.source_step_ids} + consumed.update( + sid for era in chunker.eras for c in era.chunks for sid in c.source_step_ids + ) + remaining = {sid for turn in ledger.unchunked_turns() for sid in turn.get("step_keys", [])} + assert consumed | remaining # remaining live/frozen records are not dropped + for number in range(1, 42): + assert f"s{number}" in consumed | remaining + assert counts[1] < counts[0] + + class FakeEngine: def __init__(self, steps: list[dict], base_dir=None): self.steps = steps @@ -192,7 +235,17 @@ def _make( capsule_service=capsule, # No meter: the base comes from the ledger's operator-recorded prompt # size (None until recorded), exactly like production wiring. - meter_getter=meter, + meter_getter=meter + or ( + lambda: ( + 75_000 + if chunker.awaiting_chunks + and all( + capsule.get_summary(c.capsule_key) is not None for c in chunker.awaiting_chunks + ) + else 40_000 + ) + ), goal="TEST GOAL", ) ledger.attach_chunker(chunker) @@ -443,7 +496,8 @@ def test_segment_size_threshold_triggers_without_milestone(): assert len(chunker.chunks) >= 1 assert chunker.chunks[0].start_step_number == 1 total = sum(c.end_step_number - c.start_step_number + 1 for c in chunker.chunks) - assert total >= 4 + assert total >= 3 + assert chunker.awaiting_chunks # the next prepared batch has not swapped yet assert len(ledger.unchunked_turns()) >= 2 # floor respected @@ -458,7 +512,7 @@ def test_source_token_threshold_triggers_chunk(): def test_soft_threshold_compresses_oldest_open_segment(): steps = [_step(i) for i in range(1, 9)] - meter = {"value": None} + meter = {"value": 40_000} ledger, chunker, _, _ = _make( steps, min_active=2, max_steps=100, meter=lambda: meter["value"], auto_capsule=True ) @@ -843,7 +897,7 @@ def test_hard_threshold_folds_frozen_region_into_recall_only_era_with_step_index merged) plus the per-step minimal index of every folded chunk — step, session offset and action phrase, results only via search_history.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - meter = {"value": None} + meter = {"value": 40_000} ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) _run_turns(ledger, chunker, 1, 8, _hashes(4)) capsule.resolve("chunk:1-4", _capsule(1, 4)) @@ -1083,7 +1137,7 @@ def test_failed_capsule_redispatches_and_retains_original(): """Failure rung of the ladder: an exhausted capsule job is re-dispatched at the next trigger/pressure render while the original turns stay live.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - meter = {"value": None} + meter = {"value": 40_000} ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) _run_turns(ledger, chunker, 1, 8, _hashes(4)) key = chunker.awaiting_chunks[0].capsule_key @@ -1106,7 +1160,7 @@ def test_hard_threshold_force_swaps_pending_chunks_into_recall_only_era(): paragraph degrades to the milestone/step-range list, and ③'s minimal index survives for every folded step.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - meter = {"value": None} + meter = {"value": 40_000} ledger, chunker, _, _ = _make(steps, min_active=2, meter=lambda: meter["value"]) _run_turns(ledger, chunker, 1, 8, _hashes(4)) assert chunker.awaiting_chunks[0].status == "pending" @@ -1150,7 +1204,9 @@ def test_pressure_close_waits_for_min_steps_instead_of_one_turn_chunks(): _run_turns(ledger, chunker, 7, 8) # eligible beyond the claim: 1, then 2 assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-3"] _run_turns(ledger, chunker, 9, 9) - assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-3", "chunk:4-6"] + assert [c.capsule_key for c in chunker.awaiting_chunks] == [ + "chunk:1-3" + ] # finish this batch first def test_size_trigger_waits_for_min_steps_even_when_one_turn_exceeds_the_tokens(): @@ -1193,7 +1249,7 @@ def test_start_gate_holds_ready_chunk_below_start_ratio_then_swaps(): replaced only once the operator's measured context reaches budget*start_ratio; until then the ready chunk waits in the queue.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 11)] - meter = {"value": 20_000} # < 0.35 * 100k + meter = {"value": 40_000} # < 0.35 * 100k ledger, chunker, engine, _ = _make( steps, min_active=2, meter=lambda: meter["value"], auto_capsule=True ) @@ -1212,13 +1268,13 @@ def test_start_gate_holds_ready_chunk_below_start_ratio_then_swaps(): args = held[0]["payload"]["args"] assert args["note"] == "held" assert (args["context_tokens"], args["context_budget"], args["swap_at_tokens"]) == ( - 20_000, + 40_000, 100_000, - 35_000, + 70_000, ) # Same render cadence, context now past the gate → the ready prefix swaps. - meter["value"] = 40_000 + meter["value"] = 75_000 _run_turns(ledger, chunker, 9, 9, _hashes(4)) assert len(chunker.chunks) == 1 assert chunker.chunks[0].start_step_number == 1 @@ -1226,9 +1282,7 @@ def test_start_gate_holds_ready_chunk_below_start_ratio_then_swaps(): assert ledger.frozen_blocks != () done = engine.traces[-1] assert done["status"] == "success" - assert done["payload"]["args"]["context_tokens"] == 40_000 - ( - chunker.chunks[0].source_chars // 4 - ) + (chunker.chunks[0].summary_chars // 4) + assert done["payload"]["args"]["context_tokens"] < 75_000 def test_leading_stampless_turn_joins_the_next_segment_instead_of_pinning_the_queue(): @@ -1238,13 +1292,14 @@ def test_leading_stampless_turn_joins_the_next_segment_instead_of_pinning_the_qu the head of the awaiting queue, so no L2 swap ever happened and the hard line jumped straight to the fold. It joins the following segment.""" steps = [_step(i) for i in range(1, 13)] - meter = {"value": 50_000} # above the start gate + meter = {"value": 40_000} # above the start gate ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger.stage_turn([_observation(0), AIMessage(content="no tool call this turn")]) _run_turns(ledger, chunker, 1, 7, _hashes(4)) # commits turn 0 without a step key assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-4"] assert all(entry["chunk"] is not None for entry in chunker._awaiting) + meter["value"] = 75_000 capsule.resolve("chunk:1-4", _capsule(1, 4)) _run_turns(ledger, chunker, 8, 8, _hashes(4)) @@ -1278,24 +1333,79 @@ def test_partition_folds_leading_unstamped_turns_into_the_first_segment(): ] -def test_start_gate_zero_restores_swap_on_ready(): +def test_start_gate_zero_only_prepares_and_does_not_rewrite_prefix(): steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] ledger, chunker, _, _ = _make( steps, min_active=2, meter=lambda: 1_000, auto_capsule=True, start_ratio=0.0 ) _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert len(chunker.chunks) == 1 - assert chunker.awaiting_chunks == () + assert chunker.chunks == () + assert chunker.awaiting_chunks[0].status == "ready" -def test_start_gate_unknown_base_falls_through_to_swap_on_ready(): - """No measured operator call (meter None, nothing recorded on the ledger): - the gate cannot decide and the legacy swap-on-ready behavior stands.""" +def test_unknown_usage_uses_current_prompt_estimate_and_preserves_small_prefix(): steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] ledger, chunker, _, _ = _make(steps, min_active=2, auto_capsule=True) - assert ledger.last_prompt_tokens is None + chunker._meter_getter = None _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert len(chunker.chunks) == 1 + assert ledger.last_prompt_tokens is None + assert ledger.projected_prompt_tokens > 0 + assert chunker.chunks == () + assert ledger.context_phase == 0 + + +def test_phase_batches_async_summaries_then_preserves_the_new_prefix(): + steps = [_step(i) for i in range(1, 20)] + meter = {"value": 10_000} + ledger, chunker, _, capsule = _make(steps, meter=lambda: meter["value"]) + ledger.set_static_prefix([HumanMessage(content="Stable rules")]) + _run_turns(ledger, chunker, 1, 10, lambda i: "a" if i <= 4 else "b" if i <= 7 else "c") + assert capsule.submitted == [] # no background LLM work below preparation gate + tail = [_observation(11)] + original = ledger.render(tail) + fingerprint = [message.model_dump() for message in original] + meter["value"] = 40_000 + ledger.render(tail) + assert len(chunker.awaiting_chunks) == 2 + first, second = chunker.awaiting_chunks + capsule.resolve(first.capsule_key, _capsule(first.start_step_number, first.end_step_number)) + meter["value"] = 75_000 + assert [m.model_dump() for m in ledger.render(tail)] == fingerprint + assert ledger.context_phase == 0 # partial completion must not break the prefix + capsule.resolve(second.capsule_key, _capsule(second.start_step_number, second.end_step_number)) + compacted = ledger.render(tail) + assert ledger.context_phase == 1 + assert ledger.last_compaction["after_tokens"] < ledger.last_compaction["before_tokens"] + assert [m.model_dump() for m in original] == fingerprint # prior input objects never mutated + assert len(chunker.chunks) == 2 and not chunker.awaiting_chunks + + meter["value"] = 10_000 # provider confirms lower occupancy after compaction + # Commit the previous turn, then append a newly observed turn. + ledger.commit_staged(step_key="s10") + next_input = ledger.render(tail) + snapshot = [m.model_dump() for m in next_input] + turn = _turn(11) + turn[0] = tail[0] + ledger.stage_turn(turn) + ledger.commit_staged(step_key="s11") + following = ledger.render([_observation(12)]) + assert [m.model_dump() for m in following[: len(snapshot)]] == snapshot + assert ledger.context_phase == 1 + assert compacted[0].content == "Stable rules" + + +def test_new_large_tail_triggers_pressure_without_waiting_for_next_usage(): + steps = [_step(i) for i in range(1, 12)] + ledger, chunker, _, _ = _make(steps, auto_capsule=True) + chunker._meter_getter = None + previous = _run_turns(ledger, chunker, 1, 9, _hashes(4)) + ledger.record_prompt_tokens(10_000, previous) + assert ledger.context_phase == 0 + # New evidence already exceeds the hard line, despite a small last call. + ledger.render([HumanMessage(content="新证据" * 40_000)]) + assert ledger.projected_prompt_tokens > 90_000 + assert ledger.context_phase == 1 + assert ledger.frozen_blocks def _big_turn(i: int, chars: int = 4000) -> list: @@ -1323,7 +1433,7 @@ def test_hard_threshold_swaps_ready_prefix_as_l2_when_that_is_enough(): estimated context back under the hard line, the swap is an ordinary L2 event — no fold, and the younger pending segment stays awaiting.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - meter = {"value": None} + meter = {"value": 40_000} ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) # Steps 1–4 (hash-a) close at the switch; steps 5–8 (hash-b) close at the # next switch. Turns are ~1k tokens each so the L2 swap is worth ~4k. @@ -1352,7 +1462,7 @@ def test_hard_threshold_escalates_to_fold_when_l2_swap_is_not_enough(): and the whole frozen region folds into one recall-only era whose index lists every folded step — the pending chunk's steps too.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - meter = {"value": None} + meter = {"value": 40_000} ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) _run_turns(ledger, chunker, 1, 10, lambda i: "a" if i <= 4 else ("b" if i <= 8 else "c")) capsule.resolve("chunk:1-4", _capsule(1, 4)) @@ -1394,9 +1504,10 @@ def test_hard_fold_is_monotonic_later_l2_swaps_append_after_the_folded_era(): flips back to full chunk blocks.""" steps = [_step(i) for i in range(1, 17)] hash_for = lambda i: "a" if i <= 4 else ("b" if i <= 8 else ("c" if i <= 12 else "d")) # noqa: E731 - meter = {"value": None} + meter = {"value": 40_000} ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) _run_turns(ledger, chunker, 1, 10, hash_for) + meter["value"] = 75_000 capsule.resolve("chunk:1-4", _capsule(1, 4)) meter["value"] = 99_000 _run_turns(ledger, chunker, 11, 11, hash_for) # hard: folds 1–4 and 5–8 @@ -1405,10 +1516,11 @@ def test_hard_fold_is_monotonic_later_l2_swaps_append_after_the_folded_era(): # Pressure gone (above the start gate, below soft): the next milestone # switch closes 9–12; once ready it swaps as an ordinary L2 chunk. - meter["value"] = 50_000 + meter["value"] = 40_000 _run_turns(ledger, chunker, 12, 15, hash_for) pending = [c for c in chunker.awaiting_chunks if c.status == "pending"] assert [c.capsule_key for c in pending] == ["chunk:9-12"] + meter["value"] = 75_000 capsule.resolve("chunk:9-12", _capsule(9, 12)) _run_turns(ledger, chunker, 16, 16, hash_for) @@ -1430,11 +1542,13 @@ def test_hard_threshold_with_nothing_awaiting_folds_frozen_region_once(): into a recall-only era without consuming turns, and repeated hard renders leave the frozen blocks byte-identical.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - meter = {"value": 50_000} + meter = {"value": 40_000} ledger, chunker, _, _ = _make( steps, min_active=2, meter=lambda: meter["value"], auto_capsule=True ) - _run_turns(ledger, chunker, 1, 7, _hashes(4)) + _run_turns(ledger, chunker, 1, 6, _hashes(4)) + meter["value"] = 75_000 + _run_turns(ledger, chunker, 7, 7, _hashes(4)) # Chunk 1–4 swapped as L2 at the step-7 render; s5..s6 remain = the floor. assert [c.capsule_key for c in chunker.chunks] == ["chunk:1-4"] assert chunker.awaiting_chunks == () @@ -1479,14 +1593,15 @@ def test_context_base_reads_the_ledger_operator_measurement_not_the_session_mete # Some other agent's small call lands on the session meter. get_meter("session-1").record({"prompt_tokens": 2_700, "completion_tokens": 10}) # The operator's own measured prompt is well below the gate. - ledger.record_prompt_tokens(20_000) - assert chunker._context_base_tokens(ledger) == 20_000 + chunker._meter_getter = None + ledger.record_prompt_tokens(40_000) + assert chunker._context_base_tokens(ledger) == 40_000 _run_turns(ledger, chunker, 1, 8, _hashes(4)) assert chunker.chunks == () # held: 20k < 35k assert [c.status for c in chunker.awaiting_chunks] == ["ready"] # Operator prompt grows past the gate → swap at the next render. - ledger.record_prompt_tokens(36_000) + ledger.record_prompt_tokens(75_000) _run_turns(ledger, chunker, 9, 9, _hashes(4)) assert len(chunker.chunks) == 1 @@ -1507,7 +1622,7 @@ def test_held_note_is_fresh_after_a_retry(): then readied while the start gate still holds it gets a fresh ``held`` note: the re-dispatch resets the once-per-chunk held marker.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 14)] - meter = {"value": 20_000} # < 0.35 * 100k: the gate holds ready chunks + meter = {"value": 40_000} # < 0.35 * 100k: the gate holds ready chunks ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) _run_turns(ledger, chunker, 1, 8, _hashes(4)) entry = chunker._awaiting[0] @@ -1797,7 +1912,7 @@ def test_calibrated_ratio_scales_the_size_trigger(): # Default ratio: the eligible text (two ~4k-char turns once turn 5 is # staged) is worth ~chars/4 tokens, under the 5k target → nothing closes. ledger, chunker, _, _ = _make( - steps, min_active=2, min_steps=2, max_steps=100, target_tokens=5000 + steps, min_active=2, min_steps=2, max_steps=100, target_tokens=5000, meter=lambda: 40_000 ) ledger.record_prompt_tokens(1000, messages=[HumanMessage(content="a" * 4000)]) _run_big_turns(ledger, chunker, 1, 5, lambda i: "hash-a") @@ -1807,7 +1922,7 @@ def test_calibrated_ratio_scales_the_size_trigger(): # Calibrated to 1 char/token: the same text is worth ~chars tokens → closes. ledger, chunker, _, _ = _make( - steps, min_active=2, min_steps=2, max_steps=100, target_tokens=5000 + steps, min_active=2, min_steps=2, max_steps=100, target_tokens=5000, meter=lambda: 40_000 ) ledger.record_prompt_tokens(4000, messages=[HumanMessage(content="中" * 4000)]) assert ledger.chars_per_token == pytest.approx(1.0) @@ -1840,6 +1955,7 @@ def test_compression_announces_one_tool_line_from_dispatch_to_swap(): assert args["source_tokens"] > 0 assert "context_tokens" not in args + meter["value"] = 75_000 capsule.resolve("chunk:1-4", _capsule(1, 4)) ledger.commit_staged(step_key="s8", validator_result={"status": "dispatched"}) ledger.render([_observation(9)]) @@ -1853,16 +1969,14 @@ def test_compression_announces_one_tool_line_from_dispatch_to_swap(): assert done_args["summary_tokens"] > 0 assert done_args["context_budget"] == 100_000 assert done_args["context_estimated"] is True - assert done_args["context_tokens"] == ( - 60_000 - done_args["source_tokens"] + done_args["summary_tokens"] - ) + assert 0 < done_args["context_tokens"] < 75_000 assert "forced" not in done_args def test_forced_swap_announces_fold_without_a_ratio(): """Forced folds report estimated context usage without a summary size.""" steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - meter = {"value": None} + meter = {"value": 40_000} ledger, chunker, engine, _ = _make(steps, min_active=2, meter=lambda: meter["value"]) _run_turns(ledger, chunker, 1, 8, _hashes(4)) assert any(t["status"] == "running" for t in engine.traces) @@ -1896,7 +2010,7 @@ def test_compression_trace_carries_a_plain_language_phase_on_every_path(): assert COMPRESSION_PHASES == ("summarizing", "ready", "applied", "failed") steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 15)] - meter = {"value": 20_000} # < 0.35 * 100k: the start gate holds ready chunks + meter = {"value": 40_000} # < 0.35 * 100k: the start gate holds ready chunks ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) _run_turns(ledger, chunker, 1, 8, _hashes(4)) chunk = chunker.awaiting_chunks[0] @@ -1933,9 +2047,14 @@ def test_compression_trace_carries_a_plain_language_phase_on_every_path(): assert held["payload"]["args"]["note"] == "held" # Context past the gate → the swap applies the summary. - meter["value"] = 40_000 + for pending in chunker.awaiting_chunks: + capsule.resolve( + pending.capsule_key, _capsule(pending.start_step_number, pending.end_step_number) + ) + meter["value"] = 75_000 _run_turns(ledger, chunker, 13, 13, lambda i: "hash-c") - assert [c.start_step_number for c in chunker.chunks] == [1] + assert chunker.chunks[0].start_step_number == 1 + assert not chunker.awaiting_chunks assert mine()[-1] == ("success", "applied") # Every announcement on every trace carries a known phase. diff --git a/tests/unit/memory/test_transcript_ledger.py b/tests/unit/memory/test_transcript_ledger.py index db48ded..dcec926 100644 --- a/tests/unit/memory/test_transcript_ledger.py +++ b/tests/unit/memory/test_transcript_ledger.py @@ -71,9 +71,19 @@ def _turn(i: int, tool_call_count: int = 1) -> list: return messages -def _play_turn(ledger: TranscriptLedger, i: int, *, prev_key=None, prev_result=None, **turn_kwargs): +def _play_turn( + ledger: TranscriptLedger, + i: int, + *, + prev_key=None, + prev_result=None, + compact=False, + **turn_kwargs, +): """Mimic the operator's real ordering: commit previous, render, stage.""" ledger.commit_staged(step_key=prev_key, validator_result=prev_result) + if compact: + ledger.compact_active() turn = _turn(i, **turn_kwargs) rendered = ledger.render([turn[0]]) ledger.stage_turn(turn) @@ -159,8 +169,8 @@ def test_recitation_and_ui_list_stripped_at_the_text_edge_and_screenshot_at_k(): until depth K and is then resolved in place; only the live tail ever carries an indexed element list.""" ledger = TranscriptLedger(step_memory=_service(), image_scrub_depth=3) - _play_turn(ledger, 1) - rendered = _play_turn(ledger, 2, prev_key="step-1") + _play_turn(ledger, 1, compact=True) + rendered = _play_turn(ledger, 2, compact=True, prev_key="step-1") obs1 = ledger.active_messages[0] # Turn 1 is now depth 2: text edge passed, screenshot kept. assert PLAN_RECITATION_MARKER not in str(obs1.content) @@ -168,10 +178,10 @@ def test_recitation_and_ui_list_stripped_at_the_text_edge_and_screenshot_at_k(): assert _has_image(obs1) original_obs1 = _fingerprint(obs1) - rendered = _play_turn(ledger, 3, prev_key="step-2") + rendered = _play_turn(ledger, 3, compact=True, prev_key="step-2") # Turn 1 reached depth 3: the screenshot is resolved. - assert not _has_image(obs1) - assert _fingerprint(obs1) != original_obs1 + assert not _has_image(ledger.active_messages[0]) + assert _fingerprint(ledger.active_messages[0]) != original_obs1 # Turn 2 (depth 2): text edge passed, screenshot still there; the live tail keeps both. obs2 = next(m for m in ledger.active_messages if "OBSERVATION [T+00:02]" in str(m.content)) assert PLAN_RECITATION_MARKER not in str(obs2.content) @@ -189,7 +199,7 @@ def test_depth_k_image_resolved_to_ready_visual_summary(): ledger = TranscriptLedger(step_memory=service, image_scrub_depth=3) for i in range(1, 5): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) + _play_turn(ledger, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None) first_obs = ledger.active_messages[0] assert not _has_image(first_obs) @@ -212,7 +222,7 @@ def test_pending_grace_then_placeholder_never_backfilled(): ledger = TranscriptLedger(step_memory=service, image_scrub_depth=2, pending_grace_steps=1) # Enough turns to push turn 1 past K + grace. for i in range(1, 7): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) + _play_turn(ledger, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None) first_obs = ledger.active_messages[0] assert not _has_image(first_obs) @@ -221,7 +231,7 @@ def test_pending_grace_then_placeholder_never_backfilled(): # A late summary must never mutate the frozen message. service._summaries["step-1"] = "Too late." - _play_turn(ledger, 7, prev_key="step-6") + _play_turn(ledger, 7, compact=True, prev_key="step-6") assert _fingerprint(ledger.active_messages[0]) == frozen @@ -232,7 +242,7 @@ def test_failed_summary_becomes_unavailable_placeholder(): ledger = TranscriptLedger(step_memory=service, image_scrub_depth=2, pending_grace_steps=5) for i in range(1, 4): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) + _play_turn(ledger, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None) first_obs = ledger.active_messages[0] assert not _has_image(first_obs) @@ -473,7 +483,7 @@ def test_relaxed_depth_below_start_gate_keeps_more_screenshots(): assert ledger.occupancy is None assert ledger.effective_image_scrub_depth == 6 for i in range(1, 10): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) + _play_turn(ledger, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None) # At the render of turn 9 its observation is the live tail (depth 1); # committed turns 8..5 are depths 2..5 and turn 4 (depth 6) is scrubbed. ledger.record_prompt_tokens(20_000) # 20% < 35% @@ -482,34 +492,34 @@ def test_relaxed_depth_below_start_gate_keeps_more_screenshots(): tight = TranscriptLedger(step_memory=_service(), image_scrub_depth=3) for i in range(1, 10): - _play_turn(tight, i, prev_key=f"step-{i - 1}" if i > 1 else None) + _play_turn(tight, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None) assert _image_turns(tight) == [8] def test_crossing_the_start_gate_tightens_once_and_relaxing_never_backfills(): ledger = _banded_ledger() for i in range(1, 10): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) + _play_turn(ledger, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None) assert _image_turns(ledger) == [5, 6, 7, 8] # Past the gate: the next render scrubs the extra depths in one pass # (turns 5–8 were depths 3–6 at that render; only turn 9 at depth 2 stays). ledger.record_prompt_tokens(40_000) # 40% >= 35% assert ledger.effective_image_scrub_depth == 3 - _play_turn(ledger, 10, prev_key="step-9") + _play_turn(ledger, 10, compact=True, prev_key="step-9") assert _image_turns(ledger) == [9] # Back under the gate: nothing is restored; new turns accumulate again # until four historical screenshots are in view. ledger.record_prompt_tokens(20_000) assert ledger.effective_image_scrub_depth == 6 - _play_turn(ledger, 11, prev_key="step-10") + _play_turn(ledger, 11, compact=True, prev_key="step-10") assert _image_turns(ledger) == [9, 10] - _play_turn(ledger, 12, prev_key="step-11") + _play_turn(ledger, 12, compact=True, prev_key="step-11") assert _image_turns(ledger) == [9, 10, 11] - _play_turn(ledger, 13, prev_key="step-12") + _play_turn(ledger, 13, compact=True, prev_key="step-12") assert _image_turns(ledger) == [9, 10, 11, 12] - _play_turn(ledger, 14, prev_key="step-13") # turn 9 reaches depth 6 + _play_turn(ledger, 14, compact=True, prev_key="step-13") # turn 9 reaches depth 6 assert _image_turns(ledger) == [10, 11, 12, 13] @@ -523,10 +533,8 @@ def test_no_band_configuration_keeps_the_fixed_depth(): assert relaxed_only.effective_image_scrub_depth == 3 # no gate → never relaxed -def test_scrub_edge_runs_before_the_chunker_at_render(): - """The chunker sees the active window after the scrub edge advanced, so a - chunk closing over turns older than the floor never captures a live - screenshot in its source transcript.""" +def test_chunker_sees_immutable_raw_history_before_a_phase_boundary(): + """Preparing capsules must not scrub any previously sent observation.""" ledger = _banded_ledger() seen: list[list[int]] = [] @@ -537,9 +545,9 @@ def test_scrub_edge_runs_before_the_chunker_at_render(): ledger.attach_chunker(Probe()) for i in range(1, 9): _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) - # At the render of turn 8 the scrub edge (depth 6) had already resolved - # turn 3 (depth 6); the probe saw turns 4–7 with images, never turn 3. - assert seen[-1] == [4, 5, 6, 7] + # A render below pressure preserves every historical image. + assert seen[-1] == list(range(1, 8)) + assert ledger.context_phase == 0 # --------------------------------------------------------------------------- @@ -580,6 +588,7 @@ def test_ephemeral_blocks_vanish_at_the_text_edge_and_not_before(): ledger.stage_turn(turn_with_notice(1)) for i in range(2, 4): ledger.commit_staged(step_key=f"step-{i - 1}") + ledger.compact_active() ledger.render([_observation(i)]) ledger.stage_turn(turn_with_notice(i)) @@ -600,15 +609,12 @@ def test_ephemeral_blocks_vanish_at_the_text_edge_and_not_before(): def test_rendering_past_the_edge_is_byte_identical_and_prefix_stable(): - """(iii) A render with nothing new is byte-identical to the previous one, - and across turns exactly one message — the observation reaching depth K — - differs from the previous render's prefix, so the request prefix before - it is what the provider's prompt cache already holds.""" + """Every full previous prompt, including its live tail, survives unchanged.""" ledger = TranscriptLedger(step_memory=_ready_service(12), image_scrub_depth=3) ledger.set_static_prefix([SystemMessage(content="STATIC")]) def snapshot(rendered: list) -> list[str]: - return [_fingerprint(m) for m in rendered[:-1]] # everything but the live tail + return [_fingerprint(m) for m in rendered] # includes the previously live observation previous: list[str] = [] for i in range(1, 11): @@ -620,24 +626,15 @@ def test_rendering_past_the_edge_is_byte_identical_and_prefix_stable(): ) current = snapshot(rendered) changed = [idx for idx, fp in enumerate(previous) if current[idx] != fp] - if i >= 3: - # The live tail is depth 1 and committed turn i-1 depth 2, so the - # turn reaching depth 3 is i-2; its observation heads its span. - expected = next( - idx - for idx, m in enumerate(rendered[:-1]) - if f"OBSERVATION [T+00:0{(i - 2) % 10}]" in str(m.content) - ) - assert changed == [expected], (i, changed) - else: - assert changed == [], (i, changed) + assert changed == [], (i, changed) previous = current # Same state, rendered again: byte-identical, including the tail. + ledger.commit_staged(step_key="step-10", validator_result={"status": "dispatched"}) tail = [_observation(11)] again = [_fingerprint(m) for m in ledger.render(tail)] assert again == [_fingerprint(m) for m in ledger.render(tail)] - assert again[:-1] == previous + assert again[: len(previous)] == previous # --------------------------------------------------------------------------- diff --git a/tests/unit/runtime/test_awake_service.py b/tests/unit/runtime/test_awake_service.py index 5fdb9bf..759bfea 100644 --- a/tests/unit/runtime/test_awake_service.py +++ b/tests/unit/runtime/test_awake_service.py @@ -1,5 +1,7 @@ import os from unittest.mock import MagicMock, patch +import pytest +from filelock import FileLock from artemis.runtime.awake_service import ( AWAKE_STRATEGY_HEARTBEAT, @@ -10,10 +12,37 @@ from artemis.runtime.awake_service import ( ) +@pytest.fixture(autouse=True) +def isolated_awake_owner_locks(tmp_path, monkeypatch): + monkeypatch.setattr( + "artemis.runtime.awake_service._owner_lock", + lambda device_id: FileLock(tmp_path / f"{device_id}.lock", thread_local=False), + ) + + def completed(stdout="", returncode=0, stderr=""): return MagicMock(returncode=returncode, stdout=stdout, stderr=stderr) +@patch( + "artemis.runtime.awake_service._configure_usb_stay_awake", return_value=AWAKE_STRATEGY_HEARTBEAT +) +def test_shared_device_has_one_heartbeat_owner_and_release_allows_takeover(configure): + first, second = ScreenAwakeService(), ScreenAwakeService() + try: + assert first.ensure_device("shared") == AWAKE_STRATEGY_HEARTBEAT + assert second.ensure_device("shared") is None + assert configure.call_count == 1 + thread = first._heartbeat_threads["shared"] + first.release_device("shared") + assert not thread.is_alive() + assert second.ensure_device("shared") == AWAKE_STRATEGY_HEARTBEAT + assert configure.call_count == 2 + finally: + first.shutdown() + second.shutdown() + + @patch("artemis.runtime.awake_service.ScreenAwakeLease") @patch("artemis.runtime.awake_service.subprocess.run") def test_usb_policy_is_primary_when_android_reports_it_active(mock_run, lease_type): @@ -164,6 +193,7 @@ def test_disconnect_reconciliation_stops_heartbeat_and_allows_reenrollment( service = ScreenAwakeService() stop_event = event_type.return_value thread = thread_type.return_value + thread.is_alive.return_value = False service.ensure_device("device-123") service._reconcile_connected_devices(set()) diff --git a/tests/unit/runtime/test_queue_file_io.py b/tests/unit/runtime/test_queue_file_io.py new file mode 100644 index 0000000..851abd9 --- /dev/null +++ b/tests/unit/runtime/test_queue_file_io.py @@ -0,0 +1,171 @@ +"""Real Windows handle sharing and bounded queue-write failure diagnostics.""" + +import errno +import json +import multiprocessing +import os +from pathlib import Path +import threading +from types import SimpleNamespace + +import pytest + +import artemis.runtime.device_lock as module +from artemis.runtime.device_lock import DeviceExecutionLock as Lock, DeviceQueueIOError +from artemis.runtime.queue_files import queue_file_lock + + +def _hold_shared_reader(path, ready, finish): + with queue_file_lock(Path(path)), Path(path).open(encoding="utf-8") as reader: + ready.set() + assert finish.wait(10) + assert json.loads(reader.read()) == {"version": 0} + + +def test_other_process_reader_finishes_before_atomic_replace(tmp_path): + path = tmp_path / "票据.wait" + path.write_text('{"version":0}', encoding="utf-8") + context = multiprocessing.get_context("spawn") + ready, finish = context.Event(), context.Event() + child = context.Process(target=_hold_shared_reader, args=(str(path), ready, finish)) + child.start() + release = threading.Timer(0.2, finish.set) + try: + assert ready.wait(10) + release.start() + assert Lock._atomic_write_json(path, {"version": 1}, raise_on_error=True) + assert json.loads(path.read_text()) == {"version": 1} + finally: + finish.set() + child.join(10) + if release.ident is not None: + release.join() + assert child.exitcode == 0 + + +def test_parallel_queue_readers_never_see_partial_json_or_block_claim(tmp_path): + path = tmp_path / "ticket.wait" + payload = Lock._owner_payload("token", "device", "queue stress") + assert Lock._atomic_write_json(path, payload) + stop = threading.Event() + errors = [] + + def scan(): + while not stop.is_set(): + owner = Lock._read_owner(path) + if owner is None or owner.token != "token": + errors.append(owner) + + readers = [threading.Thread(target=scan) for _ in range(3)] + for reader in readers: + reader.start() + try: + for index in range(200): + assert Lock._atomic_write_json(path, {**payload, "description": str(index)}) + finally: + stop.set() + for reader in readers: + reader.join(5) + assert not errors + assert not list(tmp_path.glob("*.tmp")) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows non-sharing reader behavior") +def test_legacy_reader_released_after_40ms_is_retried(tmp_path): + path = tmp_path / "ticket.wait" + path.write_text("{}") + reader = path.open() + release = threading.Timer(0.15, reader.close) + release.start() + try: + assert Lock._atomic_write_json(path, {"claimed": True}, raise_on_error=True) + finally: + release.join() + reader.close() + + +def _inject_replace(monkeypatch, operation): + delays = [] + monkeypatch.setattr(module, "os", SimpleNamespace(**{**vars(os), "replace": operation})) + monkeypatch.setattr( + module, "time", SimpleNamespace(**{**vars(module.time), "sleep": delays.append}) + ) + return delays + + +def test_claim_keeps_native_error_and_bounds_sharing_retry(tmp_path, monkeypatch): + monkeypatch.setattr(module, "get_temp_dir", lambda *_: tmp_path) + token = Lock.reserve("test") + lock = Lock("device", queue_ticket=token) + original = lock._find_reserved_ticket().read_text() + error = PermissionError(errno.EACCES, "held by another process") + error.winerror = 5 + attempts = [] + + def denied(src, dst): + attempts.append(dst) + raise error + + delays = _inject_replace(monkeypatch, denied) + with pytest.raises(DeviceQueueIOError) as raised: + lock.acquire() + assert raised.value.__cause__ is error + assert "winerror=5" in str(raised.value) + assert "attempts=6" in str(raised.value) + assert str(lock._find_reserved_ticket()) in str(raised.value) + assert sum(delays) == pytest.approx(1.55) + assert len(attempts) == 6 + assert lock._find_reserved_ticket().read_text() == original + assert not list(lock.queue_dir.glob("*.tmp")) + + +@pytest.mark.parametrize("code", [errno.ENOSPC, errno.EROFS, errno.EACCES]) +def test_unrelated_replace_errors_are_not_retried(tmp_path, monkeypatch, code): + error = OSError(code, "disk or permission failure") + + def fail(*_): + raise error + + delays = _inject_replace(monkeypatch, fail) + with pytest.raises(DeviceQueueIOError) as raised: + Lock._atomic_write_json(tmp_path / "ticket", {}, raise_on_error=True) + assert raised.value.__cause__ is error + assert not delays + assert not list(tmp_path.glob("*.tmp")) + + +def test_temporary_write_error_is_distinguished(tmp_path): + with pytest.raises(DeviceQueueIOError, match="write temporary") as raised: + Lock._atomic_write_json(tmp_path / "missing" / "ticket", {}, raise_on_error=True) + assert isinstance(raised.value.__cause__, FileNotFoundError) + + +def test_unreadable_owner_is_never_cleaned_as_stale(tmp_path, monkeypatch): + path = tmp_path / "owner.lock" + path.write_text("{}") + os.utime(path, (1, 1)) + error = PermissionError(errno.EACCES, "locked reader") + + def denied(_): + raise error + + monkeypatch.setattr(module, "read_queue_text", denied) + with pytest.raises(DeviceQueueIOError, match="read"): + Lock._get_active_owner_for_path(path) + assert path.exists() + + +def test_cancellation_during_retry_does_not_resurrect_ticket(tmp_path, monkeypatch): + path = tmp_path / "ticket" + path.write_text("{}") + error = PermissionError(errno.EACCES, "sharing") + error.winerror = 32 + + def cancel(src, dst): + path.unlink() + raise error + + _inject_replace(monkeypatch, cancel) + assert not Lock._atomic_write_json(path, {"claimed": True}, require_existing=True) + assert not path.exists() + assert not list(tmp_path.glob("*.tmp")) diff --git a/tests/unit/runtime/test_task_control.py b/tests/unit/runtime/test_task_control.py index 404137b..dcb87b5 100644 --- a/tests/unit/runtime/test_task_control.py +++ b/tests/unit/runtime/test_task_control.py @@ -7,6 +7,7 @@ import logging import os from pathlib import Path import threading +from types import SimpleNamespace import pytest @@ -35,8 +36,12 @@ def test_running_state_recovers_from_temporary_windows_file_access(tmp_path, mon raise _windows_access_error(winerror) return replace(source, target) - monkeypatch.setattr(task_control.os, "replace", busy_replace) - monkeypatch.setattr(task_control.time, "sleep", delays.append) + monkeypatch.setattr( + task_control, "os", SimpleNamespace(**{**vars(task_control.os), "replace": busy_replace}) + ) + monkeypatch.setattr( + task_control, "time", SimpleNamespace(**{**vars(task_control.time), "sleep": delays.append}) + ) task_control.TaskControl("task-123", object())._state("running") assert task_control.read_state("task-123")["status"] == "running" @@ -55,8 +60,12 @@ def test_persistent_windows_access_error_is_bounded_and_preserves_old_state(tmp_ attempts.append(source) raise error - monkeypatch.setattr(task_control.os, "replace", denied_replace) - monkeypatch.setattr(task_control.time, "sleep", delays.append) + monkeypatch.setattr( + task_control, "os", SimpleNamespace(**{**vars(task_control.os), "replace": denied_replace}) + ) + monkeypatch.setattr( + task_control, "time", SimpleNamespace(**{**vars(task_control.time), "sleep": delays.append}) + ) with pytest.raises(PermissionError) as raised: task_control._write_atomic(path, {"status": "running"}) @@ -82,8 +91,12 @@ def test_unrelated_file_errors_fail_without_retry_and_clean_temporary_file( attempts.append(source) raise error - monkeypatch.setattr(task_control.os, "replace", failed_replace) - monkeypatch.setattr(task_control.time, "sleep", delays.append) + monkeypatch.setattr( + task_control, "os", SimpleNamespace(**{**vars(task_control.os), "replace": failed_replace}) + ) + monkeypatch.setattr( + task_control, "time", SimpleNamespace(**{**vars(task_control.time), "sleep": delays.append}) + ) with pytest.raises(OSError) as raised: task_control._write_atomic(path, {"status": "running"}) @@ -127,7 +140,9 @@ def test_busy_temporary_cleanup_preserves_original_write_error(tmp_path, monkeyp raise _windows_access_error(32) return unlink(target, **kwargs) - monkeypatch.setattr(task_control.os, "replace", failed_replace) + monkeypatch.setattr( + task_control, "os", SimpleNamespace(**{**vars(task_control.os), "replace": failed_replace}) + ) monkeypatch.setattr(Path, "unlink", busy_unlink) monkeypatch.setattr(task_control.time, "sleep", lambda _: None) try: @@ -163,7 +178,9 @@ def test_repeated_control_requests_recover_from_file_sharing( raise _windows_access_error(32) return replace(source, target) - monkeypatch.setattr(task_control.os, "replace", busy_replace) + monkeypatch.setattr( + task_control, "os", SimpleNamespace(**{**vars(task_control.os), "replace": busy_replace}) + ) monkeypatch.setattr(task_control.time, "sleep", lambda _: None) send_request("task-123") diff --git a/tests/unit/test_android_app_launch.py b/tests/unit/test_android_app_launch.py new file mode 100644 index 0000000..b1b50df --- /dev/null +++ b/tests/unit/test_android_app_launch.py @@ -0,0 +1,137 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from artemis.drivers.android.adb_driver import AndroidAdbDriver +from artemis.drivers.errors import AppLaunchError + + +PACKAGE = "com.example.app" +COMPONENT = f"{PACKAGE}/.MainActivity" + + +def response(output, code=0): + return SimpleNamespace(returncode=code, output=output) + + +@pytest.fixture +def launch_device(): + device = MagicMock() + # An emulator without monkey must work, without installing or invoking it. + device.shell.side_effect = AssertionError("Unexpected unchecked shell/monkey call") + driver = AndroidAdbDriver("test-device", MagicMock()) + driver._device = device + return driver, device + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "output", + [ + "Starting: Intent {}\nStatus: ok\nComplete", + "Starting: Intent {}\nWarning: Activity not started, its current task has been brought to the front", + "Warning: Activity not started, intent has been delivered to currently running top-most instance.", + ], +) +async def test_launch_without_monkey_checks_start_result(launch_device, output): + driver, device = launch_device + device.shell2.side_effect = [response(f"priority=0\n{COMPONENT}"), response(output)] + assert await driver.launch_app(PACKAGE) is True + calls = device.shell2.call_args_list + assert calls[0].args[0][:3] == ["cmd", "package", "resolve-activity"] + assert calls[1].args[0][-2:] == ["-n", COMPONENT] + assert calls[1].args[0][:5] == ["am", "start", "-W", "--user", "current"] + assert "0x10200000" in calls[1].args[0] + assert all(call.kwargs["timeout"] > 0 for call in calls) + device.shell.assert_not_called() + + +@pytest.mark.asyncio +async def test_falls_back_to_pm_when_cmd_is_missing(launch_device): + driver, device = launch_device + device.shell2.side_effect = [ + response("/system/bin/sh: cmd: inaccessible or not found", 127), + response(COMPONENT), + response("Status: ok"), + ] + assert await driver.launch_app(PACKAGE) is True + assert device.shell2.call_args_list[1].args[0][:2] == ["pm", "resolve-activity"] + + +@pytest.mark.asyncio +async def test_old_resolvers_use_package_scoped_intent(launch_device): + driver, device = launch_device + device.shell2.side_effect = [ + response("Unknown command: resolve-activity", 1), + response("Unknown command: resolve-activity", 1), + response("Status: ok"), + ] + assert await driver.launch_app(PACKAGE) is True + assert device.shell2.call_args_list[-1].args[0][-2:] == ["-p", PACKAGE] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("output", ["No activity found", "", "android/.ResolverActivity"]) +async def test_missing_or_ambiguous_launcher_does_not_start(launch_device, output): + driver, device = launch_device + device.shell2.return_value = response(output) + with pytest.raises(AppLaunchError, match="未找到唯一可启动入口") as error: + await driver.launch_app(PACKAGE) + assert error.value.retryable is False + assert device.shell2.call_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "output, code", + [ + ("Starting: Intent {}\nError type 3\nError: Activity class does not exist.", 0), + ("java.lang.SecurityException: Permission Denial: not exported", 0), + ("Error: Permission Denial", 1), + ("/system/bin/sh: am: inaccessible or not found", 127), + ("Starting: Intent {}\nStatus: error", 0), + ], +) +async def test_start_errors_are_retained_even_when_exit_zero(launch_device, output, code): + driver, device = launch_device + device.shell2.side_effect = [response(COMPONENT), response(output, code)] + with pytest.raises(AppLaunchError) as error: + await driver.launch_app(PACKAGE) + assert output in str(error.value) + assert f"退出码 {code}" in str(error.value) + assert error.value.retryable is False + assert error.value.may_have_started is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", [TimeoutError(), response("Status: timeout"), response("")]) +async def test_unknown_start_outcome_requires_foreground_check(launch_device, failure): + driver, device = launch_device + device.shell2.side_effect = [response(COMPONENT), failure] + with pytest.raises(AppLaunchError) as error: + await driver.launch_app(PACKAGE) + assert error.value.retryable is True + assert error.value.may_have_started is True + + +@pytest.mark.asyncio +async def test_resolve_transport_failure_does_not_request_a_launch(launch_device): + driver, device = launch_device + device.shell2.side_effect = OSError("device offline") + with pytest.raises(AppLaunchError, match="device offline") as error: + await driver.launch_app(PACKAGE) + assert error.value.retryable is True + assert error.value.may_have_started is False + assert device.shell2.call_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "package", ["", "com.example.app;reboot", "com.example.app/.Activity", "a b"] +) +async def test_invalid_package_rejected_before_adb(launch_device, package): + driver, device = launch_device + with pytest.raises(AppLaunchError, match="无效"): + await driver.launch_app(package) + device.shell2.assert_not_called() diff --git a/tests/unit/test_android_files.py b/tests/unit/test_android_files.py new file mode 100644 index 0000000..4d03b8f --- /dev/null +++ b/tests/unit/test_android_files.py @@ -0,0 +1,307 @@ +"""Android artifact integrity, path separation and Flash/Pro tool parity.""" + +import asyncio +import hashlib +from io import BytesIO +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from PIL import Image +import pytest + +from artemis.agents.flash.runner import FlashRunner +from artemis.agents.validator.tool_declarations import ToolExecutionResult +from artemis.core.tool_failure import is_tool_failure +from artemis.mcp.action_executor import McpActionExecutor +from artemis.tools import android_files as files + + +@pytest.fixture +def ctx(tmp_path): + return SimpleNamespace( + execution_target="mixed", + device=SimpleNamespace(device_id="task-device"), + data_engine=SimpleNamespace(base_dir=tmp_path / "trace", current_session_id="session"), + execution_setup=None, + actuator=None, + plugins=None, + ) + + +@pytest.fixture +def png(): + stream = BytesIO() + Image.new("RGB", (144, 312), "red").save(stream, "PNG") + return stream.getvalue() + + +@pytest.mark.asyncio +async def test_screenshot_saved_native_and_desktop_copy_verified(ctx, png, tmp_path, monkeypatch): + desktop = tmp_path / "重定向桌面" + desktop.mkdir() + monkeypatch.setattr(files, "desktop_directory", lambda: desktop) + run = AsyncMock(return_value=png) + monkeypatch.setattr(files.AndroidFiles, "_run", run) + result = json.loads( + await files.android_file.execute( + ctx=ctx, action="screenshot", filename="手机截图.png", destination="desktop" + ) + ) + assert result["ok"] and result["verified"] + assert result["width"] == 144 and result["height"] == 312 + assert result["device_serial"] == "task-device" + assert Path(result["path"]) == desktop / "手机截图.png" + assert Path(result["path"]).read_bytes() == Path(result["trace_path"]).read_bytes() == png + assert result["sha256"] == hashlib.sha256(png).hexdigest() + run.assert_awaited_once_with("exec-out", "screencap", "-p", limit=files.MAX_FILE_BYTES) + + +@pytest.mark.asyncio +async def test_existing_artifact_is_preserved_unless_explicit_overwrite(ctx, png, monkeypatch): + monkeypatch.setattr(files.AndroidFiles, "_run", AsyncMock(return_value=png)) + first = json.loads( + await files.android_file.execute(ctx=ctx, action="screenshot", filename="a.png") + ) + path = Path(first["trace_path"]) + path.write_bytes(b"original") + error = await files.android_file.execute(ctx=ctx, action="screenshot", filename="a.png") + assert is_tool_failure(error) and path.read_bytes() == b"original" + result = await files.android_file.execute( + ctx=ctx, action="screenshot", filename="a.png", overwrite=True + ) + assert json.loads(result)["ok"] and path.read_bytes() == png + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "name", ["../escape.png", "C:\\out.png", "x:y.png", "CON.png", "x.png ", "a\n.png"] +) +async def test_invalid_host_paths_fail_before_adb(ctx, name, monkeypatch): + run = AsyncMock() + monkeypatch.setattr(files.AndroidFiles, "_run", run) + result = await files.android_file.execute(ctx=ctx, action="screenshot", filename=name) + assert is_tool_failure(result) + run.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "device_path", ["C:\\Desktop\\x", "relative", "/sdcard/../x", "/", "/sdcard/a\n"] +) +async def test_android_path_validation(ctx, device_path, monkeypatch): + run = AsyncMock() + monkeypatch.setattr(files.AndroidFiles, "_run", run) + assert is_tool_failure( + await files.android_file.execute(ctx=ctx, action="read_text", device_path=device_path) + ) + run.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_invalid_image_does_not_create_artifact(ctx, monkeypatch): + monkeypatch.setattr(files.AndroidFiles, "_run", AsyncMock(return_value=b"screencap failed")) + assert is_tool_failure(await files.android_file.execute(ctx=ctx, action="screenshot")) + assert not (ctx.data_engine.base_dir / "artifacts").exists() + + +@pytest.mark.asyncio +async def test_read_and_pull_verify_chinese_and_binary_data(ctx, monkeypatch): + service = files.AndroidFiles(ctx) + data = "中文内容\n第二行".encode() + digest = hashlib.sha256(data).hexdigest() + shell = AsyncMock(side_effect=[str(len(data)), digest, digest]) + monkeypatch.setattr(service, "_shell", shell) + monkeypatch.setattr(service, "_run", AsyncMock(return_value=data)) + result = await service.execute( + files.AndroidFileArgs(action="read_text", device_path="/sdcard/a '中'.txt") + ) + assert result["content"] == data.decode() + assert "'\"'\"'" in shell.call_args_list[0].args[0] # POSIX shell quoting + binary = b"\x00\xff\r\n\x1a" + digest = hashlib.sha256(binary).hexdigest() + shell.side_effect = [str(len(binary)), digest, digest] + service._run.return_value = binary + result = await service.execute( + files.AndroidFileArgs(action="pull", device_path="/sdcard/file.bin") + ) + assert Path(result["path"]).read_bytes() == binary + + +@pytest.mark.asyncio +async def test_changed_or_oversize_file_is_not_saved(ctx, monkeypatch): + service = files.AndroidFiles(ctx) + monkeypatch.setattr(service, "_shell", AsyncMock(side_effect=["3", "0" * 64])) + run = AsyncMock(return_value=b"abc") + monkeypatch.setattr(service, "_run", run) + with pytest.raises(OSError, match="传输"): + await service.execute(files.AndroidFileArgs(action="pull", device_path="/sdcard/a")) + assert not service.root.exists() + service._shell.side_effect = [str(files.MAX_TEXT_BYTES + 1)] + run.reset_mock() + with pytest.raises(ValueError, match="上限"): + await service.execute(files.AndroidFileArgs(action="read_text", device_path="/sdcard/a")) + run.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_write_text_pushes_utf8_and_verifies_before_rename(ctx, monkeypatch): + service = files.AndroidFiles(ctx) + data = "第一行\n第二行".encode() + calls = [] + + async def run(*args, **kwargs): + if args[0] == "push": + assert Path(args[1]).read_bytes() == data + calls.append("push") + return b"" + + async def shell(command): + calls.append(command) + return hashlib.sha256(data).hexdigest() if command.startswith("sha256sum") else "" + + monkeypatch.setattr(service, "_run", run) + monkeypatch.setattr(service, "_shell", shell) + result = await service.execute( + files.AndroidFileArgs( + action="write_text", device_path="/sdcard/Download/文本.txt", content=data.decode() + ) + ) + assert result["verified"] and result["bytes"] == len(data) + assert any("mv -n" in call and "test ! -e" in call for call in calls) + assert calls.index("push") < next(i for i, c in enumerate(calls) if "mv -n" in c) + assert not list(service.root.iterdir()) # no leaked local staging files + + +@pytest.mark.asyncio +async def test_permission_failure_propagates_and_does_not_claim_success(ctx, monkeypatch): + monkeypatch.setattr( + files.AndroidFiles, "_run", AsyncMock(side_effect=OSError("Permission denied")) + ) + result = await files.android_file.execute( + ctx=ctx, action="read_text", device_path="/data/private.txt" + ) + assert is_tool_failure(result) and "Permission denied" in result + + +@pytest.mark.asyncio +async def test_existing_android_file_is_never_pushed_over(ctx, monkeypatch): + service = files.AndroidFiles(ctx) + monkeypatch.setattr(service, "_shell", AsyncMock(return_value="exists")) + push = AsyncMock() + monkeypatch.setattr(service, "_run", push) + with pytest.raises(FileExistsError, match="设备文件已存在"): + await service.execute( + files.AndroidFileArgs(action="write_text", device_path="/sdcard/a", content="new") + ) + push.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["cancel", "timeout", "limit"]) +async def test_adb_subprocess_is_reaped_on_interrupt(ctx, monkeypatch, failure): + service = files.AndroidFiles(ctx) + process = Mock(returncode=None) + process.stdout.read = AsyncMock() + process.stderr.read = AsyncMock(return_value=b"") + if failure == "limit": + process.stdout.read.return_value = b"too large" + expected = ValueError + else: + + async def wait_forever(*args): + await asyncio.Event().wait() + + process.stdout.read.side_effect = wait_forever + expected = asyncio.CancelledError if failure == "cancel" else TimeoutError + process.wait = AsyncMock(return_value=0) + + def kill(): + process.returncode = -1 + + process.kill.side_effect = kill + spawn = AsyncMock(return_value=process) + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + monkeypatch.setattr(files, "ADB_TIMEOUT_SECONDS", 0.02) + task = asyncio.create_task(service._run("exec-out", "cat /sdcard/a", limit=2)) + await asyncio.sleep(0) + if failure == "cancel": + task.cancel() + with pytest.raises(expected): + await task + process.kill.assert_called_once() + process.wait.assert_awaited() + argv = spawn.call_args.args + assert argv[argv.index("-s") + 1] == "task-device" + assert "-H" in argv and "-P" in argv + + +@pytest.mark.asyncio +async def test_flash_and_pro_share_schema_handler_and_failure_status(ctx, png, monkeypatch): + monkeypatch.setattr(files.AndroidFiles, "_run", AsyncMock(return_value=png)) + monkeypatch.setattr("artemis.tools.base.get_driver", lambda ctx: None) + monkeypatch.setattr( + "artemis.mcp.action_executor.TraceSpan", + lambda **kw: Mock(__enter__=lambda self: self, __exit__=lambda *a: False), + ) + runner = object.__new__(FlashRunner) + runner.ctx = ctx + assert "android_file" in {t.name for t in runner._get_tools()} + pro = files.android_file.to_langchain_tool(ctx) + assert pro.args_schema is files.AndroidFileArgs + result = await pro.ainvoke({"action": "screenshot", "filename": "pro.png"}) + assert json.loads(result)["verified"] + executor = McpActionExecutor(ctx, actuator=Mock()) + result = await executor.execute( + "android_file", {"action": "screenshot", "filename": "flash.png"}, "call", None + ) + assert result.status == "success" and json.loads(result.text_summary)["verified"] + result = await executor.execute( + "android_file", {"action": "screenshot", "filename": "flash.png"}, "call2", None + ) + assert result.status == "error" + ctx.execution_target = "computer" + assert "android_file" not in {t.name for t in runner._get_tools()} + assert is_tool_failure(await files.android_file.execute(ctx=ctx, action="screenshot")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["success", "error"]) +async def test_flash_records_file_receipts_as_steps_for_final_verification(ctx, status): + runner = object.__new__(FlashRunner) + runner.ctx = ctx + runner._llm = None + runner.summarizer = None + result = ToolExecutionResult( + tool_call_id="file-call", + tool_name="android_file", + status=status, + text_summary='{"verified": true, "sha256": "receipt"}' + if status == "success" + else "Permission denied", + ) + runner.executor = SimpleNamespace( + action_tool_names=frozenset({"click"}), execute=AsyncMock(return_value=result) + ) + runner._record_action_step = Mock(return_value="recorded-file-step") + turn = SimpleNamespace(step_keys=[], actions=[]) + messages = [] + await runner._execute_and_record_action( + name="android_file", + args={"action": "screenshot"}, + tc_id="file-call", + state=SimpleNamespace(), + messages=messages, + raw_text="保存当前安卓屏幕", + step_token_usage={}, + pre_screenshot_bytes=b"previous-observation", + xml_list="UI", + action_sequence=0, + turn=turn, + ) + runner._record_action_step.assert_called_once() + assert runner._record_action_step.call_args.args[2] is result + assert turn.step_keys == ["recorded-file-step"] + assert turn.actions == [("android_file", status, result.text_summary)] + assert messages[-1].content == result.text_summary diff --git a/tests/unit/test_cache_affinity.py b/tests/unit/test_cache_affinity.py new file mode 100644 index 0000000..368da4b --- /dev/null +++ b/tests/unit/test_cache_affinity.py @@ -0,0 +1,55 @@ +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace +from uuid import uuid4 +import re + +from artemis.llm.cache_affinity import cache_affinity_options + + +def context(session, exploration=None): + return SimpleNamespace( + data_engine=SimpleNamespace(current_session_id=session), exploration_id=exploration + ) + + +def test_routing_identity_survives_resume_but_isolates_tasks_roles_and_models(): + ctx = context(uuid4(), "exploration") + first = cache_affinity_options(ctx, "flash", "gpt-6-luna") + ctx.data_engine.current_session_id = uuid4() + assert first == cache_affinity_options(ctx, "flash", "gpt-6-luna") + key = first["prompt_cache_key"] + assert re.fullmatch(r"[-0-9A-Za-z._:]{1,128}", key) + assert "exploration" not in key + assert first["extra_headers"] == {"Thread-Id": key, "Session-Id": key} + for role, model in [("checker", "gpt-6-luna"), ("flash", "gpt-6.1-sol")]: + assert cache_affinity_options(ctx, role, model)["prompt_cache_key"] != key + assert ( + cache_affinity_options(context("another"), "flash", "gpt-6-luna")["prompt_cache_key"] != key + ) + + +def test_explicit_options_are_preserved_without_mutating_headers_or_adding_aliases(): + headers = {"thread_ID": "explicit-thread", "session-id": "explicit-session", "X-Test": "ok"} + options = {"prompt_cache_key": "explicit.key", "extra_headers": headers, "temperature": 0} + actual = cache_affinity_options(context("task"), "flash", "test", options) + assert actual == options and actual is not options + assert actual["extra_headers"] is not headers + assert options["extra_headers"] == headers + assert cache_affinity_options(None, "flash", "test") == {} + for key in (None, "", "a" * 129, "unsafe\r\nheader"): + assert cache_affinity_options( + context("task"), "flash", "test", {"prompt_cache_key": key} + ) == {"prompt_cache_key": key} + + +def test_concurrent_calls_do_not_share_or_mutate_task_headers(): + shared = {"extra_headers": {"X-Test": "shared"}} + with ThreadPoolExecutor(max_workers=2) as pool: + results = list( + pool.map( + lambda task: cache_affinity_options(context(task), "flash", "test", shared), + ["task-a", "task-b"], + ) + ) + assert results[0]["extra_headers"]["Thread-Id"] != results[1]["extra_headers"]["Thread-Id"] + assert shared == {"extra_headers": {"X-Test": "shared"}} diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index f1d8063..c4a7350 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -217,7 +217,7 @@ def test_cli_mcp_generate_config_codex(): assert "[mcp_servers.artemis]" in result.output assert "[mcp_servers.artemis.env]" in result.output assert "enabled = true" in result.output - assert "required = true" in result.output + assert "required = false" in result.output assert "startup_timeout_sec = 120" in result.output assert "enabled_tools" in result.output assert "mobile_run_task" in result.output @@ -233,6 +233,8 @@ def test_cli_mcp_generate_config_codex(): "mobile_config", "mobile_task_file", "mobile_batch", + "mobile_favorites", + "mobile_exploration", "mobile_search_history", "mobile_export_report", ] @@ -248,7 +250,7 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk config_file.write_text( 'model = "test-model"\n\n' '[mcp_servers.existing]\ncommand = "echo"\n\n' - '[mcp_servers.artemis]\ncommand = "old-command"\n', + '[mcp_servers.artemis]\ncommand = "old-command"\nargs = ["-m", "mcp_server"]\n', encoding="utf-8", ) @@ -265,7 +267,7 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk assert data["mcp_servers"]["existing"]["command"] == "echo" assert data["mcp_servers"]["artemis"]["args"] == ["-m", "mcp_server"] assert data["mcp_servers"]["artemis"]["enabled"] is True - assert data["mcp_servers"]["artemis"]["required"] is True + assert data["mcp_servers"]["artemis"]["required"] is False assert data["mcp_servers"]["artemis"]["startup_timeout_sec"] == 120 assert data["mcp_servers"]["artemis"]["enabled_tools"] == [ "mobile_run_task", @@ -277,16 +279,18 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk "mobile_config", "mobile_task_file", "mobile_batch", + "mobile_favorites", + "mobile_exploration", "mobile_search_history", "mobile_export_report", ] assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path) - assert config_text.count("# BEGIN ARTEMIS MCP CONFIG") == 1 + assert config_text.count("[mcp_servers.artemis]") == 1 agents_file = codex_dir / "AGENTS.md" - agents_text = agents_file.read_text(encoding="utf-8") - assert "Mobile Testing Mindset" in agents_text - assert agents_text.count("") == 1 + assert not agents_file.exists() + skill_file = tmp_path / ".agents/skills/artemis-use/SKILL.md" + assert "name: artemis-use" in skill_file.read_text(encoding="utf-8") def test_cli_restart_help(): diff --git a/tests/unit/test_codex_skill_sync.py b/tests/unit/test_codex_skill_sync.py new file mode 100644 index 0000000..34940df --- /dev/null +++ b/tests/unit/test_codex_skill_sync.py @@ -0,0 +1,124 @@ +import json +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from artemis.integrations import codex_skill as integration + + +@pytest.fixture +def bundle(tmp_path, monkeypatch): + root = tmp_path / "source" + source = root / "skills" / "artemis-use" + source.mkdir(parents=True) + (source / "SKILL.md").write_text( + "---\nname: artemis-use\ndescription: official\n---\n", "utf-8" + ) + (source / "references").mkdir() + (source / "references" / "modes.md").write_text("official modes", "utf-8") + codex_home = tmp_path / "用户配置" / ".codex" + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + return root, source, codex_home + + +def test_install_overwrites_entire_reserved_directory_and_is_noop_when_unchanged(bundle): + root, source, home = bundle + target = home / "skills" / "artemis-use" + target.mkdir(parents=True) + (target / "SKILL.md").write_text("unmanaged custom content", "utf-8") + (target / "custom.txt").write_text("must be removed", "utf-8") + unrelated = home / "skills" / "personal" / "SKILL.md" + unrelated.parent.mkdir() + unrelated.write_text("personal", "utf-8") + assert integration.sync_official_skill(root)["status"] == "updated" + assert not (target / "custom.txt").exists() + assert (target / "SKILL.md").read_bytes() == (source / "SKILL.md").read_bytes() + assert unrelated.read_text("utf-8") == "personal" + before = (target / "SKILL.md").stat().st_mtime_ns + assert integration.sync_official_skill(root)["status"] == "unchanged" + assert (target / "SKILL.md").stat().st_mtime_ns == before + (target / "SKILL.md").write_text("edited", "utf-8") + assert integration.sync_official_skill(root)["status"] == "updated" + assert (target / "SKILL.md").read_bytes() == (source / "SKILL.md").read_bytes() + + +def test_update_removes_retired_files_and_migrates_only_marked_rules(bundle): + root, source, home = bundle + integration.sync_official_skill(root) + original = "user prefix\n\nold\n\nuser suffix\n" + for name in ("AGENTS.md", "AGENTS.override.md"): + (home / name).write_text(original, "utf-8") + (source / "references" / "modes.md").unlink() + integration.sync_official_skill(root) + assert not (home / "skills/artemis-use/references/modes.md").exists() + for name in ("AGENTS.md", "AGENTS.override.md"): + assert (home / name).read_text("utf-8") == "user prefix\nuser suffix\n" + assert (home / (name + ".artemis-update.bak")).read_text("utf-8") == original + + +def test_failed_replacement_restores_previous_bundle(bundle, monkeypatch): + root, source, home = bundle + integration.sync_official_skill(root) + target = home / "skills/artemis-use" + original = (target / "SKILL.md").read_bytes() + (source / "SKILL.md").write_text("new version", "utf-8") + rename = Path.rename + + def fail_staging(path, destination): + if path.name.startswith(".artemis-use-stage-"): + raise PermissionError("held open") + return rename(path, destination) + + monkeypatch.setattr(Path, "rename", fail_staging) + assert integration.sync_skill_safely(root)["status"] == "error" + assert (target / "SKILL.md").read_bytes() == original + assert not list(target.parent.glob(".artemis-use-stage-*")) + + +def test_newer_official_bundle_is_not_downgraded(bundle): + root, _, home = bundle + integration.sync_official_skill(root) + stamp = home / "skills/artemis-use" / integration.STAMP + data = json.loads(stamp.read_text("utf-8")) + data["artemis_version"] = "99.0.0" + stamp.write_text(json.dumps(data), "utf-8") + assert integration.sync_official_skill(root)["status"] == "newer_installed" + + +def test_default_directory_and_duplicate_cleanup(bundle, tmp_path, monkeypatch): + root, source, _ = bundle + monkeypatch.delenv("CODEX_HOME") + monkeypatch.setattr(Path, "home", lambda: tmp_path) + legacy = tmp_path / ".codex/skills/artemis-use" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("old", "utf-8") + assert integration.sync_official_skill(root)["path"] == str(legacy) + modern = tmp_path / ".agents/skills/artemis-use" + modern.mkdir(parents=True) + (modern / "SKILL.md").write_text("duplicate", "utf-8") + assert integration.sync_official_skill(root)["path"] == str(modern) + assert not legacy.exists() + + +def test_actual_bundle_links_are_self_contained(): + import re + + source = Path(__file__).resolve().parents[2] / "skills/artemis-use" + for document in source.rglob("*.md"): + for relative in re.findall(r"\]\(([^)]+)\)", document.read_text("utf-8")): + if "://" in relative or relative.startswith("#"): + continue + target = (document.parent / relative.split("#", 1)[0]).resolve() + assert target.is_relative_to(source.resolve()), (document, relative) + assert target.exists(), (document, relative) + + +def test_malformed_stamp_does_not_block_authoritative_update(bundle): + root, source, home = bundle + integration.sync_official_skill(root) + target = home / "skills/artemis-use" + (target / integration.STAMP).write_text("[]", "utf-8") + (target / "SKILL.md").write_text("unofficial", "utf-8") + assert integration.sync_official_skill(root)["status"] == "updated" + assert (target / "SKILL.md").read_bytes() == (source / "SKILL.md").read_bytes() diff --git a/tests/unit/test_exploration_budget.py b/tests/unit/test_exploration_budget.py new file mode 100644 index 0000000..0decfc7 --- /dev/null +++ b/tests/unit/test_exploration_budget.py @@ -0,0 +1,88 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from artemis.exploration.budget import ExplorationBudget +from artemis.runtime import task_control + + +def test_budget_excludes_demonstration_and_waiting_and_resets_only_on_continue(monkeypatch): + now = [0.0] + monkeypatch.setattr("artemis.runtime.task_limits.time.monotonic", lambda: now[0]) + pause = Mock() + monkeypatch.setattr("artemis.exploration.budget.request_pause", pause) + repository = Mock() + budget = ExplorationBudget("run", 30, repository) + now[0] = 10 + assert budget.update({"status": "demonstrating"}) == 20 + now[0] = 500 + assert budget.update({"status": "task_paused"}) == 20 + now[0] = 1000 + assert budget.update({"status": "running"}) == 20 + now[0] = 1020 + assert budget.update({"status": "running"}) == 0 + pause.assert_called_once_with("run", reason="execution_budget") + now[0] = 1030 + budget.update({"status": "task_paused"}) + budget.update({"status": "task_paused"}) + assert pause.call_count == 1 + now[0] = 2000 + assert budget.update({"status": "running", "budget_epoch": 1}) == 30 + assert budget.pause_requested is False + assert budget.previous_elapsed == 40 + now[0] = 2010 + assert budget.update({"status": "running", "budget_epoch": 1}) == 20 + + +@pytest.mark.asyncio +async def test_answer_received_before_pause_acknowledgement_is_not_lost(tmp_path, monkeypatch): + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) + + class Lock: + device_id = "test-device" + description = "test" + max_concurrency = None + concurrency_mode = "per_device" + ingress = "test" + lock_scope = None + + def __init__(self, *args, **kwargs): + self.released = False + + def acquire(self, **kwargs): + return None + + def release(self): + self.released = True + + monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock) + control = task_control.TaskControl("run", Lock()) + task_control.request_pause("run", reason="question") + task_control.add_instruction("run", "用户已回答", instruction_id="answer") + assert await asyncio.wait_for(control.checkpoint(), 1) + assert task_control.read_state("run")["status"] == "running" + assert control.budget_epoch == 0 + + +@pytest.mark.asyncio +async def test_budget_resume_increments_epoch_after_lease_is_reacquired(tmp_path, monkeypatch): + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) + old = SimpleNamespace( + device_id="test", + description="test", + max_concurrency=None, + concurrency_mode="per_device", + ingress="test", + lock_scope=None, + release=Mock(), + ) + lock = SimpleNamespace(acquire=Mock(), release=Mock()) + monkeypatch.setattr(task_control, "DeviceExecutionLock", Mock(return_value=lock)) + control = task_control.TaskControl("run", old) + task_control.request_pause("run", reason="execution_budget") + task_control.request_resume("run") + assert await control.checkpoint() + lock.acquire.assert_called_once() + assert task_control.read_state("run")["budget_epoch"] == 1 diff --git a/tests/unit/test_exploration_deletion.py b/tests/unit/test_exploration_deletion.py new file mode 100644 index 0000000..ce696a3 --- /dev/null +++ b/tests/unit/test_exploration_deletion.py @@ -0,0 +1,166 @@ +import json +from types import SimpleNamespace +from uuid import UUID + +from fastapi import FastAPI +from fastapi.testclient import TestClient +import pytest + +from apps.admin_console.routers import sessions +from apps.admin_console.services import exploration_deletion as deletion +from apps.admin_console.services import report_export +from apps.admin_console.services.automation_store import AutomationStore +from artemis.config.exploration import ExplorationConfig +from artemis.data_engine.storage import StorageManager +from artemis.exploration import store as store_module +from artemis.exploration.models import CaseDraft +from artemis.exploration.store import ExplorationConflict, ExplorationStore + + +@pytest.fixture +def setup(tmp_path, monkeypatch): + db = tmp_path / "data.db" + traces = tmp_path / "traces" + storage = StorageManager(db, traces) + repository = ExplorationStore(AutomationStore(db)) + monkeypatch.setattr(store_module, "explorations", repository) + monkeypatch.setattr(report_export, "TRACES_PATH", traces) + monkeypatch.setattr(report_export, "EXPORT_ROOT", tmp_path / "exports") + monkeypatch.setattr(deletion, "state", SimpleNamespace(queue_items=[], active_runs={})) + monkeypatch.setattr(deletion, "read_state", lambda _: {}) + app = FastAPI() + app.include_router(sessions.router) + session = repository.create("探索页面", ExplorationConfig(), {}, "create") + return repository, session["id"], storage, TestClient(app) + + +def make_run(repository, sid, request, *, kind="exploration", finished=True): + run = repository.reserve_run( + sid, + request, + kind=kind, + goal="观察", + task_config={}, + case_version=1 if kind == "validation" else None, + ) + if finished: + repository.finish_run(run["id"], {"status": "cancelled"}) + return run["id"] + + +def test_delete_conversation_removes_all_exploration_runs_but_keeps_reruns(setup): + repository, sid, storage, client = setup + first = make_run(repository, sid, "first") + second = make_run(repository, sid, "continue") + repository.save_case(sid, CaseDraft(name="可复用用例", goal="检查页面"), 0, "case") + validation = make_run(repository, sid, "validate", kind="validation") + unrelated = repository.create("另一探索", ExplorationConfig(), {}, "other")["id"] + directories = [] + for run_id in (first, second, validation): + repository.database.save_job({"session_id": run_id, "status": "cancelled", "position": 1}) + with repository.database.connection() as connection: + connection.execute( + "INSERT INTO sessions (session_id, initial_goal, start_time, status, device_info) VALUES (?, ?, 1, ?, '{}')", + (run_id, "观察", "cancelled"), + ) + connection.commit() + for root in (storage.base_trace_dir, report_export.EXPORT_ROOT / "sessions"): + folder = root / run_id + folder.mkdir(parents=True) + (folder / "evidence.txt").write_text("saved", encoding="utf-8") + if run_id != validation: + directories.append(folder) + with repository._transaction() as connection: + session = repository._require(connection, sid) + session["status"] = "paused" # Resumable conversation, no running process. + repository._save_session(connection, session) + response = client.post(f"/api/sessions/{first}/delete") + assert response.status_code == 200, response.text + assert set(response.json()["deleted_session_ids"]) == {first, second} + assert not any(folder.exists() for folder in directories) + assert (storage.base_trace_dir / validation / "evidence.txt").exists() + assert repository.database.job(validation) is not None + assert repository.database.job(first) is None + assert storage.get_session(UUID(validation)) is not None + assert storage.get_session(UUID(first)) is None + assert repository.get(unrelated)["goal"] == "另一探索" + assert repository.run(validation) is None # Detach it from the deleted conversation. + deleted_requests = [ + record + for record in repository.database.records("exploration_request") + if record.get("deleted") + ] + assert len(deleted_requests) >= 5 + assert all("result" not in record for record in deleted_requests) + with repository.database.connection() as connection: + assert not connection.execute( + "SELECT 1 FROM automation_records WHERE kind != 'exploration_request' " + "AND json_extract(payload,'$.exploration_id')=?", + (sid,), + ).fetchone() + with pytest.raises(KeyError): + repository.get(sid) + with pytest.raises(ExplorationConflict, match="已删除"): + repository.create("探索页面", ExplorationConfig(), {}, "create") + + +@pytest.mark.parametrize("state", ["running", "paused", "waiting_user", "demonstrating"]) +def test_live_execution_cannot_be_deleted_even_through_an_old_run(setup, state): + repository, sid, _, client = setup + first = make_run(repository, sid, "first") + active = make_run(repository, sid, "continue", finished=False) + repository.set_run_state(active, state) + response = client.post(f"/api/sessions/{first}/delete") + assert response.status_code == 409 + assert repository.get(sid)["active_run_id"] == active + assert repository.run(first) is not None + + +@pytest.mark.parametrize("kind", ["exploration_generation", "exploration_discussion"]) +def test_editorial_work_prevents_deletion(setup, kind): + repository, sid, _, client = setup + run_id = make_run(repository, sid, "first") + repository.database.put(kind, "job", {"exploration_id": sid, "status": "running"}) + assert client.post(f"/api/sessions/{run_id}/delete").status_code == 409 + assert repository.get(sid)["status"] == "cancelled" + + +def test_worker_still_exiting_prevents_deletion(setup, monkeypatch): + repository, sid, _, client = setup + run_id = make_run(repository, sid, "first") + monkeypatch.setattr( + deletion, "state", SimpleNamespace(queue_items=[], active_runs={run_id: {}}) + ) + assert client.post(f"/api/sessions/{run_id}/delete").status_code == 409 + assert repository.run(run_id) is not None + + +def test_cleanup_failure_is_retryable_and_blocks_new_work(setup, monkeypatch): + repository, sid, storage, client = setup + run_id = make_run(repository, sid, "first") + original = StorageManager.delete_session + + def fail(*args): + raise OSError("file busy") + + monkeypatch.setattr(StorageManager, "delete_session", fail) + response = client.post(f"/api/sessions/{run_id}/delete") + assert response.status_code == 500 + with pytest.raises(ExplorationConflict, match="正在删除"): + make_run(repository, sid, "restart") + monkeypatch.setattr(StorageManager, "delete_session", original) + assert client.post(f"/api/sessions/{run_id}/delete").status_code == 200 + assert not repository.list() + + +@pytest.mark.asyncio +async def test_delayed_discussion_cannot_recreate_deleted_metadata(setup): + from artemis.exploration.editorial import discuss + + repository, sid, _, client = setup + run_id = make_run(repository, sid, "first") + message = repository.add_message(sid, "不再继续", "message") + assert client.post(f"/api/sessions/{run_id}/delete").status_code == 200 + with pytest.raises(KeyError): + await discuss(repository, sid, message) + assert not repository.database.records("exploration_discussion") diff --git a/tests/unit/test_exploration_demonstrations.py b/tests/unit/test_exploration_demonstrations.py new file mode 100644 index 0000000..5e9797a --- /dev/null +++ b/tests/unit/test_exploration_demonstrations.py @@ -0,0 +1,259 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from apps.admin_console.services.automation_store import AutomationStore +from artemis.config.exploration import ExplorationConfig +from artemis.exploration.demonstrations import Demonstrations +from artemis.exploration.store import ExplorationConflict, ExplorationStore +from artemis.runtime import task_control + + +@pytest.fixture +def demo_session(tmp_path, monkeypatch): + repository = ExplorationStore(AutomationStore(tmp_path / "history.db")) + monkeypatch.setattr("artemis.exploration.runtime.explorations", repository) + monkeypatch.setattr("artemis.exploration.demo_runtime.explorations", repository) + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path / "traces") + session = repository.create("验证操作结果", ExplorationConfig(), {}, "create") + run = repository.reserve_run( + session["id"], "start", kind="exploration", goal="验证", task_config={} + ) + repository.set_run_state(run["id"], "running") + return repository, session, run + + +def test_demo_command_retries_do_not_extend_twice_or_finish_other_sessions( + demo_session, monkeypatch +): + repository, session, run = demo_session + now = [100.0] + monkeypatch.setattr("artemis.exploration.demonstrations.time.time", lambda: now[0]) + demos = Demonstrations(repository) + demo = demos.request(session["id"], "both", "request") + assert demos.request(session["id"], "both", "request")["id"] == demo["id"] + with pytest.raises(ExplorationConflict): + demos.request(session["id"], "android", "second") + demos.transition(demo["id"], "recording") + for _ in range(2): + demos.command(session["id"], demo["id"], "extend", "extend", seconds=60) + assert demos.get(demo["id"])["deadline"] == 1060 + other = repository.create("另一个目标", ExplorationConfig(), {}, "other") + with pytest.raises(KeyError): + demos.command(other["id"], demo["id"], "finish", "finish") + now[0] = 1061 + with pytest.raises(ExplorationConflict): + demos.command(session["id"], demo["id"], "finish", "finish") + demos.transition(demo["id"], "timed_out", media=[{"saved": True}]) + assert repository.get(session["id"])["active_demonstration_id"] is None + assert repository.run(run["id"])["outcome"] is None + assert demos.transition(demo["id"], "completed")["status"] == "timed_out" + + +@pytest.mark.asyncio +async def test_demo_safe_point_keeps_lease_and_restores_capture_after_success( + demo_session, monkeypatch +): + from artemis.exploration.runtime import ExplorationControl + + repository, session, run = demo_session + demo = Demonstrations(repository).request(session["id"], "android", "demo") + lock = SimpleNamespace(device_id="test-device", release=Mock()) + control = ExplorationControl(run["id"], lock) + control.context = SimpleNamespace() + stop, resume = AsyncMock(), AsyncMock() + + async def perform(ctx, observed_control, record): + assert record["id"] == demo["id"] + stop.assert_awaited_once() + lock.release.assert_not_called() + observed_control._state("demonstrating") + assert task_control.read_state(run["id"])["status"] == "demonstrating" + return "" + + monkeypatch.setattr("artemis.exploration.demo_runtime.perform_demonstration", perform) + task_control.request_pause(run["id"], reason="demonstration") + assert await control.checkpoint(on_pause=stop, on_resume=resume) + lock.release.assert_not_called() + resume.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_demo_timeout_saves_media_then_releases_lease_without_failing( + demo_session, monkeypatch +): + from artemis.exploration.runtime import ExplorationControl + + repository, session, run = demo_session + demo = Demonstrations(repository).request(session["id"], "android", "demo") + lock = SimpleNamespace(device_id="test-device", release=Mock()) + control = ExplorationControl(run["id"], lock) + control.context = SimpleNamespace() + + async def perform(*_): + Demonstrations(repository).transition( + demo["id"], "timed_out", media=[{"file": "saved.mp4"}] + ) + return "demonstration_timed_out" + + monkeypatch.setattr("artemis.exploration.demo_runtime.perform_demonstration", perform) + task_control.request_pause(run["id"], reason="demonstration") + waiting = asyncio.create_task(control.checkpoint()) + for _ in range(100): + await asyncio.sleep(0.01) + if waiting.done(): + waiting.result() + if repository.get(session["id"])["status"] == "paused": + break + try: + assert lock.release.called + assert repository.get(session["id"])["status"] == "paused" + assert repository.run(run["id"])["outcome"] is None + assert Demonstrations(repository).get(demo["id"])["media"] + finally: + waiting.cancel() + await asyncio.gather(waiting, return_exceptions=True) + + +def test_system_demo_summary_cannot_answer_pending_user_question(demo_session): + from artemis.exploration.runtime import ExplorationControl + + repository, session, run = demo_session + control = ExplorationControl(run["id"], Mock()) + question = repository.ask(run["id"], "预期是哪个状态?", "开启", "q") + task_control.add_instruction(run["id"], "视频看到了点击", source="demonstration") + assert not control.question_answered() + repository.answer_question(session["id"], question["id"]) + assert control.question_answered() + + +@pytest.mark.asyncio +async def test_saved_video_extracts_copy_and_preserves_source_and_offsets(tmp_path, monkeypatch): + from artemis.exploration.media import SavedVideoSource + + original = tmp_path / "demo.mp4" + original.write_bytes(b"evidence") + + async def render(segments, start, end, target, **kwargs): + assert (start, end) == (12, 14) + target.write_bytes(b"temporary") + return True + + monkeypatch.setattr("artemis.exploration.media.render_timeline_clip", render) + source = SavedVideoSource( + tmp_path, + { + "id": "demo-computer", + "kind": "computer", + "segments": [{"file": "demo.mp4", "offset_ms": 12000, "duration_ms": 2000}], + }, + ) + result = await source.extract_segment_metadata(0, None) + assert result.actual_start_relative_time == 12 + assert result.source_revision == "demo-computer" + result.video_path.unlink() + result.video_path.parent.rmdir() + assert original.read_bytes() == b"evidence" + with pytest.raises(ValueError): + SavedVideoSource( + tmp_path, {"segments": [{"file": "../private.mp4", "offset_ms": 0, "duration_ms": 1}]} + ) + + +def test_saved_demo_sampling_reaches_end_of_long_interval(monkeypatch, tmp_path): + from artemis.agents.video_analyzer.universal_engine import _gather_universal_frames + + sampling = Mock(return_value=[]) + monkeypatch.setattr( + "artemis.agents.video_analyzer.video_analyzer.extract_frames_at_timestamps", sampling + ) + analyzer = SimpleNamespace( + media_source=object(), _dense_action_offsets=lambda *args: [], max_dense_action_frames=24 + ) + _gather_universal_frames(analyzer, tmp_path / "video.mp4", 32.249, 92.249, 32.249) + offsets = sampling.call_args_list[0].args[1] + assert len(offsets) <= 30 + assert offsets[0] == 0 + assert offsets[-1] == pytest.approx(59.9) + + +@pytest.mark.asyncio +async def test_saved_demo_chunk_keeps_millisecond_coverage(monkeypatch, tmp_path): + from artemis.agents.video_analyzer.blackboard import VideoBlackboard + from artemis.agents.video_analyzer import chunk_runner + + board = VideoBlackboard( + "demo", db_path=tmp_path / "board.db", evidence_dir=tmp_path / "evidence" + ) + analyzer = SimpleNamespace( + max_retries=0, + ctx=SimpleNamespace(device=None), + blackboard=board, + model_name="test", + video_processing="static", + chunk_size_seconds=60, + _exec_single_chunk_universal=AsyncMock(return_value="actual summary"), + _parse_chunk_result=lambda _: ("已分析完整区间", "没有推断缺失操作"), + ) + monkeypatch.setattr(chunk_runner, "_prepare_chunk_media", AsyncMock(return_value=(None, 1))) + monkeypatch.setattr(chunk_runner, "_build_chunk_prompt", lambda *args: "query") + await chunk_runner.exec_single_chunk(analyzer, 92.249, 137.428, "query") + assert board.missing_intervals(92.249, 137.428, "query") == [] + + +@pytest.mark.asyncio +async def test_failed_render_removes_partial_temporary_clip_but_not_source(tmp_path, monkeypatch): + from artemis.exploration.media import SavedVideoSource + + original = tmp_path / "recording.mp4" + original.write_bytes(b"saved") + targets = [] + + async def render(*args, **kwargs): + target = args[3] + targets.append(target) + target.write_bytes(b"partial") + return False + + monkeypatch.setattr("artemis.exploration.media.render_timeline_clip", render) + source = SavedVideoSource( + tmp_path, + { + "id": "demo", + "kind": "android", + "segments": [{"file": original.name, "offset_ms": 0, "duration_ms": 1000}], + }, + ) + assert not (await source.extract_segment_metadata(0, 1)).success + assert not targets[0].parent.exists() + assert original.read_bytes() == b"saved" + + +def test_temporary_recording_cleanup_keeps_demonstration_playback(tmp_path): + from artemis.context import ArtemisContext + from artemis.data_engine.engine import DataEngine + from unittest.mock import MagicMock + from uuid import uuid4 + import time + + ctx = MagicMock(spec=ArtemisContext) + ctx.execution_setup = MagicMock(traces_path=str(tmp_path / "traces")) + ctx.device = None + engine = DataEngine(ctx) + sid = engine.start_session(goal="test") + keep = tmp_path / "demonstrations" + keep.mkdir() + demo = keep / "demo.mp4" + temporary = tmp_path / "temporary.mp4" + demo.write_bytes(b"saved") + temporary.write_bytes(b"temporary") + demo_id, temp_id = uuid4(), uuid4() + for vid, path in ((demo_id, demo), (temp_id, temporary)): + engine.record_video_start(vid, "device", path) + engine.record_video_stop(vid, "device", path, time.time() - 10, time.time()) + engine.storage.discard_session_recordings(str(sid), preserve_directory=keep) + assert engine.storage.get_video_recording(demo_id).local_video_path == str(demo) + assert engine.storage.get_video_recording(temp_id).local_video_path is None + assert engine.storage.get_session(sid).video_filepath == str(demo) diff --git a/tests/unit/test_exploration_exports.py b/tests/unit/test_exploration_exports.py new file mode 100644 index 0000000..5073f0b --- /dev/null +++ b/tests/unit/test_exploration_exports.py @@ -0,0 +1,50 @@ +from apps.admin_console.services.automation_store import AutomationStore +from apps.admin_console.services import exploration_evidence +from artemis.config.exploration import ExplorationConfig +from artemis.exploration.models import CaseDraft +from artemis.exploration.store import ExplorationStore + + +def test_archive_links_only_current_run_media_and_keeps_case_provenance(tmp_path, monkeypatch): + repository = ExplorationStore(AutomationStore(tmp_path / "history.db")) + session = repository.create("探索", ExplorationConfig(), {}, "create") + first = repository.reserve_run( + session["id"], "first", kind="exploration", goal="探索", task_config={} + ) + repository.finish_run(first["id"], {"status": "completed"}) + second = repository.reserve_run( + session["id"], "second", kind="exploration", goal="继续", task_config={} + ) + repository.finish_run(second["id"], {"status": "completed"}) + repository.save_case( + session["id"], + CaseDraft(name="用例", goal="已保存提示词"), + 0, + "save", + source_run_id=second["id"], + ) + for run in (first, second): + repository.database.put( + "exploration_demo", + run["id"], + { + "id": run["id"], + "exploration_id": session["id"], + "run_id": run["id"], + "media": [{"kind": "android", "segments": [{"file": "evidence/demo.mp4"}]}], + }, + ) + monkeypatch.setattr(exploration_evidence, "explorations", repository) + monkeypatch.setattr(exploration_evidence, "task_root", lambda _: tmp_path) + index = exploration_evidence.exploration_index( + second["id"], {(tmp_path / "evidence/demo.mp4").resolve(): "任务录屏/人工示范/demo.mp4"} + ) + old, current = index["demonstrations"] + assert old["media"][0]["segments"][0]["file"] is None + assert current["media"][0]["segments"][0]["file"] == "任务录屏/人工示范/demo.mp4" + assert current["media"][0]["segments"][0]["trace_file"] == "evidence/demo.mp4" + assert index["cases"][0]["source_run_id"] == second["id"] + assert ( + repository.database.get("exploration_demo", second["id"])["media"][0]["segments"][0]["file"] + == "evidence/demo.mp4" + ) diff --git a/tests/unit/test_exploration_observation.py b/tests/unit/test_exploration_observation.py new file mode 100644 index 0000000..0cbfba6 --- /dev/null +++ b/tests/unit/test_exploration_observation.py @@ -0,0 +1,112 @@ +"""Unavailable devices park exploration; unexecuted guidance survives the pause.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from artemis.exploration import runtime +from artemis.graph.perception import perception_node +from artemis.graph.state import State +from artemis.runtime import task_control + + +class FakeLock: + device_id = "test-device" + description = "observation" + max_concurrency = None + concurrency_mode = "per_device" + ingress = "test" + lock_scope = None + + def __init__(self, *args, **kwargs): + self.released = False + + def release(self): + self.released = True + + def acquire(self, **kwargs): + pass + + +@pytest.mark.asyncio +async def test_timeout_parks_without_consuming_guidance_then_fresh_observation_resumes( + tmp_path, monkeypatch +): + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) + monkeypatch.setattr(task_control, "DeviceExecutionLock", FakeLock) + repository = Mock() + monkeypatch.setattr(runtime, "explorations", repository) + control = runtime.ExplorationControl("run", FakeLock()) + ctx = MagicMock() + ctx.exploration_id = "conversation" + ctx.exploration_run_id = "run" + ctx.task_control = control + ctx.on_task_pause = AsyncMock() + ctx.on_task_resume = AsyncMock() + ctx.data_engine.base_dir = tmp_path / "run" + ctx.data_engine.current_step_id = "step" + ctx.data_engine.get_image_path.return_value = tmp_path / "frame.jpg" + controller = SimpleNamespace( + get_screen_data=AsyncMock( + side_effect=[ + TimeoutError("设备屏幕观测超时(60 秒)"), + SimpleNamespace( + base64="ZHVtbXk=", elements=[], width=100, height=200, platform="android" + ), + SimpleNamespace( + base64="ZnJlc2g=", elements=[], width=100, height=200, platform="android" + ), + ] + ) + ) + monkeypatch.setattr("artemis.graph.perception.UnifiedMobileController", lambda _: controller) + monkeypatch.setattr("artemis.graph.perception.is_ocr_configured", lambda: False) + task_control.add_instruction("run", "打开浏览器后等我指导", instruction_id="guidance") + running = asyncio.create_task(perception_node(State(initial_goal="explore"), ctx)) + + async def wait_for_pause(): + while task_control.read_state("run").get("status") != "task_paused": + await asyncio.sleep(0.01) + + try: + await asyncio.wait_for(wait_for_pause(), 2) + assert control.pause_reason == "device_observation" + assert control.device_lock.released + assert task_control.list_instructions("run")[0]["status"] == "pending" + await asyncio.sleep(0.05) + assert controller.get_screen_data.await_count == 1 + assert not running.done() + repository.add_message.assert_called_once() + task_control.request_resume("run") + result = await asyncio.wait_for(running, 2) + assert result["injected_instruction"] == "打开浏览器后等我指导" + assert task_control.list_instructions("run")[0]["status"] == "consumed" + assert controller.get_screen_data.await_count == 3 + assert result["operator_raw_data"]["screenshot_b64"] == "ZnJlc2g=" + ctx.on_task_resume.assert_awaited_once() + assert task_control.read_state("run")["status"] == "running" + repository.finish_run.assert_not_called() + finally: + running.cancel() + await asyncio.gather(running, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("error", [TimeoutError("capture"), ValueError("bad implementation")]) +async def test_ordinary_pro_errors_are_not_parked(error): + ctx = SimpleNamespace(task_control=None) + controller = SimpleNamespace(get_screen_data=AsyncMock(side_effect=error)) + with pytest.raises(type(error)): + await runtime.observe_or_pause(ctx, controller) + controller.get_screen_data.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_exploration_programming_error_is_not_hidden(tmp_path, monkeypatch): + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) + ctx = SimpleNamespace(task_control=runtime.ExplorationControl("run", FakeLock())) + controller = SimpleNamespace(get_screen_data=AsyncMock(side_effect=ValueError("bad code"))) + with pytest.raises(ValueError, match="bad code"): + await runtime.observe_or_pause(ctx, controller) diff --git a/tests/unit/test_exploration_recovery.py b/tests/unit/test_exploration_recovery.py new file mode 100644 index 0000000..ce37c5e --- /dev/null +++ b/tests/unit/test_exploration_recovery.py @@ -0,0 +1,123 @@ +import os +import time +import subprocess +import sys +from unittest.mock import AsyncMock, Mock + +import pytest + +from apps.admin_console.services.automation_store import AutomationStore +from apps.admin_console.services.exploration_recovery import reconcile_explorations +from artemis.config.exploration import ExplorationConfig +from artemis.exploration.editorial import DiscussionReply, discuss +from artemis.exploration.store import ExplorationStore +from artemis.runtime import task_control + + +def test_worker_can_import_exploration_without_importing_web_server(): + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; from artemis.exploration.runtime import ExplorationControl; assert 'apps.admin_console.server' not in sys.modules", + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=30, + ) + assert result.returncode == 0, result.stderr + + +def test_web_and_exploration_share_the_same_queue_state(): + code = "from apps.admin_console import server; from apps.admin_console.services import task_queue_service, exploration_recovery; from apps.admin_console.routers import tasks; assert server.state is task_queue_service.state is exploration_recovery.state is tasks.state" + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, encoding="utf-8", timeout=30 + ) + assert result.returncode == 0, result.stderr + + +@pytest.fixture +def records(tmp_path, monkeypatch): + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path / "traces") + repo = ExplorationStore(AutomationStore(tmp_path / "history.db")) + session = repo.create("探索设置", ExplorationConfig(), {}, "create") + return repo, session + + +def test_recovery_preserves_live_paused_owner_and_real_verdict(records, monkeypatch): + repo, session = records + run = repo.reserve_run(session["id"], "start", kind="exploration", goal="设置", task_config={}) + repo.set_run_state(run["id"], "paused", reason="execution_budget") + monkeypatch.setattr( + "apps.admin_console.services.exploration_recovery.read_state", + lambda _: {"pid": os.getpid()}, + ) + reconcile_explorations(repo) + assert repo.get(session["id"])["status"] == "paused" + monkeypatch.setattr( + "apps.admin_console.services.exploration_recovery.read_worker_outcome", + lambda _: { + "task_status": "completed", + "test_result": {"status": "passed", "reason": "有证据"}, + }, + ) + reconcile_explorations(repo) + assert repo.get(session["id"])["active_run_id"] is None + assert repo.run(run["id"])["outcome"]["test_result"]["status"] == "passed" + + +def test_orphan_editorial_jobs_become_retryable_without_starting_tasks(records): + repo, session = records + for kind in ("exploration_generation", "exploration_discussion"): + repo.database.put( + kind, + "dead", + { + "id": "dead", + "status": "running", + "owner_pid": 0, + "lease_until": time.time() + 999, + "exploration_id": session["id"], + }, + ) + reconcile_explorations(repo) + assert repo.runs(session["id"]) == [] + assert repo.database.records("exploration_generation")[0]["status"] == "failed" + assert repo.database.records("exploration_discussion")[0]["status"] == "failed" + + +def test_validation_status_follows_actual_worker_without_creating_outcome(records, monkeypatch): + from artemis.exploration.models import CaseDraft + + repo, session = records + repo.save_case(session["id"], CaseDraft(name="设置", goal="检查设置"), 0, "save") + run = repo.reserve_run( + session["id"], "validate", kind="validation", goal="复跑", task_config={}, case_version=1 + ) + monkeypatch.setattr( + "apps.admin_console.services.exploration_recovery.read_state", + lambda _: {"pid": os.getpid(), "status": "running"}, + ) + reconcile_explorations(repo) + assert repo.run(run["id"])["status"] == "running" + assert repo.run(run["id"])["outcome"] is None + + +@pytest.mark.asyncio +async def test_completed_discussion_is_idempotent_and_never_admits_device_work( + records, monkeypatch +): + repo, session = records + message = repo.add_message(session["id"], "为什么选这个入口?", "m") + monkeypatch.setattr("artemis.exploration.editorial.editorial_context", Mock()) + model = Mock() + model.with_structured_output.return_value.ainvoke = AsyncMock( + return_value=DiscussionReply(reply="已观察到的入口在右上角;可编辑提示词。") + ) + monkeypatch.setattr("artemis.exploration.editorial.get_llm", Mock(return_value=model)) + await discuss(repo, session["id"], message) + await discuss(repo, session["id"], message) + assert len([m for m in repo.messages(session["id"]) if m["role"] == "assistant"]) == 1 + assert repo.runs(session["id"]) == [] + model.with_structured_output.return_value.ainvoke.assert_awaited_once() diff --git a/tests/unit/test_exploration_report_output.py b/tests/unit/test_exploration_report_output.py new file mode 100644 index 0000000..3dd472a --- /dev/null +++ b/tests/unit/test_exploration_report_output.py @@ -0,0 +1,39 @@ +"""The default SDK path must return the answer already prepared for review.""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from artemis.config import OutputConfig +from artemis.graph.state import State +from artemis.sdk.agent import Agent + + +@pytest.mark.asyncio +@pytest.mark.parametrize("outputter_enabled", [True, False]) +async def test_default_output_reuses_executor_report_without_another_model( + tmp_path, monkeypatch, outputter_enabled +): + synthesize = AsyncMock() + monkeypatch.setattr("artemis.sdk.agent.outputter", synthesize) + ctx = SimpleNamespace( + execution_setup=SimpleNamespace( + outputter=SimpleNamespace(enabled=outputter_enabled, force_synthesis=False), + disable_outputter=not outputter_enabled, + ) + ) + result_path = tmp_path / "result.json" + body = "# 调查结果\n双端检查已完成;未测功能不能记为通过。" + result = await Agent._extract_output( + object.__new__(Agent), + task_name="exploration", + ctx=ctx, + request=SimpleNamespace(llm_output_path=result_path), + output_config=OutputConfig(structured_output=None, output_description=None), + state=State(initial_goal="调查", execution_report=body), + ) + assert result == body + assert json.loads(result_path.read_text(encoding="utf-8")) == {"result": body} + synthesize.assert_not_called() diff --git a/tests/unit/test_exploration_service.py b/tests/unit/test_exploration_service.py new file mode 100644 index 0000000..4aeab29 --- /dev/null +++ b/tests/unit/test_exploration_service.py @@ -0,0 +1,322 @@ +from unittest.mock import AsyncMock + +import pytest +from pydantic import ValidationError + +from apps.admin_console.services.automation_store import AutomationStore +from apps.admin_console.services.exploration_service import ExplorationService +from artemis.config.exploration import ExplorationConfig +from artemis.exploration.models import ( + CaseBatchRequest, + CreateExploration, + CaseDraft, + ValidateCaseRequest, +) +from artemis.exploration.store import ExplorationStore +from artemis.runtime import task_control + + +@pytest.fixture +def service(tmp_path, monkeypatch): + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path / "traces") + return ExplorationService(ExplorationStore(AutomationStore(tmp_path / "history.db"))) + + +@pytest.mark.asyncio +async def test_admission_uses_shared_queue_with_independent_model(service, monkeypatch): + enqueue = AsyncMock(return_value={"status": "queued", "tasks": []}) + monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) + session = service.create(CreateExploration(goal="探索设置", request_id="create")) + await service.start(session["id"], "start") + request = enqueue.call_args.args[0] + assert request.profile == "pro" + assert request.runtime_overrides.model == "gpt-6.1-sol" + assert request.runtime_overrides.reasoning_effort == "high" + assert request.runtime_overrides.verification_level == "final" + assert request.conversation_id == session["id"] + first_id = request.session_id + await service.start(session["id"], "start") + assert enqueue.call_args.args[0].session_id == first_id + + +@pytest.mark.asyncio +async def test_validation_keeps_version_and_does_not_inherit_exploration_model( + service, monkeypatch +): + enqueue = AsyncMock(return_value={"status": "queued"}) + monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) + session = service.create( + CreateExploration( + goal="探索设置", + request_id="create", + task_config={"model": "expensive", "reasoning_effort": "max"}, + ) + ) + service.repository.save_case(session["id"], CaseDraft(name="设置", goal="复跑设置"), 0, "save") + await service.validate(session["id"], ValidateCaseRequest(request_id="validate", version=1)) + request = enqueue.call_args.args[0] + assert request.profile == "flash" + assert request.runtime_overrides.model is None + assert request.runtime_overrides.reasoning_effort is None + assert request.goal == "复跑设置" + + +@pytest.mark.asyncio +async def test_rejected_admission_releases_conversation_for_corrected_request(service, monkeypatch): + from fastapi import HTTPException + + monkeypatch.setattr( + "apps.admin_console.routers.tasks.run_task", + AsyncMock(side_effect=HTTPException(409, "设备锁定")), + ) + session = service.create(CreateExploration(goal="探索设置", request_id="create")) + with pytest.raises(HTTPException): + await service.start(session["id"], "start") + assert service.repository.get(session["id"])["active_run_id"] is None + assert service.repository.runs(session["id"])[0]["outcome"]["status"] == "rejected" + + +def test_retrying_answer_does_not_duplicate_guidance_or_answer_a_new_question(service): + session = service.create(CreateExploration(goal="探索设置", request_id="create")) + sid = session["id"] + run = service.repository.reserve_run( + sid, "run", kind="exploration", goal="探索", task_config={} + ) + first = service.repository.ask(run["id"], "第一个问题", "建议", "q1") + reply = service.message(sid, "可以", "answer") + consumed = task_control.consume_instructions(task_control.control_dir(run["id"])) + assert consumed["instruction"] == "可以" + second = service.repository.ask(run["id"], "第二个问题", "建议", "q2") + assert service.message(sid, "可以", "answer") == reply + assert first["id"] == reply["question_id"] + assert task_control.consume_instructions(task_control.control_dir(run["id"])) is None + assert service.repository.get(sid)["pending_question"] == second["id"] + + +@pytest.mark.asyncio +async def test_retrying_continue_after_run_ended_never_starts_another_run(service, monkeypatch): + session = service.create(CreateExploration(goal="探索", request_id="create")) + run = service.repository.reserve_run( + session["id"], "start", kind="exploration", goal="探索", task_config={} + ) + monkeypatch.setattr( + "apps.admin_console.services.exploration_service.read_state", + lambda _: {"status": "task_paused"}, + ) + first = await service.resume(session["id"], "continue") + service.repository.finish_run(run["id"], {"status": "completed"}) + assert await service.resume(session["id"], "continue") == first + assert len(service.repository.runs(session["id"])) == 1 + + +def test_validation_guidance_is_delivered_and_counted_once(service): + session = service.create(CreateExploration(goal="探索", request_id="create")) + service.repository.save_case(session["id"], CaseDraft(name="设置", goal="复跑设置"), 0, "save") + run = service.repository.reserve_run( + session["id"], "validate", kind="validation", goal="复跑", task_config={}, case_version=1 + ) + service.message(session["id"], "只检查页面,不更改设置", "guide") + service.message(session["id"], "只检查页面,不更改设置", "guide") + assert service.repository.run(run["id"])["user_guidance_count"] == 1 + assert ( + task_control.consume_instructions(task_control.control_dir(run["id"]))["instruction"] + == "只检查页面,不更改设置" + ) + + +def test_seal_rejects_unreviewed_guidance_then_refuses_silent_editorial_delivery(service): + from artemis.exploration.store import ExplorationConflict + + session = service.create(CreateExploration(goal="探索", request_id="create")) + run = service.repository.reserve_run( + session["id"], "run", kind="exploration", goal="探索", task_config={} + ) + service.message(session["id"], "先回到主页,再结束", "home") + assert not service.repository.seal_execution(run["id"], 0) + assert service.repository.seal_execution(run["id"], 1) + with pytest.raises(ExplorationConflict, match="指导尚未发送"): + service.message(session["id"], "再检查一次", "late") + assert service.repository.run(run["id"])["user_guidance_count"] == 1 + assert service.repository.messages(session["id"])[-1]["text"] == "先回到主页,再结束" + + +@pytest.mark.asyncio +async def test_continue_cannot_skip_an_unanswered_question(service): + from artemis.exploration.store import ExplorationConflict + + session = service.create(CreateExploration(goal="探索", request_id="create")) + run = service.repository.reserve_run( + session["id"], "run", kind="exploration", goal="探索", task_config={} + ) + service.repository.ask(run["id"], "下一步?", "返回主页", "ask") + with pytest.raises(ExplorationConflict, match="先回答"): + await service.resume(session["id"], "resume") + + +def test_validation_uses_saved_case_title_without_replacing_its_full_goal(service): + from apps.admin_console.services.exploration_listing import exploration_metadata + + session = service.create(CreateExploration(goal="探索设置", request_id="create")) + service.repository.save_case( + session["id"], CaseDraft(name="深色主题检查", goal="完整执行提示词"), 0, "save" + ) + run = service.repository.reserve_run( + session["id"], + "validate", + kind="validation", + goal="完整执行提示词", + task_config={}, + case_version=1, + ) + with service.repository.database.connection() as connection: + metadata = exploration_metadata(connection)[run["id"]] + assert metadata["display_name"] == "深色主题检查" + assert metadata["case_version"] == 1 + assert service.repository.run(run["id"])["goal"] == "完整执行提示词" + + +def test_execution_context_omits_duplicate_assistant_narration_but_retains_guidance(service): + session = service.create(CreateExploration(goal="探索设置", request_id="create")) + service.repository.add_message(session["id"], "正在读取页面", "narration", role="assistant") + service.repository.add_message( + session["id"], "下一步?", "question", role="assistant", metadata={"kind": "question"} + ) + service.repository.add_message(session["id"], "返回主页", "home") + texts = [ + m["text"] for m in service.repository.recent_messages(session["id"], guidance_only=True) + ] + assert texts == ["探索设置", "下一步?", "返回主页"] + assert len(service.repository.recent_messages(session["id"])) == 4 + + +def test_independent_saved_defaults_do_not_change_existing_sessions(service): + session = service.create(CreateExploration(goal="探索设置", request_id="create")) + service.save_settings(ExplorationConfig(model="gpt-6-astra", reasoning_effort="max")) + later = service.create(CreateExploration(goal="另一次探索", request_id="later")) + assert service.repository.get(session["id"])["config"]["model"] == "gpt-6.1-sol" + assert later["config"]["model"] == "gpt-6-astra" + + +def test_creation_rejects_secrets_and_unknown_task_options(service): + with pytest.raises(ValidationError): + service.create( + CreateExploration(goal="探索", request_id="create", task_config={"api_key": "secret"}) + ) + assert service.repository.list() == [] + + +@pytest.mark.asyncio +async def test_fast_worker_state_cannot_be_replaced_with_queued(service, monkeypatch): + async def enqueue(request): + service.repository.set_run_state(request.session_id, "running") + return {"status": "started"} + + monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) + session = service.create(CreateExploration(goal="探索设置", request_id="create")) + await service.start(session["id"], "start") + assert service.repository.get(session["id"])["status"] == "running" + + +@pytest.mark.asyncio +async def test_rerun_includes_user_edited_preconditions(service, monkeypatch): + enqueue = AsyncMock(return_value={"status": "queued"}) + monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) + session = service.create(CreateExploration(goal="探索", request_id="create")) + service.repository.save_case( + session["id"], + CaseDraft(name="用例", goal="观察设置", preconditions="已连接测试网络"), + 0, + "save", + ) + await service.validate(session["id"], ValidateCaseRequest(request_id="validate", version=1)) + assert "已连接测试网络" in enqueue.call_args.args[0].goal + + +@pytest.mark.asyncio +@pytest.mark.parametrize("field", ["goal", "preconditions"]) +async def test_unresolved_case_never_reserves_run_or_creates_batch(service, monkeypatch, field): + from apps.admin_console.services import automation_service + + enqueue = AsyncMock() + monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) + monkeypatch.setattr(automation_service, "store", service.repository.database) + session = service.create(CreateExploration(goal="探索", request_id="create")) + draft = {"name": "旧版用例", "goal": "检查页面", field: "报告保存到 ${报告位置}"} + service.repository.save_case(session["id"], CaseDraft(**draft), 0, "save") + with pytest.raises(ValueError, match=r"未填写参数.*\$\{报告位置\}"): + await service.validate(session["id"], ValidateCaseRequest(request_id="validate", version=1)) + with pytest.raises(ValueError, match="未填写参数"): + service.create_case_batch(session["id"], CaseBatchRequest(request_id="batch", version=1)) + enqueue.assert_not_called() + assert service.repository.runs(session["id"]) == [] + assert service.repository.database.records("batch") == [] + assert service.repository.case(session["id"])["version"] == 1 + + # A saved correction can use the same admission request ID; rejection created no run. + service.repository.save_case( + session["id"], + CaseDraft(name="可执行", goal="检查页面,报告保存到本次任务 trace"), + 1, + "edit", + ) + enqueue.return_value = {"status": "queued"} + await service.validate(session["id"], ValidateCaseRequest(request_id="validate", version=2)) + enqueue.assert_awaited_once() + + +@pytest.mark.parametrize("profile", ["flash", "pro"]) +def test_case_batch_creates_only_an_idempotent_draft_with_bound_resources( + service, monkeypatch, profile +): + from apps.admin_console.services import automation_service + + monkeypatch.setattr(automation_service, "store", service.repository.database) + session = service.create( + CreateExploration( + goal="探索设置", + request_id="create", + task_config={ + "device_serial": "test-device", + "execution_target": "android", + "model": "exploration-model", + "reasoning_effort": "max", + "timeout_seconds": 900, + }, + ) + ) + service.repository.save_case( + session["id"], + CaseDraft(name="设置", goal="观察设置", preconditions="已解锁"), + 0, + "save", + ) + request = CaseBatchRequest(request_id="batch", version=1, profile=profile) + batch = service.create_case_batch(session["id"], request) + assert service.create_case_batch(session["id"], request)["id"] == batch["id"] + assert batch["status"] == "draft" + jobs = service.repository.database.jobs(batch["id"]) + assert len(jobs) == 1 + assert jobs[0]["status"] == "pending" + case = jobs[0]["case"] + assert case["case_id"] == f"{session['id']}-v1" + assert case["category"] == "探索用例" + assert "已解锁" in case["goal"] + assert case["task_config"]["device_serial"] == "test-device" + assert case["task_config"]["execution_target"] == "android" + assert case["task_config"]["profile"] == profile + assert case["task_config"]["model"] is None + assert case["task_config"]["reasoning_effort"] is None + assert case["task_config"]["timeout_seconds"] is None + assert service.repository.runs(session["id"]) == [] + + +def test_stale_validation_guidance_never_targets_a_new_active_run(service): + from artemis.exploration.store import ExplorationConflict + + session = service.create(CreateExploration(goal="探索", request_id="create")) + run = service.repository.reserve_run( + session["id"], "run", kind="exploration", goal="探索", task_config={} + ) + with pytest.raises(ExplorationConflict, match="所选执行已结束"): + service.message(session["id"], "旧复跑的指导", "stale", expected_run_id="previous-run") + assert task_control.list_instructions(run["id"]) == [] diff --git a/tests/unit/test_exploration_store.py b/tests/unit/test_exploration_store.py new file mode 100644 index 0000000..e8b0502 --- /dev/null +++ b/tests/unit/test_exploration_store.py @@ -0,0 +1,200 @@ +from concurrent.futures import ThreadPoolExecutor + +import pytest +from pydantic import ValidationError + +from apps.admin_console.services.automation_store import AutomationStore +from artemis.config.exploration import ExplorationConfig +from artemis.exploration.models import CaseDraft, CreateExploration +from artemis.exploration.store import ExplorationConflict, ExplorationStore + + +@pytest.fixture +def repository(tmp_path): + return ExplorationStore(AutomationStore(tmp_path / "history.db")) + + +def create(repository, request_id="create-one"): + return repository.create("探索相机夜视设置并验证画面", ExplorationConfig(), {}, request_id) + + +def test_create_retry_is_idempotent_and_conflicting_request_is_rejected(repository): + first = create(repository) + assert create(repository)["id"] == first["id"] + assert len(repository.list()) == 1 + assert len(repository.messages(first["id"])) == 1 + with pytest.raises(ExplorationConflict): + repository.create("另一个目标", ExplorationConfig(), {}, "create-one") + assert len(repository.list()) == 1 + + +def test_parallel_web_mcp_messages_are_additive_and_cursor_is_stable(repository): + sid = create(repository)["id"] + with ThreadPoolExecutor(max_workers=6) as pool: + messages = list( + pool.map( + lambda i: repository.add_message(sid, f"指导 {i}", f"message-{i}", source="mcp"), + range(24), + ) + ) + assert len({m["sequence"] for m in messages}) == 24 + assert repository.get(sid)["message_count"] == 25 + first_page = repository.messages(sid, limit=10) + next_page = repository.messages(sid, after=first_page[-1]["sequence"]) + assert [m["sequence"] for m in first_page + next_page] == list(range(1, 26)) + previous = repository.add_message(sid, "指导 0", "message-0", source="web") + assert previous["source"] == "mcp" + assert repository.get(sid)["message_count"] == 25 + + +def test_parallel_start_reserves_only_one_actual_run(repository): + sid = create(repository)["id"] + + def reserve(_): + return repository.reserve_run( + sid, "run-one", kind="exploration", goal="探索", task_config={} + ) + + with ThreadPoolExecutor(max_workers=4) as pool: + runs = list(pool.map(reserve, range(8))) + assert len({r["id"] for r in runs}) == 1 + assert len(repository.runs(sid)) == 1 + with pytest.raises(ExplorationConflict): + repository.reserve_run(sid, "run-two", kind="exploration", goal="探索", task_config={}) + + +def test_case_versions_are_immutable_and_stale_edits_do_not_overwrite(repository): + sid = create(repository)["id"] + first = repository.save_case( + sid, CaseDraft(name="夜视", goal="打开夜视并核对画面"), 0, "case-one" + ) + assert ( + repository.save_case(sid, CaseDraft(name="夜视", goal="打开夜视并核对画面"), 0, "case-one") + == first + ) + second = repository.save_case( + sid, CaseDraft(name="夜视", goal="切换自动夜视并核对画面"), 1, "case-two" + ) + with pytest.raises(ExplorationConflict): + repository.save_case(sid, CaseDraft(name="过期", goal="旧编辑"), 1, "stale") + assert repository.case(sid, 1) == first + assert repository.case(sid) == second + assert repository.get(sid)["case_version"] == 2 + + +def test_validation_pins_case_version_and_actual_verdict(repository): + sid = create(repository)["id"] + repository.save_case(sid, CaseDraft(name="夜视", goal="核对夜视"), 0, "save") + with pytest.raises(ValueError, match="版本"): + repository.reserve_run( + sid, "bad-version", kind="validation", goal="复跑", task_config={}, case_version=2 + ) + run = repository.reserve_run( + sid, + "validation", + kind="validation", + goal="核对夜视", + task_config={"profile": "flash"}, + case_version=1, + ) + outcome = { + "status": "completed", + "test_result": {"status": "failed", "reason": "画面不符合预期"}, + } + repository.finish_run(run["id"], outcome) + assert repository.run(run["id"])["outcome"] == outcome + assert repository.run(run["id"])["case_version"] == 1 + assert repository.get(sid)["active_run_id"] is None + assert ( + repository.finish_run( + run["id"], {"status": "completed", "test_result": {"status": "passed"}} + )["outcome"] + == outcome + ) + + +def test_old_worker_cannot_change_new_run_state(repository): + sid = create(repository)["id"] + old = repository.reserve_run(sid, "old", kind="exploration", goal="探索", task_config={}) + repository.finish_run(old["id"], {"status": "interrupted"}) + new = repository.reserve_run(sid, "new", kind="exploration", goal="继续", task_config={}) + with pytest.raises(ExplorationConflict): + repository.set_run_state(old["id"], "running") + assert repository.get(sid)["active_run_id"] == new["id"] + + +def test_normal_exit_preserves_checker_verdict_and_incomplete_checks(repository): + sid = create(repository)["id"] + run = repository.reserve_run(sid, "start", kind="exploration", goal="探索", task_config={}) + verified = { + "task_status": "completed", + "tests": {"passed": 1, "inconclusive": 1}, + "test_result": { + "status": "inconclusive", + "reason": "缺少入口点击证据", + "evidence": ["搜索页可见"], + }, + } + repository.record_verdict(run["id"], verified) + result = repository.finish_run(run["id"], {"status": "completed", "test_result": None}) + assert result["outcome"] == {**verified, "status": "completed"} + assert "缺少入口点击证据" in repository.messages(sid)[-1]["text"] + + +def test_execution_budget_pause_does_not_finish_run_or_create_failed_verdict(repository): + sid = create(repository)["id"] + run = repository.reserve_run(sid, "run", kind="exploration", goal="探索", task_config={}) + repository.set_run_state(run["id"], "running", elapsed_seconds=10) + repository.set_run_state(run["id"], "paused", reason="execution_budget", elapsed_seconds=1800) + assert repository.run(run["id"])["outcome"] is None + assert repository.get(sid)["active_run_id"] == run["id"] + assert repository.get(sid)["elapsed_seconds"] == 1800 + repository.set_run_state(run["id"], "running", elapsed_seconds=1800) + repository.set_run_state(run["id"], "running", elapsed_seconds=1810) + assert repository.get(sid)["elapsed_seconds"] == 1810 + + +def test_questions_and_answers_keep_origin_and_order(repository): + sid = create(repository)["id"] + run = repository.reserve_run(sid, "run", kind="exploration", goal="探索", task_config={}) + question = repository.ask(run["id"], "预期需要自动切换吗?", "建议验证自动切换", "q1") + assert repository.get(sid)["pending_question"] == question["id"] + assert repository.ask(run["id"], "预期需要自动切换吗?", "建议验证自动切换", "q1") == question + answer = repository.add_message( + sid, "是", "answer", source="mcp", metadata={"question_id": question["id"]} + ) + assert answer["sequence"] == question["sequence"] + 1 + assert answer["question_id"] == question["id"] + + +def test_generated_case_cannot_borrow_another_sessions_evidence(repository): + sid = create(repository)["id"] + other = create(repository, "other")["id"] + run = repository.reserve_run(other, "run", kind="exploration", goal="探索", task_config={}) + repository.finish_run(run["id"], {"status": "completed"}) + with pytest.raises(ValueError, match="不属于"): + repository.save_case( + sid, + CaseDraft(name="用例", goal="目标"), + 0, + "case", + source_run_id=run["id"], + generated=True, + ) + assert repository.get(sid)["case_version"] == 0 + + +def test_exploration_config_defaults_and_rejects_credentials(): + config = ExplorationConfig() + assert (config.model, config.reasoning_effort, config.timeout_seconds) == ( + "gpt-6.1-sol", + "high", + 1800, + ) + assert config.demonstration_timeout_seconds == 900 + with pytest.raises(ValidationError): + ExplorationConfig.model_validate({"api_key": "not-allowed"}) + with pytest.raises(ValidationError): + ExplorationConfig(demonstration_timeout_seconds=60, demonstration_warning_seconds=60) + with pytest.raises(ValidationError): + CreateExploration(request_id="r", goal=" ") diff --git a/tests/unit/test_exploration_synthesis.py b/tests/unit/test_exploration_synthesis.py new file mode 100644 index 0000000..a2e33a3 --- /dev/null +++ b/tests/unit/test_exploration_synthesis.py @@ -0,0 +1,248 @@ +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from apps.admin_console.services.automation_store import AutomationStore +from artemis.config.exploration import ExplorationConfig +from artemis.exploration.models import CaseDraft +from artemis.exploration.store import ExplorationStore, ExplorationConflict +from artemis.exploration.synthesis import generate_case +from artemis.runtime import task_control + + +@pytest.mark.asyncio +async def test_editorial_never_consumes_device_guidance_or_restarts_recording(tmp_path): + from artemis.exploration.synthesis import _invoke_editorial + + directory = tmp_path / "instructions" + directory.mkdir() + pending = directory / "user.json" + pending.write_text('{"instruction":"return home"}', encoding="utf-8") + control = SimpleNamespace( + directory=tmp_path, checkpoint=AsyncMock(), run_interruptibly=AsyncMock() + ) + ctx = SimpleNamespace(task_control=control, on_task_resume=AsyncMock()) + model = SimpleNamespace(ainvoke=AsyncMock(return_value="case")) + assert await _invoke_editorial(ctx, model, []) == "case" + assert pending.exists() + control.checkpoint.assert_not_called() + control.run_interruptibly.assert_not_called() + ctx.on_task_resume.assert_not_called() + + +@pytest.fixture +def synthesis(tmp_path, monkeypatch): + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path / "traces") + repository = ExplorationStore(AutomationStore(tmp_path / "history.db")) + session = repository.create("打开设置并确认状态", ExplorationConfig(), {}, "create") + run = repository.reserve_run( + session["id"], "start", kind="exploration", goal="探索", task_config={} + ) + outcome = { + "status": "completed", + "test_result": {"status": "passed", "reason": "状态正确", "evidence": ["步骤3设置开启"]}, + } + repository.finish_run(run["id"], outcome) + root = task_control.control_dir(run["id"]) + root.mkdir(parents=True) + (root / "exploration-evidence.json").write_text( + json.dumps( + { + "plan": "已完成设置检查", + "outcome": outcome, + "steps": [{"step_number": 3, "summary": "设置开启"}], + } + ), + encoding="utf-8", + ) + model = Mock() + model.with_structured_output.return_value.ainvoke = AsyncMock( + return_value=CaseDraft( + name="设置检查", goal="打开设置,验证开关为开启。", evidence=["步骤3"] + ) + ) + monkeypatch.setattr("artemis.exploration.synthesis.get_llm", Mock(return_value=model)) + return repository, session, run, model + + +@pytest.mark.asyncio +async def test_generation_is_idempotent_and_preserves_rerun_independence(synthesis): + repository, session, run, model = synthesis + ctx = SimpleNamespace(task_control=None) + result = await generate_case(repository, session["id"], "generate", run["id"], 0, ctx=ctx) + duplicate = await generate_case(repository, session["id"], "generate", run["id"], 0, ctx=ctx) + assert result == duplicate + assert result["version"] == 1 + assert result["source_run_id"] == run["id"] + assert "validation_status" not in result + assert len(repository.runs(session["id"])) == 1 + model.with_structured_output.return_value.ainvoke.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_synthesis_repairs_unbound_parameters_once_with_original_evidence(synthesis): + repository, session, run, model = synthesis + invoke = model.with_structured_output.return_value.ainvoke + invoke.side_effect = [ + CaseDraft(name="错误草稿", goal="访问 ${首页URL}", preconditions="已指定 ${报告位置}"), + CaseDraft(name="修订", goal="核对设置,报告保存在本次任务 trace。", evidence=["步骤3"]), + ] + result = await generate_case( + repository, session["id"], "generate", run["id"], 0, ctx=SimpleNamespace(task_control=None) + ) + assert result["version"] == 1 and "${" not in result["goal"] + assert invoke.await_count == 2 + messages = invoke.await_args.args[0] + assert "不生成 ${名称}" in messages[0].content + assert "用户明确指定的位置必须保留" in messages[0].content + assert "不要让一次复跑递归地产生下一份复跑用例" in messages[0].content + assert "保留该业务验收" in messages[0].content + assert "设置开启" in messages[1].content + assert "不得猜测未知业务输入或降低验收标准" in messages[-1].content + + +@pytest.mark.asyncio +async def test_failed_parameter_repair_never_saves_unexecutable_generated_case(synthesis): + repository, session, run, model = synthesis + repository.save_case(session["id"], CaseDraft(name="原用例", goal="核对设置"), 0, "save") + invoke = model.with_structured_output.return_value.ainvoke + invoke.return_value = CaseDraft(name="错误", goal="保存到 ${报告位置}") + with pytest.raises(ValueError, match="未填写参数"): + await generate_case( + repository, + session["id"], + "generate", + run["id"], + 1, + ctx=SimpleNamespace(task_control=None), + ) + assert invoke.await_count == 2 + assert repository.case(session["id"])["version"] == 1 + assert repository.case(session["id"])["name"] == "原用例" + assert repository.database.records("exploration_generation")[0]["status"] == "failed" + assert repository.run(run["id"])["outcome"]["test_result"]["status"] == "passed" + + +@pytest.mark.asyncio +async def test_model_generation_cannot_overwrite_a_concurrent_manual_revision(synthesis): + repository, session, run, model = synthesis + started, finish = asyncio.Event(), asyncio.Event() + + async def generate(_): + started.set() + await finish.wait() + return CaseDraft(name="模型", goal="模型改动") + + model.with_structured_output.return_value.ainvoke = generate + pending = asyncio.create_task( + generate_case( + repository, + session["id"], + "generate", + run["id"], + 0, + ctx=SimpleNamespace(task_control=None), + ) + ) + await started.wait() + repository.save_case(session["id"], CaseDraft(name="用户", goal="用户修订"), 0, "manual") + finish.set() + with pytest.raises(ExplorationConflict): + await pending + assert repository.case(session["id"])["goal"] == "用户修订" + assert repository.database.records("exploration_generation")[0]["status"] == "failed" + + +@pytest.mark.asyncio +async def test_interrupted_execution_cannot_generate_a_verified_case(synthesis): + repository, session, run, model = synthesis + stored = repository.run(run["id"]) + stored["outcome"]["status"] = "interrupted" + repository.database.put("exploration_run", run["id"], stored) + with pytest.raises(ExplorationConflict, match="尚未验证通过"): + await generate_case( + repository, + session["id"], + "generate", + run["id"], + 0, + ctx=SimpleNamespace(task_control=None), + ) + model.with_structured_output.return_value.ainvoke.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_synthesis_failure_leaves_execution_verdict_unchanged(synthesis): + repository, session, run, model = synthesis + model.with_structured_output.return_value.ainvoke = AsyncMock( + side_effect=ValueError("malformed response") + ) + with pytest.raises(ValueError): + await generate_case( + repository, + session["id"], + "generate", + run["id"], + 0, + ctx=SimpleNamespace(task_control=None), + ) + assert repository.run(run["id"])["outcome"]["test_result"]["status"] == "passed" + assert repository.case(session["id"]) is None + + +@pytest.mark.asyncio +async def test_context_initialization_failure_marks_generation_retryable(synthesis, monkeypatch): + repository, session, run, model = synthesis + monkeypatch.setattr( + "artemis.exploration.synthesis.get_default_llm_config", + Mock(side_effect=ValueError("bad config")), + ) + with pytest.raises(ValueError): + await generate_case(repository, session["id"], "generate", run["id"], 0) + assert repository.database.records("exploration_generation")[0]["status"] == "failed" + + +@pytest.mark.asyncio +async def test_long_path_compaction_covers_beginning_and_end(monkeypatch): + from artemis.exploration.synthesis import _complete_path, PathSummary + + seen = [] + + async def invoke(messages): + seen.append(messages[-1].content) + return PathSummary(verified_path="保留这一段路径及证据") + + model = Mock() + model.with_structured_output.return_value.ainvoke = invoke + monkeypatch.setattr("artemis.exploration.synthesis.get_llm", Mock(return_value=model)) + await _complete_path( + SimpleNamespace(task_control=None), + [{"step_number": i, "summary": "状态" * 200} for i in range(250)], + ) + assert '"step_number": 0,' in seen[0] + assert '"step_number": 249,' in seen[-1] + + +@pytest.mark.asyncio +async def test_expired_generation_cannot_save_after_recovery(synthesis): + repository, session, run, model = synthesis + + async def generate(_): + job = repository.database.records("exploration_generation")[0] + repository.database.put("exploration_generation", job["id"], {**job, "status": "failed"}) + return CaseDraft(name="过期生成", goal="不应写入") + + model.with_structured_output.return_value.ainvoke = generate + with pytest.raises(ExplorationConflict): + await generate_case( + repository, + session["id"], + "generate", + run["id"], + 0, + ctx=SimpleNamespace(task_control=None), + ) + assert repository.case(session["id"]) is None diff --git a/tests/unit/test_final_review_budget.py b/tests/unit/test_final_review_budget.py new file mode 100644 index 0000000..1f17b07 --- /dev/null +++ b/tests/unit/test_final_review_budget.py @@ -0,0 +1,245 @@ +import asyncio +from copy import deepcopy +import json +from types import SimpleNamespace + +from langchain_core.messages import AIMessageChunk, HumanMessage, ToolMessage +from langchain_core.tools import tool +import pytest + +from artemis.agents.checker import checker +from artemis.config.llm import LLM +from artemis.llm import final_review +from artemis.llm.diagnostics import ACTIVE_ATTEMPT +from artemis.llm.final_review import ( + ACTIVE_FINAL_REVIEW, + FinalReviewBudget, + FinalReviewBudgetExceeded, +) +from artemis.llm.reliability import LLMExhaustedError +from artemis.llm.router import ModelEndpoint +from artemis.services import llm + + +class RecordingEngine: + current_session_id = "review-test" + current_step_id = None + + def __init__(self): + self.records = [] + + def record_trace(self, **record): + self.records.append(deepcopy(record)) + + def _publish(self, *_): + pass + + def latest(self, kind): + return next(r["payload"] for r in reversed(self.records) if r["type"] == kind) + + +@pytest.fixture +def engine(monkeypatch, tmp_path): + engine = RecordingEngine() + monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine) + monkeypatch.setattr(llm.settings, "TRACES_PATH", str(tmp_path)) + monkeypatch.setattr(llm, "PAUSE_FILE", tmp_path / "pause") + monkeypatch.setattr( + llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 0, max_attempts=2) + ) + llm._ENDPOINT_BREAKER._states.clear() + yield engine + llm._ENDPOINT_BREAKER._states.clear() + assert ACTIVE_FINAL_REVIEW.get() is None + assert ACTIVE_ATTEMPT.get() is None + + +def budget(engine, *, timeout=1, retries=1): + return FinalReviewBudget( + engine, attempt_id="final#1", request_timeout=timeout, max_retries=retries, retry_delay=0 + ) + + +def test_budget_uses_effective_checker_config_and_respects_overall_cap(monkeypatch): + monkeypatch.setattr(llm.settings, "OPENAI_TIMEOUT_SECONDS", 300) + monkeypatch.setattr(llm.settings, "OPENAI_MAX_RETRIES", 5) + monkeypatch.setattr(llm.settings, "OPENAI_RETRY_INTERVAL_SECONDS", 10) + monkeypatch.setattr(llm.settings, "OPENAI_RATE_LIMIT_RETRY_SECONDS", 60) + cfg = LLM(model="test", timeout=20, max_retries=2) + ctx = SimpleNamespace(llm_config=SimpleNamespace(get_agent=lambda name: cfg), data_engine=None) + review = llm.final_review_budget(ctx, attempt_id="final#1") + assert review.total_seconds == 280 # three normal calls + two retries + waits/tool reads + assert review.data["request_timeout_seconds"] == 20 + assert review.data["max_retries"] == 2 + + cfg.timeout = 300 + cfg.max_retries = 100 + assert llm.final_review_budget(ctx, attempt_id="final#2").total_seconds == 3600 + + +@pytest.mark.asyncio +async def test_checker_retries_only_failed_request_with_existing_tool_evidence(engine, monkeypatch): + calls = [] + evidence_reads = 0 + stream_closed = asyncio.Event() + + @tool + async def read_evidence() -> str: + """Read the already-recorded page evidence.""" + nonlocal evidence_reads + evidence_reads += 1 + return "实际为中文搜索,预期为英文搜索" + + class ScriptedModel: + def bind_tools(self, *args, **_): + return self + + async def astream(self, messages, **_): + calls.append(deepcopy(messages)) + if len(calls) == 1: + yield AIMessageChunk( + content="", + tool_calls=[{"name": "read_evidence", "args": {}, "id": "read-1"}], + ) + elif len(calls) == 2: + try: + yield AIMessageChunk(content='{"test_result":{"status":"passed","reason":"') + await asyncio.Event().wait() # the *request* timer triggers a retry + finally: + stream_closed.set() + else: + yield AIMessageChunk( + content=json.dumps( + { + "test_result": { + "status": "failed", + "reason": "中文搜索不满足英文预期", + "evidence": ["read_evidence:实际为中文搜索"], + } + } + ) + ) + + wrapper = llm.RobustChatModelWrapper( + ScriptedModel(), + endpoint=ModelEndpoint(model_name="test", timeout_seconds=0.02, max_retries=1), + ) + monkeypatch.setattr(checker, "get_llm", lambda **_: wrapper) + monkeypatch.setattr(checker, "trace_langchain_tool", lambda tool, ctx: tool) + events = [] + monkeypatch.setattr(llm, "_record_llm_event", lambda *args, **kwargs: events.append(args)) + ctx = SimpleNamespace(execution_setup=SimpleNamespace(checker_max_iterations=4)) + review = budget(engine) + report = await review.run( + lambda: checker._run_check_loop( + ctx, [HumanMessage(content="核实英文搜索")], [read_evidence], [] + ) + ) + + assert report.test_result.status == "failed" # partial `passed` was never a verdict + assert evidence_reads == 1 + assert len(calls) == 3 + assert calls[1] == calls[2] + assert any(isinstance(m, ToolMessage) and "中文搜索" in m.content for m in calls[2]) + assert stream_closed.is_set() + assert any(e[0] == "llm_stream_reset" and e[1]["action"] == "discard" for e in events) + stored = engine.latest("final_review_diagnostic") + assert stored["status"] == "completed" + assert stored["request_count"] == 2 + assert stored["retry_count"] == 1 + assert stored["last_failure_category"] == "timeout" + assert engine.latest("llm_diagnostic")["final_review"]["review_id"] == stored["review_id"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("phase", ["request", "retry_wait", "evidence"]) +async def test_total_budget_expiry_is_not_user_cancellation_or_a_new_retry( + engine, monkeypatch, tmp_path, phase +): + monkeypatch.setattr(final_review, "MAX_FINAL_REVIEW_SECONDS", 0.02) + monkeypatch.setattr( + llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 60, max_attempts=6) + ) + cancelled = asyncio.Event() + calls = 0 + + async def request(): + nonlocal calls + calls += 1 + if phase == "retry_wait": + raise ConnectionError("connection reset") + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + review = budget(engine, retries=5) + + async def operation(): + if phase == "evidence": + return await request() + return await llm._run_with_recovery(request, request_timeout=1, max_retries=5) + + with pytest.raises(FinalReviewBudgetExceeded, match="总预算已用尽"): + await review.run(operation) + assert calls == 1 + assert not (tmp_path / "pause").exists() + assert engine.latest("final_review_diagnostic")["status"] == "budget_exhausted" + if phase != "retry_wait": + assert cancelled.is_set() + if phase != "evidence": + stored = engine.latest("llm_diagnostic") + assert stored["phase"] == "failed" + assert stored["category"] == "final_review_budget_exhausted" + + +@pytest.mark.asyncio +async def test_exhausted_final_review_request_ends_without_hidden_pause( + engine, monkeypatch, tmp_path +): + calls = 0 + + async def fails(): + nonlocal calls + calls += 1 + raise TimeoutError() + + pause = [] + monkeypatch.setattr(llm, "_handle_llm_pause_and_resume", lambda *_: pause.append(True)) + with pytest.raises(LLMExhaustedError): + await budget(engine).run( + lambda: llm._run_with_recovery(fails, request_timeout=1, max_retries=1) + ) + assert calls == 2 + assert not pause + assert not (tmp_path / "pause").exists() + assert engine.latest("final_review_diagnostic")["status"] == "request_retries_exhausted" + + +@pytest.mark.asyncio +async def test_user_cancellation_closes_review_and_child_without_retry(engine): + started = asyncio.Event() + cancelled = asyncio.Event() + calls = 0 + + async def request(): + nonlocal calls + calls += 1 + started.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + task = asyncio.create_task( + budget(engine).run( + lambda: llm._run_with_recovery(request, request_timeout=1, max_retries=5) + ) + ) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert calls == 1 and cancelled.is_set() + assert engine.latest("final_review_diagnostic")["status"] == "cancelled" + assert engine.latest("llm_diagnostic")["category"] == "cancelled" diff --git a/tests/unit/test_installation_update.py b/tests/unit/test_installation_update.py index 0ec1b89..ef4270d 100644 --- a/tests/unit/test_installation_update.py +++ b/tests/unit/test_installation_update.py @@ -112,6 +112,7 @@ command = "old-python" cwd = {json.dumps(str(root))} args = ["-m", "mcp_server"] enabled = false +required = true enabled_tools = [ "mobile_run_task", ] @@ -127,10 +128,11 @@ command = "leave-alone" data = tomllib.loads(path.read_text(encoding="utf-8")) config = data["mcp_servers"]["artemis"] assert config["enabled"] is False + assert config["required"] is False assert config["disabled_tools"] == ["mobile_config"] assert config["tool_timeout_sec"] == 900 assert config["env"]["OPENAI_API_KEY"] == "test-only-secret" - assert config["enabled_tools"] == ["mobile_run_task", "mobile_batch"] + assert config["enabled_tools"] == ["mobile_run_task"] assert data["mcp_servers"]["other"]["command"] == "leave-alone" assert not update.refresh_codex(path, root, "new-python", ("mobile_run_task", "mobile_batch")) assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original @@ -176,7 +178,7 @@ def test_codex_sync_preserves_tool_policies_verbatim(tmp_path, monkeypatch, poli "PYTHONUNBUFFERED": "1", "ARTEMIS_DESKTOP_NOTIFY": "true", } - assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS) + assert entry["enabled_tools"] == ["mobile_run_task"] assert entry["disabled_tools"] == ["mobile_config"] assert entry["tools"] == before["mcp_servers"]["artemis"]["tools"] assert policies.split("\n", 1)[1] in refreshed @@ -197,7 +199,10 @@ def test_codex_sync_reports_generated_toml_error_without_blame_or_overwrite( path.write_text(original, encoding="utf-8") monkeypatch.setenv("CODEX_HOME", str(codex_home)) monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN") - monkeypatch.setattr(update, "_replace_toml_tables", lambda *_: 'tools = {"secret": true}\n') + monkeypatch.setattr( + "artemis.integrations.codex_mcp._replace_toml_tables", + lambda *_: 'tools = {"secret": true}\n', + ) assert update.refresh_mcp(tmp_path / "project") == 1 assert path.read_text(encoding="utf-8") == original assert not path.with_name(path.name + ".artemis-update.bak").exists() @@ -250,7 +255,7 @@ def test_mcp_sync_updates_only_codex_and_preserves_other_clients(tmp_path, monke assert entry["enabled"] is False assert entry["env"]["OPENAI_API_KEY"] == "keep-secret" assert entry["env"]["PYTHONPATH"] == str(root) - assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS) + assert entry["enabled_tools"] == ["mobile_run_task", "retired_tool"] cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8")) assert "artemis" not in cursor["mcpServers"] assert cursor["mcpServers"]["other"] == {"command": "keep"} @@ -329,9 +334,9 @@ def test_codex_sync_reports_missing_directory_without_touching_other_clients( assert update.refresh_mcp(tmp_path / "project") == 0 output = capsys.readouterr().out assert str(tmp_path / ".codex/config.toml") in output - assert "未发现 Codex 配置目录" in output - assert "--install codex" in output - assert not (tmp_path / ".codex").exists() + assert "已安装" in output + assert (tmp_path / ".codex/config.toml").is_file() + assert (tmp_path / ".agents/skills/artemis-use/SKILL.md").is_file() def test_custom_codex_command_is_preserved_and_reported(tmp_path, monkeypatch, capsys): diff --git a/tests/unit/test_llm_gateway.py b/tests/unit/test_llm_gateway.py index b21f9cb..bcf01db 100644 --- a/tests/unit/test_llm_gateway.py +++ b/tests/unit/test_llm_gateway.py @@ -79,6 +79,28 @@ async def test_gpt_stream_uses_adapted_messages_without_mutating_history(): assert isinstance(original[0], SystemMessage) +@pytest.mark.asyncio +async def test_consumer_error_closes_live_model_stream_before_returning(): + closed = [] + + class StreamingModel: + async def astream(self, *args, **kwargs): + try: + yield AIMessageChunk(content="partial") + finally: + closed.append(True) + + wrapper = RobustChatModelWrapper(StreamingModel()) + + def failing_consumer(*_): + raise RuntimeError("output sink closed") + + wrapper._emit_stream_delta = failing_consumer + with pytest.raises(RuntimeError, match="output sink closed"): + await wrapper._complete_attempt(True, []) + assert closed == [True] + + @pytest.mark.asyncio async def test_auth_error_fails_fast_without_retry_or_pause(tmp_path): class AuthFails: @@ -193,6 +215,40 @@ async def test_with_fallback_refuses_to_hide_bad_requests(): assert calls["fallback"] == 0 +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["ainvoke", "complete"]) +async def test_streamed_tool_pairing_error_does_not_retry_pause_or_fallback(monkeypatch, method): + import httpx + from openai import APIError + from unittest.mock import AsyncMock + + error = APIError( + "No tool call found for function call output with call_id call_test.", + request=httpx.Request("POST", "https://example.test/responses"), + body=None, + ) + calls = [] + + class BrokenHistory: + async def ainvoke(self, *_args, **_kwargs): + calls.append("invoke") + raise error + + async def astream(self, *_args, **_kwargs): + calls.append("stream") + raise error + yield # Make this a stream whose error arrives before any chunks. + + pause, fallback = AsyncMock(), AsyncMock() + monkeypatch.setattr(llm_service, "_wait_for_resume", pause) + wrapper = RobustChatModelWrapper(BrokenHistory()) + with pytest.raises(LLMPermanentError): + await with_fallback(lambda: getattr(wrapper, method)([]), fallback) + assert len(calls) == 1 + pause.assert_not_called() + fallback.assert_not_called() + + @pytest.mark.asyncio async def test_acomplete_structured_corrective_reask(): class CorrectsItself: @@ -263,7 +319,8 @@ async def test_mid_stream_failure_records_stream_reset_payload(monkeypatch): assert payload["action"] == "discard" assert payload["reason"] == "mid_stream_failure" assert "stream_exec_id" in payload - assert "lower API priority" in payload["message"] + assert "未完成的输出已丢弃" in payload["message"] + assert "lower API priority" not in payload["message"] @pytest.mark.asyncio diff --git a/tests/unit/test_llm_stream_guard.py b/tests/unit/test_llm_stream_guard.py new file mode 100644 index 0000000..222ba0f --- /dev/null +++ b/tests/unit/test_llm_stream_guard.py @@ -0,0 +1,141 @@ +"""Runaway click arguments must never reach the executor or endless retries.""" + +from copy import deepcopy +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from langchain_core.messages import AIMessageChunk +import pytest + +from artemis.llm.reliability import FailureCategory, LLMExhaustedError, retry_policy_for +from artemis.llm.router import ModelEndpoint +from artemis.llm.stream_guard import ( + SMALL_ACTION_ARGUMENT_LIMIT, + ToolArgumentGuard, + ToolArgumentOverflow, +) +from artemis.services import llm + + +def delta(arguments, *, name=None, index=0): + return AIMessageChunk( + content=[], + tool_call_chunks=[{"name": name, "args": arguments, "index": index}], + ) + + +@pytest.fixture(autouse=True) +def isolate(monkeypatch, tmp_path): + llm._ENDPOINT_BREAKER._states.clear() + llm._NON_STREAMING_ENDPOINTS.clear() + monkeypatch.setattr(llm, "PAUSE_FILE", tmp_path / "pause") + monkeypatch.setattr( + llm, "retry_policy_for", lambda _: SimpleNamespace(max_attempts=2, delay_for=lambda _: 0) + ) + monkeypatch.setattr(llm, "_wait_for_resume", AsyncMock(return_value=False)) + monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", None) + + +@pytest.mark.parametrize("name", ["save_note", "input_text", "report_task_status", "plugin_tool"]) +def test_free_text_tools_and_regular_text_are_not_limited(name): + guard = ToolArgumentGuard() + text = "报告内容" * SMALL_ACTION_ARGUMENT_LIMIT + guard.observe(AIMessageChunk(content=text)) + guard.observe(delta(json.dumps({"text": text}, ensure_ascii=False), name=name)) + + +def test_split_and_interleaved_arguments_are_counted_per_call_without_storing_body(): + guard = ToolArgumentGuard() + for index in (0, 1): + guard.observe(delta('{"target_description":"', name="click", index=index)) + for index in (0, 1): + guard.observe(delta("private" * 1200, index=index)) + with pytest.raises(ToolArgumentOverflow) as caught: + guard.observe(delta("private" * 1200, index=1)) + assert caught.value.details["tool"] == "click" + assert caught.value.details["argument_chars"] > SMALL_ACTION_ARGUMENT_LIMIT + assert "private" not in str(caught.value) + assert "private" not in repr(guard.calls) + + +def test_name_arriving_after_arguments_still_triggers_guard(): + guard = ToolArgumentGuard() + guard.observe(delta("x" * (SMALL_ACTION_ARGUMENT_LIMIT + 1))) + with pytest.raises(ToolArgumentOverflow): + guard.observe(delta("", name="click")) + + +def test_invalid_output_retry_policy_is_bounded_and_honors_zero_retries(monkeypatch): + monkeypatch.setattr(llm.settings, "OPENAI_MAX_RETRIES", 5) + assert retry_policy_for(FailureCategory.INVALID_OUTPUT).max_attempts == 2 + monkeypatch.setattr(llm.settings, "OPENAI_MAX_RETRIES", 0) + assert retry_policy_for(FailureCategory.INVALID_OUTPUT).max_attempts == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("recover", [False, True]) +@pytest.mark.parametrize("first_chunk_overflows", [False, True]) +async def test_gateway_closes_bad_stream_discards_partial_and_bounds_retry( + monkeypatch, recover, first_chunk_overflows +): + records, resets, closed = [], [], [] + + class Engine: + current_session_id = "test-session" + current_step_id = None + + def record_trace(self, **record): + records.append(deepcopy(record)) + + def _publish(self, *_): + pass + + class Model: + calls = 0 + + async def astream(self, *_args, **_kwargs): + self.calls += 1 + try: + if recover and self.calls == 2: + yield delta('{"target":3}', name="click") + return + if not first_chunk_overflows: + yield delta('{"target_description":"', name="click") + yield delta( + "private" * (SMALL_ACTION_ARGUMENT_LIMIT // 7 + 1), + name="click" if first_chunk_overflows else None, + ) + pytest.fail("Runaway stream was consumed after the safety bound") + finally: + closed.append(self.calls) + + monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", Engine()) + monkeypatch.setattr(llm, "_record_llm_event", lambda *a, **_: resets.append(a)) + model = Model() + wrapper = llm.RobustChatModelWrapper(model, endpoint=ModelEndpoint(max_retries=5)) + if recover: + response = await wrapper.complete([]) + assert response.tool_calls == [ + {"name": "click", "args": {"target": 3}, "id": None, "type": "tool_call"} + ] + llm._wait_for_resume.assert_not_awaited() + else: + with pytest.raises(LLMExhaustedError) as caught: + await wrapper.complete([]) + assert caught.value.failure.category is FailureCategory.INVALID_OUTPUT + llm._wait_for_resume.assert_awaited_once() + assert model.calls == 2 + assert closed == [1, 2] + failure = next( + r["payload"] + for r in records + if r["type"] == "llm_diagnostic" and r["payload"]["phase"] == "failed" + ) + assert failure["stream_guard"]["tool"] == "click" + assert failure["stream_guard"]["argument_chars"] > SMALL_ACTION_ARGUMENT_LIMIT + assert failure["failure_phase"] == "generating_tool_arguments" + assert failure["category"] == "invalid_output" + assert failure["usage"] is None + assert "private" not in json.dumps(records + resets, default=str) + assert any(name == "llm_stream_reset" and p["action"] == "discard" for name, p in resets) diff --git a/tests/unit/test_memory_config.py b/tests/unit/test_memory_config.py index d737688..5a89a7e 100644 --- a/tests/unit/test_memory_config.py +++ b/tests/unit/test_memory_config.py @@ -19,6 +19,16 @@ import pytest from artemis.config import AgentGlobalConfig, load_agent_config +def test_transcript_budget_cannot_exceed_300k_context_ceiling(): + from pydantic import ValidationError + from artemis.config.agent import MemoryTranscriptConfig + + assert MemoryTranscriptConfig().context_budget_tokens == 80_000 + assert MemoryTranscriptConfig(context_budget_tokens=300_000).context_budget_tokens == 300_000 + with pytest.raises(ValidationError): + MemoryTranscriptConfig(context_budget_tokens=300_001) + + def test_memory_defaults(): cfg = AgentGlobalConfig.model_validate({}) assert cfg.flash.step_summarizer.timeout_seconds == 120.0 @@ -35,7 +45,7 @@ def test_memory_defaults(): assert cfg.memory.transcript.xml_scrub_depth == 1 # Ready chunks replace their turns only once the # operator's measured context reaches budget*start_ratio. - assert cfg.memory.chunking.min_steps == 3 + assert cfg.memory.chunking.min_steps == 6 assert cfg.memory.transcript.image_scrub_depth_relaxed == 6 assert cfg.memory.transcript.start_ratio == 0.35 assert cfg.memory.transcript.soft_ratio == 0.7 @@ -124,6 +134,8 @@ def test_xml_scrub_depth_positions_the_text_edge(): ledger.stage_turn([obs(1)]) ledger.commit_staged(step_key="s1") ledger.render([obs(2)]) + assert PRO_UI_LIST_MARKER in str(ledger.active_messages[0].content) + ledger.compact_active() # depth settings apply at a compaction boundary committed = ledger.active_messages[0] assert PRO_UI_LIST_MARKER not in str(committed.content) assert any(isinstance(b, dict) and b.get("type") == "image_url" for b in committed.content) diff --git a/tests/unit/test_openai_responses.py b/tests/unit/test_openai_responses.py index 21454e7..04af4a9 100644 --- a/tests/unit/test_openai_responses.py +++ b/tests/unit/test_openai_responses.py @@ -40,9 +40,9 @@ def text_output(text): @pytest.fixture def transport_model(monkeypatch): - import langchain_openai + from langchain_openai import ChatOpenAI + import openai - original = langchain_openai.ChatOpenAI requests = [] outputs = [] @@ -52,16 +52,16 @@ def transport_model(monkeypatch): 200, json=response_body(outputs.pop(0) if outputs else text_output("OK")) ) - client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) monkeypatch.setattr( - langchain_openai, - "ChatOpenAI", - lambda **kwargs: original(**kwargs, http_async_client=client), + openai, + "DefaultAsyncHttpxClient", + lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handle), **kwargs), ) monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("test-secret")) monkeypatch.setattr(settings, "OPENAI_BASE_URL", "https://responses.example/v1") monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False) model = ModelFactory.create_model(ModelEndpoint(model_name="test-model", timeout_seconds=23)) + assert isinstance(model, ChatOpenAI) return model, requests, outputs @@ -284,7 +284,10 @@ def test_video_text_fallback_extracts_responses_blocks(): @pytest.mark.asyncio -async def test_wrapper_structured_output_uses_responses_function_call(transport_model): +@pytest.mark.parametrize("mode", ["parsed", "raw", "pipeline"]) +async def test_wrapper_structured_output_uses_responses_function_call( + transport_model, monkeypatch, mode +): class Verdict(BaseModel): ok: bool @@ -302,9 +305,96 @@ async def test_wrapper_structured_output_uses_responses_function_call(transport_ ] ) wrapper = RobustChatModelWrapper(model, endpoint=ModelEndpoint(model_name="test-model")) - result = await wrapper.with_structured_output(Verdict).ainvoke("Check") - assert result.ok is True + metered = [] + monkeypatch.setattr( + "artemis.services.llm.record_llm_usage", + lambda engine, response, **kw: metered.append(response), + ) + structured = wrapper.with_structured_output(Verdict, include_raw=mode == "raw") + if mode == "pipeline": + from langchain_core.runnables import RunnableLambda + + result = await (structured | RunnableLambda(lambda value: value.ok)).ainvoke("Check") + assert result is True + else: + result = await structured.complete("Check") + assert (result["parsed"] if mode == "raw" else result).ok is True + if mode == "raw": + assert result["parsing_error"] is None + assert result["raw"].tool_calls payload = json.loads(requests[0].content) assert requests[0].url.path == "/v1/responses" assert payload["tools"][0]["name"] == "Verdict" assert payload.get("text", {}).get("format", {}).get("type") != "json_schema" + from artemis.llm.usage import usage_from_message + + assert len(metered) == 1 + assert usage_from_message(metered[0])["total_tokens"] == 13 + + +@pytest.mark.asyncio +async def test_cache_key_is_stable_for_exploration_but_separates_roles( + transport_model, monkeypatch +): + from types import SimpleNamespace + + model, requests, _ = transport_model + engine = SimpleNamespace(current_session_id="run-1") + ctx = SimpleNamespace(data_engine=engine, exploration_id="exploration-1") + monkeypatch.setattr("artemis.services.llm.record_llm_usage", lambda *a, **k: None) + wrapper = RobustChatModelWrapper( + model, ctx, endpoint=ModelEndpoint(model_name="test-model"), role="operator" + ) + await wrapper.ainvoke([HumanMessage(content="first")]) + engine.current_session_id = "resumed-run-2" + await wrapper.ainvoke([HumanMessage(content="first"), HumanMessage(content="next")]) + wrapper.role = "checker" + await wrapper.ainvoke([HumanMessage(content="verify")]) + payloads = [json.loads(r.content) for r in requests] + assert payloads[0]["prompt_cache_key"] == payloads[1]["prompt_cache_key"] + assert payloads[1]["prompt_cache_key"] != payloads[2]["prompt_cache_key"] + assert "exploration-1" not in payloads[0]["prompt_cache_key"] + assert payloads[1]["input"][:1] == payloads[0]["input"] + for request, payload in zip(requests, payloads): + assert request.headers["thread-id"] == payload["prompt_cache_key"] + assert request.headers["session-id"] == payload["prompt_cache_key"] + assert request.headers["x-client-request-id"] + assert "extra_headers" not in payload + assert requests[0].headers["x-client-request-id"] != requests[1].headers["x-client-request-id"] + + +@pytest.mark.asyncio +async def test_auxiliary_summary_calls_send_role_scoped_affinity_on_responses(transport_model): + from types import SimpleNamespace + + from artemis.agents.flash.summarizer import VisualStepSummarizer + from artemis.memory.chunking import StepCapsuleLens + + model, requests, outputs = transport_model + outputs.extend( + [text_output("我看到页面显示当前设置,尚未确认其他状态变化。") for _ in range(4)] + ) + ctx = SimpleNamespace(data_engine=SimpleNamespace(current_session_id="summary-task")) + lens = StepCapsuleLens(model_name="test-model", llm=model, ctx=ctx) + for text in ("first chunk", "next chunk"): + await lens._invoke(model, [HumanMessage(content=text)], "test-model") + visual = VisualStepSummarizer(ctx, model_name="test-model") + visual._llm = model + for number in (1, 2): + visual._step_inputs[number] = { + "step_number": number, + "action_name": "press_key", + "action_args": {}, + "pre_img_bytes": None, + "post_img_bytes": None, + "exec_outcome": "dispatched", + } + assert await visual._attempt(number) + keys = [] + for request in requests: + payload = json.loads(request.content) + assert request.url.path == "/v1/responses" + key = payload["prompt_cache_key"] + assert request.headers["thread-id"] == request.headers["session-id"] == key + keys.append(key) + assert len(keys) == 4 and keys[0] == keys[1] and keys[2] == keys[3] and keys[0] != keys[2] diff --git a/tests/unit/test_plugin_evidence.py b/tests/unit/test_plugin_evidence.py new file mode 100644 index 0000000..f61ffc9 --- /dev/null +++ b/tests/unit/test_plugin_evidence.py @@ -0,0 +1,396 @@ +"""Plugin contracts and evidence persistence, without devices or external services.""" + +import base64 +import hashlib +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +from uuid import uuid4 + +from mcp.types import CallToolResult, ImageContent, TextContent, Tool, ToolAnnotations +import pytest + +from artemis.plugins.evidence import ( + decode_receipt, + persist_attachments, + persist_receipt, + source_identity, + validate_tools, +) +from artemis.plugins.models import EvidenceSource, PluginCallResult, PluginManifest +from artemis.plugins.runtime import PluginRuntime + + +def manifest(**updates): + return PluginManifest.model_validate( + { + "schema_version": 2, + "id": "visual-test", + "name": "Visual fixture", + "version": "1.0.0", + "skill": "SKILL.md", + "observation": {"tool": "observe", "refresh_tools": ["observe"]}, + "capabilities": { + "kind": "visual", + "recording": "windows", + "tools": { + "observe": { + "effect": "read", + "observation": True, + "target_argument": "app", + } + }, + "required_tools": ["observe"], + }, + **updates, + } + ) + + +def observation_tool(*, readonly=True, properties=None): + return Tool( + name="observe", + inputSchema={ + "type": "object", + "properties": {"app": {}} if properties is None else properties, + }, + annotations=ToolAnnotations(readOnlyHint=readonly), + ) + + +def attachment(data=b"verified evidence", name="capture.png"): + return { + "name": name, + "data": base64.b64encode(data).decode(), + "sha256": hashlib.sha256(data).hexdigest(), + "media_type": "image/png", + } + + +def test_schema_one_stays_compatible_but_schema_two_requires_contract(): + legacy = manifest(schema_version=1, capabilities=None) + validate_tools(legacy, [observation_tool(readonly=False)]) + with pytest.raises(ValueError, match="capabilities"): + manifest(capabilities=None) + with pytest.raises(ValueError, match="schema_version=2"): + manifest(schema_version=1) + + +@pytest.mark.parametrize("effect,observation", [("write", True), ("read", False)]) +def test_auto_observation_requires_readonly_contract(effect, observation): + with pytest.raises(ValueError, match="只读"): + manifest( + capabilities={ + "kind": "visual", + "tools": {"observe": {"effect": effect, "observation": observation}}, + } + ) + + +def test_nonvisual_plugin_cannot_request_screen_recording(): + with pytest.raises(ValueError, match="视觉插件"): + manifest(observation=None, capabilities={"kind": "api", "recording": "windows"}) + + +def test_manifest_is_checked_against_actual_mcp_tools(): + validate_tools(manifest(), [observation_tool()]) + with pytest.raises(ValueError, match="只读声明"): + validate_tools(manifest(), [observation_tool(readonly=False)]) + with pytest.raises(ValueError, match="缺少目标参数"): + validate_tools(manifest(), [observation_tool(properties={})]) + with pytest.raises(ValueError, match="缺少:observe"): + validate_tools(manifest(), []) + with pytest.raises(ValueError, match="未声明:click"): + validate_tools(manifest(), [observation_tool(), Tool(name="click", inputSchema={})]) + + +def test_source_identity_invalidates_geometry_but_not_title_or_capture_time(): + source = EvidenceSource( + kind="computer", + target_id="123", + label="before", + captured_at=1, + width=800, + height=600, + window={"left": 0, "top": 0, "width": 800, "height": 600}, + ) + identity = source_identity("visual-test", source) + assert identity == source_identity( + "visual-test", source.model_copy(update={"label": "after", "captured_at": 2}) + ) + for change in ( + {"target_id": "456"}, + {"width": 400}, + {"window": {"left": 100, "top": 0, "width": 800, "height": 600}}, + ): + assert identity != source_identity("visual-test", source.model_copy(update=change)) + assert identity != source_identity("another-plugin", source) + + +@pytest.mark.parametrize( + "execution,retryable,observe", + [ + ("unknown", False, True), + ("partial", False, True), + ("completed", False, False), + ("not_started", True, False), + ], +) +def test_uncertain_writes_cannot_claim_to_be_retryable(execution, retryable, observe): + result = PluginCallResult( + status="error", + structured_content={ + "artemis": { + "version": 1, + "execution": execution, + "side_effect": "possible", + "retryable": True, + "error_code": "remote_failure", + } + }, + ) + decode_receipt(result) + assert result.execution == execution + assert result.retryable is retryable + assert result.requires_observation is observe + assert result.error_code == "remote_failure" + + +@pytest.mark.parametrize("raw", ["invalid", {"version": 99}]) +def test_rejects_unsupported_receipt_shapes(raw): + with pytest.raises(ValueError): + decode_receipt(PluginCallResult(status="success", structured_content={"artemis": raw})) + + +def test_attachments_are_read_back_and_never_overwrite_existing_evidence(tmp_path): + receipt = attachment() + saved = persist_attachments(tmp_path, "visual-test", [receipt])[0] + path = tmp_path / saved["path"] + assert path.read_bytes() == b"verified evidence" + assert saved["sha256"] == receipt["sha256"] + assert saved["bytes"] == len(b"verified evidence") + assert ( + persist_attachments( + tmp_path, "visual-test", [{k: v for k, v in receipt.items() if k != "data"}] + )[0] + == saved + ) + with pytest.raises(ValueError, match="不能覆盖"): + persist_attachments(tmp_path, "visual-test", [attachment(b"different")]) + assert path.read_bytes() == b"verified evidence" + + +def test_invalid_hash_does_not_publish_attachment(tmp_path): + receipt = attachment() + receipt["sha256"] = "0" * 64 + with pytest.raises(ValueError, match="校验失败"): + persist_attachments(tmp_path, "visual-test", [receipt]) + assert not (tmp_path / "artifacts/plugins/visual-test/capture.png").exists() + + +@pytest.mark.parametrize( + "name", + [ + "../outside.png", + "..\\outside.png", + "C:\\outside.png", + "file.png:stream", + "CON.png", + "LPT1", + "file.", + "file ", + "bad\nname", + ], +) +def test_attachment_names_cannot_escape_or_alias_windows_files(tmp_path, name): + with pytest.raises(ValueError, match="普通文件名"): + persist_attachments(tmp_path, "visual-test", [attachment(name=name)]) + + +def test_receipt_index_has_references_instead_of_binary_content(tmp_path): + result = PluginCallResult( + status="success", + call_id="call-1", + image_names=["image-1"], + content=[{"type": "image", "data": "large-binary"}], + structured_content={"artemis": {"large": "payload"}}, + ) + persist_receipt( + SimpleNamespace(data_engine=SimpleNamespace(base_dir=tmp_path)), + result, + {"app": "fixture"}, + 123, + ) + receipt = json.loads((tmp_path / "evidence/calls.jsonl").read_text(encoding="utf-8")) + assert receipt["call_id"] == "call-1" + assert receipt["image_names"] == ["image-1"] + assert receipt["started_at"] == 123 + assert "content" not in receipt + assert "structured_content" not in receipt + + +@pytest.mark.asyncio +async def test_receipt_disk_failure_preserves_successful_write_result(monkeypatch): + runtime = PluginRuntime.__new__(PluginRuntime) + runtime.ctx = None + runtime.bindings = { + "write": ( + "visual-test", + SimpleNamespace(name="write", inputSchema={"type": "object"}, annotations=None), + ) + } + runtime.packages = { + "visual-test": ( + SimpleNamespace(capabilities=None, observation=None, version="1.0.0"), + SimpleNamespace(argument_constraints={}, secrets={}), + None, + ) + } + session = SimpleNamespace( + call=AsyncMock( + return_value=CallToolResult( + content=[TextContent(type="text", text="changed")], isError=False + ) + ), + stop_uncertain=False, + ) + runtime.sessions = {"visual-test": session} + runtime.ensure_resources_ready = lambda: None + runtime._image_targets = {} + runtime.last_images = {} + runtime._observation_calls = {} + + def unavailable(*_args, **_kwargs): + raise OSError("disk full") + + monkeypatch.setattr("artemis.plugins.runtime.persist_receipt", unavailable) + outcome = await runtime.call("write", {}) + session.call.assert_awaited_once() + assert outcome.status == "success" + assert outcome.execution == "completed" + assert outcome.side_effect != "none" + + +@pytest.mark.asyncio +async def test_v2_observation_without_task_context_never_starts_recording(monkeypatch): + runtime = PluginRuntime.__new__(PluginRuntime) + runtime.ctx = None + runtime.bindings = {"observe": ("visual-test", observation_tool())} + runtime.packages = { + "visual-test": ( + manifest(), + SimpleNamespace(argument_constraints={}, secrets={}), + None, + ) + } + runtime.sessions = { + "visual-test": SimpleNamespace( + call=AsyncMock( + return_value=CallToolResult( + content=[ImageContent(type="image", mimeType="image/png", data="aW1hZ2U=")], + structuredContent={ + "artemis": { + "version": 1, + "execution": "completed", + "source": {"kind": "computer", "target_id": "123"}, + } + }, + ) + ), + stop_uncertain=False, + ) + } + runtime.ensure_resources_ready = lambda: None + runtime._image_targets = {} + runtime.last_images = {} + runtime._observation_calls = {} + runtime._recordings = None + + def unexpected_recorder(*_args): + pytest.fail("Recording requires an owning task trace") + + monkeypatch.setattr("artemis.plugins.recording.PluginRecordings", unexpected_recorder) + outcome = await runtime.call("observe", {"app": "fixture"}) + assert outcome.status == "success" + assert outcome.source.target_id == "123" + assert outcome.source_id.startswith("plugin:visual-test:") + assert runtime.last_images["visual-test"] == b"image" + assert runtime._recordings is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "target,pre_valid,cleared", + [ + ("computer", False, True), + ("computer", True, False), + ("android", False, True), + ("mixed", False, True), + ], +) +async def test_pro_cross_window_evidence_does_not_reuse_another_source_before_image( + tmp_path, monkeypatch, target, pre_valid, cleared +): + from artemis.agents.validator import execution_loop + from artemis.data_engine.engine import DataEngine + from artemis.data_engine.models import ImageRecord, SessionMetadata, StepRecord + from artemis.data_engine.storage import StorageManager + from apps.admin_console.services import report_export + + storage = StorageManager(tmp_path / "evidence.db", tmp_path) + session_id, step_id = uuid4(), uuid4() + storage.create_session(SessionMetadata(session_id=session_id, initial_goal="fixture")) + storage.create_image(ImageRecord(image_name="window-a")) + storage.create_image(ImageRecord(image_name="window-b")) + storage.create_step( + StepRecord(step_id=step_id, session_id=session_id, step_number=1, pre_image_name="window-a") + ) + engine = DataEngine.__new__(DataEngine) + engine.storage = storage + engine._run_in_background = lambda fn, *args: fn(*args) + engine.get_step_number = lambda _step_id: 1 + engine._get_generic_tools_for_sse = lambda _step_id: [] + engine._publish = Mock() + engine.record_trace = Mock() + engine.get_relative_time = lambda _timestamp: "0s" + ctx = SimpleNamespace(execution_target=target, data_engine=engine) + node = SimpleNamespace( + ctx=ctx, + _parse_decisions=lambda _: ([{"action": "plugin-click"}], None), + _get_mcp_session=AsyncMock(), + _get_initial_screenshot=AsyncMock(return_value=("old-screen", "window-a")), + ) + state = SimpleNamespace( + structured_decisions=None, current_step_id=str(step_id), open_incident=None + ) + outcome = execution_loop._ActionOutcome( + action_item={ + "action": "plugin-click", + "plugin_id": "visual-test", + "source_id": "window-b", + "post_image_name": "window-b", + "pre_image_valid": pre_valid, + }, + success=True, + post_screenshot_b64="new-screen", + ) + monkeypatch.setattr(execution_loop, "_process_action", AsyncMock(return_value=outcome)) + monkeypatch.setattr(execution_loop, "_absorb_post_screenshot", lambda *_args: "window-b") + await execution_loop.run_validation_loop(node, state) + step = storage.get_steps(session_id)[0].model_dump() + assert (step["pre_image_name"] is None) is cleared + if cleared: + assert step["post_image_name"] == "window-b" + assert engine._publish.call_args.args[1]["pre_image_name"] is None + before = tmp_path / "window-a.png" + after = tmp_path / "window-b.png" + before.write_bytes(b"a") + after.write_bytes(b"b") + monkeypatch.setattr( + report_export, + "image_path", + lambda name: {"window-a": before, "window-b": after}.get(name), + ) + exported = report_export.session_screenshots({"steps": [step]}) + assert [path for path, _name in exported] == [after] + assert all("操作前" not in name for _path, name in exported) diff --git a/tests/unit/test_plugin_recording.py b/tests/unit/test_plugin_recording.py new file mode 100644 index 0000000..cd48f73 --- /dev/null +++ b/tests/unit/test_plugin_recording.py @@ -0,0 +1,437 @@ +"""Recorder lifecycle and archive limits use fake processes, never a live desktop.""" + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from artemis.config.agent import RecordingConfig +from artemis.plugins.models import EvidenceSource +from artemis.plugins import recording +from artemis.plugins.recording import PluginRecordings, build_capture_command, capture_rectangle + + +def source(**updates): + return EvidenceSource( + **{ + "kind": "computer", + "target_id": "123", + "label": "Fixture window", + "width": 800, + "height": 600, + "window": {"left": 40, "top": 50, "width": 800, "height": 600}, + "screen": {"left": -1920, "top": 0, "width": 1920, "height": 1080}, + **updates, + } + ) + + +def context(root, *, enabled=True, **config): + return SimpleNamespace( + data_engine=SimpleNamespace(base_dir=root, session_start_time=100), + agent_config=SimpleNamespace(recording=RecordingConfig(**config)), + execution_setup=SimpleNamespace( + record_video_enabled=enabled, video_recording_tools_enabled=False + ), + ) + + +class FakeRecorderProcess: + def __init__(self, *, produces_frames=True): + self.pid = 123456 + self.returncode = None if produces_frames else 1 + self.stdout = asyncio.StreamReader() + if produces_frames: + self.stdout.feed_data(b"frame=1\n") + self.stdout.feed_eof() + self.stdin = SimpleNamespace(write=self._quit, drain=AsyncMock()) + self.killed = False + self.commands = [] + + def _quit(self, data): + self.commands.append(data) + if data == b"q\n": + self.returncode = 0 + + async def wait(self): + return self.returncode + + def kill(self): + self.killed = True + self.returncode = -9 + + +@pytest.fixture +def fake_capture(monkeypatch): + state = SimpleNamespace(commands=[], processes=[], fail_hevc=False) + + async def spawn(*command, **_kwargs): + state.commands.append(command) + encoder = command[command.index("-c:v") + 1] + process = FakeRecorderProcess( + produces_frames=not (state.fail_hevc and encoder.startswith("hevc")) + ) + state.processes.append(process) + Path(command[-1]).write_bytes(b"fake mp4") + return process + + local_asyncio = SimpleNamespace(**vars(asyncio)) + local_asyncio.create_subprocess_exec = spawn + monkeypatch.setattr(recording, "asyncio", local_asyncio) + monkeypatch.setattr(recording, "os", SimpleNamespace(name="nt", getpid=lambda: 123)) + monkeypatch.setattr( + recording, + "CaptureProcessJob", + lambda: SimpleNamespace(assign=lambda _pid: None, close=lambda: None), + ) + monkeypatch.setattr( + recording, "shutil", SimpleNamespace(disk_usage=lambda _path: SimpleNamespace(free=10**10)) + ) + monkeypatch.setattr(recording, "time", SimpleNamespace(time=lambda: 123.0)) + monkeypatch.setattr(recording, "get_ffmpeg_path", lambda: "fake-ffmpeg") + monkeypatch.setattr( + recording, + "probe_video_segment", + AsyncMock(return_value={"duration": 2.5, "width": 1280, "height": 720}), + ) + return state + + +def argument(command, option): + return command[command.index(option) + 1] + + +def test_software_capture_avoids_first_frame_lookahead_delay(tmp_path): + command = build_capture_command( + "ffmpeg", source(), RecordingConfig(), tmp_path / "clip.mp4", "libx264" + ) + assert argument(command, "-tune") == "zerolatency" + assert argument(command, "-b:v") == "1500k" + + +def test_archive_command_keeps_physical_capture_and_limits_encoded_output(tmp_path): + command = build_capture_command( + "ffmpeg", source(), RecordingConfig(), tmp_path / "video.mp4", "libx264" + ) + assert argument(command, "-offset_x") == "-1920" + assert argument(command, "-video_size") == "1920x1080" + assert argument(command, "-vf") == "scale=1280:720" + assert argument(command, "-framerate") == "15" + assert argument(command, "-b:v") == "1500k" + assert argument(command, "-g") == "30" + assert "-an" in command + + +def test_small_window_is_not_upscaled_and_hwnd_capture_is_explicit(tmp_path): + command = build_capture_command( + "ffmpeg", + source(), + RecordingConfig(computer_scope="window", codec="h265"), + tmp_path / "video.mp4", + "hevc_nvenc", + ) + assert argument(command, "--hwnd") == "123" + assert "artemis.plugins.window_capture" in command + assert argument(command, "-i") == "pipe:0" + assert "gdigrab" not in command and "desktop" not in command + assert argument(command, "-vf") == "scale=800:600" + assert argument(command, "-b:v") == "1000k" + assert argument(command, "-tag:v") == "hvc1" + assert "-offset_x" not in command + + +@pytest.mark.parametrize( + "area", + [ + None, + {"left": 0}, + {"left": 0, "top": 0, "width": 0, "height": 600}, + {"left": 65537, "top": 0, "width": 800, "height": 600}, + ], +) +def test_invalid_capture_geometry_is_not_sent_to_ffmpeg(area): + with pytest.raises(ValueError): + capture_rectangle(source(screen=area), "screen") + + +def test_window_recording_rejects_non_hwnd_targets(tmp_path): + with pytest.raises(ValueError, match="HWND"): + build_capture_command( + "ffmpeg", + source(target_id="window-title"), + RecordingConfig(computer_scope="window"), + tmp_path / "video.mp4", + "libx264", + ) + + +@pytest.mark.asyncio +async def test_disabled_recording_does_not_spawn_or_create_evidence(tmp_path, fake_capture): + recorder = PluginRecordings(context(tmp_path, enabled=False)) + await recorder.observe("source-1", "visual-test", source()) + await recorder.stop() + assert fake_capture.commands == [] + assert not (tmp_path / "evidence").exists() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "codec,hevc_playback,expected_attempts", + [("auto", False, 1), ("auto", True, 4), ("h265", False, 4)], +) +async def test_hardware_hevc_failure_falls_back_to_compatible_archive( + tmp_path, fake_capture, codec, hevc_playback, expected_attempts +): + fake_capture.fail_hevc = True + recorder = PluginRecordings(context(tmp_path, codec=codec, hevc_playback=hevc_playback)) + try: + await recorder.observe("source-1", "visual-test", source()) + assert len(fake_capture.commands) == expected_attempts + assert argument(fake_capture.commands[-1], "-c:v") == "libx264" + assert len(recorder.active) == 1 + finally: + await recorder.stop() + track = next(iter(recorder.sources.values())) + assert track["status"] == "ready" + assert len(track["segments"]) == 1 + assert track["segments"][0]["codec"] == "h264" + assert track["segments"][0]["duration_ms"] == 2500 + assert all(process.returncode is not None for process in fake_capture.processes) + + +@pytest.mark.asyncio +async def test_same_screen_reuses_recording_and_keeps_both_target_ids(tmp_path, fake_capture): + recorder = PluginRecordings(context(tmp_path)) + try: + await recorder.observe("window-1", "visual-test", source()) + await recorder.observe("window-2", "visual-test", source(target_id="456")) + assert len(fake_capture.commands) == 1 + finally: + await recorder.stop() + assert next(iter(recorder.sources.values()))["target_ids"] == ["window-1", "window-2"] + + +@pytest.mark.asyncio +async def test_low_disk_space_records_failure_without_starting_capture( + tmp_path, fake_capture, monkeypatch +): + monkeypatch.setattr(recording.shutil, "disk_usage", lambda _path: SimpleNamespace(free=1)) + recorder = PluginRecordings(context(tmp_path)) + await recorder.observe("source-1", "visual-test", source()) + await recorder.stop() + assert fake_capture.commands == [] + track = next(iter(recorder.sources.values())) + assert track["status"] == "failed" + assert "磁盘" in track["message"] + + +@pytest.mark.asyncio +async def test_index_write_failure_does_not_leave_capture_processes( + tmp_path, fake_capture, monkeypatch +): + recorder = PluginRecordings(context(tmp_path)) + + def unavailable(): + raise OSError("disk full") + + monkeypatch.setattr(recorder, "_save", unavailable) + await recorder.observe("source-1", "visual-test", source()) + await recorder.stop() + assert not recorder.active + assert all(process.returncode is not None for process in fake_capture.processes) + assert next(iter(recorder.sources.values()))["status"] == "failed" + + +@pytest.mark.asyncio +async def test_probe_failure_during_stop_is_recorded_and_capture_is_reaped( + tmp_path, fake_capture, monkeypatch +): + recorder = PluginRecordings(context(tmp_path)) + await recorder.observe("source-1", "visual-test", source()) + monkeypatch.setattr( + recording, "probe_video_segment", AsyncMock(side_effect=OSError("probe unavailable")) + ) + await recorder.stop() + assert not recorder.active + assert all(process.returncode is not None for process in fake_capture.processes) + track = next(iter(recorder.sources.values())) + assert track["status"] == "failed" + assert "probe unavailable" in track["message"] + + +@pytest.mark.asyncio +async def test_stop_retries_process_cleanup_interrupted_in_watchdog( + tmp_path, fake_capture, monkeypatch +): + recorder = PluginRecordings(context(tmp_path)) + await recorder.observe("source-1", "visual-test", source()) + recorder._watchdog.cancel() + await asyncio.gather(recorder._watchdog, return_exceptions=True) + key = next(iter(recorder.active)) + entered = asyncio.Event() + attempts = 0 + terminate = recorder._terminate + + async def interrupted_first(process): + nonlocal attempts + attempts += 1 + if attempts == 1: + entered.set() + await asyncio.Future() + await terminate(process) + + monkeypatch.setattr(recorder, "_terminate", interrupted_first) + recorder._watchdog = asyncio.create_task(recorder._finish(key)) + await entered.wait() + await recorder.stop() + assert attempts == 2 + assert not recorder.active + assert all(process.returncode is not None for process in fake_capture.processes) + + +@pytest.mark.asyncio +async def test_stop_preserves_clip_when_watchdog_is_cancelled_during_probe( + tmp_path, fake_capture, monkeypatch +): + recorder = PluginRecordings(context(tmp_path)) + await recorder.observe("source-1", "visual-test", source()) + recorder._watchdog.cancel() + await asyncio.gather(recorder._watchdog, return_exceptions=True) + key = next(iter(recorder.active)) + entered = asyncio.Event() + attempts = 0 + + async def interrupted_probe(_output): + nonlocal attempts + attempts += 1 + if attempts == 1: + entered.set() + await asyncio.Future() + return {"duration": 2.5, "width": 1280, "height": 720} + + monkeypatch.setattr(recording, "probe_video_segment", interrupted_probe) + recorder._watchdog = asyncio.create_task(recorder._finish(key)) + await entered.wait() + await recorder.stop() + assert attempts == 2 + assert not recorder.active + track = recorder.sources[key] + assert track["status"] == "ready" + assert track["segments"][0]["duration_ms"] == 2500 + + +@pytest.mark.asyncio +async def test_resumed_recorder_appends_and_does_not_overwrite_previous_clips( + tmp_path, fake_capture +): + first = PluginRecordings(context(tmp_path)) + await first.observe("source-1", "visual-test", source()) + await first.stop() + original = next(iter(first.sources.values()))["segments"][0].copy() + original_path = tmp_path / original["file"] + original_path.write_bytes(b"immutable original clip") + second = PluginRecordings(context(tmp_path)) + try: + await second.observe("source-1", "visual-test", source()) + finally: + await second.stop() + segments = next(iter(second.sources.values()))["segments"] + assert len(segments) == 2 + assert segments[0] == original + assert segments[1]["file"] != original["file"] + assert original_path.read_bytes() == b"immutable original clip" + manifest = json.loads( + (tmp_path / "evidence/recordings/recordings.json").read_text(encoding="utf-8") + ) + assert len(manifest["sources"][0]["segments"]) == 2 + + +@pytest.mark.asyncio +async def test_resized_window_seals_old_geometry_before_new_capture(tmp_path, fake_capture): + recorder = PluginRecordings(context(tmp_path, computer_scope="window")) + try: + await recorder.observe("source-1", "visual-test", source()) + await recorder.observe( + "source-2", + "visual-test", + source(window={"left": 40, "top": 50, "width": 1000, "height": 700}), + ) + assert len(fake_capture.commands) == 2 + assert fake_capture.processes[0].returncode is not None + assert len(recorder.active) == 1 + finally: + await recorder.stop() + assert len(recorder.sources) == 2 + assert all(track["status"] == "ready" for track in recorder.sources.values()) + + +@pytest.mark.asyncio +async def test_window_worker_starts_only_after_process_ownership( + tmp_path, fake_capture, monkeypatch +): + def assign(_pid): + assert fake_capture.processes[-1].commands == [] + + monkeypatch.setattr( + recording, "CaptureProcessJob", lambda: SimpleNamespace(assign=assign, close=lambda: None) + ) + recorder = PluginRecordings(context(tmp_path, computer_scope="window")) + try: + await recorder.observe("source", "visual-test", source()) + assert fake_capture.processes[0].commands == [b"start\n"] + finally: + await recorder.stop() + assert fake_capture.processes[0].commands == [b"start\n", b"q\n"] + + +@pytest.mark.asyncio +async def test_capture_error_keeps_partial_clip_but_never_marks_ready(tmp_path, fake_capture): + recorder = PluginRecordings(context(tmp_path, computer_scope="window")) + await recorder.observe("source", "visual-test", source()) + process, output, _, _ = next(iter(recorder.active.values())) + output.with_suffix(".log").write_text("capture stopped", encoding="utf-8") + process.returncode = 1 + await recorder.stop() + track = next(iter(recorder.sources.values())) + assert track["status"] == "failed" and "capture stopped" in track["message"] + assert output.exists() and track["segments"][0]["duration_ms"] == 2500 + + +@pytest.mark.asyncio +async def test_dead_worker_recovery_keeps_partial_clip_and_does_not_restart( + tmp_path, fake_capture, monkeypatch +): + recorder = PluginRecordings(context(tmp_path)) + await recorder.observe("source-1", "visual-test", source()) + await recorder.stop() + track = next(iter(recorder.sources.values())) + track.update(status="recording", owner_pid=987654321, owner_created_at=1) + track["segments"][0].update(duration=0, duration_ms=0) + recorder._save() + count = len(fake_capture.commands) + assert await recording.recover_interrupted_recordings(tmp_path) + recovered = PluginRecordings(context(tmp_path)) + result = next(iter(recovered.sources.values())) + assert result["status"] == "failed" + assert result["segments"][0]["duration"] == 2.5 + assert len(fake_capture.commands) == count + + +@pytest.mark.asyncio +async def test_live_worker_recovery_never_finalizes_active_capture(tmp_path, fake_capture): + import os + import psutil + + recorder = PluginRecordings(context(tmp_path)) + await recorder.observe("source-1", "visual-test", source()) + track = next(iter(recorder.sources.values())) + track.update(owner_pid=os.getpid(), owner_created_at=psutil.Process().create_time()) + recorder._save() + try: + assert not await recording.recover_interrupted_recordings(tmp_path) + assert track["status"] == "recording" + finally: + await recorder.stop() diff --git a/tests/unit/test_plugin_result_delivery.py b/tests/unit/test_plugin_result_delivery.py new file mode 100644 index 0000000..3f8edcc --- /dev/null +++ b/tests/unit/test_plugin_result_delivery.py @@ -0,0 +1,274 @@ +"""Cross-device results must reach the model and history on their own resource.""" + +import base64 +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langchain_openai import ChatOpenAI +from PIL import Image + +from artemis.context import ArtemisContext +from artemis.agents.validator.execution_loop import _process_action +from artemis.agents.validator.tool_declarations import ToolExecutionResult +from artemis.data_engine.engine import DataEngine +from artemis.data_engine.history_reader import OfflineHistoryReader +from artemis.data_engine.models import ImageRecord, SessionMetadata, StepRecord +from artemis.data_engine.storage import StorageManager +from artemis.memory.transcript import TranscriptLedger +from artemis.plugins.results import plugin_result_messages +from artemis.plugins.models import PluginCallResult +from artemis.plugins.runtime import PluginRuntime +from artemis.tools.history.screenshot import load_step_screenshot + + +def test_large_tree_deduplicates_without_mutating_raw_evidence(): + tree = "- Button abc\n" * 3000 + result = PluginCallResult( + status="success", + plugin_id="test", + content=[{"type": "text", "text": "Screenshot\n" + tree}], + structured_content={ + "tree_markdown": tree, + "capture_id": "capture-current", + "window_bounds": {"width": 2560}, + }, + ) + text = result.text() + assert text.count(tree) == 1 + assert result.structured_content["tree_markdown"] == tree + assert "capture-current" in text and "2560" in text + + +@pytest.mark.parametrize("mode", ["flash", "pro"]) +def test_verified_receipts_survive_observation_truncation_for_both_modes(mode): + receipt = { + "path": "artifacts/plugins/test/screen.png", + "sha256": "a" * 64, + "bytes": 123, + "media_type": "image/png", + } + result = PluginCallResult( + status="success", + plugin_id="test", + content=[{"type": "text", "text": "TREE " * 10000}], + attachments=[receipt], + ) + if mode == "flash": + blocks = PluginRuntime.content_blocks(result) + else: + action = _action("call", attachments=[receipt]) + action["result"] = result.text() + blocks = plugin_result_messages({"execution": [action]}, None)["call"][0].content + text = str(blocks) + assert receipt["path"] in text and receipt["sha256"] in text + assert "verified file receipts" in text + assert "观察文本已节选" in text + assert len(text) < 14000 + + +def test_plugin_claim_does_not_become_runtime_receipt(): + result = PluginCallResult( + status="success", + plugin_id="test", + structured_content={"attachments": [{"path": "pretend.png", "verified": True}]}, + ) + assert "verified file receipts" not in str(PluginRuntime.content_blocks(result)) + + +def _action(call_id, name="plug_test_get_app_state", **kwargs): + return dict( + action=name, + tool_call_id=call_id, + plugin_id="test", + plugin_status="success", + result=f"来源:电脑插件 / {call_id}\nAddress bar", + **kwargs, + ) + + +def _stage(ledger, actions): + ledger.stage_turn( + [ + AIMessage( + content="", + tool_calls=[ + {"name": a["action"], "id": a["tool_call_id"], "args": {}} for a in actions + ], + ), + *[ + ToolMessage(content="Action Recorded", tool_call_id=a["tool_call_id"]) + for a in actions + ], + ] + ) + + +@pytest.mark.parametrize("image_format", ["JPEG", "PNG", "WEBP"]) +def test_burst_results_reach_responses_once_with_images_before_compression(tmp_path, image_format): + image = tmp_path / "pc.jpg" + Image.new("RGB", (200, 100), "blue").save(image, format=image_format) + engine = SimpleNamespace(get_image_path=lambda _: image) + actions = [_action(f"call_{i}", post_image_name="pc") for i in range(4)] + # A long accessibility tree and the tail must both survive delivery. + actions[0]["result"] += " UI node" * 12000 + " END_OF_TREE" + actions[1]["plugin_status"] = "error" + actions[1]["result"] = "窗口已关闭,请重新观察。" + actions[2].pop("plugin_status") + actions[2].pop("result") + actions[2].pop("post_image_name") + actions[2]["attempts"] = ["Skipped"] + report = {"status": "failed", "execution": actions} + ledger = TranscriptLedger( + image_scrub_depth=1, pending_grace_steps=0, context_budget_tokens=300_000 + ) + _stage(ledger, actions) + ledger.commit_staged( + step_key="step1", + validator_result=report, + tool_results=plugin_result_messages(report, engine), + ) + messages = ledger.render([]) + outputs = [m for m in messages if isinstance(m, ToolMessage)] + assert [m.tool_call_id for m in outputs] == [f"call_{i}" for i in range(4)] + assert [m.status for m in outputs] == ["success", "error", "error", "success"] + assert "END_OF_TREE" in str(outputs[0].content) + assert "未完成或未执行" in str(outputs[2].content) + assert "Action Recorded" not in str(messages) + wire = ChatOpenAI(model="fixture", api_key="fixture", use_responses_api=True, store=False) + payload = wire._get_request_payload(messages)["input"] + assert len([item for item in payload if item.get("type") == "function_call_output"]) == 4 + assert str(payload).count(base64.b64encode(image.read_bytes()).decode()) == 3 + assert f"data:{Image.MIME[image_format]};base64," in str(payload) + # No duplicate outputs or rewritten images on the following ordinary turn. + ledger.commit_staged( + validator_result=report, tool_results=plugin_result_messages(report, engine) + ) + later = ledger.render([]) + assert len([m for m in later if isinstance(m, ToolMessage)]) == 4 + assert str(later).count(base64.b64encode(image.read_bytes()).decode()) == 3 + ledger.compact_active() + later = ledger.render([]) + assert str(later).count(base64.b64encode(image.read_bytes()).decode()) < 3 + assert str(messages).count(base64.b64encode(image.read_bytes()).decode()) == 3 + + +def test_missing_plugin_screenshot_keeps_real_result_and_reports_missing_evidence(tmp_path): + action = _action("call", post_image_name="missing") + messages = plugin_result_messages( + {"execution": [action]}, SimpleNamespace(get_image_path=lambda _: tmp_path / "missing.jpg") + )["call"] + assert "Address bar" in str(messages) + assert "截图无法读取" in str(messages) + assert not any(isinstance(m, HumanMessage) for m in messages) + + +@pytest.mark.asyncio +async def test_validator_preserves_model_call_id_and_actual_plugin_result(monkeypatch): + name = "plug_test_get_app_state" + plugins = SimpleNamespace( + bindings={name: None}, + execute=AsyncMock( + return_value=ToolExecutionResult( + tool_name=name, + tool_call_id="original", + status="success", + text_summary="PC tree", + screenshot_bytes=b"pc", + metadata={"plugin_id": "test", "plugin_status": "success"}, + ) + ), + ) + monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins) + ctx = ArtemisContext.model_construct() + outcome = await _process_action( + SimpleNamespace(ctx=ctx), + None, + None, + {"action": name, "args": {"app": "browser"}, "tool_call_id": "original"}, + name, + "phone", + burst=False, + ) + plugins.execute.assert_awaited_once_with(name, {"app": "browser"}, "original") + assert outcome.action_item["tool_call_id"] == "original" + assert outcome.action_item["result"] == "PC tree" + assert base64.b64decode(outcome.post_screenshot_b64) == b"pc" + + +@pytest.mark.parametrize("live", [False, True]) +def test_old_mixed_step_history_returns_pc_post_and_never_phone_pre(tmp_path, live): + (tmp_path / "images").mkdir() + for name, size in [("phone", (100, 200)), ("pc", (200, 100))]: + Image.new("RGB", size).save(tmp_path / "images" / f"{name}.jpg") + storage = StorageManager(tmp_path / "data_engine.db", tmp_path) + session_id = uuid4() + storage.create_session(SessionMetadata(session_id=session_id, initial_goal="fixture")) + for name in ("phone", "pc"): + storage.create_image(ImageRecord(image_name=name)) + storage.create_step( + StepRecord( + step_id=uuid4(), + session_id=session_id, + step_number=24, + pre_image_name="phone", + last_execution_result={"execution": [_action("original", post_image_name="pc")]}, + ) + ) + reader = OfflineHistoryReader(tmp_path / "data_engine.db", tmp_path, session_id) + if live: + engine = DataEngine.__new__(DataEngine) + engine.get_step_record = reader.get_step_record + engine.get_image_path = reader.get_image_path + reader = engine + assert load_step_screenshot(reader, 24, "pre").image_bytes is None + assert ( + load_step_screenshot(reader, 24, "post").image_bytes + == (tmp_path / "images/pc.jpg").read_bytes() + ) + + +def test_cross_resource_burst_has_no_single_step_image(): + from artemis.data_engine.step_evidence import step_image_name + + record = { + "pre_image_name": "phone", + "post_image_name": "pc", + "last_execution_result": { + "execution": [{"action": "tap"}, _action("pc", post_image_name="pc")] + }, + } + assert step_image_name(record, "pre") is None + assert step_image_name(record, "post") is None + + +@pytest.mark.asyncio +async def test_interrupted_plugin_call_is_not_reported_as_executed(monkeypatch): + from artemis.runtime.task_control import TaskControl + + name = "plug_test_click" + plugins = SimpleNamespace(bindings={name: None}, execute=AsyncMock()) + monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins) + control = TaskControl.__new__(TaskControl) + + async def interrupt(operation, **_kwargs): + operation.close() + return True, None + + control.run_interruptibly = interrupt + outcome = await _process_action( + SimpleNamespace(ctx=SimpleNamespace(task_control=control)), + None, + None, + {"action": name, "args": {}, "tool_call_id": "interrupted"}, + name, + "phone", + burst=False, + ) + assert not outcome.success + messages = plugin_result_messages({"execution": [outcome.action_item]}, None)["interrupted"] + assert messages[0].status == "error" + assert "打断" in str(messages[0].content) + plugins.execute.assert_not_awaited() diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py new file mode 100644 index 0000000..6302a0b --- /dev/null +++ b/tests/unit/test_plugins.py @@ -0,0 +1,1090 @@ +"""Plugin transport/installation tests use local deterministic MCP servers, never devices.""" + +import asyncio +import base64 +import io +import json +from pathlib import Path +import sys +import zipfile + +from mcp.types import CallToolResult, ImageContent, TextContent +import pytest + +from artemis.plugins.installer import install_archive +from artemis.plugins.models import CatalogEntry, PluginManifest, PluginProfile, PluginSelection +from artemis.plugins.runtime import PluginRuntime +from artemis.plugins.session import resolve_server +from artemis.plugins.store import PluginStore +from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock + + +def manifest(**updates): + return PluginManifest.model_validate( + { + "id": "test-plugin", + "name": "测试插件", + "version": "1.0.0", + "platforms": [sys.platform], + "skill": "SKILL.md", + "resources": ["desktop:local"], + "config": {"token": {"label": "密钥", "secret": True, "required": True}}, + **updates, + } + ) + + +def archive(plugin, extra=None): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as output: + output.writestr("plugin.json", plugin.model_dump_json()) + output.writestr("SKILL.md", "先观察,再操作;报告真实证据。") + for name, value in (extra or {}).items(): + output.writestr(name, value) + return buffer.getvalue() + + +@pytest.fixture +def store(tmp_path): + return PluginStore(tmp_path / "plugins") + + +def enable(store, plugin, **profile): + install_archive(store, archive(plugin)) + store.save_profile( + plugin.id, PluginProfile(secrets={"token": "private-value"}, authorized=True, **profile) + ) + store.set_enabled(plugin.id, True) + + +@pytest.mark.asyncio +async def test_android_task_discovers_cross_device_plugins_and_loads_only_on_demand(store): + plugin = manifest(targets=["computer", "mixed"]) + enable(store, plugin) + runtime = PluginRuntime(store=store, target="android") + try: + assert list(runtime.packages) == [plugin.id] + assert runtime.loaded == set() + assert runtime.sessions == {} + assert plugin.id in runtime.prompt() + assert (await runtime.call("plugin_load", {"plugin_id": plugin.id})).status == "success" + assert runtime.loaded == {plugin.id} + finally: + await runtime.aclose() + + +def test_android_auto_selection_respects_enablement_authorization_and_explicit_opt_out(store): + enable(store, manifest(targets=["mixed"])) + assert len(store.pin(None, "android")) == 1 + assert store.pin([], "android") == [] + store.set_enabled("test-plugin", False) + assert store.pin(None, "android") == [] + with pytest.raises(ValueError, match="未启用"): + store.pin([PluginSelection(id="test-plugin")], "android") + store.set_enabled("test-plugin", True) + store.save_profile("test-plugin", PluginProfile(authorized=False)) + assert store.pin(None, "android") == [] + with pytest.raises(ValueError, match="授权"): + store.pin([PluginSelection(id="test-plugin")], "android") + + +def test_android_task_uses_authorized_default_and_allows_explicit_profile(store): + enable(store, manifest(targets=["mixed"])) + store.save_profile( + "test-plugin", + PluginProfile(name="lab", is_default=True, authorized=True, secrets={"token": "lab"}), + ) + assert store.pin(None, "android")[0].profile == "lab" + assert store.pin([PluginSelection(id="test-plugin")], "android")[0].profile == "default" + + +def test_computer_only_plugin_is_not_automatically_offered_to_android(store): + enable(store, manifest(targets=["computer"])) + assert store.pin(None, "android") == [] + with pytest.raises(ValueError, match="不支持"): + store.pin([PluginSelection(id="test-plugin")], "android") + + +def test_profile_secrets_are_write_only_and_blank_preserves(store): + enable(store, manifest()) + assert "private-value" not in json.dumps(store.list()) + assert store.list()[0]["profiles"][0]["configured_secrets"] == ["token"] + store.save_profile("test-plugin", PluginProfile(secrets={"token": ""}, authorized=True)) + _, profile, _ = store.resolve(PluginSelection(id="test-plugin")) + assert profile.secrets["token"].get_secret_value() == "private-value" + + +def test_untrusted_config_cannot_hide_secrets_in_ordinary_fields(store): + install_archive(store, archive(manifest())) + with pytest.raises(ValueError, match="普通配置"): + store.save_profile("test-plugin", PluginProfile(values={"token": "secret"})) + with pytest.raises(ValueError, match="授权"): + store.set_enabled("test-plugin", True) + + +def test_package_versions_immutable_and_old_pin_survives_upgrade(store): + enable(store, manifest()) + with pytest.raises(ValueError, match="覆盖"): + install_archive(store, archive(manifest())) + install_archive(store, archive(manifest(version="1.1.0"))) + assert store.get("test-plugin")["enabled"] is False + store.set_enabled("test-plugin", True) + old, _, _ = store.resolve(PluginSelection(id="test-plugin", version="1.0.0")) + assert old.version == "1.0.0" + + +@pytest.mark.parametrize( + "path", + ["../../escape.txt", "C:/escape.txt", "/escape.txt", "dir/../../../escape", "x/../escape"], +) +def test_archive_rejects_traversal(store, path): + with pytest.raises(ValueError): + install_archive(store, archive(manifest(), {path: "bad"})) + assert store.list() == [] + + +def test_checksum_failure_keeps_previous_version(store): + enable(store, manifest()) + entry = CatalogEntry( + manifest=manifest(version="1.1.0"), + archive_url="https://example.com/package.zip", + sha256="0" * 64, + verification="本地确定性验证", + ) + with pytest.raises(ValueError, match="SHA-256"): + install_archive(store, archive(entry.manifest), entry=entry) + assert store.get("test-plugin")["manifest"]["version"] == "1.0.0" + assert store.get("test-plugin")["enabled"] is True + + +def test_server_substitutions_do_not_use_shell_or_expand_unknown_values(tmp_path): + plugin = manifest( + server={ + "command": "${plugin_dir}/server.exe", + "args": ["${config.target}"], + "env": {"TOKEN": "${secret.token}"}, + }, + config={"token": {"label": "密钥", "secret": True}, "target": {"label": "目标"}}, + ) + profile = PluginProfile( + values={"target": "a b;$(echo forbidden)"}, secrets={"token": "private-value"} + ) + config = resolve_server(plugin, profile, tmp_path) + assert config.args == ["a b;$(echo forbidden)"] + assert config.env == {"TOKEN": "private-value"} + + +@pytest.mark.asyncio +async def test_skill_load_and_reference_cannot_read_outside_package(store): + enable(store, manifest()) + runtime = PluginRuntime(store=store) + assert {t.name for t in runtime.declarations()} == { + "plugin_load", + "plugin_read_file", + "plugin_read_result", + } + result = await runtime.call("plugin_load", {"plugin_id": "test-plugin"}) + assert result.status == "success" and "先观察" in result.text() + assert ( + await runtime.call( + "plugin_read_file", {"plugin_id": "test-plugin", "path": "../../../registry.sqlite3"} + ) + ).status == "error" + + +@pytest.mark.asyncio +async def test_real_stdio_mcp_errors_images_secrets_and_langchain(store, tmp_path): + server = tmp_path / "server.py" + server.write_text( + """ +import os +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent, ImageContent +server = FastMCP("test") +@server.tool() +def observe(app: str) -> CallToolResult: + return CallToolResult(content=[TextContent(type="text", text=os.environ["TOKEN"]), + ImageContent(type="image", mimeType="image/png", data="aGVsbG8=")], + structuredContent={"app": app}, isError=app == "failed") +server.run() +""", + encoding="utf-8", + ) + plugin = manifest( + server={ + "command": sys.executable, + "args": [str(server)], + "env": {"TOKEN": "${secret.token}"}, + } + ) + enable(store, plugin, argument_constraints={"observe": {"app": ["ok", "failed"]}}) + runtime = PluginRuntime(store=store) + try: + loaded = await runtime.load(plugin.id) + assert loaded.status == "success" + tool_name = next(iter(runtime.bindings)) + result = await runtime.execute(tool_name, {"app": "ok"}, "tc-1") + assert result.status == "success" + assert json.loads(json.dumps(result.metadata))["resource_ids"] == ["desktop:local"] + assert result.screenshot_bytes == b"hello" + assert "private-value" not in result.text_summary + assert "[已隐藏密钥]" in result.text_summary + assert any(b["type"] == "image_url" for b in result.raw_result) + failed = await runtime.call(tool_name, {"app": "failed"}) + assert failed.status == "error" + assert failed.structured_content == {"app": "failed"} + blocked = await runtime.call(tool_name, {"app": "not-authorized"}) + assert blocked.status == "error" + wrapper = next(t for t in runtime.langchain_tools() if t.name == tool_name) + assert (await wrapper.ainvoke({"app": "failed"})).status == "error" + finally: + await runtime.aclose() + assert runtime.sessions == {} + + +@pytest.mark.asyncio +async def test_stdio_timeout_is_bounded_and_never_replays(store, tmp_path, monkeypatch): + from artemis.config.settings import settings + + server = tmp_path / "slow.py" + count = tmp_path / "calls.txt" + server.write_text( + f""" +import asyncio +from pathlib import Path +from mcp.server.fastmcp import FastMCP +server = FastMCP("slow") +@server.tool() +async def write_once() -> str: + with Path({str(count)!r}).open("a") as f: f.write("called\\n") + await asyncio.sleep(60) + return "done" +server.run() +""", + encoding="utf-8", + ) + enable(store, manifest(server={"command": sys.executable, "args": [str(server)]})) + runtime = PluginRuntime(store=store) + await runtime.load("test-plugin") + monkeypatch.setattr(settings, "ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS", 0.2) + try: + name = next(iter(runtime.bindings)) + result = await asyncio.wait_for(runtime.call(name, {}), timeout=10) + assert result.status == "uncertain" + assert count.read_text().splitlines() == ["called"] + again = await runtime.call(name, {}) + assert again.status == "uncertain" + assert count.read_text().splitlines() == ["called"] + finally: + await runtime.aclose() + + +@pytest.mark.parametrize("scope", ["", "test-adb-endpoint"]) +def test_resource_lock_conflicts_across_devices_without_holding_partial_leases( + tmp_path, monkeypatch, scope +): + import artemis.runtime.device_lock as locks + + monkeypatch.setattr(locks, "get_temp_dir", lambda *_: tmp_path) + monkeypatch.delenv(DeviceExecutionLock.QUEUE_TICKET_ENV, raising=False) + monkeypatch.setenv(DeviceExecutionLock.LOCK_SCOPE_ENV, scope) + first = DeviceExecutionLock( + "phone-a", session_id="first", resource_ids=["desktop:local"], concurrency_mode="per_device" + ) + second = DeviceExecutionLock( + "phone-b", + session_id="second", + resource_ids=["desktop:local", "router:lab"], + concurrency_mode="per_device", + ) + other = DeviceExecutionLock( + "phone-c", session_id="other", resource_ids=["router:lab"], concurrency_mode="per_device" + ) + first.acquire(blocking=False) + try: + with pytest.raises(DeviceBusyError): + second.acquire(blocking=False) + other.acquire(blocking=False) + assert len(DeviceExecutionLock.get_active_owners()) == 2 + assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == ("desktop:local",) + assert DeviceExecutionLock.annotate_active_owner( + session_id="first", device_id="phone-a", ingress="test" + ) + assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == ("desktop:local",) + finally: + other.release() + first.release() + second.acquire(blocking=False) + second.release() + assert not DeviceExecutionLock.get_active_owners() + + +def test_task_reservation_blocks_mutations_and_removes_files_after_release(store): + enable(store, manifest()) + selection = PluginSelection(id="test-plugin", version="1.0.0") + assert store.reserve("queued-task", [selection], "mixed") == ["desktop:local"] + for change in ( + lambda: store.save_profile("test-plugin", PluginProfile()), + lambda: store.set_enabled("test-plugin", False), + lambda: store.uninstall("test-plugin"), + lambda: install_archive(store, archive(manifest(version="1.1.0"))), + ): + with pytest.raises(ValueError, match="任务"): + change() + assert not store.package_dir("test-plugin", "1.1.0").exists() + store.release("queued-task") + store.uninstall("test-plugin") + assert store.list() == [] + assert not store.package_dir("test-plugin", "1.0.0").exists() + + +@pytest.mark.parametrize("stop_all", [False, True]) +def test_cancelled_queue_entries_release_plugin_pin_but_live_workers_keep_it( + store, monkeypatch, stop_all +): + from types import SimpleNamespace + from apps.admin_console.services import task_queue_service as queue_module + from apps.admin_console.services import automation_store + + enable(store, manifest()) + selection = PluginSelection(id="test-plugin", version="1.0.0") + store.reserve("pending", [selection], "mixed") + store.reserve("active", [selection], "mixed") + pending = {"session_id": "pending", "status": "pending"} + active = {"session_id": "active", "status": "running"} + state = SimpleNamespace( + queue_items=[pending, active], + active_connections={}, + active_session_id=None, + current_goal=None, + current_profile=None, + wake_event=SimpleNamespace(set=lambda: None), + ) + state.clear_queue = lambda: state.queue_items.clear() + monkeypatch.setattr(queue_module, "state", state) + monkeypatch.setattr("artemis.plugins.store.PluginStore", lambda: store) + monkeypatch.setattr(DeviceExecutionLock, "cancel_reservation", lambda *_: None) + monkeypatch.setattr( + automation_store, "store", SimpleNamespace(records=lambda *_: [], job=lambda *_: None) + ) + service = queue_module.TaskQueueService + for name in ( + "_terminate_all_device_owners", + "_kill_all_local_runs", + "_clear_pause_file", + "ensure_worker_running", + ): + monkeypatch.setattr(service, name, lambda: None) + if stop_all: + service._stop_all_tasks() + else: + service._remove_stopped_queue_item("pending", False) + service._remove_stopped_queue_item("active", False) + with store.connect() as db: + assert [row[0] for row in db.execute("SELECT task_id FROM task_pins")] == ["active"] + # Even after stop removes the queue row, the worker finalizer must drop its reservation. + service._remove_task("active") + store.assert_idle("test-plugin") + + +def test_registry_failure_rolls_back_package_files(store, monkeypatch): + def failed(*_): + raise OSError("disk failure") + + monkeypatch.setattr(store, "register", failed) + with pytest.raises(OSError): + install_archive(store, archive(manifest())) + assert not store.package_dir("test-plugin", "1.0.0").exists() + assert store.list() == [] + + +@pytest.mark.parametrize("path", ["NUL", "dir/COM1.txt", "aux.md", "LPT9/data"]) +def test_archive_rejects_windows_device_names(store, path): + with pytest.raises(ValueError, match="路径"): + install_archive(store, archive(manifest(), {path: "bad"})) + + +@pytest.mark.asyncio +async def test_runtime_keeps_version_and_profile_reserved_while_paused(store): + enable(store, manifest()) + runtime = PluginRuntime(store=store) + await runtime.aclose(release=False) + with pytest.raises(ValueError, match="任务"): + store.save_profile("test-plugin", PluginProfile()) + await runtime.aclose() + store.save_profile("test-plugin", PluginProfile()) + + +def test_plugin_http_api_redacts_invalid_payload_and_requires_resource_confirmation( + store, monkeypatch +): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from apps.admin_console.routers import plugins + + enable(store, manifest()) + monkeypatch.setattr(plugins, "get_store", lambda: store) + monkeypatch.setattr(DeviceExecutionLock, "get_active_owners", lambda: {}) + app = FastAPI() + app.include_router(plugins.router) + with TestClient(app) as client: + response = client.put( + "/api/plugins/test-plugin/profiles", + json={ + "name": "default", + "secrets": {"token": {"invalid": "DO-NOT-LEAK"}}, + }, + ) + assert response.status_code == 422 + assert "DO-NOT-LEAK" not in response.text + assert "private-value" not in client.get("/api/plugins").text + store.block_resources(["desktop:local"], "动作未确认停止") + assert client.get("/api/plugins").json()["blocked_resources"] + assert ( + client.post( + "/api/plugins/resources/confirm-stopped", json={"resource": "desktop:local"} + ).status_code + == 422 + ) + assert store.blocked_resources() + assert ( + client.post( + "/api/plugins/resources/confirm-stopped", + json={"resource": "desktop:local", "confirmed_stopped": True}, + ).status_code + == 200 + ) + assert store.blocked_resources() == {} + + +@pytest.mark.asyncio +async def test_pause_cleanup_error_does_not_release_resource(tmp_path, monkeypatch): + from unittest.mock import Mock + from artemis.runtime import task_control + + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) + lock = Mock() + control = task_control.TaskControl("cleanup-failed", lock) + entered = asyncio.Event() + + async def operation(): + entered.set() + try: + await asyncio.sleep(30) + except asyncio.CancelledError: + raise RuntimeError("still running remotely") from None + + future = asyncio.create_task(control.run_interruptibly(operation())) + await entered.wait() + task_control.request_pause(control.session_id) + with pytest.raises(RuntimeError, match="still running"): + await future + lock.release.assert_not_called() + + +@pytest.mark.asyncio +async def test_stdio_pause_yields_resources_and_resume_waits_for_other_task( + store, tmp_path, monkeypatch +): + from artemis.runtime import task_control + import artemis.runtime.device_lock as locks + + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path / "traces") + monkeypatch.setattr(locks, "get_temp_dir", lambda *_: tmp_path) + monkeypatch.delenv(DeviceExecutionLock.QUEUE_TICKET_ENV, raising=False) + started = tmp_path / "started.txt" + server = tmp_path / "pause_server.py" + server.write_text( + f""" +import asyncio +from pathlib import Path +from mcp.server.fastmcp import FastMCP +server = FastMCP("pause") +@server.tool() +async def observe(slow: bool = False) -> str: + if slow: + Path({str(started)!r}).write_text("started") + await asyncio.sleep(60) + return "fresh observation" +server.run() +""", + encoding="utf-8", + ) + enable(store, manifest(server={"command": sys.executable, "args": [str(server)]})) + runtime = PluginRuntime(store=store) + first = DeviceExecutionLock( + "computer-a", + session_id="paused-plugin", + resource_ids=runtime.resources, + concurrency_mode="per_device", + ) + other = DeviceExecutionLock( + "computer-b", + session_id="other-task", + resource_ids=runtime.resources, + concurrency_mode="per_device", + ) + first.acquire(blocking=False) + control = task_control.TaskControl("paused-plugin", first) + future = None + + async def until(predicate): + async with asyncio.timeout(12): + while not predicate(): + await asyncio.sleep(0.05) + + async def park(): + await runtime.aclose(release=False) + + async def resume(): + await runtime.load("test-plugin") + + try: + await runtime.load("test-plugin") + name = next(iter(runtime.bindings)) + future = asyncio.create_task( + control.run_interruptibly( + runtime.call(name, {"slow": True}), on_pause=park, on_resume=resume + ) + ) + await until(started.exists) + task_control.request_pause(control.session_id) + await until( + lambda: task_control.read_state(control.session_id).get("status") == "task_paused" + ) + assert not runtime.sessions + with pytest.raises(ValueError, match="任务"): + store.assert_idle("test-plugin") + other.acquire(blocking=False) + task_control.request_resume(control.session_id) + await until(lambda: task_control.read_state(control.session_id).get("status") == "resuming") + assert not future.done() + other.release() + assert await asyncio.wait_for(future, timeout=15) == (True, None) + assert DeviceExecutionLock.get_active_owners()["computer-a"].resource_ids == ( + "desktop:local", + ) + result = await runtime.call(next(iter(runtime.bindings)), {}) + assert result.status == "success" + assert result.structured_content == {"result": "fresh observation"} + finally: + if future and not future.done(): + future.cancel() + await asyncio.gather(future, return_exceptions=True) + await runtime.aclose() + control.release() + other.release() + store.assert_idle("test-plugin") + + +@pytest.mark.asyncio +async def test_computer_observation_and_both_action_pipelines_without_adb( + store, tmp_path, monkeypatch +): + from types import SimpleNamespace + from unittest.mock import Mock + from PIL import Image + from artemis.context import ArtemisContext, DeviceContext, DevicePlatform + from artemis.drivers.factory import create_driver + from artemis.mcp.action_executor import McpActionExecutor + from artemis.agents.validator.execution_loop import _process_action + from artemis.agents.checker.checker import build_checker_tools + + picture = io.BytesIO() + Image.new("RGB", (80, 60), "white").save(picture, format="PNG") + encoded = base64.b64encode(picture.getvalue()).decode() + server = tmp_path / "desktop.py" + server.write_text( + f""" +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent, ImageContent +server = FastMCP("desktop") +value = "initial" +@server.tool() +def list_apps() -> str: + return "fixture-window" +@server.tool() +def get_app_state(app: str) -> CallToolResult: + return CallToolResult(content=[TextContent(type="text", text=app + ":" + value), ImageContent(type="image", mimeType="image/png", data={encoded!r})]) +@server.tool() +def set_value(text: str) -> str: + global value + value = text + return value +server.run() +""", + encoding="utf-8", + ) + enable( + store, + manifest( + targets=["computer", "mixed"], + observation={"tool": "list_apps", "refresh_tools": ["get_app_state"]}, + server={"command": sys.executable, "args": [str(server)]}, + ), + ) + ctx = ArtemisContext( + device=DeviceContext(mobile_platform=DevicePlatform.COMPUTER, device_id="computer:local"), + execution_target="computer", + ) + ctx.plugins = PluginRuntime(store=store, ctx=ctx) + adb = Mock(side_effect=AssertionError("computer tasks cannot create an ADB client")) + monkeypatch.setattr("artemis.drivers.factory.AdbClient", adb) + try: + driver = create_driver(ctx) + first = await driver.get_screen_data() + assert first.screenshot_bytes == b"" and "fixture-window" in first.observation_text + names = {tool.name: name for name, (_, tool) in ctx.plugins.bindings.items()} + await ctx.plugins.call(names["get_app_state"], {"app": "fixture-window"}) + executor = McpActionExecutor(ctx) + result = await executor.execute( + names["set_value"], {"text": "flash-value"}, "flash-call", None + ) + assert result.status == "success" and result.metadata["plugin_id"] == "test-plugin" + observed = await driver.get_screen_data() + assert observed.width == 80 and "flash-value" in observed.observation_text + node = SimpleNamespace(ctx=ctx) + outcome = await _process_action( + node, + None, + None, + {"action": names["set_value"], "args": {"text": "pro-value"}}, + names["set_value"], + "", + burst=False, + ) + assert outcome.success and outcome.action_item["plugin_version"] == "1.0.0" + observed = await driver.get_screen_data() + assert "pro-value" in observed.observation_text + assert "probe_device" not in {tool.name for tool in build_checker_tools(ctx, "final")} + adb.assert_not_called() + finally: + await ctx.plugins.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target", ["computer", "mixed", "android"]) +async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(monkeypatch, target): + from types import SimpleNamespace + from unittest.mock import Mock, AsyncMock + from artemis.agents.flash import runner as flash + from artemis.agents.validator.tool_declarations import ToolExecutionResult + + name = "plug_test_observe" + plugins = SimpleNamespace( + bindings={name: ("test-plugin", None)}, + tool_names={name}, + last_images={"test-plugin": b"desktop-before"}, + previous_image=lambda name, args: b"desktop-before", + ) + monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda ctx: plugins) + capture = AsyncMock(return_value=("phone-path", b"phone-after", "phone-tree-after")) + monkeypatch.setattr(flash, "capture_screenshot_and_parse_ui", capture) + runner = flash.FlashRunner.__new__(flash.FlashRunner) + runner.ctx = SimpleNamespace(execution_target=target, data_engine=Mock(), task_control=None) + runner.controller, runner.summarizer, runner._llm = Mock(), None, None + runner.executor = SimpleNamespace( + action_tool_names={name}, + execute=AsyncMock( + return_value=ToolExecutionResult( + tool_name=name, + tool_call_id="tc", + status="success", + screenshot_bytes=b"desktop-after", + ui_elements_text="desktop-tree", + text_summary="Desktop observation", + raw_result=[ + {"type": "text", "text": "Desktop observation"}, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64," + + base64.b64encode(b"desktop-after").decode() + }, + }, + ], + metadata={"plugin_id": "test-plugin"}, + ) + ), + ) + runner._build_action_record = Mock(return_value={"action": name}) + runner._record_action_step = Mock(return_value="step") + state = SimpleNamespace(latest_screenshot="old-phone-path") + messages = [] + result = await runner._execute_and_record_action( + name, + {}, + "tc", + state, + messages, + "", + {}, + b"phone-before", + "phone-tree-before", + 0, + flash._TurnRecord(), + ) + assert runner._record_action_step.call_args.args[5:8] == ( + b"desktop-before", + "", + b"desktop-after", + ) + assert messages[0].tool_call_id == "tc" + assert "Desktop observation" in str(messages[0].content) + assert base64.b64encode(b"desktop-after").decode() in str(messages) + if target != "computer": + assert result[0] is None + assert "安卓旧截图、元素编号和坐标已失效" in result[1] + assert state.latest_screenshot is None + assert state.indexed_elements == [] + assert state.indexed_points == [] + assert runner._android_observation_stale + capture.assert_not_awaited() + else: + assert result[:2] == (b"desktop-after", "desktop-tree") + capture.assert_not_awaited() + + +def test_named_default_profile_is_used_only_when_authorized(store): + enable(store, manifest()) + store.save_profile( + "test-plugin", + PluginProfile( + name="lab", is_default=True, authorized=True, secrets={"token": "lab-secret"} + ), + ) + assert store.selections()[0].profile == "lab" + store.save_profile("test-plugin", PluginProfile(name="lab", is_default=True, authorized=False)) + assert store.selections() == [] + + +@pytest.mark.asyncio +async def test_streamable_http_timeout_protects_resources_and_never_replays( + store, tmp_path, monkeypatch +): + import socket + import subprocess + from artemis.config.settings import settings + + with socket.socket() as available: + available.bind(("127.0.0.1", 0)) + port = available.getsockname()[1] + counter = tmp_path / "writes.txt" + script = tmp_path / "http_mcp.py" + script.write_text( + f""" +import asyncio +from pathlib import Path +from mcp.server.fastmcp import FastMCP +server = FastMCP("http-test", host="127.0.0.1", port={port}) +@server.tool() +async def write_once() -> str: + with Path({str(counter)!r}).open("a") as output: output.write("write\\n") + await asyncio.sleep(30) + return "written" +server.run(transport="streamable-http") +""", + encoding="utf-8", + ) + process = subprocess.Popen( + [sys.executable, str(script)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + runtime = None + try: + for _ in range(100): + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", port)) == 0: + break + assert process.poll() is None + await asyncio.sleep(0.05) + else: + pytest.fail("local MCP fixture did not start") + enable( + store, + manifest( + server={"transport": "streamable_http", "url": f"http://127.0.0.1:{port}/mcp"} + ), + ) + runtime = PluginRuntime(store=store) + await runtime.load("test-plugin") + monkeypatch.setattr(settings, "ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS", 0.2) + name = next(iter(runtime.bindings)) + result = await asyncio.wait_for(runtime.call(name, {}), timeout=10) + assert result.status == "uncertain" + assert "desktop:local" in store.blocked_resources() + assert counter.read_text().splitlines() == ["write"] + with pytest.raises(RuntimeError, match="资源"): + await runtime.load("test-plugin") + assert counter.read_text().splitlines() == ["write"] + finally: + if runtime: + await runtime.aclose() + process.terminate() + process.wait(timeout=10) + + +@pytest.mark.asyncio +async def test_computer_only_plugin_diagnosis_uses_its_declared_target(store, monkeypatch): + from apps.admin_console.routers import plugins + + enable(store, manifest(targets=["computer"], observation={"tool": "observe"})) + monkeypatch.setattr(plugins, "get_store", lambda: store) + result = await plugins.diagnose_plugin("test-plugin", plugins.DiagnoseRequest()) + assert result["ok"] is True + store.assert_idle("test-plugin") + + +@pytest.mark.asyncio +async def test_workspace_computer_discovery_never_queries_adb(monkeypatch): + from mcp_server.tools import automation + + routes = [] + + async def request(method, route): + routes.append(route) + assert route != "/api/devices" + return {"plugins": [{"id": "computer"}]} if route == "/api/plugins" else {} + + monkeypatch.setattr(automation, "_request", request) + result = await automation.mobile_workspace(include_devices=False) + assert result["devices"] == [] + assert result["plugins"] == [{"id": "computer"}] + assert set(routes) == {"/api/status", "/api/plugins"} + + +@pytest.mark.asyncio +async def test_sdk_setup_failure_releases_plugin_version_pin(store, monkeypatch): + from artemis.sdk.agent import Agent + import artemis.plugins.runtime as runtime_module + + enable(store, manifest(targets=["computer"], observation={"tool": "observe"})) + monkeypatch.setattr(runtime_module, "PluginStore", lambda: store) + monkeypatch.setattr(DeviceExecutionLock, "get_active_owner", lambda _: None) + + def fail_before_tracing(*args, **kwargs): + raise ValueError("cannot initialize lock") + + monkeypatch.setattr("artemis.sdk.agent.DeviceExecutionLock", fail_before_tracing) + agent = Agent() + await agent.init(execution_target="computer") + request = agent.new_task("fixture").with_plugins([PluginSelection(id="test-plugin")]).build() + # Fail while constructing the lock, before device/trace initialization. + fail_before_tracing.get_active_owner = lambda _: None + with pytest.raises(ValueError, match="initialize lock"): + await agent.run_task(request=request) + store.assert_idle("test-plugin") + + +def test_plugin_screenshots_export_with_source_and_without_phone_substitution( + tmp_path, monkeypatch +): + from apps.admin_console.services import report_export + + phone, desktop = tmp_path / "phone.png", tmp_path / "desktop.png" + phone.write_bytes(b"phone") + desktop.write_bytes(b"desktop") + monkeypatch.setattr( + report_export, "image_path", lambda name: {"phone": phone, "desktop": desktop}.get(name) + ) + evidence = {"plugin_id": "desktop-control", "image_names": ["desktop"]} + report = { + "steps": [ + { + "step_number": 1, + "pre_image_name": "phone", + "generic_tools": [{"payload": evidence}, {"payload": json.dumps(evidence)}], + } + ] + } + images = report_export.session_screenshots(report) + assert [path for path, _ in images] == [phone, desktop] + assert "插件_desktop-control" in images[1][1] + + +def test_plugin_images_live_until_the_last_referencing_task_is_deleted(tmp_path): + from uuid import uuid4 + from artemis.data_engine.storage import StorageManager + + storage = StorageManager(tmp_path / "history.db", tmp_path) + name = "f" * 64 + image = tmp_path / "images" / f"{name}.jpg" + image.parent.mkdir() + image.write_bytes(b"plugin image") + first, second = uuid4(), uuid4() + storage.reference_plugin_image(first, name) + storage.reference_plugin_image(second, name) + storage.delete_session(first) + assert image.read_bytes() == b"plugin image" + storage.delete_session(second) + assert not image.exists() + + +def test_plugin_operation_export_retains_source_and_failure_evidence(): + from apps.admin_console.services.report_export import plugin_operations + from apps.admin_console.database.repositories.step_repository import StepRepository + + operations = plugin_operations( + [ + { + "step_number": 3, + "generic_tools": [ + { + "name": "plug_click", + "status": "failed", + "payload": { + "plugin_id": "desktop", + "plugin_version": "1.0.0", + "resource_ids": ["desktop:local"], + "error": "窗口已关闭", + "post_image_name": "evidence", + }, + }, + ], + } + ] + ) + trace = StepRepository()._normalize_display_trace( + { + "name": "plug_click", + "type": "tool", + "status": "failed", + "payload": { + "plugin_id": "desktop", + "plugin_version": "1.0.0", + "resource_ids": ["desktop:local"], + "post_image_name": "evidence", + "image_names": ["evidence"], + "error": "窗口已关闭", + }, + } + ) + assert trace["payload"]["post_image_name"] == "evidence" + assert plugin_operations([{"step_number": 3, "generic_tools": [trace]}]) == operations + assert operations == [ + { + "step": 3, + "plugin": "desktop", + "version": "1.0.0", + "tool": "plug_click", + "resources": "desktop:local", + "status": "failed", + "content": "窗口已关闭", + } + ] + + +@pytest.mark.asyncio +async def test_pro_text_only_observation_never_sends_an_empty_image(): + from artemis.agents.operator.prompts import ObservationPromptComponent, PromptBuilder + + builder = PromptBuilder() + await ObservationPromptComponent()( + builder, None, None, latest_screenshot_b64="", minimal_list="fixture window" + ) + assert all( + not isinstance(part, dict) or part.get("type") != "image_url" + for part in builder.human_parts + ) + assert any("fixture window" in part for part in builder.human_parts if isinstance(part, str)) + + +@pytest.mark.asyncio +async def test_plugin_image_text_stays_with_its_own_observation(store, tmp_path, monkeypatch): + from types import SimpleNamespace + from unittest.mock import MagicMock + from PIL import Image + from artemis.context import ArtemisContext + from artemis.data_engine.engine import DataEngine + from artemis.plugins.models import PluginCallResult + + monkeypatch.setattr(DataEngine, "_ipc_port_candidates", staticmethod(lambda: [])) + monkeypatch.setattr("artemis.data_engine.engine.PAUSE_FILE", tmp_path / "pause") + ctx = MagicMock(spec=ArtemisContext) + ctx.execution_setup = SimpleNamespace(traces_path=str(tmp_path / "traces")) + ctx.device = None + engine = DataEngine(ctx) + engine.start_session("plugin image provenance") + runtime = PluginRuntime([], store=store, ctx=SimpleNamespace(data_engine=engine)) + images = [] + names = [] + try: + for text, color in (("baseline", "white"), ("changed", "green")): + buffer = io.BytesIO() + Image.new("RGB", (40, 40), color).save(buffer, format="PNG") + images.append(buffer.getvalue()) + outcome = PluginCallResult( + status="success", + content=[ + {"type": "text", "text": text}, + {"type": "image", "data": base64.b64encode(images[-1]).decode()}, + ], + ) + runtime._persist_images(outcome) + names.extend(outcome.image_names) + engine.record_step( + pre_screenshot_bytes=images[0], + post_screenshot_bytes=images[1], + ui_tree="changed", + ) + await engine.shutdown() + assert engine.storage.get_image(names[0]).ui_tree == "baseline" + assert engine.storage.get_image(names[1]).ui_tree == "changed" + finally: + await runtime.aclose() + + +@pytest.mark.asyncio +async def test_long_plugin_result_roundtrips_through_task_scoped_reader(store, tmp_path): + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock + from mcp.types import Tool, ToolAnnotations + from artemis.config.constants import PLUGIN_PROMPT_TEXT_CHARS + + plugin = manifest() + enable(store, plugin) + engine = MagicMock(base_dir=tmp_path / "task-a") + ctx = SimpleNamespace(data_engine=engine) + runtime = PluginRuntime(store=store, ctx=ctx) + tool = Tool( + name="observe", + inputSchema={"type": "object"}, + annotations=ToolAnnotations(readOnlyHint=True), + ) + original = "private-value\n" + "node 中文\n" * 9000 + "END" + runtime.bindings["plug_test_observe"] = (plugin.id, tool) + session = SimpleNamespace( + call=AsyncMock( + return_value=CallToolResult(content=[TextContent(type="text", text=original)]) + ) + ) + runtime.sessions[plugin.id] = session + try: + outcome = await runtime.call("plug_test_observe", {}) + assert outcome.status == "success" + assert outcome.text_ref == outcome.call_id + assert runtime.is_read_only("plug_test_observe") + shown = runtime.content_blocks(outcome)[0]["text"] + assert len(shown) <= PLUGIN_PROMPT_TEXT_CHARS + assert outcome.text_ref in shown + text = outcome.text() + pages = [] + for offset in range(0, len(text), PLUGIN_PROMPT_TEXT_CHARS): + page = await runtime.call( + "plugin_read_result", {"call_id": outcome.text_ref, "offset": offset} + ) + assert page.status == "success" + delivered = runtime.content_blocks(page)[0]["text"] + pages.append(delivered.split("\n", 1)[1]) + assert "".join(pages) == text + assert "private-value" not in text and "已隐藏密钥" in text + assert text.endswith("END") + engine.base_dir = tmp_path / "task-b" + inaccessible = await runtime.call("plugin_read_result", {"call_id": outcome.text_ref}) + assert inaccessible.status == "error" + session.call.assert_awaited_once() + finally: + runtime.sessions.clear() + await runtime.aclose() diff --git a/tests/unit/test_prefix_cache.py b/tests/unit/test_prefix_cache.py new file mode 100644 index 0000000..0640380 --- /dev/null +++ b/tests/unit/test_prefix_cache.py @@ -0,0 +1,63 @@ +import json +from types import SimpleNamespace + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage + +from artemis.llm.prefix_cache import PromptPrefixTracker +from artemis.llm.router import ModelEndpoint +from artemis.services.llm import RobustChatModelWrapper + + +def test_prefix_diagnostics_distinguish_append_rewrite_and_tool_changes_without_payloads(): + tracker = PromptPrefixTracker() + messages = [SystemMessage(content="private rules"), HumanMessage(content="private screenshot")] + options = {"tools": [{"name": "click"}, {"name": "observe"}]} + assert tracker.observe("task:flash", messages, options)["cold_start"] + messages.extend( + [ + AIMessage(content="", tool_calls=[{"name": "click", "args": {"x": 5}, "id": "c1"}]), + ToolMessage(content="clicked", tool_call_id="c1"), + ] + ) + extended = tracker.observe("task:flash", messages, options) + assert extended["append_only"] and extended["shared_prefix_messages"] == 2 + messages[1] = HumanMessage(content="compressed summary") + rewritten = tracker.observe("task:flash", messages, options) + assert rewritten["first_changed_message"] == 2 and not rewritten["append_only"] + reordered = tracker.observe("task:flash", messages, {"tools": list(reversed(options["tools"]))}) + assert reordered["tools_or_format_changed"] and not reordered["append_only"] + assert tracker.observe("task:checker", messages, options)["cold_start"] + assert "private" not in json.dumps(tracker._previous) + + +def test_bound_wrappers_reuse_task_prefix_tracker_after_message_adaptation(): + class Model: + kwargs = {"tools": [{"type": "function", "name": "observe"}]} + + def bind_tools(self, *_args, **_kwargs): + return self + + ctx = SimpleNamespace(data_engine=SimpleNamespace(current_session_id="session")) + wrapper = RobustChatModelWrapper(Model(), ctx=ctx, endpoint=ModelEndpoint(), role="flash") + messages = [SystemMessage(content="rules"), HumanMessage(content="step 1")] + + def diagnostics(items): + bound = wrapper.bind_tools([]) # Flash binds again for each call + args, kwargs = bound._prepare_model_call((items,), {}) + return bound._diagnostics(args, kwargs) + + first = diagnostics(messages) + messages.append(HumanMessage(content="step 2")) + second = diagnostics(messages) + assert first["prefix_cache"]["cold_start"] + assert second["prefix_cache"]["append_only"] + assert first["prompt_cache_key"] == second["prompt_cache_key"] + assert not any("rules" in str(value) for value in ctx.prompt_prefix_tracker._previous.values()) + + ctx.transcript_ledger = SimpleNamespace( + context_phase=1, last_compaction={"reason": "context_pressure"} + ) + third = diagnostics(messages[:1] + [HumanMessage(content="history summary")]) + assert third["prefix_cache"]["context_phase"] == 1 + assert third["prefix_cache"]["last_compaction"]["reason"] == "context_pressure" + assert not third["prefix_cache"]["append_only"] diff --git a/tests/unit/test_prompt_efficiency.py b/tests/unit/test_prompt_efficiency.py new file mode 100644 index 0000000..efc26fc --- /dev/null +++ b/tests/unit/test_prompt_efficiency.py @@ -0,0 +1,134 @@ +"""Real result preservation, bounded context, and zero-call observation receipts.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from artemis.config.constants import PLUGIN_PROMPT_TEXT_CHARS, PLUGIN_SUMMARY_TEXT_CHARS +from artemis.plugins.prompt_budget import bounded_result_text, read_result_text, save_result_text +from artemis.plugins.models import PluginCallResult +from artemis.plugins.runtime import PluginRuntime + + +def test_long_plugin_result_is_bounded_but_paged_evidence_is_lossless(tmp_path): + engine = SimpleNamespace(base_dir=tmp_path) + call_id = "a" * 32 + original = "Address bar\n" + "关键控件与真实观察\n" * 10000 + "ERROR: window unavailable" + save_result_text(engine, call_id, original) + prompt = bounded_result_text(original, call_id=call_id) + assert len(prompt) <= PLUGIN_PROMPT_TEXT_CHARS + assert "Address bar" in prompt and "ERROR: window unavailable" in prompt + assert call_id in prompt and "节选" in prompt + recovered = "".join( + read_result_text(engine, call_id, offset).split("\n", 1)[1] + for offset in range(0, len(original), PLUGIN_PROMPT_TEXT_CHARS) + ) + assert recovered == original + assert "已到结尾" in read_result_text(engine, call_id, len(original)) + with pytest.raises(ValueError): + read_result_text(engine, call_id, len(original) + 1) + + +@pytest.mark.parametrize("bad", ["../outside", "a" * 31, "a" * 32 + ".txt", "A" * 32]) +def test_plugin_evidence_reader_rejects_arbitrary_paths(tmp_path, bad): + with pytest.raises(ValueError): + read_result_text(SimpleNamespace(base_dir=tmp_path), bad) + + +def test_flash_plugin_output_is_bounded_without_losing_image_or_error(): + outcome = PluginCallResult( + status="error", + plugin_id="pc", + text_ref="b" * 32, + content=[ + {"type": "text", "text": "X" * 50000 + "Permission denied"}, + {"type": "image", "mimeType": "image/png", "data": "AA=="}, + ], + ) + result = PluginRuntime.content_blocks(outcome) + assert len(result[0]["text"]) <= PLUGIN_PROMPT_TEXT_CHARS + assert "Permission denied" in result[0]["text"] + assert result[1]["image_url"]["url"] == "data:image/png;base64,AA==" + assert len(outcome.content[0]["text"]) > PLUGIN_PROMPT_TEXT_CHARS + + +def test_read_only_observation_needs_no_summary_model(monkeypatch): + from artemis.agents.flash.summarizer import VisualStepSummarizer + + model = MagicMock() + monkeypatch.setattr("artemis.agents.flash.summarizer.get_llm", lambda *a, **k: model) + engine = MagicMock() + service = VisualStepSummarizer(SimpleNamespace(data_engine=engine)) + service.dispatch( + step_number=2, + action_name="plug_test_get_app_state", + action_args={}, + pre_img_bytes=None, + post_img_bytes=b"actual screenshot", + exec_outcome="UI" * 20000, + data_engine_step_id="step-2", + action_key="call-2", + receipt_only=True, + ) + assert not service.is_pending("call-2") + summary = service.get_summary("call-2") + assert summary and "不代表视觉验收通过" in summary + assert len(summary) < PLUGIN_SUMMARY_TEXT_CHARS + 150 + assert service.get_job_payload("call-2")["post_img_bytes"] is None + assert service.get_job_payload("call-2")["data_engine_step_id"] == "step-2" + assert not service._pending_tasks + model.ainvoke.assert_not_called() + assert engine.update_step_summary.call_args.kwargs["source"] == "tool_receipt" + + +def test_assistant_message_count_does_not_change_exploration_context(monkeypatch): + from artemis.exploration.runtime import conversation_message + + session = {"goal": "inspect website", "message_count": 10} + history = [{"sequence": 1, "role": "user", "text": "inspect website"}] + store = SimpleNamespace( + get=lambda _: session, recent_messages=lambda *a, **k: history, case=lambda _: None + ) + monkeypatch.setattr("artemis.exploration.runtime.explorations", store) + monkeypatch.setattr("artemis.exploration.editorial.prior_context", lambda *a, **k: []) + ctx = SimpleNamespace(exploration_id="explore", exploration_run_id="run") + first = conversation_message(ctx).content + session["message_count"] = 99 + assert conversation_message(ctx).content == first + history.append({"sequence": 99, "role": "user", "text": "return home"}) + assert "return home" in conversation_message(ctx).content + + +def test_observation_steps_do_not_force_plan_bookkeeping(): + from artemis.agents.operator.prompts import unwritten_action_streak + + write = {"action_taken": "click"} + read = { + "action_taken": "get_app_state", + "last_execution_result": {"execution": [{"read_only": True}]}, + } + assert unwritten_action_streak([write, read, read, read, read]) == 1 + assert unwritten_action_streak([write] * 4) == 4 + + +def test_flash_plugin_summary_caps_text_before_model_submission(monkeypatch): + from artemis.agents.flash.summarizer import VisualStepSummarizer + + monkeypatch.setattr("artemis.agents.flash.summarizer.get_llm", lambda *a, **k: MagicMock()) + service = VisualStepSummarizer(SimpleNamespace(data_engine=None)) + submit = MagicMock() + monkeypatch.setattr(service, "submit", submit) + service.dispatch( + step_number=1, + action_name="plug_test_click", + action_args={}, + pre_img_bytes=None, + post_img_bytes=b"image", + exec_outcome="target clicked" * 10000, + post_ui_elements_text="control tree" * 10000, + ) + payload = submit.call_args.args[1] + assert len(payload["exec_outcome"]) <= PLUGIN_SUMMARY_TEXT_CHARS + assert len(payload["post_ui_elements_text"]) <= PLUGIN_SUMMARY_TEXT_CHARS + assert payload["post_img_bytes"] == b"image" diff --git a/tests/unit/test_recording_config.py b/tests/unit/test_recording_config.py new file mode 100644 index 0000000..90a015f --- /dev/null +++ b/tests/unit/test_recording_config.py @@ -0,0 +1,114 @@ +"""Per-task archive quality overrides outrank JSON and saved environment defaults.""" + +import json +from types import SimpleNamespace + +import pytest + +from apps.admin_console.services.task_import import TaskCase, TaskOptions +from artemis.config.agent import AgentGlobalConfig, load_agent_config +from artemis.config.settings import settings + + +@pytest.fixture(autouse=True) +def recording_defaults(monkeypatch): + monkeypatch.setattr(settings, "ARTEMIS_RECORDING_CONFIG", {"fps": 10, "h264_kbps": 900}) + monkeypatch.setattr(settings, "ARTEMIS_TASK_RECORDING_CONFIG", {}) + + +def test_json_recording_defaults_override_saved_defaults(tmp_path): + config = tmp_path / "artemis.jsonc" + config.write_text(json.dumps({"agent": {"recording": {"fps": 20}}}), encoding="utf-8") + result = load_agent_config(config).recording + assert result.fps == 20 + assert result.h264_kbps == 900 + assert result.max_long_edge == 1280 + + +def test_task_recording_override_has_highest_priority_and_is_partial(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "ARTEMIS_TASK_RECORDING_CONFIG", {"fps": 30}) + config = tmp_path / "artemis.jsonc" + config.write_text( + json.dumps({"agent": {"recording": {"fps": 20, "max_long_edge": 1920}}}), encoding="utf-8" + ) + result = load_agent_config(config).recording + assert result.fps == 30 + assert result.max_long_edge == 1920 + assert result.h264_kbps == 900 + + +def test_task_recording_override_applies_without_json_recording_block(monkeypatch): + monkeypatch.setattr(settings, "ARTEMIS_TASK_RECORDING_CONFIG", {"fps": 30}) + assert AgentGlobalConfig().recording.fps == 30 + + +def test_invalid_task_recording_override_is_rejected(monkeypatch): + monkeypatch.setattr(settings, "ARTEMIS_TASK_RECORDING_CONFIG", {"fps": 500}) + with pytest.raises(ValueError, match="fps"): + AgentGlobalConfig.model_validate({"recording": {"fps": 20}}) + + +def test_task_and_batch_serialization_do_not_inject_unset_recording_defaults(): + options = TaskOptions.model_validate({"recording": {"fps": 30}}) + assert options.model_dump(exclude_none=True)["recording"] == {"fps": 30} + case = TaskCase(name="fixture", goal="fixture", task_config=options) + assert case.model_dump()["task_config"]["recording"] == {"fps": 30} + # Round trips through stored batches preserve the partial override. + restored = TaskCase.model_validate(case.model_dump()) + assert restored.model_dump()["task_config"]["recording"] == {"fps": 30} + + +def test_worker_env_keeps_task_quality_separate_from_saved_defaults(monkeypatch): + from apps.admin_console.services.task_queue_service import TaskQueueService + from artemis.runtime.adb_endpoint import AdbEndpoint, AdbTarget + + monkeypatch.setenv("ARTEMIS_RECORDING_CONFIG", '{"fps":10}') + monkeypatch.setenv("ARTEMIS_TASK_RECORDING_CONFIG", '{"fps":60}') + _, environment = TaskQueueService._build_worker_invocation( + {"runtime_overrides": {"recording": {"fps": 30}}}, + "test-run", + None, + "fixture", + "flash", + AdbTarget(AdbEndpoint.local()), + ) + assert json.loads(environment["ARTEMIS_RECORDING_CONFIG"]) == {"fps": 10} + assert json.loads(environment["ARTEMIS_TASK_RECORDING_CONFIG"]) == {"fps": 30} + _, clean_environment = TaskQueueService._build_worker_invocation( + {}, "test-run", None, "fixture", "flash", AdbTarget(AdbEndpoint.local()) + ) + assert "ARTEMIS_TASK_RECORDING_CONFIG" not in clean_environment + + +def test_sdk_preserves_task_recording_scope_through_recorder(tmp_path, monkeypatch): + from artemis.plugins.models import EvidenceSource + from artemis.plugins.recording import PluginRecordings, capture_rectangle + from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder + + monkeypatch.setattr( + settings, + "ARTEMIS_TASK_RECORDING_CONFIG", + {"computer_scope": "window", "codec": "h264", "fps": 7, "max_long_edge": 960}, + ) + builder = AgentConfigBuilder() + config = builder.build() + ctx = SimpleNamespace( + agent_config=config, + data_engine=SimpleNamespace(base_dir=tmp_path, session_start_time=100), + execution_setup=SimpleNamespace(record_video_enabled=True), + ) + recorder = PluginRecordings(ctx) + source = EvidenceSource( + kind="computer", + target_id="fixture", + window={"left": 40, "top": 50, "width": 800, "height": 600}, + screen={"left": 0, "top": 0, "width": 1920, "height": 1080}, + ) + assert recorder.config.computer_scope == "window" + assert capture_rectangle(source, recorder.config.computer_scope) == source.window + assert recorder.config.codec == "h264" + assert recorder.config.fps == 7 + assert recorder.config.max_long_edge == 960 + # Building another task must not share a mutable recording configuration. + config.recording.fps = 12 + assert builder.build().recording.fps == 7 diff --git a/tests/unit/test_recording_process.py b/tests/unit/test_recording_process.py new file mode 100644 index 0000000..4c15788 --- /dev/null +++ b/tests/unit/test_recording_process.py @@ -0,0 +1,42 @@ +"""Exercise Windows ownership without recording a user screen or running FFmpeg.""" + +import os +from pathlib import Path +import subprocess +import sys +import time + +import psutil +import pytest + + +@pytest.mark.skipif(os.name != "nt", reason="Windows capture process ownership") +def test_worker_exit_terminates_owned_capture_child(): + project = str(Path(__file__).resolve().parents[2]) + script = f""" +import os, subprocess, sys +sys.path.insert(0, {project!r}) +from artemis.plugins.recording_process import CaptureProcessJob +job = CaptureProcessJob() +child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)'], + creationflags=0x08000000, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +job.assign(child.pid) +print(child.pid, flush=True) +os._exit(0) +""" + parent = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=15, + creationflags=0x08000000, + check=True, + ) + pid = int(parent.stdout.strip()) + for _ in range(50): + if not psutil.pid_exists(pid): + return + time.sleep(0.1) + # Only the disposable child created above is eligible for cleanup. + psutil.Process(pid).kill() + pytest.fail("Owned capture child survived abrupt worker exit") diff --git a/tests/unit/test_request_diagnostics.py b/tests/unit/test_request_diagnostics.py new file mode 100644 index 0000000..8c88289 --- /dev/null +++ b/tests/unit/test_request_diagnostics.py @@ -0,0 +1,416 @@ +import asyncio +from copy import deepcopy +import json +from types import SimpleNamespace +from uuid import uuid4 + +import httpx +from langchain_core.messages import AIMessageChunk, HumanMessage +import pytest + +from artemis.llm.diagnostics import ( + ACTIVE_ATTEMPT, + RequestAttempt, + error_text, + input_images, + observe_request_headers, + observe_response_headers, +) +from artemis.llm.router import ModelEndpoint +from artemis.services import llm + + +class Engine: + current_session_id = "diagnostic-test" + current_step_id = None + + def __init__(self): + self.records = [] + + def record_trace(self, **record): + self.records.append(deepcopy(record)) + return record.get("trace_id", uuid4()) + + def _publish(self, *_): + pass + + +@pytest.mark.asyncio +async def test_request_hook_correlates_api_calls_without_logging_header_values(): + engine = Engine() + attempt = RequestAttempt( + engine, request_id="local-request", attempt=1, timeout=300, max_retries=1, metadata={} + ) + token = ACTIVE_ATTEMPT.set(attempt) + try: + request = httpx.Request( + "POST", + "https://example.test/v1/responses", + headers={ + "Thread-Id": "private-thread", + "Session-Id": "private-session", + "Authorization": "Bearer private-key", + }, + ) + await observe_request_headers(request) + assert request.headers["x-client-request-id"] == "local-request" + assert attempt.data["client_request_id_matches"] is True + assert len(attempt.data["routing_header_fingerprints"]) == 2 + assert "private" not in json.dumps(engine.records, default=str) + request.headers["X-Client-Request-Id"] = "caller-id" + await observe_request_headers(request) + assert request.headers["x-client-request-id"] == "caller-id" + assert attempt.data["client_request_id_matches"] is False + finally: + ACTIVE_ATTEMPT.reset(token) + + +@pytest.mark.asyncio +async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatch): + engine = Engine() + monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine) + monkeypatch.setattr( + llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 0, max_attempts=2) + ) + llm._ENDPOINT_BREAKER._states.clear() + calls = 0 + + class StalledBody(httpx.AsyncByteStream): + async def __aiter__(self): + await asyncio.sleep(10) + yield b"" + + async def transport(request): + nonlocal calls + calls += 1 + headers = {"x-request-id": f"server-{calls}", "set-cookie": "secret-cookie"} + return httpx.Response( + 200, headers=headers, stream=StalledBody() if calls == 1 else httpx.ByteStream(b"ok") + ) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(transport), + event_hooks={"response": [observe_response_headers]}, + ) as client: + + class Model: + async def astream(self, *_args, **_kwargs): + async with client.stream( + "POST", + "https://example.test/responses", + headers={"Authorization": "Bearer secret-key"}, + ) as response: + await response.aread() + yield AIMessageChunk( + content="ready", + usage_metadata={"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}, + ) + + wrapper = llm.RobustChatModelWrapper( + Model(), endpoint=ModelEndpoint(timeout_seconds=0.05, max_retries=1) + ) + result = await wrapper.complete([]) + assert result.content == "ready" + snapshots = [r["payload"] for r in engine.records if r["type"] == "llm_diagnostic"] + failed = next(p for p in snapshots if p["phase"] == "failed") + final = snapshots[-1] + assert failed["http_status"] == 200 + assert failed["server_request_id"] == "server-1" + assert failed["failure_phase"] == "waiting_output" + assert failed["error_type"] == "TimeoutError" and "0.05 秒" in failed["error"] + assert failed["usage"] is None and "first_output_at" not in failed + assert final["request_id"] == failed["request_id"] + assert final["attempt_id"] != failed["attempt_id"] and final["attempt"] == 2 + assert final["phase"] == "completed" and final["usage"]["total_tokens"] == 12 + assert ( + final["started_at"] <= final["headers_at"] <= final["first_output_at"] <= final["ended_at"] + ) + assert "secret" not in json.dumps(snapshots) + assert ACTIVE_ATTEMPT.get() is None + assert any(r["name"] == "llm_retry" and r["payload"]["error"] for r in engine.records) + + +def test_tool_deltas_are_counted_once_without_retaining_arguments(): + engine = Engine() + attempt = RequestAttempt( + engine, request_id="tools", attempt=1, timeout=300, max_retries=1, metadata={} + ) + deltas = [ + AIMessageChunk( + content=[], tool_call_chunks=[{"name": "click", "args": "", "id": "c1", "index": 0}] + ), + AIMessageChunk( + content=[{"type": "function_call", "arguments": '{"secret":'}], + tool_call_chunks=[{"args": '{"secret":', "index": 0}], + ), + AIMessageChunk(content=[], tool_call_chunks=[{"args": '"private"}', "index": 0}]), + AIMessageChunk( + content=[], + tool_call_chunks=[{"name": "wait", "args": '{"seconds":1}', "id": "c2", "index": 1}], + ), + ] + combined = deltas[0] + for i, delta in enumerate(deltas): + attempt.chunk(delta) + if i: + combined += delta + attempt.chunk(AIMessageChunk(content=[], response_metadata={"status": "completed"})) + attempt.finish("completed", response=combined) + assert attempt.data["output_chunks"] == 4 + assert attempt.data["tool_argument_chars"] == len('{"secret":"private"}{"seconds":1}') + assert attempt.data["tools"] == [ + {"name": "click", "argument_chars": len('{"secret":"private"}')}, + {"name": "wait", "argument_chars": len('{"seconds":1}')}, + ] + assert attempt.data["response_status"] == "completed" + assert "private" not in json.dumps(engine.records, default=str) + assert "secret" not in json.dumps(engine.records, default=str) + + +@pytest.mark.asyncio +async def test_timeout_during_tool_generation_keeps_last_output_and_tool_name(monkeypatch): + engine = Engine() + monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine) + llm._ENDPOINT_BREAKER._states.clear() + calls = 0 + + class Model: + async def astream(self, *_args, **_kwargs): + nonlocal calls + calls += 1 + yield AIMessageChunk( + content=[], + tool_call_chunks=[{"name": "click", "args": '{"x":', "id": "c1", "index": 0}], + ) + if calls == 1: + await asyncio.sleep(10) + yield AIMessageChunk(content=[], tool_call_chunks=[{"args": "1}", "index": 0}]) + + monkeypatch.setattr( + llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 0, max_attempts=2) + ) + wrapper = llm.RobustChatModelWrapper( + Model(), endpoint=ModelEndpoint(timeout_seconds=0.03, max_retries=1, max_tokens=12000) + ) + await wrapper.complete([]) + failed = next( + r["payload"] + for r in engine.records + if r["type"] == "llm_diagnostic" and r["payload"]["phase"] == "failed" + ) + assert failed["failure_phase"] == "generating_tool_arguments" + assert failed["last_output_at"] >= failed["first_output_at"] + assert failed["tools"] == [{"name": "click", "argument_chars": 5}] + assert failed["usage"] is None + assert failed["max_output_tokens"] == 12000 + + +@pytest.mark.asyncio +async def test_concurrent_requests_do_not_mix_server_ids(): + engine = Engine() + + async def run(name): + attempt = RequestAttempt( + engine, request_id=name, attempt=1, timeout=5, max_retries=0, metadata={"model": name} + ) + token = ACTIVE_ATTEMPT.set(attempt) + try: + await asyncio.sleep(0) + await observe_response_headers(httpx.Response(200, headers={"x-request-id": name})) + finally: + ACTIVE_ATTEMPT.reset(token) + return attempt.data + + first, second = await asyncio.gather(run("one"), run("two")) + assert first["server_request_id"] == "one" + assert second["server_request_id"] == "two" + + +def test_error_redaction_and_image_headers(): + import base64 + import io + from PIL import Image + + out = io.BytesIO() + Image.new("RGB", (32, 48)).save(out, "PNG") + message = HumanMessage( + content=[ + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64," + base64.b64encode(out.getvalue()).decode() + }, + } + ] + ) + assert input_images(([message],), {}) == [{"width": 32, "height": 48}] + text = error_text( + RuntimeError("Authorization: Bearer sk-my-secret https://host/path?token=private") + ) + assert "sk-my-secret" not in text and "private" not in text + assert error_text(RuntimeError()) == "RuntimeError" + + +@pytest.mark.parametrize( + "kind, key", [("llm_diagnostic", "requests"), ("final_review_diagnostic", "final_reviews")] +) +def test_snapshots_survive_out_of_order_writes_and_export(tmp_path, kind, key): + from artemis.data_engine.models import TraceRecord + from artemis.data_engine.storage import StorageManager + from apps.admin_console.database.connection import db_session + from apps.admin_console.services.request_diagnostics import request_diagnostics + + path = tmp_path / "data.db" + storage = StorageManager(db_path=path, base_trace_dir=tmp_path) + session_id, trace_id = uuid4(), uuid4() + with db_session(path) as conn: + conn.execute( + "INSERT INTO sessions(session_id,status) VALUES (?, 'completed')", (str(session_id),) + ) + conn.commit() + for sequence, phase in [(3, "completed"), (2, "receiving_output"), (1, "waiting_output")]: + storage.create_trace( + TraceRecord( + trace_id=trace_id, + session_id=session_id, + type=kind, + name="llm_request", + timestamp=10 + sequence, + status="success" if sequence == 3 else "running", + payload={ + "sequence": sequence, + "phase": phase, + "status": "completed" if sequence == 3 else "running", + "request_id": "call-one", + "started_at": 10, + "ended_at": 13 if sequence == 3 else None, + }, + ) + ) + result = request_diagnostics(str(session_id), db_path=path) + assert len(result[key]) == 1 + assert result[key][0]["phase"] == "completed" + assert result[key][0]["active"] is False + assert result[key][0]["timestamps_utc"]["started_at"].endswith("+00:00") + assert result["total"] == (1 if kind == "llm_diagnostic" else 0) + + +def test_review_diagnostics_show_evidence_stage_without_a_live_model_request(tmp_path, monkeypatch): + from artemis.data_engine.storage import StorageManager + from apps.admin_console.database.connection import db_session + from apps.admin_console.services import request_diagnostics as service + + path = tmp_path / "data.db" + StorageManager(db_path=path, base_trace_dir=tmp_path) + monkeypatch.setattr(service.time, "time", lambda: 130) + with db_session(path) as conn: + conn.execute("INSERT INTO sessions(session_id,status) VALUES ('task', 'running')") + value = { + "review_id": "final-review", + "status": "running", + "stage": "reading_evidence", + "started_at": 100, + "deadline_at": 200, + "total_seconds": 100, + "updated_at": 110, + } + conn.execute( + "INSERT INTO traces(trace_id,session_id,type,timestamp,payload) " + "VALUES ('review', 'task', 'final_review_diagnostic', 100, ?)", + (json.dumps(value),), + ) + conn.commit() + result = service.request_diagnostics("task", db_path=path, limit=100) + assert result["requests"] == [] + review = result["final_reviews"][0] + assert review["active"] is True + assert review["stage"] == "reading_evidence" + assert review["elapsed_seconds"] == 30 + assert review["remaining_seconds"] == 70 + assert review["updated_at"] == 110 # preserve the actual last persisted event time + assert review["timestamps_utc"]["deadline_at"].endswith("+00:00") + assert ( + service.request_diagnostics("task", db_path=path, offset=1)["final_reviews"][0]["active"] + is False + ) + + +@pytest.mark.asyncio +async def test_cancellation_closes_attempt_and_does_not_retry(monkeypatch): + engine = Engine() + monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine) + llm._ENDPOINT_BREAKER._states.clear() + + async def cancelled(): + raise asyncio.CancelledError() + + with pytest.raises(asyncio.CancelledError): + await llm._run_with_recovery(cancelled, max_retries=5) + assert engine.records[-1]["payload"]["phase"] == "cancelled" + assert ACTIVE_ATTEMPT.get() is None + + +@pytest.mark.asyncio +async def test_cancelling_retry_wait_does_not_leave_a_live_request(monkeypatch): + waiting = asyncio.Event() + + class RetryEngine(Engine): + def record_trace(self, **record): + result = super().record_trace(**record) + if record["payload"].get("phase") == "retry_wait": + waiting.set() + return result + + engine = RetryEngine() + monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine) + monkeypatch.setattr( + llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 60, max_attempts=2) + ) + + async def timeout(): + raise TimeoutError() + + task = asyncio.create_task(llm._run_with_recovery(timeout, max_retries=1)) + await asyncio.wait_for(waiting.wait(), 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert engine.records[-1]["payload"]["phase"] == "cancelled" + assert "模型请求" in engine.records[-1]["payload"]["error"] + + +def test_diagnostic_api_limits_preview_but_downloads_complete_history(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from apps.admin_console.database import connection + from apps.admin_console.routers.sessions import router + from artemis.data_engine.storage import StorageManager + + path = tmp_path / "data.db" + StorageManager(db_path=path, base_trace_dir=tmp_path) + monkeypatch.setattr(connection, "DB_PATH", path) + with connection.db_session() as conn: + conn.execute("INSERT INTO sessions(session_id,status) VALUES ('task', 'running')") + for i in range(105): + value = { + "request_id": "one-request", + "attempt_id": str(i), + "started_at": i, + "phase": "completed" if i == 104 else "retry_wait", + } + conn.execute( + "INSERT INTO traces(trace_id,session_id,type,timestamp,payload) VALUES (?, 'task', 'llm_diagnostic', ?, ?)", + (str(i), i, json.dumps(value)), + ) + conn.commit() + app = FastAPI() + app.include_router(router) + with TestClient(app) as client: + preview = client.get("/api/sessions/task/request-diagnostics") + assert preview.status_code == 200 + assert len(preview.json()["requests"]) == 100 + assert not any(r["active"] for r in preview.json()["requests"]) + download = client.get("/api/sessions/task/request-diagnostics?download=true") + assert download.headers["content-disposition"].startswith("attachment;") + assert len(download.json()["requests"]) == 105 + assert download.json()["session_id"] == "task" diff --git a/tests/unit/test_task_inspection_mcp.py b/tests/unit/test_task_inspection_mcp.py new file mode 100644 index 0000000..0b66ff2 --- /dev/null +++ b/tests/unit/test_task_inspection_mcp.py @@ -0,0 +1,257 @@ +"""Read-only diagnostics and destructive-action gates against disposable task data.""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient +import pytest + +from apps.admin_console.database.repositories.session_repository import SessionRepository +from apps.admin_console.routers import sessions +from apps.admin_console.services import report_export, task_deletion, task_inspection +from apps.admin_console.services.automation_store import AutomationStore +from apps.admin_console.services.request_diagnostics import request_diagnostics +from artemis.data_engine.storage import StorageManager +from artemis.runtime import trace_store +from mcp_server.tools import automation, exploration, inspect_trace, task_manager + + +@pytest.fixture +def task(tmp_path, monkeypatch): + sid = str(uuid4()) + db = tmp_path / "data_engine.db" + StorageManager(db, tmp_path) + store = AutomationStore(db) + repo = SessionRepository(db) + with store.connection() as connection: + connection.execute( + "INSERT INTO sessions (session_id, initial_goal, start_time, status, device_info) " + "VALUES (?, 'observe', 1, 'completed', '{}')", + (sid,), + ) + connection.commit() + monkeypatch.setattr(task_inspection, "session_repo", repo) + monkeypatch.setattr(task_inspection, "store", store) + monkeypatch.setattr(task_inspection.evidence_service, "task_root", lambda _: tmp_path / sid) + monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) + monkeypatch.setattr(report_export, "store", store) + monkeypatch.setattr(report_export, "TRACES_PATH", tmp_path) + (tmp_path / sid).mkdir() + return sid, db, tmp_path, store + + +def add_trace(store, sid, kind, name, payload, timestamp=1): + with store.connection() as connection: + connection.execute( + "INSERT INTO traces (trace_id,session_id,type,name,payload,timestamp) VALUES (?,?,?,?,?,?)", + (str(uuid4()), sid, kind, name, json.dumps(payload), timestamp), + ) + connection.commit() + + +def test_usage_uses_input_denominator_and_does_not_invent_missing_model(task): + sid, _, _, store = task + assert task_inspection.inspect_task(sid, "usage")["cache_hit_rate"] is None + add_trace( + store, + sid, + "llm_call", + "llm_usage", + { + "node": "operator", + "model": "model-a", + "prompt_tokens": 1000, + "cached_tokens": 900, + "completion_tokens": 500, + }, + ) + add_trace( + store, + sid, + "llm_call", + "llm_usage", + { + "node": "checker", + "prompt_tokens": 1000, + "cached_tokens": 0, + "completion_tokens": 500, + }, + ) + result = task_inspection.inspect_task(sid, "usage") + assert result["cache_hit_rate"] == 0.45 + assert result["total_tokens"] == 3000 + assert result["by_node_model"][1]["model"] == "unknown" + + +def test_log_tail_is_bounded_paginated_and_redacted(task, monkeypatch): + sid, _, root, _ = task + monkeypatch.setenv("TEST_API_KEY", "test-only-sensitive-value") + lines = ["x" * 200 for _ in range(1000)] + [ + "old", + "test-only-sensitive-value", + "Bearer abcdef", + "password=hush", + ] + (root / sid / "stderr.log").write_text("\n".join(lines), "utf-8") + result = task_inspection.inspect_task(sid, "logs", limit=2) + error = result["logs"][1] + assert error["older_bytes_omitted"] > 0 + assert error["next_offset"] == 2 + assert error["lines"] == ["Bearer [REDACTED]", "password=[REDACTED]"] + older = task_inspection.inspect_task(sid, "logs", offset=2, limit=2) + assert older["logs"][1]["lines"] == ["old", "[REDACTED]"] + assert result["logs"][0]["available"] is False + + +def test_report_pagination_preserves_total_and_caps_nested_content(task, monkeypatch): + sid, _, _, _ = task + monkeypatch.setattr( + report_export, + "session_report", + lambda _: { + "steps": [{"number": i} for i in range(35)], + "content": "x" * 100000, + "metadata": {"api_key": "hidden"}, + "nested": [[{"value": "x" * 100} for _ in range(1000)] for _ in range(1000)], + }, + ) + result = task_inspection.inspect_task(sid, "report", offset=20, limit=10) + assert result["steps"]["items"] == [{"number": i} for i in range(20, 30)] + assert result["steps"]["total"] == 35 + assert result["steps"]["next_offset"] == 30 + assert result["metadata"]["api_key"] == "[REDACTED]" + assert result["content_truncated"] is True + assert len(json.dumps(result, ensure_ascii=False)) < 150000 + + +def test_request_pagination_reports_latest_phase_only(task): + sid, db, _, store = task + with store.connection() as connection: + connection.execute("UPDATE sessions SET status='running' WHERE session_id=?", (sid,)) + connection.commit() + add_trace( + store, sid, "llm_diagnostic", "request", {"request_id": "r", "phase": "waiting_response"}, 1 + ) + add_trace(store, sid, "llm_diagnostic", "request", {"request_id": "r", "phase": "completed"}, 2) + assert request_diagnostics(sid, db_path=db, limit=1)["next_offset"] == 1 + older = request_diagnostics(sid, db_path=db, limit=1, offset=1) + assert older["requests"][0]["active"] is False + assert older["next_offset"] is None + + +def test_status_and_report_use_same_human_review_without_losing_ai_result(task): + sid, db, root, store = task + (root / sid / "run_outcome.json").write_text( + json.dumps( + { + "test_result": { + "status": "failed", + "reason": "AI found mismatch", + "evidence": ["step 1: actual differs from expected"], + }, + } + ), + "utf-8", + ) + store.put( + "case_review", + sid, + {"reviews": [{"status": "passed", "reason": "用户确认", "reviewed_at": 10}]}, + ) + result = task_manager.mobile_manage_task("status", sid)["test_result"] + assert result == report_export.session_report(sid, db_path=db)["test_result"] + assert result["source"] == "human" + assert result["ai_result"]["status"] == "failed" + + +def test_inspection_not_found_and_pagination_validation(task): + app = FastAPI() + app.include_router(sessions.router) + client = TestClient(app) + assert client.get(f"/api/sessions/{uuid4()}/inspection").status_code == 404 + assert client.get(f"/api/sessions/{task[0]}/inspection?limit=101").status_code == 422 + + +def test_active_normal_task_delete_is_rejected_without_touching_history(task, monkeypatch): + sid, _, root, store = task + monkeypatch.setattr(task_deletion, "session_repo", task_inspection.session_repo) + monkeypatch.setattr(task_deletion, "store", store) + monkeypatch.setattr(task_deletion, "_check_runs", lambda _: None) + store.save_job({"session_id": sid, "status": "running", "position": 1}) + app = FastAPI() + app.include_router(sessions.router) + client = TestClient(app) + assert client.get(f"/api/sessions/{sid}/deletion-preview").status_code == 409 + assert client.post(f"/api/sessions/{sid}/delete").status_code == 409 + assert (root / sid).is_dir() + assert store.job(sid)["status"] == "running" + + +def test_mcp_review_and_delete_require_user_intent_and_share_http_backend(monkeypatch): + request = AsyncMock(return_value={"test_result": {"source": "human"}}) + monkeypatch.setattr(automation, "_request", request) + for action in ("review", "delete"): + with pytest.raises(ValueError, match="用户"): + task_manager.mobile_manage_task(action, "task") + request.assert_not_awaited() + result = task_manager.mobile_manage_task( + "review", "task", review_status="passed", review_reason="确认", user_confirmed=True + ) + assert result["test_result"]["source"] == "human" + request.assert_awaited_once_with( + "PUT", "/api/automation/reports/task/review", json={"status": "passed", "reason": "确认"} + ) + + +@pytest.mark.asyncio +async def test_mcp_read_inspection_and_exploration_delete_routes(monkeypatch): + request = AsyncMock(return_value={"ok": True}) + monkeypatch.setattr(automation, "_request", request) + monkeypatch.setattr(exploration, "_request", request) + await inspect_trace.mobile_inspect_trace("view_requests", "task", offset=20, limit=10) + request.assert_awaited_with( + "GET", + "/api/sessions/task/inspection", + params={"view": "requests", "offset": 20, "limit": 10}, + ) + with pytest.raises(ValueError, match="用户"): + await exploration.mobile_exploration("delete", exploration_id="e") + await exploration.mobile_exploration("delete", exploration_id="e", user_confirmed=True) + request.assert_awaited_with("DELETE", "/api/explorations/e") + + +@pytest.mark.asyncio +async def test_plugin_probe_only_targets_installed_plugin(monkeypatch): + async def request(method, path, **kwargs): + return {"plugins": [{"manifest": {"id": "demo"}}]} if path == "/api/plugins" else {} + + mocked = AsyncMock(side_effect=request) + monkeypatch.setattr(automation, "_request", mocked) + result = await automation.mobile_workspace( + include_devices=False, plugin_id="demo", probe_plugin=True, include_development=True + ) + assert result["plugins"][0]["manifest"]["id"] == "demo" + assert result["development"]["plugin_schema_version"] == 2 + assert [call.args for call in mocked.call_args_list if call.args[0] == "POST"] == [ + ("POST", "/api/plugins/demo/diagnose") + ] + + +def test_exploration_deletion_preview_keeps_validation_runs(monkeypatch): + repository = SimpleNamespace( + get=lambda _: {}, + runs=lambda _: [ + {"id": "explore-run", "kind": "exploration"}, + {"id": "rerun", "kind": "validation"}, + ], + ) + monkeypatch.setattr(task_deletion, "explorations", repository) + check = Mock() + monkeypatch.setattr(task_deletion, "_check_runs", check) + result = task_deletion.exploration_deletion_preview("e") + assert result["session_ids"] == ["explore-run"] + assert result["preserved_session_ids"] == ["rerun"] + check.assert_called_once() diff --git a/tests/unit/test_unified_controller_video.py b/tests/unit/test_unified_controller_video.py index d89788d..2722734 100644 --- a/tests/unit/test_unified_controller_video.py +++ b/tests/unit/test_unified_controller_video.py @@ -714,6 +714,32 @@ async def test_start_recording_anchors_timeline_to_first_frame(mock_ctx, tmp_pat remove_active_session("emulator-5554") +@pytest.mark.asyncio +async def test_unconfirmed_recording_start_fails_and_closes_own_process(mock_ctx, tmp_path): + controller = UnifiedMobileController(mock_ctx) + remove_active_session("emulator-5554") + proc = _FakeScrcpyProcess([]) + with ( + patch.object(controller, "_spawn_scrcpy", AsyncMock(return_value=proc)), + patch.object(controller, "_stop_scrcpy", AsyncMock()) as stop, + patch( + "artemis.controllers.unified_controller.get_android_display_state", + AsyncMock(return_value=None), + ), + patch( + "artemis.controllers.unified_controller.await_scrcpy_first_frame", + AsyncMock(side_effect=TimeoutError("no first frame")), + ) as first, + ): + result = await controller.start_video_recording(output_dir=tmp_path) + assert not result.success + assert "no first frame" in result.message + assert first.call_args.kwargs["require_marker"] is True + stop.assert_awaited() + assert get_active_session("emulator-5554") is None + mock_ctx.data_engine.record_video_failure.assert_called_once() + + @pytest.mark.asyncio async def test_next_segment_anchors_at_first_frame(mock_ctx, tmp_path): controller = UnifiedMobileController(mock_ctx) diff --git a/tests/unit/test_window_capture.py b/tests/unit/test_window_capture.py new file mode 100644 index 0000000..3c27017 --- /dev/null +++ b/tests/unit/test_window_capture.py @@ -0,0 +1,19 @@ +"""Blank-capture guard without opening a desktop or native capture session.""" + +import numpy as np + +from artemis.plugins.window_capture import has_picture + + +def test_black_or_cursor_only_frame_is_not_ready(): + frame = np.zeros((256, 256, 4), dtype=np.uint8) + frame[:, :, 3] = 255 + assert not has_picture(frame) + frame[0, 0, :3] = 255 + assert not has_picture(frame) + + +def test_dark_page_with_visible_controls_is_valid(): + frame = np.zeros((256, 256, 4), dtype=np.uint8) + frame[40:60, 40:120, :3] = 50 + assert has_picture(frame) diff --git a/tests/unit/tools/history/test_search.py b/tests/unit/tools/history/test_search.py index e899bc1..eae5f40 100644 --- a/tests/unit/tools/history/test_search.py +++ b/tests/unit/tools/history/test_search.py @@ -56,6 +56,9 @@ def _make_engine(tmp_path): mock_ctx.execution_setup = mock_execution_setup mock_ctx.device = None engine = DataEngine(mock_ctx) + # These tests exercise search contents, not the storage scheduler. Background + # publishers can enqueue another thread while _flush is taking its snapshot. + engine._run_in_background = lambda function, *args: function(*args) engine.start_session("search test session") return engine diff --git a/tests/unit/utils/test_app_launch_utils.py b/tests/unit/utils/test_app_launch_utils.py index 7c988ab..908bf10 100644 --- a/tests/unit/utils/test_app_launch_utils.py +++ b/tests/unit/utils/test_app_launch_utils.py @@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, Mock, patch from artemis.context import ArtemisContext +from artemis.drivers.errors import AppLaunchError from artemis.utils.app_launch_utils import ( ForegroundTask, _handle_initial_app_launch, @@ -375,7 +376,7 @@ async def test_launch_timeout_message_names_foreground_package_and_task( mock_get_task.return_value = parse_foreground_task(DUMP_SEARCH_IN_OWN_TASK) with patch("artemis.utils.app_launch_utils.logger") as mock_logger: - success, _ = await launch_app_with_retries( + success, detail = await launch_app_with_retries( mock_context, SETTINGS, max_retries=1, max_poll_seconds=1 ) @@ -383,7 +384,7 @@ async def test_launch_timeout_message_names_foreground_package_and_task( timeout_messages = [ call.args[0] for call in mock_logger.error.call_args_list - if "Timeout waiting" in call.args[0] + if call.args[0].startswith("Timeout waiting") ] assert len(timeout_messages) == 1 message = timeout_messages[0] @@ -391,6 +392,7 @@ async def test_launch_timeout_message_names_foreground_package_and_task( assert "task #120" in message assert f"base={SETTINGS_SEARCH}" in message assert f"resumed={SETTINGS_SEARCH}/.modules.search.SearchActivity" in message + assert message in detail @pytest.mark.asyncio @@ -418,6 +420,56 @@ async def test_launch_app_null_focus_keeps_polling_without_reading_task( # --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_deterministic_launch_failure_skips_polling_and_retry(mock_context): + error = AppLaunchError("am 启动失败:Permission Denial(退出码 1)") + with ( + patch("artemis.utils.app_launch_utils.UnifiedMobileController") as controller_cls, + patch("artemis.utils.app_launch_utils._poll_for_app_ready", new_callable=AsyncMock) as poll, + ): + controller = _controller(controller_cls) + controller.launch_app.side_effect = error + result, detail = await launch_app_with_retries(mock_context, SETTINGS) + assert result is False + assert detail == str(error) + controller.launch_app.assert_awaited_once() + controller.terminate_app.assert_not_called() + poll.assert_not_called() + + +@pytest.mark.asyncio +async def test_start_acknowledgement_timeout_checks_foreground_before_relaunch(mock_context): + with ( + patch("artemis.utils.app_launch_utils.UnifiedMobileController") as controller_cls, + patch("artemis.utils.app_launch_utils._poll_for_app_ready", new_callable=AsyncMock) as poll, + ): + controller = _controller(controller_cls) + controller.launch_app.side_effect = AppLaunchError( + "ADB timeout", retryable=True, may_have_started=True + ) + poll.return_value = (True, None) + assert await launch_app_with_retries(mock_context, SETTINGS) == (True, None) + controller.launch_app.assert_awaited_once() + controller.terminate_app.assert_not_called() + poll.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_transient_launch_failure_is_bounded_and_keeps_cause(mock_context): + with ( + patch("artemis.utils.app_launch_utils.UnifiedMobileController") as controller_cls, + patch("artemis.utils.app_launch_utils._poll_for_app_ready", new_callable=AsyncMock) as poll, + patch("artemis.utils.app_launch_utils.asyncio.sleep", new_callable=AsyncMock), + ): + controller = _controller(controller_cls) + controller.launch_app.side_effect = AppLaunchError("device offline", retryable=True) + result, detail = await launch_app_with_retries(mock_context, SETTINGS) + assert result is False + assert detail == "device offline" + assert controller.launch_app.await_count == 3 + poll.assert_not_called() + + @pytest.mark.asyncio @patch("artemis.utils.app_launch_utils.launch_app_with_retries") @patch("artemis.utils.app_launch_utils.get_foreground_task") diff --git a/tests/unit/utils/test_notes.py b/tests/unit/utils/test_notes.py index 6671c6d..2c82c84 100644 --- a/tests/unit/utils/test_notes.py +++ b/tests/unit/utils/test_notes.py @@ -17,11 +17,34 @@ from artemis.utils.notes import ( list_notes_info, read_note_content, save_note_content, + save_verified_note, update_note_content, ) import pytest +@pytest.mark.parametrize("content", ["# 报告\n结果\n", "line\r\nsecond", ""]) +def test_verified_note_receipt_matches_actual_file_bytes(tmp_path, content): + import hashlib + + receipt = save_verified_note(tmp_path, "report.md", content) + data = (tmp_path / receipt["path"]).read_bytes() + assert receipt["path"] == "notes/report.md" + assert receipt["verified"] is True and receipt["bytes"] == len(data) + assert receipt["sha256"] == hashlib.sha256(data).hexdigest() + + +def test_verified_note_does_not_confirm_incomplete_write(tmp_path, monkeypatch): + from artemis.utils import notes + + def partial_write(base_dir, key, content): + save_note_content(base_dir, key, content[:1]) + + monkeypatch.setattr(notes, "save_note_content", partial_write) + with pytest.raises(OSError, match="读回不一致"): + save_verified_note(tmp_path, "report", "完整报告") + + def test_list_notes_info(tmp_path): base_dir = tmp_path diff --git a/tests/unit/utils/test_video_validation.py b/tests/unit/utils/test_video_validation.py new file mode 100644 index 0000000..8f9a22e --- /dev/null +++ b/tests/unit/utils/test_video_validation.py @@ -0,0 +1,206 @@ +"""Reject corrupt recording timelines and recover only actually captured frames.""" + +import asyncio +import json +from pathlib import Path +import subprocess +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from artemis.utils import video + + +def metadata(**updates): + return { + "streams": [ + { + "width": 640, + "height": 360, + "duration": "1.0", + "start_time": "0", + "nb_frames": "15", + "avg_frame_rate": "15/1", + } + ], + "format": {"duration": "1.0"}, + "packets": [{"pts_time": "0.0", "flags": "K__"}, {"pts_time": "0.9", "flags": "___"}], + **updates, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "change", + ["microsecond-duration", "pts-outside-duration", "nan-duration", "no-packets", "no-dimensions"], +) +async def test_probe_rejects_invalid_timeline_despite_valid_container_exit(monkeypatch, change): + payload = metadata() + stream = payload["streams"][0] + if change == "microsecond-duration": + stream.update(duration="0.000375", avg_frame_rate="56000/3", nb_frames="7") + elif change == "pts-outside-duration": + payload["packets"][-1]["pts_time"] = "3" + elif change == "nan-duration": + stream["duration"] = "NaN" + elif change == "no-packets": + payload["packets"] = [] + else: + stream["width"] = 0 + process = SimpleNamespace( + returncode=0, communicate=AsyncMock(return_value=(json.dumps(payload).encode(), b"")) + ) + monkeypatch.setattr(video.asyncio, "create_subprocess_exec", AsyncMock(return_value=process)) + assert await video.probe_video_segment(Path("invalid.mp4")) == {} + + +@pytest.mark.asyncio +async def test_probe_accepts_vfr_timing_without_inventing_extra_duration(monkeypatch): + payload = metadata() + payload["streams"][0].update(duration="8", avg_frame_rate="1/4", nb_frames="2") + payload["packets"][-1]["pts_time"] = "7.9" + process = SimpleNamespace( + returncode=0, communicate=AsyncMock(return_value=(json.dumps(payload).encode(), b"")) + ) + monkeypatch.setattr(video.asyncio, "create_subprocess_exec", AsyncMock(return_value=process)) + assert (await video.probe_video_segment(Path("valid.mp4")))["duration"] == 8 + + +@pytest.mark.asyncio +async def test_remux_never_publishes_invalid_output_and_preserves_source(tmp_path, monkeypatch): + source, output = tmp_path / "source.mkv", tmp_path / "recording.mp4" + source.write_bytes(b"original recording") + commands = [] + + async def spawn(*command, **_kwargs): + commands.append(command) + Path(command[-1]).write_bytes(b"nonempty but invalid") + return SimpleNamespace(returncode=0, communicate=AsyncMock(return_value=(b"", b""))) + + monkeypatch.setattr(video.asyncio, "create_subprocess_exec", spawn) + monkeypatch.setattr(video, "probe_video_segment", AsyncMock(return_value={})) + assert not await video.remux_recording_to_mp4(source, output) + assert source.read_bytes() == b"original recording" + assert not output.exists() + assert not output.with_name("recording.part.mp4").exists() + assert len(commands) == 2 + fallback = commands[1] + assert fallback[fallback.index("-fps_mode") + 1] == "passthrough" + assert "-r" not in fallback + assert "setpts=PTS-STARTPTS" in fallback + + +@pytest.mark.asyncio +async def test_bounded_media_wait_reaps_process_on_timeout(): + async def stalled(): + await asyncio.Future() + + process = SimpleNamespace( + returncode=None, communicate=stalled, kill=Mock(), wait=AsyncMock(return_value=-9) + ) + with pytest.raises(TimeoutError): + await video._communicate_video_process(process, 0.01) + process.kill.assert_called_once() + process.wait.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_strict_scrcpy_start_rejects_eof_without_recording_marker(): + reader = asyncio.StreamReader() + reader.feed_data(b"INFO: connection initialized\n") + reader.feed_eof() + process = SimpleNamespace(stdout=reader, wait=AsyncMock(return_value=0)) + with pytest.raises(OSError, match="未确认开始录制"): + await video.await_scrcpy_first_frame(process, 100, require_marker=True) + + +@pytest.mark.asyncio +async def test_strict_scrcpy_start_rejects_silent_process_timeout(): + process = SimpleNamespace(stdout=asyncio.StreamReader()) + with pytest.raises(TimeoutError, match="启动时限"): + await video.await_scrcpy_first_frame(process, 100, timeout=0.01, require_marker=True) + + +@pytest.mark.asyncio +async def test_strict_scrcpy_start_accepts_actual_marker(): + reader = asyncio.StreamReader() + reader.feed_data(b"INFO: Recording started to file: recording.mkv\n") + process = SimpleNamespace(stdout=reader) + assert await video.await_scrcpy_first_frame(process, 100, require_marker=True) >= 100 + + +def run_media(*command): + return subprocess.run(command, check=True, capture_output=True, timeout=30).stdout + + +def frame_times(path): + payload = json.loads( + run_media( + video.get_ffprobe_path(), + "-v", + "error", + "-select_streams", + "v:0", + "-show_frames", + "-show_entries", + "frame=pts_time", + "-of", + "json", + str(path), + ) + ) + return [float(frame["pts_time"]) for frame in payload["frames"]] + + +@pytest.mark.asyncio +async def test_real_remux_recovers_pts_without_filling_missing_capture_time(tmp_path): + ffmpeg = video.get_ffmpeg_path() + clean = tmp_path / "clean.mkv" + corrupt = tmp_path / "corrupt.mp4" + restored = tmp_path / "restored.mp4" + run_media( + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=size=64x96:rate=10:duration=0.7", + "-c:v", + "libx264", + "-bf", + "0", + "-pix_fmt", + "yuv420p", + str(clean), + ) + # Preserve original frame PTS while compressing decode timestamps to 1 ms. + run_media( + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(clean), + "-map", + "0:v:0", + "-c", + "copy", + "-bsf:v", + "setts=dts=N:duration=1", + str(corrupt), + ) + assert await video.probe_video_segment(corrupt) == {} + captured = frame_times(corrupt) + assert await video.remux_recording_to_mp4(corrupt, restored) + result = await video.probe_video_segment(restored) + assert 0.4 <= result["duration"] <= 0.8 + recovered = frame_times(restored) + assert recovered + assert len(recovered) <= len(captured) + assert recovered == pytest.approx(captured[: len(recovered)], abs=0.001) + assert corrupt.exists() diff --git a/uv.lock b/uv.lock index f56add0..845d221 100644 --- a/uv.lock +++ b/uv.lock @@ -206,18 +206,20 @@ wheels = [ [[package]] name = "artemis" -version = "1.5.2" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "adbutils" }, { name = "artemis-client" }, { name = "colorama" }, { name = "fastapi" }, + { name = "filelock" }, { name = "httpx" }, { name = "imageio-ffmpeg" }, { name = "inquirer" }, { name = "ipykernel" }, { name = "jinja2" }, + { name = "jsonschema" }, { name = "jupyter-client" }, { name = "langchain" }, { name = "langchain-community" }, @@ -230,6 +232,7 @@ dependencies = [ { name = "numpy" }, { name = "opencv-python" }, { name = "openpyxl" }, + { name = "packaging" }, { name = "pillow" }, { name = "posthog" }, { name = "psutil" }, @@ -248,6 +251,7 @@ dependencies = [ { name = "uuid-utils" }, { name = "uvicorn", extra = ["standard"] }, { name = "websockets" }, + { name = "windows-capture", marker = "platform_machine == 'AMD64' and sys_platform == 'win32'" }, ] [package.optional-dependencies] @@ -277,11 +281,13 @@ requires-dist = [ { name = "artemis-client", editable = "packages/artemis-client" }, { name = "colorama", specifier = ">=0.4.6" }, { name = "fastapi", specifier = ">=0.111.0" }, + { name = "filelock", specifier = ">=3.20.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "imageio-ffmpeg", specifier = ">=0.5.1" }, { name = "inquirer", specifier = ">=3.4.0" }, { name = "ipykernel", specifier = ">=6.29.0" }, { name = "jinja2", specifier = ">=3.1.5" }, + { name = "jsonschema", specifier = ">=4.20.0" }, { name = "jupyter-client", specifier = ">=8.6.0" }, { name = "langchain", specifier = ">=1.0.0" }, { name = "langchain-community", specifier = ">=0.4.1" }, @@ -294,6 +300,7 @@ requires-dist = [ { name = "numpy", specifier = ">=1.26.0" }, { name = "opencv-python", specifier = ">=4.9.0.80" }, { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "packaging", specifier = ">=24.0" }, { name = "pillow", specifier = ">=10.0.0" }, { name = "posthog", specifier = ">=3.5.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, @@ -319,6 +326,7 @@ requires-dist = [ { name = "uuid-utils", specifier = ">=0.9.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.1" }, { name = "websockets", specifier = ">=12.0" }, + { name = "windows-capture", marker = "platform_machine == 'AMD64' and sys_platform == 'win32'", specifier = ">=2.0.1,<3" }, ] provides-extras = ["dev"] @@ -4072,6 +4080,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "windows-capture" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/00/326a51bc2b27bc53e2a3e6d0c8f320af7e65f8315fb0d1d149699ca45995/windows_capture-2.0.1.tar.gz", hash = "sha256:2c2b394f4587b067cf520456f7f64c11b00380851b077a4d061fe5356a9a7645", size = 148413, upload-time = "2026-08-08T16:07:07.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/02/cdb3c75e60863e94fbd53c2b3607e61af5d464a4f9301e45897fcb08dbfa/windows_capture-2.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:209c2318e2168802b9afd1005e99499639e8835e71a1e188a3ddc77c7ccc3f04", size = 229121, upload-time = "2026-08-08T16:07:04.283Z" }, +] + [[package]] name = "xmod" version = "1.10.0"