Skip to content

Commit 6af71a2

Browse files
committed
docs(ai-chat): document tail-on-ping and the client-side caught-up close
The client-protocol reference now describes the tail carried on the heartbeat ping, the caught-up test a hand-rolled client can run against it, and the SSEStreamSubscription's own close-on-caught-up on the reconnect path.
1 parent d291924 commit 6af71a2

1 file changed

Lines changed: 4 additions & 2 deletions

File tree

docs/ai-chat/client-protocol.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ The output stream uses [S2](https://s2.dev) under the hood and follows the stand
320320
| Event | Meaning |
321321
| --- | --- |
322322
| `batch` | One or more records. The records you actually care about. |
323-
| `ping` | Keepalive (~every 5s on idle). Body is `{"timestamp": <ms>}`. Ignore it. |
323+
| `ping` | Keepalive (~every 5s on idle). Body is `{"timestamp": <ms>}`, and on backends that report it a `tail` (same `{seq_num, timestamp}` shape as a batch tail). The tail is what lets you tell you have caught up to the live edge; if you don't need caught-up detection, ignore the ping. |
324324
| _(no `event:`, just `data: [DONE]`)_ | Stream is closing — server sends this once before EOF. |
325325
326326
A `batch` event in raw SSE format looks like this — note the `data` is a single line of JSON, no embedded newlines (per the SSE spec):
@@ -359,7 +359,7 @@ Decoded `data` payload:
359359
| `records[].timestamp` | Unix ms when the record was written to S2. |
360360
| `records[].body` | For data records: a JSON-encoded **string** wrapping `{ data: UIMessageChunk, id: string }`. For control records: an empty string (semantics live in `headers`). For S2 command records: opaque bytes. See [Records on session.out](#records-on-session-out). |
361361
| `records[].headers` | Optional `[name, value]` pairs. Empty for data records; a `trigger-control` entry for control records; a single empty-name `["", "<op>"]` entry for S2 command records. |
362-
| `tail.seq_num` | Latest known tail of the S2 streamuseful for detecting how far behind the live edge you are. Skip if you don't need it. |
362+
| `tail.seq_num` | Latest known tail of the S2 stream, useful for detecting how far behind the live edge you are. When `last delivered seq_num + 1 === tail.seq_num` you have drained the backlog and are caught up to the live edge. The same `tail` also rides on `ping` events. Skip if you don't need it. |
363363
| `tail.timestamp` | Timestamp of `tail.seq_num`. |
364364
365365
### Records on `session.out`
@@ -652,6 +652,8 @@ On **reconnect-on-reload** paths (resuming a chat where nothing may be streaming
652652
653653
**Do not send `X-Peek-Settled` on the active-send response-stream path.** The peek would race the newly-triggered turn's first chunk — if the agent hasn't written the new turn's first record yet, the peek sees the prior turn's `turn-complete` and closes the SSE before the response lands on S2. The built-in `TriggerChatTransport.reconnectToStream` sets the header; `sendMessages → subscribeToStream` does not.
654654
655+
If you use the TypeScript `SSEStreamSubscription` (or `useChat`, which builds on it), the client settles on its own too: on the reconnect path it watches the `tail` on batch and ping events and closes a resumed stream as soon as it reaches the live edge, so a settled idle reconnect closes promptly without waiting out the long poll. Hand-rolled clients can do the same by comparing their last processed `seq_num` to the ping/batch `tail`. On older self-hosted backends whose `ping` carries no `tail`, this falls back to the `X-Peek-Settled` behavior above.
656+
655657
```ts
656658
// Reconnect path (page reload)
657659
const response = await fetch(sseUrl, {

0 commit comments

Comments
 (0)