@ably/ai-transport 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +426 -0
  3. package/dist/ably-ai-transport.js +1388 -0
  4. package/dist/ably-ai-transport.js.map +1 -0
  5. package/dist/ably-ai-transport.umd.cjs +2 -0
  6. package/dist/ably-ai-transport.umd.cjs.map +1 -0
  7. package/dist/constants.d.ts +50 -0
  8. package/dist/core/codec/decoder.d.ts +62 -0
  9. package/dist/core/codec/encoder.d.ts +56 -0
  10. package/dist/core/codec/index.d.ts +8 -0
  11. package/dist/core/codec/lifecycle-tracker.d.ts +74 -0
  12. package/dist/core/codec/types.d.ts +188 -0
  13. package/dist/core/transport/client-transport.d.ts +10 -0
  14. package/dist/core/transport/conversation-tree.d.ts +9 -0
  15. package/dist/core/transport/decode-history.d.ts +41 -0
  16. package/dist/core/transport/headers.d.ts +26 -0
  17. package/dist/core/transport/index.d.ts +4 -0
  18. package/dist/core/transport/pipe-stream.d.ts +16 -0
  19. package/dist/core/transport/server-transport.d.ts +7 -0
  20. package/dist/core/transport/stream-router.d.ts +19 -0
  21. package/dist/core/transport/turn-manager.d.ts +34 -0
  22. package/dist/core/transport/types.d.ts +407 -0
  23. package/dist/errors.d.ts +46 -0
  24. package/dist/event-emitter.d.ts +65 -0
  25. package/dist/index.d.ts +11 -0
  26. package/dist/logger.d.ts +103 -0
  27. package/dist/react/ably-ai-transport-react.js +823 -0
  28. package/dist/react/ably-ai-transport-react.js.map +1 -0
  29. package/dist/react/ably-ai-transport-react.umd.cjs +2 -0
  30. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -0
  31. package/dist/react/index.d.ts +11 -0
  32. package/dist/react/use-ably-messages.d.ts +18 -0
  33. package/dist/react/use-active-turns.d.ts +8 -0
  34. package/dist/react/use-client-transport.d.ts +7 -0
  35. package/dist/react/use-conversation-tree.d.ts +20 -0
  36. package/dist/react/use-edit.d.ts +7 -0
  37. package/dist/react/use-history.d.ts +19 -0
  38. package/dist/react/use-messages.d.ts +7 -0
  39. package/dist/react/use-regenerate.d.ts +7 -0
  40. package/dist/react/use-send.d.ts +7 -0
  41. package/dist/utils.d.ts +127 -0
  42. package/dist/vercel/ably-ai-transport-vercel.js +2331 -0
  43. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -0
  44. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +2 -0
  45. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -0
  46. package/dist/vercel/codec/accumulator.d.ts +21 -0
  47. package/dist/vercel/codec/decoder.d.ts +22 -0
  48. package/dist/vercel/codec/encoder.d.ts +41 -0
  49. package/dist/vercel/codec/index.d.ts +22 -0
  50. package/dist/vercel/index.d.ts +3 -0
  51. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2082 -0
  52. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -0
  53. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +2 -0
  54. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -0
  55. package/dist/vercel/react/index.d.ts +3 -0
  56. package/dist/vercel/react/use-chat-transport.d.ts +29 -0
  57. package/dist/vercel/react/use-message-sync.d.ts +19 -0
  58. package/dist/vercel/transport/chat-transport.d.ts +118 -0
  59. package/dist/vercel/transport/index.d.ts +36 -0
  60. package/package.json +123 -0
  61. package/react/README.md +3 -0
  62. package/react/index.d.ts +1 -0
  63. package/react/index.js +1 -0
  64. package/react/index.umd.cjs +1 -0
  65. package/src/constants.ts +98 -0
  66. package/src/core/codec/decoder.ts +402 -0
  67. package/src/core/codec/encoder.ts +470 -0
  68. package/src/core/codec/index.ts +28 -0
  69. package/src/core/codec/lifecycle-tracker.ts +140 -0
  70. package/src/core/codec/types.ts +249 -0
  71. package/src/core/transport/client-transport.ts +959 -0
  72. package/src/core/transport/conversation-tree.ts +434 -0
  73. package/src/core/transport/decode-history.ts +337 -0
  74. package/src/core/transport/headers.ts +46 -0
  75. package/src/core/transport/index.ts +34 -0
  76. package/src/core/transport/pipe-stream.ts +95 -0
  77. package/src/core/transport/server-transport.ts +458 -0
  78. package/src/core/transport/stream-router.ts +118 -0
  79. package/src/core/transport/turn-manager.ts +147 -0
  80. package/src/core/transport/types.ts +533 -0
  81. package/src/errors.ts +58 -0
  82. package/src/event-emitter.ts +103 -0
  83. package/src/index.ts +89 -0
  84. package/src/logger.ts +241 -0
  85. package/src/react/index.ts +11 -0
  86. package/src/react/use-ably-messages.ts +37 -0
  87. package/src/react/use-active-turns.ts +61 -0
  88. package/src/react/use-client-transport.ts +37 -0
  89. package/src/react/use-conversation-tree.ts +71 -0
  90. package/src/react/use-edit.ts +24 -0
  91. package/src/react/use-history.ts +111 -0
  92. package/src/react/use-messages.ts +32 -0
  93. package/src/react/use-regenerate.ts +24 -0
  94. package/src/react/use-send.ts +25 -0
  95. package/src/react/vite.config.ts +32 -0
  96. package/src/tsconfig.json +25 -0
  97. package/src/utils.ts +230 -0
  98. package/src/vercel/codec/accumulator.ts +603 -0
  99. package/src/vercel/codec/decoder.ts +615 -0
  100. package/src/vercel/codec/encoder.ts +396 -0
  101. package/src/vercel/codec/index.ts +37 -0
  102. package/src/vercel/index.ts +12 -0
  103. package/src/vercel/react/index.ts +4 -0
  104. package/src/vercel/react/use-chat-transport.ts +60 -0
  105. package/src/vercel/react/use-message-sync.ts +34 -0
  106. package/src/vercel/react/vite.config.ts +33 -0
  107. package/src/vercel/transport/chat-transport.ts +278 -0
  108. package/src/vercel/transport/index.ts +56 -0
  109. package/src/vercel/vite.config.ts +33 -0
  110. package/src/vite.config.ts +31 -0
  111. package/vercel/README.md +3 -0
  112. package/vercel/index.d.ts +1 -0
  113. package/vercel/index.js +1 -0
  114. package/vercel/index.umd.cjs +1 -0
  115. package/vercel/react/README.md +3 -0
  116. package/vercel/react/index.d.ts +1 -0
  117. package/vercel/react/index.js +1 -0
  118. package/vercel/react/index.umd.cjs +1 -0
