"""STATE-WP-0093: per-recipient broadcast receipts and standing notices.""" from __future__ import annotations import importlib import uuid from datetime import datetime, timedelta, timezone import pytest from sqlalchemy import inspect, select, text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from api.models.agent_message import AgentMessage, MessageReceipt from api.models.legacy_meter import LegacyInterface from api.models.managed_repo import ManagedRepo from api.models.repository_rename import RepositorySlug from api.routers.messages import UNATTRIBUTED_BROADCAST_READ_KEY from tests.conftest import create_test_domain, create_test_repo async def _send(client, **payload): body = {"from_agent": "the-custodian", "subject": "s", "body": "b", **payload} body.setdefault("to_agent", "broadcast") resp = await client.post("/messages/", json=body) assert resp.status_code == 201, resp.text return resp.json() async def _inbox(client, agent, unread_only=True): resp = await client.get( "/messages/", params={"to_agent": agent, "unread_only": str(unread_only).lower()} ) assert resp.status_code == 200, resp.text return resp.json() def _ids(messages): return {m["id"] for m in messages} async def _receipts(test_engine, message_id): factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: result = await session.execute( select(MessageReceipt).where(MessageReceipt.message_id == uuid.UUID(message_id)) ) return {r.agent: r for r in result.scalars().all()} @pytest.mark.asyncio async def test_broadcast_defaults_to_news_with_expiry(client): msg = await _send(client) assert msg["kind"] == "news" expires = datetime.fromisoformat(msg["expires_at"]) assert timedelta(days=29) < expires - datetime.now(timezone.utc) <= timedelta(days=30) @pytest.mark.asyncio async def test_direct_message_rejects_broadcast_fields(client): resp = await client.post( "/messages/", json={"from_agent": "a", "to_agent": "b", "subject": "s", "body": "b", "kind": "standing"}, ) assert resp.status_code == 422 direct = await _send(client, to_agent="b") assert direct["kind"] == "message" assert direct["expires_at"] is None @pytest.mark.asyncio async def test_broadcast_read_by_a_stays_unread_for_b(client, test_engine): msg = await _send(client, kind="standing") resp = await client.patch(f"/messages/{msg['id']}/read", params={"reader": "agent-a"}) assert resp.status_code == 200 body = resp.json() assert body["reader"] == "agent-a" assert body["read_at"] is not None assert msg["id"] in _ids(await _inbox(client, "agent-b")) receipts = await _receipts(test_engine, msg["id"]) assert set(receipts) == {"agent-a", "agent-b"} # b: delivery receipt only assert receipts["agent-b"].read_at is None # Global read_at never set for a broadcast. listing = (await client.get("/messages/")).json() assert next(m for m in listing if m["id"] == msg["id"])["read_at"] is None @pytest.mark.asyncio async def test_mark_read_reader_in_json_body(client, test_engine): msg = await _send(client, kind="standing") resp = await client.patch(f"/messages/{msg['id']}/read", json={"reader": "agent-a"}) assert resp.status_code == 200 assert (await _receipts(test_engine, msg["id"]))["agent-a"].read_at is not None @pytest.mark.asyncio async def test_unattributed_broadcast_mark_read_is_noop_with_deprecation(client, test_engine): msg = await _send(client, kind="standing") resp = await client.patch(f"/messages/{msg['id']}/read", json={}) assert resp.status_code == 200 assert resp.headers["Deprecation"] == "true" assert "reader" in resp.headers["Warning"] assert resp.json()["read_at"] is None assert await _receipts(test_engine, msg["id"]) == {} assert msg["id"] in _ids(await _inbox(client, "agent-a")) factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: meter = ( await session.execute( select(LegacyInterface).where( LegacyInterface.interface_key == UNATTRIBUTED_BROADCAST_READ_KEY ) ) ).scalar_one_or_none() assert meter is not None @pytest.mark.asyncio async def test_direct_message_read_archive_reply_unchanged(client, test_engine): direct = await _send(client, to_agent="agent-b") assert direct["id"] in _ids(await _inbox(client, "agent-b")) resp = await client.patch(f"/messages/{direct['id']}/read", json={}) assert resp.status_code == 200 assert "Deprecation" not in resp.headers assert resp.json()["read_at"] is not None assert direct["id"] not in _ids(await _inbox(client, "agent-b")) assert await _receipts(test_engine, direct["id"]) == {} second = await _send(client, to_agent="agent-b") reply = await client.post( f"/messages/{second['id']}/reply", json={"from_agent": "agent-b", "body": "ok"} ) assert reply.status_code == 201 assert reply.json()["to_agent"] == "the-custodian" assert second["id"] not in _ids(await _inbox(client, "agent-b")) assert await _receipts(test_engine, second["id"]) == {} third = await _send(client, to_agent="agent-b") archived = (await client.patch(f"/messages/{third['id']}/archive")).json() assert archived["archived_at"] is not None assert archived["read_at"] == archived["archived_at"] @pytest.mark.asyncio async def test_reply_to_broadcast_writes_receipt_for_replier_only(client, test_engine): msg = await _send(client, kind="standing") reply = await client.post( f"/messages/{msg['id']}/reply", json={"from_agent": "agent-a", "body": "noted"} ) assert reply.status_code == 201 receipts = await _receipts(test_engine, msg["id"]) assert set(receipts) == {"agent-a"} assert receipts["agent-a"].read_at is not None listing = (await client.get("/messages/")).json() assert next(m for m in listing if m["id"] == msg["id"])["read_at"] is None assert msg["id"] in _ids(await _inbox(client, "agent-b")) @pytest.mark.asyncio async def test_archive_broadcast_does_not_set_read_at(client): msg = await _send(client) archived = (await client.patch(f"/messages/{msg['id']}/archive")).json() assert archived["archived_at"] is not None assert archived["read_at"] is None assert msg["id"] not in _ids(await _inbox(client, "agent-a")) @pytest.mark.asyncio async def test_delivery_receipt_only_for_scoped_unread_listing(client, test_engine): msg = await _send(client, kind="standing") await client.get("/messages/") await client.get("/messages/", params={"unread_only": "true"}) await _inbox(client, "agent-a", unread_only=False) assert await _receipts(test_engine, msg["id"]) == {} first = await _inbox(client, "agent-a") delivered = (await _receipts(test_engine, msg["id"]))["agent-a"].delivered_at assert delivered is not None assert first[0]["delivered_at"] is not None await _inbox(client, "agent-a") receipts = await _receipts(test_engine, msg["id"]) assert len(receipts) == 1 assert receipts["agent-a"].delivered_at == delivered # first timestamp wins @pytest.mark.asyncio async def test_news_disappears_after_delivery_standing_persists_until_ack(client): news = await _send(client, kind="news") standing = await _send(client, kind="standing") first = _ids(await _inbox(client, "agent-a")) assert {news["id"], standing["id"]} <= first second = _ids(await _inbox(client, "agent-a")) assert news["id"] not in second assert standing["id"] in second # Plain read does not clear a standing notice. await client.patch(f"/messages/{standing['id']}/read", params={"reader": "agent-a"}) assert standing["id"] in _ids(await _inbox(client, "agent-a")) ack = await client.post(f"/messages/{standing['id']}/ack", json={"agent": "agent-a"}) assert ack.status_code == 200 assert ack.json()["acknowledged_at"] is not None assert standing["id"] not in _ids(await _inbox(client, "agent-a")) assert standing["id"] in _ids(await _inbox(client, "agent-b")) @pytest.mark.asyncio async def test_ack_via_mark_read_flag_and_ack_rejects_direct(client): standing = await _send(client, kind="standing") await client.patch( f"/messages/{standing['id']}/read", params={"reader": "agent-a", "ack": "true"} ) assert standing["id"] not in _ids(await _inbox(client, "agent-a")) direct = await _send(client, to_agent="agent-a") resp = await client.post(f"/messages/{direct['id']}/ack", json={"agent": "agent-a"}) assert resp.status_code == 400 @pytest.mark.asyncio async def test_expired_broadcast_hidden(client): past = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat() expired = await _send(client, kind="standing", expires_at=past) assert expired["id"] not in _ids(await _inbox(client, "agent-a")) assert expired["id"] not in _ids(await _inbox(client, "agent-a", unread_only=False)) assert (await client.get("/messages/notices")).json() == [] @pytest.mark.asyncio async def test_supersede_archives_predecessor(client): old = await _send(client, kind="standing", subject="v1") new = await _send(client, kind="standing", subject="v2", supersedes_id=old["id"]) assert new["supersedes_id"] == old["id"] inbox = _ids(await _inbox(client, "agent-a")) assert new["id"] in inbox and old["id"] not in inbox notices = (await client.get("/messages/notices")).json() assert [n["id"] for n in notices] == [new["id"]] missing = await client.post( "/messages/", json={ "from_agent": "x", "to_agent": "broadcast", "subject": "s", "body": "b", "kind": "standing", "supersedes_id": str(uuid.uuid4()), }, ) assert missing.status_code == 404 @pytest.mark.asyncio async def test_notices_view_and_summary_counts(client): domain = await create_test_domain(client) for slug in ("repo-a", "repo-b", "repo-c"): await create_test_repo(client, domain_slug=domain["slug"], slug=slug) notice = await _send(client, kind="standing", subject="Read the orientation") await _send(client, kind="news", subject="not a notice") await client.post(f"/messages/{notice['id']}/ack", json={"agent": "repo-a"}) await _inbox(client, "repo-b") notices = (await client.get("/messages/notices")).json() assert len(notices) == 1 status = notices[0] assert status["acknowledged"] == ["repo-a"] assert status["delivered_only"] == ["repo-b"] assert status["unreached"] == ["repo-c"] assert (status["acked_count"], status["delivered_only_count"], status["unreached_count"]) == (1, 1, 1) summary = (await client.get("/state/summary")).json() assert summary["standing_notices"] == [ { "id": notice["id"], "subject": "Read the orientation", "expires_at": None, "acked": 1, "pending": 2, } ] @pytest.mark.asyncio async def test_renamed_repo_reader_resolves_to_canonical_receipt(client, test_engine): domain = await create_test_domain(client) repo = await create_test_repo(client, domain_slug=domain["slug"], slug="flex-auth") notice = await _send(client, kind="standing") await client.post(f"/messages/{notice['id']}/ack", json={"agent": "flex-auth"}) factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: managed = await session.get(ManagedRepo, uuid.UUID(repo["id"])) old = ( await session.execute(select(RepositorySlug).where(RepositorySlug.slug == "flex-auth")) ).scalar_one() old.kind = "alias" old.protected = True managed.slug = "access-engine" managed.name = "Access Engine" session.add( RepositorySlug(repo_id=managed.id, slug="access-engine", kind="canonical", protected=True) ) await session.commit() # The pre-rename ack still counts for the new name, and new writes via the # old name land on the canonical slug. assert notice["id"] not in _ids(await _inbox(client, "access-engine")) second = await _send(client, kind="standing", subject="second") await client.patch(f"/messages/{second['id']}/read", params={"reader": "flex-auth"}) assert set(await _receipts(test_engine, second["id"])) == {"access-engine"} @pytest.mark.asyncio async def test_capability_request_broadcast_is_news(client, test_engine): await create_test_domain(client) resp = await client.post( "/capability-requests/", json={ "title": "Need a thing", "requesting_agent": "agent-a", "requesting_domain": "infotech", "capability_type": "service", "description": "unmatched", }, ) assert resp.status_code == 201, resp.text factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: msgs = ( await session.execute(select(AgentMessage).where(AgentMessage.to_agent == "broadcast")) ).scalars().all() assert msgs and all(m.kind == "news" and m.expires_at is not None for m in msgs) @pytest.mark.asyncio async def test_migration_archives_broadcasts_and_is_reversible(test_engine): migration = importlib.import_module( "migrations.versions.d7e8f9a0b1c2_broadcast_receipts_and_notices" ) from alembic.migration import MigrationContext from alembic.operations import Operations schema = f"receipts_migration_{uuid.uuid4().hex}" direct_id, read_bc, unread_bc, archived_bc = (uuid.uuid4() for _ in range(4)) old_archive = datetime(2026, 1, 1, tzinfo=timezone.utc) async with test_engine.connect() as connection: def run(sync_connection): quoted = sync_connection.dialect.identifier_preparer.quote(schema) sync_connection.exec_driver_sql(f"CREATE SCHEMA {quoted}") sync_connection.exec_driver_sql(f"SET LOCAL search_path TO {quoted}") sync_connection.exec_driver_sql( "CREATE TABLE agent_messages (" "id uuid PRIMARY KEY, from_agent varchar(100) NOT NULL, " "to_agent varchar(100) NOT NULL, subject varchar(500) NOT NULL, " "body text NOT NULL, thread_id uuid REFERENCES agent_messages(id), " "read_at timestamptz, archived_at timestamptz, " "created_at timestamptz NOT NULL DEFAULT now())" ) sync_connection.execute( text( "INSERT INTO agent_messages (id, from_agent, to_agent, subject, body, read_at, archived_at) VALUES " "(:d, 'a', 'b', 's', 'b', NULL, NULL), " "(:r, 'gate-house', 'broadcast', 's', 'b', now(), NULL), " "(:u, 'system', 'broadcast', 's', 'b', NULL, NULL), " "(:x, 'system', 'broadcast', 's', 'b', now(), :old)" ), {"d": direct_id, "r": read_bc, "u": unread_bc, "x": archived_bc, "old": old_archive}, ) original_op = migration.op migration.op = Operations(MigrationContext.configure(sync_connection)) try: migration.upgrade() assert "message_receipts" in inspect(sync_connection).get_table_names(schema=schema) rows = { r.id: r for r in sync_connection.execute( text("SELECT id, kind, read_at, archived_at FROM agent_messages") ) } assert rows[direct_id].kind == "message" assert rows[direct_id].archived_at is None assert rows[direct_id].read_at is None for bc in (read_bc, unread_bc, archived_bc): assert rows[bc].kind == "news" assert rows[bc].archived_at is not None assert rows[archived_bc].archived_at == old_archive assert rows[unread_bc].read_at is None # archive does not stamp read_at migration.downgrade() columns = { c["name"] for c in inspect(sync_connection).get_columns("agent_messages", schema=schema) } assert not {"kind", "expires_at", "supersedes_id"} & columns assert "message_receipts" not in inspect(sync_connection).get_table_names(schema=schema) count = sync_connection.execute(text("SELECT count(*) FROM agent_messages")).scalar() assert count == 4 finally: migration.op = original_op await connection.run_sync(run) await connection.rollback()