Files
hapi/docs/api/client-contract/read-state.md
T
weishu 5ef4304a4a fix: synchronize read state across web and native clients
Track readable content revisions and durable read cursors in the hub. Acknowledge only rendered foreground tails, persist offline read operations, and preserve newer manual unread choices across devices.

Migrate existing history to a read baseline and cover pagination receipts with shared native conformance fixtures.
2026-11-01 09:52:51 +08:00

4.4 KiB
Raw Blame History

Session read state

Sources: shared/src/sessionReadState.ts, shared/src/messages.ts, hub/src/store/sessionReadState.ts, hub/src/sync/messageService.ts.

Read state is shared by Web, iOS and Android within one Hub and JWT namespace. CLI terminal activity does not acknowledge reading. updatedAt, message seq, permission state and transport heartbeat are independent of unread status.

Revisions and unread

Session, SessionSummary and session SSE patches carry:

contentRevision: number
readState: { readRevision: number; manualUnread: boolean; version: number }

The hub increments contentRevision atomically with a new readable assistant message: answer text, rendered media or an error. User prompts, reasoning, tool traffic, usage, metadata and heartbeats do not advance it. Imported/copied history starts at zero; live delayed output counts. A replay with the same localId keeps its original revision. Meaningful message rows carry their contentRevision in REST and SSE.

Unread is manualUnread || (!(active && thinking) && contentRevision > readRevision). Running suppresses the ordinary dot without discarding unseen content. Input and permission attention remain independent. Selecting a session does not make it read. Clients merge revisions monotonically and use the highest read-state version for the manual flag, including when REST and SSE arrive out of order.

Read acknowledgement

PUT /api/sessions/:id/read-state accepts one of:

{"type":"read","readRevision":12,"expectedVersion":4}
{"type":"unread","expectedVersion":4}

The response includes {status, contentRevision, readState}. Counters must be nonnegative safe integers. An invalid/future revision returns 400. Manual unread uses compare-and-set: a mismatched version returns 409 with authoritative state. Read requests advance by maximum; they clear manual unread only when expectedVersion matches. Thus an older automatic read cannot erase a newer manual choice, and an acknowledgement of revision 12 cannot consume revision 13. An old session ID resolving to a replacement returns 409: revisions cannot be replayed against a different session. Namespace access is checked before mutation. Read writes emit session-updated patches without advancing updatedAt.

Automatic reads require a visible foreground chat, successful message loading, and the latest tail laid out on screen. Background, history browsing, hidden inspectors/terminals, failed loads and list navigation cannot acknowledge reading. A read-state-only update is not a new viewing action. Explicit “mark all read” uses the content revisions observed in that list action.

Latest message pages carry page.readThroughRevision. A completed forward catch-up carries it only if its snapshot still reaches the current head. Before pages and partial/bounded catch-ups cannot certify a later head. Clients retain the receipt with the corresponding message window; they can advance it with rendered incoming message revisions after initial tail synchronization. Never substitute the summary’s head revision for a rendered window receipt.

Offline and lifecycle

Persist the exact observed revision and expected version in a durable outbox before presenting a successful local read. Retry transient failures, including after restart/foregrounding. Each operation has an ID so an old response cannot remove a newer queued operation. Authoritative 400/403/404/409 rejection rolls back the optimistic mark; do not blindly rebase onto a newer manual choice. Storage keys include Hub origin and JWT namespace; never derive namespace by splitting the pairing access token. Switching Hub must finish pending disk writes.

Upgrade and history changes

Database v30 adds persistent read fields with zero defaults. Existing history becomes a read baseline once; old client timestamp/manual-marker files are ignored. This clears old unread dots on upgrade, while pending approvals/input remain. Update Hub and clients together; clients do not fall back to timestamp-based reads.

Fork/import copies are baseline history. A session merge translates unread source revisions above the destination head, transfers manual unread, and retires the source state. Client windows seeded across IDs discard old message revisions and fetch a fresh tail. Rewind never reuses revisions; if all unread output is removed, it retires that output while preserving an explicit manual-unread choice.