2026-08-29 10:47:51 +02:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
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
2026-09-22 00:26:33 +02:00
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
|
|
|
|
|
from sqlalchemy import and_, or_, select
|
2026-08-29 10:47:51 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
feat(CUST-WP-0015): implement agent inbox for inter-agent coordination
Adds a message-passing layer to state-hub so Claude instances can
coordinate across sessions without polling shared progress events.
- Migration f3a4b5c6d7e8: agent_messages table with thread support
- FastAPI router: POST/GET /messages/, thread view, mark-read, archive, reply
- 4 MCP tools: send_message, get_messages, mark_message_read, reply_to_message
- Observable dashboard: /inbox page with unread/read/archived sections + KPI
- CLAUDE.md updates: global, custodian, marki-docx, activity-core, template
- TOOLS.md: Agent Inbox tools section documented
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 02:55:45 +01:00
|
|
|
from api.database import get_session
|
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
2026-09-22 00:26:33 +02:00
|
|
|
from api.models.agent_message import BROADCAST, MESSAGE_KINDS, AgentMessage
|
|
|
|
|
from api.schemas.agent_message import (
|
|
|
|
|
MessageAck,
|
|
|
|
|
MessageCreate,
|
|
|
|
|
MessageMarkRead,
|
|
|
|
|
MessageRead,
|
|
|
|
|
MessageReply,
|
|
|
|
|
NoticeStatus,
|
|
|
|
|
)
|
|
|
|
|
from api.services.legacy_meter import identity_from_request, record_legacy_usage
|
|
|
|
|
from api.services.message_receipts import (
|
|
|
|
|
NEWS_DEFAULT_TTL,
|
|
|
|
|
broadcast_unread_clause,
|
|
|
|
|
is_broadcast,
|
|
|
|
|
notice_statuses,
|
|
|
|
|
receipts_for,
|
|
|
|
|
upsert_receipt,
|
|
|
|
|
utcnow,
|
|
|
|
|
)
|
2026-08-29 10:47:51 +02:00
|
|
|
from api.services.repository_aliases import (
|
|
|
|
|
canonicalize_repository_slug,
|
|
|
|
|
resolve_repository_slug,
|
|
|
|
|
)
|
|
|
|
|
from hub_core.message_identity import resolve_message_reference
|
|
|
|
|
from hub_core.models.message_identity_alias import MessageIdentityAlias
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/messages", tags=["messages"])
|
|
|
|
|
|
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
2026-09-22 00:26:33 +02:00
|
|
|
UNATTRIBUTED_BROADCAST_READ_KEY = "rest_api:PATCH /messages/{id}/read broadcast-without-reader"
|
|
|
|
|
UNATTRIBUTED_BROADCAST_READ_REPLACEMENT = "PATCH /messages/{id}/read?reader=<agent>"
|
|
|
|
|
UNATTRIBUTED_BROADCAST_WARNING = '299 - "broadcast mark-read needs ?reader=<agent>"'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _reader_values(session: AsyncSession, reader: str) -> tuple[str, tuple[str, ...]]:
|
|
|
|
|
"""Canonical reader slug plus every historical slug it answers to."""
|
|
|
|
|
resolution = await resolve_repository_slug(session, reader, required=False)
|
|
|
|
|
if resolution is None:
|
|
|
|
|
return reader, (reader,)
|
|
|
|
|
return resolution.canonical_slug, tuple(resolution.slug_values)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _read_view(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
message: AgentMessage,
|
|
|
|
|
reader_values: tuple[str, ...] | None,
|
|
|
|
|
reader: str | None,
|
|
|
|
|
) -> MessageRead:
|
|
|
|
|
view = MessageRead.model_validate(message)
|
|
|
|
|
if reader_values is None or not is_broadcast(message):
|
|
|
|
|
return view
|
|
|
|
|
state = (await receipts_for(session, [message.id], reader_values)).get(message.id, {})
|
|
|
|
|
return view.model_copy(
|
|
|
|
|
update={
|
|
|
|
|
"reader": reader,
|
|
|
|
|
"delivered_at": state.get("delivered_at"),
|
|
|
|
|
"read_at": state.get("read_at"),
|
|
|
|
|
"acknowledged_at": state.get("acknowledged_at"),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-29 10:47:51 +02:00
|
|
|
|
|
|
|
|
async def _get_message(reference: str, session: AsyncSession) -> AgentMessage:
|
|
|
|
|
message_id = await resolve_message_reference(
|
|
|
|
|
session, reference, alias_model=MessageIdentityAlias
|
|
|
|
|
)
|
|
|
|
|
if message_id is None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=404, detail=f"Message reference {reference!r} not found"
|
|
|
|
|
)
|
|
|
|
|
message = await session.get(AgentMessage, message_id)
|
|
|
|
|
if message is None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=404, detail=f"Message reference {reference!r} not found"
|
|
|
|
|
)
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
|
|
|
|
async def send_message(
|
|
|
|
|
body: MessageCreate,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> AgentMessage:
|
|
|
|
|
if body.thread_id and await session.get(AgentMessage, body.thread_id) is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
|
|
|
|
|
payload = body.model_dump()
|
|
|
|
|
payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent)
|
|
|
|
|
payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent)
|
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
2026-09-22 00:26:33 +02:00
|
|
|
if payload["to_agent"] == BROADCAST:
|
|
|
|
|
kind = body.kind or "news"
|
|
|
|
|
if kind == "message":
|
|
|
|
|
kind = "news"
|
|
|
|
|
if kind not in MESSAGE_KINDS:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"kind must be one of {', '.join(MESSAGE_KINDS[1:])} for broadcasts",
|
|
|
|
|
)
|
|
|
|
|
payload["kind"] = kind
|
|
|
|
|
if kind == "news" and body.expires_at is None:
|
|
|
|
|
payload["expires_at"] = utcnow() + NEWS_DEFAULT_TTL
|
|
|
|
|
if body.supersedes_id is not None:
|
|
|
|
|
predecessor = await session.get(AgentMessage, body.supersedes_id)
|
|
|
|
|
if predecessor is None or not is_broadcast(predecessor):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=404,
|
|
|
|
|
detail=f"Superseded broadcast {body.supersedes_id} not found",
|
|
|
|
|
)
|
|
|
|
|
if predecessor.archived_at is None:
|
|
|
|
|
predecessor.archived_at = utcnow()
|
|
|
|
|
else:
|
|
|
|
|
if body.kind not in (None, "message"):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422, detail="kind news/standing is only valid for broadcasts"
|
|
|
|
|
)
|
|
|
|
|
if body.expires_at is not None or body.supersedes_id is not None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail="expires_at and supersedes_id are only valid for broadcasts",
|
|
|
|
|
)
|
|
|
|
|
payload["kind"] = "message"
|
2026-08-29 10:47:51 +02:00
|
|
|
message = AgentMessage(**payload)
|
|
|
|
|
session.add(message)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(message)
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/", response_model=list[MessageRead])
|
|
|
|
|
async def list_messages(
|
|
|
|
|
to_agent: str | None = None,
|
|
|
|
|
from_agent: str | None = None,
|
|
|
|
|
unread_only: bool = False,
|
|
|
|
|
limit: int = 50,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
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
2026-09-22 00:26:33 +02:00
|
|
|
) -> list[MessageRead]:
|
|
|
|
|
now = utcnow()
|
2026-08-29 10:47:51 +02:00
|
|
|
query = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
|
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
2026-09-22 00:26:33 +02:00
|
|
|
reader: str | None = None
|
|
|
|
|
reader_values: tuple[str, ...] | None = None
|
2026-08-29 10:47:51 +02:00
|
|
|
if to_agent:
|
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
2026-09-22 00:26:33 +02:00
|
|
|
reader, reader_values = await _reader_values(session, to_agent)
|
|
|
|
|
direct = AgentMessage.to_agent.in_(reader_values)
|
|
|
|
|
if unread_only:
|
|
|
|
|
query = query.where(
|
|
|
|
|
or_(
|
|
|
|
|
and_(direct, AgentMessage.read_at.is_(None)),
|
|
|
|
|
broadcast_unread_clause(reader_values, now),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
query = query.where(
|
|
|
|
|
or_(
|
|
|
|
|
direct,
|
|
|
|
|
and_(
|
|
|
|
|
AgentMessage.to_agent == BROADCAST,
|
|
|
|
|
or_(AgentMessage.expires_at.is_(None), AgentMessage.expires_at > now),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
elif unread_only:
|
|
|
|
|
query = query.where(AgentMessage.read_at.is_(None))
|
2026-08-29 10:47:51 +02:00
|
|
|
if from_agent:
|
|
|
|
|
resolution = await resolve_repository_slug(session, from_agent, required=False)
|
|
|
|
|
values = resolution.slug_values if resolution else (from_agent,)
|
|
|
|
|
query = query.where(AgentMessage.from_agent.in_(values))
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
query.order_by(AgentMessage.created_at.desc()).limit(limit)
|
|
|
|
|
)
|
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
2026-09-22 00:26:33 +02:00
|
|
|
messages = list(result.scalars().all())
|
|
|
|
|
if reader_values is None or reader is None:
|
|
|
|
|
return [MessageRead.model_validate(m) for m in messages]
|
|
|
|
|
|
|
|
|
|
broadcast_ids = [m.id for m in messages if is_broadcast(m)]
|
|
|
|
|
if unread_only and broadcast_ids and reader != BROADCAST:
|
|
|
|
|
# D3 (founder-approved 2026-09-22): the orientation inbox call records
|
|
|
|
|
# an idempotent delivery receipt for each broadcast it returns.
|
|
|
|
|
await upsert_receipt(session, broadcast_ids, reader, delivered=True, at=now)
|
|
|
|
|
await session.commit()
|
|
|
|
|
receipts = await receipts_for(session, broadcast_ids, reader_values)
|
|
|
|
|
views = []
|
|
|
|
|
for message in messages:
|
|
|
|
|
view = MessageRead.model_validate(message)
|
|
|
|
|
if is_broadcast(message):
|
|
|
|
|
state = receipts.get(message.id, {})
|
|
|
|
|
view = view.model_copy(
|
|
|
|
|
update={
|
|
|
|
|
"reader": reader,
|
|
|
|
|
"delivered_at": state.get("delivered_at"),
|
|
|
|
|
"read_at": state.get("read_at"),
|
|
|
|
|
"acknowledged_at": state.get("acknowledged_at"),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
views.append(view)
|
|
|
|
|
return views
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/notices", response_model=list[NoticeStatus])
|
|
|
|
|
async def list_notices(
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> list[NoticeStatus]:
|
|
|
|
|
"""Live standing notices with acknowledged / delivered-only / unreached repos."""
|
|
|
|
|
return await notice_statuses(session)
|
2026-08-29 10:47:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
|
|
|
|
|
async def get_thread(
|
|
|
|
|
thread_id: str,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> list[AgentMessage]:
|
|
|
|
|
resolved = await resolve_message_reference(
|
|
|
|
|
session, thread_id, alias_model=MessageIdentityAlias
|
|
|
|
|
)
|
|
|
|
|
if resolved is None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=404, detail=f"Message reference {thread_id!r} not found"
|
|
|
|
|
)
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
select(AgentMessage)
|
|
|
|
|
.where(or_(AgentMessage.id == resolved, AgentMessage.thread_id == resolved))
|
|
|
|
|
.order_by(AgentMessage.created_at)
|
|
|
|
|
)
|
|
|
|
|
return list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/{message_id}/read", response_model=MessageRead)
|
|
|
|
|
async def mark_read(
|
|
|
|
|
message_id: str,
|
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
2026-09-22 00:26:33 +02:00
|
|
|
request: Request,
|
|
|
|
|
response: Response,
|
|
|
|
|
reader: str | None = None,
|
|
|
|
|
ack: bool = False,
|
|
|
|
|
body: MessageMarkRead | None = Body(default=None),
|
2026-08-29 10:47:51 +02:00
|
|
|
session: AsyncSession = Depends(get_session),
|
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
2026-09-22 00:26:33 +02:00
|
|
|
) -> AgentMessage | MessageRead:
|
2026-08-29 10:47:51 +02:00
|
|
|
message = await _get_message(message_id, session)
|
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
2026-09-22 00:26:33 +02:00
|
|
|
if not is_broadcast(message):
|
|
|
|
|
if message.read_at is None:
|
|
|
|
|
message.read_at = datetime.now(timezone.utc)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(message)
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
reader = reader or (body.reader if body else None)
|
|
|
|
|
ack = ack or bool(body and body.ack)
|
|
|
|
|
if not reader:
|
|
|
|
|
view = MessageRead.model_validate(message)
|
|
|
|
|
# D2: an unattributed mark-read never hides a broadcast from anyone.
|
|
|
|
|
response.headers["Deprecation"] = "true"
|
|
|
|
|
response.headers["Warning"] = UNATTRIBUTED_BROADCAST_WARNING
|
|
|
|
|
response.headers["X-StateHub-Replacement"] = UNATTRIBUTED_BROADCAST_READ_REPLACEMENT
|
|
|
|
|
try:
|
|
|
|
|
await record_legacy_usage(
|
|
|
|
|
session,
|
|
|
|
|
interface_key=UNATTRIBUTED_BROADCAST_READ_KEY,
|
|
|
|
|
interface_kind="rest_api",
|
|
|
|
|
replacement_ref=UNATTRIBUTED_BROADCAST_READ_REPLACEMENT,
|
|
|
|
|
owner_component="state-hub.api",
|
|
|
|
|
replacement_verified=True,
|
|
|
|
|
identity=identity_from_request(request),
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
await session.rollback()
|
|
|
|
|
return view
|
|
|
|
|
|
|
|
|
|
canonical, values = await _reader_values(session, reader)
|
|
|
|
|
await upsert_receipt(session, [message.id], canonical, read=True, acknowledged=ack)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return await _read_view(session, message, values, canonical)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{message_id}/ack", response_model=MessageRead)
|
|
|
|
|
async def acknowledge_message(
|
|
|
|
|
message_id: str,
|
|
|
|
|
body: MessageAck,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> MessageRead:
|
|
|
|
|
"""Acknowledge a broadcast (clears a standing notice for that agent)."""
|
|
|
|
|
message = await _get_message(message_id, session)
|
|
|
|
|
if not is_broadcast(message):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="ack applies to broadcasts only; use PATCH /messages/{id}/read",
|
|
|
|
|
)
|
|
|
|
|
canonical, values = await _reader_values(session, body.agent)
|
|
|
|
|
await upsert_receipt(session, [message.id], canonical, acknowledged=True)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return await _read_view(session, message, values, canonical)
|
2026-08-29 10:47:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
|
|
|
|
async def archive_message(
|
|
|
|
|
message_id: str,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> AgentMessage:
|
|
|
|
|
message = await _get_message(message_id, session)
|
|
|
|
|
message.archived_at = datetime.now(timezone.utc)
|
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
2026-09-22 00:26:33 +02:00
|
|
|
# Broadcast archive is a global withdrawal; it no longer stamps read_at.
|
|
|
|
|
if message.read_at is None and not is_broadcast(message):
|
2026-08-29 10:47:51 +02:00
|
|
|
message.read_at = message.archived_at
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(message)
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/{message_id}/reply",
|
|
|
|
|
response_model=MessageRead,
|
|
|
|
|
status_code=status.HTTP_201_CREATED,
|
|
|
|
|
)
|
|
|
|
|
async def reply_to_message(
|
|
|
|
|
message_id: str,
|
|
|
|
|
body: MessageReply,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> AgentMessage:
|
|
|
|
|
original = await _get_message(message_id, session)
|
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
2026-09-22 00:26:33 +02:00
|
|
|
replier = await canonicalize_repository_slug(session, body.from_agent)
|
|
|
|
|
if is_broadcast(original):
|
|
|
|
|
await upsert_receipt(session, [original.id], replier, read=True)
|
|
|
|
|
elif original.read_at is None:
|
2026-08-29 10:47:51 +02:00
|
|
|
original.read_at = datetime.now(timezone.utc)
|
|
|
|
|
reply = AgentMessage(
|
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
2026-09-22 00:26:33 +02:00
|
|
|
from_agent=replier,
|
2026-08-29 10:47:51 +02:00
|
|
|
to_agent=await canonicalize_repository_slug(session, original.from_agent),
|
|
|
|
|
subject=f"Re: {original.subject}",
|
|
|
|
|
body=body.body,
|
|
|
|
|
thread_id=original.thread_id or original.id,
|
|
|
|
|
)
|
|
|
|
|
session.add(reply)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(reply)
|
|
|
|
|
return reply
|
feat(CUST-WP-0015): implement agent inbox for inter-agent coordination
Adds a message-passing layer to state-hub so Claude instances can
coordinate across sessions without polling shared progress events.
- Migration f3a4b5c6d7e8: agent_messages table with thread support
- FastAPI router: POST/GET /messages/, thread view, mark-read, archive, reply
- 4 MCP tools: send_message, get_messages, mark_message_read, reply_to_message
- Observable dashboard: /inbox page with unread/read/archived sections + KPI
- CLAUDE.md updates: global, custodian, marki-docx, activity-core, template
- TOOLS.md: Agent Inbox tools section documented
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 02:55:45 +01:00
|
|
|
|
|
|
|
|
|
2026-06-07 11:22:13 +02:00
|
|
|
__all__ = ["router"]
|