@ably/ai-transport 0.0.1 → 0.1.0

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 (110) hide show
  1. package/README.md +54 -47
  2. package/dist/ably-ai-transport.js +1006 -539
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +4 -0
  7. package/dist/core/codec/types.d.ts +19 -2
  8. package/dist/core/transport/decode-history.d.ts +8 -6
  9. package/dist/core/transport/headers.d.ts +4 -2
  10. package/dist/core/transport/index.d.ts +4 -1
  11. package/dist/core/transport/pipe-stream.d.ts +3 -2
  12. package/dist/core/transport/stream-router.d.ts +11 -1
  13. package/dist/core/transport/tree.d.ts +171 -0
  14. package/dist/core/transport/turn-manager.d.ts +4 -1
  15. package/dist/core/transport/types.d.ts +270 -119
  16. package/dist/core/transport/view.d.ts +166 -0
  17. package/dist/errors.d.ts +19 -2
  18. package/dist/index.d.ts +3 -1
  19. package/dist/react/ably-ai-transport-react.js +1019 -486
  20. package/dist/react/ably-ai-transport-react.js.map +1 -1
  21. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  22. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  23. package/dist/react/contexts/transport-context.d.ts +31 -0
  24. package/dist/react/contexts/transport-provider.d.ts +49 -0
  25. package/dist/react/create-transport-hooks.d.ts +124 -0
  26. package/dist/react/index.d.ts +14 -8
  27. package/dist/react/use-ably-messages.d.ts +14 -8
  28. package/dist/react/use-active-turns.d.ts +7 -3
  29. package/dist/react/use-client-transport.d.ts +78 -5
  30. package/dist/react/use-create-view.d.ts +22 -0
  31. package/dist/react/use-tree.d.ts +20 -0
  32. package/dist/react/use-view.d.ts +79 -0
  33. package/dist/vercel/ably-ai-transport-vercel.js +1478 -842
  34. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  35. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  36. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  37. package/dist/vercel/codec/tool-transitions.d.ts +50 -0
  38. package/dist/vercel/index.d.ts +3 -0
  39. package/dist/vercel/react/ably-ai-transport-vercel-react.js +9099 -852
  40. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  41. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +45 -1
  42. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  43. package/dist/vercel/react/contexts/chat-transport-context.d.ts +32 -0
  44. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +84 -0
  45. package/dist/vercel/react/index.d.ts +5 -0
  46. package/dist/vercel/react/use-chat-transport.d.ts +61 -20
  47. package/dist/vercel/react/use-message-sync.d.ts +41 -9
  48. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +30 -0
  49. package/dist/vercel/tool-approvals.d.ts +124 -0
  50. package/dist/vercel/tool-events.d.ts +26 -0
  51. package/dist/vercel/transport/chat-transport.d.ts +33 -11
  52. package/dist/vercel/transport/index.d.ts +5 -2
  53. package/package.json +23 -17
  54. package/src/constants.ts +6 -0
  55. package/src/core/codec/encoder.ts +10 -1
  56. package/src/core/codec/types.ts +19 -3
  57. package/src/core/transport/client-transport.ts +382 -364
  58. package/src/core/transport/decode-history.ts +229 -81
  59. package/src/core/transport/headers.ts +6 -2
  60. package/src/core/transport/index.ts +13 -5
  61. package/src/core/transport/pipe-stream.ts +8 -5
  62. package/src/core/transport/server-transport.ts +212 -58
  63. package/src/core/transport/stream-router.ts +21 -3
  64. package/src/core/transport/{conversation-tree.ts → tree.ts} +192 -77
  65. package/src/core/transport/turn-manager.ts +28 -10
  66. package/src/core/transport/types.ts +318 -139
  67. package/src/core/transport/view.ts +840 -0
  68. package/src/errors.ts +21 -1
  69. package/src/index.ts +10 -5
  70. package/src/react/contexts/transport-context.ts +37 -0
  71. package/src/react/contexts/transport-provider.tsx +164 -0
  72. package/src/react/create-transport-hooks.ts +144 -0
  73. package/src/react/index.ts +15 -8
  74. package/src/react/use-ably-messages.ts +34 -16
  75. package/src/react/use-active-turns.ts +28 -17
  76. package/src/react/use-client-transport.ts +184 -24
  77. package/src/react/use-create-view.ts +68 -0
  78. package/src/react/use-tree.ts +53 -0
  79. package/src/react/use-view.ts +233 -0
  80. package/src/react/vite.config.ts +4 -1
  81. package/src/vercel/codec/accumulator.ts +64 -79
  82. package/src/vercel/codec/decoder.ts +11 -8
  83. package/src/vercel/codec/encoder.ts +68 -54
  84. package/src/vercel/codec/index.ts +0 -2
  85. package/src/vercel/codec/tool-transitions.ts +122 -0
  86. package/src/vercel/index.ts +17 -0
  87. package/src/vercel/react/contexts/chat-transport-context.ts +40 -0
  88. package/src/vercel/react/contexts/chat-transport-provider.tsx +122 -0
  89. package/src/vercel/react/index.ts +14 -0
  90. package/src/vercel/react/use-chat-transport.ts +164 -42
  91. package/src/vercel/react/use-message-sync.ts +77 -19
  92. package/src/vercel/react/use-staged-add-tool-approval-response.ts +87 -0
  93. package/src/vercel/react/vite.config.ts +4 -2
  94. package/src/vercel/tool-approvals.ts +380 -0
  95. package/src/vercel/tool-events.ts +53 -0
  96. package/src/vercel/transport/chat-transport.ts +225 -79
  97. package/src/vercel/transport/index.ts +14 -3
  98. package/dist/core/transport/conversation-tree.d.ts +0 -9
  99. package/dist/react/use-conversation-tree.d.ts +0 -20
  100. package/dist/react/use-edit.d.ts +0 -7
  101. package/dist/react/use-history.d.ts +0 -19
  102. package/dist/react/use-messages.d.ts +0 -7
  103. package/dist/react/use-regenerate.d.ts +0 -7
  104. package/dist/react/use-send.d.ts +0 -7
  105. package/src/react/use-conversation-tree.ts +0 -71
  106. package/src/react/use-edit.ts +0 -24
  107. package/src/react/use-history.ts +0 -111
  108. package/src/react/use-messages.ts +0 -32
  109. package/src/react/use-regenerate.ts +0 -24
  110. package/src/react/use-send.ts +0 -25
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Core client-side transport, parameterized by codec.
3
3
  *
