@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,5 +1,5 @@
1
1
  /**
2
- * ConversationTree — materializes a branching conversation from a flat
2
+ * Tree — materializes a branching conversation from a flat
3
3
  * oplog of Ably messages using serial-first ordering.
4
4
  *
5
5
  * Serial order (the total order assigned by Ably) is the primary mechanism
@@ -9,39 +9,91 @@
9
9
  *
10
10
  * `upsert()` is the sole mutation method. Messages can arrive in any order
11
11
  * (live subscription, history pages, seed data) and the tree produces the
12
- * correct `flatten()` output once all messages are present.
12
+ * correct `flattenNodes()` output once all messages are present.
13
13
  *
14
- * The tree owns conversation state. `flatten()` returns the linear message
14
+ * The tree owns conversation state. `flattenNodes()` returns the linear node
15
15
  * list for the currently selected branches — this is what the transport's
16
16
  * `getMessages()` delegates to.
17
17
  */
18
18
 
19
+ import type * as Ably from 'ably';
20
+
19
21
  import { HEADER_FORK_OF, HEADER_PARENT } from '../../constants.js';
22
+ import { EventEmitter } from '../../event-emitter.js';
20
23
  import type { Logger } from '../../logger.js';
21
- import type { ConversationNode, ConversationTree } from './types.js';
24
+ import type { MessageNode, Tree, TurnLifecycleEvent } from './types.js';
22
25
 
23
26
  // ---------------------------------------------------------------------------
24
27
  // Internal node type
25
28
  // ---------------------------------------------------------------------------
26
29
 
27
30
  interface InternalNode<TMessage> {
28
- node: ConversationNode<TMessage>;
31
+ node: MessageNode<TMessage>;
29
32
  /** Insertion sequence — tiebreaker for null-serial messages. */
30
33
  insertSeq: number;
31
34
  }
32
35
 
36
+ // ---------------------------------------------------------------------------
37
+ // Internal interface — extended surface consumed by View
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** Internal tree surface used by View — not part of the public Tree API. */
41
+ export interface TreeInternal<TMessage> extends Tree<TMessage> {
42
+ /**
43
+ * Monotonic counter that increments on structural changes (node insert,
44
+ * delete, serial promotion/reorder) but NOT on content-only updates
45
+ * (existing node's message replaced). Allows the View to skip full
46
+ * tree walks when only message content changed.
47
+ */
48
+ readonly structuralVersion: number;
49
+
50
+ /**
51
+ * Flatten the tree along selected branches into a linear node list.
52
+ * The `selections` map provides the selected sibling's msgId at each
53
+ * fork point, keyed by group root msgId. Fork points not present in
54
+ * the map default to the latest sibling. If a selectedMsgId is not
55
+ * found in the sibling group (stale/deleted), falls back to latest.
56
+ */
57
+ flattenNodes(selections: Map<string, string>): MessageNode<TMessage>[];
58
+
59
+ /**
60
+ * Get the "group root" msgId for a sibling group — the original message
61
+ * that all forks in the group trace back to.
62
+ */
63
+ getGroupRoot(msgId: string): string;
64
+
65
+ /**
66
+ * Get the sibling group that `msgId` belongs to, as full MessageNode objects.
67
+ * Allows callers to resolve index ↔ msgId without losing identity.
68
+ */
69
+ getSiblingNodes(msgId: string): MessageNode<TMessage>[];
70
+
71
+ /** Forward a raw Ably message event to tree subscribers. */
72
+ emitAblyMessage(msg: Ably.InboundMessage): void;
73
+ /** Forward a turn lifecycle event to tree subscribers. */
74
+ emitTurn(event: TurnLifecycleEvent): void;
75
+ /** Register an active turn. */
76
+ trackTurn(turnId: string, clientId: string): void;
77
+ /** Unregister an active turn. */
78
+ untrackTurn(turnId: string): void;
79
+ }
80
+
33
81
  // ---------------------------------------------------------------------------
