STATE-WP-0093: per-recipient broadcast receipts and standing notices (T01-T06).
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
This commit is contained in:
parent
740068fcf1
commit
ef541f58cf
18 changed files with 1256 additions and 52 deletions
|
|
@ -9,6 +9,7 @@ def test_codex_server_has_only_repository_coordination_tools() -> None:
|
|||
"get_domain_summary",
|
||||
"get_messages",
|
||||
"mark_message_read",
|
||||
"acknowledge_notice",
|
||||
"add_progress_event",
|
||||
"record_decision",
|
||||
"update_task_status",
|
||||
|
|
@ -17,3 +18,18 @@ def test_codex_server_has_only_repository_coordination_tools() -> None:
|
|||
|
||||
def test_codex_server_uses_distinct_server_identity() -> None:
|
||||
assert codex_server.mcp.name == "dev-hub-codex"
|
||||
|
||||
|
||||
def test_mark_message_read_passes_reader(monkeypatch) -> None:
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
codex_server, "_request", lambda method, path, body=None: calls.append((method, path, body)) or {}
|
||||
)
|
||||
codex_server.mark_message_read("abc", reader="state-hub")
|
||||
codex_server.mark_message_read("abc")
|
||||
codex_server.acknowledge_notice("abc", "state-hub")
|
||||
assert calls == [
|
||||
("PATCH", "/messages/abc/read?reader=state-hub", {}),
|
||||
("PATCH", "/messages/abc/read", {}),
|
||||
("POST", "/messages/abc/ack", {"agent": "state-hub"}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -37,8 +37,12 @@ from hub_core.schemas.tpsc import (
|
|||
)
|
||||
|
||||
|
||||
def test_state_hub_reexports_core_message_schema() -> None:
|
||||
assert MessageCreate is CoreMessageCreate
|
||||
def test_state_hub_message_schema_extends_core() -> None:
|
||||
# STATE-WP-0093 extends the hub-core shape locally (kind, expires_at,
|
||||
# supersedes_id) until hub-core absorbs the fields; then this returns to
|
||||
# an identity re-export.
|
||||
assert issubclass(MessageCreate, CoreMessageCreate)
|
||||
assert set(CoreMessageCreate.model_fields) <= set(MessageCreate.model_fields)
|
||||
|
||||
|
||||
def test_state_hub_reexports_core_doi_schema() -> None:
|
||||
|
|
|
|||
392
tests/test_message_receipts.py
Normal file
392
tests/test_message_receipts.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue