@ably/ai-transport 0.2.0 → 0.3.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 (166) hide show
  1. package/README.md +10 -19
  2. package/dist/ably-ai-transport.js +1790 -1091
  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 +2 -2
  7. package/dist/core/agent.d.ts +20 -5
  8. package/dist/core/channel-options.d.ts +57 -0
  9. package/dist/core/codec/codec-event.d.ts +9 -0
  10. package/dist/core/codec/decoder.d.ts +4 -1
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +2 -7
  13. package/dist/core/codec/field-bag.d.ts +85 -0
  14. package/dist/core/codec/fields.d.ts +141 -0
  15. package/dist/core/codec/index.d.ts +8 -1
  16. package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
  17. package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
  18. package/dist/core/codec/input-descriptors.d.ts +281 -0
  19. package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
  20. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  21. package/dist/core/codec/output-descriptors.d.ts +237 -0
  22. package/dist/core/codec/types.d.ts +95 -36
  23. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  24. package/dist/core/transport/agent-view.d.ts +296 -0
  25. package/dist/core/transport/decode-fold.d.ts +40 -32
  26. package/dist/core/transport/headers.d.ts +30 -1
  27. package/dist/core/transport/index.d.ts +1 -1
  28. package/dist/core/transport/invocation.d.ts +1 -1
  29. package/dist/core/transport/load-history-pages.d.ts +71 -0
  30. package/dist/core/transport/load-history.d.ts +21 -16
  31. package/dist/core/transport/run-manager.d.ts +9 -11
  32. package/dist/core/transport/session-support.d.ts +55 -0
  33. package/dist/core/transport/tree.d.ts +165 -15
  34. package/dist/core/transport/types/agent.d.ts +120 -98
  35. package/dist/core/transport/types/client.d.ts +45 -12
  36. package/dist/core/transport/types/tree.d.ts +52 -10
  37. package/dist/core/transport/types/view.d.ts +55 -28
  38. package/dist/core/transport/view.d.ts +176 -58
  39. package/dist/core/transport/wire-log.d.ts +102 -0
  40. package/dist/errors.d.ts +10 -4
  41. package/dist/index.d.ts +6 -5
  42. package/dist/react/ably-ai-transport-react.js +784 -415
  43. package/dist/react/ably-ai-transport-react.js.map +1 -1
  44. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  45. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  46. package/dist/react/contexts/client-session-context.d.ts +2 -1
  47. package/dist/react/contexts/client-session-provider.d.ts +3 -0
  48. package/dist/react/index.d.ts +2 -1
  49. package/dist/react/internal/skipped-session.d.ts +8 -0
  50. package/dist/react/use-view.d.ts +3 -3
  51. package/dist/utils.d.ts +22 -54
  52. package/dist/vercel/ably-ai-transport-vercel.js +2297 -2026
  53. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  55. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  56. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  57. package/dist/vercel/codec/events.d.ts +1 -2
  58. package/dist/vercel/codec/fields.d.ts +44 -0
  59. package/dist/vercel/codec/fold-content.d.ts +16 -0
  60. package/dist/vercel/codec/fold-data.d.ts +16 -0
  61. package/dist/vercel/codec/fold-input.d.ts +67 -0
  62. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  63. package/dist/vercel/codec/fold-text.d.ts +16 -0
  64. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  65. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  66. package/dist/vercel/codec/index.d.ts +5 -30
  67. package/dist/vercel/codec/inputs.d.ts +11 -0
  68. package/dist/vercel/codec/outputs.d.ts +11 -0
  69. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  70. package/dist/vercel/codec/reducer.d.ts +20 -102
  71. package/dist/vercel/codec/tool-transitions.d.ts +0 -6
  72. package/dist/vercel/codec/wire-data.d.ts +34 -0
  73. package/dist/vercel/index.d.ts +1 -0
  74. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2013 -9500
  75. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  76. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -70
  77. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  78. package/dist/vercel/react/contexts/chat-transport-context.d.ts +2 -1
  79. package/dist/vercel/run-end-reason.d.ts +66 -11
  80. package/dist/vercel/tool-part.d.ts +21 -0
  81. package/dist/vercel/transport/chat-transport.d.ts +0 -2
  82. package/dist/vercel/transport/index.d.ts +1 -1
  83. package/dist/vercel/transport/run-output-stream.d.ts +6 -8
  84. package/dist/version.d.ts +1 -1
  85. package/package.json +2 -2
  86. package/src/constants.ts +2 -2
  87. package/src/core/agent.ts +43 -19
  88. package/src/core/channel-options.ts +89 -0
  89. package/src/core/codec/codec-event.ts +27 -0
  90. package/src/core/codec/decoder.ts +145 -21
  91. package/src/core/codec/define-codec.ts +432 -0
  92. package/src/core/codec/encoder.ts +13 -54
  93. package/src/core/codec/field-bag.ts +142 -0
  94. package/src/core/codec/fields.ts +193 -0
  95. package/src/core/codec/index.ts +43 -0
  96. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  97. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  98. package/src/core/codec/input-descriptors.ts +373 -0
  99. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  100. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  101. package/src/core/codec/output-descriptors.ts +307 -0
  102. package/src/core/codec/types.ts +99 -36
  103. package/src/core/codec/well-known-inputs.ts +96 -0
  104. package/src/core/transport/agent-session.ts +330 -589
  105. package/src/core/transport/agent-view.ts +738 -0
  106. package/src/core/transport/client-session.ts +74 -69
  107. package/src/core/transport/decode-fold.ts +57 -47
  108. package/src/core/transport/headers.ts +57 -4
  109. package/src/core/transport/index.ts +2 -1
  110. package/src/core/transport/invocation.ts +1 -1
  111. package/src/core/transport/load-history-pages.ts +220 -0
  112. package/src/core/transport/load-history.ts +63 -61
  113. package/src/core/transport/pipe-stream.ts +10 -1
  114. package/src/core/transport/run-manager.ts +25 -31
  115. package/src/core/transport/session-support.ts +96 -0
  116. package/src/core/transport/tree.ts +414 -47
  117. package/src/core/transport/types/agent.ts +129 -102
  118. package/src/core/transport/types/client.ts +49 -13
  119. package/src/core/transport/types/tree.ts +61 -12
  120. package/src/core/transport/types/view.ts +57 -28
  121. package/src/core/transport/view.ts +520 -172
  122. package/src/core/transport/wire-log.ts +189 -0
  123. package/src/errors.ts +10 -3
  124. package/src/index.ts +44 -11
  125. package/src/react/contexts/client-session-context.ts +1 -1
  126. package/src/react/contexts/client-session-provider.tsx +38 -2
  127. package/src/react/index.ts +2 -1
  128. package/src/react/internal/skipped-session.ts +62 -0
  129. package/src/react/use-client-session.ts +7 -30
  130. package/src/react/use-view.ts +3 -3
  131. package/src/utils.ts +31 -97
  132. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  133. package/src/vercel/codec/events.ts +1 -3
  134. package/src/vercel/codec/fields.ts +58 -0
  135. package/src/vercel/codec/fold-content.ts +54 -0
  136. package/src/vercel/codec/fold-data.ts +46 -0
  137. package/src/vercel/codec/fold-input.ts +255 -0
  138. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  139. package/src/vercel/codec/fold-text.ts +55 -0
  140. package/src/vercel/codec/fold-tool-input.ts +86 -0
  141. package/src/vercel/codec/fold-tool-output.ts +79 -0
  142. package/src/vercel/codec/index.ts +23 -63
  143. package/src/vercel/codec/inputs.ts +116 -0
  144. package/src/vercel/codec/outputs.ts +207 -0
  145. package/src/vercel/codec/reducer-state.ts +169 -0
  146. package/src/vercel/codec/reducer.ts +52 -838
  147. package/src/vercel/codec/tool-transitions.ts +1 -12
  148. package/src/vercel/codec/wire-data.ts +64 -0
  149. package/src/vercel/index.ts +1 -0
  150. package/src/vercel/react/contexts/chat-transport-context.ts +1 -1
  151. package/src/vercel/react/use-chat-transport.ts +8 -28
  152. package/src/vercel/react/use-message-sync.ts +5 -10
  153. package/src/vercel/run-end-reason.ts +95 -16
  154. package/src/vercel/tool-part.ts +25 -0
  155. package/src/vercel/transport/chat-transport.ts +10 -22
  156. package/src/vercel/transport/index.ts +1 -1
  157. package/src/vercel/transport/run-output-stream.ts +7 -8
  158. package/src/version.ts +1 -1
  159. package/dist/core/transport/branch-chain.d.ts +0 -43
  160. package/dist/core/transport/load-conversation.d.ts +0 -128
  161. package/dist/vercel/codec/decoder.d.ts +0 -9
  162. package/dist/vercel/codec/encoder.d.ts +0 -11
  163. package/src/core/transport/branch-chain.ts +0 -58
  164. package/src/core/transport/load-conversation.ts +0 -355
  165. package/src/vercel/codec/decoder.ts +0 -696
  166. package/src/vercel/codec/encoder.ts +0 -548