34
82
  // Implementation
35
83
  // ---------------------------------------------------------------------------
36
84
 
85
+ /** EventEmitter events map for the tree. */
86
+ interface TreeEventsMap {
87
+ update: undefined;
88
+ 'ably-message': Ably.InboundMessage;
89
+ turn: TurnLifecycleEvent;
90
+ }
91
+
37
92
  // Spec: AIT-CT13
38
- class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
93
+ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
39
94
  /** All nodes indexed by msgId (x-ably-msg-id). */
40
95
  private readonly _nodeIndex = new Map<string, InternalNode<TMessage>>();
41
96
 
42
- /** Secondary index: codec message key to msgId. Bridges UIMessage.id to x-ably-msg-id. */
43
- private readonly _codecKeyIndex = new Map<string, string>();
44
-
45
97
  /**
46
98
  * All nodes sorted by serial (lexicographic). Null-serial messages
47
99
  * (optimistic inserts, seed data) sort after all serial-bearing messages,
@@ -55,21 +107,25 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
55
107
  */
56
108
  private readonly _parentIndex = new Map<string | undefined, Set<string>>();
57
109
 
58
- /**
59
- * Selected sibling index at each fork point, keyed by the msgId of
60
- * the first sibling in the group (the fork target). Default: last.
61
- */
62
- private readonly _selections = new Map<string, number>();
63
-
64
- private readonly _getKey: (message: TMessage) => string;
110
+ private readonly _emitter: EventEmitter<TreeEventsMap>;
65
111
  private readonly _logger: Logger;
66
112
 
113
+ /** Active turns: turnId → clientId. */
114
+ private readonly _turnClientIds = new Map<string, string>();
115
+
67
116
  /** Monotonically increasing counter for insertion sequence. */
68
117
  private _seqCounter = 0;
69
118
 
