Files
hapi/docs/api/client-contract/pagination.md
T

201 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Message pagination, windowing, and optimistic sends
**Audience:** Implementers of native HAPI clients (iOS / Android). This page specifies the message paging protocol (`GET /api/sessions/:id/messages`), the epoch reset contract, the tail-sync loop, recommended client windowing, and the optimistic-send / cancel lifecycle. Companion pages: [sse](./sse.md), [messages](./messages.md), [rest](./rest.md).
Source of truth: `shared/src/apiTypes.ts` (`MessagesQuerySchema`, `MessagesResponse`, `SendMessageRequestSchema`), `hub/src/web/routes/messages.ts`, `hub/src/sync/messageService.ts`, `hub/src/store/messages.ts`, reference client `web/src/lib/message-window-store.ts` + `web/src/lib/messages.ts`.
---
## Position key
Messages are ordered by a **compound position**, not by `seq` alone:
```
position = (at, seq) where at = invokedAt ?? createdAt
```
Ascending by `at`, ties broken by `seq`. Rationale: a queued user message sits at its `createdAt` until the agent consumes it, at which point `invokedAt` is stamped and the row **moves forward** to its invocation position. `seq` (per-session insert counter) alone would freeze queued rows at enqueue order. Every cursor in this protocol is therefore a `(seq, at)` **pair** — both halves are always required together.
---
## `GET /api/sessions/:id/messages`
Query parameters (`MessagesQuerySchema`; all numbers coerced from strings):
| Param | Type | Constraint |
|---|---|---|
| `limit` | int | 1–200. **Default 50** when omitted (the web reference always sends 200). |
| `beforeSeq` + `beforeAt` | int + int | Page strictly older than this position. Pairwise required. |
| `afterSeq` + `afterAt` | int + int | Page strictly newer than this position. Pairwise required. |
| `untilSeq` + `untilAt` | int + int | Inclusive snapshot head for a catch-up loop. Pairwise required; **requires an `after` cursor**. |
| `epoch` | int ≥ 0 | Client's cached epoch. **Requires an `after` cursor.** |
Validation rules (violations are `400 {"error":"Invalid query","issues":…}`):
- `beforeAt`⇄`beforeSeq`, `afterAt`⇄`afterSeq`, `untilAt`⇄`untilSeq` must each be provided together.
- `before` and `after` are **mutually exclusive**.
- `until` and `epoch` are only valid alongside `after`.
Session errors: `404` not found, `403` foreign namespace (see [errors](./errors.md)).
### Response shape
```ts
type MessagesResponse = {
messages: DecryptedMessage[] // ascending display order
page: {
direction: 'latest' | 'before' | 'after'
limit: number
epoch: number // server's current epoch for this session
reset: boolean // true ⇒ discard your window, this page replaces it
nextBeforeSeq: number | null // cursor for the next OLDER page
nextBeforeAt: number | null
nextAfterSeq: number | null // cursor for the next NEWER page
nextAfterAt: number | null
snapshotHeadSeq: number | null // newest position at snapshot time
snapshotHeadAt: number | null
hasMore: boolean // more rows exist in the requested direction
}
}
```
### `latest` (no cursor)
Newest `limit` rows by position, **plus** — out of band — every uninvoked local user message (queued rows, including future-scheduled ones), so a fresh client still sees the queued bar even when those rows fall outside the page. The out-of-band rows are pinned to every latest response and do **not** affect the cursor: `nextBefore*` anchors to the oldest row of the position-ordered page proper. `hasMore` = at least one row exists before that. If a page contains only server-side-filtered rows (see [messages](./messages.md)), the hub auto-advances to older pages until it can return something or history is exhausted.
### `before`
Rows strictly older than the cursor. `nextBefore*` = oldest row of this page; `hasMore` = at least one row older than that. The response also carries the current `epoch` — compare it to your cached one (see below).
### `after`
Rows strictly newer than the cursor, bounded by an **inclusive snapshot head** = `min(until, currentHead)` (or whichever exists). This keeps a catch-up loop from chasing messages appended while it runs. Responses:
- Client `epoch` ≠ server epoch ⇒ the server ignores the cursor and returns the **latest page with `reset: true`** (`direction: 'latest'`).
- Snapshot head ≤ cursor ⇒ empty page, `hasMore: false`, `nextAfter*` echoes the cursor.
- Otherwise: `nextAfter*` = last row's position, `hasMore` = `nextAfter < snapshotHead`.
---
## Epoch
`epoch` is a per-session monotonic counter (`message_epochs` table, starts at 0) that is bumped whenever history changes in a way that invalidates composite cursors (`hub/src/store/messages.ts`):
- a new row lands **before** the current head position (out-of-order insert, e.g. transcript import with an earlier timestamp);
- a queued message is deleted (cancel);
- rewind / history replace (`replaceSessionMessagesFrom`);
- messages are copied/merged between sessions (both sides), fork hydration.
Client contract:
- **`after` request** — always send your cached `epoch`. On mismatch the server answers with the latest page and `reset: true`; **discard the entire local window** and replace it with that page.
- **`before` request** — the response's `page.epoch` may differ from your cached one; if it does, your cursors are meaningless: drop cursor state, flag the window for a latest reset, and run a fresh tail sync (web: `fetchOlderMessages` → `epoch-reset` outcome).
- A structural change is also announced live via the `messages-invalidated` SSE event — on the open session, clear the window and tail-sync from scratch.
---
## Tail-sync loop
Run after connect, after an SSE `resume: 'gap'` handshake, on session open, and when told to (`messages-invalidated`). Reference: `runTailSync` in `web/src/lib/message-window-store.ts`.
1. **No usable state** (no newest cursor, no cached epoch, or a reset is pending): `GET …/messages?limit=200` (latest), replace/merge into the window, store `page.epoch`, `nextBefore*` (older-page cursor) and `snapshotHead*` (newest cursor). Done.
2. **Have cursor + epoch**: loop
- `GET …/messages?afterSeq&afterAt&epoch[&untilSeq&untilAt]&limit=200`, where `after` starts at your newest cursor and `until` is the `snapshotHead*` captured from the **first** response of the loop (fixes the target so the loop terminates).
- `page.reset` or `direction: 'latest'` ⇒ replace the window with this page; stop.
- Otherwise merge the rows, advance `after = nextAfter*`, update the newest cursor to `max(current, nextAfter)`; stop when `hasMore` is false.
- Guard: if `nextAfter` did not advance past the previous cursor, abort with an error (protocol violation, do not spin).
New live rows keep arriving via the SSE `message-received` event; ingest them and advance the newest cursor to `max(current, incoming position)`. Only run one tail sync at a time per session; if events force another (e.g. a reset was flagged mid-loop), queue a trailing run.
---
## Client windowing (normative recommendation)
Constants from the web reference (`web/src/lib/message-window-store.ts`):
| Constant | Value | Meaning |
|---|---|---|
| `PAGE_SIZE` | 200 | Request size for every page fetch. |
| `VISIBLE_WINDOW_SIZE` | 400 | Max regular rows kept in **tail** mode (following live bottom). |
| `HISTORY_WINDOW_SIZE` | 600 | Max regular rows kept in **history** mode (user scrolled back). |
| `OLDER_LOAD_WINDOW_SIZE` | 800 | Temporary cap while an older page is being merged (prepend). |
| `AGENT_RUN_WINDOW_SIZE` | 800 | Separate trim bucket for codex `agent-run-*` rows so background-agent traces don't evict chat. |
Rules:
- **Tail mode** trims from the top (oldest dropped). Dropping rows ⇒ set `hasMore: true` and recompute the older-page cursor from the oldest kept row.
- **History mode** trims from the bottom (newest dropped). Dropping newest rows means your window no longer reaches the tail ⇒ flag "latest reset required": on returning to tail mode, discard cursors and fetch a fresh latest page rather than trusting stale ones.
- **Queued rows are never trimmed** (user messages with `invokedAt === null`, see below) — they are re-merged after every trim.
- Persist the window (messages + cursors + epoch) per session for instant cold-start rendering; on re-activation with a persisted cursor, still fetch a fresh latest page first (another client may have advanced the session by many pages) and reconcile.
---
## Optimistic sends
Send: `POST /api/sessions/:id/messages` (see constraints below). Reference: `web/src/hooks/mutations/useSendMessage.ts`, `mergeMessages` in `web/src/lib/messages.ts`.
Lifecycle:
1. Generate a client-side `localId` and append an **optimistic row**: `{id: localId, seq: null, localId, invokedAt: null, scheduledAt, createdAt: now, status:'sending', content: {role:'user', content:{type:'text', text, attachments?}, meta:{deliveryMode}}}`. A row is *optimistic* iff `id === localId`.
2. On POST success: status → `queued` if the session is currently thinking, else `sent`. On failure: drop the row and restore the composer (or keep it as `failed` with a retry affordance when attachments are involved).
3. **Echo**: the hub emits `message-received` carrying the stored row (server `id`, real `seq`, same `localId`). Merging a stored row whose `localId` matches an optimistic row **replaces** the optimistic one, preserving the client-side `status` and any already-known `invokedAt` the server row lacks. Fallback when no `localId` echo matches: drop an optimistic `sent` row when a server user message lands within **10 s** of the same position.
4. **`messages-consumed {localIds, invokedAt}`** (SSE): stamp `invokedAt` and flip status to `sent` on matching rows (skip `failed` ones). This is what moves a message out of the queued bar and into the thread at its invocation position.
5. **`messages-indeterminate {localIds}`** (SSE): a native dispatch or queue mutation has an unknown outcome. Keep `invokedAt: null`, mark `deliveryState:'indeterminate'`, and exclude the row from automatic replay. Retry/Cancel are explicit resolution actions and may remain unavailable until the native outcome can be reconciled.
6. **`messages-requeued {localIds}`** (SSE): an explicit Retry restored normal queue delivery; clear `deliveryState`.
7. **`message-cancelled {messageId, localId?}`** (SSE): remove the row (match either id).
**Queued semantics**: a user message is "queued" iff `invokedAt === null` **strictly**, `deliveryState !== 'indeterminate'`, and `status !== 'failed'`. An indeterminate row remains visible in the unresolved-delivery bar but is not eligible for automatic delivery. `undefined` means already-invoked (rows from pre-V8 hubs omit the field) — only rows explicitly carrying `null` belong in the queued bar. Server-side, rows sent without a `localId` are stamped invoked at insert and can never be queued.
### Queued-state recovery
After a reconnect whose handshake said `resume: 'gap'` (an `ok` resume replayed the consume/cancel events already), the consumed/cancelled events for your queued rows may have been lost. Reference: `web/src/lib/queued-state-reconciliation.ts`.
1. Finish a tail sync.
2. Collect candidate `localId`s: user rows with `invokedAt === null`, excluding optimistic rows still `sending`/`failed`.
3. `POST /api/sessions/:id/messages/queued-state` with `{"localIds": […]}` (max 1000 per call; batch above that) → `{queuedLocalIds: string[], indeterminateLocalIds: string[], invokedLocalMessages: [{localId, invokedAt}]}`.
4. Apply `invokedLocalMessages` exactly like `messages-consumed`; mark `indeterminateLocalIds` as unresolved delivery. Retain both queued and indeterminate rows; drop only candidates absent from **all three** result groups. An in-flight native dispatch is reported as indeterminate, not as a deleted message.
---
## Send constraints
`POST /api/sessions/:id/messages` body (`SendMessageRequestSchema`):
| Field | Type | Rules |
|---|---|---|
| `text` | string | Required (route also accepts empty text when `attachments` is non-empty). |
| `localId` | string | Optional but **required for `scheduledAt`**, and required in practice: without it the row is stamped invoked at insert (no queue/ack/cancel path). |
| `attachments` | `AttachmentMetadata[]` | Optional. Not allowed with `scheduledAt`. |
| `scheduledAt` | epoch ms | Optional. Must be ≤ now + **7 days**; requires `localId`; no attachments; never `steer`. |
| `deliveryMode` | `'queue'` \| `'steer'` | Optional, default `queue`. `steer` is honored only for **Pi-flavor** sessions and never for scheduled sends — the hub silently normalizes everything else to `queue`, and deferred/replayed delivery (reconnect backfill, retries, scheduled release) always degrades `steer` to `queue`. |
Response `{"ok": true}`. Sending to an inactive session returns `409 {"error":"Session is inactive","code":"session_inactive"}` — resume/reopen first, and note the resumed session **may have a different id** (migrate drafts and re-target, see [rest](./rest.md)).
---
## Cancel and steer
**Cancel**: `DELETE /api/sessions/:id/messages/:messageId` — `:messageId` may be the server id **or** the `localId`. Response union (`CancelMessageResponseSchema`):
| Response | Meaning | Client action |
|---|---|---|
| `{"status":"cancelled","localId":string\|null}` | Row deleted (or already gone). Bumps the epoch. | Remove the row. |
| `{"status":"invoked","message":DecryptedMessage}` | Too late — the agent consumed it before the cancel landed. | **Ingest the returned message** as the authoritative row (correct `invokedAt`, status `sent`); do not resurrect the queued snapshot. |
| `{"status":"busy","localId":string}` | Native delivery/removal is unresolved; cancellation cannot be confirmed. | Restore the row as indeterminate; reconcile queued state before allowing Retry/Cancel. |
Other subscribers learn the same outcome via `message-cancelled` / `messages-consumed` SSE events.
**Steer a queued message into the current turn**: `POST /api/sessions/:id/messages/:messageId/steer` → `SteerQueuedMessageResponseSchema`. Unlike the send-time `deliveryMode` option above, this endpoint supports Pi, Codex, and Cursor ACP sessions (`isSteeringSupportedForSession` in `shared/src/modes.ts`). It rejects all scheduled messages, and rejects terminal-controlled sessions unless they advertise `concurrentClients`.
| Response | Client action |
|---|---|
| `{"status":"steered","localId"}` | Keep the row queued-side; it is being injected into the live turn. |
| `{"status":"invoked","message"}` | Already consumed — ingest the message. |
| `{"status":"failed","error","localId":string\|null}` | Surface the error. Do not infer delivery state from this alone; reconcile before retrying when the native outcome is unknown. |
**Retry indeterminate delivery**: `POST /api/sessions/:id/messages/:messageId/retry`
is user-initiated only. `retried` or `already-queued` means normal queue delivery;
`invoked` carries the authoritative message; `not-found` means the row is gone.
`retry-unavailable` leaves the row unresolved: the hub could not prove that
retrying would avoid duplicate work. Reconcile instead of automatically retrying.