4
- * Composes StreamRouter and ConversationTree to handle the full client-side
4
+ * Composes StreamRouter and Tree to handle the full client-side
5
5
  * lifecycle. Subscribes to the Ably channel on construction. The same
6
6
  * subscription, decoder, and channel are reused across turns.
7
7
  *
@@ -16,13 +16,14 @@ import {
16
16
  EVENT_CANCEL,
17
17
  EVENT_TURN_END,
18
18
  EVENT_TURN_START,
19
+ HEADER_AMEND,
19
20
  HEADER_CANCEL_ALL,
20
21
  HEADER_CANCEL_CLIENT_ID,
21
22
  HEADER_CANCEL_OWN,
22
23
  HEADER_CANCEL_TURN_ID,
24
+ HEADER_FORK_OF,
23
25
  HEADER_MSG_ID,
24
26
  HEADER_PARENT,
25
- HEADER_ROLE,
26
27
  HEADER_TURN_CLIENT_ID,
27
28
  HEADER_TURN_ID,
28
29
  HEADER_TURN_REASON,
@@ -33,25 +34,26 @@ import type { Logger } from '../../logger.js';
33
34
  import { LogLevel, makeLogger } from '../../logger.js';
34
35
  import { getHeaders } from '../../utils.js';
35
36
  import type { DecoderOutput, MessageAccumulator, StreamDecoder } from '../codec/types.js';
36
- import { createConversationTree } from './conversation-tree.js';
37
- import { decodeHistory } from './decode-history.js';
38
37
  import { buildTransportHeaders } from './headers.js';
39
38
  import type { StreamRouter } from './stream-router.js';
40
39
  import { createStreamRouter } from './stream-router.js';
40
+ import type { DefaultTree } from './tree.js';
41
+ import { createTree } from './tree.js';
41
42
  import type {
42
43
  ActiveTurn,
43
44
  CancelFilter,
44
45
  ClientTransport,
45
46
  ClientTransportOptions,
46
47
  CloseOptions,
47
- ConversationTree,
48
- LoadHistoryOptions,
49
- MessageWithHeaders,
50
- PaginatedMessages,
48
+ EventsNode,
49
+ MessageNode,
51
50
  SendOptions,
51
+ Tree,
52
52
  TurnEndReason,
53
53
  TurnLifecycleEvent,
54
+ View,
54
55
  } from './types.js';
56
+ import { createView, type DefaultView } from './view.js';
55
57
 
56
58
  /**
57
59
  * Returned from `on()` when the transport is already closed — the subscription
@@ -60,15 +62,21 @@ import type {
60
62
  // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentional no-op
61
63
  const noopUnsubscribe = (): void => {};
62
64
 
65
+ // ---------------------------------------------------------------------------
66
+ // Internal state machine
67
+ // ---------------------------------------------------------------------------
68
+
69
+ enum ClientTransportState {
70
+ READY = 'ready',
71
+ CLOSED = 'closed',
72
+ }
73
+
63
74
  // ---------------------------------------------------------------------------
64
75
  // Event map for the transport's typed EventEmitter
65
76
  // ---------------------------------------------------------------------------
66
77
 
67
78
  interface ClientTransportEventsMap {
68
- message: undefined;
69
- turn: TurnLifecycleEvent;
70
79
  error: Ably.ErrorInfo;
71
- 'ably-message': undefined;
72
80
  }
73
81
 
74
82
  // ---------------------------------------------------------------------------
@@ -97,15 +105,13 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
97
105
  private readonly _fetchFn: typeof globalThis.fetch;
98
106
  private readonly _logger: Logger;
99
107
 
100
- // Typed event emitter for all transport events
108
+ // Typed event emitter only 'error' remains on the transport
101
109
  private readonly _emitter: EventEmitter<ClientTransportEventsMap>;
102
110
 
103
111
  // Relay detection — tracks msg-ids of optimistic inserts for reconciliation
104
112
  private readonly _ownMsgIds = new Set<string>();
105
113
  private readonly _ownTurnIds = new Set<string>();
106
114
 
107
- // Track clientId per turn for getActiveTurnIds()
108
- private readonly _turnClientIds = new Map<string, string>();
109
115
  // Track msgIds per turn for cleanup on turn-end
110
116
  private readonly _turnMsgIds = new Map<string, Set<string>>();
111
117
 
@@ -113,29 +119,40 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
113
119
  // A single .delete(turnId) cleans up all three.
114
120
  private readonly _turnObservers = new Map<string, TurnObserverState<TEvent, TMessage>>();
115
121
 
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>();
122
+ // Callbacks to resolve pending waitForTurn promises on close, preventing leaked subscriptions.
123
+ private readonly _closeResolvers: (() => void)[] = [];
121
124
 
122
125
  // Sub-components
123
- private readonly _tree: ConversationTree<TMessage>;
126
+ private readonly _tree: DefaultTree<TMessage>;
127
+ private readonly _view: DefaultView<TEvent, TMessage>;
128
+ private readonly _views = new Set<DefaultView<TEvent, TMessage>>();
124
129
  private readonly _router: StreamRouter<TEvent>;
125
130
  private readonly _decoder: StreamDecoder<TEvent, TMessage>;
126
131
 
132
+ // Spec: AIT-CT10, AIT-CT10a
133
+ readonly tree: Tree<TMessage>;
134
+ readonly view: View<TEvent, TMessage>;
135
+
127
136
  // Channel subscription — subscribe() returns a Promise that resolves when the channel attaches
128
137
  private readonly _attachPromise: Promise<unknown>;
129
138
  private readonly _onMessage: (msg: Ably.InboundMessage) => void;
130
139
 
131
- private _closed = false;
140
+ private _state = ClientTransportState.READY;
141
+ private _hasAttachedOnce: boolean;
142
+ private readonly _onChannelStateChange: Ably.channelEventCallback;
143
+
144
+ // Events staged locally via stageEvents(). Flushed into the eventNodes
145
+ // parameter of _internalSend on the next send operation.
146
+ private _pendingLocalEvents: EventsNode<TEvent>[] = [];
132
147
 
133
148
  constructor(options: ClientTransportOptions<TEvent, TMessage>) {
134
149
  this._channel = options.channel;
135
150
  this._codec = options.codec;
136
151
  this._clientId = options.clientId;
137
- this._api = options.api ?? '/api/chat';
152
+ this._api = options.api;
138
153
  this._credentials = options.credentials;
154
+ // CAST: TS can't narrow options.headers/body inside a closure because the outer
155
+ // object is mutable. The truthiness check on the preceding line guarantees non-nullish.
139
156
  this._headersFn =
140
157
  typeof options.headers === 'function'
141
158
  ? options.headers
@@ -154,23 +171,37 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
154
171
  });
155
172
 
156
173
  this._emitter = new EventEmitter<ClientTransportEventsMap>(this._logger);
174
+ this._hasAttachedOnce = this._channel.state === 'attached';
157
175
 
158
176
  // Compose sub-components
159
- this._tree = createConversationTree<TMessage>(this._codec.getMessageKey.bind(this._codec), this._logger);
177
+ this._tree = createTree<TMessage>(this._logger);
178
+ this._view = createView<TEvent, TMessage>({
179
+ tree: this._tree,
180
+ channel: this._channel,
181
+ codec: this._codec,
182
+ sendDelegate: this._internalSend.bind(this),
183
+ logger: this._logger,
184
+ onClose: () => this._views.delete(this._view),
185
+ });
160
186
  this._router = createStreamRouter<TEvent>(this._codec.isTerminal.bind(this._codec), this._logger);
161
187
  this._decoder = this._codec.createDecoder();
162
188
 
163
- // Seed tree with initial messages
189
+ this._views.add(this._view);
190
+
191
+ // Public accessors (typed as narrow interfaces)
192
+ this.tree = this._tree;
193
+ this.view = this._view;
194
+
195
+ // Seed tree with initial messages — transport assigns its own msgId
164
196
  if (options.messages) {
165
197
  let prevMsgId: string | undefined;
166
198
  for (const msg of options.messages) {
167
- const msgId = this._codec.getMessageKey(msg);
168
- const seedHeaders: Record<string, string> = {};
199
+ const msgId = crypto.randomUUID();
200
+ const seedHeaders: Record<string, string> = { [HEADER_MSG_ID]: msgId };
169
201
  if (prevMsgId) seedHeaders[HEADER_PARENT] = prevMsgId;
170
202
  this._tree.upsert(msgId, msg, seedHeaders);
171
203
  prevMsgId = msgId;
172
204
  }
173
- this._emitter.emit('message');
174
205
  }
175
206
 
176
207
  // Spec: AIT-CT2
@@ -179,6 +210,15 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
179
210
  this._handleMessage(ablyMessage);
180
211
  };
181
212
  this._attachPromise = this._channel.subscribe(this._onMessage);
213
+
214
+ // Listen for channel state changes that break message continuity.
215
+ // _hasAttachedOnce is seeded from the channel's current state so that
216
+ // pre-attached channels are handled correctly. It distinguishes the
217
+ // initial attach (expected) from a genuine discontinuity.
218
+ this._onChannelStateChange = (stateChange: Ably.ChannelStateChange) => {
219
+ this._handleChannelStateChange(stateChange);
220
+ };
221
+ this._channel.on(this._onChannelStateChange);
182
222
  }
183
223
 
184
224
  // ---------------------------------------------------------------------------
@@ -186,10 +226,7 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
186
226
  // ---------------------------------------------------------------------------
187
227
 
188
228
  private _handleMessage(ablyMessage: Ably.InboundMessage): void {
189
- if (this._closed) return;
190
-
191
- this._ablyMessages.push(ablyMessage);
192
- this._emitter.emit('ably-message');
229
+ if (this._state === ClientTransportState.CLOSED) return;
193
230
 
194
231
  try {
195
232
  // Spec: AIT-CT16a
@@ -199,9 +236,18 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
199
236
  const turnId = headers[HEADER_TURN_ID];
200
237
  const turnCid = headers[HEADER_TURN_CLIENT_ID] ?? '';
201
238
  if (turnId) {
202
- this._turnClientIds.set(turnId, turnCid);
203
- this._emitter.emit('turn', { type: EVENT_TURN_START, turnId, clientId: turnCid });
239
+ this._tree.trackTurn(turnId, turnCid);
240
+ const parentRaw = headers[HEADER_PARENT];
241
+ const forkOf = headers[HEADER_FORK_OF];
242
+ this._tree.emitTurn({
243
+ type: EVENT_TURN_START,
244
+ turnId,
245
+ clientId: turnCid,
246
+ ...(parentRaw !== undefined && { parent: parentRaw }),
247
+ ...(forkOf !== undefined && { forkOf }),
248
+ });
204
249
  }
250
+ this._tree.emitAblyMessage(ablyMessage);
205
251
  return;
206
252
  }
207
253
 
@@ -214,7 +260,7 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
214
260
  if (turnId) {
215
261
  this._router.closeStream(turnId);
216
262
  this._turnObservers.delete(turnId);
217
- this._turnClientIds.delete(turnId);
263
+ this._tree.untrackTurn(turnId);
218
264
  // Clean up per-turn relay-detection state
219
265
  const msgIds = this._turnMsgIds.get(turnId);
220
266
  if (msgIds) {
@@ -222,8 +268,9 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
222
268
  this._turnMsgIds.delete(turnId);
223
269
  }
224
270
  this._ownTurnIds.delete(turnId);
225
- this._emitter.emit('turn', { type: EVENT_TURN_END, turnId, clientId: turnCid, reason });
271
+ this._tree.emitTurn({ type: EVENT_TURN_END, turnId, clientId: turnCid, reason });
226
272
  }
273
+ this._tree.emitAblyMessage(ablyMessage);
227
274
  return;
228
275
  }
229
276
 
@@ -232,6 +279,18 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
232
279
  const headers = getHeaders(ablyMessage);
233
280
  const serial = ablyMessage.serial;
234
281
 
282
+ // Cross-turn events target an existing message from a prior turn,
283
+ // bypassing the current turn's accumulator.
284
+ const amendTarget = headers[HEADER_AMEND];
285
+ if (amendTarget) {
286
+ for (const output of outputs) {
287
+ if (output.kind === 'event') {
288
+ this._handleAmendmentEvent(amendTarget, output);
289
+ }
290
+ }
291
+ return;
292
+ }
293
+
235
294
  // Always update observer headers, even when the decoder produces no outputs.
236
295
  // This ensures header transitions (e.g. x-ably-status: streaming → aborted)
237
296
  // are captured for events that the decoder suppresses (AIT-CD8: aborted
@@ -248,6 +307,11 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
248
307
  this._handleEventOutput(output, headers);
249
308
  }
250
309
  }
310
+
311
+ // Emit ably-message AFTER decode/upsert so that View subscribers can
312
+ // find the node in _lastVisibleIds (which is refreshed by tree 'update'
313
+ // events triggered during upsert).
314
+ this._tree.emitAblyMessage(ablyMessage);
251
315
  } catch (error) {
252
316
  const cause = error instanceof Ably.ErrorInfo ? error : undefined;
253
317
  this._emitter.emit(
@@ -319,6 +383,79 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
319
383
  if (this._codec.isTerminal(event)) this._turnObservers.delete(turnId);
320
384
  }
321
385
 
386
+ /**
387
+ * Handle a cross-turn event targeting an existing message from a prior turn.
388
+ * Creates a temporary accumulator, seeds it with the existing message,
389
+ * processes the event, and upserts the updated message into the tree.
390
+ * @param targetMsgId - The x-ably-msg-id of the message to update.
391
+ * @param output - The decoded event output to apply.
392
+ */
393
+ private _handleAmendmentEvent(targetMsgId: string, output: DecoderOutput<TEvent, TMessage>): void {
394
+ this._logger.trace('ClientTransport._handleAmendmentEvent();', { targetMsgId });
395
+
396
+ const existingNode = this._tree.getNode(targetMsgId);
397
+ if (!existingNode) {
398
+ this._logger.debug('ClientTransport._handleAmendmentEvent(); target not found, dropping', { targetMsgId });
399
+ return;
400
+ }
401
+
402
+ const accumulator = this._codec.createAccumulator();
403
+ accumulator.initMessage(targetMsgId, existingNode.message);
404
+ accumulator.processOutputs([output]);
405
+
406
+ const updatedMsg = accumulator.messages.at(-1);
407
+ if (updatedMsg) {
408
+ this._tree.upsert(targetMsgId, updatedMsg, existingNode.headers, existingNode.serial);
409
+ }
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Channel state change handler
414
+ // ---------------------------------------------------------------------------
415
+
416
+ // Spec: AIT-CT19, AIT-CT19a
417
+ private _handleChannelStateChange(stateChange: Ably.ChannelStateChange): void {
418
+ if (this._state === ClientTransportState.CLOSED) return;
419
+
420
+ const { current, resumed } = stateChange;
421
+
422
+ // Track the initial attach so we don't treat it as a discontinuity
423
+ if (current === 'attached' && !this._hasAttachedOnce) {
424
+ this._hasAttachedOnce = true;
425
+ return;
426
+ }
427
+
428
+ // Continuity-breaking states:
429
+ // - FAILED, SUSPENDED, DETACHED: no more messages expected (or gap)
430
+ // - ATTACHED with resumed: false (UPDATE): messages were lost
431
+ const continuityLost =
432
+ current === 'failed' || current === 'suspended' || current === 'detached' || (current === 'attached' && !resumed);
433
+
434
+ if (!continuityLost) return;
435
+
436
+ this._logger.error('ClientTransport._handleChannelStateChange(); channel continuity lost', {
437
+ current,
438
+ resumed,
439
+ previous: stateChange.previous,
440
+ });
441
+
442
+ const err = new Ably.ErrorInfo(
443
+ `unable to deliver events; channel continuity lost (${current}${current === 'attached' ? ', resumed: false' : ''})`,
444
+ ErrorCode.ChannelContinuityLost,
445
+ 500,
446
+ stateChange.reason,
447
+ );
448
+
449
+ // As with cancellation (_closeMatchingTurnStreams), do not clear
450
+ // _ownTurnIds or _turnObservers here — late events must still accumulate
451
+ // into the tree. The turn-end handler cleans up observers.
452
+ for (const turnId of this._ownTurnIds) {
453
+ this._router.errorStream(turnId, err);
454
+ }
455
+
456
+ this._emitter.emit('error', err);
457
+ }
458
+
322
459
  // ---------------------------------------------------------------------------
323
460
  // Tree mutation + notification helpers
324
461
  // ---------------------------------------------------------------------------
@@ -330,10 +467,9 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
330
467
  * @param serial - Ably serial for tree ordering.
331
468
  */
332
469
  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;
470
+ const msgId = headers[HEADER_MSG_ID];
471
+ if (!msgId) return;
335
472
  this._tree.upsert(msgId, message, headers, serial);
336
- this._emitter.emit('message');
337
473
  }
338
474
 
339
475
  // ---------------------------------------------------------------------------
@@ -380,6 +516,18 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
380
516
  const observer = this._turnObservers.get(turnId);
381
517
  if (!observer) return;
382
518
 
519
+ // Sync the accumulator with the tree before processing. If the message
520
+ // was updated externally (via cross-turn events), initMessage syncs the
521
+ // accumulator's state so the update isn't lost when processing
522
+ // late turn events like finish-step/finish.
523
+ const msgId = observer.headers[HEADER_MSG_ID];
524
+ if (msgId) {
525
+ const treeNode = this._tree.getNode(msgId);
526
+ if (treeNode) {
527
+ observer.accumulator.initMessage(msgId, treeNode.message);
528
+ }
529
+ }
530
+
383
531
  observer.accumulator.processOutputs([output]);
384
532
 
385
533
  const messages = observer.accumulator.messages;
@@ -398,13 +546,10 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
398
546
  }
