Push notification dedupe: one message = one notification — an offline message was notified twice (FCM push, then again when the app synced the outbox and mirrored the replayed frames). Fix: the gateway records the highest outbox cursor delivered per device via push (devices.last_pushed_cursor, advanced only on successful send) and returns it in hello.ack; sync-replayed frames carry their outbox cursor in the envelope; the app skips system notifications for replayed frames at/below the watermark (live frames never suppressed — that is the case where no push fired). Also: 5s per-chat push coalescing so a cron delivery (notification frame + message frame) pushes once, and the FCM handler no longer posts a redundant notification (skips when WS is connected or FCM already displayed the notification payload; data-only messages are the exception). Docs: frames.schema.json, 04-wire-protocol.md, 08-push.md §8.8

This commit is contained in:
ARIA committed 2026-08-21 17:21:54 +02:00
1 parent acd5fb4ad0
commit 9f3f9842c8
11 files changed
+301 -98

No files matched your search

+117 -78
View File
@@ -17,7 +17,7 @@ Milestone M5: notification, fcm.register, read.receipt, status.
import json
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from typing import Any, Optional
PROTOCOL_VERSION = 1
@@ -146,30 +146,38 @@ STATUS_DEGRADED = "degraded"
# Envelope
# ---------------------------------------------------------------------------
@dataclass
class Frame:
"""One wire frame.
``v`` is always serialised; ``id``/``chat_id``/``thread_id`` are
omitted when ``None`` (events carry no ``id``; chat-scoped frames carry
``chat_id``/``thread_id`` at the top level for convenience).
``v`` is always serialised; ``id``/``chat_id``/``thread_id``/``cursor``
are omitted when ``None`` (events carry no ``id``; chat-scoped frames
carry ``chat_id``/``thread_id`` at the top level for convenience).
``cursor`` is set only on frames replayed by ``sync``: the outbox cursor
the frame was parked under. The app uses it to skip re-notifying frames
that already woke the device via push (docs/08 §8.7).
"""
type: str
payload: Dict[str, Any] = field(default_factory=dict)
id: Optional[int] = None
chat_id: Optional[str] = None
thread_id: Optional[str] = None
payload: dict[str, Any] = field(default_factory=dict)
id: int | None = None
chat_id: str | None = None
thread_id: str | None = None
cursor: int | None = None
v: int = PROTOCOL_VERSION
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"v": self.v, "type": self.type}
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {"v": self.v, "type": self.type}
if self.id is not None:
d["id"] = self.id
if self.chat_id is not None:
d["chat_id"] = self.chat_id
if self.thread_id is not None:
d["thread_id"] = self.thread_id
if self.cursor is not None:
d["cursor"] = self.cursor
d["payload"] = self.payload
return d
@@ -202,17 +210,29 @@ class Frame:
thread_id = data.get("thread_id")
if not isinstance(thread_id, str):
thread_id = None
return cls(type=ftype, payload=payload, id=fid, chat_id=chat_id, thread_id=thread_id)
cursor = data.get("cursor")
if not isinstance(cursor, int) or isinstance(cursor, bool):
cursor = None
return cls(
type=ftype,
payload=payload,
id=fid,
chat_id=chat_id,
thread_id=thread_id,
cursor=cursor,
)
# ---------------------------------------------------------------------------
# Frame constructors (server -> app)
# ---------------------------------------------------------------------------
def hello_ack(
server_caps: Dict[str, Any],
server_caps: dict[str, Any],
sync_cursor: int = 0,
channels: Optional[list] = None,
channels: list | None = None,
last_pushed_cursor: int = 0,
) -> Frame:
return Frame(
type=TYPE_HELLO_ACK,
@@ -220,6 +240,11 @@ def hello_ack(
"server_caps": server_caps,
"sync_cursor": sync_cursor,
"channels": channels or [],
# M5: highest outbox cursor already delivered to THIS device via
# the push backend (0 = never). The app skips system
# notifications for sync-replayed frames at/below it (dedupe,
# docs/08 §8.7).
"last_pushed_cursor": last_pushed_cursor,
},
)
@@ -230,15 +255,15 @@ def message(
role: str,
text: str,
*,
thread_id: Optional[str] = None,
reasoning: Optional[str] = None,
media: Optional[list] = None,
reply_to: Optional[str] = None,
model: Optional[str] = None,
tokens: Optional[int] = None,
ts: Optional[int] = None,
thread_id: str | None = None,
reasoning: str | None = None,
media: list | None = None,
reply_to: str | None = None,
model: str | None = None,
tokens: int | None = None,
ts: int | None = None,
) -> Frame:
payload: Dict[str, Any] = {
payload: dict[str, Any] = {
"message_id": message_id,
"role": role,
"text": text,
@@ -255,10 +280,12 @@ def message(
payload["tokens"] = tokens
if ts is not None:
payload["ts"] = ts
return Frame(type=TYPE_MESSAGE, chat_id=chat_id, thread_id=thread_id, payload=payload)
return Frame(
type=TYPE_MESSAGE, chat_id=chat_id, thread_id=thread_id, payload=payload
)
def typing(chat_id: str, on: bool = True, *, thread_id: Optional[str] = None) -> Frame:
def typing(chat_id: str, on: bool = True, *, thread_id: str | None = None) -> Frame:
return Frame(
type=TYPE_TYPING,
chat_id=chat_id,
@@ -271,12 +298,13 @@ def typing(chat_id: str, on: bool = True, *, thread_id: Optional[str] = None) ->
# Streaming frames (M2)
# ---------------------------------------------------------------------------
def message_start(
chat_id: str,
message_id: str,
role: str = ROLE_ASSISTANT,
*,
thread_id: Optional[str] = None,
thread_id: str | None = None,
) -> Frame:
"""Open a streaming bubble."""
return Frame(
@@ -292,7 +320,7 @@ def message_update(
message_id: str,
text: str,
*,
thread_id: Optional[str] = None,
thread_id: str | None = None,
) -> Frame:
"""Replace the live bubble text (full snapshot)."""
return Frame(
@@ -308,14 +336,14 @@ def message_stop(
message_id: str,
final_text: str,
*,
thread_id: Optional[str] = None,
reasoning: Optional[str] = None,
model: Optional[str] = None,
tokens: Optional[int] = None,
ts: Optional[int] = None,
thread_id: str | None = None,
reasoning: str | None = None,
model: str | None = None,
tokens: int | None = None,
ts: int | None = None,
) -> Frame:
"""Finalize a streaming bubble."""
payload: Dict[str, Any] = {
payload: dict[str, Any] = {
"message_id": message_id,
"final_text": final_text,
}
@@ -339,16 +367,17 @@ def message_stop(
# Tool activity frames (M2)
# ---------------------------------------------------------------------------
def tool_start(
chat_id: str,
index: int,
name: str,
*,
thread_id: Optional[str] = None,
preview: Optional[str] = None,
args: Optional[Dict[str, Any]] = None,
thread_id: str | None = None,
preview: str | None = None,
args: dict[str, Any] | None = None,
) -> Frame:
payload: Dict[str, Any] = {"index": index, "name": name}
payload: dict[str, Any] = {"index": index, "name": name}
if preview:
payload["preview"] = preview
if args:
@@ -366,10 +395,10 @@ def tool_progress(
index: int,
name: str,
*,
thread_id: Optional[str] = None,
note: Optional[str] = None,
thread_id: str | None = None,
note: str | None = None,
) -> Frame:
payload: Dict[str, Any] = {"index": index, "name": name}
payload: dict[str, Any] = {"index": index, "name": name}
if note:
payload["note"] = note
return Frame(
@@ -385,12 +414,12 @@ def tool_end(
index: int,
name: str,
*,
thread_id: Optional[str] = None,
thread_id: str | None = None,
ok: bool = True,
duration: Optional[float] = None,
output_preview: Optional[str] = None,
duration: float | None = None,
output_preview: str | None = None,
) -> Frame:
payload: Dict[str, Any] = {"index": index, "name": name, "ok": ok}
payload: dict[str, Any] = {"index": index, "name": name, "ok": ok}
if duration is not None:
payload["duration"] = duration
if output_preview:
@@ -407,12 +436,13 @@ def tool_end(
# Commentary frame (M2)
# ---------------------------------------------------------------------------
def commentary(
chat_id: str,
message_id: str,
text: str,
*,
thread_id: Optional[str] = None,
thread_id: str | None = None,
) -> Frame:
"""An intermediate assistant beat (between tool iterations)."""
return Frame(
@@ -427,9 +457,10 @@ def commentary(
# Channel directory frames (M3)
# ---------------------------------------------------------------------------
def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
def _channel_payload(entry: dict[str, Any]) -> dict[str, Any]:
"""Project a directory entry onto the wire shape."""
payload: Dict[str, Any] = {
payload: dict[str, Any] = {
"chat_id": entry.get("chat_id"),
"name": entry.get("name"),
"kind": entry.get("kind", "channel"),
@@ -451,7 +482,7 @@ def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
return payload
def channel_created(entry: Dict[str, Any], auto: bool = False) -> Frame:
def channel_created(entry: dict[str, Any], auto: bool = False) -> Frame:
"""Broadcast: a channel/thread was created.
``auto=True`` marks a thread the gateway minted itself for an incoming
@@ -465,7 +496,7 @@ def channel_created(entry: Dict[str, Any], auto: bool = False) -> Frame:
return Frame(type=TYPE_CHANNEL_CREATED, payload=payload)
def channel_renamed(entry: Dict[str, Any]) -> Frame:
def channel_renamed(entry: dict[str, Any]) -> Frame:
"""Broadcast: a channel/thread was renamed."""
return Frame(type=TYPE_CHANNEL_RENAMED, payload=_channel_payload(entry))
@@ -475,7 +506,7 @@ def channel_deleted(chat_id: str) -> Frame:
return Frame(type=TYPE_CHANNEL_DELETED, payload={"chat_id": chat_id})
def channel_list(channels: List[Dict[str, Any]]) -> Frame:
def channel_list(channels: list[dict[str, Any]]) -> Frame:
"""Full directory (response to a ``channel.list`` request)."""
return Frame(
type=TYPE_CHANNEL_LIST,
@@ -487,12 +518,13 @@ def channel_list(channels: List[Dict[str, Any]]) -> Frame:
# Search frames (M3)
# ---------------------------------------------------------------------------
def search_results(
query: str,
scope: str,
hits: List[Dict[str, Any]],
hits: list[dict[str, Any]],
*,
id: Optional[int] = None,
id: int | None = None,
) -> Frame:
"""Response to a ``search`` request.
@@ -509,7 +541,8 @@ def search_results(
# Slash-command catalog frames
# ---------------------------------------------------------------------------
def commands_catalog(commands: List[Dict[str, Any]], *, id: Optional[int] = None) -> Frame:
def commands_catalog(commands: list[dict[str, Any]], *, id: int | None = None) -> Frame:
"""Response to a ``commands.catalog`` request: the gateway's slash-command
catalog for the app's ``/`` drawer.
@@ -527,7 +560,8 @@ def commands_catalog(commands: List[Dict[str, Any]], *, id: Optional[int] = None
# Sync frames (M3 outbox)
# ---------------------------------------------------------------------------
def sync_done(cursor: int, *, id: Optional[int] = None) -> Frame:
def sync_done(cursor: int, *, id: int | None = None) -> Frame:
"""Terminal frame of a ``sync`` replay: the new cursor to persist."""
return Frame(type=TYPE_SYNC_DONE, id=id, payload={"cursor": cursor})
@@ -536,20 +570,21 @@ def sync_done(cursor: int, *, id: Optional[int] = None) -> Frame:
# History frame (full message history for a chat/thread)
# ---------------------------------------------------------------------------
def history(
chat_id: str,
messages: List[Dict[str, Any]],
messages: list[dict[str, Any]],
has_more: bool,
*,
thread_id: Optional[str] = None,
oldest_message_id: Optional[str] = None,
id: Optional[int] = None,
thread_id: str | None = None,
oldest_message_id: str | None = None,
id: int | None = None,
) -> Frame:
"""Response to a ``history`` request: a page of final messages for a
chat/thread, ordered oldest → newest. ``has_more`` signals older pages
exist; ``oldest_message_id`` is the ``before_message_id`` for the next
(older) page."""
payload: Dict[str, Any] = {
payload: dict[str, Any] = {
"messages": messages,
"has_more": has_more,
}
@@ -568,12 +603,13 @@ def history(
# Message deletion frames
# ---------------------------------------------------------------------------
def message_deleted(
chat_id: str,
message_ids: List[str],
message_ids: list[str],
*,
thread_id: Optional[str] = None,
id: Optional[int] = None,
thread_id: str | None = None,
id: int | None = None,
) -> Frame:
"""Broadcast: the given message(s) were deleted from a chat/thread.
@@ -595,18 +631,19 @@ def message_deleted(
# Push / notification frames (M5)
# ---------------------------------------------------------------------------
def notification(
chat_id: str,
kind: str,
title: str,
body: str,
*,
thread_id: Optional[str] = None,
ts: Optional[int] = None,
thread_id: str | None = None,
ts: int | None = None,
) -> Frame:
"""Event: a transient in-app banner (and a push mirror when the device is
offline). ``kind`` is one of the ``NOTIF_*`` constants."""
payload: Dict[str, Any] = {"kind": kind, "title": title, "body": body}
payload: dict[str, Any] = {"kind": kind, "title": title, "body": body}
if ts is not None:
payload["ts"] = ts
return Frame(
@@ -614,11 +651,9 @@ def notification(
)
def fcm_register(
fcm_token: Optional[str] = None, ntfy_topic: Optional[str] = None
) -> Frame:
def fcm_register(fcm_token: str | None = None, ntfy_topic: str | None = None) -> Frame:
"""Request: update the device's push tokens (FCM rotation / ntfy topic)."""
payload: Dict[str, Any] = {}
payload: dict[str, Any] = {}
if fcm_token:
payload["fcm_token"] = fcm_token
if ntfy_topic:
@@ -640,6 +675,7 @@ def read_receipt(chat_id: str, message_id: str) -> Frame:
# Gateway health frame (M5)
# ---------------------------------------------------------------------------
def status(state: str) -> Frame:
"""Gateway health state (``state`` is one of the ``STATUS_*`` constants)."""
return Frame(type=TYPE_STATUS, payload={"state": state})
@@ -649,6 +685,7 @@ def status(state: str) -> Frame:
# Media frames (M4)
# ---------------------------------------------------------------------------
def media_offer(
media_id: str,
kind: str,
@@ -656,16 +693,16 @@ def media_offer(
size: int,
filename: str,
*,
chat_id: Optional[str] = None,
thread_id: Optional[str] = None,
message_id: Optional[str] = None,
chat_id: str | None = None,
thread_id: str | None = None,
message_id: str | None = None,
) -> Frame:
"""Event: the agent produced media the app can fetch via ``media.pull``.
``message_id`` (optional) associates the offer with the assistant message
it belongs to (the app falls back to the lane's last assistant message).
"""
payload: Dict[str, Any] = {
payload: dict[str, Any] = {
"media_id": media_id,
"kind": kind,
"mime": mime,
@@ -674,15 +711,17 @@ def media_offer(
}
if message_id:
payload["message_id"] = message_id
return Frame(type=TYPE_MEDIA_OFFER, chat_id=chat_id, thread_id=thread_id, payload=payload)
return Frame(
type=TYPE_MEDIA_OFFER, chat_id=chat_id, thread_id=thread_id, payload=payload
)
def media_pull_end(ok: bool, *, id: Optional[int] = None) -> Frame:
def media_pull_end(ok: bool, *, id: int | None = None) -> Frame:
"""Terminal frame of a ``media.pull`` binary stream."""
return Frame(type=TYPE_MEDIA_PULL_END, id=id, payload={"ok": ok})
def media_upload_ack(ok: bool, media_ref: str, *, id: Optional[int] = None) -> Frame:
def media_upload_ack(ok: bool, media_ref: str, *, id: int | None = None) -> Frame:
"""Response to ``media.upload.end``: the ref is cached and may be used in
a ``message.send`` ``media_refs``. Failures use ``error`` frames instead."""
return Frame(
@@ -692,12 +731,12 @@ def media_upload_ack(ok: bool, media_ref: str, *, id: Optional[int] = None) -> F
)
def error(code: str, message: str, *, id: Optional[int] = None) -> Frame:
def error(code: str, message: str, *, id: int | None = None) -> Frame:
return Frame(type=TYPE_ERROR, id=id, payload={"code": code, "message": message})
def pong(ts: Optional[int] = None) -> Frame:
payload: Dict[str, Any] = {}
def pong(ts: int | None = None) -> Frame:
payload: dict[str, Any] = {}
if ts is not None:
payload["ts"] = ts
return Frame(type=TYPE_PONG, payload=payload)
return Frame(type=TYPE_PONG, payload=payload)