Founder approved T01 on 2026-09-22 (D2, D3, D6 as recommended).
- T02: message_receipts table; kind/expires_at/supersedes_id on
agent_messages; migration d7e8f9a0b1c2 archives existing broadcasts,
leaves direct messages untouched, reversible.
- T03: reader-aware mark-read (unattributed broadcast mark-read is a
metered, deprecated no-op), delivery receipts on the scoped unread inbox,
POST /messages/{id}/ack, news/standing kinds, expiry, supersede, broadcast
archive no longer stamps read_at, reply writes the replier's receipt.
- T04 (state-hub part): Codex MCP reader param and acknowledge_notice;
hub-core part handed off (message 69fc387c).
- T05: GET /messages/notices, standing_notices in /state/summary,
dashboard standing-notices panel.
- T06: 17 new tests; full suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 63291@bnt-lap001
Assistant-Session: 8bd77868-ca68-4f49-bb1e-d539ecc0d703
392 lines
17 KiB
Python
392 lines
17 KiB
Python
"""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()
|