399
547
 
400
548
  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');
549
+ const msgId = observer.headers[HEADER_MSG_ID];
550
+ if (msgId) {
551
+ this._tree.upsert(msgId, message, { ...observer.headers }, observer.serial);
552
+ }
408
553
  }
409
554
  }
410
555
 
@@ -437,39 +582,37 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
437
582
  // The observer must remain alive so that late server events (e.g. abort,
438
583
  // x-ably-status: aborted) arriving before turn-end are still accumulated
439
584
  // 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);
585
+ for (const turnId of this._getMatchingTurnIds(filter)) {
586
+ this._router.closeStream(turnId);
456
587
  }
457
588
  }
458
589
 
459
590
  private _getMatchingTurnIds(filter: CancelFilter): Set<string> {
460
591
  const matched = new Set<string>();
592
+ const activeTurns = this._tree.getActiveTurnIds();
593
+
461
594
  if (filter.all) {
462
- for (const turnId of this._turnClientIds.keys()) matched.add(turnId);
595
+ for (const turnIds of activeTurns.values()) {
596
+ for (const turnId of turnIds) matched.add(turnId);
597
+ }
463
598
  } else if (filter.own) {
464
- for (const [turnId, cid] of this._turnClientIds) {
465
- if (cid === this._clientId) matched.add(turnId);
599
+ const ownTurns = activeTurns.get(this._clientId ?? '');
600
+ if (ownTurns) {
601
+ for (const turnId of ownTurns) matched.add(turnId);
466
602
  }
467
603
  } else if (filter.clientId) {
468
- for (const [turnId, cid] of this._turnClientIds) {
469
- if (cid === filter.clientId) matched.add(turnId);
604
+ const clientTurns = activeTurns.get(filter.clientId);
605
+ if (clientTurns) {
606
+ for (const turnId of clientTurns) matched.add(turnId);
607
+ }
608
+ } else if (filter.turnId) {
609
+ // Check if the turnId exists in any client's turns
610
+ for (const turnIds of activeTurns.values()) {
611
+ if (turnIds.has(filter.turnId)) {
612
+ matched.add(filter.turnId);
613
+ break;
614
+ }
470
615
  }
471
- } else if (filter.turnId && this._turnClientIds.has(filter.turnId)) {
472
- matched.add(filter.turnId);
473
616
  }
474
617
  return matched;
475
618
  }
@@ -478,125 +621,89 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
478
621
  // Input message helpers
479
622
  // ---------------------------------------------------------------------------
480
623
 
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
624
  // ---------------------------------------------------------------------------
501
- // History pagination helpers
625
+ // Public API
502
626
  // ---------------------------------------------------------------------------
503
627
 
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');
628
+ // Spec: AIT-CT10b
629
+ createView(): View<TEvent, TMessage> {
630
+ if (this._state === ClientTransportState.CLOSED) {
631
+ throw new Ably.ErrorInfo('unable to create view; transport is closed', ErrorCode.TransportClosed, 400);
554
632
  }
633
+ this._logger.trace('DefaultClientTransport.createView();');
634
+ const view = createView<TEvent, TMessage>({
635
+ tree: this._tree,
636
+ channel: this._channel,
637
+ codec: this._codec,
638
+ sendDelegate: this._internalSend.bind(this),
639
+ logger: this._logger,
640
+ onClose: () => this._views.delete(view),
641
+ });
642
+ this._views.add(view);
643
+ return view;
555
644
  }
556
645
 
557
- // ---------------------------------------------------------------------------
558
- // Public API
559
- // ---------------------------------------------------------------------------
560
-
561
646
  // Spec: AIT-CT3, AIT-CT4
562
- async send(input: TMessage | TMessage[], sendOptions?: SendOptions): Promise<ActiveTurn<TEvent>> {
563
- if (this._closed) {
647
+ private async _internalSend(
648
+ input: TMessage | TMessage[],
649
+ sendOptions: SendOptions | undefined,
650
+ history: MessageNode<TMessage>[],
651
+ eventNodes?: EventsNode<TEvent>[],
652
+ ): Promise<ActiveTurn<TEvent>> {
653
+ if (this._state === ClientTransportState.CLOSED) {
564
654
  throw new Ably.ErrorInfo('unable to send; transport is closed', ErrorCode.TransportClosed, 400);
565
655
  }
566
656
  await this._attachPromise;
567
657
  // 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) {
658
+ // TypeScript's control flow narrows _state after the first check, but the
659
+ // await yields and close() can mutate _state concurrently.
660
+ if ((this._state as ClientTransportState) === ClientTransportState.CLOSED) {
571
661
  throw new Ably.ErrorInfo('unable to send; transport is closed', ErrorCode.TransportClosed, 400);
572
662
  }
573
663
 
574
- this._logger.trace('ClientTransport.send();');
664
+ // Spec: AIT-CT20
665
+ const state = this._channel.state;
666
+ if (state !== 'attached' && state !== 'attaching') {
667
+ throw new Ably.ErrorInfo(`unable to send; channel is ${state}`, ErrorCode.ChannelNotReady, 400);
668
+ }
669
+
670
+ this._logger.trace('ClientTransport._internalSend();');
575
671
 
576
672
  const msgs = Array.isArray(input) ? input : [input];
577
673
  const turnId = crypto.randomUUID();
578
674
  this._ownTurnIds.add(turnId);
675
+ this._tree.trackTurn(turnId, this._clientId ?? '');
676
+
677
+ // Flush any events staged via stageEvents() since the last send. They
678
+ // have already been applied to the tree, so merge them into the POST
679
+ // body without re-applying. External eventNodes (e.g. from view.update)
680
+ // have NOT been applied yet and need the optimistic tree update below.
681
+ const flushedStaged = this._pendingLocalEvents;
682
+ this._pendingLocalEvents = [];
683
+
684
+ // Optimistic tree updates for external cross-turn events — must happen
685
+ // before capturing history so the POST body includes the updated
686
+ // message state.
687
+ if (eventNodes && eventNodes.length > 0) {
688
+ this._applyEventsToTree(eventNodes);
689
+ }
690
+
691
+ const allEventNodes: EventsNode<TEvent>[] = [...flushedStaged, ...(eventNodes ?? [])];
579
692
 
580
693
  const msgIds = new Set<string>();
581
- const postMessages: { message: TMessage; headers: Record<string, string> }[] = [];
694
+ const postMessages: MessageNode<TMessage>[] = [];
582
695
 
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();
696
+ // The View pre-computed the visible branch before calling this delegate,
697
+ // so preInsertHistory reflects the state before any optimistic inserts.
698
+ const preInsertHistory = history;
587
699
 
588
700
  // Spec: AIT-CT3d
589
701
  // Auto-compute parent from the current thread if not explicitly provided
590
702
  let autoParent: string | undefined;
591
703
  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
- }
704
+ const lastNode = preInsertHistory.at(-1);
705
+ if (lastNode) {
706
+ autoParent = lastNode.msgId;
600
707
  }
601
708
  }
602
709
 
@@ -608,7 +715,7 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
608
715
  this._ownMsgIds.add(msgId);
609
716
  msgIds.add(msgId);
610
717
 
611
- const resolvedParent = sendOptions?.parent === undefined ? autoParent : (sendOptions.parent ?? undefined);
718
+ const resolvedParent = sendOptions?.parent === undefined ? autoParent : sendOptions.parent;
612
719
 
613
720
  const optimisticHeaders = buildTransportHeaders({
614
721
  role: 'user',
@@ -622,10 +729,16 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
622
729
  // Optimistically insert each user message into the tree
623
730
  this._upsertAndNotify(message, optimisticHeaders);
624
731
 
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 });
732
+ // Build MessageNode for the POST body
733
+ postMessages.push({
734
+ kind: 'message',
735
+ message,
736
+ msgId,
737
+ parentId: resolvedParent,
738
+ forkOf: sendOptions?.forkOf,
739
+ headers: optimisticHeaders,
740
+ serial: undefined,
741
+ });
629
742
 
630
743
  // Spec: AIT-CT3e
631
744
  // Chain: each subsequent message in the batch parents off the previous
@@ -653,6 +766,7 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
653
766
  messages: postMessages,
654
767
  ...(sendOptions?.forkOf !== undefined && { forkOf: sendOptions.forkOf }),
655
768
  ...(postParent !== undefined && { parent: postParent }),
769
+ ...(allEventNodes.length > 0 && { events: allEventNodes }),
656
770
  };
657
771
 
658
772
  const postHeaders: Record<string, string> = {
@@ -674,90 +788,103 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
674
788
  })
675
789
  .then((response) => {
676
790
  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
- ),
791
+ const err = new Ably.ErrorInfo(
792
+ `unable to send; HTTP POST to ${this._api} returned ${String(response.status)} ${response.statusText}`,
793
+ ErrorCode.TransportSendFailed,
794
+ response.status,
684
795
  );
685
- this._router.closeStream(turnId);
796
+ this._emitter.emit('error', err);
797
+ this._router.errorStream(turnId, err);
686
798
  }
687
799
  })
