Add persistent agent messaging and shared chat bubbles

Connect Sybil to the Pi root conversation with authenticated REST and WebSocket
APIs, persistent read state, and a dedicated agent tab. Keep optimistic messages
stable through delivery and retries, and animate arrivals without losing the
reader's scroll position.

Share bubble styling with regular chats and remove the obsolete admission sender.
Reject malformed upstream receipts and WebSocket upgrade requests, with regression
coverage and updated API documentation.

Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile
bubble comparisons, and 11 browser animation and scrolling checks.
This commit is contained in:
2026-10-14 12:20:11 -07:00
parent dd6cd1ea7b
commit ef3d79c428
29 changed files with 2752 additions and 25 deletions
+70
View File
@@ -562,3 +562,73 @@ Behavior notes:
```
For streaming contracts, see `docs/api/streaming-chat.md`.
## Persistent Agent Room
The Agent tab connects to one persistent Pi root conversation. Pi owns its messages,
background work, and history; these endpoints do not create ordinary Sybil chats or
copy the transcript into the Sybil message database. All endpoints use normal Sybil
REST authentication. The server connects privately to `PI_AGENT_BASE_URL` (default
`http://pi-durable:8788`) using `PI_AGENT_API_KEY`. The Pi key never reaches browsers.
### `GET /v1/agent`
Returns the Pi room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`,
`protocol: "pi-room.v1"`, `activity`, and `websocket: "/v1/agent/ws"`. The socket path
is relative to the API base, so web deployments use `/api/v1/agent/ws`. Missing Pi
configuration returns 503; an unreachable Pi server or malformed upstream JSON
returns 502.
### `GET /v1/agent/messages`
Query parameters: optional `after` or `before` (mutually exclusive nonnegative
numeric cursor strings), and `limit` (1–200). Without a cursor, returns recent
history. `after` catches up forward; `before` loads earlier history.
```json
{
"conversationId": "1",
"messages": [{ "id": "outbound:43", "createdAt": "2026-10-14T18:00:00.000Z", "role": "assistant", "content": "The task finished.", "name": "Pi", "metadata": { "kind": "message", "entryId": 43, "status": "complete" } }],
"cursor": "43",
"beforeCursor": "43",
"hasMore": false
}
```
Messages use `user`, `assistant`, and `tool` roles. Tools use
`metadata.kind: "tool_call"` and stable IDs; merge by message `id`, since later
pages can update initiated tools to completed or failed. Internal worker reports,
event prompts, and model reasoning are excluded. Cursors refer to underlying
durable entries and can advance without visible messages. Use returned cursors;
do not derive them from message IDs. `limit` bounds source entries, not displayed
messages.
### `POST /v1/agent/messages`
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
Content must contain non-whitespace text and is limited to 40,000 characters;
`requestId` is required (1–256 characters). Preserve the ID when retrying uncertain
network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
`wait: false`.
Returns Pi's `202 Accepted` receipt, including `id` (submission ID), `status`, and
`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not
completion: watch the room for answers, including unsolicited later replies.
Multiple requests can be accepted while Pi works. Attachments are not yet supported.
The proxy verifies successful receipts contain a positive submission ID, a known
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`.
Invalid receipts return 502; delivery remains uncertain, so retry using the original
`requestId` to avoid creating a duplicate message.
### `GET /v1/agent/read-state`
### `PUT /v1/agent/read-state`
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z" }`.
Initially: `{ "cursor": "0", "updatedAt": null }`. PUT takes
`{ "cursor": "43" }` and returns the same shape. The cursor is shared across this
personal installation and persisted in SQLite. Atomic, monotonic updates prevent
a stale browser from lowering a cursor saved by another browser. Advance it only
after showing the corresponding history; poll it to reconcile unread indicators
across clients. Agent REST responses use `Cache-Control: no-store`.
See the persistent Agent WebSocket section in `streaming-chat.md` for live updates.
+60 -1
View File
@@ -1,6 +1,6 @@
# Streaming Chat API Contract
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions.
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions, and the WebSocket contract for the persistent Agent room.
Endpoint:
- `POST /v1/chat-completions/stream`
@@ -275,3 +275,62 @@ event: done
data: {"type":"done","text":"Hello world"}
```
## Persistent Agent WebSocket
`GET /v1/agent/ws` upgrades to a continuous WebSocket room subscription. Behind the
web proxy use `/api/v1/agent/ws`, with `ws:` or `wss:` matching the page. It remains
connected after each assistant turn and receives later background results. Sending
messages uses `POST /v1/agent/messages`.
The browser must send this first frame within five seconds:
```json
{ "type": "auth", "token": "Sybil token", "after": "43" }
```
In open mode, use `token: null`. Omit `after` for recent history; on reconnect supply
the last successfully merged cursor. Invalid credentials close with 4401, malformed
initial frames with 4400. Malformed upgrade URLs and query parameters are rejected
with HTTP 400; unknown upgrade paths return HTTP 404. Never put tokens in URLs.
Sybil adds its private Pi credential only to the upstream Authorization header;
browser credentials are not forwarded to Pi.
Native `pi-room.v1` frames are forwarded after authentication:
```json
{ "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." }
```
```json
{
"type": "messages",
"conversationId": "1",
"messages": [{ "id": "outbound:44", "role": "assistant", "content": "The result is ready.", "createdAt": "2026-10-14T18:00:00.000Z", "metadata": { "kind": "message", "entryId": 44, "status": "complete" } }],
"cursor": "44",
"beforeCursor": "44",
"hasMore": false
}
```
```json
{ "type": "activity", "conversationId": "1", "activity": { "state": "thinking", "isThinking": true, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": [] } }
```
Activity is forwarded from Pi and also describes tools, queued messages, durable
waiting, errors, and workers. Use `isThinking` for the thinking indicator and
`backgroundWorkers` for ongoing delegated work; no model reasoning is exposed. Tools arrive as `role: "tool"` messages
with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and
socket messages are upserts keyed by stable `id`. Save each returned cursor after
applying its entire frame, even when it contains no visible messages.
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
ping/pong heartbeats detect dead connections. Other writes belong on REST.
Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011
or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged
cursor. Missing Pi configuration closes with 4503.
The proxy closes upstream sockets when browsers leave or Sybil shuts down.
History remains durable in Pi. Shared read position is separate from delivery:
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.