# PRD: TUI Session Manager **Module:** `tui-sm/` | **Namespace:** `ajet.chat.tui-sm.*` **Status:** v1 | **Last updated:** 2026-02-17 --- ## 1. Overview The TUI Session Manager serves terminal clients via SSE. It holds live connections from TUI clients, subscribes to NATS for real-time events, and calls the API for data. It acts as an intermediary — translating NATS events into structured SSE events that the TUI client renders. Unlike Web SM (which sends HTML fragments), TUI SM sends **JSON-structured events** over SSE. The TUI client handles all rendering. ## 2. Architecture ``` TUI Client ←─ SSE (JSON events) ──→ TUI SM ──→ NATS (subscribe) ←─ HTTP POST (signals) ──→ ──→ API (HTTP, data) ``` ## 3. SSE Event Protocol ### 3.1 Connection **Endpoint:** `GET /tui/sse/events` **Headers:** - `Accept: text/event-stream` - `X-User-Id`, `X-Trace-Id` (injected by Auth GW) **Query params:** - `community_id=` — active community (optional, can start with DMs) - `last_event_id=` — for reconnection replay ### 3.2 SSE Event Format ``` event: id: data: ``` ### 3.3 Event Types | Event Type | Payload | Description | |------------|---------|-------------| | `init` | `{communities, channels, dms, user}` | Initial state on connect | | `message.new` | `{message}` | New message in subscribed channel | | `message.edit` | `{message}` | Message edited | | `message.delete` | `{message_id, channel_id}` | Message deleted | | `reaction.add` | `{message_id, emoji, user_id}` | Reaction added | | `reaction.remove` | `{message_id, emoji, user_id}` | Reaction removed | | `typing.start` | `{channel_id, user_id, username}` | User started typing | | `typing.stop` | `{channel_id, user_id}` | User stopped typing | | `presence.update` | `{user_id, status}` | Online/offline change | | `channel.new` | `{channel}` | New channel in community | | `channel.update` | `{channel}` | Channel updated (topic, name) | | `channel.delete` | `{channel_id}` | Channel deleted | | `member.join` | `{channel_id, user}` | User joined channel | | `member.leave` | `{channel_id, user_id}` | User left channel | | `notification` | `{notification}` | New notification for user | | `unread.update` | `{channel_id, count, mentions}` | Unread count changed | | `ping` | `{}` | Keepalive (every 30s) | ### 3.4 Init Event Sent immediately after SSE connection established: ```json { "communities": [ {"id": "uuid", "name": "My Team", "slug": "my-team", "role": "owner"} ], "channels": { "community-uuid": [ {"id": "uuid", "name": "general", "type": "text", "category": "General", "unread_count": 3, "mention_count": 1} ] }, "dms": [ {"id": "uuid", "type": "dm", "members": [...], "unread_count": 0} ], "user": {"id": "uuid", "username": "alice", "display_name": "Alice"} } ``` ## 4. Client → Server Signals (HTTP POST) | Action | Endpoint | Payload | |--------|----------|---------| | Send message | `POST /tui/messages` | `{channel_id, body_md, parent_id?}` | | Edit message | `POST /tui/messages/:id/edit` | `{body_md}` | | Delete message | `POST /tui/messages/:id/delete` | — | | Add reaction | `POST /tui/reactions` | `{message_id, emoji}` | | Remove reaction | `POST /tui/reactions/remove` | `{message_id, emoji}` | | Switch channel | `POST /tui/navigate` | `{channel_id, community_id?}` | | Mark read | `POST /tui/read` | `{channel_id, message_id}` | | Typing | `POST /tui/typing` | `{channel_id}` | | Heartbeat | `POST /tui/heartbeat` | — | | Fetch messages | `GET /tui/messages` | `?channel_id=&before=&limit=50` | | Search | `GET /tui/search` | `?q=&type=` | | Slash command | `POST /tui/command` | `{command, channel_id, community_id}` | All responses are JSON. ## 5. Connection Tracking ```clojure {user-id {:sse-channel :active-community uuid-or-nil :active-channel uuid-or-nil :nats-subs [sub-handles...] :last-event-id int :connected-at instant}} ``` - On connect: send `init` event, subscribe to user's NATS subjects - On disconnect: unsubscribe from NATS, clean up state - On community switch: update NATS subscriptions - Keepalive pings every 30 seconds to detect dead connections ## 6. Presence Batching TUI SM buffers presence events to avoid flooding clients: - Collect heartbeat events over 60-second windows - On flush: diff with previous state, send only changes - Typing indicators sent immediately (latency-sensitive) ## 7. Service Configuration ### 7.1 Config Shape ```clojure {:server {:host "0.0.0.0" :port 3003} :api {:base-url "http://localhost:3001"} :nats {:url "nats://localhost:4222" :stream-name "ajet-events"} :session {:max-connections 5000 ;; max concurrent SSE connections :ping-interval-sec 30} :presence {:batch-interval-sec 60}} ``` ### 7.2 Startup / Shutdown Sequence **Startup:** ``` 1. Load config 2. Connect to NATS 3. Initialize connection tracker (atom) 4. Start http-kit server 5. Start ping scheduler (30s interval) 6. Log "TUI SM started on port {port}" ``` **Shutdown (graceful):** ``` 1. Stop accepting new SSE connections 2. Send close event to all connected TUI clients 3. Stop ping scheduler 4. Unsubscribe all NATS subscriptions 5. Close NATS connection 6. Stop http-kit server 7. Log "TUI SM stopped" ``` ### 7.3 Health Check | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/tui/health` | None | Service health | ```json {"status": "ok", "connections": 23, "checks": {"api": "ok", "nats": "ok"}} ``` ### 7.4 Error Handling | Scenario | Behavior | |----------|----------| | API unavailable | Client signals (POST) return 502 JSON error. SSE stays open. | | NATS unavailable | SSE stays open, no real-time events. Events resume when NATS reconnects. Status event sent to client. | | Client sends invalid JSON | Return 400 with error description | | Client sends to unauthorized channel | Return 403 | | SSE write fails (dead connection) | Clean up connection state, unsubscribe NATS | | Max connections reached | Return 503 `{"error": "max connections reached, retry later"}` | ### 7.5 Backpressure - If a client's SSE buffer exceeds 1000 events, disconnect the client (forces reconnect with replay) - Events are dropped (not queued) if write buffer is full — JetStream replay handles recovery - Connection tracking atom is bounded by `max-connections` config --- ## 8. Test Cases ### 8.1 SSE Connection | ID | Test | Description | |----|------|-------------| | TSM-T1 | SSE connect | GET /tui/sse/events returns text/event-stream | | TSM-T2 | Init event sent | First event is `init` with communities, channels, DMs, user | | TSM-T3 | NATS subscribed on connect | Server subscribes to user's community subjects | | TSM-T4 | Ping keepalive | Ping event sent every 30 seconds | | TSM-T5 | Disconnect cleanup | SSE disconnect unsubscribes from NATS | | TSM-T6 | Reconnect with last_event_id | Missed events replayed via JetStream | ### 8.2 Real-Time Events | ID | Test | Description | |----|------|-------------| | TSM-T7 | New message event | Message from NATS → SSE `message.new` event to client | | TSM-T8 | Message edit event | Edit from NATS → SSE `message.edit` event | | TSM-T9 | Message delete event | Delete from NATS → SSE `message.delete` event | | TSM-T10 | Reaction events | Add/remove from NATS → SSE reaction events | | TSM-T11 | Typing indicator | Typing from NATS → SSE `typing.start` event | | TSM-T12 | Typing auto-expire | `typing.stop` sent after 15 seconds of silence | | TSM-T13 | Presence update | Batched presence diffs sent every 60 seconds | | TSM-T14 | Channel events | New/update/delete channel events forwarded | | TSM-T15 | Notification event | User-targeted notification forwarded | | TSM-T16 | Unread count update | New message in other channel → unread count SSE event | ### 8.3 Client Signals | ID | Test | Description | |----|------|-------------| | TSM-T17 | Send message | POST /tui/messages → API call → success response | | TSM-T18 | Edit message | POST /tui/messages/:id/edit → API call → success | | TSM-T19 | Delete message | POST /tui/messages/:id/delete → API call → success | | TSM-T20 | Switch channel | POST /tui/navigate → NATS sub update → returns channel messages | | TSM-T21 | Fetch older messages | GET /tui/messages with before cursor → paginated results | | TSM-T22 | Search | GET /tui/search → API search → JSON results | | TSM-T23 | Slash command | POST /tui/command → API command → result | | TSM-T24 | Heartbeat | POST /tui/heartbeat → proxied to API | ### 8.4 Edge Cases | ID | Test | Description | |----|------|-------------| | TSM-T25 | Multiple TUI clients | Same user, two connections — both receive events | | TSM-T26 | API unavailable | Returns 502, SSE stays open | | TSM-T27 | NATS unavailable | SSE stays open, events resume when NATS recovers | | TSM-T28 | Event filtering | Events for non-subscribed channels not forwarded | | TSM-T29 | DM events | DM messages forwarded via `chat.dm.{channel-id}` subscription | | TSM-T30 | Large init payload | Init event with many channels/DMs serializes correctly |