"""Integration tests for exploration settlement on campaign finalize (v0.9 票据 07). Covers both terminal transitions: window completion (durable loop COMPLETE) and mid-flight cancellation. Running sessions must turn expired and reject further messages (409), leaving no dangling sessions. """ import asyncio import pytest from agenteval.evaluation import campaign_runner as runner_module from agenteval.evaluation.campaign_runner import CampaignRuntime from agenteval.exploration.models import ExplorationSessionStatus from agenteval.models import ( Campaign, CampaignPlanEntry, Case, CaseType, ChannelType, EvalTarget, PlatformType, Scenario, TargetStatus, ) from agenteval.storage.db import utc_now from agenteval.storage.repository import ( CampaignRepository, ExplorationSessionRepository, ScenarioRepository, TargetRepository, ) from agenteval.web.app import app from httpx import ASGITransport, AsyncClient def _session_payload(campaign_id: str) -> dict: return { "campaign_id": campaign_id, "persona": {"name": "巡检用户", "traits": ["耐心"]}, "goal": "查询账单", "triggered_by": "manual", } @pytest.fixture() def seeded_db(db_session, monkeypatch): """Point every get_session consumer at the test session and seed the target + scenario needed by both the loop path and the cancel path.""" from agenteval.channels import factory as factory_module from agenteval.evaluation import engine as engine_module from agenteval.exploration import lifecycle as exploration_lifecycle from agenteval.storage import db as db_module from agenteval.storage import repository as repo_module from agenteval.web import app as app_module monkeypatch.setattr(app_module, "init_db", lambda: None) def _test_get_session(): return db_session monkeypatch.setattr(db_module, "get_session", _test_get_session) monkeypatch.setattr(repo_module, "get_session", _test_get_session) monkeypatch.setattr(engine_module, "get_session", _test_get_session) monkeypatch.setattr(runner_module, "get_session", _test_get_session) monkeypatch.setattr(runner_module, "_auto_start_analysis", lambda campaign, session: None) from tests.unit.mock_channel import MockChannel channel = MockChannel(reply_delay=0.0) monkeypatch.setattr(factory_module.ChannelFactory, "create", lambda target: channel) monkeypatch.setattr(exploration_lifecycle, "start_judge_review", lambda session_id: None) from agenteval.web.deps import get_db def _test_get_db(): try: yield db_session finally: pass app.dependency_overrides[get_db] = _test_get_db target = EvalTarget( id="t-1", name="mock-target", platform=PlatformType.AI_DIGITAL_EMPLOYEE, channel_type=ChannelType.TUTU_API, channel_config={"base_url": "http://mock", "token": "x"}, status=TargetStatus.ACTIVE, ) TargetRepository(db_session).create(target) scenario = Scenario( id="s-1", name="mock-scenario", cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])], ) ScenarioRepository(db_session).create(scenario) yield db_session app.dependency_overrides.clear() def _make_campaign(db_session, campaign_id: str, *, time_scale: float) -> Campaign: return CampaignRepository(db_session).create( Campaign( id=campaign_id, name=f"campaign-{campaign_id}", target_id="t-1", window_seconds=3600, time_scale=time_scale, plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1)], status="running", started_at=utc_now(), ) ) async def _create_running_session(db_session, campaign_id: str) -> str: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: resp = await c.post("/api/exploration/sessions", json=_session_payload(campaign_id)) assert resp.status_code == 200, resp.text session_id = resp.json()["id"] assert ExplorationSessionRepository(db_session).get(session_id).status == (ExplorationSessionStatus.RUNNING) return session_id async def test_window_completion_expires_running_sessions(seeded_db): campaign = _make_campaign(seeded_db, "c-done", time_scale=3600.0) session_id = await _create_running_session(seeded_db, "c-done") # time_scale 3600 → 1 real second covers the whole window. runtime = CampaignRuntime(session_factory=lambda: seeded_db, tick_seconds=0.05) try: assert runtime.start(campaign.id) async with asyncio.timeout(5): while CampaignRepository(seeded_db).get(campaign.id).status.value == "running": await asyncio.sleep(0.05) finally: await runtime.shutdown() assert CampaignRepository(seeded_db).get("c-done").status.value == "completed" settled = ExplorationSessionRepository(seeded_db).get(session_id) assert settled.status == ExplorationSessionStatus.EXPIRED assert settled.closed_at is not None async def test_cancel_expires_running_sessions_and_rejects_messages(seeded_db): _make_campaign(seeded_db, "c-1", time_scale=1.0) session_id = await _create_running_session(seeded_db, "c-1") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post("/api/campaigns/c-1/cancel") assert resp.status_code == 200, resp.text assert resp.json()["status"] == "cancelled" settled = ExplorationSessionRepository(seeded_db).get(session_id) assert settled.status == ExplorationSessionStatus.EXPIRED assert settled.closed_at is not None resp = await client.post(f"/api/exploration/sessions/{session_id}/messages", json={"content": "还在吗"}) assert resp.status_code == 409 async def test_completed_session_survives_settlement(seeded_db): _make_campaign(seeded_db, "c-1", time_scale=1.0) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post("/api/exploration/sessions", json=_session_payload("c-1")) session_id = resp.json()["id"] resp = await client.post( f"/api/exploration/sessions/{session_id}/close", json={"experience": {"goal_achieved": True}}, ) assert resp.status_code == 200 await client.post("/api/campaigns/c-1/cancel") assert ExplorationSessionRepository(seeded_db).get(session_id).status == (ExplorationSessionStatus.COMPLETED) async def test_production_line_manual_trigger_also_settles(seeded_db): _make_campaign(seeded_db, "c-1", time_scale=1.0) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( "/api/exploration/sessions", json={**_session_payload("c-1"), "triggered_by": "manual"}, ) assert resp.status_code == 200, resp.text session_id = resp.json()["id"] assert resp.json()["triggered_by"] == "manual" # 正式线手动会话同样纳入终态结算,触发来源不影响收口。 await client.post("/api/campaigns/c-1/cancel") assert ExplorationSessionRepository(seeded_db).get(session_id).status == (ExplorationSessionStatus.EXPIRED)