70
- constructor(getKey: (message: TMessage) => string, logger: Logger) {
71
- this._getKey = getKey;
119
+ /** Incremented on structural changes; unchanged on content-only updates. */
120
+ private _structuralVersion = 0;
121
+
122
+ get structuralVersion(): number {
123
+ return this._structuralVersion;
124
+ }
125
+
126
+ constructor(logger: Logger) {
72
127
  this._logger = logger;
128
+ this._emitter = new EventEmitter<TreeEventsMap>(logger);
73
129
  }
74
130
 
75
131
  // -------------------------------------------------------------------------
@@ -171,7 +227,7 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
171
227
  * @returns The ordered list of sibling nodes.
172
228
  */
173
229
  // Spec: AIT-CT13b
174
- private _getSiblingGroup(msgId: string): ConversationNode<TMessage>[] {
230
+ private _getSiblingGroup(msgId: string): MessageNode<TMessage>[] {
175
231
  const entry = this._nodeIndex.get(msgId);
176
232
  if (!entry) return [];
177
233
 
@@ -218,7 +274,7 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
218
274
  * @param originalId - The group root to match against.
219
275
  * @returns True if the node belongs to the sibling group.
220
276
  */
221
- private _isSiblingOf(node: ConversationNode<TMessage>, originalId: string): boolean {
277
+ private _isSiblingOf(node: MessageNode<TMessage>, originalId: string): boolean {
222
278
  if (node.msgId === originalId) return true;
223
279
  let current = node;
224
280
  const visited = new Set<string>([current.msgId]);
@@ -239,7 +295,7 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
239
295
  * @param msgId - Any msg-id in the sibling group.
240
296
  * @returns The msg-id of the group root.
241
297
  */
242
- private _getGroupRoot(msgId: string): string {
298
+ getGroupRoot(msgId: string): string {
243
299
  const entry = this._nodeIndex.get(msgId);
244
300
  if (!entry) return msgId;
245
301
 
@@ -259,8 +315,9 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
259
315
  // Public query methods
260
316
  // -------------------------------------------------------------------------
261
317
 
262
- flatten(): TMessage[] {
263
- const result: TMessage[] = [];
318
+ flattenNodes(selections: Map<string, string>): MessageNode<TMessage>[] {
319
+ this._logger.trace('DefaultTree.flattenNodes();');
320
+ const result: MessageNode<TMessage>[] = [];
264
321
  const currentPath = new Set<string>();
265
322
  // Track which sibling groups we've already resolved to avoid
266
323
  // re-resolving for every member of the group.
@@ -278,14 +335,18 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
278
335
  // Step 2: Check sibling selection.
279
336
  const group = this._getSiblingGroup(msgId);
280
337
  if (group.length > 1) {
281
- const groupRootId = this._getGroupRoot(msgId);
338
+ const groupRootId = this.getGroupRoot(msgId);
282
339
  let selectedId = resolvedGroups.get(groupRootId);
283
340
  if (selectedId === undefined) {
284
- const selectedIdx = this._selections.get(groupRootId) ?? group.length - 1;
285
- const clamped = Math.max(0, Math.min(selectedIdx, group.length - 1));
286
- const selected = group[clamped];
287
- if (!selected) break; // unreachable: clamped is always in bounds
288
- selectedId = selected.msgId;
341
+ const preferredId = selections.get(groupRootId);
342
+ // Verify the preferred msgId is in the group, otherwise default to latest
343
+ if (preferredId && group.some((n) => n.msgId === preferredId)) {
344
+ selectedId = preferredId;
345
+ } else {
346
+ const latest = group.at(-1);
347
+ if (!latest) break; // unreachable: group.length > 1
348
+ selectedId = latest.msgId;
349
+ }
289
350
  resolvedGroups.set(groupRootId, selectedId);
290
351
  }
291
352
  if (msgId !== selectedId) {
@@ -294,49 +355,32 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
294
355
  }
295
356
 
296
357
  currentPath.add(msgId);
297
- result.push(node.message);
358
+ result.push(node);
298
359
  }
299
360
 
300
361
  return result;
301
362
  }
302
363
 
303
364
  getSiblings(msgId: string): TMessage[] {
365
+ this._logger.trace('DefaultTree.getSiblings();', { msgId });
304
366
  return this._getSiblingGroup(msgId).map((n) => n.message);
305
367
  }
306
368
 
307
- hasSiblings(msgId: string): boolean {
308
- return this._getSiblingGroup(msgId).length > 1;
309
- }
310
-
311
- getSelectedIndex(msgId: string): number {
312
- const group = this._getSiblingGroup(msgId);
313
- if (group.length <= 1) return 0;
314
- const groupRootId = this._getGroupRoot(msgId);
315
- const stored = this._selections.get(groupRootId);
316
- if (stored !== undefined) return Math.max(0, Math.min(stored, group.length - 1));
317
- return group.length - 1; // default: latest
318
- }
319
-
320
- // Spec: AIT-CT13c
321
- select(msgId: string, index: number): void {
322
- this._logger.debug('ConversationTree.select();', { msgId, index });
323
- const group = this._getSiblingGroup(msgId);
324
- if (group.length <= 1) return;
325
- const groupRootId = this._getGroupRoot(msgId);
326
- this._selections.set(groupRootId, Math.max(0, Math.min(index, group.length - 1)));
369
+ getSiblingNodes(msgId: string): MessageNode<TMessage>[] {
370
+ return this._getSiblingGroup(msgId);
327
371
  }
328
372
 
329
- getNode(msgId: string): ConversationNode<TMessage> | undefined {
330
- return this._nodeIndex.get(msgId)?.node;
373
+ hasSiblings(msgId: string): boolean {
374
+ return this._getSiblingGroup(msgId).length > 1;
331
375
  }
332
376
 
333
- getNodeByKey(key: string): ConversationNode<TMessage> | undefined {
334
- const msgId = this._codecKeyIndex.get(key);
335
- if (!msgId) return undefined;
377
+ getNode(msgId: string): MessageNode<TMessage> | undefined {
378
+ this._logger.trace('DefaultTree.getNode();', { msgId });
336
379
  return this._nodeIndex.get(msgId)?.node;
337
380
  }
338
381
 
339
382
  getHeaders(msgId: string): Record<string, string> | undefined {
383
+ this._logger.trace('DefaultTree.getHeaders();', { msgId });
340
384
  return this._nodeIndex.get(msgId)?.node.headers;
341
385
  }
342
386
 
@@ -348,9 +392,6 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
348
392
  const parentId = headers[HEADER_PARENT] ?? undefined;
349
393
  const forkOf = headers[HEADER_FORK_OF] ?? undefined;
350
394
 
351
- // Maintain codec key → msgId secondary index
352
- this._codecKeyIndex.set(this._getKey(message), msgId);
353
-
354
395
  const existing = this._nodeIndex.get(msgId);
355
396
  if (existing) {
356
397
  // Update in place — message content may have changed (e.g. streaming).
@@ -363,18 +404,21 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
363
404
  // Spec: AIT-CT13d
364
405
  // Promote serial: optimistic (null) → server-assigned on relay.
365
406
  if (serial && !existing.node.serial) {
366
- this._logger.debug('ConversationTree.upsert(); promoting serial', { msgId, serial });
407
+ this._logger.debug('Tree.upsert(); promoting serial', { msgId, serial });
367
408
  existing.node.serial = serial;
368
409
  // Re-sort: remove from current position, re-insert at correct position.
369
410
  this._removeSorted(existing);
370
411
  this._insertSorted(existing);
412
+ this._structuralVersion++;
371
413
  }
414
+ this._emitter.emit('update');
372
415
  return;
373
416
  }
374
417
 
375
- this._logger.trace('ConversationTree.upsert(); inserting new node', { msgId, parentId, forkOf });
418
+ this._logger.trace('Tree.upsert(); inserting new node', { msgId, parentId, forkOf });
376
419
 
377
- const node: ConversationNode<TMessage> = {
420
+ const node: MessageNode<TMessage> = {
421
+ kind: 'message',
378
422
  message,
379
423
  msgId,
380
424
  parentId,
@@ -387,22 +431,18 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
387
431
  this._nodeIndex.set(msgId, internal);
388
432
  this._addToParentIndex(parentId, msgId);
389
433
  this._insertSorted(internal);
434
+ this._structuralVersion++;
435
+ this._emitter.emit('update');
390
436
  }
391
437
 
392
438
  delete(msgId: string): void {
393
439
  const entry = this._nodeIndex.get(msgId);
394
440
  if (!entry) return;
395
441
 
396
- this._logger.debug('ConversationTree.delete();', { msgId });
442
+ this._logger.debug('Tree.delete();', { msgId });
397
443
 
398
444
  const { node } = entry;
399
445
 
400
- // Clean up secondary index
401
- const codecKey = this._getKey(node.message);
402
- if (this._codecKeyIndex.get(codecKey) === msgId) {
403
- this._codecKeyIndex.delete(codecKey);
404
- }
405
-
406
446
  // Remove from parent index
407
447
  this._removeFromParentIndex(node.parentId, msgId);
408
448
 
@@ -411,10 +451,87 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
411
451
 
412
452
  // Remove from primary index
413
453
  this._nodeIndex.delete(msgId);
414
- this._selections.delete(msgId);
415
454
 
416
- // Children are NOT deleted — they become unreachable in flatten()
455
+ // Children are NOT deleted — they become unreachable in flattenNodes()
417
456
  // because their parent is no longer on the active path.
457
+ this._structuralVersion++;
458
+ this._emitter.emit('update');
459
+ }
460
+
461
+ // -------------------------------------------------------------------------
462
+ // Events
463
+ // -------------------------------------------------------------------------
464
+
465
+ // Spec: AIT-CT17
466
+ getActiveTurnIds(): Map<string, Set<string>> {
467
+ this._logger.trace('DefaultTree.getActiveTurnIds();');
468
+ const result = new Map<string, Set<string>>();
469
+ for (const [turnId, clientId] of this._turnClientIds) {
470
+ let set = result.get(clientId);
471
+ if (!set) {
472
+ set = new Set<string>();
473
+ result.set(clientId, set);
474
+ }
475
+ set.add(turnId);
476
+ }
477
+ return result;
478
+ }
479
+
480
+ // Spec: AIT-CT8b, AIT-CT8e
481
+ on(event: 'update', handler: () => void): () => void;
482
+ on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
483
+ on(event: 'turn', handler: (event: TurnLifecycleEvent) => void): () => void;
484
+ on(
485
+ event: 'update' | 'ably-message' | 'turn',
486
+ handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: TurnLifecycleEvent) => void),
487
+ ): () => void {
488
+ // CAST: overload signatures enforce correct handler types per event name.
489
+ const cb = handler as (arg: TreeEventsMap[keyof TreeEventsMap]) => void;
490
+ this._emitter.on(event, cb);
491
+ return () => {
492
+ this._emitter.off(event, cb);
493
+ };
494
+ }
495
+
496
+ // -------------------------------------------------------------------------
497
+ // Internal methods (called by the transport, not part of Tree interface)
498
+ // -------------------------------------------------------------------------
499
+
500
+ /**
501
+ * Forward a raw Ably message event to tree subscribers.
502
+ * @param msg - The raw Ably message to emit.
503
+ */
504
+ emitAblyMessage(msg: Ably.InboundMessage): void {
505
+ this._logger.trace('DefaultTree.emitAblyMessage();');
506
+ this._emitter.emit('ably-message', msg);
507
+ }
508
+
509
+ /**
510
+ * Forward a turn lifecycle event to tree subscribers.
511
+ * @param event - The turn lifecycle event to emit.
512
+ */
513
+ emitTurn(event: TurnLifecycleEvent): void {
514
+ this._logger.trace('DefaultTree.emitTurn();', { turnId: event.turnId });
515
+ this._emitter.emit('turn', event);
516
+ }
517
+
518
+ /**
519
+ * Register an active turn.
520
+ * @param turnId - The turn's unique identifier.
521
+ * @param clientId - The client that owns the turn.
522
+ */
523
+ trackTurn(turnId: string, clientId: string): void {
524
+ this._logger.trace('DefaultTree.trackTurn();', { turnId, clientId });
525
+ this._turnClientIds.set(turnId, clientId);
526
+ }
527
+
528
+ /**
529
+ * Unregister an active turn.
530
+ * @param turnId - The turn to untrack.
531
+ */
532
+ untrackTurn(turnId: string): void {
533
+ this._logger.trace('DefaultTree.untrackTurn();', { turnId });
534
+ this._turnClientIds.delete(turnId);
418
535
  }
419
536
  }
420
537
 
@@ -423,12 +540,10 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
423
540
  // ---------------------------------------------------------------------------
424
541
 
425
542
  /**
426
- * Create a ConversationTree that materializes branching history from a flat oplog.
427
- * @param getKey - Codec function that returns a stable key for a domain message.
543
+ * Create a Tree that materializes branching history from a flat oplog.
428
544
  * @param logger - Logger for diagnostic output.
429
- * @returns A new {@link ConversationTree} instance.
545
+ * @returns A new {@link DefaultTree} instance. The transport uses DefaultTree
546
+ * directly for internal methods (emitAblyMessage, emitTurn, trackTurn, untrackTurn).
547
+ * Public consumers see the narrower {@link Tree} interface.
430
548
  */
431
- export const createConversationTree = <TMessage>(
432
- getKey: (message: TMessage) => string,
433
- logger: Logger,
434
- ): ConversationTree<TMessage> => new DefaultConversationTree(getKey, logger);
549
+ export const createTree = <TMessage>(logger: Logger): DefaultTree<TMessage> => new DefaultTree(logger);
@@ -11,6 +11,8 @@ import type * as Ably from 'ably';
11
11
  import {
12
12
  EVENT_TURN_END,
13
13
  EVENT_TURN_START,
14
+ HEADER_FORK_OF,
15
+ HEADER_PARENT,
14
16
  HEADER_TURN_CLIENT_ID,
15
17
  HEADER_TURN_ID,
16
18
  HEADER_TURN_REASON,
@@ -25,7 +27,12 @@ import type { TurnEndReason } from './types.js';
25
27
  /** Manages active turns and publishes turn lifecycle events on the channel. */
26
28
  export interface TurnManager {
27
29
  /** Register a new turn. Publishes turn-start on the channel. Returns AbortSignal. */
28
- startTurn(turnId: string, clientId?: string, controller?: AbortController): Promise<AbortSignal>;
30
+ startTurn(
31
+ turnId: string,
32
+ clientId?: string,
33
+ controller?: AbortController,
34
+ metadata?: { parent?: string; forkOf?: string },
35
+ ): Promise<AbortSignal>;
29
36
  /** End a turn. Publishes turn-end on the channel. Cleans up internal state. */
30
37
  endTurn(turnId: string, reason: TurnEndReason): Promise<void>;
31
38
  /** Get the AbortSignal for a turn. */
@@ -44,7 +51,7 @@ export interface TurnManager {
44
51
  // Internal state
45
52
  // ---------------------------------------------------------------------------
46
53
 
47
- interface TurnState {
54
+ interface ActiveTurnsEntry {
48
55
  controller: AbortController;
49
56
  clientId: string;
50
57
  }
@@ -56,28 +63,39 @@ interface TurnState {
56
63
  class DefaultTurnManager implements TurnManager {
57
64
  private readonly _channel: Ably.RealtimeChannel;
58
65
  private readonly _logger: Logger | undefined;
59
- private readonly _activeTurns = new Map<string, TurnState>();
66
+ private readonly _activeTurns = new Map<string, ActiveTurnsEntry>();
60
67
 
61
68
  constructor(channel: Ably.RealtimeChannel, logger?: Logger) {
62
69
  this._channel = channel;
63
70
  this._logger = logger?.withContext({ component: 'TurnManager' });
64
71
  }
65
72
 
66
- async startTurn(turnId: string, clientId?: string, externalController?: AbortController): Promise<AbortSignal> {
73
+ async startTurn(
74
+ turnId: string,
75
+ clientId?: string,
76
+ externalController?: AbortController,
77
+ metadata?: { parent?: string; forkOf?: string },
78
+ ): Promise<AbortSignal> {
67
79
  this._logger?.trace('DefaultTurnManager.startTurn();', { turnId, clientId });
68
80
 
69
81
  const controller = externalController ?? new AbortController();
70
82
  const resolvedClientId = clientId ?? '';
71
83
  this._activeTurns.set(turnId, { controller, clientId: resolvedClientId });
72
84
 
85
+ const headers: Record<string, string> = {
86
+ [HEADER_TURN_ID]: turnId,
87
+ [HEADER_TURN_CLIENT_ID]: resolvedClientId,
88
+ };
89
+ if (metadata?.parent !== undefined) {
90
+ headers[HEADER_PARENT] = metadata.parent;
91
+ }
92
+ if (metadata?.forkOf !== undefined) {
93
+ headers[HEADER_FORK_OF] = metadata.forkOf;
94
+ }
95
+
73
96
  await this._channel.publish({
74
97
  name: EVENT_TURN_START,
75
- extras: {
76
- headers: {
77
- [HEADER_TURN_ID]: turnId,
78
- [HEADER_TURN_CLIENT_ID]: resolvedClientId,
79
- },
80
- },
98
+ extras: { headers },
81
99
  });
82
100
 
83
101
  this._logger?.debug('DefaultTurnManager.startTurn(); turn started', { turnId });