Files
Artemis-Eapil/tests/unit/test_task_inspection_mcp.py
chenmingxuan e1ea099592 feat: add official Codex skill and harden task execution
Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
2026-10-18 14:18:37 +08:00

258 lines
10 KiB
Python

"""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()