diff --git a/app/routers/agents.py b/app/routers/agents.py index 520f10e..454a327 100644 --- a/app/routers/agents.py +++ b/app/routers/agents.py @@ -267,7 +267,23 @@ async def regenerate_webhook_secret_endpoint( @router.get("/{ref}/inbox") async def get_inbox_endpoint( ref: str, - state: Optional[str] = Query(default=None, description="Comma-separated states; default excludes acked/expired"), + state: Optional[str] = Query( + default=None, + description=( + "Comma-separated states to include. Default excludes ``acked`` " + "+ ``expired`` (worker-runtime/agent-handler default — those " + "consumers poll for actionable messages only). " + "**Conversation/transcript consumers** (chat UI, message-thread " + "renderer, audit log, etc.) should pass an explicit " + "``state=queued,delivering,retry_ready,delivered,read,claimed,acked,expired,failed`` " + "to include the full history — otherwise messages that have " + "been ``acked`` (e.g., by the recipient's own desktop worker) " + "vanish from the conversation view while the sender's bubble " + "remains visible via the symmetric ``GET /v1/messages`` sent " + "view. See ``Conversation-view consumer note`` in the " + "docstring below." + ), + ), since: Optional[datetime] = Query(default=None), thread_id: Optional[str] = Query(default=None), counterpart: Optional[str] = Query( @@ -297,6 +313,34 @@ async def get_inbox_endpoint( from that counterpart agent only. The atomic queued→delivered UPDATE applies the same filter so polling a single counterpart thread doesn't auto-deliver other threads' queued messages. + + ## Conversation-view consumer note (v1.1.5 docs) + + The default ``state=`` filter excludes ``acked`` + ``expired``. That + default is right for **worker-runtime / agent-handler consumers** + that poll for new actionable work — they don't want to re-process + messages they've already acknowledged. + + It's **wrong** for conversation/transcript consumers (chat UIs, + message-thread renderers, audit logs, support tools) because + ``acked`` messages disappear from the recipient's inbox view while + they remain visible on the sender's side via ``GET /v1/messages`` + (sent view). That asymmetry produces a confusing UX: the sender + sees their own message in the conversation, but the recipient's + pane shows nothing — even though the message WAS delivered and + acknowledged successfully. + + **Fix for conversation views**: pass an explicit + ``state=queued,delivering,retry_ready,delivered,read,claimed,acked,expired,failed`` + on the inbox fetch. This includes the full state set + matches + the symmetric sent-side view, restoring conversation parity. + + Empirical anchor: 2026-05-21, Dock's ``cue.dock.svc`` ``/api/threads`` + consumer hit this trap — their recipient pane was empty for + cross-user messages while sent-side bubbles rendered correctly. + Substrate behavior is correct (messages WERE delivered + acked + fast by Dock's desktop worker); Dock fixed Dock-side by passing + the full state set on their threads-view fetch. """ result = await list_inbox( db,