1845 lines
84 KiB
Python
1845 lines
84 KiB
Python
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from app.auth.permissions import (
|
|
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
|
|
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
|
OPERATIONAL_TASK_VIEW,
|
|
OPERATIONAL_TASK_ACCESS_APPROVE,
|
|
SCADA_VIEW,
|
|
)
|
|
from app.auth.project_dependencies import ProjectContext
|
|
from app.domain.domain_errors import DomainError
|
|
from app.domain.schemas.operational_task import (
|
|
ActionDecisionCommand,
|
|
ActionExecuteCommand,
|
|
ActionProposalCommand,
|
|
AgentArtifactsCommand,
|
|
AgentRunCommand,
|
|
ExternalFeedbackCommand,
|
|
ExternalSourceWithdrawalCommand,
|
|
HumanApprovalAction,
|
|
NotificationAcknowledgeCommand,
|
|
OperationalTaskNotification,
|
|
OperationalTaskRun,
|
|
OperationalTaskSummary,
|
|
ExecutionPlanInput,
|
|
TaskAccessRequestCreate,
|
|
TaskAccessRequestDecision,
|
|
TaskAccessRequest,
|
|
WorkOrderDraftUpdateCommand,
|
|
WorkOrderConfirmDispatchCommand,
|
|
TaskPresentationEventCommand,
|
|
StoredEvidenceRecord,
|
|
)
|
|
from app.domain.schemas.operational_data import TaskDataSnapshot
|
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
|
from app.infra.integrations.operational_actions import ActionDispatchOutcome
|
|
from app.services.operational_tasks import OperationalTaskService
|
|
|
|
|
|
@pytest.fixture
|
|
def anyio_backend():
|
|
return "asyncio"
|
|
|
|
|
|
class NotificationRepository:
|
|
def __init__(self, notification):
|
|
self.notification = notification
|
|
self.reads = {}
|
|
self.requests = {}
|
|
self.events = []
|
|
|
|
@asynccontextmanager
|
|
async def transaction(self):
|
|
yield self
|
|
|
|
async def lock(self, _key):
|
|
return None
|
|
|
|
async def get_request(self, scope, request_id):
|
|
return self.requests.get((scope, request_id))
|
|
|
|
async def save_request(self, scope, request_id, request_digest, response):
|
|
self.requests[(scope, request_id)] = {
|
|
"request_digest": request_digest,
|
|
"response": response,
|
|
}
|
|
|
|
async def get_notification(self, notification_id, user_id):
|
|
if notification_id != str(self.notification.notification_id):
|
|
return None
|
|
read = self.reads.get(user_id)
|
|
return self.notification.model_copy(update={
|
|
"status": "read" if read else "unread",
|
|
"version": 2 if read else 1,
|
|
"read_at": read,
|
|
"read_by": f"principal:{user_id}" if read else None,
|
|
}).model_dump(mode="json")
|
|
|
|
async def mark_notification_read(self, notification_id, user_id, version, read_at):
|
|
assert version == 2
|
|
self.reads[user_id] = read_at
|
|
|
|
async def append_event(self, **event):
|
|
self.events.append(event)
|
|
return len(self.events)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_access_request_read_keeps_existing_tasks_available_before_migration():
|
|
connection = SimpleNamespace(execute=AsyncMock())
|
|
connection.execute.return_value.fetchone = AsyncMock(return_value={"relation": None})
|
|
repository = OperationalTaskRepository(connection, uuid4())
|
|
|
|
assert await repository.list_access_requests(str(uuid4())) == []
|
|
assert connection.execute.await_count == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_access_request_read_uses_table_after_migration():
|
|
availability = SimpleNamespace(fetchone=AsyncMock(return_value={"relation": "workbench.operational_task_access_requests"}))
|
|
records = [{"document": {"access_request_id": str(uuid4())}}]
|
|
result = SimpleNamespace(fetchall=AsyncMock(return_value=records))
|
|
connection = SimpleNamespace(execute=AsyncMock(side_effect=[availability, result]))
|
|
repository = OperationalTaskRepository(connection, uuid4())
|
|
|
|
assert await repository.list_access_requests(str(uuid4())) == [records[0]["document"]]
|
|
assert connection.execute.await_count == 2
|
|
|
|
|
|
def _context(user_id=None, *, system_role="user"):
|
|
return ProjectContext(
|
|
project_id=uuid4(), project_code="test", user_id=user_id or uuid4(),
|
|
project_role="member", system_role=system_role,
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_notification_read_state_is_isolated_per_user():
|
|
now = datetime(2026, 9, 23, tzinfo=timezone.utc)
|
|
notification = OperationalTaskNotification(
|
|
notification_id=uuid4(), task_id=uuid4(), kind="task_created",
|
|
title="任务", description="已创建", status="unread", version=1,
|
|
created_at=now,
|
|
)
|
|
repository = NotificationRepository(notification)
|
|
first = _context()
|
|
second = _context()
|
|
first_service = OperationalTaskService(
|
|
repository, first, {OPERATIONAL_TASK_VIEW}, clock=lambda: now
|
|
)
|
|
second_service = OperationalTaskService(
|
|
repository, second, {OPERATIONAL_TASK_VIEW}, clock=lambda: now
|
|
)
|
|
|
|
request_id = uuid4()
|
|
read = await first_service.acknowledge_notification(
|
|
notification.notification_id,
|
|
NotificationAcknowledgeCommand(request_id=request_id, expected_version=1),
|
|
)
|
|
|
|
assert read.status == "read"
|
|
assert (await repository.get_notification(
|
|
str(notification.notification_id), str(second.user_id)
|
|
))["status"] == "unread"
|
|
|
|
second_read = await second_service.acknowledge_notification(
|
|
notification.notification_id,
|
|
NotificationAcknowledgeCommand(request_id=request_id, expected_version=1),
|
|
)
|
|
assert second_read.status == "read"
|
|
assert second_read.read_by == f"principal:{second.user_id}"
|
|
assert [event["aggregate_type"] for event in repository.events] == [
|
|
"operational_notification_read",
|
|
"operational_notification_read",
|
|
]
|
|
assert repository.events[0]["aggregate_id"] != repository.events[1]["aggregate_id"]
|
|
|
|
|
|
def test_running_plan_state_wins_over_future_human_waiting_node():
|
|
nodes = [
|
|
type("Node", (), {"status": "running", "owner": "agent"})(),
|
|
type("Node", (), {"status": "waiting", "owner": "human"})(),
|
|
]
|
|
|
|
assert OperationalTaskService._status_from_plan(nodes, []) == "running"
|
|
|
|
|
|
def test_pending_approval_prevents_plan_from_marking_task_completed():
|
|
action = HumanApprovalAction(
|
|
action_id=uuid4(), task_id=uuid4(), version=1,
|
|
kind="create_work_order", reason="现场采样",
|
|
payload={"title": "采样", "description": "核验", "work_order_type": "sampling"},
|
|
evidence_refs=[], required_approval_permission="work-order.approve",
|
|
required_execution_permission="work-order.execute",
|
|
approval_status="pending", execution_status="not_submitted",
|
|
proposed_by="principal:test", proposed_at=datetime.now(timezone.utc),
|
|
)
|
|
nodes = [type("Node", (), {"status": "completed", "owner": "agent"})()]
|
|
|
|
assert OperationalTaskService._status_from_plan(nodes, [action]) == "waiting_human"
|
|
|
|
|
|
def test_pending_access_request_prevents_plan_from_marking_task_completed():
|
|
request = TaskAccessRequest(
|
|
access_request_id=uuid4(), task_id=uuid4(), node_id="read-scada",
|
|
scopes=["scada_devices"], reason="核对监测点", status="pending",
|
|
approved_scopes=[], version=1, requested_by="principal:agent",
|
|
requested_at=datetime.now(timezone.utc),
|
|
)
|
|
nodes = [type("Node", (), {"status": "completed", "owner": "agent"})()]
|
|
|
|
assert OperationalTaskService._status_from_plan(
|
|
nodes, [], [request]
|
|
) == "waiting_human"
|
|
|
|
|
|
def test_explicit_agent_run_failure_marks_task_failed_even_with_waiting_work():
|
|
nodes = [
|
|
type("Node", (), {"id": "FIELD_SAMPLE", "status": "waiting", "owner": "human"})(),
|
|
type("Node", (), {"id": "AGENT_RUN_FAILURE", "status": "failed", "owner": "agent"})(),
|
|
]
|
|
|
|
assert OperationalTaskService._status_from_plan(nodes, []) == "failed"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_targeted_notification_cannot_be_acknowledged_without_permission():
|
|
now = datetime(2026, 9, 23, tzinfo=timezone.utc)
|
|
notification = OperationalTaskNotification(
|
|
notification_id=uuid4(), task_id=uuid4(), kind="action_pending",
|
|
title="待确认", description="命令", required_permission="command.approve",
|
|
status="unread", version=1, created_at=now,
|
|
)
|
|
service = OperationalTaskService(
|
|
NotificationRepository(notification), _context(), {OPERATIONAL_TASK_VIEW},
|
|
clock=lambda: now,
|
|
)
|
|
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.acknowledge_notification(
|
|
notification.notification_id,
|
|
NotificationAcknowledgeCommand(request_id=uuid4(), expected_version=1),
|
|
)
|
|
|
|
assert exc.value.code == "PERMISSION_DENIED"
|
|
|
|
|
|
def _task_document(context, now, *, lease=None):
|
|
task_id = uuid4()
|
|
return OperationalTaskSummary(
|
|
project_id=context.project_id,
|
|
task_id=task_id,
|
|
task_type="event_investigation",
|
|
workflow="water_quality_complaint",
|
|
status="running",
|
|
priority="normal",
|
|
revision=2,
|
|
execution_generation=1,
|
|
execution_request_id=uuid4(),
|
|
title="调查",
|
|
summary="验证状态机",
|
|
source={"kind": "operator", "event_kind": "water_quality_complaint"},
|
|
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
|
|
scheduled_for=None,
|
|
agent_session_id="session-1",
|
|
agent_lease_expires_at=lease,
|
|
current_plan_revision=None,
|
|
current_report_revision=None,
|
|
created_at=now,
|
|
updated_at=now,
|
|
).model_dump(mode="json")
|
|
|
|
|
|
class TaskRepository:
|
|
def __init__(self, task, *, action=None):
|
|
self.task = task
|
|
self.action = action
|
|
self.requests = {}
|
|
self.evidence = {}
|
|
self.feedback = {}
|
|
self.plans = []
|
|
self.reports = []
|
|
self.progress = {}
|
|
self.notifications = []
|
|
self.locks = []
|
|
self.access_requests = {}
|
|
|
|
@asynccontextmanager
|
|
async def transaction(self):
|
|
yield self
|
|
|
|
async def lock(self, key):
|
|
self.locks.append(key)
|
|
|
|
async def get_request(self, scope, request_id):
|
|
return self.requests.get((scope, request_id))
|
|
|
|
async def save_request(self, scope, request_id, request_digest, response):
|
|
self.requests[(scope, request_id)] = {
|
|
"request_digest": request_digest,
|
|
"response": response,
|
|
}
|
|
|
|
async def get_task(self, task_id):
|
|
return self.task if task_id == self.task["task_id"] else None
|
|
|
|
async def update_task(self, task):
|
|
self.task = task
|
|
|
|
async def get_latest_plan(self, _task_id):
|
|
return self.plans[-1] if self.plans else None
|
|
|
|
async def get_latest_report(self, _task_id):
|
|
return self.reports[-1] if self.reports else None
|
|
|
|
async def get_latest_progress(self, task_id, generation):
|
|
return self.progress.get((task_id, generation))
|
|
|
|
async def save_progress(self, task_id, snapshot):
|
|
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
|
|
|
|
async def get_report(self, _task_id, revision):
|
|
return next((item for item in self.reports if item["revision"] == revision), None)
|
|
|
|
async def list_reports(self, _task_id, limit, offset):
|
|
return list(reversed(self.reports))[offset:offset + limit]
|
|
|
|
async def insert_plan(self, _task_id, snapshot):
|
|
self.plans.append(snapshot)
|
|
|
|
async def insert_report(self, _task_id, snapshot):
|
|
self.reports.append(snapshot)
|
|
|
|
async def list_evidence(self, _task_id):
|
|
return list(self.evidence.values())
|
|
|
|
async def get_evidence(self, _task_id, evidence_id):
|
|
return self.evidence.get(evidence_id)
|
|
|
|
async def insert_evidence(self, _task_id, evidence):
|
|
self.evidence[evidence["evidence_id"]] = evidence
|
|
|
|
async def list_access_requests(self, _task_id):
|
|
return list(self.access_requests.values())
|
|
|
|
async def get_access_request(self, _task_id, access_request_id):
|
|
return self.access_requests.get(access_request_id)
|
|
|
|
async def insert_access_request(self, _task_id, document):
|
|
self.access_requests[document["access_request_id"]] = document
|
|
|
|
async def update_access_request(self, _task_id, document, expected_version):
|
|
assert document["version"] == expected_version + 1
|
|
self.access_requests[document["access_request_id"]] = document
|
|
|
|
async def list_actions(self, _task_id):
|
|
return [self.action] if self.action else []
|
|
|
|
async def get_action(self, _task_id, action_id):
|
|
if self.action and self.action["action_id"] == action_id:
|
|
return self.action
|
|
return None
|
|
|
|
async def update_action(self, _task_id, action):
|
|
self.action = action
|
|
|
|
async def insert_action(self, _task_id, action):
|
|
self.action = action
|
|
|
|
async def insert_notification(self, notification):
|
|
self.notifications.append(notification)
|
|
|
|
async def resolve_notifications(
|
|
self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason
|
|
):
|
|
resolved = []
|
|
for notification in self.notifications:
|
|
if (
|
|
notification["task_id"] == task_id
|
|
and notification["kind"] in kinds
|
|
and notification.get("lifecycle_status", "active") == "active"
|
|
and notification.get("subject_type") == subject_type
|
|
and notification.get("subject_id") == subject_id
|
|
):
|
|
notification.update(
|
|
lifecycle_status="resolved",
|
|
resolved_at=resolved_at.isoformat(),
|
|
resolution_reason=resolution_reason,
|
|
)
|
|
resolved.append(notification.copy())
|
|
return resolved
|
|
|
|
async def get_feedback(self, task_id, source_event_id):
|
|
return self.feedback.get((task_id, source_event_id))
|
|
|
|
async def upsert_feedback(self, task_id, feedback, accepted_at):
|
|
self.feedback[(task_id, feedback["source_event_id"])] = {
|
|
"source_revision": feedback["source_revision"],
|
|
"document": feedback,
|
|
"accepted_at": accepted_at,
|
|
}
|
|
|
|
|
|
class RunTaskRepository(TaskRepository):
|
|
def __init__(self, task, run):
|
|
super().__init__(task)
|
|
self.run = run
|
|
self.events = []
|
|
|
|
async def get_active_run(self, task_id):
|
|
if task_id == self.task["task_id"] and self.run["status"] in {"queued", "claimed", "running"}:
|
|
return self.run
|
|
return None
|
|
|
|
async def get_run(self, run_id):
|
|
return self.run if run_id == self.run["run_id"] else None
|
|
|
|
async def update_run(self, run, expected_revision):
|
|
assert run["revision"] == expected_revision + 1
|
|
self.run = run
|
|
|
|
async def insert_run(self, run):
|
|
self.run = run
|
|
|
|
async def append_event(self, **event):
|
|
self.events.append(event)
|
|
return len(self.events)
|
|
|
|
async def current_event_cursor(self):
|
|
return len(self.events)
|
|
|
|
|
|
def _active_run(task, now):
|
|
return OperationalTaskRun(
|
|
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
|
|
execution_request_id=task["execution_request_id"], status="running", revision=2,
|
|
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
|
|
heartbeat_at=now, started_at=now, dispatch_attempts=0,
|
|
next_dispatch_at=now, created_at=now, updated_at=now,
|
|
).model_dump(mode="json")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_progress_can_publish_without_conclusion_and_replaces_the_complete_graph():
|
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
first = AgentArtifactsCommand.model_validate({
|
|
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
|
|
"progress": {"summary": "正在核对输入,尚未形成结论。", "diagram": {
|
|
"title": "执行进展", "mermaid": "flowchart LR\nfirst[开始查询]",
|
|
"node_refs": [{"node_id": "first", "label": "开始查询", "status": "running",
|
|
"map_targets": [{"source_id": "pipes", "feature_id": "P-1"}]}],
|
|
}},
|
|
})
|
|
initial = await service.publish_artifacts(task["task_id"], first)
|
|
assert initial.progress_revision == 1
|
|
assert initial.task.latest_report is None and initial.task.latest_plan is None
|
|
assert initial.task.active_run.status == "running"
|
|
assert (await service.publish_artifacts(task["task_id"], first)).progress_revision == 1
|
|
assert len([event for event in repository.events if event["topic"] == "operational.progress.changed"]) == 1
|
|
replacement = AgentArtifactsCommand.model_validate({
|
|
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
|
|
"progress": {"summary": "重新核查另一条路径。", "diagram": {
|
|
"title": "新脉络", "mermaid": "flowchart TD\nother[核查新路径]",
|
|
"node_refs": [{"node_id": "other", "label": "核查新路径", "status": "running"}],
|
|
}},
|
|
})
|
|
updated = await service.publish_artifacts(task["task_id"], replacement)
|
|
assert updated.progress_revision == 2
|
|
assert [node.node_id for node in updated.task.latest_progress.progress.diagram.node_refs] == ["other"]
|
|
cleared = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
|
|
progress={"summary": "当前不需要图示。", "diagram": None},
|
|
))
|
|
assert cleared.progress_revision == 3
|
|
assert cleared.task.latest_progress.progress.diagram is None
|
|
assert not repository.notifications
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("kind", ["progress", "presentation"])
|
|
@pytest.mark.parametrize("invalid", ["run", "session", "lease", "finished"])
|
|
async def test_reading_publications_reject_stale_execution_ownership(kind, invalid):
|
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
run = _active_run(task, now)
|
|
if invalid == "lease":
|
|
run["lease_expires_at"] = now.isoformat()
|
|
elif invalid == "finished":
|
|
run["status"] = "succeeded"
|
|
repository = RunTaskRepository(task, run)
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
identity = {"request_id": uuid4(), "run_id": uuid4() if invalid == "run" else run["run_id"],
|
|
"source_session_id": "other-session" if invalid == "session" else "session-1"}
|
|
with pytest.raises(DomainError):
|
|
if kind == "progress":
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
**identity, progress={"summary": "执行中"},
|
|
))
|
|
else:
|
|
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
|
**identity, command={"action": "clear_highlights"},
|
|
))
|
|
assert repository.progress == {} and repository.events == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_progress_unknown_evidence_does_not_publish_or_authorize_actions():
|
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
with pytest.raises(DomainError):
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
|
progress={"summary": "执行中", "diagram": {"title": "核查", "mermaid": "graph LR\nA[核查]",
|
|
"node_refs": [{"node_id": "A", "label": "核查", "evidence_refs": ["unknown"]}]}},
|
|
))
|
|
assert not repository.progress
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
|
progress={"summary": "查询完成,正在判断"},
|
|
))
|
|
with pytest.raises(DomainError) as error:
|
|
await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="不能用进展授权动作",
|
|
payload={"title": "现场核查", "description": "核查", "work_order_type": "sampling"},
|
|
))
|
|
assert error.value.code == "ACTION_CONCLUSION_REQUIRED"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
|
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
baseline = await service.presentation_cursor(task["task_id"])
|
|
assert baseline.event_cursor == 0 and str(baseline.active_run.run_id) == repository.run["run_id"]
|
|
command = TaskPresentationEventCommand(
|
|
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
|
command={"action": "highlight_features", "layer": "pipes", "feature_ids": ["P-1"], "effect": "pulse"},
|
|
)
|
|
result = await service.publish_presentation_event(task["task_id"], command)
|
|
assert (await service.publish_presentation_event(task["task_id"], command)) == result
|
|
assert (await service.presentation_cursor(task["task_id"])).event_cursor == result.event_seq
|
|
assert repository.task["revision"] == task["revision"]
|
|
assert len(repository.events) == 1
|
|
assert repository.events[0]["metadata"]["command"]["effect"] == "pulse"
|
|
with pytest.raises(DomainError):
|
|
await service.presentation_cursor(uuid4())
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_oversized_data():
|
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="verified-points", queried_at=now,
|
|
origin="system_data", truncated=False,
|
|
data={"items": [{"device_id": "D-1", "longitude": 121.4, "latitude": 31.1}]})
|
|
evidence = StoredEvidenceRecord(evidence_id="verified-points", kind="system_data", summary="实际坐标",
|
|
source_label="SCADA", observed_at=now, recorded_at=now, recorded_by="server",
|
|
has_snapshot=True, data_snapshot=snapshot)
|
|
repository.evidence[evidence.evidence_id] = evidence.model_dump(mode="json")
|
|
identity = {"run_id": repository.run["run_id"], "source_session_id": "session-1"}
|
|
overlay = {"action": "set_overlay", "overlay_id": "points", "title": "监测点分布", "mode": "heatmap"}
|
|
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
|
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
|
|
))
|
|
emitted = repository.events[-1]["metadata"]["command"]["data"]
|
|
assert emitted["features"][0]["geometry"]["coordinates"] == [121.4, 31.1]
|
|
assert emitted["features"][0]["properties"]["weight"] == 1
|
|
with pytest.raises(DomainError) as missing:
|
|
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
|
request_id=uuid4(), **identity, command={**overlay, "evidence_id": "not-this-task"},
|
|
))
|
|
assert missing.value.code == "EVIDENCE_NOT_FOUND"
|
|
repository.evidence[evidence.evidence_id]["data_snapshot"]["data"] = {
|
|
"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [121, 31]},
|
|
"properties": {"label": "x" * (512 * 1024)}}],
|
|
}
|
|
with pytest.raises(DomainError) as oversized:
|
|
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
|
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
|
|
))
|
|
assert oversized.value.code == "PRESENTATION_TOO_LARGE"
|
|
assert len(repository.events) == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_completed_investigation_resumes_once_for_linked_new_field_feedback():
|
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
_seed_concluded_report(repository, now)
|
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="现场检测",
|
|
payload={"title": "现场检测", "description": "回填复测结果", "work_order_type": "sampling"},
|
|
))
|
|
child_id = str(uuid4())
|
|
repository.action = action.model_copy(update={
|
|
"approval_status": "approved", "execution_status": "succeeded",
|
|
}).model_dump(mode="json")
|
|
repository.action["receipt"] = {
|
|
"adapter": "peripheral_sample", "receipt_id": child_id, "submitted_at": now.isoformat(),
|
|
"completed_at": now.isoformat(), "external_status": "open", "detail": "工单已创建,现场未完成",
|
|
}
|
|
repository.run["status"] = "succeeded"
|
|
repository.task.update(status="completed", lifecycle_status="completed", agent_session_id=None,
|
|
agent_lease_expires_at=None)
|
|
identity = {"source_event_id": f"peripheral_sample:{child_id}", "source_revision": 2,
|
|
"status": "completed", "occurred_at": now, "detail": "现场复测通过"}
|
|
report_before = repository.reports[-1].copy()
|
|
command = ExternalFeedbackCommand(request_id=uuid4(), **identity)
|
|
accepted = await service.external_feedback(task["task_id"], command)
|
|
assert accepted.task.execution_generation == 2 and accepted.task.active_run.status == "queued"
|
|
assert repository.reports[-1] == report_before
|
|
assert (await service.external_feedback(task["task_id"], command)).task.execution_generation == 2
|
|
# Source-version deduplication also survives a caller changing the request ID.
|
|
again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity))
|
|
assert again.task.execution_generation == 2
|
|
assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1
|
|
repository.task.update(status="cancelled", lifecycle_status="cancelled")
|
|
with pytest.raises(DomainError) as cancelled:
|
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
|
request_id=uuid4(), **{**identity, "source_revision": 3},
|
|
))
|
|
assert cancelled.value.code == "TASK_NOT_ACTIONABLE"
|
|
assert repository.task["execution_generation"] == 2
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_completed_investigation_rejects_unlinked_or_initial_child_feedback():
|
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=None)
|
|
task.update(status="completed", lifecycle_status="completed")
|
|
run = _active_run(task, now)
|
|
run["status"] = "succeeded"
|
|
repository = RunTaskRepository(task, run)
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
with pytest.raises(DomainError) as unlinked:
|
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{uuid4()}", source_revision=2,
|
|
status="completed", occurred_at=now, detail="无关联的工单",
|
|
))
|
|
assert unlinked.value.code == "EXTERNAL_SOURCE_MISMATCH"
|
|
assert repository.task["execution_generation"] == 1 and repository.feedback == {}
|
|
child_id = str(uuid4())
|
|
repository.action = {"kind": "create_work_order", "receipt": {
|
|
"adapter": "peripheral_sample", "receipt_id": child_id,
|
|
}}
|
|
with pytest.raises(DomainError) as initial:
|
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{child_id}", source_revision=1,
|
|
status="assigned", occurred_at=now, detail="工单创建,尚未回填",
|
|
))
|
|
assert initial.value.code == "INITIAL_CHILD_FEEDBACK"
|
|
assert repository.feedback == {}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_original_complaint_feedback_advances_its_source_revision():
|
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
|
context = _context()
|
|
source_id = str(uuid4())
|
|
task = _task_document(context, now, lease=None)
|
|
task.update(status="completed", lifecycle_status="completed",
|
|
source={"kind": "external_system", "external_id": source_id, "external_revision": 1})
|
|
run = _active_run(task, now)
|
|
run["status"] = "succeeded"
|
|
repository = RunTaskRepository(task, run)
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=f"peripheral_sample:{source_id}",
|
|
source_revision=2, status="in_progress", occurred_at=now, detail="新投诉信息")
|
|
accepted = await service.external_feedback(task["task_id"], command)
|
|
assert accepted.task.source.external_revision == 2
|
|
assert accepted.task.execution_generation == 2
|
|
with pytest.raises(DomainError) as stale:
|
|
await service.external_feedback(task["task_id"], command.model_copy(update={"request_id": uuid4(), "source_revision": 1}))
|
|
assert stale.value.code == "STALE_EXTERNAL_REVISION"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_heartbeat_does_not_change_task_business_revision():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
updated = await service.heartbeat_run(
|
|
task["task_id"], repository.run["run_id"],
|
|
AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
|
|
),
|
|
)
|
|
|
|
assert updated.revision == 3
|
|
assert repository.task["revision"] == 2
|
|
assert repository.events == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_complete_without_same_generation_artifacts_fails_run_explicitly():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
completed = await service.complete_run(
|
|
task["task_id"], repository.run["run_id"],
|
|
AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
|
|
),
|
|
)
|
|
|
|
assert repository.run["status"] == "failed"
|
|
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
|
|
assert completed.lifecycle_status == "failed"
|
|
assert completed.active_run is None
|
|
assert [item["kind"] for item in repository.notifications] == ["task_failed"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_external_feedback_coalesces_into_an_already_queued_run():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=None)
|
|
task.update(
|
|
status="queued", lifecycle_status="open", execution_generation=2,
|
|
execution_request_id=str(uuid4()), agent_session_id=None,
|
|
)
|
|
run = _active_run(task, now)
|
|
run.update(
|
|
generation=2, execution_request_id=task["execution_request_id"],
|
|
status="queued", revision=1, agent_session_id=None,
|
|
lease_expires_at=None, heartbeat_at=None, started_at=None,
|
|
)
|
|
repository = RunTaskRepository(task, run)
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
result = await service.external_feedback(
|
|
task["task_id"],
|
|
ExternalFeedbackCommand(
|
|
request_id=uuid4(), source_event_id="sample-result", source_revision=1,
|
|
status="completed", occurred_at=now, detail="新的现场结果",
|
|
),
|
|
)
|
|
|
|
assert result.task.execution_generation == 2
|
|
assert str(result.task.execution_request_id) == task["execution_request_id"]
|
|
assert result.task.active_run is not None
|
|
assert str(result.task.active_run.run_id) == run["run_id"]
|
|
|
|
|
|
def _waiting_plan(action_id=None):
|
|
node = {
|
|
"id": "field-work",
|
|
"title": "现场处置",
|
|
"owner": "human",
|
|
"status": "waiting",
|
|
"detail": "等待审批与执行",
|
|
"evidence_refs": [],
|
|
"action_refs": [str(action_id)] if action_id else [],
|
|
}
|
|
return {
|
|
"schema": "execution-plan@1",
|
|
"title": "调查计划",
|
|
"reason": "等待现场处置",
|
|
"nodes": [node],
|
|
"edges": [],
|
|
}
|
|
|
|
|
|
def _current_report():
|
|
return {
|
|
"stage": "final", "outcome": "concluded", "stop_reason": None, "data_gaps": [],
|
|
"title": "本代调查报告",
|
|
"current_conclusion": "等待现场处置",
|
|
"conclusion_boundary": "尚无现场回执",
|
|
"evidence_refs": [],
|
|
"recommendations": ["完成现场处置"],
|
|
"pending_items": ["现场处置"],
|
|
}
|
|
|
|
|
|
def _insufficient_report():
|
|
return {
|
|
**_current_report(), "outcome": "insufficient_data",
|
|
"current_conclusion": "无法确定投诉原因", "stop_reason": "缺少投诉时段的末梢采样",
|
|
"recommendations": [], "pending_items": [], "evidence_refs": ["empty-query"],
|
|
"data_gaps": [{"gap_id": "sample-gap", "node_id": "read", "missing_data": "末梢采样",
|
|
"scope": "投诉小区,2026-09-23 08:00 至 09:00", "impact": "无法判断异常来源",
|
|
"required_data": "补齐该时段采样结果", "evidence_refs": ["empty-query"], "blocking": True}],
|
|
}
|
|
|
|
|
|
def test_report_contract_separates_legacy_reads_from_new_writes():
|
|
from app.domain.schemas.operational_task import InvestigationReport, InvestigationReportInput
|
|
legacy = {k: v for k, v in _current_report().items()
|
|
if k not in {"stage", "outcome", "stop_reason", "data_gaps"}}
|
|
stored = InvestigationReport.model_validate(legacy)
|
|
assert stored.outcome is None and stored.data_gaps is None
|
|
with pytest.raises(ValueError):
|
|
InvestigationReportInput.model_validate(legacy)
|
|
assert InvestigationReportInput.model_validate(_insufficient_report()).outcome == "insufficient_data"
|
|
for changes in [{"stage": "interim"}, {"stop_reason": None}, {"data_gaps": []},
|
|
{"recommendations": ["自动派采样工单"]}, {"pending_items": ["等待人工"]},
|
|
{"outcome": "concluded"}, {"outcome": "other"}]:
|
|
with pytest.raises(ValueError):
|
|
InvestigationReportInput.model_validate({**_insufficient_report(), **changes})
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_insufficient_data_exits_successfully_and_blocks_actions_until_new_run():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
command = AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1",
|
|
evidence=[{"evidence_id": "empty-query", "kind": "synthetic", "summary": "查询成功,该范围无采样记录",
|
|
"source_label": "合成查询", "observed_at": now.isoformat()}],
|
|
plan={"schema": "execution-plan@1", "title": "调查", "reason": "关键数据不足",
|
|
"nodes": [{"id": "read", "title": "查询采样", "owner": "agent", "status": "completed",
|
|
"detail": "已核对查询范围,无记录", "evidence_refs": ["empty-query"]},
|
|
{"id": "action", "title": "处置建议", "owner": "agent", "status": "skipped",
|
|
"detail": "缺少关键数据,未进入"}],
|
|
"edges": [{"from": "read", "to": "action"}]},
|
|
report=_insufficient_report(),
|
|
)
|
|
published = await service.publish_artifacts(task["task_id"], command)
|
|
replay = await service.publish_artifacts(task["task_id"], command)
|
|
assert published.report_revision == replay.report_revision == 1
|
|
with pytest.raises(DomainError) as ended:
|
|
await service.publish_artifacts(task["task_id"], command.model_copy(update={"request_id": uuid4()}))
|
|
assert ended.value.code == "INVESTIGATION_ENDED"
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="缺数后不应派单",
|
|
payload={"title": "采样", "description": "采样", "work_order_type": "sampling"},
|
|
))
|
|
assert exc.value.code == "ACTION_CONCLUSION_REQUIRED"
|
|
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
|
assert completed.lifecycle_status == "completed"
|
|
assert repository.run["status"] == "succeeded"
|
|
assert completed.latest_report.report.outcome == "insufficient_data"
|
|
assert repository.action is None
|
|
assert not any(item["kind"] in {"task_failed", "human_input_required", "action_approval_pending"}
|
|
for item in repository.notifications)
|
|
from app.domain.schemas.operational_task import OperationalTaskRetryCommand
|
|
restarted = await service.retry_task(task["task_id"], OperationalTaskRetryCommand(
|
|
request_id=uuid4(), reason="已补充新的采样记录"))
|
|
assert restarted.execution_generation == 2
|
|
assert restarted.latest_report.execution_generation == 1
|
|
assert restarted.latest_report.report.outcome == "insufficient_data"
|
|
with pytest.raises(DomainError) as stale:
|
|
await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="新轮次尚未形成结论",
|
|
payload={"title": "采样", "description": "采样", "work_order_type": "sampling"}))
|
|
assert stale.value.code == "ACTION_CONCLUSION_REQUIRED"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("defect,expected", [
|
|
("unlisted_evidence", "REPORT_GAP_EVIDENCE_INVALID"), ("pending_action", "REPORT_ACTION_PENDING"),
|
|
])
|
|
async def test_insufficient_report_rejects_inconsistent_context(defect, expected):
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
report = _insufficient_report()
|
|
node = {"id": "read", "title": "查询", "owner": "agent", "status": "skipped", "detail": "关键数据不足"}
|
|
if defect == "unknown_node": report["data_gaps"][0]["node_id"] = "unknown"
|
|
if defect == "unlisted_evidence": report["data_gaps"][0]["evidence_refs"] = ["unlisted"]
|
|
if defect == "waiting_node": node["status"] = "waiting"
|
|
if defect == "pending_action":
|
|
_seed_concluded_report(repository, now)
|
|
await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="已产生待审动作",
|
|
payload={"title": "采样", "description": "采样", "work_order_type": "sampling"}))
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1",
|
|
evidence=[{"evidence_id": "empty-query", "kind": "synthetic", "summary": "查询空结果",
|
|
"source_label": "合成查询", "observed_at": now.isoformat()}],
|
|
plan={"schema": "execution-plan@1", "title": "调查", "reason": "无数据", "nodes": [node]},
|
|
report=report))
|
|
assert exc.value.code == expected
|
|
|
|
|
|
def _seed_concluded_report(repository, now):
|
|
if repository.reports and repository.reports[-1].get("execution_generation") == repository.task["execution_generation"]:
|
|
return
|
|
repository.reports.append({
|
|
"revision": 1, "execution_generation": repository.task["execution_generation"],
|
|
"source_session_id": "session-1", "published_at": now.isoformat(),
|
|
"published_by": "agent", "report": _current_report(),
|
|
})
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_plan_only_keeps_lease_until_same_generation_report_closes_run():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
|
|
))
|
|
assert planned.task.status == "running"
|
|
assert planned.task.agent_lease_expires_at == now + timedelta(minutes=1)
|
|
assert planned.task.latest_plan.execution_generation == 1
|
|
|
|
reported = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
|
))
|
|
assert reported.task.status == "running"
|
|
assert reported.task.latest_report.execution_generation == 1
|
|
|
|
completed = await service.complete_run(
|
|
task["task_id"], repository.run["run_id"],
|
|
AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=repository.run["revision"],
|
|
source_session_id="session-1",
|
|
),
|
|
)
|
|
assert completed.status == "completed"
|
|
assert completed.agent_lease_expires_at is None
|
|
assert [item["kind"] for item in repository.notifications] == [
|
|
"report_updated"
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_interim_report_cannot_finish_even_with_a_completed_plan():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
plan = _waiting_plan()
|
|
plan["nodes"][0]["status"] = "completed"
|
|
plan["nodes"][0]["evidence_refs"] = ["field-result"]
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", plan=plan,
|
|
evidence=[{"evidence_id": "field-result", "kind": "synthetic", "summary": "模拟现场结果",
|
|
"source_label": "测试采样", "observed_at": now.isoformat()}],
|
|
report={**_current_report(), "stage": "interim"},
|
|
))
|
|
|
|
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
|
|
))
|
|
|
|
assert completed.lifecycle_status == "failed"
|
|
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_report_is_not_blocked_by_a_historical_plan():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
task["execution_generation"] = 2
|
|
repository = TaskRepository(task)
|
|
repository.plans.append({
|
|
"revision": 1,
|
|
"execution_generation": None,
|
|
"source_session_id": "session-1",
|
|
"published_at": now.isoformat(),
|
|
"published_by": "principal:test",
|
|
"plan": _waiting_plan(),
|
|
})
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
|
))
|
|
assert result.task.latest_report.execution_generation == 2
|
|
assert result.task.status == "completed"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_report_and_action_without_plan_finish_with_real_approval_pending():
|
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
|
))
|
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", node_id="display-only",
|
|
reason="已形成判断,需要现场核查", payload={"title": "现场核查", "description": "核验", "work_order_type": "general"},
|
|
))
|
|
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
|
|
))
|
|
assert completed.latest_plan is None
|
|
assert completed.lifecycle_status == "waiting_human"
|
|
assert completed.actions[0].action_id == action.action_id
|
|
assert repository.run["status"] == "succeeded"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_insufficient_data_without_graph_or_node_ends_the_round():
|
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
report = _insufficient_report()
|
|
report["data_gaps"][0].pop("node_id")
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", report=report,
|
|
evidence=[{"evidence_id": "empty-query", "kind": "synthetic", "summary": "成功查询,范围内没有记录",
|
|
"source_label": "测试输入", "observed_at": now.isoformat()}],
|
|
))
|
|
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
|
|
))
|
|
assert completed.lifecycle_status == "completed"
|
|
assert completed.latest_report.report.data_gaps[0].node_id is None
|
|
assert repository.run["status"] == "succeeded"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_report_history_and_notifications_keep_selected_and_current_references_separate():
|
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
for conclusion in ["第一次判断", "更新后的判断"]:
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1",
|
|
report={**_current_report(), "current_conclusion": conclusion},
|
|
))
|
|
page, offset = await service.list_reports(task["task_id"], 1, 0)
|
|
assert [item.revision for item in page] == [2] and offset == 1
|
|
page, offset = await service.list_reports(task["task_id"], 1, offset)
|
|
assert [item.revision for item in page] == [1] and offset is None
|
|
selected = await service.agent_context(task["task_id"], report_revision=1)
|
|
assert selected.selected_report.revision == 1
|
|
assert selected.task.latest_report.revision == 2
|
|
assert selected.selected_report.report.current_conclusion == "第一次判断"
|
|
assert repository.notifications[0]["lifecycle_status"] == "resolved"
|
|
assert repository.notifications[-1]["report_reference"] == {"execution_generation": 1, "revision": 2}
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1",
|
|
report={**_current_report(), "current_conclusion": "更新后的判断",
|
|
"diagram": {"title": "说明", "mermaid": 'flowchart TD\nA["核查"] --> B["判断"]'}},
|
|
))
|
|
assert len(repository.notifications) == 2
|
|
with pytest.raises(DomainError) as missing:
|
|
await service.report(task["task_id"], 999)
|
|
assert missing.value.code == "REPORT_NOT_FOUND"
|
|
with pytest.raises(DomainError) as other_task:
|
|
await service.list_reports(uuid4(), 20, 0)
|
|
assert other_task.value.status == 404
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_final_report_archives_its_progress_diagram_across_new_rounds():
|
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
diagram = {"title": "调查过程", "mermaid": "flowchart LR\nA[核查] --> B[形成判断]",
|
|
"node_refs": [{"node_id": "B", "label": "形成判断", "status": "running"}]}
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
|
|
progress={"summary": "正在整理判断", "diagram": diagram},
|
|
))
|
|
command = AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", report={**_current_report(), "diagram": None},
|
|
)
|
|
await service.publish_artifacts(task["task_id"], command)
|
|
assert repository.reports[-1]["report"]["diagram"]["title"] == "调查过程"
|
|
assert repository.reports[-1]["report"]["diagram"]["node_refs"][0]["status"] == "running"
|
|
assert command.report.diagram is None # Do not change the idempotency request.
|
|
repository.task["execution_generation"] = 2
|
|
detail = await service.detail(task["task_id"])
|
|
assert detail.latest_progress is None
|
|
assert detail.latest_report.report.diagram.title == "调查过程"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("mismatch", [None, "session", "future", "evidence", "cleared"])
|
|
async def test_legacy_report_diagram_reads_only_matching_prepublication_progress(mismatch):
|
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
repository.reports.append({
|
|
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
|
|
"published_at": now.isoformat(), "published_by": "agent", "report": _current_report(),
|
|
})
|
|
repository.progress[(task["task_id"], 1)] = {
|
|
"revision": 1, "execution_generation": 1, "run_id": repository.run["run_id"],
|
|
"source_session_id": "another-session" if mismatch == "session" else "session-1",
|
|
"published_at": (now + timedelta(seconds=1) if mismatch == "future" else now - timedelta(seconds=1)).isoformat(),
|
|
"progress": {"summary": "报告发布前的核查", "diagram": None if mismatch == "cleared" else {
|
|
"title": "已保存的过程图", "mermaid": "flowchart LR\nA[核查]",
|
|
"node_refs": [{"node_id": "A", "label": "核查", "evidence_refs": ["not-in-report"] if mismatch == "evidence" else []}],
|
|
}},
|
|
}
|
|
repository.task["execution_generation"] = 2
|
|
detail = await service.detail(task["task_id"])
|
|
selected = await service.report(task["task_id"], 1)
|
|
history, _ = await service.list_reports(task["task_id"], 20, 0)
|
|
for report in (detail.latest_report, selected, history[0]):
|
|
assert (report.report.diagram is not None) is (mismatch is None)
|
|
assert repository.reports[0]["report"].get("diagram") is None # Read compatibility never rewrites history.
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("node_ref,code", [
|
|
({"node_id": "A", "label": "依据", "evidence_refs": ["forged"]}, "EVIDENCE_NOT_FOUND"),
|
|
({"node_id": "A", "label": "动作", "action_refs": [str(uuid4())]}, "ACTION_NOT_FOUND"),
|
|
])
|
|
async def test_optional_diagram_cannot_forge_business_references(node_ref, code):
|
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
with pytest.raises(DomainError) as invalid:
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1",
|
|
report={**_current_report(), "diagram": {"title": "调查脉络", "mermaid": 'flowchart TD\nA["核查"]', "node_refs": [node_ref]}},
|
|
))
|
|
assert invalid.value.code == code
|
|
assert repository.reports == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_action_proposal_keeps_running_lease_until_plan_and_report_close_run():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
|
|
))
|
|
revision_before_proposal = repository.run["revision"]
|
|
lease_before_proposal = repository.run["lease_expires_at"]
|
|
|
|
_seed_concluded_report(repository, now)
|
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
|
node_id="field-work", evidence_refs=[],
|
|
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
|
|
))
|
|
assert repository.task["status"] == "running"
|
|
assert repository.run["revision"] == revision_before_proposal
|
|
assert repository.run["lease_expires_at"] == lease_before_proposal
|
|
heartbeat = await service.heartbeat_run(
|
|
task["task_id"], repository.run["run_id"], AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=revision_before_proposal,
|
|
source_session_id="session-1",
|
|
),
|
|
)
|
|
assert heartbeat.status == "running"
|
|
|
|
published = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1",
|
|
run_id=repository.run["run_id"],
|
|
plan=_waiting_plan(action.action_id), report=_current_report(),
|
|
))
|
|
closed = await service.complete_run(
|
|
task["task_id"], repository.run["run_id"],
|
|
AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=published.task.active_run.revision,
|
|
source_session_id="session-1",
|
|
),
|
|
)
|
|
assert closed.status == "waiting_human"
|
|
assert closed.actions[0].action_id == action.action_id
|
|
assert [item["kind"] for item in repository.notifications] == [
|
|
"action_approval_pending"
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rejected_action_requeues_task_in_a_new_execution_generation():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now).copy())
|
|
service = OperationalTaskService(
|
|
repository,
|
|
context,
|
|
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
|
clock=lambda: now,
|
|
)
|
|
_seed_concluded_report(repository, now)
|
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
|
evidence_refs=[],
|
|
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
|
|
))
|
|
original_request_id = repository.task["execution_request_id"]
|
|
|
|
rejected = await service.decide_action(
|
|
task["task_id"],
|
|
action.action_id,
|
|
ActionDecisionCommand(
|
|
request_id=uuid4(), expected_version=action.version,
|
|
decision="rejected", comment="当前方案不执行",
|
|
),
|
|
)
|
|
|
|
assert rejected.approval_status == "rejected"
|
|
assert rejected.version == action.version + 1
|
|
assert repository.task["status"] == "queued"
|
|
assert repository.task["execution_generation"] == 2
|
|
assert repository.task["execution_request_id"] != original_request_id
|
|
assert repository.task["agent_session_id"] is None
|
|
assert repository.task["agent_lease_expires_at"] is None
|
|
approval_notice = next(
|
|
item for item in repository.notifications
|
|
if item["kind"] == "action_approval_pending"
|
|
)
|
|
assert approval_notice["lifecycle_status"] == "resolved"
|
|
assert approval_notice["resolution_reason"] == "action_rejected"
|
|
assert any(
|
|
event["topic"] == "operational.notification.changed"
|
|
and event["metadata"].get("change") == "resolved"
|
|
for event in repository.events
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_action_decision_rejects_a_stale_expected_version():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(
|
|
repository,
|
|
context,
|
|
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
|
clock=lambda: now,
|
|
)
|
|
_seed_concluded_report(repository, now)
|
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
|
evidence_refs=[],
|
|
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
|
|
))
|
|
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.decide_action(
|
|
task["task_id"],
|
|
action.action_id,
|
|
ActionDecisionCommand(
|
|
request_id=uuid4(), expected_version=action.version + 1,
|
|
decision="approved",
|
|
),
|
|
)
|
|
|
|
assert exc.value.code == "VERSION_CONFLICT"
|
|
assert repository.action["approval_status"] == "pending"
|
|
assert repository.action["version"] == action.version
|
|
|
|
|
|
def test_v2_plan_requires_explicit_decision_route_and_rejects_cycles():
|
|
plan = {
|
|
"schema": "execution-plan@2", "title": "动态调查", "reason": "核对投诉与风险",
|
|
"nodes": [
|
|
{"id": "risk", "title": "判断风险", "owner": "agent", "status": "completed",
|
|
"detail": "按已核实事件选择", "kind": "decision", "selected_edge_id": "urgent",
|
|
"evidence_refs": ["risk-evidence"]},
|
|
{"id": "urgent-check", "title": "紧急采样", "owner": "human", "status": "waiting",
|
|
"detail": "等待现场人员", "kind": "activity"},
|
|
{"id": "routine-check", "title": "常规核查", "owner": "human", "status": "skipped",
|
|
"detail": "紧急路线已选", "kind": "activity"},
|
|
],
|
|
"edges": [
|
|
{"id": "urgent", "from": "risk", "to": "urgent-check", "condition": "有紧急风险"},
|
|
{"id": "routine", "from": "risk", "to": "routine-check", "condition": "无紧急风险"},
|
|
],
|
|
}
|
|
assert ExecutionPlanInput.model_validate(plan).nodes[0].selected_edge_id == "urgent"
|
|
with pytest.raises(ValidationError, match="outgoing route"):
|
|
ExecutionPlanInput.model_validate({**plan, "nodes": [{**plan["nodes"][0], "selected_edge_id": "missing"}, *plan["nodes"][1:]]})
|
|
with pytest.raises(ValidationError, match="acyclic"):
|
|
ExecutionPlanInput.model_validate({**plan, "edges": [*plan["edges"], {"id": "loop", "from": "urgent-check", "to": "risk"}]})
|
|
with pytest.raises(ValidationError, match="unchosen route"):
|
|
ExecutionPlanInput.model_validate({**plan, "nodes": [plan["nodes"][0], plan["nodes"][1],
|
|
{**plan["nodes"][2], "status": "waiting"}]})
|
|
with pytest.raises(ValidationError, match="only decision or parallel split"):
|
|
ExecutionPlanInput.model_validate({**plan, "nodes": [{**plan["nodes"][0], "kind": "activity", "selected_edge_id": None}, *plan["nodes"][1:]]})
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_task_access_grant_is_project_scoped_reviewable_and_requeues():
|
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
initial_revision = task["revision"]
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
agent = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
request = await agent.request_access(task["task_id"], TaskAccessRequestCreate(
|
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
|
scopes=["scada_devices"], reason="核对上游监测点",
|
|
))
|
|
assert repository.task["revision"] == initial_revision + 1
|
|
assert any(event["topic"] == "operational.task.changed" for event in repository.events)
|
|
assert not await agent.can_read_source(task["task_id"], "scada_devices")
|
|
assert repository.notifications[0]["kind"] == "access_approval_pending"
|
|
reviewer = OperationalTaskService(repository, context, {OPERATIONAL_TASK_ACCESS_APPROVE, SCADA_VIEW}, clock=lambda: now)
|
|
approved = await reviewer.decide_access(task["task_id"], request.access_request_id,
|
|
TaskAccessRequestDecision(request_id=uuid4(), expected_version=1,
|
|
decision="approved", approved_scopes=["scada_devices"]))
|
|
assert approved.version == 2
|
|
assert not await agent.can_read_source(task["task_id"], "scada_devices")
|
|
repository.run.update(status="running", agent_session_id="session-2",
|
|
lease_expires_at=now + timedelta(minutes=1))
|
|
assert await agent.can_read_source(task["task_id"], "scada_devices")
|
|
assert not await agent.can_read_source(task["task_id"], "scada_readings")
|
|
assert repository.task["status"] == "queued"
|
|
access_notice = next(
|
|
item for item in repository.notifications
|
|
if item["kind"] == "access_approval_pending"
|
|
)
|
|
assert access_notice["lifecycle_status"] == "resolved"
|
|
assert access_notice["resolution_reason"] == "access_request_approved"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_task_access_request_rejects_a_second_pending_request():
|
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
|
scopes=["scada_devices"], reason="核对上游监测点",
|
|
))
|
|
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
|
scopes=["scada_devices"], reason="重复申请",
|
|
))
|
|
|
|
assert exc.value.code == "ACCESS_REQUEST_PENDING"
|
|
assert len(repository.access_requests) == 1
|
|
assert len(repository.notifications) == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_task_access_request_fails_closed_before_project_migration():
|
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
repository.access_requests_available = AsyncMock(return_value=False)
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
|
scopes=["scada_devices"], reason="核对上游监测点",
|
|
))
|
|
|
|
assert exc.value.code == "DEPENDENCY_UNAVAILABLE"
|
|
assert exc.value.status == 503
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_completed_gate_requires_access_request_decision():
|
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
request = await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
|
scopes=["scada_devices"], reason="核对上游监测点",
|
|
))
|
|
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1",
|
|
plan={
|
|
"schema": "execution-plan@2",
|
|
"title": "读取监测数据",
|
|
"reason": "等待授权后读取",
|
|
"nodes": [{
|
|
"id": "read-scada", "title": "数据访问审核",
|
|
"owner": "human", "status": "completed",
|
|
"detail": "尚未审核", "kind": "gate",
|
|
"gate_ref": str(request.access_request_id),
|
|
}],
|
|
"edges": [],
|
|
},
|
|
))
|
|
|
|
assert exc.value.code == "GATE_NOT_RESOLVED"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_work_order_draft_edit_creates_new_review_version():
|
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, clock=lambda: now)
|
|
_seed_concluded_report(repository, now)
|
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="现场采样", evidence_refs=[],
|
|
payload={"title": "旧草稿", "description": "采样", "work_order_type": "general"},
|
|
))
|
|
updated = await service.update_work_order_draft(task["task_id"], action.action_id,
|
|
WorkOrderDraftUpdateCommand(request_id=uuid4(), expected_version=1,
|
|
payload={"title": "人工修订草稿", "description": "增加复测", "work_order_type": "general"}))
|
|
assert updated.version == 2
|
|
assert updated.payload.title == "人工修订草稿"
|
|
assert updated.approval_status == "pending"
|
|
with pytest.raises(DomainError, match="Action version changed"):
|
|
await service.update_work_order_draft(task["task_id"], action.action_id,
|
|
WorkOrderDraftUpdateCommand(request_id=uuid4(), expected_version=1,
|
|
payload={"title": "过期草稿", "description": "采样", "work_order_type": "general"}))
|
|
assert repository.action["approval_status"] == "pending"
|
|
assert repository.action["version"] == updated.version
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_one_click_work_order_confirmation_replays_without_duplicate_dispatch():
|
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, {
|
|
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
|
}, clock=lambda: now)
|
|
_seed_concluded_report(repository, now)
|
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="现场采样", evidence_refs=[],
|
|
payload={"title": "采样", "description": "现场核验", "work_order_type": "general"},
|
|
))
|
|
|
|
class Dispatcher:
|
|
calls = 0
|
|
|
|
async def dispatch(self, **_kwargs):
|
|
self.calls += 1
|
|
return ActionDispatchOutcome("unsupported", "测试适配器未配置")
|
|
|
|
dispatcher = Dispatcher()
|
|
command = WorkOrderConfirmDispatchCommand(request_id=uuid4(), expected_version=action.version)
|
|
first = await service.confirm_work_order_dispatch(task["task_id"], action.action_id,
|
|
command, dispatcher, None)
|
|
replay = await service.confirm_work_order_dispatch(task["task_id"], action.action_id,
|
|
command, dispatcher, None)
|
|
assert first.approval_status == "approved"
|
|
assert first.execution_status == "unsupported"
|
|
assert replay.version == first.version
|
|
assert dispatcher.calls == 1
|
|
assert [item["kind"] for item in repository.notifications] == [
|
|
"action_approval_pending",
|
|
"external_coordination_failed",
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_new_payload_creates_a_new_pending_action_without_inheriting_approval():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = TaskRepository(task)
|
|
service = OperationalTaskService(
|
|
repository,
|
|
context,
|
|
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
|
clock=lambda: now,
|
|
)
|
|
_seed_concluded_report(repository, now)
|
|
original = await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="需要首次核验",
|
|
evidence_refs=[],
|
|
payload={"title": "首次核验", "description": "原始内容", "work_order_type": "sampling"},
|
|
))
|
|
approved = await service.decide_action(
|
|
task["task_id"],
|
|
original.action_id,
|
|
ActionDecisionCommand(
|
|
request_id=uuid4(), expected_version=original.version, decision="approved",
|
|
),
|
|
)
|
|
|
|
_seed_concluded_report(repository, now)
|
|
revised = await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="内容已修改",
|
|
evidence_refs=[],
|
|
payload={"title": "修改后核验", "description": "新的内容", "work_order_type": "sampling"},
|
|
))
|
|
|
|
assert approved.approval_status == "approved"
|
|
assert revised.action_id != approved.action_id
|
|
assert revised.payload != approved.payload
|
|
assert revised.approval_status == "pending"
|
|
assert revised.execution_status == "not_submitted"
|
|
assert revised.version == 1
|
|
assert revised.decided_by is None
|
|
assert revised.decided_at is None
|
|
|
|
|
|
def _work_order_snapshot(work_order_id, now):
|
|
return {
|
|
"work_order_id": str(work_order_id),
|
|
"revision": 2,
|
|
"title": "复测预约",
|
|
"summary": "等待现场复测回令",
|
|
"status": "awaiting_reply",
|
|
"priority": "high",
|
|
"assignee": "水质班组",
|
|
"scheduled_at": (now + timedelta(hours=1)).isoformat(),
|
|
"work_order_type": "water_quality_complaint",
|
|
"location": "联调小区",
|
|
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
|
|
"feedback_requirements": ["上传检测结果"],
|
|
"activity": [{"sequence": 1, "occurred_at": now.isoformat(), "actor": "水质班组",
|
|
"kind": "feedback", "title": "现场反馈", "detail": "上传复测结果"}],
|
|
"origin": "synthetic",
|
|
"updated_at": now.isoformat(),
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_external_feedback_exposes_validated_work_order_snapshot_and_refreshes_summary():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
work_order_id = uuid4()
|
|
task = _task_document(context, now, lease=None)
|
|
task.update(
|
|
task_type="external_work_order",
|
|
workflow="water_quality_complaint",
|
|
source={
|
|
"kind": "external_system",
|
|
"external_id": f"peripheral_sample:{work_order_id}",
|
|
"external_revision": 1,
|
|
"event_kind": "water_quality_complaint",
|
|
},
|
|
)
|
|
repository = TaskRepository(task)
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
result = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
|
request_id=uuid4(),
|
|
source_event_id=f"peripheral_sample:{work_order_id}",
|
|
source_revision=2,
|
|
status="awaiting_feedback",
|
|
occurred_at=now,
|
|
detail="等待现场复测回令",
|
|
work_order_snapshot=_work_order_snapshot(work_order_id, now),
|
|
))
|
|
|
|
assert result.task.status == "waiting_human"
|
|
assert result.task.title == "复测预约"
|
|
assert result.task.summary == "等待现场复测回令"
|
|
assert result.task.scheduled_for == now + timedelta(hours=1)
|
|
assert result.task.priority == "urgent"
|
|
assert result.task.work_order_snapshot is not None
|
|
assert result.task.work_order_snapshot.activity[0].title == "现场反馈"
|
|
assert result.task.work_order_snapshot.feedback_requirements == ["上传检测结果"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_withdraw_external_source_keeps_history_and_is_idempotent():
|
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
work_order_id = uuid4()
|
|
source_id = f"peripheral_sample:{work_order_id}"
|
|
task = _task_document(context, now, lease=None)
|
|
task.update(
|
|
task_type="external_work_order",
|
|
source={"kind": "external_system", "external_id": source_id,
|
|
"external_revision": 1, "event_kind": "general"},
|
|
)
|
|
repository = TaskRepository(task)
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
command = ExternalSourceWithdrawalCommand(
|
|
request_id=uuid4(), source_event_id=source_id
|
|
)
|
|
|
|
withdrawn = await service.withdraw_external_source(task["task_id"], command)
|
|
replayed = await service.withdraw_external_source(task["task_id"], command)
|
|
|
|
assert withdrawn.source.deleted_at == now
|
|
assert withdrawn.status == "cancelled"
|
|
assert withdrawn.lifecycle_status == "cancelled"
|
|
assert withdrawn.revision == 3
|
|
assert replayed.revision == withdrawn.revision
|
|
assert repository.task["source"]["external_revision"] == 1
|
|
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.withdraw_external_source(task["task_id"], ExternalSourceWithdrawalCommand(
|
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{uuid4()}"
|
|
))
|
|
assert exc.value.code == "EXTERNAL_SOURCE_MISMATCH"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_withdrawn_external_task_rejects_action_changes_and_resolves_approval():
|
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
work_order_id = uuid4()
|
|
source_id = f"peripheral_sample:{work_order_id}"
|
|
task = _task_document(context, now, lease=None)
|
|
task.update(
|
|
task_type="external_work_order",
|
|
status="scheduled",
|
|
source={"kind": "external_system", "external_id": source_id,
|
|
"external_revision": 1, "event_kind": "general"},
|
|
)
|
|
repository = TaskRepository(task)
|
|
service = OperationalTaskService(
|
|
repository,
|
|
context,
|
|
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
|
clock=lambda: now,
|
|
)
|
|
_seed_concluded_report(repository, now)
|
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
|
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
|
evidence_refs=[],
|
|
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
|
|
))
|
|
|
|
await service.withdraw_external_source(
|
|
task["task_id"],
|
|
ExternalSourceWithdrawalCommand(request_id=uuid4(), source_event_id=source_id),
|
|
)
|
|
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.decide_action(
|
|
task["task_id"],
|
|
action.action_id,
|
|
ActionDecisionCommand(
|
|
request_id=uuid4(), expected_version=action.version, decision="approved",
|
|
),
|
|
)
|
|
assert exc.value.code == "TASK_NOT_ACTIONABLE"
|
|
assert repository.notifications[0]["lifecycle_status"] == "resolved"
|
|
assert repository.notifications[0]["resolution_reason"] == "external_source_withdrawn"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_event_investigation_rejects_work_order_snapshot():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = TaskRepository(task)
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
work_order_id = uuid4()
|
|
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
|
request_id=uuid4(), source_event_id=str(work_order_id), source_revision=2,
|
|
status="awaiting_feedback", occurred_at=now, detail="不应接受",
|
|
work_order_snapshot=_work_order_snapshot(work_order_id, now),
|
|
))
|
|
|
|
assert exc.value.code == "INVALID_FEEDBACK_SNAPSHOT"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_expired_owned_lease_is_rejected_with_stable_code():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now - timedelta(seconds=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
repository.run["lease_expires_at"] = now - timedelta(seconds=1)
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.heartbeat_run(
|
|
task["task_id"], repository.run["run_id"],
|
|
AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
|
|
),
|
|
)
|
|
|
|
assert exc.value.code == "LEASE_EXPIRED"
|
|
assert repository.run["revision"] == 2
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_interrupted_submit_replays_unknown_without_dispatching_twice():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
task.update(status="waiting_human", agent_lease_expires_at=None)
|
|
action = HumanApprovalAction(
|
|
action_id=uuid4(),
|
|
task_id=task["task_id"],
|
|
version=2,
|
|
kind="create_work_order",
|
|
reason="现场核验",
|
|
payload={"title": "采样", "description": "核验", "work_order_type": "sampling"},
|
|
evidence_refs=[],
|
|
required_approval_permission="work-order.approve",
|
|
required_execution_permission=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
|
approval_status="approved",
|
|
execution_status="not_submitted",
|
|
proposed_by="principal:agent",
|
|
proposed_at=now,
|
|
decided_by="principal:approver",
|
|
decided_at=now,
|
|
).model_dump(mode="json")
|
|
repository = TaskRepository(task, action=action)
|
|
service = OperationalTaskService(
|
|
repository,
|
|
context,
|
|
{OPERATIONAL_ACTION_WORK_ORDER_EXECUTE},
|
|
clock=lambda: now,
|
|
)
|
|
command = ActionExecuteCommand(request_id=uuid4(), expected_version=2)
|
|
|
|
class LostResponseDispatcher:
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
async def dispatch(self, **_kwargs):
|
|
self.calls += 1
|
|
raise RuntimeError("connection lost after submission")
|
|
|
|
dispatcher = LostResponseDispatcher()
|
|
with pytest.raises(RuntimeError):
|
|
await service.submit_action(
|
|
task["task_id"], action["action_id"], command, dispatcher, None
|
|
)
|
|
|
|
replay = await service.submit_action(
|
|
task["task_id"], action["action_id"], command, dispatcher, None
|
|
)
|
|
|
|
assert dispatcher.calls == 1
|
|
assert replay.execution_status == "unknown"
|
|
assert replay.execution_request_id is not None
|
|
assert [item["kind"] for item in repository.notifications] == [
|
|
"external_coordination_failed"
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_snapshot_reuse_returns_the_original_provenance_and_rejects_collision():
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = TaskRepository(task)
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
original = TaskDataSnapshot(
|
|
source="peripheral_points",
|
|
source_ref="peripheral_points:sha256:same",
|
|
queried_at=now,
|
|
origin="synthetic",
|
|
data={"items": [{"id": "p-1"}]},
|
|
truncated=False,
|
|
)
|
|
evidence, stored = await service.record_data_snapshot(task["task_id"], original)
|
|
|
|
replayed = original.model_copy(update={"queried_at": now + timedelta(minutes=1)})
|
|
replay_evidence, replay_snapshot = await service.record_data_snapshot(
|
|
task["task_id"], replayed
|
|
)
|
|
|
|
assert replay_evidence == evidence
|
|
assert replay_snapshot == stored == original
|
|
|
|
collision = original.model_copy(update={"data": {"items": [{"id": "p-2"}]}})
|
|
with pytest.raises(DomainError) as exc:
|
|
await service.record_data_snapshot(task["task_id"], collision)
|
|
assert exc.value.code == "EVIDENCE_CONFLICT"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_repository_task_update_rejects_a_stale_revision():
|
|
cursor = SimpleNamespace(rowcount=0)
|
|
connection = SimpleNamespace(execute=AsyncMock(return_value=cursor))
|
|
repository = OperationalTaskRepository(connection, uuid4())
|
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
task["revision"] = 3
|
|
|
|
with pytest.raises(DomainError) as exc:
|
|
await repository.update_task(task)
|
|
|
|
assert exc.value.code == "VERSION_CONFLICT"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_interactive_report_publication_is_idempotent_and_versioned_with_evidence():
|
|
from tests.unit.test_report_ui import rich_report
|
|
|
|
now = datetime(2026, 10, 20, 8, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
report = {**rich_report(), "stage": "final"}
|
|
command = AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", report=report,
|
|
evidence=[{"evidence_id": "demo-1", "kind": "synthetic", "summary": "演练压力记录",
|
|
"source_label": "演练监测", "observed_at": now.isoformat()}],
|
|
)
|
|
first = await service.publish_artifacts(task["task_id"], command)
|
|
replay = await service.publish_artifacts(task["task_id"], command)
|
|
assert first.report_revision == replay.report_revision == 1
|
|
report["ui_blocks"][0]["block"]["spec"]["elements"]["chart"]["props"]["series"][0]["data"] = [0.3, 0.29]
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", report=report,
|
|
))
|
|
old = await service.report(task["task_id"], 1)
|
|
latest = await service.detail(task["task_id"])
|
|
history, _ = await service.list_reports(task["task_id"], 10, 0)
|
|
assert old.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27]
|
|
assert latest.latest_report.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.29]
|
|
assert [item.revision for item in history] == [2, 1]
|
|
assert latest.latest_report.report.evidence_refs == ["demo-1"]
|
|
assert len(repository.notifications) == 2
|
|
report["evidence_refs"] = ["foreign"]
|
|
report["ui_blocks"][0]["evidence_refs"] = ["foreign"]
|
|
with pytest.raises(DomainError) as error:
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="session-1", report=report,
|
|
))
|
|
assert error.value.code == "EVIDENCE_NOT_FOUND"
|