688
800
  .catch((error: unknown) => {
689
801
  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
- ),
802
+ const err = new Ably.ErrorInfo(
803
+ `unable to send; HTTP POST to ${this._api} failed: ${error instanceof Error ? error.message : String(error)}`,
804
+ ErrorCode.TransportSendFailed,
805
+ 500,
806
+ cause,
698
807
  );
699
- this._router.closeStream(turnId);
808
+ this._emitter.emit('error', err);
809
+ this._router.errorStream(turnId, err);
700
810
  });
701
811
 
702
812
  return {
703
813
  stream,
704
814
  turnId,
705
815
  cancel: async () => this.cancel({ turnId }),
816
+ optimisticMsgIds: [...msgIds],
706
817
  };
707
818
  }
708
819
 
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
820
  // Spec: AIT-CT7, AIT-CT7a
750
821
  async cancel(filter?: CancelFilter): Promise<void> {
751
- if (this._closed) return;
822
+ if (this._state === ClientTransportState.CLOSED) return;
752
823
  const resolved = filter ?? { own: true };
753
824
  this._logger.debug('ClientTransport.cancel();', { filter: resolved });
754
825
  await this._publishCancel(resolved);
755
826
  this._closeMatchingTurnStreams(resolved);
756
827
  }
757
828
 
