Files
roboco/tests/unit/services/test_notification.py
T
1114ee5ea0 [77719d3f] A2A team telemetry: coordination event notifications for 5 event types (#477)
* [13d03d5c] Add 5 coordination-event notification producers + wire at chokepoints (#472) (#474)

* [13d03d5c] Add 5 coordination-event notification producer methods

* [13d03d5c] Wire reassignment/collision/unblock/dependency-revival notifications

* [13d03d5c] Wire stale-claim-reaped notification into orchestrator reaper

* [13d03d5c] fix(runtime): guard reaper's UUID annotation + defensive attr access

The stale-claim-reaped notification hook added a runtime-unquoted
`UUID` type annotation (only imported under TYPE_CHECKING, so the
module raised NameError on import) and a direct `t.assigned_to`
attribute access that crashes against the minimal test doubles the
existing reaper test suite uses. Quote the annotation and switch to
getattr-defensive access, matching `_assignee_is_provider_parked`'s
existing convention in the same file.

* [13d03d5c] test(notification): unit coverage for 5 coordination-event producers

One test per new send_* method (reassignment, collision-sequencing,
unblock, dependency-revival, stale-claim-reaped) following the
existing _FakeDb/_patch_db_context pattern, asserting subject/body/
related_task_id/priority/recipient-count, plus a no-recipients no-op
case for reassignment.

* [13d03d5c] test(task): prove reassign + unblock don't double-fire notifications

Two chokepoint-level tests mocking NotificationService at its defining
module: a repeated reassign() to the same already-current target skips
the notification (guarded by comparing against the pre-mutation
assignee), and a repeated unblock() on the same task only notifies
once since the second call short-circuits on the status!=BLOCKED
guard.

* [13d03d5c] style(task): ruff format the collision-sequencing wiring block

No behavior change — reflows the newly-added _notify_collision_sequencing
call site to satisfy ruff format's line-length rules.

* [13d03d5c] docs(backend): add coordination-event notification producers guide

Documented the 5 new NotificationService producers (reassignment, collision-sequencing,
unblock, dependency-revival, stale-claim-reaped) with fire conditions, double-fire
prevention mechanisms, and implementation patterns. Updated backend README to link the
new services guide for developers integrating new coordination events.

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [3ee8150b] Frontend: render coordination-event notifications + e2e smoke coverage (#475)

* [69777c3a] test(e2e-smoke): add coverage for soft-block + unblock coordination notifications (#471)

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>

* [8eb82639] Render 5 coordination-event notification types with task deep-links (#470)

* [8eb82639] feat(notifications): add APPROVAL type icon and deep-link component test

Add missing APPROVAL member to the frontend NotificationType enum to
match backend roboco/models/base.py, wire its icon into the existing
typeIcons Record in the notifications page, and add a component test
covering type rendering and the task deep-link.

* [8eb82639] docs(notifications): document 5 coordination-event types and APPROVAL enum addition

Added comprehensive reference guide explaining the 5 notification types
(TASK_ASSIGNMENT, BLOCKER_ESCALATION, REVIEW_REQUEST, DOCUMENTATION_REQUEST,
APPROVAL), their visual identities (icon + color), use cases, and
deep-linking behavior to related tasks. Updated panel README with quick
reference table. TypeScript Record pattern ensures exhaustive type coverage
at build time.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [a27de2a8] fix(docs): reflow hard-wrapped notification-types.md to pass markdown gate (#479) (#481)

The Python quality gate on assembled PR #477 was red because the newly
added docs/frontend/components/notification-types.md (introduced by the
frontend coordination-event rendering commit) had manually wrapped prose
paragraphs, which scripts/reflow_md.py --check rejects as part of make
quality. Reflowed the file with scripts/reflow_md.py --apply (whitespace
only, no content change) so the check passes. ruff format/check, mypy,
xenon, vulture, bandit, and the full pytest suite (10284 passed) all
confirmed green on this commit; notification.py, task.py, and
orchestrator.py are untouched.

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

* [705419d5] Remove duplicate unblock notification and fix its dependent tests (#485) (#488)

* [705419d5] fix(notifications): remove duplicate unblock notification, fix its tests

The /unblock route was still calling delivery.notify_assignee_of_unblock()
(TASK_ASSIGNMENT) after TaskService.unblock() already sent the
send_unblock_notification() ALERT wired in by an earlier task — a real
duplicate notification on every unblock. Delete the route-layer call and
the now-dead NotificationDeliveryService.notify_assignee_of_unblock
method, fix the integration test that mocked it, and fix/extend the e2e
notification-coordination-events test to assert the persisted ALERT rows
(exact subjects) for both the direct-unblock and dependency-revival
producers instead of the old TASK_ASSIGNMENT assertion.

* [705419d5] docs(backend): update coordination-events doc for unblock duplicate removal

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [6c142a73] docs(changelog): document restored coordination-event notification producers and add collision-sequencing double-fire test (#489) (#490)

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>

* [77719d3f] Seed system agent in e2e harness to fix unblock/dependency-revival notifications

The e2e harness's seed_company omitted the system sentinel agent that
production seeds via initial_data.py. The unblock and dependency-revival
notification producers default to from_agent="system", which
_resolve_agent_uuid looks up by slug in the DB. With no system row the
resolver returns None and _create_notification silently skips the
notification, so the two ALERT assertions got 0 rows instead of 1.

The soft-block test passed because it uses NotificationDeliveryService
which creates the notification directly with a real agent UUID as
from_agent, bypassing the slug resolution path entirely.

* [77719d3f] Use foundation UUID for system agent to avoid slug collision

The first attempt seeded the system agent with a random UUID. Other
tests (_seed_system_and_secretary, _seed_video_agents) check by the
fixed foundation UUID via session.get(AgentTable, uuid); not finding
it they INSERT their own system row, hitting ix_agents_slug. Using the
foundation UUID makes their check find the seed_company row and skip.

* [77719d3f] Fix dependency-revival notification event loop mismatch

The dependency-revival test calls _unblock_dependents directly via
stack.run_db, which creates a new asyncio event loop. Inside,
_notify_dependency_revival -> NotificationService._create_notification
opened its own session via get_db_context(), which reuses the singleton
_DbHolder engine — bound to the FastAPI server's event loop. The
asyncpg connection raised 'Future attached to a different loop' and the
exception was silently caught + logged as a warning, so the notification
never persisted and the test saw 0 rows.

Fix: add an optional db_session parameter to _create_notification and
the two send methods. When provided, use the caller's session directly
and skip the internal commit (the caller owns the transaction). The
TaskService's _notify_unblock and _notify_dependency_revival now pass
self.session, keeping the notification in the same event loop + session
as the task transition.

* [77719d3f] Scope system-agent seeding to notification tests only

Seeding the system sentinel in seed_company (commits 3bba7b32/617b7890)
fixed the 0-notification bug but caused 3 i_documented gateway_timeout
failures: every e2e test now paid notification-creation latency for
system-origin notifications that were previously silently skipped,
pushing the already-slow i_documented verb past its 120s timeout.

Move system-agent seeding out of seed_company and into a scoped
_seed_system_agent helper called only by the two coordination-event
tests that exercise send_unblock_notification /
send_dependency_revival_notification (both resolve from_agent='system'
via DB lookup). dev_lifecycle and state_machine tests revert to the
pre-fix behavior (system-origin notifications silently skipped, no extra
latency).

The event-loop fix (commit 7b95d77d: pass db_session=self.session to
_create_notification) is unchanged — dependency_revival still needs it
because stack.run_db creates a new event loop while _DbHolder.engine is
bound to the FastAPI server loop.

* [77719d3f] Fix reassignment notification deadlock + suppressed-notification commit regression

Two fixes in notification.py / task.py:

1. Cross-session self-deadlock in send_reassignment_notification:
   TaskService.reassign() flushes an uncommitted row lock on the task,
   then calls _notify_reassignment -> send_reassignment_notification ->
   _create_notification(db_session=None) which opens a SEPARATE session
   via get_db_context() and INSERTs a notification with related_task_id
   FK -> tasks.id. The FK key-share lock blocks on the request session's
   uncommitted exclusive lock, but the request can't commit until the
   notify returns -> 120s verb hard-cut. Fix: pass db_session=self.session
   so the notification joins the verb's own transaction, same pattern as
   the unblock/dependency-revival fix in 7b95d77d.

2. Suppressed-notification commit regression: the 7b95d77d refactor moved
   await db.commit() out of _create_notification_with_session into
   _create_notification's db_session=None branch, where it ran
   unconditionally — even when _create_notification_with_session returned
   early (suppressed: unresolvable from_agent / no recipients /
   refire-guard / dedup-hit). Fix: _create_notification_with_session now
   returns bool (False at each early return, True after delivery);
   _create_notification commits only when created is True.

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-13 06:38:15 +02:00

520 lines
18 KiB
Python

"""NotificationService coverage — mock the DB context.
The service uses `get_db_context()` internally rather than taking a session.
We patch it to a fake context that records inserted notification rows so we
can assert each `send_*` helper builds the right `CreateNotificationParams`
without spinning up a Postgres + Redis stack.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID, uuid4
import pytest
from roboco.foundation.policy.communications import ACK_REQUIRED_BY_TYPE
from roboco.models import NotificationPriority, NotificationType
from roboco.models.notification import CreateNotificationParams
from roboco.services.notification import (
NotificationService,
_resolve_agent_uuid,
)
class _FakeDb:
"""Stand-in for AsyncSession that records inserts and pretends to flush."""
def __init__(self, *, agent_uuid: UUID | None = None) -> None:
self.added: list = []
self.committed = False
self._agent_uuid = agent_uuid
def add(self, obj: Any) -> None:
self.added.append(obj)
# The notification row needs an `id` for delivery_service.deliver().
obj.id = uuid4()
async def flush(self) -> None:
return None
async def commit(self) -> None:
self.committed = True
async def execute(self, *_args: Any, **_kwargs: Any) -> Any:
# Two paths use this: agent slug→UUID resolution and the
# notification_delivery service's own DB queries. We return a
# MagicMock that supports `scalar_one_or_none()` returning either
# an agent (with .id) or None depending on the configured agent_uuid.
result = MagicMock()
if self._agent_uuid:
agent = MagicMock()
agent.id = self._agent_uuid
agent.slug = "test-agent"
result.scalar_one_or_none.return_value = agent
else:
result.scalar_one_or_none.return_value = None
result.scalars.return_value.all.return_value = []
# _duplicate_unacked_exists runs `db.execute(...).all()` and iterates;
# an empty list ⇒ no exact-set-equal candidate ⇒ not suppressed.
result.all.return_value = []
return result
async def scalar(self, *_args: Any, **_kwargs: Any) -> Any:
# _create_notification's purpose-dedup lookup runs db.scalar(); model
# "no existing duplicate" so creation proceeds.
return None
@asynccontextmanager
async def _fake_ctx(db: _FakeDb) -> AsyncIterator[_FakeDb]:
yield db
@pytest.fixture
def svc() -> NotificationService:
return NotificationService()
@pytest.mark.asyncio
async def test_resolve_agent_uuid_returns_none_for_blank() -> None:
db = _FakeDb()
assert await _resolve_agent_uuid(cast("Any", db), None) is None
assert await _resolve_agent_uuid(cast("Any", db), "") is None
@pytest.mark.asyncio
async def test_resolve_agent_uuid_passes_through_uuid() -> None:
aid = uuid4()
db = _FakeDb()
assert await _resolve_agent_uuid(cast("Any", db), aid) == aid
@pytest.mark.asyncio
async def test_resolve_agent_uuid_parses_uuid_string() -> None:
aid = uuid4()
db = _FakeDb()
assert await _resolve_agent_uuid(cast("Any", db), str(aid)) == aid
@pytest.mark.asyncio
async def test_resolve_agent_uuid_resolves_slug() -> None:
expected = uuid4()
db = _FakeDb(agent_uuid=expected)
resolved = await _resolve_agent_uuid(cast("Any", db), "be-dev-1")
assert resolved == expected
@pytest.mark.asyncio
async def test_resolve_agent_uuid_returns_none_for_unknown_slug() -> None:
db = _FakeDb(agent_uuid=None)
assert await _resolve_agent_uuid(cast("Any", db), "ghost") is None
class _PatchDbContext:
"""Patch get_db_context + notification_delivery in one block."""
def __init__(self, db: _FakeDb) -> None:
self.db = db
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()
def _patch_db_context(db: _FakeDb) -> _PatchDbContext:
return _PatchDbContext(db)
@pytest.mark.asyncio
async def test_send_blocker_notification(svc: NotificationService) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_blocker_notification(
task_id="t1",
blocker_reason="reason",
from_agent="system",
to_pm="cell-pm",
)
assert any("Task t1" in row.subject for row in db.added)
@pytest.mark.asyncio
async def test_send_qa_ready_notification(svc: NotificationService) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_qa_ready_notification(
task_id="t1", from_agent="be-dev-1", to_qa="be-qa"
)
assert any("ready for QA" in row.subject for row in db.added)
@pytest.mark.asyncio
async def test_send_docs_ready_notification(svc: NotificationService) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_docs_ready_notification(
task_id="t1", from_agent="be-qa", to_documenter="be-doc"
)
assert any("needs documentation" in row.subject for row in db.added)
@pytest.mark.asyncio
async def test_send_handoff_notification(svc: NotificationService) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_handoff_notification(
task_id="t1",
handoff_id="h1",
from_agent="be-pm",
to_documenter="be-doc",
)
assert any("Handoff required" in row.subject for row in db.added)
@pytest.mark.asyncio
async def test_send_qa_failed_notification(svc: NotificationService) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_qa_failed_notification(
task_id="t1", qa_notes="fix this", to_developer="be-dev-1"
)
assert any("QA Failed" in row.subject for row in db.added)
@pytest.mark.asyncio
async def test_send_a2a_notification(svc: NotificationService) -> None:
"""priority=URGENT writes the row + the [URGENT] cosmetic prefix.
Pre-P3-Task-9 this used `urgent: True`; the contract is now a
tristate `priority` so HIGH can survive end-to-end. See
tests/integration/test_a2a_priority_tristate.py for the full
HIGH/NORMAL coverage.
"""
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(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": NotificationPriority.URGENT,
},
)
# Urgent prefix appears in subject.
assert any("URGENT" in row.subject for row in db.added)
assert any(row.priority == NotificationPriority.URGENT for row in db.added)
@pytest.mark.asyncio
async def test_send_board_review_complete_notification(
svc: NotificationService,
) -> None:
"""Board-review-complete handoff is an APPROVAL notification to the CEO
carrying the task_id (cluster C5 / finding #2)."""
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_board_review_complete_notification(task_id="t1")
assert any("Board review complete" in row.subject for row in db.added)
assert any(row.type == NotificationType.APPROVAL for row in db.added)
assert any(row.priority == NotificationPriority.HIGH for row in db.added)
assert any(row.related_task_id == "t1" for row in db.added)
@pytest.mark.asyncio
async def test_send_ack_notification(svc: NotificationService) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_ack_notification(
from_agent="main-pm",
to_agent="ceo",
body="please review",
priority=NotificationPriority.HIGH,
)
assert db.added # Notification row recorded.
@pytest.mark.asyncio
async def test_create_notification_skips_when_from_agent_unresolvable(
svc: NotificationService,
) -> None:
"""Unresolvable from_agent → log and skip, no row inserted."""
db = _FakeDb(agent_uuid=None) # All slug lookups return None.
with _patch_db_context(db):
await svc._create_notification(
CreateNotificationParams(
notification_type=NotificationType.BLOCKER_ESCALATION,
priority=NotificationPriority.HIGH,
from_agent="ghost-agent",
to_agents=["be-pm"],
subject="x",
body="y",
)
)
assert db.added == []
@pytest.mark.asyncio
async def test_create_notification_skips_when_no_resolvable_recipients(
svc: NotificationService,
) -> None:
"""All recipients unresolvable → skip with warn."""
aid = uuid4()
# First call resolves from_agent, subsequent slug lookups still hit our
# fake — which always returns the same agent. Use a fake that returns the
# configured agent only on the first lookup.
class _OnceFake(_FakeDb):
def __init__(self) -> None:
super().__init__(agent_uuid=aid)
self._calls = 0
async def execute(self, *_args: Any, **_kwargs: Any) -> Any:
self._calls += 1
result = MagicMock()
if self._calls == 1:
# from_agent resolution succeeds
agent = MagicMock()
agent.id = aid
result.scalar_one_or_none.return_value = agent
else:
result.scalar_one_or_none.return_value = None
result.scalars.return_value.all.return_value = []
return result
db = _OnceFake()
with _patch_db_context(db):
await svc._create_notification(
CreateNotificationParams(
notification_type=NotificationType.BLOCKER_ESCALATION,
priority=NotificationPriority.HIGH,
from_agent="be-pm",
to_agents=["ghost1", "ghost2"],
subject="x",
body="y",
)
)
assert db.added == []
# ---------------------------------------------------------------------------
# requires_ack must follow ACK_REQUIRED_BY_TYPE, not the True default
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_informational_notification_does_not_require_ack(
svc: NotificationService,
) -> None:
"""REVIEW_REQUEST / DOCUMENTATION_REQUEST / A2A_REQUEST are informational
(pickup proves receipt) — requires_ack must be False, not the
NotificationTable True default. A False type forced to True inflates the
recipient's unacked set and soft-blocks i_am_idle → respawn churn."""
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_qa_ready_notification(
task_id="t1", from_agent="be-dev-1", to_qa="be-qa"
)
await svc.send_a2a_notification(
task_id="t2",
a2a_context={
"from_agent": "be-dev-1",
"to_agent": "fe-dev-1",
"skill": "react",
"message": "hi",
"priority": NotificationPriority.NORMAL,
},
)
qa_rows = [r for r in db.added if r.type == NotificationType.REVIEW_REQUEST]
a2a_rows = [r for r in db.added if r.type == NotificationType.A2A_REQUEST]
assert qa_rows, "REVIEW_REQUEST row should have been inserted"
assert a2a_rows, "A2A_REQUEST row should have been inserted"
# Identity checks (``is False``) — the mocked flush doesn't apply SQLA's
# insert-time default, so pre-fix the attribute is None, not False. The fix
# must set it explicitly on the NotificationTable constructor.
assert all(r.requires_ack is False for r in qa_rows)
assert all(r.requires_ack is False for r in a2a_rows)
@pytest.mark.asyncio
async def test_action_required_notification_still_requires_ack(
svc: NotificationService,
) -> None:
"""BLOCKER_ESCALATION / APPROVAL / ALERT are action-required —
requires_ack stays True (ACK_REQUIRED_BY_TYPE maps them True)."""
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_blocker_notification(
task_id="t1", blocker_reason="r", from_agent="system", to_pm="cell-pm"
)
await svc.send_board_review_complete_notification(task_id="t2")
blocker_rows = [
r for r in db.added if r.type == NotificationType.BLOCKER_ESCALATION
]
approval_rows = [r for r in db.added if r.type == NotificationType.APPROVAL]
assert blocker_rows and all(r.requires_ack is True for r in blocker_rows)
assert approval_rows and all(r.requires_ack is True for r in approval_rows)
@pytest.mark.asyncio
async def test_create_notification_requires_ack_derives_from_type(
svc: NotificationService,
) -> None:
"""A raw _create_notification call derives requires_ack from the type via
ACK_REQUIRED_BY_TYPE (KNOWLEDGE_SHARE → False)."""
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc._create_notification(
CreateNotificationParams(
notification_type=NotificationType.KNOWLEDGE_SHARE,
priority=NotificationPriority.NORMAL,
from_agent="be-dev-1",
to_agents=["fe-dev-1"],
subject="tip",
body="reuse the helper",
)
)
rows = [r for r in db.added if r.type == NotificationType.KNOWLEDGE_SHARE]
assert rows
assert (
rows[0].requires_ack is ACK_REQUIRED_BY_TYPE[NotificationType.KNOWLEDGE_SHARE]
)
# ---------------------------------------------------------------------------
# Coordination-event producers (reassignment / collision / unblock /
# dependency-revival / stale-claim-reaped)
# ---------------------------------------------------------------------------
# previous_assignee + new_assignee + ceo, resolved to UUIDs pre-insert.
_REASSIGN_RECIPIENT_COUNT = 3
# {task-owner, ceo} for the other four coordination producers.
_TWO_RECIPIENT_COUNT = 2
@pytest.mark.asyncio
async def test_send_reassignment_notification(svc: NotificationService) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_reassignment_notification(
task_id="t1", previous_assignee="be-dev-1", new_assignee="be-dev-2"
)
rows = [r for r in db.added if r.related_task_id == "t1"]
assert rows
assert all(len(r.to_agents) == _REASSIGN_RECIPIENT_COUNT for r in rows)
assert any("reassigned" in r.subject for r in rows)
@pytest.mark.asyncio
async def test_send_reassignment_notification_no_recipients_is_noop(
svc: NotificationService,
) -> None:
"""All three recipients falsy ⇒ nothing is created (no crash)."""
db = _FakeDb()
with _patch_db_context(db):
await svc.send_reassignment_notification(
task_id="t1",
previous_assignee=None,
new_assignee=None,
to_ceo="",
)
assert db.added == []
@pytest.mark.asyncio
async def test_send_collision_sequencing_notification(
svc: NotificationService,
) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_collision_sequencing_notification(
held_back_task_id="t2",
blocking_task_id="t1",
held_back_assignee="be-dev-1",
)
rows = [r for r in db.added if r.related_task_id == "t2"]
assert rows
assert all(len(r.to_agents) == _TWO_RECIPIENT_COUNT for r in rows)
assert any("sequenced behind" in r.subject for r in rows)
@pytest.mark.asyncio
async def test_send_unblock_notification(svc: NotificationService) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_unblock_notification(task_id="t1", restored_owner="be-dev-1")
rows = [r for r in db.added if r.related_task_id == "t1"]
assert rows
assert all(len(r.to_agents) == _TWO_RECIPIENT_COUNT for r in rows)
assert any("unblocked" in r.subject for r in rows)
@pytest.mark.asyncio
async def test_send_dependency_revival_notification(
svc: NotificationService,
) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_dependency_revival_notification(
task_id="t1", assignee="be-dev-1", completed_dependency_id="dep1"
)
rows = [r for r in db.added if r.related_task_id == "t1"]
assert rows
assert all(len(r.to_agents) == _TWO_RECIPIENT_COUNT for r in rows)
assert any("revived" in r.subject for r in rows)
assert any("dep1" in r.body for r in rows)
@pytest.mark.asyncio
async def test_send_stale_claim_reaped_notification(
svc: NotificationService,
) -> None:
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_stale_claim_reaped_notification(
task_id="t1", reaped_agent="be-dev-1", last_heartbeat="2026-07-11T00:00:00"
)
rows = [r for r in db.added if r.related_task_id == "t1"]
assert rows
assert all(len(r.to_agents) == _TWO_RECIPIENT_COUNT for r in rows)
assert any(r.priority == NotificationPriority.HIGH for r in rows)
assert any("stale claim reaped" in r.subject for r in rows)