"""A2A urgency must round-trip as the full Priority tristate. Pre-Phase-3 the A2A path collapsed `priority` at the request boundary into a boolean `urgent`, then mapped that boolean back to NotificationPriority.URGENT / NORMAL — so the middle tier NotificationPriority.HIGH was unreachable through the A2A code path. After Task 9 the tristate (NORMAL/HIGH/URGENT) survives end-to-end: the NotificationTable row inserted by `send_a2a_notification` carries the priority the caller asked for. These tests pin that contract on both layers: * unit — `NotificationService.send_a2a_notification` accepts a `Priority` in its `a2a_context` and writes that exact enum value to the inserted row. * service — `A2AService.create_a2a_notification` parses `metadata["priority"]` (preferring the tristate value) and passes a `Priority` through to NotificationService. """ from __future__ import annotations from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID, uuid4 import pytest import pytest_asyncio from roboco.db.tables import AgentTable, ProjectTable, TaskTable from roboco.foundation.policy.communications import Priority from roboco.models import AgentRole, AgentStatus, Team from roboco.models.a2a import A2AMessage, SendMessageRequest, TextPart from roboco.models.base import NotificationPriority, TaskNature, TaskStatus, TaskType from roboco.services.a2a import A2AService from roboco.services.notification import NotificationService if TYPE_CHECKING: from collections.abc import AsyncGenerator, AsyncIterator from sqlalchemy.ext.asyncio import AsyncSession # --------------------------------------------------------------------------- # Foundation sanity — Priority IS the tristate, not a boolean. # --------------------------------------------------------------------------- def test_priority_has_three_distinct_values() -> None: """If this fails Phase 3 Task 1 regressed.""" assert Priority.NORMAL != Priority.HIGH assert Priority.HIGH != Priority.URGENT assert Priority.NORMAL != Priority.URGENT # Same enum object as the DB column type. assert Priority is NotificationPriority # --------------------------------------------------------------------------- # NotificationService.send_a2a_notification — DB row carries tristate # --------------------------------------------------------------------------- class _FakeDb: """Records inserted notification rows so we can assert on .priority.""" def __init__(self, *, agent_uuid: UUID) -> None: self.added: list = [] self._agent_uuid = agent_uuid def add(self, obj: Any) -> None: self.added.append(obj) obj.id = uuid4() async def flush(self) -> None: return None async def commit(self) -> None: return None async def execute(self, *_args: Any, **_kwargs: Any) -> Any: result = MagicMock() agent = MagicMock() agent.id = self._agent_uuid agent.slug = "test-agent" result.scalar_one_or_none.return_value = agent result.scalars.return_value.all.return_value = [] return result async def scalar(self, *_args: Any, **_kwargs: Any) -> None: # _create_notification's purpose-dedup lookup — no existing duplicate. return None @asynccontextmanager async def _fake_ctx(db: _FakeDb) -> AsyncGenerator[_FakeDb]: yield db class _PatchDbContext: def __init__(self, db: _FakeDb) -> None: delivery_mock = MagicMock() delivery_mock.deliver = AsyncMock(return_value=None) self._patches: list[Any] = [ patch( "roboco.services.notification.get_db_context", lambda: _fake_ctx(db), ), patch( "roboco.services.notification_delivery." "get_notification_delivery_service", lambda _db: delivery_mock, ), ] def __enter__(self) -> None: for p in self._patches: p.start() def __exit__(self, *_args: Any) -> None: for p in self._patches: p.stop() @pytest.mark.asyncio async def test_send_a2a_notification_high_priority_writes_high() -> None: """priority=Priority.HIGH ends up at NotificationTable.priority=HIGH. This is the heart of Task 9: pre-fix, HIGH was unreachable via this method because the contract was `urgent: bool`. """ svc = NotificationService() db = _FakeDb(agent_uuid=uuid4()) with _PatchDbContext(db): await svc.send_a2a_notification( task_id="t1", a2a_context={ "from_agent": "be-dev-1", "to_agent": "fe-dev-1", "skill": "react", "message": "hi", "priority": Priority.HIGH, }, ) assert db.added, "Notification row should have been inserted." row = db.added[0] assert row.priority == NotificationPriority.HIGH # HIGH gets NO cosmetic [URGENT] prefix — that label is urgent-only. assert "[URGENT]" not in row.subject assert "[URGENT]" not in row.body @pytest.mark.asyncio async def test_send_a2a_notification_normal_priority_writes_normal() -> None: svc = NotificationService() db = _FakeDb(agent_uuid=uuid4()) with _PatchDbContext(db): await svc.send_a2a_notification( task_id="t1", a2a_context={ "from_agent": "be-dev-1", "to_agent": "fe-dev-1", "skill": "react", "message": "hi", "priority": Priority.NORMAL, }, ) row = db.added[0] assert row.priority == NotificationPriority.NORMAL assert "[URGENT]" not in row.subject @pytest.mark.asyncio async def test_send_a2a_notification_urgent_preserves_prefix() -> None: """URGENT still gets the cosmetic [URGENT] prefix in subject + body.""" svc = NotificationService() db = _FakeDb(agent_uuid=uuid4()) with _PatchDbContext(db): await svc.send_a2a_notification( task_id="t1", a2a_context={ "from_agent": "be-dev-1", "to_agent": "fe-dev-1", "skill": "react", "message": "hi", "priority": Priority.URGENT, }, ) row = db.added[0] assert row.priority == NotificationPriority.URGENT assert "[URGENT]" in row.subject assert "[URGENT]" in row.body # --------------------------------------------------------------------------- # A2AService.create_a2a_notification — request parses + forwards Priority # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def a2a_setup( db_session: AsyncSession, ) -> AsyncIterator[dict]: # Same seeded slugs the existing a2a tests use — A2A policy # rejects unknown roles. dev = AgentTable( id=uuid4(), name="Dev", slug="be-dev-1", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) qa = AgentTable( id=uuid4(), name="QA", slug="be-qa", role=AgentRole.QA, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="qa", capabilities=[], permissions={}, metrics={}, ) db_session.add_all([dev, qa]) await db_session.flush() project = ProjectTable( id=uuid4(), name="A-Proj", slug=f"a-proj-{uuid4().hex[:8]}", git_url="https://example.com/r.git", assigned_cell=Team.BACKEND, created_by=dev.id, ) db_session.add(project) await db_session.flush() task = TaskTable( id=uuid4(), title="t", description="d", acceptance_criteria=["ac"], status=TaskStatus.PENDING, priority=2, task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, project_id=project.id, created_by=dev.id, team=Team.BACKEND, ) db_session.add(task) await db_session.flush() yield { "svc": A2AService(db_session), "task_id": task.id, } @pytest.mark.asyncio async def test_create_a2a_notification_metadata_priority_high_propagates( a2a_setup: dict, ) -> None: """metadata={"priority": "high"} → NotificationService gets Priority.HIGH.""" svc = a2a_setup["svc"] task_id = str(a2a_setup["task_id"]) msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id) req = SendMessageRequest( message=msg, metadata={ "from_agent": "be-dev-1", "target_agent": "be-qa", "priority": "high", }, ) mock_ns = AsyncMock() mock_ns.send_a2a_notification = AsyncMock(return_value=None) with patch( "roboco.services.notification.NotificationService", return_value=mock_ns, ): await svc.create_a2a_notification(req) mock_ns.send_a2a_notification.assert_awaited_once() kwargs = mock_ns.send_a2a_notification.await_args.kwargs a2a_context = kwargs["a2a_context"] assert a2a_context["priority"] == Priority.HIGH @pytest.mark.asyncio async def test_create_a2a_notification_metadata_priority_urgent_propagates( a2a_setup: dict, ) -> None: svc = a2a_setup["svc"] task_id = str(a2a_setup["task_id"]) msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id) req = SendMessageRequest( message=msg, metadata={ "from_agent": "be-dev-1", "target_agent": "be-qa", "priority": "urgent", }, ) mock_ns = AsyncMock() mock_ns.send_a2a_notification = AsyncMock(return_value=None) with patch( "roboco.services.notification.NotificationService", return_value=mock_ns, ): await svc.create_a2a_notification(req) a2a_context = mock_ns.send_a2a_notification.await_args.kwargs["a2a_context"] assert a2a_context["priority"] == Priority.URGENT @pytest.mark.asyncio async def test_create_a2a_notification_default_priority_is_normal( a2a_setup: dict, ) -> None: """No metadata.priority + no configuration.urgent → Priority.NORMAL.""" svc = a2a_setup["svc"] task_id = str(a2a_setup["task_id"]) msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id) req = SendMessageRequest( message=msg, metadata={"from_agent": "be-dev-1", "target_agent": "be-qa"}, ) mock_ns = AsyncMock() mock_ns.send_a2a_notification = AsyncMock(return_value=None) with patch( "roboco.services.notification.NotificationService", return_value=mock_ns, ): await svc.create_a2a_notification(req) a2a_context = mock_ns.send_a2a_notification.await_args.kwargs["a2a_context"] assert a2a_context["priority"] == Priority.NORMAL @pytest.mark.asyncio async def test_create_a2a_notification_legacy_urgent_bool_maps_to_urgent( a2a_setup: dict, ) -> None: """Backcompat: callers that still send `urgent: True` (e.g. agent_sdk fallback at server.py:258) are honored as Priority.URGENT until that path is refactored in a later task. priority key wins if both set.""" svc = a2a_setup["svc"] task_id = str(a2a_setup["task_id"]) msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id) req = SendMessageRequest( message=msg, metadata={ "from_agent": "be-dev-1", "target_agent": "be-qa", "urgent": True, }, ) mock_ns = AsyncMock() mock_ns.send_a2a_notification = AsyncMock(return_value=None) with patch( "roboco.services.notification.NotificationService", return_value=mock_ns, ): await svc.create_a2a_notification(req) a2a_context = mock_ns.send_a2a_notification.await_args.kwargs["a2a_context"] assert a2a_context["priority"] == Priority.URGENT @pytest.mark.asyncio async def test_create_a2a_notification_unknown_priority_falls_back_to_normal( a2a_setup: dict, ) -> None: """Garbage priority string → NORMAL, not a crash.""" svc = a2a_setup["svc"] task_id = str(a2a_setup["task_id"]) msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id) req = SendMessageRequest( message=msg, metadata={ "from_agent": "be-dev-1", "target_agent": "be-qa", "priority": "nuclear", }, ) mock_ns = AsyncMock() mock_ns.send_a2a_notification = AsyncMock(return_value=None) with patch( "roboco.services.notification.NotificationService", return_value=mock_ns, ): await svc.create_a2a_notification(req) a2a_context = mock_ns.send_a2a_notification.await_args.kwargs["a2a_context"] assert a2a_context["priority"] == Priority.NORMAL