|
| 1 | +# Implementation Plan: Consistent Message Output for channels subscribe & history |
| 2 | + |
| 3 | +## Original Request |
| 4 | + |
| 5 | +Currently when running `bin/run.js channels subscribe test`, the output is: |
| 6 | +``` |
| 7 | +Using: Account=Free account (DIBHRw) • App=Sandbox (jy3uew) • Key=Root (jy3uew.oZJBOA) |
| 8 | +
|
| 9 | +Attaching to channel: test... |
| 10 | +Successfully attached to channel: test |
| 11 | +✓ Subscribed to channel: test. |
| 12 | +Listening for messages. Press Ctrl+C to exit. |
| 13 | +[2026-03-06T05:13:09.160Z] Channel: test | Event: (none) |
| 14 | +Data: hello |
| 15 | +``` |
| 16 | + |
| 17 | +Fields shown are `Channel`, `Event`, and `Data`. |
| 18 | + |
| 19 | +When running `bin/run.js channels history test`, the output is: |
| 20 | +``` |
| 21 | +[1] 2026-03-06T05:19:36.130Z |
| 22 | +Event: (none) |
| 23 | +Client ID: ably-cli-d6d6be45 |
| 24 | +Data: |
| 25 | +hello |
| 26 | +``` |
| 27 | + |
| 28 | +The output is inconsistent between subscribe and history. We want to show exactly the same fields with the same format for both commands. The important fields are: `event`, `channel`, `id`, `clientId`, `data`, `timestamp`, and `serial`. All of these fields should be consistently available for both commands. |
| 29 | + |
| 30 | +## Problem |
| 31 | + |
| 32 | +The `channels subscribe` and `channels history` commands display messages in inconsistent formats: |
| 33 | + |
| 34 | +**Subscribe** (current): |
| 35 | +``` |
| 36 | +[2026-03-06T05:13:09.160Z] Channel: test | Event: (none) |
| 37 | +Data: hello |
| 38 | +``` |
| 39 | +- Shows Channel and Event on one line, Data on next line |
| 40 | +- Missing fields: `id`, `clientId`, `serial` |
| 41 | + |
| 42 | +**History** (current): |
| 43 | +``` |
| 44 | +[1] 2026-03-06T05:19:36.130Z |
| 45 | +Event: (none) |
| 46 | +Client ID: ably-cli-d6d6be45 |
| 47 | +Data: |
| 48 | +hello |
| 49 | +``` |
| 50 | +- Starts with arbitrary index `[1]` — not useful, not a message field |
| 51 | +- Timestamp on same line as index, not in `[brackets]` like subscribe |
| 52 | +- Missing fields: `channel`, `id`, `serial` |
| 53 | +- Data value is on a separate line from the `Data:` label (inconsistent with subscribe where simple data is inline) |
| 54 | + |
| 55 | +**Subscribe JSON** (current `--json`): |
| 56 | +```json |
| 57 | +{ |
| 58 | + "channel": "test", |
| 59 | + "clientId": "...", |
| 60 | + "connectionId": "...", |
| 61 | + "data": "hello", |
| 62 | + "encoding": "...", |
| 63 | + "event": "(none)", |
| 64 | + "id": "msg-123", |
| 65 | + "timestamp": "2026-03-06T05:13:09.160Z" |
| 66 | +} |
| 67 | +``` |
| 68 | +- Missing: `serial` |
| 69 | + |
| 70 | +**History JSON** (current `--json`): |
| 71 | +```json |
| 72 | +{ |
| 73 | + "messages": [ |
| 74 | + { |
| 75 | + "id": "msg-1", |
| 76 | + "name": "test-event", |
| 77 | + "data": { "text": "Hello world" }, |
| 78 | + "timestamp": 1700000000000, |
| 79 | + "clientId": "client-1", |
| 80 | + "connectionId": "conn-1" |
| 81 | + } |
| 82 | + ] |
| 83 | +} |
| 84 | +``` |
| 85 | +- Missing: `channel`, `serial` |
| 86 | +- Uses `name` instead of `event` |
| 87 | +- Timestamp is raw milliseconds, not ISO string |
| 88 | + |
| 89 | +## Target Format |
| 90 | + |
| 91 | +Both commands must display **exactly the same fields in the same format**. Required fields: |
| 92 | +`timestamp`, `channel`, `event`, `id`, `clientId`, `serial`, `data` |
| 93 | + |
| 94 | +### Human-readable output |
| 95 | + |
| 96 | +Each field on its own line, all at the same level. Coloring follows CLAUDE.md conventions: |
| 97 | +- **Secondary labels**: `chalk.dim("Label:")` — for all field names |
| 98 | +- **Resource names** (channel): `resource(name)` — cyan |
| 99 | +- **Event types**: `chalk.yellow(eventType)` |
| 100 | +- **Client IDs**: `chalk.blue(clientId)` |
| 101 | +- **Data**: For simple values, inline on same line as label. For JSON objects/arrays, label on its own line then formatted JSON below. |
| 102 | + |
| 103 | +Single message: |
| 104 | +``` |
| 105 | +Timestamp: 2026-03-06T05:13:09.160Z |
| 106 | +Channel: test |
| 107 | +Event: greeting |
| 108 | +ID: msg-123 |
| 109 | +Client ID: publisher-client |
| 110 | +Serial: 01826232064561-001@e]GBiqkIkBnR52:001 |
| 111 | +Data: hello world |
| 112 | +``` |
| 113 | + |
| 114 | +For JSON data: |
| 115 | +``` |
| 116 | +Timestamp: 2026-03-06T05:13:09.160Z |
| 117 | +Channel: test |
| 118 | +Event: greeting |
| 119 | +ID: msg-123 |
| 120 | +Client ID: publisher-client |
| 121 | +Serial: 01826232064561-001@e]GBiqkIkBnR52:001 |
| 122 | +Data: |
| 123 | +{ |
| 124 | + "text": "hello world" |
| 125 | +} |
| 126 | +``` |
| 127 | + |
| 128 | +Multiple messages (history or subscribe stream), separated by blank lines: |
| 129 | +``` |
| 130 | +Timestamp: 2026-03-06T05:13:09.160Z |
| 131 | +Channel: test |
| 132 | +Event: greeting |
| 133 | +ID: msg-123 |
| 134 | +Client ID: publisher-client |
| 135 | +Serial: 01826232064561-001@e]GBiqkIkBnR52:001 |
| 136 | +Data: hello world |
| 137 | +
|
| 138 | +Timestamp: 2026-03-06T05:13:10.200Z |
| 139 | +Channel: test |
| 140 | +Event: update |
| 141 | +ID: msg-124 |
| 142 | +Client ID: another-client |
| 143 | +Serial: 01826232064562-001@e]GBiqkIkBnR52:002 |
| 144 | +Data: |
| 145 | +{ |
| 146 | + "status": "active" |
| 147 | +} |
| 148 | +``` |
| 149 | + |
| 150 | +### JSON output (--json flag) |
| 151 | + |
| 152 | +Both commands must include all required fields with consistent naming and formatting. |
| 153 | + |
| 154 | +**Subscribe JSON** — emits one JSON object per message (streaming, one at a time): |
| 155 | +```json |
| 156 | +{ |
| 157 | + "timestamp": "2026-03-06T05:13:09.160Z", |
| 158 | + "channel": "test", |
| 159 | + "event": "greeting", |
| 160 | + "id": "msg-123", |
| 161 | + "clientId": "publisher-client", |
| 162 | + "serial": "01826232064561-001@e]GBiqkIkBnR52:001", |
| 163 | + "data": "hello world" |
| 164 | +} |
| 165 | +``` |
| 166 | + |
| 167 | +Changes from current: |
| 168 | +- Add `serial` field (from `message.serial`) |
| 169 | +- Remove `connectionId` and `encoding` (not in required fields) |
| 170 | + |
| 171 | +**History JSON** — emits an array of message objects: |
| 172 | +```json |
| 173 | +[ |
| 174 | + { |
| 175 | + "timestamp": "2023-11-14T22:13:20.000Z", |
| 176 | + "channel": "test", |
| 177 | + "event": "test-event", |
| 178 | + "id": "msg-1", |
| 179 | + "clientId": "client-1", |
| 180 | + "serial": "01826232064561-001@e]GBiqkIkBnR52:001", |
| 181 | + "data": { "text": "Hello world" } |
| 182 | + }, |
| 183 | + { |
| 184 | + "timestamp": "2023-11-14T22:13:21.000Z", |
| 185 | + "channel": "test", |
| 186 | + "event": "another-event", |
| 187 | + "id": "msg-2", |
| 188 | + "clientId": "client-2", |
| 189 | + "serial": "01826232064562-001@e]GBiqkIkBnR52:002", |
| 190 | + "data": "Plain text message" |
| 191 | + } |
| 192 | +] |
| 193 | +``` |
| 194 | + |
| 195 | +Changes from current: |
| 196 | +- Output a plain JSON array instead of `{ messages: [...] }` wrapper |
| 197 | +- Add `channel` field (from args) |
| 198 | +- Add `serial` field (from `message.serial`) |
| 199 | +- Rename `name` → `event` for consistency |
| 200 | +- Convert `timestamp` from raw milliseconds to ISO 8601 string |
| 201 | +- Remove `connectionId` (not in required fields) |
| 202 | + |
| 203 | +### Design decisions |
| 204 | + |
| 205 | +- **Each field on its own line** — consistent, scannable, easy to grep |
| 206 | +- **Timestamp is a regular field** — `Timestamp:` label like all others, not a special `[brackets]` header |
| 207 | +- **No indentation** — all fields at the same level, no nesting |
| 208 | +- **Field order**: Timestamp → Channel → Event → ID → Client ID → Serial → Data |
| 209 | +- **Missing values**: `(none)` for missing event name. Omit fields entirely if not available (e.g. if `clientId` is undefined, don't show the Client ID line). Exception: Event always shown, using `(none)` as fallback. |
| 210 | +- **No index numbers**: History currently shows `[1]`, `[2]` — remove. Timestamp + serial provide ordering. |
| 211 | +- **Coloring**: Applied per CLAUDE.md conventions listed above. No unnecessary coloring on plain values (id, serial, data strings). |
| 212 | +- **Blank line separator**: Between messages in multi-message output. |
| 213 | +- **JSON consistency**: Both commands use the same field names (`event` not `name`), same timestamp format (ISO 8601), same field set. |
| 214 | +- **History JSON is a plain array**: No `{ messages: [...] }` wrapper — just the array directly, which is simpler and more consistent with subscribe's per-message objects. |
| 215 | + |
| 216 | +## Implementation Steps |
| 217 | + |
| 218 | +### 1. Add `formatMessagesOutput` helper to `src/utils/output.ts` |
| 219 | + |
| 220 | +Create a single shared function that accepts an array of messages: |
| 221 | + |
| 222 | +```typescript |
| 223 | +export interface MessageDisplayFields { |
| 224 | + channel: string; |
| 225 | + clientId?: string; |
| 226 | + data: unknown; |
| 227 | + event: string; |
| 228 | + id?: string; |
| 229 | + serial?: string; |
| 230 | + sequencePrefix?: string; |
| 231 | + timestamp: string; |
| 232 | +} |
| 233 | + |
| 234 | +/** |
| 235 | + * Format an array of messages for human-readable console output. |
| 236 | + * Each message shows all fields on separate lines, messages separated by blank lines. |
| 237 | + * Returns "No messages found." for empty arrays. |
| 238 | + */ |
| 239 | +export function formatMessagesOutput(messages: MessageDisplayFields[]): string |
| 240 | +``` |
| 241 | + |
| 242 | +The function: |
| 243 | +- Returns `"No messages found."` for empty arrays |
| 244 | +- For each message, builds lines: |
| 245 | + - `${chalk.dim("Timestamp:")} ${timestamp}` + optional sequencePrefix |
| 246 | + - `${chalk.dim("Channel:")} ${resource(channel)}` |
| 247 | + - `${chalk.dim("Event:")} ${chalk.yellow(event)}` |
| 248 | + - (if id) `${chalk.dim("ID:")} ${id}` |
| 249 | + - (if clientId) `${chalk.dim("Client ID:")} ${chalk.blue(clientId)}` |
| 250 | + - (if serial) `${chalk.dim("Serial:")} ${serial}` |
| 251 | + - Data: `${chalk.dim("Data:")} ${value}` for simple, or `${chalk.dim("Data:")}` + formatted JSON block on next lines |
| 252 | +- Joins messages with `\n\n` (blank line separator) |
| 253 | +
|
| 254 | +### 2. Add `toMessageJson` helper to `src/utils/output.ts` |
| 255 | +
|
| 256 | +Create a single helper that normalizes one message into the consistent JSON shape. For arrays, callers simply use `.map(toMessageJson)`: |
| 257 | +
|
| 258 | +```typescript |
| 259 | +/** |
| 260 | + * Convert a single MessageDisplayFields to a plain object for JSON output. |
| 261 | + * Includes all required fields, omits undefined optional fields. |
| 262 | + * |
| 263 | + * Usage: |
| 264 | + * Single message (subscribe): toMessageJson(msg) |
| 265 | + * Array of messages (history): messages.map(toMessageJson) |
| 266 | + */ |
| 267 | +export function toMessageJson(msg: MessageDisplayFields): Record<string, unknown> |
| 268 | +``` |
| 269 | + |
| 270 | +Returns: |
| 271 | +```typescript |
| 272 | +{ |
| 273 | + timestamp: msg.timestamp, |
| 274 | + channel: msg.channel, |
| 275 | + event: msg.event, |
| 276 | + ...(msg.id ? { id: msg.id } : {}), |
| 277 | + ...(msg.clientId ? { clientId: msg.clientId } : {}), |
| 278 | + ...(msg.serial ? { serial: msg.serial } : {}), |
| 279 | + data: msg.data, |
| 280 | +} |
| 281 | +``` |
| 282 | + |
| 283 | +### 3. Update `src/commands/channels/subscribe.ts` |
| 284 | + |
| 285 | +- Import `formatMessagesOutput`, `toMessageJson` |
| 286 | +- Remove imports of `formatJson`, `isJsonData`, `formatTimestamp` |
| 287 | +- Add `serial` to the message fields (from `message.serial`) |
| 288 | +- Build a `MessageDisplayFields` object from the Ably message |
| 289 | +- Human output: `this.log(formatMessagesOutput([msgFields]))` |
| 290 | +- JSON output: `this.log(this.formatJsonOutput(toMessageJson(msgFields), flags))` |
| 291 | +- Remove `connectionId` and `encoding` from JSON output |
| 292 | + |
| 293 | +### 4. Update `src/commands/channels/history.ts` |
| 294 | + |
| 295 | +- Import `formatMessagesOutput`, `toMessageJson` from output utils |
| 296 | +- Build a `MessageDisplayFields[]` array from history results: |
| 297 | + - `channel` from `args.channel` |
| 298 | + - `event` from `message.name || "(none)"` |
| 299 | + - `serial` from `message.serial` |
| 300 | + - `timestamp` as ISO string (convert from milliseconds) |
| 301 | +- Human output: `this.log(formatMessagesOutput(displayMessages))` |
| 302 | + - The "No messages found" case is handled by `formatMessagesOutput` returning the appropriate string |
| 303 | + - Remove the `[index]` prefix, the for-loop, and the "Found N messages" header |
| 304 | +- JSON output: `this.log(this.formatJsonOutput(displayMessages.map(toMessageJson), flags))` |
| 305 | + - Output a plain array instead of `{ messages: [...] }` wrapper |
| 306 | + |
| 307 | +### 5. Update `.claude/CLAUDE.md` |
| 308 | + |
| 309 | +Add a "Message display" subsection under "CLI Output & Flag Conventions": |
| 310 | + |
| 311 | +```markdown |
| 312 | +### Message display (channels subscribe, channels history, etc.) |
| 313 | +- Use `formatMessagesOutput()` from `src/utils/output.ts` for all message rendering |
| 314 | +- Use `toMessageJson()` for consistent JSON output shape; for arrays use `.map(toMessageJson)` |
| 315 | +- Each field on its own line, no indentation — all fields at the same level |
| 316 | +- Field order: Timestamp → Channel → Event → ID → Client ID → Serial → Data |
| 317 | +- Omit optional fields (ID, Client ID, Serial) if the value is undefined/null |
| 318 | +- Event always shown; use `(none)` when message has no event name |
| 319 | +- Data: inline for simple values, block for JSON objects/arrays |
| 320 | +- Multiple messages separated by blank lines |
| 321 | +- JSON output uses consistent field names (`event` not `name`), ISO 8601 timestamps |
| 322 | +``` |
| 323 | + |
| 324 | +### 6. Update tests |
| 325 | + |
| 326 | +- `test/unit/commands/channels/subscribe.test.ts`: |
| 327 | + - Update assertion for "Event: test-event" (still present) |
| 328 | + - Add checks for new fields: "Timestamp:", "Channel:", "ID:", "Client ID:", "Serial:" |
| 329 | + - Update JSON test if needed (serial field added, connectionId/encoding removed) |
| 330 | + |
| 331 | +- `test/unit/commands/channels/history.test.ts`: |
| 332 | + - Remove assertion for `[1]` index format |
| 333 | + - Update to match new field layout: "Timestamp:", "Channel:", "Event:", "ID:", "Serial:" |
| 334 | + - Update JSON test: now expects a plain array instead of `{ messages: [...] }`, with `event` instead of `name`, ISO timestamp instead of milliseconds |
| 335 | + |
| 336 | +### 7. Verify E2E compatibility |
| 337 | + |
| 338 | +The E2E test (`channel-subscribe-e2e.test.ts`) only checks `output.includes("Subscribe E2E Test")` — unaffected by format changes. |
| 339 | + |
| 340 | +## Files Changed |
| 341 | + |
| 342 | +1. `src/utils/output.ts` — Add `formatMessagesOutput`, `toMessageJson`, and `MessageDisplayFields` |
| 343 | +2. `src/commands/channels/subscribe.ts` — Use `formatMessagesOutput([msg])` + `toMessageJson(msg)`, add serial |
| 344 | +3. `src/commands/channels/history.ts` — Use `formatMessagesOutput(messages)` + `messages.map(toMessageJson)`, add channel/serial, remove index, plain array JSON |
| 345 | +4. `.claude/CLAUDE.md` — Document message display conventions |
| 346 | +5. `test/unit/commands/channels/subscribe.test.ts` — Update assertions for new format |
| 347 | +6. `test/unit/commands/channels/history.test.ts` — Update assertions for new format + JSON structure |
0 commit comments