@@ -0,0 +1,959 @@
1
+ /**
2
+ * Core client-side transport, parameterized by codec.
3
+ *
4
+ * Composes StreamRouter and ConversationTree to handle the full client-side
5
+ * lifecycle. Subscribes to the Ably channel on construction. The same
6
+ * subscription, decoder, and channel are reused across turns.
7
+ *
8
+ * The client never publishes user messages directly. Instead, it sends them
9
+ * to the server via HTTP POST. The server publishes user messages and turn
10
+ * lifecycle events (turn-start, turn-end) on behalf of the client.
11
+ */
12
+
13
+ import * as Ably from 'ably';
14
+
15
+ import {
16
+ EVENT_CANCEL,
17
+ EVENT_TURN_END,
18
+ EVENT_TURN_START,
19
+ HEADER_CANCEL_ALL,
20
+ HEADER_CANCEL_CLIENT_ID,
21
+ HEADER_CANCEL_OWN,
22
+ HEADER_CANCEL_TURN_ID,
23
+ HEADER_MSG_ID,
24
+ HEADER_PARENT,
25
+ HEADER_ROLE,
26
+ HEADER_TURN_CLIENT_ID,
27
+ HEADER_TURN_ID,
28
+ HEADER_TURN_REASON,
29
+ } from '../../constants.js';
30
+ import { ErrorCode } from '../../errors.js';
31
+ import { EventEmitter } from '../../event-emitter.js';
32
+ import type { Logger } from '../../logger.js';
33
+ import { LogLevel, makeLogger } from '../../logger.js';
34
+ import { getHeaders } from '../../utils.js';
35
+ import type { DecoderOutput, MessageAccumulator, StreamDecoder } from '../codec/types.js';
36
+ import { createConversationTree } from './conversation-tree.js';
37
+ import { decodeHistory } from './decode-history.js';
38
+ import { buildTransportHeaders } from './headers.js';
39
+ import type { StreamRouter } from './stream-router.js';
40
+ import { createStreamRouter } from './stream-router.js';
41
+ import type {
42
+ ActiveTurn,
43
+ CancelFilter,
44
+ ClientTransport,
45
+ ClientTransportOptions,
46
+ CloseOptions,
47
+ ConversationTree,
48
+ LoadHistoryOptions,
49
+ MessageWithHeaders,
50
+ PaginatedMessages,
51
+ SendOptions,
52
+ TurnEndReason,
53
+ TurnLifecycleEvent,
54
+ } from './types.js';
55
+
56
+ /**
57
+ * Returned from `on()` when the transport is already closed — the subscription
58
+ * is silently ignored since no further events will fire.
59
+ */
60
+ // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentional no-op
61
+ const noopUnsubscribe = (): void => {};
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Event map for the transport's typed EventEmitter
65
+ // ---------------------------------------------------------------------------
66
+
67
+ interface ClientTransportEventsMap {
68
+ message: undefined;
69
+ turn: TurnLifecycleEvent;
70
+ error: Ably.ErrorInfo;
71
+ 'ably-message': undefined;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Per-turn observer state — consolidated to avoid parallel-map bookkeeping
76
+ // ---------------------------------------------------------------------------
77
+
78
+ interface TurnObserverState<TEvent, TMessage> {
79
+ headers: Record<string, string>;
80
+ serial: string | undefined;
81
+ accumulator: MessageAccumulator<TEvent, TMessage>;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Implementation
86
+ // ---------------------------------------------------------------------------
87
+
88
+ // Spec: AIT-CT1
89
+ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent, TMessage> {
90
+ private readonly _channel: Ably.RealtimeChannel;
91
+ private readonly _codec: ClientTransportOptions<TEvent, TMessage>['codec'];
92
+ private readonly _clientId: string | undefined;
93
+ private readonly _api: string;
94
+ private readonly _credentials: RequestCredentials | undefined;
95
+ private readonly _headersFn: (() => Record<string, string>) | undefined;
96
+ private readonly _bodyFn: (() => Record<string, unknown>) | undefined;
97
+ private readonly _fetchFn: typeof globalThis.fetch;
98
+ private readonly _logger: Logger;
99
+
100
+ // Typed event emitter for all transport events
101
+ private readonly _emitter: EventEmitter<ClientTransportEventsMap>;
102
+
103
+ // Relay detection — tracks msg-ids of optimistic inserts for reconciliation
104
+ private readonly _ownMsgIds = new Set<string>();
105
+ private readonly _ownTurnIds = new Set<string>();
106
+
107
+ // Track clientId per turn for getActiveTurnIds()
108
+ private readonly _turnClientIds = new Map<string, string>();
109
+ // Track msgIds per turn for cleanup on turn-end
110
+ private readonly _turnMsgIds = new Map<string, Set<string>>();
111
+
112
+ // Per-turn observer state: headers, serial, and accumulator in one map.
113
+ // A single .delete(turnId) cleans up all three.
114
+ private readonly _turnObservers = new Map<string, TurnObserverState<TEvent, TMessage>>();
115
+
116
+ // Raw Ably message log
117
+ private readonly _ablyMessages: Ably.InboundMessage[] = [];
118
+
119
+ // History pagination: withheld messages hidden from getMessages()
120
+ private readonly _withheldKeys = new Set<string>();
121
+
122
+ // Sub-components
123
+ private readonly _tree: ConversationTree<TMessage>;
124
+ private readonly _router: StreamRouter<TEvent>;
125
+ private readonly _decoder: StreamDecoder<TEvent, TMessage>;
126
+
127
+ // Channel subscription — subscribe() returns a Promise that resolves when the channel attaches
128
+ private readonly _attachPromise: Promise<unknown>;
129
+ private readonly _onMessage: (msg: Ably.InboundMessage) => void;
130
+
131
+ private _closed = false;
132
+
133
+ constructor(options: ClientTransportOptions<TEvent, TMessage>) {
134
+ this._channel = options.channel;
135
+ this._codec = options.codec;
136
+ this._clientId = options.clientId;
137
+ this._api = options.api ?? '/api/chat';
138
+ this._credentials = options.credentials;
139
+ this._headersFn =
140
+ typeof options.headers === 'function'
141
+ ? options.headers
142
+ : options.headers
143
+ ? () => options.headers as Record<string, string>
144
+ : undefined;
145
+ this._bodyFn =
146
+ typeof options.body === 'function'
147
+ ? options.body
148
+ : options.body
149
+ ? () => options.body as Record<string, unknown>
150
+ : undefined;
151
+ this._fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
152
+ this._logger = (options.logger ?? makeLogger({ logLevel: LogLevel.Silent })).withContext({
153
+ component: 'ClientTransport',
154
+ });
155
+
156
+ this._emitter = new EventEmitter<ClientTransportEventsMap>(this._logger);
157
+
158
+ // Compose sub-components
159
+ this._tree = createConversationTree<TMessage>(this._codec.getMessageKey.bind(this._codec), this._logger);
160
+ this._router = createStreamRouter<TEvent>(this._codec.isTerminal.bind(this._codec), this._logger);
161
+ this._decoder = this._codec.createDecoder();
162
+
163
+ // Seed tree with initial messages
164
+ if (options.messages) {
165
+ let prevMsgId: string | undefined;
166
+ for (const msg of options.messages) {
167
+ const msgId = this._codec.getMessageKey(msg);
168
+ const seedHeaders: Record<string, string> = {};
169
+ if (prevMsgId) seedHeaders[HEADER_PARENT] = prevMsgId;
170
+ this._tree.upsert(msgId, msg, seedHeaders);
171
+ prevMsgId = msgId;
172
+ }
173
+ this._emitter.emit('message');
174
+ }
175
+
176
+ // Spec: AIT-CT2
177
+ // Subscribe before attach (RTL7g)
178
+ this._onMessage = (ablyMessage: Ably.InboundMessage) => {
179
+ this._handleMessage(ablyMessage);
180
+ };
181
+ this._attachPromise = this._channel.subscribe(this._onMessage);
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Message subscription handler
186
+ // ---------------------------------------------------------------------------
187
+
188
+ private _handleMessage(ablyMessage: Ably.InboundMessage): void {
189
+ if (this._closed) return;
190
+
191
+ this._ablyMessages.push(ablyMessage);
192
+ this._emitter.emit('ably-message');
193
+
194
+ try {
195
+ // Spec: AIT-CT16a
196
+ // --- Turn lifecycle events from the server ---
197
+ if (ablyMessage.name === EVENT_TURN_START) {
198
+ const headers = getHeaders(ablyMessage);
199
+ const turnId = headers[HEADER_TURN_ID];
200
+ const turnCid = headers[HEADER_TURN_CLIENT_ID] ?? '';
201
+ if (turnId) {
202
+ this._turnClientIds.set(turnId, turnCid);
203
+ this._emitter.emit('turn', { type: EVENT_TURN_START, turnId, clientId: turnCid });
204
+ }
205
+ return;
206
+ }
207
+
208
+ if (ablyMessage.name === EVENT_TURN_END) {
209
+ const headers = getHeaders(ablyMessage);
210
+ const turnId = headers[HEADER_TURN_ID];
211
+ const turnCid = headers[HEADER_TURN_CLIENT_ID] ?? '';
212
+ // CAST: server always writes a valid TurnEndReason; default to 'complete' for robustness
213
+ const reason = (headers[HEADER_TURN_REASON] ?? 'complete') as TurnEndReason;
214
+ if (turnId) {
215
+ this._router.closeStream(turnId);
216
+ this._turnObservers.delete(turnId);
217
+ this._turnClientIds.delete(turnId);
218
+ // Clean up per-turn relay-detection state
219
+ const msgIds = this._turnMsgIds.get(turnId);
220
+ if (msgIds) {
221
+ for (const mid of msgIds) this._ownMsgIds.delete(mid);
222
+ this._turnMsgIds.delete(turnId);
223
+ }
224
+ this._ownTurnIds.delete(turnId);
225
+ this._emitter.emit('turn', { type: EVENT_TURN_END, turnId, clientId: turnCid, reason });
226
+ }
227
+ return;
228
+ }
229
+
230
+ // --- Codec-decoded messages ---
231
+ const outputs = this._decoder.decode(ablyMessage);
232
+ const headers = getHeaders(ablyMessage);
233
+ const serial = ablyMessage.serial;
234
+
235
+ // Always update observer headers, even when the decoder produces no outputs.
236
+ // This ensures header transitions (e.g. x-ably-status: streaming → aborted)
237
+ // are captured for events that the decoder suppresses (AIT-CD8: aborted
238
+ // stream appends emit no events but still carry the updated status header).
239
+ const turnId = headers[HEADER_TURN_ID];
240
+ if (turnId) {
241
+ this._updateTurnObserverHeaders(turnId, headers, serial);
242
+ }
243
+
244
+ for (const output of outputs) {
245
+ if (output.kind === 'message') {
246
+ this._handleMessageOutput(output.message, headers, serial, ablyMessage.action);
247
+ } else {
248
+ this._handleEventOutput(output, headers);
249
+ }
250
+ }
251
+ } catch (error) {
252
+ const cause = error instanceof Ably.ErrorInfo ? error : undefined;
253
+ this._emitter.emit(
254
+ 'error',
255
+ new Ably.ErrorInfo(
256
+ `unable to process channel message; ${error instanceof Error ? error.message : String(error)}`,
257
+ ErrorCode.TransportSubscriptionError,
258
+ 500,
259
+ cause,
260
+ ),
261
+ );
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Handle a decoded domain message (user message create or relayed own message).
267
+ * @param message - The decoded domain message.
268
+ * @param headers - Ably headers from the wire message.
269
+ * @param serial - Ably serial for tree ordering.
270
+ * @param action - Ably message action (e.g. 'message.create').
271
+ */
272
+ private _handleMessageOutput(
273
+ message: TMessage,
274
+ headers: Record<string, string>,
275
+ serial: string | undefined,
276
+ action: string | undefined,
277
+ ): void {
278
+ // Spec: AIT-CT15
279
+ const msgId = headers[HEADER_MSG_ID];
280
+ if (msgId && this._ownMsgIds.has(msgId)) {
281
+ // Relayed own message — reconcile optimistic entry with server-assigned fields
282
+ this._upsertAndNotify(message, headers, serial);
283
+ return;
284
+ }
285
+
286
+ if (action === 'message.create') {
287
+ this._upsertAndNotify(message, headers, serial);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Handle a decoded streaming event: route to own-turn stream or accumulate for observer.
293
+ * @param output - The decoded event output from the codec.
294
+ * @param headers - Ably headers from the wire message.
295
+ */
296
+ private _handleEventOutput(output: DecoderOutput<TEvent, TMessage>, headers: Record<string, string>): void {
297
+ if (output.kind !== 'event') return;
298
+ const event = output.event;
299
+ const turnId = headers[HEADER_TURN_ID];
300
+ if (!turnId) return;
301
+
302
+ // Observer headers are already updated in _handleMessage (before outputs
303
+ // are iterated) so that header transitions are captured even when the
304
+ // decoder produces no outputs (e.g. aborted stream appends per AIT-CD8).
305
+
306
+ // Active own turn — route to the ReadableStream
307
+ if (this._router.route(turnId, event)) {
308
+ this._accumulateAndEmit(turnId, output);
309
+ if (this._codec.isTerminal(event)) this._turnObservers.delete(turnId);
310
+ return;
311
+ }
312
+
313
+ // Completed own turn — late arrival, skip
314
+ if (this._ownTurnIds.has(turnId) && !this._turnObservers.has(turnId)) return;
315
+
316
+ // Spec: AIT-CT16
317
+ // Observer turn — accumulate and emit
318
+ this._accumulateAndEmit(turnId, output);
319
+ if (this._codec.isTerminal(event)) this._turnObservers.delete(turnId);
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Tree mutation + notification helpers
324
+ // ---------------------------------------------------------------------------
325
+
326
+ /**
327
+ * Upsert a message into the tree and notify subscribers.
328
+ * @param message - The domain message to insert or update.
329
+ * @param headers - Ably headers for the message.
330
+ * @param serial - Ably serial for tree ordering.
331
+ */
332
+ private _upsertAndNotify(message: TMessage, headers: Record<string, string>, serial?: string): void {
333
+ const key = this._codec.getMessageKey(message);
334
+ const msgId = headers[HEADER_MSG_ID] ?? key;
335
+ this._tree.upsert(msgId, message, headers, serial);
336
+ this._emitter.emit('message');
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Observer accumulation
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /**
344
+ * Ensure a TurnObserverState exists for turnId, updating headers and serial as new events arrive.
345
+ * @param turnId - The turn to track.
346
+ * @param headers - Headers from the current event.
347
+ * @param serial - Ably serial from the current event.
348
+ */
349
+ private _updateTurnObserverHeaders(
350
+ turnId: string,
351
+ headers: Record<string, string>,
352
+ serial: string | undefined,
353
+ ): void {
354
+ const existing = this._turnObservers.get(turnId);
355
+ if (existing) {
356
+ if (Object.keys(headers).length > 0) {
357
+ Object.assign(existing.headers, headers);
358
+ }
359
+ // Always advance the serial so the tree node sorts after all
360
+ // earlier messages in the turn (e.g. user-message relays that
361
+ // arrive before the assistant response).
362
+ if (serial !== undefined) {
363
+ existing.serial = serial;
364
+ }
365
+ } else {
366
+ this._turnObservers.set(turnId, {
367
+ headers: { ...headers },
368
+ serial,
369
+ accumulator: this._codec.createAccumulator(),
370
+ });
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Process a streaming event through the turn's accumulator and emit the latest message.
376
+ * @param turnId - The turn this event belongs to.
377
+ * @param output - The decoded event output to accumulate.
378
+ */
379
+ private _accumulateAndEmit(turnId: string, output: DecoderOutput<TEvent, TMessage>): void {
380
+ const observer = this._turnObservers.get(turnId);
381
+ if (!observer) return;
382
+
383
+ observer.accumulator.processOutputs([output]);
384
+
385
+ const messages = observer.accumulator.messages;
386
+ if (messages.length === 0) return;
387
+
388
+ let message: TMessage | undefined;
389
+ try {
390
+ message = structuredClone(messages.at(-1));
391
+ } catch {
392
+ // CAST: structuredClone can fail if the message contains non-cloneable
393
+ // values (e.g. functions). Fall back to the reference — the tree upsert
394
+ // below copies headers independently, so shared message state is the
395
+ // only risk. Accumulator messages are replaced on each event, so
396
+ // mutation between events is not a practical concern.
397
+ message = messages.at(-1);
398
+ }
399
+
400
+ if (message) {
401
+ this._tree.upsert(
402
+ observer.headers[HEADER_MSG_ID] ?? this._codec.getMessageKey(message),
403
+ message,
404
+ { ...observer.headers },
405
+ observer.serial,
406
+ );
407
+ this._emitter.emit('message');
408
+ }
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Cancel helpers
413
+ // ---------------------------------------------------------------------------
414
+
415
+ private async _publishCancel(filter: CancelFilter): Promise<void> {
416
+ this._logger.trace('ClientTransport._publishCancel();', { filter });
417
+
418
+ const headers: Record<string, string> = {};
419
+ if (filter.turnId) {
420
+ headers[HEADER_CANCEL_TURN_ID] = filter.turnId;
421
+ } else if (filter.own) {
422
+ headers[HEADER_CANCEL_OWN] = 'true';
423
+ } else if (filter.clientId) {
424
+ headers[HEADER_CANCEL_CLIENT_ID] = filter.clientId;
425
+ } else if (filter.all) {
426
+ headers[HEADER_CANCEL_ALL] = 'true';
427
+ }
428
+
429
+ await this._channel.publish({
430
+ name: EVENT_CANCEL,
431
+ extras: { headers },
432
+ });
433
+ }
434
+
435
+ private _closeMatchingTurnStreams(filter: CancelFilter): void {
436
+ // Only close the router streams here — do NOT clear _turnObservers.
437
+ // The observer must remain alive so that late server events (e.g. abort,
438
+ // x-ably-status: aborted) arriving before turn-end are still accumulated
439
+ // into the message store. The turn-end handler cleans up observers.
440
+ if (filter.all) {
441
+ for (const turnId of this._ownTurnIds) {
442
+ this._router.closeStream(turnId);
443
+ }
444
+ } else if (filter.own) {
445
+ for (const tid of this._ownTurnIds) {
446
+ this._router.closeStream(tid);
447
+ }
448
+ } else if (filter.clientId) {
449
+ for (const [tid, cid] of this._turnClientIds) {
450
+ if (cid === filter.clientId) {
451
+ this._router.closeStream(tid);
452
+ }
453
+ }
454
+ } else if (filter.turnId) {
455
+ this._router.closeStream(filter.turnId);
456
+ }
457
+ }
458
+
459
+ private _getMatchingTurnIds(filter: CancelFilter): Set<string> {
460
+ const matched = new Set<string>();
461
+ if (filter.all) {
462
+ for (const turnId of this._turnClientIds.keys()) matched.add(turnId);
463
+ } else if (filter.own) {
464
+ for (const [turnId, cid] of this._turnClientIds) {
465
+ if (cid === this._clientId) matched.add(turnId);
466
+ }
467
+ } else if (filter.clientId) {
468
+ for (const [turnId, cid] of this._turnClientIds) {
469
+ if (cid === filter.clientId) matched.add(turnId);
470
+ }
471
+ } else if (filter.turnId && this._turnClientIds.has(filter.turnId)) {
472
+ matched.add(filter.turnId);
473
+ }
474
+ return matched;
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Input message helpers
479
+ // ---------------------------------------------------------------------------
480
+
481
+ private _getMessagesWithHeaders(): MessageWithHeaders<TMessage>[] {
482
+ return this._tree.flatten().map((m) => ({
483
+ message: m,
484
+ headers: this.getMessageHeaders(m),
485
+ }));
486
+ }
487
+
488
+ /**
489
+ * Compute truncated history: everything before the target message.
490
+ * Used by regenerate so the LLM doesn't see the response being replaced.
491
+ * @param messageId - The msg-id to truncate history before.
492
+ * @returns Input messages preceding the target.
493
+ */
494
+ private _getHistoryBefore(messageId: string): MessageWithHeaders<TMessage>[] {
495
+ const all = this._getMessagesWithHeaders();
496
+ const idx = all.findIndex((inp) => inp.headers?.[HEADER_MSG_ID] === messageId);
497
+ return idx === -1 ? all : all.slice(0, idx);
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // History pagination helpers
502
+ // ---------------------------------------------------------------------------
503
+
504
+ private _processHistoryPage(page: PaginatedMessages<TMessage>): void {
505
+ for (const [i, message] of page.items.entries()) {
506
+ const headers = page.itemHeaders?.[i] ?? {};
507
+ const serial = page.itemSerials?.[i];
508
+ const key = this._codec.getMessageKey(message);
509
+ const msgId = headers[HEADER_MSG_ID] ?? key;
510
+ this._tree.upsert(msgId, message, headers, serial);
511
+ }
512
+ this._emitter.emit('message');
513
+
514
+ // Prepend raw Ably messages (older messages go at the beginning)
515
+ if (page.rawMessages && page.rawMessages.length > 0) {
516
+ this._ablyMessages.unshift(...page.rawMessages);
517
+ this._emitter.emit('ably-message');
518
+ }
519
+ }
520
+
521
+ private async _loadUntilVisible(
522
+ firstPage: PaginatedMessages<TMessage>,
523
+ target: number,
524
+ beforeKeys: Set<string>,
525
+ ): Promise<{ newVisible: TMessage[]; lastPage: PaginatedMessages<TMessage> }> {
526
+ this._processHistoryPage(firstPage);
527
+ let page = firstPage;
528
+
529
+ const newVisibleCount = (): number => {
530
+ let count = 0;
531
+ for (const m of this._tree.flatten()) {
532
+ if (!beforeKeys.has(this._codec.getMessageKey(m))) count++;
533
+ }
534
+ return count;
535
+ };
536
+
537
+ while (newVisibleCount() < target && page.hasNext()) {
538
+ const nextPage = await page.next();
539
+ if (!nextPage) break;
540
+ this._processHistoryPage(nextPage);
541
+ page = nextPage;
542
+ }
543
+
544
+ const newVisible = this._tree.flatten().filter((m) => !beforeKeys.has(this._codec.getMessageKey(m)));
545
+ return { newVisible, lastPage: page };
546
+ }
547
+
548
+ private _releaseWithheld(messages: TMessage[]): void {
549
+ for (const m of messages) {
550
+ this._withheldKeys.delete(this._codec.getMessageKey(m));
551
+ }
552
+ if (messages.length > 0) {
553
+ this._emitter.emit('message');
554
+ }
555
+ }
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // Public API
559
+ // ---------------------------------------------------------------------------
560
+
561
+ // Spec: AIT-CT3, AIT-CT4
562
+ async send(input: TMessage | TMessage[], sendOptions?: SendOptions): Promise<ActiveTurn<TEvent>> {
563
+ if (this._closed) {
564
+ throw new Ably.ErrorInfo('unable to send; transport is closed', ErrorCode.TransportClosed, 400);
565
+ }
566
+ await this._attachPromise;
567
+ // CAST: re-check after await — close() may have been called while waiting for attach.
568
+ // TypeScript's control flow narrows _closed to false after the first check, but the
569
+ // await yields and close() can mutate _closed concurrently.
570
+ if (this._closed as boolean) {
571
+ throw new Ably.ErrorInfo('unable to send; transport is closed', ErrorCode.TransportClosed, 400);
572
+ }
573
+
574
+ this._logger.trace('ClientTransport.send();');
575
+
576
+ const msgs = Array.isArray(input) ? input : [input];
577
+ const turnId = crypto.randomUUID();
578
+ this._ownTurnIds.add(turnId);
579
+
580
+ const msgIds = new Set<string>();
581
+ const postMessages: { message: TMessage; headers: Record<string, string> }[] = [];
582
+
583
+ // Capture history BEFORE optimistic inserts. The optimistic messages are
584
+ // sent in the `messages` field — including them in `history` too would
585
+ // cause the server to see them twice.
586
+ const preInsertHistory = this._getMessagesWithHeaders();
587
+
588
+ // Spec: AIT-CT3d
589
+ // Auto-compute parent from the current thread if not explicitly provided
590
+ let autoParent: string | undefined;
591
+ if (sendOptions?.parent === undefined && !sendOptions?.forkOf) {
592
+ const flat = this._tree.flatten();
593
+ if (flat.length > 0) {
594
+ const lastMsg = flat.at(-1);
595
+ if (lastMsg) {
596
+ const lastKey = this._codec.getMessageKey(lastMsg);
597
+ const lastNode = this._tree.getNodeByKey(lastKey);
598
+ autoParent = lastNode?.msgId ?? lastKey;
599
+ }
600
+ }
601
+ }
602
+
603
+ // Capture the first parent for the POST body before the loop advances it.
604
+ const postParent = sendOptions?.parent === undefined ? autoParent : sendOptions.parent;
605
+
606
+ for (const message of msgs) {
607
+ const msgId = crypto.randomUUID();
608
+ this._ownMsgIds.add(msgId);
609
+ msgIds.add(msgId);
610
+
611
+ const resolvedParent = sendOptions?.parent === undefined ? autoParent : (sendOptions.parent ?? undefined);
612
+
613
+ const optimisticHeaders = buildTransportHeaders({
614
+ role: 'user',
615
+ turnId,
616
+ msgId,
617
+ turnClientId: this._clientId,
618
+ parent: resolvedParent,
619
+ forkOf: sendOptions?.forkOf,
620
+ });
621
+ // Spec: AIT-CT3c
622
+ // Optimistically insert each user message into the tree
623
+ this._upsertAndNotify(message, optimisticHeaders);
624
+
625
+ // Include per-message parent so the server chains messages correctly.
626
+ const postHeaders: Record<string, string> = { [HEADER_MSG_ID]: msgId, [HEADER_ROLE]: 'user' };
627
+ if (resolvedParent) postHeaders[HEADER_PARENT] = resolvedParent;
628
+ postMessages.push({ message, headers: postHeaders });
629
+
630
+ // Spec: AIT-CT3e
631
+ // Chain: each subsequent message in the batch parents off the previous
632
+ // one, forming a linear conversation thread rather than siblings.
633
+ if (sendOptions?.parent === undefined && !sendOptions?.forkOf) {
634
+ autoParent = msgId;
635
+ }
636
+ }
637
+
638
+ this._turnMsgIds.set(turnId, msgIds);
639
+
640
+ // Create ReadableStream via router
641
+ const stream = this._router.createStream(turnId);
642
+
643
+ // Resolve headers and body
644
+ const resolvedHeaders = this._headersFn?.() ?? {};
645
+ const resolvedBody = this._bodyFn?.() ?? {};
646
+
647
+ const postBody: Record<string, unknown> = {
648
+ ...resolvedBody,
649
+ history: preInsertHistory,
650
+ ...sendOptions?.body,
651
+ turnId,
652
+ clientId: this._clientId,
653
+ messages: postMessages,
654
+ ...(sendOptions?.forkOf !== undefined && { forkOf: sendOptions.forkOf }),
655
+ ...(postParent !== undefined && { parent: postParent }),
656
+ };
657
+
658
+ const postHeaders: Record<string, string> = {
659
+ ...resolvedHeaders,
660
+ ...sendOptions?.headers,
661
+ };
662
+
663
+ // Spec: AIT-CT3a, AIT-CT3b
664
+ // Fire-and-forget: POST must not block the stream return to the caller.
665
+ // .catch() is intentional — async/await would delay stream availability.
666
+ this._fetchFn(this._api, {
667
+ method: 'POST',
668
+ headers: {
669
+ 'Content-Type': 'application/json',
670
+ ...postHeaders,
671
+ },
672
+ body: JSON.stringify(postBody),
673
+ ...(this._credentials ? { credentials: this._credentials } : {}),
674
+ })
675
+ .then((response) => {
676
+ if (!response.ok) {
677
+ this._emitter.emit(
678
+ 'error',
679
+ new Ably.ErrorInfo(
680
+ `unable to send; HTTP POST to ${this._api} returned ${String(response.status)} ${response.statusText}`,
681
+ ErrorCode.TransportSendFailed,
682
+ response.status,
683
+ ),
684
+ );
685
+ this._router.closeStream(turnId);
686
+ }
687
+ })
688
+ .catch((error: unknown) => {
689
+ const cause = error instanceof Ably.ErrorInfo ? error : undefined;
690
+ this._emitter.emit(
691
+ 'error',
692
+ new Ably.ErrorInfo(
693
+ `unable to send; HTTP POST to ${this._api} failed: ${error instanceof Error ? error.message : String(error)}`,
694
+ ErrorCode.TransportSendFailed,
695
+ 500,
696
+ cause,
697
+ ),
698
+ );
699
+ this._router.closeStream(turnId);
700
+ });
701
+
702
+ return {
703
+ stream,
704
+ turnId,
705
+ cancel: async () => this.cancel({ turnId }),
706
+ };
707
+ }
708
+
709
+ // Spec: AIT-CT5
710
+ async regenerate(messageId: string, sendOptions?: SendOptions): Promise<ActiveTurn<TEvent>> {
711
+ this._logger.trace('ClientTransport.regenerate();', { messageId });
712
+
713
+ const node = this._tree.getNode(messageId);
714
+ const parentId = node?.parentId;
715
+
716
+ return this.send([], {
717
+ ...sendOptions,
718
+ body: {
719
+ history: this._getHistoryBefore(messageId),
720
+ ...sendOptions?.body,
721
+ },
722
+ forkOf: messageId,
723
+ parent: parentId,
724
+ });
725
+ }
726
+
727
+ // Spec: AIT-CT6
728
+ async edit(
729
+ messageId: string,
730
+ newMessages: TMessage | TMessage[],
731
+ sendOptions?: SendOptions,
732
+ ): Promise<ActiveTurn<TEvent>> {
733
+ this._logger.trace('ClientTransport.edit();', { messageId });
734
+
735
+ const node = this._tree.getNode(messageId);
736
+ const parentId = node?.parentId;
737
+
738
+ return this.send(newMessages, {
739
+ ...sendOptions,
740
+ body: {
741
+ history: this._getHistoryBefore(messageId),
742
+ ...sendOptions?.body,
743
+ },
744
+ forkOf: messageId,
745
+ parent: parentId,
746
+ });
747
+ }
748
+
749
+ // Spec: AIT-CT7, AIT-CT7a
750
+ async cancel(filter?: CancelFilter): Promise<void> {
751
+ if (this._closed) return;
752
+ const resolved = filter ?? { own: true };
753
+ this._logger.debug('ClientTransport.cancel();', { filter: resolved });
754
+ await this._publishCancel(resolved);
755
+ this._closeMatchingTurnStreams(resolved);
756
+ }
757
+
758
+ // Spec: AIT-CT18
759
+ async waitForTurn(filter?: CancelFilter): Promise<void> {
760
+ if (this._closed) return;
761
+ const resolved = filter ?? { own: true };
762
+ const remaining = this._getMatchingTurnIds(resolved);
763
+ if (remaining.size === 0) return;
764
+
765
+ this._logger.debug('ClientTransport.waitForTurn();', { turnIds: [...remaining] });
766
+
767
+ return new Promise<void>((resolve) => {
768
+ const handler = (event: TurnLifecycleEvent): void => {
769
+ if (event.type !== EVENT_TURN_END) return;
770
+ remaining.delete(event.turnId);
771
+ if (remaining.size === 0) {
772
+ this._emitter.off('turn', handler);
773
+ resolve();
774
+ }
775
+ };
776
+ this._emitter.on('turn', handler);
777
+ });
778
+ }
779
+
780
+ // Spec: AIT-CT8, AIT-CT8a, AIT-CT8b, AIT-CT8c, AIT-CT8d
781
+ on(event: 'message' | 'ably-message', handler: () => void): () => void;
782
+ on(event: 'turn', handler: (event: TurnLifecycleEvent) => void): () => void;
783
+ on(event: 'error', handler: (error: Ably.ErrorInfo) => void): () => void;
784
+ on(
785
+ eventName: 'message' | 'turn' | 'error' | 'ably-message',
786
+ handler: (() => void) | ((event: TurnLifecycleEvent) => void) | ((error: Ably.ErrorInfo) => void),
787
+ ): () => void {
788
+ if (this._closed) return noopUnsubscribe;
789
+ // CAST: the overload signatures enforce correct handler types per event name.
790
+ // The implementation must cast to satisfy the EventEmitter's generic callback type.
791
+ const cb = handler as (arg: ClientTransportEventsMap[keyof ClientTransportEventsMap]) => void;
792
+ this._emitter.on(eventName, cb);
793
+ return () => {
794
+ this._emitter.off(eventName, cb);
795
+ };
796
+ }
797
+
798
+ // Spec: AIT-CT10
799
+ getTree(): ConversationTree<TMessage> {
800
+ return this._tree;
801
+ }
802
+
803
+ // Spec: AIT-CT17
804
+ getActiveTurnIds(): Map<string, Set<string>> {
805
+ const result = new Map<string, Set<string>>();
806
+ for (const [turnId, cid] of this._turnClientIds) {
807
+ let set = result.get(cid);
808
+ if (!set) {
809
+ set = new Set();
810
+ result.set(cid, set);
811
+ }
812
+ set.add(turnId);
813
+ }
814
+ return result;
815
+ }
816
+
817
+ getMessageHeaders(message: TMessage): Record<string, string> | undefined {
818
+ const key = this._codec.getMessageKey(message);
819
+ return this._tree.getNodeByKey(key)?.headers;
820
+ }
821
+
822
+ // Spec: AIT-CT9
823
+ getMessages(): TMessage[] {
824
+ if (this._withheldKeys.size === 0) return this._tree.flatten();
825
+ return this._tree.flatten().filter((m) => !this._withheldKeys.has(this._codec.getMessageKey(m)));
826
+ }
827
+
828
+ getMessagesWithHeaders(): MessageWithHeaders<TMessage>[] {
829
+ return this._getMessagesWithHeaders();
830
+ }
831
+
832
+ getAblyMessages(): Ably.InboundMessage[] {
833
+ return [...this._ablyMessages];
834
+ }
835
+
836
+ // Spec: AIT-CT11, AIT-CT11a, AIT-CT11b, AIT-CT11c
837
+ async history(opts?: LoadHistoryOptions): Promise<PaginatedMessages<TMessage>> {
838
+ if (this._closed) {
839
+ throw new Ably.ErrorInfo('unable to load history; transport is closed', ErrorCode.TransportClosed, 400);
840
+ }
841
+ this._logger.trace('ClientTransport.history();', { limit: opts?.limit });
842
+ const limit = opts?.limit ?? 100;
843
+
844
+ // Snapshot before loading — everything already in the tree stays visible
845
+ const beforeKeys = new Set(this._tree.flatten().map((m) => this._codec.getMessageKey(m)));
846
+
847
+ let lastPage = await decodeHistory(this._channel, this._codec, opts, this._logger);
848
+
849
+ const initial = await this._loadUntilVisible(lastPage, limit, beforeKeys);
850
+ lastPage = initial.lastPage;
851
+
852
+ // newVisible is chronological (oldest-first from flatten).
853
+ // For "load older" pagination: release the NEWEST `limit` now,
854
+ // withhold the older ones for subsequent next() calls.
855
+ const newVisible = initial.newVisible;
856
+
857
+ // Withhold ALL new visible messages first, then release the newest batch
858
+ for (const m of newVisible) {
859
+ this._withheldKeys.add(this._codec.getMessageKey(m));
860
+ }
861
+
862
+ const released = newVisible.slice(-limit);
863
+ // Mutable buffer of older messages, drained newest-first by successive next() calls
864
+ const withheldBuffer = newVisible.slice(0, -limit);
865
+ this._releaseWithheld(released);
866
+
867
+ const buildPage = (items: TMessage[]): PaginatedMessages<TMessage> => ({
868
+ items,
869
+ hasNext: () => withheldBuffer.length > 0 || lastPage.hasNext(),
870
+ next: async () => {
871
+ // Drain withheld buffer first (older messages, released newest-first)
872
+ if (withheldBuffer.length > 0) {
873
+ // Remove and return the newest `limit` items from the buffer
874
+ const batch = withheldBuffer.splice(-limit, limit);
875
+ this._releaseWithheld(batch);
876
+ return buildPage(batch);
877
+ }
878
+
879
+ // Buffer exhausted — load more pages from decodeHistory
880
+ if (!lastPage.hasNext()) return;
881
+
882
+ const nextInternal = await lastPage.next();
883
+ if (!nextInternal) return;
884
+
885
+ // Everything currently in the tree is "already known"
886
+ const alreadyKnown = new Set(beforeKeys);
887
+ for (const m of this._tree.flatten()) {
888
+ alreadyKnown.add(this._codec.getMessageKey(m));
889
+ }
890
+
891
+ const loaded = await this._loadUntilVisible(nextInternal, limit, alreadyKnown);
892
+ lastPage = loaded.lastPage;
893
+
894
+ const moreVisible = loaded.newVisible;
895
+ for (const m of moreVisible) {
896
+ this._withheldKeys.add(this._codec.getMessageKey(m));
897
+ }
898
+ // Remove and return the newest `limit` items; rest stays in buffer
899
+ const moreBatch = moreVisible.splice(-limit, limit);
900
+ withheldBuffer.push(...moreVisible);
901
+ this._releaseWithheld(moreBatch);
902
+
903
+ if (moreBatch.length === 0) return;
904
+ return buildPage(moreBatch);
905
+ },
906
+ });
907
+
908
+ return buildPage(released);
909
+ }
910
+
911
+ // Spec: AIT-CT12, AIT-CT12a, AIT-CT12b
912
+ async close(options?: CloseOptions): Promise<void> {
913
+ if (this._closed) return;
914
+ this._closed = true;
915
+ this._logger.info('ClientTransport.close();');
916
+
917
+ // Best-effort cancel publish before tearing down local state
918
+ if (options?.cancel) {
919
+ try {
920
+ await this._publishCancel(options.cancel);
921
+ } catch {
922
+ // Swallow: cancel is best-effort during teardown
923
+ }
924
+ this._closeMatchingTurnStreams(options.cancel);
925
+ }
926
+
927
+ this._channel.unsubscribe(this._onMessage);
928
+
929
+ // Close any remaining active streams
930
+ for (const turnId of this._ownTurnIds) {
931
+ this._router.closeStream(turnId);
932
+ }
933
+
934
+ this._turnObservers.clear();
935
+ this._emitter.off();
936
+ this._ownTurnIds.clear();
937
+ this._ownMsgIds.clear();
938
+ this._turnMsgIds.clear();
939
+ this._turnClientIds.clear();
940
+ this._withheldKeys.clear();
941
+ this._ablyMessages.length = 0;
942
+ }
943
+ }
944
+
945
+ // ---------------------------------------------------------------------------
946
+ // Factory
947
+ // ---------------------------------------------------------------------------
948
+
949
+ /**
950
+ * Create a client-side transport that manages conversation state over an Ably channel.
951
+ *
952
+ * Subscribes to the channel immediately (before attach per RTL7g). The caller should
953
+ * ensure the channel is attached or will be attached shortly after creation.
954
+ * @param options - Configuration for the client transport.
955
+ * @returns A new {@link ClientTransport} instance.
956
+ */
957
+ export const createClientTransport = <TEvent, TMessage>(
958
+ options: ClientTransportOptions<TEvent, TMessage>,
959
+ ): ClientTransport<TEvent, TMessage> => new DefaultClientTransport(options);