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_existing_waiting_node_accepts_a_status_only_update_without_extending_the_graph(): task, repo, service = setup_service() first = await publish(service, repo, task["task_id"], nodes=[ {"node_id": "read", "label": "读取监测", "status": "recorded"}, {"node_id": "review", "label": "核对补充资料", "status": "needs_input", "detail": "等待现场记录"}, ], edges=[{"source": "read", "target": "review"}]) original = first.task.latest_progress.progress.diagram 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"], "report": { "title": "阶段报告", "current_conclusion": "仍需核对现场资料", "conclusion_boundary": "现有资料范围", "stage": "interim", "outcome": "concluded", "data_gaps": [], }, })) result = await publish(service, repo, task["task_id"], nodes=[ {"node_id": "review", "status": "recorded", "detail": "已核对收到的现场记录"}, ]) updated = result.task.latest_progress.progress.diagram assert [node.node_id for node in updated.node_refs] == ["read", "review"] assert updated.node_refs[1].label == "核对补充资料" assert updated.node_refs[1].status == "recorded" assert updated.node_refs[1].detail == "已核对收到的现场记录" assert updated.edges == original.edges assert updated.mermaid == original.mermaid assert original.node_refs[1].status == "needs_input" archived = (await service.report(task["task_id"], 1)).report.diagram assert archived.node_refs[1].status == "needs_input" assert archived.node_refs[1].detail == "等待现场记录" @pytest.mark.parametrize("label_patch", [{}, {"label": None}]) def test_partial_updates_keep_labels_and_accumulated_references_including_legacy_nodes(label_patch): evidence_id = "saved-reading" action_id = uuid4() original = InvestigationDiagram.model_validate({ "title": "旧调查图", "mermaid": "flowchart LR\nold{核对资料} --> next[继续调查]", "node_refs": [{"node_id": "old", "label": "核对资料", "status": "waiting_human", "detail": "等待资料", "evidence_refs": [evidence_id], "action_refs": [action_id], "map_targets": [{"source_id": "scada", "feature_id": "P1"}]}], }) updated = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({ "nodes": [{"node_id": "old", "status": "recorded", **label_patch}], })) assert updated.mermaid == updated.source_mermaid == original.mermaid assert updated.node_refs[0] == original.node_refs[0].model_copy(update={"status": "recorded"}) assert original.node_refs[0].status == "needs_input" def test_status_only_updates_cannot_create_an_unlabelled_node_or_rewrite_a_failed_attempt(): with pytest.raises(DomainError) as error: update_investigation_graph(None, InvestigationGraphUpdate.model_validate({ "nodes": [{"node_id": "missing", "status": "recorded"}], })) assert error.value.code == "GRAPH_NODE_LABEL_REQUIRED" failed = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({ "nodes": [{"node_id": "attempt", "label": "失败尝试", "status": "failed", "detail": "接口超时"}], })) enriched = update_investigation_graph(failed, InvestigationGraphUpdate.model_validate({ "nodes": [{"node_id": "attempt", "evidence_refs": ["timeout-record"]}], })) assert enriched.node_refs[0].label == "失败尝试" assert enriched.node_refs[0].detail == "接口超时" assert enriched.node_refs[0].status == "failed" assert enriched.node_refs[0].evidence_refs == ["timeout-record"] with pytest.raises(DomainError) as error: update_investigation_graph(enriched, InvestigationGraphUpdate.model_validate({ "nodes": [{"node_id": "attempt", "status": "recorded"}], })) assert error.value.code == "GRAPH_FAILED_ATTEMPT_IMMUTABLE" @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": '核查 "地点"