829
+ stageEvents(msgId: string, events: TEvent[]): void {
830
+ this._logger.trace('ClientTransport.stageEvents();', { msgId, eventCount: events.length });
831
+ if (this._state === ClientTransportState.CLOSED) {
832
+ this._logger.warn('ClientTransport.stageEvents(); transport is closed', { msgId });
833
+ return;
834
+ }
835
+ if (!this._tree.getNode(msgId)) {
836
+ this._logger.warn('ClientTransport.stageEvents(); msgId not found in tree', { msgId });
837
+ return;
838
+ }
839
+ if (events.length === 0) return;
840
+ const node: EventsNode<TEvent> = { kind: 'event', msgId, events };
841
+ // Apply immediately so any subsequent useMessageSync / tree observer
842
+ // sees the merged state — no window where the staged event can be
843
+ // clobbered by an interleaved observer turn update.
844
+ this._applyEventsToTree([node]);
845
+ this._pendingLocalEvents.push(node);
846
+ }
847
+
848
+ stageMessage(msgId: string, message: TMessage): void {
849
+ this._logger.trace('ClientTransport.stageMessage();', { msgId });
850
+ if (this._state === ClientTransportState.CLOSED) {
851
+ this._logger.warn('ClientTransport.stageMessage(); transport is closed', { msgId });
852
+ return;
853
+ }
854
+ const existing = this._tree.getNode(msgId);
855
+ if (!existing) {
856
+ this._logger.warn('ClientTransport.stageMessage(); msgId not found in tree', { msgId });
857
+ return;
858
+ }
859
+ // Preserve structural metadata; only the message body changes.
860
+ this._tree.upsert(msgId, message, existing.headers, existing.serial);
861
+ }
862
+
863
+ // Apply events to the tree using the codec's accumulator. Shared by
864
+ // stageEvents (local staging) and _internalSend (external eventNodes
865
+ // arriving via view.update).
866
+ private _applyEventsToTree(eventNodes: EventsNode<TEvent>[]): void {
867
+ for (const node of eventNodes) {
868
+ const existingNode = this._tree.getNode(node.msgId);
869
+ if (!existingNode) continue;
870
+ const outputs = node.events.map((event) => ({
871
+ kind: 'event' as const,
872
+ event,
873
+ messageId: node.msgId,
874
+ }));
875
+ const accumulator = this._codec.createAccumulator();
876
+ accumulator.initMessage(node.msgId, existingNode.message);
877
+ accumulator.processOutputs(outputs);
878
+ const updatedMsg = accumulator.messages.at(-1);
879
+ if (updatedMsg) {
880
+ this._tree.upsert(node.msgId, updatedMsg, existingNode.headers, existingNode.serial);
881
+ }
882
+ }
883
+ }
884
+
758
885
  // Spec: AIT-CT18
