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-terra" 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-terra" 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"]) == []