from datetime import datetime, timedelta, timezone from uuid import uuid4 import pytest from app.domain.domain_errors import DomainError from app.domain.schemas.operational_task import AgentArtifactsCommand from app.domain.schemas.operational_task import InvestigationDiagram, InvestigationGraphUpdate from app.domain.schemas.operational_data import TaskDataSnapshot from app.services.investigation_graph import update_investigation_graph from app.services.operational_tasks import OperationalTaskService from tests.support.operational_task_repository import ( TaskRepository, _active_run, _context, _task_document, ) @pytest.fixture def anyio_backend(): return "asyncio" def setup_service(): now = datetime(2026, 11, 10, tzinfo=timezone.utc) context = _context() task = _task_document(context, now, lease=now + timedelta(minutes=1)) repo = TaskRepository(task, _active_run(task, now)) return task, repo, OperationalTaskService(repo, context, set(), clock=lambda: now) async def publish(service, repo, task_id, *, nodes=(), edges=(), **progress): command = AgentArtifactsCommand.model_validate({ "request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"], "run_id": repo.run["run_id"], "progress": {"summary": "更新调查路线", "graph_update": { "nodes": list(nodes), "edges": list(edges), }, **progress}, }) return await service.publish_artifacts(task_id, command) @pytest.mark.anyio async def test_failed_path_is_retained_when_a_new_route_is_added(): task, repo, service = setup_service() await publish(service, repo, task["task_id"], nodes=[ {"node_id": "read", "label": "读取全量 SCADA", "status": "recorded"}, {"node_id": "locate_1", "label": "第一次候选定位", "status": "reference"}, ], edges=[{"source": "read", "target": "locate_1"}]) await publish(service, repo, task["task_id"], nodes=[ {"node_id": "locate_1", "label": "第一次候选定位", "status": "failed", "detail": "计算超时"}, {"node_id": "locate_2", "label": "调整窗口后重试", "status": "reference"}, ], edges=[{"source": "locate_1", "target": "locate_2", "label": "调整后重试"}]) result = await publish(service, repo, task["task_id"], nodes=[ {"node_id": "locate_2", "label": "调整窗口后重试", "status": "recorded"}, ]) diagram = result.task.latest_progress.progress.diagram assert [(n.node_id, n.status) for n in diagram.node_refs] == [ ("read", "recorded"), ("locate_1", "failed"), ("locate_2", "recorded"), ] assert diagram.node_refs[1].detail == "计算超时" assert len(diagram.edges) == 2 assert "第一次候选定位(失败)" in diagram.mermaid assert 'locate_1 -->|"调整后重试"| locate_2' in diagram.mermaid # A text-only update cannot clear the accumulated graph. summary = AgentArtifactsCommand.model_validate({ "request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"], "run_id": repo.run["run_id"], "progress": {"summary": "等待人工", "diagram": None}, }) assert (await service.publish_artifacts(task["task_id"], summary)).task.latest_progress.progress.diagram == diagram @pytest.mark.anyio async def test_failed_attempt_cannot_be_relabelled_as_a_success(): task, repo, service = setup_service() await publish(service, repo, task["task_id"], nodes=[ {"node_id": "attempt_1", "label": "候选定位", "status": "failed", "detail": "超时"}, ]) with pytest.raises(DomainError, match="new node"): await publish(service, repo, task["task_id"], nodes=[ {"node_id": "attempt_1", "label": "候选定位", "status": "recorded"}, ]) @pytest.mark.anyio async def test_new_execution_round_continues_the_same_task_graph(): task, repo, service = setup_service() first = await publish(service, repo, task["task_id"], nodes=[ {"node_id": "first", "label": "首次查询", "status": "failed"}, ]) archived = first.task.latest_progress.progress.diagram.model_dump(mode="json") repo.task["execution_generation"] = 2 repo.task["execution_request_id"] = str(uuid4()) repo.run["status"] = "succeeded" repo.run = _active_run(repo.task, datetime(2026, 11, 10, tzinfo=timezone.utc)) repo.runs[repo.run["run_id"]] = repo.run assert (await service.detail(task["task_id"])).latest_progress.progress.diagram.node_refs[0].status == "failed" result = await publish(service, repo, task["task_id"], nodes=[ {"node_id": "second", "label": "补充信息后查询", "status": "recorded"}, ], edges=[{"source": "first", "target": "second"}]) assert result.progress_revision == 1 assert result.task.latest_progress.execution_generation == 2 assert len(result.task.latest_progress.progress.diagram.node_refs) == 2 assert repo.progress[(task["task_id"], 1)]["progress"]["diagram"] == archived @pytest.mark.anyio async def test_reports_archive_the_host_graph_and_keep_older_versions_unchanged(): task, repo, service = setup_service() await publish(service, repo, task["task_id"], nodes=[ {"node_id": "failed", "label": "数据读取", "status": "failed", "detail": "接口超时"}, ]) command = AgentArtifactsCommand.model_validate({ "request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"], "run_id": repo.run["run_id"], "report": { "title": "调查", "current_conclusion": "已有阶段判断", "conclusion_boundary": "限于现有资料", "stage": "final", "outcome": "concluded", "data_gaps": [], "diagram": {"title": "重新生成的图", "mermaid": "flowchart TD\nNEW[新图]"}, }, }) first = await service.publish_artifacts(task["task_id"], command) assert first.task.latest_report.report.diagram.node_refs[0].status == "failed" await publish(service, repo, task["task_id"], nodes=[ {"node_id": "retry", "label": "重新读取", "status": "recorded"}, ], edges=[{"source": "failed", "target": "retry"}]) assert len((await service.report(task["task_id"], 1)).report.diagram.node_refs) == 1 assert len((await service.detail(task["task_id"])).latest_progress.progress.diagram.node_refs) == 2 # Idempotent replays do not append nodes or change the archived report. assert (await service.publish_artifacts(task["task_id"], command)).report_revision == 1 @pytest.mark.anyio async def test_graph_updates_validate_references_and_cannot_replace_a_managed_graph(): task, repo, service = setup_service() with pytest.raises(DomainError, match="unknown evidence"): await publish(service, repo, task["task_id"], nodes=[ {"node_id": "unknown", "label": "未知依据", "evidence_refs": ["forged"]}, ]) await publish(service, repo, task["task_id"], nodes=[ {"node_id": "kept", "label": "实际步骤", "status": "recorded"}, ]) with pytest.raises(DomainError, match="graph_update"): await service.publish_artifacts(task["task_id"], AgentArtifactsCommand.model_validate({ "request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"], "run_id": repo.run["run_id"], "progress": { "summary": "重画", "diagram": {"title": "替换", "mermaid": "flowchart TD\nNEW[新图]"}, }, })) def test_graph_renderer_escapes_labels_and_keeps_explicit_parallel_and_failed_paths(): graph = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({ "nodes": [ {"node_id": "start", "label": '核查 "地点"