759
886
  async waitForTurn(filter?: CancelFilter): Promise<void> {
760
- if (this._closed) return;
887
+ if (this._state === ClientTransportState.CLOSED) return;
761
888
  const resolved = filter ?? { own: true };
762
889
  const remaining = this._getMatchingTurnIds(resolved);
763
890
  if (remaining.size === 0) return;
@@ -765,153 +892,42 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
765
892
  this._logger.debug('ClientTransport.waitForTurn();', { turnIds: [...remaining] });
766
893
 
767
894
  return new Promise<void>((resolve) => {
768
- const handler = (event: TurnLifecycleEvent): void => {
895
+ let resolved = false;
896
+ const done = (): void => {
897
+ if (resolved) return;
898
+ resolved = true;
899
+ unsub();
900
+ const idx = this._closeResolvers.indexOf(done);
901
+ if (idx !== -1) this._closeResolvers.splice(idx, 1);
902
+ resolve();
903
+ };
904
+
905
+ const unsub = this._tree.on('turn', (event: TurnLifecycleEvent) => {
769
906
  if (event.type !== EVENT_TURN_END) return;
770
907
  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);
908
+ if (remaining.size === 0) done();
909
+ });
910
+
911
+ // Resolve on transport close to prevent leaked subscriptions
912
+ this._closeResolvers.push(done);
777
913
  });
778
914
  }