@@ -25,6 +25,7 @@ import type * as Ably from 'ably';
25
25
 
26
26
  import {
27
27
  HEADER_CODEC_MESSAGE_ID,
28
+ HEADER_EVENT_ID,
28
29
  HEADER_FORK_OF,
29
30
  HEADER_INPUT_CODEC_MESSAGE_ID,
30
31
  HEADER_INVOCATION_ID,
@@ -33,20 +34,62 @@ import {
33
34
  HEADER_ROLE,
34
35
  HEADER_RUN_CLIENT_ID,
35
36
  HEADER_RUN_ID,
37
+ HEADER_STREAM,
36
38
  } from '../../constants.js';
37
39
  import { EventEmitter } from '../../event-emitter.js';
38
40
  import type { Logger } from '../../logger.js';
39
- import type { CodecInputEvent, CodecOutputEvent, Reducer } from '../codec/types.js';
41
+ import { getTransportHeaders } from '../../utils.js';
42
+ import { toCodecEvents } from '../codec/codec-event.js';
43
+ import type { CodecEvent, CodecInputEvent, CodecOutputEvent, Reducer } from '../codec/types.js';
40
44
  import type { ConversationNode, InputNode, OutputEvent, RunLifecycleEvent, RunNode, Tree } from './types.js';
45
+ import { WireLog } from './wire-log.js';
41
46
 
42
47
  // ---------------------------------------------------------------------------
43
48
  // Internal node type
44
49
  // ---------------------------------------------------------------------------
45
50
 
46
- interface InternalNode<TProjection> {
51
+ /**
52
+ * How long (in ms, on the Ably message-timestamp timeline) a structurally
53
+ * complete run's event log is retained after the node's last observed
54
+ * activity. Bounds cross-publisher live delivery reorder: a wire can be
55
+ * delivered after a higher-serial wire by at most this window. Conservative
56
+ * placeholder pending confirmation of the actual cross-region bound.
57
+ */
58
+ export const REORDER_WINDOW_MS = 120_000;
59
+
60
+ interface InternalNode<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection> {
47
61
  node: ConversationNode<TProjection>;
48
62
  /** Insertion sequence — tiebreaker for nodes with no sort serial (optimistic). */
49
63
  insertSeq: number;
64
+ /**
65
+ * The node's event log: every serial-bearing wire applied to this node, in
66
+ * canonical serial order. Owns its own record/refold/replay-guard/sweep
67
+ * mutation (see {@link WireLog}). Optimistic (serial-less) applies are not
68
+ * recorded.
69
+ */
70
+ log: WireLog<CodecEvent<TInput, TOutput>>;
71
+ /**
72
+ * Max Ably message timestamp (epoch ms) of everything applied to this node,
73
+ * including its run lifecycle events; 0 until a timestamped apply. The
74
+ * retention sweep measures {@link REORDER_WINDOW_MS} from here.
75
+ */
76
+ lastActivityTs: number;
77
+ /**
78
+ * Whether this run's `ai-run-start` has been observed (run nodes only —
79
+ * always false for input nodes). The structural half of log retention:
80
+ * run-start is the run's serial floor, so once it is observed no older
81
+ * history page can deliver further wires for this node.
82
+ */
83
+ runStartSeen: boolean;
84
+ /** Whether this node is already queued for sweeping (guards double-enqueue). */
85
+ sweepQueued: boolean;
86
+ /**
87
+ * Whether an optimistic (serial-less) seed has been folded into the
88
+ * projection but not into the log. The first serial-bearing wire (the echo)
89
+ * refolds the node from the log alone, discarding the seed, then clears
90
+ * this — so a codec needs no seed-replacement logic of its own.
91
+ */
92
+ optimistic: boolean;
50
93
  }
51
94
 
52
95
  /**
@@ -145,26 +188,38 @@ export interface TreeInternal<
145
188
  * @param events.outputs - Agent-published events (`ai-output` wire).
146
189
  * @param headers - Transport headers from the inbound Ably message.
147
190
  * @param serial - Ably channel serial; undefined for optimistic inserts.
191
+ * @param timestamp - Ably server timestamp (epoch ms) of the message —
192
+ * top-level `Message.timestamp`, the message's create time on every
193
+ * delivery (an append's own receive time lives in `version.timestamp`) —
194
+ * or undefined for optimistic inserts. Advances the Tree's event-log
195
+ * retention clock and the owning node's last-activity time.
196
+ * @param version - The delivery's `Message.version.serial`, or undefined
197
+ * when the delivery carried none (optimistic inserts, never-mutated
198
+ * deliveries from sources that omit it). Guards the node's event log
199
+ * against whole-wire replays: a delivery at or below the version already
200
+ * decoded into its log entry is dropped.
148
201
  */
149
202
  applyMessage(
150
203
  events: { inputs: TInput[]; outputs: TOutput[] },
151
204
  headers: Record<string, string>,
152
205
  serial?: string,
206
+ timestamp?: number,
207
+ version?: string,
153
208
  ): void;
154
209
 
155
210
  /**
156
211
  * Apply a run-lifecycle event.
157
212
  *
158
213
  * - `start`: creates the reply run (if missing) or, for an existing run,
159
- * sets RunNode.status to 'active', promotes startSerial, and backfills
214
+ * sets RunNode.state to 'active', promotes startSerial, and backfills
160
215
  * structural metadata (parent / forkOf / regenerates / invocationId).
161
- * - `suspend`: sets RunNode.status to 'suspended' and records `endSerial`.
216
+ * - `suspend`: sets RunNode.state to 'suspended' and records `endSerial`.
162
217
  * The run stays live so a resume under the same `runId` picks up where it
163
218
  * left off.
164
- * - `resume`: re-activates an existing suspended Run (status back to
219
+ * - `resume`: re-activates an existing suspended Run (state back to
165
220
  * 'active') without touching its structure or serials — a pure re-entry
166
221
  * signal. A no-op if the Run is not yet known.
167
- * - `end`: sets RunNode.status to the terminal reason and records
222
+ * - `end`: sets RunNode.state to the terminal reason and records
168
223
  * `endSerial`.
169
224
  *
170
225
  * Always emits a 'run' event to subscribers.
@@ -214,7 +269,7 @@ export class DefaultTree<
214
269
  TOutput extends CodecOutputEvent,
215
270
  TProjection,
216
271
  > implements TreeInternal<TInput, TOutput, TProjection> {
217
- private readonly _codec: Reducer<TInput | TOutput, TProjection>;
272
+ private readonly _codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>;
218
273
  private readonly _logger: Logger;
219
274
  private readonly _emitter: EventEmitter<TreeEventsMap<TOutput>>;
220
275
 
@@ -222,7 +277,7 @@ export class DefaultTree<
222
277
  * All nodes indexed by their primary key ({@link nodeKey}): a reply run's
223
278
  * runId, or an input node's codec-message-id.
224
279
  */
225
- private readonly _nodeIndex = new Map<string, InternalNode<TProjection>>();
280
+ private readonly _nodeIndex = new Map<string, InternalNode<TInput, TOutput, TProjection>>();
226
281
 
227
282
  /**
228
283
  * Maps every observed `codec-message-id` to its owning node's key
@@ -240,7 +295,7 @@ export class DefaultTree<
240
295
  * serial (optimistic) sort after all serial-bearing nodes, ordered among
241
296
  * themselves by insertion sequence.
242
297
  */
243
- private readonly _sortedNodes: InternalNode<TProjection>[] = [];
298
+ private readonly _sortedNodes: InternalNode<TInput, TOutput, TProjection>[] = [];
244
299
 
245
300
  /**
246
301
  * Parent index: parent node key (the key its children's
@@ -273,10 +328,37 @@ export class DefaultTree<
273
328
  * the View calls `getSiblingNodes` once per visible node plus extra
274
329
  * per-message branch-anchor probes from React components.
275
330
  */
276
- private _siblingCache = new Map<string, InternalNode<TProjection>[]>();
331
+ private _siblingCache = new Map<string, InternalNode<TInput, TOutput, TProjection>[]>();
277
332
  private _siblingCacheVersion = -1;
278
333
 
279
- constructor(codec: Reducer<TInput | TOutput, TProjection>, logger: Logger) {
334
+ /**
335
+ * Index from `event-id` header to the raw Ably message that carried it.
336
+ * Populated incrementally as messages arrive via {@link emitAblyMessage};
337
+ * reads back the raw message for the agent's input-event lookup
338
+ * ({@link findAblyMessageByEventId}). Bounded by the Tree's lifetime — cleared
339
+ * when the Tree is replaced on continuity loss / session close.
340
+ */
341
+ private readonly _eventIdIndex = new Map<string, Ably.InboundMessage>();
342
+
343
+ /**
344
+ * Event-log retention logical clock: the max Ably message timestamp (epoch
345
+ * ms) observed across every apply, 0 until the first timestamped one. Only
346
+ * ever advances — older-page history application carries smaller timestamps
347
+ * and leaves it (and therefore the sweep) untouched.
348
+ */
349
+ private _clock = 0;
350
+
351
+ /**
352
+ * Keys of structurally complete run nodes (run-start and run-end both
353
+ * observed) whose event logs await the retention window, in completion
354
+ * order. Drained from the front whenever {@link _clock} advances; sweeping
355
+ * only at clock advances keeps a history page's batch atomic — applying an
356
+ * older page can never advance the clock, so a node cannot be swept between
357
+ * its run-start and the rest of its wires in the same page.
358
+ */
359
+ private readonly _sweepQueue: string[] = [];
360
+
361
+ constructor(codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>, logger: Logger) {
280
362
  this._codec = codec;
281
363
  this._logger = logger;
282
364
  this._emitter = new EventEmitter<TreeEventsMap<TOutput>>(logger);
@@ -302,7 +384,10 @@ export class DefaultTree<
302
384
  * @returns Negative if a sorts before b, positive if after, zero if equal.
303
385
  */
304
386
  // Spec: AIT-CT13a
305
- private _compareNodes(a: InternalNode<TProjection>, b: InternalNode<TProjection>): number {
387
+ private _compareNodes(
388
+ a: InternalNode<TInput, TOutput, TProjection>,
389
+ b: InternalNode<TInput, TOutput, TProjection>,
390
+ ): number {
306
391
  const sa = sortSerial(a.node);
307
392
  const sb = sortSerial(b.node);
308
393
  if (sa === undefined && sb === undefined) return a.insertSeq - b.insertSeq;
@@ -317,7 +402,7 @@ export class DefaultTree<
317
402
  * Insert a node into the sorted list at the correct position via binary search.
318
403
  * @param internal - The node to insert.
319
404
  */
320
- private _insertSortedNode(internal: InternalNode<TProjection>): void {
405
+ private _insertSortedNode(internal: InternalNode<TInput, TOutput, TProjection>): void {
321
406
  const startSerial = sortSerial(internal.node);
322
407
 
323
408
  // Fast path: null-startSerial always appends to end.
@@ -345,7 +430,7 @@ export class DefaultTree<
345
430
  * Remove a node from the sorted list.
346
431
  * @param internal - The node to remove.
347
432
  */
348
- private _removeSortedNode(internal: InternalNode<TProjection>): void {
433
+ private _removeSortedNode(internal: InternalNode<TInput, TOutput, TProjection>): void {
349
434
  const idx = this._sortedNodes.indexOf(internal);
350
435
  if (idx !== -1) this._sortedNodes.splice(idx, 1);
351
436
  }
@@ -359,7 +444,11 @@ export class DefaultTree<
359
444
  * @param entry - The internal node to insert.
360
445
  * @param parentCodecMessageId - The node's structural parent, or undefined for a root.
361
446
  */
362
- private _insertNode(key: string, entry: InternalNode<TProjection>, parentCodecMessageId: string | undefined): void {
447
+ private _insertNode(
448
+ key: string,
449
+ entry: InternalNode<TInput, TOutput, TProjection>,
450
+ parentCodecMessageId: string | undefined,
451
+ ): void {
363
452
  this._nodeIndex.set(key, entry);
364
453
  this._addToParentIndex(parentCodecMessageId, key);
365
454
  this._insertSortedNode(entry);
@@ -373,7 +462,7 @@ export class DefaultTree<
373
462
  * optimistic-serial promotion paths when the server relay/echo arrives.
374
463
  * @param entry - The internal node whose serial was just promoted.
375
464
  */
376
- private _promoteSerial(entry: InternalNode<TProjection>): void {
465
+ private _promoteSerial(entry: InternalNode<TInput, TOutput, TProjection>): void {
377
466
  this._removeSortedNode(entry);
378
467
  this._insertSortedNode(entry);
379
468
  this._structuralVersion++;
@@ -389,8 +478,8 @@ export class DefaultTree<
389
478
  * @param messageId - The reducer routing key (codec-message-id), or undefined.
390
479
  */
391
480
  private _foldInto(
392
- entry: InternalNode<TProjection>,
393
- events: (TInput | TOutput)[],
481
+ entry: InternalNode<TInput, TOutput, TProjection>,
482
+ events: CodecEvent<TInput, TOutput>[],
394
483
  serial: string | undefined,
395
484
  messageId: string | undefined,
396
485
  ): void {
@@ -403,6 +492,196 @@ export class DefaultTree<
403
492
  }
404
493
  }
405
494
 
495
+ /**
496
+ * Record a serial-bearing wire in the node's event log and fold it. Events
497
+ * extending the log tail (the common case — in-order live delivery) fold
498
+ * incrementally onto the existing projection, identical to a bare
499
+ * {@link _foldInto}. Events that land earlier in the log (an earlier-serial
500
+ * wire delivered late — cross-publisher reorder, or a history page applying
501
+ * an older message after a newer one) cannot be folded incrementally without
502
+ * corrupting serial order, so the node is refolded from the whole log via
503
+ * {@link _refold}.
504
+ *
505
+ * Optimistic (serial-less) applies and empty event batches are not logged;
506
+ * an optimistic seed folds into the projection but never into the log, and
507
+ * marks the node `optimistic`. The first serial-bearing wire (the echo of
508
+ * the optimistic input, which re-delivers the seeded content) refolds the
509
+ * node from the log alone — rebuilding the projection without the seed
510
+ * rather than folding the echo on top of it. The codec therefore never sees
511
+ * the seed and its echo in one projection, and needs no seed-replacement
512
+ * logic. The seed must be a faithful preview of the echo, since the echo's
513
+ * content is what survives.
514
+ *
515
+ * Whole-wire replays are dropped at the log: each entry records the highest
516
+ * `Message.version.serial` decoded into it (`decodedThrough`), so a
517
+ * version-bearing delivery the entry has already incorporated — a second
518
+ * hydration over a populated Tree, a remounted View's re-fetch, an agent
519
+ * re-walk — records nothing and folds nothing. A newer version of a
520
+ * discrete wire (an edited discrete) is likewise dropped; propagating edits
521
+ * into projections is deliberately out of scope.
522
+ * @param entry - The internal node whose log and projection are updated.
523
+ * @param events - The decoded events to fold, in wire order.
524
+ * @param serial - Ably channel serial; undefined for an optimistic insert.
525
+ * @param messageId - The reducer routing key (codec-message-id), or undefined.
526
+ * @param version - The delivery's `Message.version.serial`, or undefined.
527
+ * @param streamed - Whether the delivery is part of a streamed wire.
528
+ */
529
+ private _recordAndFold(
530
+ entry: InternalNode<TInput, TOutput, TProjection>,
531
+ events: CodecEvent<TInput, TOutput>[],
532
+ serial: string | undefined,
533
+ messageId: string | undefined,
534
+ version: string | undefined,
535
+ streamed: boolean,
536
+ ): void {
537
+ // A serial-less optimistic seed (or an empty batch) is not logged. Fold it
538
+ // in; a non-empty seed marks the node so its echo refolds the seed away.
539
+ if (serial === undefined || events.length === 0) {
540
+ if (serial === undefined && events.length > 0) entry.optimistic = true;
541
+ this._foldInto(entry, events, serial, messageId);
542
+ return;
543
+ }
544
+
545
+ const fold = entry.log.record(serial, messageId, events, version, streamed);
546
+ if (fold === 'dropped') {
547
+ // The version guard rejected a re-delivery the log already incorporated —
548
+ // a whole-wire replay (second hydration, remount, agent re-walk, or a
549
+ // `loadOlder()` re-applying a swept run's history) or an edit to a
550
+ // discrete. Nothing to fold.
551
+ this._logger.debug('Tree._recordAndFold(); version guard dropped re-delivered wire', {
552
+ key: nodeKey(entry.node),
553
+ serial,
554
+ version,
555
+ swept: entry.log.swept,
556
+ });
557
+ return;
558
+ }
559
+ if (entry.optimistic && !entry.log.swept) {
560
+ // First serial-bearing wire (the echo) on a node that carries an
561
+ // optimistic seed. The seed is in the projection but not the log, so
562
+ // refold from the log alone — the echo re-delivers the seeded content —
563
+ // rebuilding the projection without the seed instead of folding the echo
564
+ // on top of it.
565
+ entry.optimistic = false;
566
+ this._refold(entry);
567
+ return;
568
+ }
569
+ if (fold === 'refold') {
570
+ this._refold(entry);
571
+ return;
572
+ }
573
+ // 'incremental'. On a swept log this is a genuinely-new wire outside the
574
+ // reorder window (it should not occur) folding in arrival order — the log
575
+ // could not refold it.
576
+ if (entry.log.swept) {
577
+ this._logger.warn('Tree._recordAndFold(); late wire after log retention window; folding in arrival order', {
578
+ key: nodeKey(entry.node),
579
+ serial,
580
+ });
581
+ }
582
+ this._foldInto(entry, events, serial, messageId);
583
+ }
584
+
585
+ /**
586
+ * Rebuild a node's projection from its event log in canonical serial order:
587
+ * a fresh {@link Reducer.init} folded through every logged event, each with
588
+ * its own wire's serial and messageId. Used when a late, earlier-serial wire
589
+ * makes incremental folding unsound. Reducer purity (a fold is a function of
590
+ * its inputs alone) is what makes the rebuild faithful; the per-fold
591
+ * try/catch mirrors {@link _foldInto} so one throwing event can't abort the
592
+ * rebuild.
593
+ *
594
+ * Rebuilds the projection only; the surrounding apply emits its usual
595
+ * `output` event carrying just the triggering wire's events. Consumers read
596
+ * the rebuilt state from `node.projection` (the View recomputes its message
597
+ * list from it), so on the refold path the event's `events` payload is not a
598
+ * delta of the full projection change.
599
+ * @param entry - The internal node whose projection is rebuilt in place.
600
+ */
601
+ private _refold(entry: InternalNode<TInput, TOutput, TProjection>): void {
602
+ let projection = this._codec.init();
603
+ entry.log.replay((event, serial, messageId) => {
604
+ try {
605
+ projection = this._codec.fold(projection, event, { serial, messageId });
606
+ } catch (error) {
607
+ this._logger.error('Tree._refold(); fold threw', { key: nodeKey(entry.node), messageId, err: error });
608
+ }
609
+ });
610
+ entry.node.projection = projection;
611
+ }
612
+
613
+ // -------------------------------------------------------------------------
614
+ // Event-log retention
615
+ // -------------------------------------------------------------------------
616
+
617
+ /**
618
+ * Record activity on a node and advance the retention clock. Updates the
619
+ * node's `lastActivityTs` and the Tree-wide `_clock` to the given timestamp
620
+ * when it is newer; a clock advance drains the sweep queue. `undefined`
621
+ * (an optimistic local apply) advances nothing.
622
+ * @param entry - The node the activity belongs to.
623
+ * @param timestamp - Ably message timestamp (epoch ms), or undefined.
624
+ */
625
+ private _recordActivity(entry: InternalNode<TInput, TOutput, TProjection>, timestamp: number | undefined): void {
626
+ if (timestamp === undefined) return;
627
+ if (timestamp > entry.lastActivityTs) entry.lastActivityTs = timestamp;
628
+ if (timestamp > this._clock) {
629
+ this._clock = timestamp;
630
+ this._drainSweepQueue();
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Queue a run node's event log for retention sweeping once the node is
636
+ * structurally complete: its run-start (serial floor — no older history page
637
+ * can add to it) and its run-end (no further agent output) have both been
638
+ * observed. The actual drop happens in {@link _drainSweepQueue} once the
639
+ * reorder window has also lapsed. No-op for input nodes (never swept — no
640
+ * floor marker, and their logs are bounded by one user message), for nodes
641
+ * already queued or swept, and while either marker is missing.
642
+ * @param entry - The node to consider for sweeping.
643
+ */
644
+ private _maybeQueueSweep(entry: InternalNode<TInput, TOutput, TProjection>): void {
645
+ const node = entry.node;
646
+ if (node.kind !== 'run') return;
647
+ if (entry.log.swept || entry.sweepQueued) return;
648
+ if (!entry.runStartSeen) return;
649
+ if (node.state.status === 'active' || node.state.status === 'suspended') return;
650
+ entry.sweepQueued = true;
651
+ this._sweepQueue.push(node.runId);
652
+ }
653
+
654
+ /**
655
+ * Drop the event logs of queued nodes whose retention window has lapsed:
656
+ * `lastActivityTs + REORDER_WINDOW_MS < _clock`. Drains from the front and
657
+ * stops at the first node still inside the window — completion order is
658
+ * time-ordered for live traffic, so this is amortised O(1) per apply, and
659
+ * stopping early only ever over-retains (memory, never correctness). Called
660
+ * only when the clock advances, so applying an older history page (smaller
661
+ * timestamps) can never sweep mid-batch. Deleted nodes are skipped.
662
+ */
663
+ private _drainSweepQueue(): void {
664
+ while (this._sweepQueue.length > 0) {
665
+ const key = this._sweepQueue[0];
666
+ const entry = key === undefined ? undefined : this._nodeIndex.get(key);
667
+ if (!entry || entry.log.swept) {
668
+ this._sweepQueue.shift();
669
+ continue;
670
+ }
671
+ if (entry.lastActivityTs + REORDER_WINDOW_MS >= this._clock) return;
672
+ this._sweepQueue.shift();
673
+ entry.sweepQueued = false;
674
+ // Drop the decoded payloads (the unbounded cost) but keep each entry's
675
+ // replay key, so a post-sweep whole-wire replay is still recognised and
676
+ // dropped rather than re-folded (a refold can no longer rebuild them).
677
+ entry.log.sweep();
678
+ this._logger.debug('Tree._drainSweepQueue(); dropped event-log payloads, kept replay keys', {
679
+ key,
680
+ lastActivityTs: entry.lastActivityTs,
681
+ });
682
+ }
683
+ }
684
+
406
685
  // -------------------------------------------------------------------------
407
686
  // Parent index maintenance
408
687
  // -------------------------------------------------------------------------
@@ -471,7 +750,7 @@ export class DefaultTree<
471
750
  * @returns The ordered list of sibling nodes.
472
751
  */
473
752
  // Spec: AIT-CT13b
474
- private _getSiblingGroup(key: string): InternalNode<TProjection>[] {
753
+ private _getSiblingGroup(key: string): InternalNode<TInput, TOutput, TProjection>[] {
475
754
  if (this._siblingCacheVersion !== this._structuralVersion) {
476
755
  this._siblingCache.clear();
477
756
  this._siblingCacheVersion = this._structuralVersion;
@@ -495,7 +774,7 @@ export class DefaultTree<
495
774
  // still files/groups correctly — the parent codec-message-id is known at
496
775
  // creation, the resolved key may not be.
497
776
  const parentKey = original.parentCodecMessageId;
498
- const siblings: InternalNode<TProjection>[] = [];
777
+ const siblings: InternalNode<TInput, TOutput, TProjection>[] = [];
499
778
  const candidateKeys = this._parentIndex.get(parentKey);
500
779
  if (candidateKeys) {
501
780
  for (const childKey of candidateKeys) {
@@ -668,6 +947,8 @@ export class DefaultTree<
668
947
  events: { inputs: TInput[]; outputs: TOutput[] },
669
948
  headers: Record<string, string>,
670
949
  serial?: string,
950
+ timestamp?: number,
951
+ version?: string,
671
952
  ): void {
672
953
  const wireRunId = headers[HEADER_RUN_ID];
673
954
  const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
@@ -691,7 +972,7 @@ export class DefaultTree<
691
972
  }
692
973
 
693
974
  // Fold inputs first, then outputs, preserving wire order.
694
- const all: (TInput | TOutput)[] = [...events.inputs, ...events.outputs];
975
+ const all: CodecEvent<TInput, TOutput>[] = toCodecEvents(events);
695
976
 
696
977
  // Wire-only metadata-carrier messages (e.g. `ait-regenerate`) decode to
697
978
  // zero events and don't need a node at the tree level — the eventual reply
@@ -710,9 +991,9 @@ export class DefaultTree<
710
991
  const structuralBefore = this._structuralVersion;
711
992
 
712
993
  if (inputNodeCodecMessageId !== undefined) {
713
- this._applyInputMessage(inputNodeCodecMessageId, headers, serial, all);
994
+ this._applyInputMessage(inputNodeCodecMessageId, headers, serial, timestamp, version, all);
714
995
  } else if (wireRunId !== undefined) {
715
- this._applyRunMessage(wireRunId, events, headers, serial);
996
+ this._applyRunMessage(wireRunId, events, headers, serial, timestamp, version);
716
997
  }
717
998
 
718
999
  if (this._structuralVersion !== structuralBefore) this._emitter.emit('update');
@@ -726,13 +1007,17 @@ export class DefaultTree<
726
1007
  * @param codecMessageId - The input node's codec-message-id (its primary key).
727
1008
  * @param headers - Transport headers from the inbound Ably message.
728
1009
  * @param serial - Ably channel serial; undefined for an optimistic insert.
729
- * @param all - The decoded input events to fold, in wire order.
1010
+ * @param timestamp - Ably server timestamp (epoch ms); undefined for an optimistic insert.
1011
+ * @param version - The delivery's `Message.version.serial`, or undefined.
1012
+ * @param all - The direction-tagged input events to fold, in wire order.
730
1013
  */
731
1014
  private _applyInputMessage(
732
1015
  codecMessageId: string,
733
1016
  headers: Record<string, string>,
734
1017
  serial: string | undefined,
735
- all: (TInput | TOutput)[],
1018
+ timestamp: number | undefined,
1019
+ version: string | undefined,
1020
+ all: CodecEvent<TInput, TOutput>[],
736
1021
  ): void {
737
1022
  let entry = this._nodeIndex.get(codecMessageId);
738
1023
  if (!entry) {
@@ -747,7 +1032,11 @@ export class DefaultTree<
747
1032
  this._promoteSerial(entry);
748
1033
  }
749
1034
 
750
- this._foldInto(entry, all, serial, codecMessageId);
1035
+ this._recordActivity(entry, timestamp);
1036
+
1037
+ // Log the wire and fold it — incrementally onto the tail in the common
1038
+ // case, or by refolding the node if this wire arrived out of serial order.
1039
+ this._recordAndFold(entry, all, serial, codecMessageId, version, headers[HEADER_STREAM] === 'true');
751
1040
 
752
1041
  // An input node owns no agent outputs; the event still fires (empty
753
1042
  // outputs) so consumers observe the projection change. It has no run-id —
@@ -774,19 +1063,23 @@ export class DefaultTree<
774
1063
  * @param events.outputs - Agent-published events (`ai-output` wire).
775
1064
  * @param headers - Transport headers from the inbound Ably message.
776
1065
  * @param serial - Ably channel serial; undefined for an optimistic insert.
1066
+ * @param timestamp - Ably server timestamp (epoch ms); undefined for an optimistic insert.
1067
+ * @param version - The delivery's `Message.version.serial`, or undefined.
777
1068
  */
778
1069
  private _applyRunMessage(
779
1070
  wireRunId: string,
780
1071
  events: { inputs: TInput[]; outputs: TOutput[] },
781
1072
  headers: Record<string, string>,
782
1073
  serial: string | undefined,
1074
+ timestamp: number | undefined,
1075
+ version: string | undefined,
783
1076
  ): void {
784
1077
  const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
785
1078
  // The triggering input's codec-message-id (the agent's echo), surfaced on
786
1079
  // the `output` event as the stream's causal routing key.
787
1080
  const inputCodecMessageId = headers[HEADER_INPUT_CODEC_MESSAGE_ID];
788
1081
  // Fold inputs first, then outputs, preserving wire order.
789
- const all: (TInput | TOutput)[] = [...events.inputs, ...events.outputs];
1082
+ const all: CodecEvent<TInput, TOutput>[] = toCodecEvents(events);
790
1083
  const outputs = events.outputs;
791
1084
 
792
1085
  let run = this._nodeIndex.get(wireRunId);
@@ -816,7 +1109,13 @@ export class DefaultTree<
816
1109
  const ownerKey = nodeKey(run.node);
817
1110
  if (codecMessageId) this._codecMessageIdToNodeKey.set(codecMessageId, ownerKey);
818
1111
 
819
- this._foldInto(run, all, serial, codecMessageId);
1112
+ this._recordActivity(run, timestamp);
1113
+
1114
+ // Log the wire and fold it — incrementally onto the tail in the common
1115
+ // case, or by refolding the node if this wire arrived out of serial order.
1116
+ // `run` may be a reconciled optimistic node: record on whichever entry
1117
+ // owns the fold.
1118
+ this._recordAndFold(run, all, serial, codecMessageId, version, headers[HEADER_STREAM] === 'true');
820
1119
 
821
1120
  this._emitter.emit('output', { runId: ownerKey, inputCodecMessageId, codecMessageId, serial, events: outputs });
822
1121
  }
@@ -876,8 +1175,12 @@ export class DefaultTree<
876
1175
  const existing = this._nodeIndex.get(event.runId);
877
1176
  if (existing?.node.kind === 'run') {
878
1177
  const node = existing.node;
879
- if (node.status !== 'active') {
880
- node.status = 'active';
1178
+ // Activate only a suspended run. A run-start can be observed AFTER the
1179
+ // run's terminal event (history pages replay newest-first, so an older
1180
+ // page delivers the start last) — like a stray resume, it must never
1181
+ // resurrect a run that has ended.
1182
+ if (node.state.status === 'suspended') {
1183
+ node.state = { status: 'active' };
881
1184
  }
882
1185
  if (event.serial && !node.startSerial) {
883
1186
  node.startSerial = event.serial;
@@ -919,10 +1222,18 @@ export class DefaultTree<
919
1222
  if (node.invocationId === '' && event.invocationId !== '') {
920
1223
  node.invocationId = event.invocationId;
921
1224
  }
1225
+ // The run's serial floor is now observed: no older history page can
1226
+ // deliver further wires for this node. With a terminal status this
1227
+ // makes the node structurally complete — eligible for log retention
1228
+ // sweeping once the reorder window lapses.
1229
+ existing.runStartSeen = true;
1230
+ this._recordActivity(existing, event.timestamp);
1231
+ this._maybeQueueSweep(existing);
922
1232
  } else if (!existing) {
923
1233
  const run = this._createRunFromLifecycle(event);
924
1234
  this._insertNode(event.runId, run, run.node.parentCodecMessageId);
925
1235
  this._indexReplyRun(run.node, event.runId);
1236
+ this._recordActivity(run, event.timestamp);
926
1237
  }
927
1238
  }
928
1239
 
@@ -937,8 +1248,9 @@ export class DefaultTree<
937
1248
  private _applyRunSuspend(event: RunLifecycleEvent & { type: 'suspend' }): void {
938
1249
  const run = this._nodeIndex.get(event.runId);
939
1250
  if (run?.node.kind === 'run') {
940
- run.node.status = 'suspended';
1251
+ run.node.state = { status: 'suspended' };
941
1252
  run.node.endSerial = event.serial;
1253
+ this._recordActivity(run, event.timestamp);
942
1254
  }
943
1255
  }
944
1256
 
@@ -955,23 +1267,33 @@ export class DefaultTree<
955
1267
  */
956
1268
  private _applyRunResume(event: RunLifecycleEvent & { type: 'resume' }): void {
957
1269
  const run = this._nodeIndex.get(event.runId);
958
- if (run?.node.kind === 'run' && run.node.status === 'suspended') {
959
- run.node.status = 'active';
1270
+ if (run?.node.kind === 'run' && run.node.state.status === 'suspended') {
1271
+ run.node.state = { status: 'active' };
1272
+ this._recordActivity(run, event.timestamp);
960
1273
  }
961
1274
  }
962
1275
 
963
1276
  /**
964
- * Apply a run-end lifecycle event: record the terminal reason as the node's
965
- * status and the serial it ended at. Status/endSerial are content, not
966
- * structure, so this never mutates `_structuralVersion`; the caller owns the
967
- * emits.
1277
+ * Apply a run-end lifecycle event: record the terminal reason (and, for an
1278
+ * error end, the error) as the node's state, plus the serial it ended at.
1279
+ * State/endSerial are content, not structure, so this never mutates
1280
+ * `_structuralVersion`; the caller owns the emits.
1281
+ *
1282
+ * A run-end for an unknown runId is a no-op: nothing else is known about the
1283
+ * run yet, so there is no node to mark. When that happens during history
1284
+ * replay (a page boundary falling just before the run-end, so the run's
1285
+ * other wires arrive in later pages), the run is never marked terminal and
1286
+ * its event log is retained for the Tree's lifetime — over-retention, never
1287
+ * corruption.
968
1288
  * @param event - The run-end lifecycle event.
969
1289
  */
970
1290
  private _applyRunEnd(event: RunLifecycleEvent & { type: 'end' }): void {
971
1291
  const run = this._nodeIndex.get(event.runId);
972
1292
  if (run?.node.kind === 'run') {
973
- run.node.status = event.reason;
1293
+ run.node.state = event.reason === 'error' ? { status: 'error', error: event.error } : { status: event.reason };
974
1294
  run.node.endSerial = event.serial;
1295
+ this._recordActivity(run, event.timestamp);
1296
+ this._maybeQueueSweep(run);
975
1297
  }
976
1298
  }
977
1299
 
@@ -1012,7 +1334,7 @@ export class DefaultTree<
1012
1334
  runId: string,
1013
1335
  headers: Record<string, string>,
1014
1336
  serial: string | undefined,
1015
- ): InternalNode<TProjection> {
1337
+ ): InternalNode<TInput, TOutput, TProjection> {
1016
1338
  const forkOfMsgId = headers[HEADER_FORK_OF];
1017
1339
  return this._buildRunNode({
1018
1340
  runId,
@@ -1024,9 +1346,37 @@ export class DefaultTree<
1024
1346
  clientId: headers[HEADER_RUN_CLIENT_ID] ?? '',
1025
1347
  invocationId: headers[HEADER_INVOCATION_ID] ?? '',
1026
1348
  startSerial: serial,
1349
+ // Created from a content wire — the run's ai-run-start has not been
1350
+ // observed (it may still be in an unloaded older history page).
1351
+ runStartSeen: false,
1027
1352
  });
1028
1353
  }
1029
1354
 
1355
+ /**
1356
+ * Wrap a freshly-built conversation node in its internal envelope — sort
1357
+ * sequence, event log, and retention/promotion state. The single home for
1358
+ * those per-node fields, so a new field is added in one place rather than at
1359
+ * every node-construction site.
1360
+ * @param node - The conversation node to wrap.
1361
+ * @param runStartSeen - Whether the run's ai-run-start has been observed
1362
+ * (run nodes only; always false for input nodes).
1363
+ * @returns A newly-allocated internal node ready for insertion.
1364
+ */
1365
+ private _wrapNode(
1366
+ node: ConversationNode<TProjection>,
1367
+ runStartSeen = false,
1368
+ ): InternalNode<TInput, TOutput, TProjection> {
1369
+ return {
1370
+ node,
1371
+ insertSeq: this._seqCounter++,
1372
+ log: new WireLog<CodecEvent<TInput, TOutput>>(),
1373
+ lastActivityTs: 0,
1374
+ runStartSeen,
1375
+ sweepQueued: false,
1376
+ optimistic: false,
1377
+ };
1378
+ }
1379
+
1030
1380
  /**
1031
1381
  * Allocate a RunNode from already-resolved fields. Shared by the
1032
1382
  * header-driven and lifecycle-driven run creators: both build the identical
@@ -1039,6 +1389,7 @@ export class DefaultTree<
1039
1389
  * @param params.clientId - The publishing client's id.
1040
1390
  * @param params.invocationId - The agent invocation id.
1041
1391
  * @param params.startSerial - Ably channel serial; undefined for optimistic inserts.
1392
+ * @param params.runStartSeen - Whether the run's ai-run-start has been observed (true only for lifecycle-created runs).
1042
1393
  * @returns A newly-allocated internal run node ready for insertion.
1043
1394
  */
1044
1395
  private _buildRunNode(params: {
@@ -1049,7 +1400,8 @@ export class DefaultTree<
1049
1400
  clientId: string;
1050
1401
  invocationId: string;
1051
1402
  startSerial: string | undefined;
1052
- }): InternalNode<TProjection> {
1403
+ runStartSeen: boolean;
1404
+ }): InternalNode<TInput, TOutput, TProjection> {
1053
1405
  const node: RunNode<TProjection> = {
1054
1406
  kind: 'run',
1055
1407
  runId: params.runId,
@@ -1058,13 +1410,13 @@ export class DefaultTree<
1058
1410
  regeneratesCodecMessageId: params.regeneratesCodecMessageId,
1059
1411
  clientId: params.clientId,
1060
1412
  invocationId: params.invocationId,
1061
- status: 'active',
1413
+ state: { status: 'active' },
1062
1414
  projection: this._codec.init(),
1063
1415
  startSerial: params.startSerial,
1064
1416
  endSerial: undefined,
1065
1417
  };
1066
1418
 
1067
- return { node, insertSeq: this._seqCounter++ };
1419
+ return this._wrapNode(node, params.runStartSeen);
1068
1420
  }
1069
1421
 
1070
1422
  /**
@@ -1078,7 +1430,7 @@ export class DefaultTree<
1078
1430
  codecMessageId: string,
1079
1431
  headers: Record<string, string>,
1080
1432
  serial: string | undefined,
1081
- ): InternalNode<TProjection> {
1433
+ ): InternalNode<TInput, TOutput, TProjection> {
1082
1434
  const forkOfMsgId = headers[HEADER_FORK_OF];
1083
1435
  const node: InputNode<TProjection> = {
1084
1436
  kind: 'input',
@@ -1090,7 +1442,7 @@ export class DefaultTree<
1090
1442
  projection: this._codec.init(),
1091
1443
  serial,
1092
1444
  };
1093
- return { node, insertSeq: this._seqCounter++ };
1445
+ return this._wrapNode(node);
1094
1446
  }
1095
1447
 
1096
1448
  /**
@@ -1100,7 +1452,9 @@ export class DefaultTree<
1100
1452
  * its channel serial.
1101
1453
  * @returns A newly-allocated internal run node ready for insertion.
1102
1454
  */
1103
- private _createRunFromLifecycle(event: RunLifecycleEvent & { type: 'start' }): InternalNode<TProjection> {
1455
+ private _createRunFromLifecycle(
1456
+ event: RunLifecycleEvent & { type: 'start' },
1457
+ ): InternalNode<TInput, TOutput, TProjection> {
1104
1458
  const forkOfMsgId = event.forkOf;
1105
1459
  return this._buildRunNode({
1106
1460
  runId: event.runId,
@@ -1110,6 +1464,8 @@ export class DefaultTree<
1110
1464
  clientId: event.clientId,
1111
1465
  invocationId: event.invocationId,
1112
1466
  startSerial: event.serial,
1467
+ // Created from the run-start itself — the serial floor is observed.
1468
+ runStartSeen: true,
1113
1469
  });
1114
1470
  }
1115
1471
 
@@ -1139,13 +1495,24 @@ export class DefaultTree<
1139
1495
  }
1140
1496
 
1141
1497
  /**
1142
- * Forward a raw Ably message event to tree subscribers.
1498
+ * Forward a raw Ably message event to tree subscribers. Also indexes the
1499
+ * Ably message by `event-id` header (if present) for
1500
+ * {@link findAblyMessageByEventId} lookups.
1143
1501
  * @param msg - The raw Ably message to emit.
1144
1502
  */
1145
1503
  emitAblyMessage(msg: Ably.InboundMessage): void {
1146
1504
  this._logger.trace('DefaultTree.emitAblyMessage();');
1505
+ const headers = getTransportHeaders(msg);
1506
+ const eventId = headers[HEADER_EVENT_ID];
1507
+ if (eventId !== undefined && !this._eventIdIndex.has(eventId)) {
1508
+ this._eventIdIndex.set(eventId, msg);
1509
+ }
1147
1510
  this._emitter.emit('ably-message', msg);
1148
1511
  }
1512
+
1513
+ findAblyMessageByEventId(eventId: string): Ably.InboundMessage | undefined {
1514
+ return this._eventIdIndex.get(eventId);
1515
+ }
1149
1516
  }
1150
1517
 
1151
1518
  // ---------------------------------------------------------------------------
@@ -1162,6 +1529,6 @@ export class DefaultTree<
1162
1529
  * emitAblyMessage). Public consumers see the narrower {@link Tree} interface.
1163
1530
  */
1164
1531
  export const createTree = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection>(
1165
- codec: Reducer<TInput | TOutput, TProjection>,
1532
+ codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>,
1166
1533
  logger: Logger,
1167
1534
  ): DefaultTree<TInput, TOutput, TProjection> => new DefaultTree<TInput, TOutput, TProjection>(codec, logger);