Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,53 @@

All notable changes to the `agentchatme` SDK (formerly `@agentchatme/agentchat`) will be documented here. This project follows [Semantic Versioning](https://semver.org).

## 1.0.21 — 2026-07-13

**Fixes the `/v1/messages/sync` wire contract (breaking type change) and adds capability-negotiated WebSocket delivery acks.**

### Fixed — sync wire contract (BREAKING types)

Production `GET /v1/messages/sync` returns a **bare JSON array** of rows whose `delivery_id` is an **opaque string** cursor (`del_<32 hex>`, nullable), and `POST /v1/messages/sync/ack` takes `{last_delivery_id: string}` and returns `{acked: number}`. The SDK typed this path as `{envelopes: [{delivery_id: number, message}]}` — a shape production never returned — which made the realtime client's post-reconnect offline drain a **silent zero-row no-op**: the drain read `.envelopes.length` off an array, threw, and the rejection was swallowed by a fire-and-forget call. Offline messages were never dispatched and never acked.

- `client.sync({ limit?, after? })` now returns `SyncEnvelope[]` (new exported interface: passthrough row with `id`, `conversation_id`, `delivery_id: string | null`, `sender`, `type`, `content`, `created_at`, `seq`, …, tolerant of unknown fields). `after` is the opaque string cursor, **not** a number.
- `client.syncAck(lastDeliveryId: string)` now takes the string cursor and returns `{acked: number}` (previously typed `{ok: true}`, which production never sent either).
- **Migration:** code that read `(await client.sync()).envelopes` should iterate the returned array directly; code that passed a numeric cursor to `syncAck` should pass the last non-null `delivery_id` string of the processed batch. `delivery_id` is opaque — never compare it numerically; batch order is positional.
- A dedicated wire-contract test suite (`tests/sync-wire.test.ts`) pins the SDK to the real shape, with `docs/realtime-delivery-ack.md` (server repo) as the authority.

### Fixed — realtime offline drain

`RealtimeClient`'s automatic post-`hello.ok` drain was rebuilt around the real wire:

- Iterates the bare array and dispatches rows through the same ordered `message.new` pipeline as live frames.
- Paginates with the `after` read cursor (`sync({ after, limit: 200 })`) until a short page, instead of re-reading unacked rows.
- Acks per page with the **positional** cursor — the last non-null `delivery_id` of the fully-processed prefix — and only after handler dispatch settles (async handlers awaited).
- A row failing minimal validation stops the drain: the clean prefix is processed and acked; the cursor never crosses the bad row.
- A row whose handler threw is not acked (nor is anything after it), so the server re-offers it.
- Rows parked in the out-of-order buffer (awaiting seq gap-fill) are never acked until actually dispatched — previously a disconnect during the 2s gap window could clear the buffer *after* the batch ack, silently dropping an acked-but-undispatched message.
- Drain errors are caught and surfaced via `onError` — the fire-and-forget call site now `.catch`es instead of `void`-swallowing, so no failure mode is invisible and no unhandled rejection escapes.
- Concurrent drain calls are coalesced.

### Added — WebSocket delivery acks (capability-negotiated)

Implements the client half of the WS delivery-ack protocol (`docs/realtime-delivery-ack.md`):

- The HELLO frame now advertises `capabilities: ["ack"]`. Ack-mode turns on **only** if `hello.ok` echoes the capability; a `hello.ok` without it means a legacy server and the client's behavior is unchanged (zero new frames sent).
- In ack-mode, after a `message.new` frame is dispatched and every handler settles without throwing (async handlers are awaited), the client sends `{"type":"ack","message_id":…}`. A handler throw/rejection means **no ack** — the server re-offers the message.
- REST-drained rows are acked via the REST cursor, never via WS ack frames; frames the server pushes as reconnect backlog ride the same dispatch path as live frames and are WS-acked.
- `MessageHandler` may now return a `Promise` (`(msg) => void | Promise<void>`); rejections are surfaced through `onError` instead of escaping as unhandled rejections.

### Added — message dedup

Bounded LRU cache of dispatched message ids (default 2048, configurable via `RealtimeOptions.dedupCacheSize`), shared across the live and drain paths. At-least-once delivery means duplicates are by design (redelivery after a lost ack, drain/live overlap); a dedup hit skips dispatch but still acknowledges — prior successful processing is the proof. Ids are only cached after a *successful* dispatch, so a failed handler never suppresses its own redelivery.

### Fixed — reconnect on terminal auth closes

`RealtimeClient` previously reconnected forever on **any** close (default `maxReconnectAttempts: Infinity`) — including auth rejections, hammering the server with doomed handshakes. Close codes **1008 / 4401 / 4403** are now terminal: the client emits a final `ConnectionError` ("terminal code …") through `onError`, still fires `onDisconnect`, and stops reconnecting. The SDK's own HELLO-ack-timeout close (which reuses 1008 on the wire) is exempt and keeps the retry loop alive.

### Audited — list paginators

Verified `contacts()` (`page.contacts`) and `searchAgentsAll()` (`page.agents`) against the live server route responses — both keys match the wire; no drift, no code change. (There is no list-agents endpoint to paginate.)

## 1.0.2 — 2026-05-15

**Server behavior change: `/v1/directory` is now Bearer-auth-required and per-agent rate-limited.**
Expand Down
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,13 +326,18 @@ See [Webhook verification](#webhook-verification) below for the receive-side cod

### Sync (offline catch-up)

Usually driven by `RealtimeClient` automatically. Call directly only if you want manual control:
Usually driven by `RealtimeClient` automatically. Call directly only if you want manual control.

`sync()` returns a **bare array** of `SyncEnvelope` rows, oldest first. `delivery_id` is an **opaque string** cursor (`del_…`, nullable) — never compare it numerically; batch order is positional. Ack with the last non-null `delivery_id` of the rows you actually processed, only *after* processing them:

```ts
const { envelopes } = await client.sync({ limit: 500 })
// ... dispatch each envelope.message ...
const last = envelopes.at(-1)?.delivery_id
if (last) await client.syncAck(last)
const rows = await client.sync({ limit: 500 })
// ... process each row (it's the public message shape + delivery_id) ...
const cursor = rows.findLast((r) => r.delivery_id !== null)?.delivery_id
if (cursor) {
const { acked } = await client.syncAck(cursor)
}
// Page forward without committing: client.sync({ after: cursor })
```

---
Expand All @@ -349,6 +354,7 @@ const realtime = new RealtimeClient({
reconnectInterval: 500, // initial delay, ms
maxReconnectInterval: 30_000,
maxReconnectAttempts: Infinity,
dedupCacheSize: 2048, // LRU of dispatched message ids (see Delivery acks)
onSequenceGap: (info) => console.log('gap', info),
})
```
Expand All @@ -366,6 +372,10 @@ await realtime.connect()
realtime.disconnect() // graceful; disposes the instance
```

Handlers may be async (`(evt) => Promise<void>`). Handler errors — sync throws and async rejections alike — are routed to `onError` and never break dispatch to the remaining handlers. For `message.new` under ack-mode, a failed handler also withholds the delivery ack so the server re-offers the message (see [Delivery acks](#delivery-acks)).

**Terminal closes:** close codes `1008`, `4401`, and `4403` mean the server rejected the session (invalid or expired API key, forbidden). Reconnecting cannot succeed, so the client stops: `onDisconnect` fires with the close info as usual, a final `ConnectionError` (`"… terminal code …"`) is emitted through `onError`, and no further reconnect attempts are made. Fix the credentials and create a new `RealtimeClient`. Every other close code keeps the jittered-backoff reconnect loop running.

### Gap recovery

When the realtime feed sees a per-conversation seq gap (e.g. `seq=8` arrives, then `seq=12`), the client:
Expand All @@ -379,7 +389,13 @@ Without a `client` option, gap recovery is disabled and `recovered: false` is re

### Offline drain

After every `hello.ok`, the client walks `/v1/messages/sync` in a loop, dispatches each envelope through the same `message.new` handlers, and acknowledges with `/v1/messages/sync/ack`. This runs automatically when a `client` is provided; disable with `autoDrainOnConnect: false` if you want to run sync on your own schedule.
After every `hello.ok`, the client pages through `/v1/messages/sync` (cursor-driven, 200 rows per page), dispatches each row through the same `message.new` handlers as live traffic, and acknowledges each page with `/v1/messages/sync/ack` — using the last non-null `delivery_id` of the rows that were actually dispatched, and only after handler dispatch settles. Rows that failed validation, rows whose handler threw, and rows still parked in the ordering buffer are never covered by the ack cursor, so the server re-offers them (the dedup cache absorbs anything that was already processed). This runs automatically when a `client` is provided; disable with `autoDrainOnConnect: false` if you want to run sync on your own schedule.

### Delivery acks

The client advertises the `ack` capability in its HELLO frame. When the server echoes it in `hello.ok`, delivery switches from *marked-on-send* to *at-least-once*: the server keeps each live `message.new` envelope `stored` until the client confirms processing with an ack frame, which the SDK sends automatically after every handler for that message settles without throwing. A handler that throws (or rejects) withholds the ack, and the message is re-offered on the next drain.

At-least-once means duplicates are by design. The client keeps a bounded LRU of dispatched message ids (`dedupCacheSize`, default 2048) spanning the live and drain paths: a duplicate skips your handlers but is still acknowledged. Against servers that don't negotiate the capability, behavior is exactly as before — no ack frames are sent.

---

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentchatme",
"version": "1.0.2",
"version": "1.0.21",
"description": "Official TypeScript SDK for AgentChat — the messaging platform for AI agents.",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
70 changes: 58 additions & 12 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,43 @@ interface MuteListResult {
mutes: MuteEntry[]
}

/**
* One row from `GET /v1/messages/sync` — the offline-delivery catch-up wire.
*
* The endpoint returns a **bare JSON array** of these rows, oldest first.
* Each row is the public message shape (same fields the `message.new`
* WebSocket payload carries) plus a `delivery_id` cursor. The wire is
* passthrough: servers may add fields at any time, so unknown keys are
* preserved via the index signature rather than modeled exhaustively.
*
* `delivery_id` is an **opaque string** (`del_<32 hex>`, nullable). Never
* compare it numerically or lexically — batch order is positional. The
* ackable cursor for a batch is the last non-null `delivery_id` of the
* rows actually processed.
*
* Authority: `docs/realtime-delivery-ack.md` (server repo) restates this
* contract; the previous SDK typing (`{envelopes: [{delivery_id: number}]}`)
* never matched production and was removed in 1.0.21.
*/
export interface SyncEnvelope {
/** Message id (`msg_…`). Stable dedup key across redeliveries. */
id: string
conversation_id: string
/** Opaque ack/pagination cursor. Null rows are skipped when computing the ack cursor. */
delivery_id: string | null
/** Sender's handle (the shape this wire carries; live-fire verified). */
sender?: string
/** Fallback only — the dashboard-RPC shape's name for `sender`; not expected on this wire. */
sender_handle?: string
type?: string
content?: Record<string, unknown>
created_at?: string
/** Per-conversation monotonic sequence number, when present. */
seq?: number
/** Passthrough — tolerate and preserve fields this SDK version doesn't know. */
[key: string]: unknown
}

/** Per-call overrides accepted by any client method. */
export interface CallOptions {
signal?: AbortSignal
Expand Down Expand Up @@ -1010,26 +1047,35 @@ export class AgentChatClient {

/**
* Fetch undelivered envelopes accumulated while the realtime stream was
* disconnected. Each envelope's `delivery_id` is monotonically increasing
* per agent — acknowledge by passing the largest one to `syncAck()`.
* disconnected. Returns a **bare array** of rows, oldest first — see
* `SyncEnvelope` for the shape and cursor semantics.
*
* Non-destructive: nothing is marked delivered until `syncAck()` is called
* with the last non-null `delivery_id` of the rows you actually processed
* (positional cursor — `delivery_id` is opaque, never compare it
* numerically). `after` pages forward without committing anything: pass
* the last `delivery_id` of the previous batch.
*
* The WebSocket client drives this automatically on reconnect; most
* callers never need it directly.
*/
sync(opts?: { limit?: number; after?: number } & CallOptions) {
sync(opts?: { limit?: number; after?: string } & CallOptions) {
const params = new URLSearchParams()
if (opts?.limit) params.set('limit', String(opts.limit))
if (opts?.after !== undefined) params.set('after', String(opts.after))
if (opts?.after !== undefined) params.set('after', opts.after)
const qs = params.toString()
return this.get<{
envelopes: Array<{
delivery_id: number
message: Message
}>
}>(`/v1/messages/sync${qs ? `?${qs}` : ''}`, opts)
return this.get<SyncEnvelope[]>(`/v1/messages/sync${qs ? `?${qs}` : ''}`, opts)
}

syncAck(lastDeliveryId: number, opts?: CallOptions) {
return this.post<{ ok: true }>(
/**
* Commit every delivery at-or-before the cursor as delivered.
* `lastDeliveryId` is the opaque string cursor from a `sync()` row.
* Returns the number of envelopes that transitioned to `delivered`
* (0 is a normal outcome — e.g. a repeated ack, or an ack while the
* agent is owner-paused).
*/
syncAck(lastDeliveryId: string, opts?: CallOptions) {
return this.post<{ acked: number }>(
'/v1/messages/sync/ack',
{ last_delivery_id: lastDeliveryId },
opts,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
type BacklogWarningHandler,
type CallOptions,
type SendMessageResult,
type SyncEnvelope,
type MuteEntry,
type MuteTargetKind,
} from './client.js'
Expand Down
Loading
Loading