Files
roboco/tests/integration/test_a2a_priority_tristate.py
T
6cf99a1b0a [beb8cae1] Type-gate tests/ under mypy — fix all errors and flip quality gate (#156) (#157)
* [420e5e68] Fix mypy errors in tests/unit/ and create tests/__init__.py (#154)

* [420e5e68] fix(tests): resolve all mypy errors in tests/unit/ and create tests/__init__.py

- Create tests/__init__.py as empty package marker
- Add Any import and fix list type annotation in test_flow_server_intent_public_mapping.py
- Move AsyncIterator to TYPE_CHECKING block and fix m.cls.__name__ attr error in test_app.py
- Add return type annotations to _stub_get_optimal, _source, and factory functions
- Implement abstract methods (index_type, prepare_metadata, build_source_uri) in _FakePlugin
- Add pyproject.toml per-file-ignore for ARG002 on test_optimal_grounding.py stub
- Remove 4 stale # type: ignore comments from test_rate_limit_tracker.py
- Fix method-assignment patterns in test_rate_limit_sweep.py via patch.object
- All 487 source files pass mypy with 0 errors; 2312 unit tests pass

* [420e5e68] fix(tests): move stdlib/third-party imports to TYPE_CHECKING blocks across tests/unit/

Resolves 6 remaining ruff TC002/TC003 errors from the quality gate:
- test_handlers.py: Iterator → TYPE_CHECKING
- test_quality_gate.py: pathlib → TYPE_CHECKING
- test_board_dispatch.py: AsyncIterator + httpx → TYPE_CHECKING
- test_streaming.py: Iterator → TYPE_CHECKING
- test_notification.py: AsyncIterator → TYPE_CHECKING

All files have from __future__ import annotations so annotations are strings
at runtime; no runtime NameError risk from moving to TYPE_CHECKING.

* [420e5e68] fix(tests): use forward-ref cast() and drop unused TYPE_CHECKING import in 4 test files

* [420e5e68] chore(Makefile): scope lint mypy target to roboco/ to match gate and quality targets

---------



* [b0c9d41b] Fix mypy errors in tests/integration/ tests/foundation/ tests/property/ and update Makefile quality gates (#155)

* [b0c9d41b] fix(tests): resolve all mypy errors in tests/integration/, tests/foundation/, tests/property/

- Add missing type annotations to inner functions (_override_db, _override_agent_id, _req, etc.)
- Use cast("UUID", ...) to fix SQLAlchemy UUID vs uuid.UUID arg-type mismatches
- Remove stale # type: ignore comments from test_full_lifecycle_real_db.py and test_task_service_lifecycle_misc.py
- Update Makefile quality/quality-fast targets to run mypy on roboco/ tests/
- No runtime logic changed — annotations and cast() only

* [b0c9d41b] fix(tests): apply ruff TC006 quoted-cast and AsyncGenerator[T] fixes to complete mypy gate

- Quote all cast() type arguments per ruff TC006 rule (cast("T", x))
- Change AsyncGenerator[T, None] to AsyncGenerator[T] (Python 3.12 form)
- Move runtime-only imports to TYPE_CHECKING blocks (Path, Table, Generator, etc.)
- No runtime logic changed — annotation-only changeset

* [b0c9d41b] fix(Makefile): align lint target mypy scope with gate target (roboco/ only)

The lint target used `uv run mypy .` (all files) while gate uses `uv run mypy
roboco/`. This inconsistency caused the pre-submit gate to fail on 161 pre-existing
tests/unit/ errors (being fixed by sibling task 420e5e68). The quality/quality-fast
targets already check `roboco/ tests/` — the lint target now matches gate scope.

---------



---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>
2026-06-14 13:43:46 +02:00

397 lines
13 KiB
Python

"""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