779
915
 
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.
916
+ // Spec: AIT-CT8, AIT-CT8c, AIT-CT8d
917
+ on(event: 'error', handler: (error: Ably.ErrorInfo) => void): () => void {
918
+ if (this._state === ClientTransportState.CLOSED) return noopUnsubscribe;
919
+ // CAST: the overload signature enforces the correct handler type.
791
920
  const cb = handler as (arg: ClientTransportEventsMap[keyof ClientTransportEventsMap]) => void;
792
- this._emitter.on(eventName, cb);
921
+ this._emitter.on(event, cb);
793
922
  return () => {
794
- this._emitter.off(eventName, cb);
923
+ this._emitter.off(event, cb);
795
924
  };
796
925
  }
797
926
 
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
927
+ // Spec: AIT-CT12, AIT-CT12a, AIT-CT12b, AIT-CT10c
912
928
  async close(options?: CloseOptions): Promise<void> {
913
- if (this._closed) return;
914
- this._closed = true;
929
+ if (this._state === ClientTransportState.CLOSED) return;
930
+ this._state = ClientTransportState.CLOSED;
915
931
  this._logger.info('ClientTransport.close();');
916
932
 
917
933
  // Best-effort cancel publish before tearing down local state
@@ -925,6 +941,7 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
925
941
  }
926
942
 
927
943
  this._channel.unsubscribe(this._onMessage);
944
+ this._channel.off(this._onChannelStateChange);
928
945
 
929
946
  // Close any remaining active streams
930
947
  for (const turnId of this._ownTurnIds) {
@@ -933,12 +950,13 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
933
950
 
934
951
  this._turnObservers.clear();
935
952
  this._emitter.off();
953
+ for (const v of this._views) v.close();
954
+ this._views.clear();
955
+ for (const resolve of this._closeResolvers) resolve();
956
+ this._closeResolvers.length = 0;
936
957
  this._ownTurnIds.clear();
937
958
  this._ownMsgIds.clear();
938
959
  this._turnMsgIds.clear();
939
- this._turnClientIds.clear();
940
- this._withheldKeys.clear();
941
- this._ablyMessages.length = 0;
942
960
  }
943
961
  }
944
962