@ably/ai-transport 0.1.0 → 0.2.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 (163) hide show
  1. package/README.md +91 -100
  2. package/dist/ably-ai-transport.js +1553 -1238
  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 +116 -42
  7. package/dist/core/agent.d.ts +29 -0
  8. package/dist/core/codec/decoder.d.ts +20 -23
  9. package/dist/core/codec/encoder.d.ts +11 -8
  10. package/dist/core/codec/index.d.ts +1 -2
  11. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  12. package/dist/core/codec/types.d.ts +407 -115
  13. package/dist/core/transport/agent-session.d.ts +10 -0
  14. package/dist/core/transport/branch-chain.d.ts +43 -0
  15. package/dist/core/transport/client-session.d.ts +13 -0
  16. package/dist/core/transport/decode-fold.d.ts +47 -0
  17. package/dist/core/transport/headers.d.ts +96 -18
  18. package/dist/core/transport/index.d.ts +5 -6
  19. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  20. package/dist/core/transport/invocation.d.ts +74 -0
  21. package/dist/core/transport/load-conversation.d.ts +128 -0
  22. package/dist/core/transport/load-history.d.ts +39 -0
  23. package/dist/core/transport/pipe-stream.d.ts +9 -9
  24. package/dist/core/transport/run-manager.d.ts +78 -0
  25. package/dist/core/transport/tree.d.ts +373 -109
  26. package/dist/core/transport/types/agent.d.ts +353 -0
  27. package/dist/core/transport/types/client.d.ts +168 -0
  28. package/dist/core/transport/types/shared.d.ts +24 -0
  29. package/dist/core/transport/types/tree.d.ts +315 -0
  30. package/dist/core/transport/types/view.d.ts +222 -0
  31. package/dist/core/transport/types.d.ts +13 -553
  32. package/dist/core/transport/view.d.ts +272 -84
  33. package/dist/errors.d.ts +21 -10
  34. package/dist/index.d.ts +6 -8
  35. package/dist/logger.d.ts +12 -0
  36. package/dist/react/ably-ai-transport-react.js +976 -990
  37. package/dist/react/ably-ai-transport-react.js.map +1 -1
  38. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  39. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  40. package/dist/react/contexts/client-session-context.d.ts +36 -0
  41. package/dist/react/contexts/client-session-provider.d.ts +53 -0
  42. package/dist/react/create-session-hooks.d.ts +116 -0
  43. package/dist/react/index.d.ts +12 -12
  44. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  45. package/dist/react/use-ably-messages.d.ts +17 -14
  46. package/dist/react/use-client-session.d.ts +81 -0
  47. package/dist/react/use-create-view.d.ts +14 -13
  48. package/dist/react/use-tree.d.ts +30 -15
  49. package/dist/react/use-view.d.ts +82 -51
  50. package/dist/utils.d.ts +32 -23
  51. package/dist/vercel/ably-ai-transport-vercel.js +2573 -2086
  52. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  53. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  55. package/dist/vercel/codec/decoder.d.ts +5 -18
  56. package/dist/vercel/codec/encoder.d.ts +6 -36
  57. package/dist/vercel/codec/events.d.ts +51 -0
  58. package/dist/vercel/codec/index.d.ts +24 -12
  59. package/dist/vercel/codec/reducer.d.ts +144 -0
  60. package/dist/vercel/codec/tool-transitions.d.ts +2 -2
  61. package/dist/vercel/index.d.ts +4 -5
  62. package/dist/vercel/react/ably-ai-transport-vercel-react.js +3907 -3266
  63. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  64. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +33 -8
  65. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  66. package/dist/vercel/react/contexts/chat-transport-context.d.ts +7 -6
  67. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
  68. package/dist/vercel/react/index.d.ts +1 -2
  69. package/dist/vercel/react/use-chat-transport.d.ts +30 -26
  70. package/dist/vercel/react/use-message-sync.d.ts +17 -30
  71. package/dist/vercel/run-end-reason.d.ts +29 -0
  72. package/dist/vercel/transport/chat-transport.d.ts +43 -24
  73. package/dist/vercel/transport/index.d.ts +25 -21
  74. package/dist/vercel/transport/run-output-stream.d.ts +56 -0
  75. package/dist/version.d.ts +2 -0
  76. package/package.json +30 -23
  77. package/src/constants.ts +124 -51
  78. package/src/core/agent.ts +68 -0
  79. package/src/core/codec/decoder.ts +71 -98
  80. package/src/core/codec/encoder.ts +113 -65
  81. package/src/core/codec/index.ts +13 -6
  82. package/src/core/codec/lifecycle-tracker.ts +10 -9
  83. package/src/core/codec/types.ts +436 -120
  84. package/src/core/transport/agent-session.ts +1344 -0
  85. package/src/core/transport/branch-chain.ts +58 -0
  86. package/src/core/transport/client-session.ts +775 -0
  87. package/src/core/transport/decode-fold.ts +91 -0
  88. package/src/core/transport/headers.ts +181 -22
  89. package/src/core/transport/index.ts +25 -26
  90. package/src/core/transport/internal/bounded-map.ts +27 -0
  91. package/src/core/transport/invocation.ts +98 -0
  92. package/src/core/transport/load-conversation.ts +355 -0
  93. package/src/core/transport/load-history.ts +269 -0
  94. package/src/core/transport/pipe-stream.ts +54 -39
  95. package/src/core/transport/run-manager.ts +249 -0
  96. package/src/core/transport/tree.ts +926 -308
  97. package/src/core/transport/types/agent.ts +407 -0
  98. package/src/core/transport/types/client.ts +211 -0
  99. package/src/core/transport/types/shared.ts +27 -0
  100. package/src/core/transport/types/tree.ts +344 -0
  101. package/src/core/transport/types/view.ts +259 -0
  102. package/src/core/transport/types.ts +13 -706
  103. package/src/core/transport/view.ts +864 -433
  104. package/src/errors.ts +22 -9
  105. package/src/event-emitter.ts +3 -2
  106. package/src/index.ts +52 -41
  107. package/src/logger.ts +14 -1
  108. package/src/react/contexts/client-session-context.ts +41 -0
  109. package/src/react/contexts/client-session-provider.tsx +186 -0
  110. package/src/react/create-session-hooks.ts +141 -0
  111. package/src/react/index.ts +23 -13
  112. package/src/react/internal/use-resolved-session.ts +63 -0
  113. package/src/react/use-ably-messages.ts +32 -22
  114. package/src/react/use-client-session.ts +201 -0
  115. package/src/react/use-create-view.ts +33 -29
  116. package/src/react/use-tree.ts +61 -30
  117. package/src/react/use-view.ts +139 -97
  118. package/src/utils.ts +63 -45
  119. package/src/vercel/codec/decoder.ts +336 -258
  120. package/src/vercel/codec/encoder.ts +343 -205
  121. package/src/vercel/codec/events.ts +87 -0
  122. package/src/vercel/codec/index.ts +60 -13
  123. package/src/vercel/codec/reducer.ts +977 -0
  124. package/src/vercel/codec/tool-transitions.ts +2 -2
  125. package/src/vercel/index.ts +6 -19
  126. package/src/vercel/react/contexts/chat-transport-context.ts +7 -6
  127. package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
  128. package/src/vercel/react/index.ts +3 -5
  129. package/src/vercel/react/use-chat-transport.ts +47 -49
  130. package/src/vercel/react/use-message-sync.ts +80 -39
  131. package/src/vercel/run-end-reason.ts +78 -0
  132. package/src/vercel/transport/chat-transport.ts +392 -98
  133. package/src/vercel/transport/index.ts +39 -38
  134. package/src/vercel/transport/run-output-stream.ts +170 -0
  135. package/src/version.ts +2 -0
  136. package/dist/core/transport/client-transport.d.ts +0 -10
  137. package/dist/core/transport/decode-history.d.ts +0 -43
  138. package/dist/core/transport/server-transport.d.ts +0 -7
  139. package/dist/core/transport/stream-router.d.ts +0 -29
  140. package/dist/core/transport/turn-manager.d.ts +0 -37
  141. package/dist/react/contexts/transport-context.d.ts +0 -31
  142. package/dist/react/contexts/transport-provider.d.ts +0 -49
  143. package/dist/react/create-transport-hooks.d.ts +0 -124
  144. package/dist/react/use-active-turns.d.ts +0 -12
  145. package/dist/react/use-client-transport.d.ts +0 -80
  146. package/dist/vercel/codec/accumulator.d.ts +0 -21
  147. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
  148. package/dist/vercel/tool-approvals.d.ts +0 -124
  149. package/dist/vercel/tool-events.d.ts +0 -26
  150. package/src/core/transport/client-transport.ts +0 -977
  151. package/src/core/transport/decode-history.ts +0 -485
  152. package/src/core/transport/server-transport.ts +0 -612
  153. package/src/core/transport/stream-router.ts +0 -136
  154. package/src/core/transport/turn-manager.ts +0 -165
  155. package/src/react/contexts/transport-context.ts +0 -37
  156. package/src/react/contexts/transport-provider.tsx +0 -164
  157. package/src/react/create-transport-hooks.ts +0 -144
  158. package/src/react/use-active-turns.ts +0 -72
  159. package/src/react/use-client-transport.ts +0 -197
  160. package/src/vercel/codec/accumulator.ts +0 -588
  161. package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
  162. package/src/vercel/tool-approvals.ts +0 -380
  163. package/src/vercel/tool-events.ts +0 -53
@@ -1,81 +1,199 @@
1
1
  /**
2
- * Tree — materializes a branching conversation from a flat
3
- * oplog of Ably messages using serial-first ordering.
2
+ * Tree — materializes a branching conversation as a forest of nodes. Each turn
3
+ * is two nodes: a user {@link InputNode} keyed by its client-owned
4
+ * codec-message-id and an agent {@link RunNode} keyed by the agent-minted
5
+ * run-id, parented to the input node.
4
6
  *
5
- * Serial order (the total order assigned by Ably) is the primary mechanism
6
- * for linear message sequences. `x-ably-parent` and `x-ably-fork-of` headers
7
- * are only structurally meaningful at branch points where the user is
8
- * interacting with a visible message and the client always has it loaded.
7
+ * Each node holds a per-node codec {@link TProjection} which the Tree folds
8
+ * from inbound events. The Tree owns the complete conversation state across
9
+ * every observed node. The {@link View} walks the parent chain to extract a
10
+ * flat message list for rendering.
9
11
  *
10
- * `upsert()` is the sole mutation method. Messages can arrive in any order
11
- * (live subscription, history pages, seed data) and the tree produces the
12
- * correct `flattenNodes()` output once all messages are present.
12
+ * `applyMessage()` is the entry point for inbound channel messages it
13
+ * classifies a run-less user input into an input node (keyed by
14
+ * codec-message-id) or routes a run-bearing wire to its reply run (keyed by
15
+ * run-id), folds events into that node's projection, and maintains a secondary
16
+ * `codecMessageId -> nodeKey` index. `applyRunLifecycle()` handles run-start /
17
+ * run-suspend / run-resume / run-end events.
13
18
  *
14
- * The tree owns conversation state. `flattenNodes()` returns the linear node
15
- * list for the currently selected branches this is what the transport's
16
- * `getMessages()` delegates to.
19
+ * Sibling structure: editing a prompt produces a sibling input node linked by
20
+ * {@link InputNode.forkOf}; regenerating a reply produces a sibling reply run
21
+ * sharing the same input-node parent (no fork-of).
17
22
  */
18
23
 
19
24
  import type * as Ably from 'ably';
20
25
 
21
- import { HEADER_FORK_OF, HEADER_PARENT } from '../../constants.js';
26
+ import {
27
+ HEADER_CODEC_MESSAGE_ID,
28
+ HEADER_FORK_OF,
29
+ HEADER_INPUT_CODEC_MESSAGE_ID,
30
+ HEADER_INVOCATION_ID,
31
+ HEADER_MSG_REGENERATE,
32
+ HEADER_PARENT,
33
+ HEADER_ROLE,
34
+ HEADER_RUN_CLIENT_ID,
35
+ HEADER_RUN_ID,
36
+ } from '../../constants.js';
22
37
  import { EventEmitter } from '../../event-emitter.js';
23
38
  import type { Logger } from '../../logger.js';
24
- import type { MessageNode, Tree, TurnLifecycleEvent } from './types.js';
39
+ import type { CodecInputEvent, CodecOutputEvent, Reducer } from '../codec/types.js';
40
+ import type { ConversationNode, InputNode, OutputEvent, RunLifecycleEvent, RunNode, Tree } from './types.js';
25
41
 
26
42
  // ---------------------------------------------------------------------------
27
43
  // Internal node type
28
44
  // ---------------------------------------------------------------------------
29
45
 
30
- interface InternalNode<TMessage> {
31
- node: MessageNode<TMessage>;
32
- /** Insertion sequence — tiebreaker for null-serial messages. */
46
+ interface InternalNode<TProjection> {
47
+ node: ConversationNode<TProjection>;
48
+ /** Insertion sequence — tiebreaker for nodes with no sort serial (optimistic). */
33
49
  insertSeq: number;
34
50
  }
35
51
 
52
+ /**
53
+ * The primary key a node is indexed under: a reply run's `runId`, or an input
54
+ * node's `codecMessageId` (the client owns it before the agent mints a runId).
55
+ * @param node - The node to key.
56
+ * @returns The node's primary key.
57
+ */
58
+ export const nodeKey = <TProjection>(node: ConversationNode<TProjection>): string =>
59
+ node.kind === 'run' ? node.runId : node.codecMessageId;
60
+
61
+ /**
62
+ * The serial a node sorts by: a reply run's `startSerial`, an input node's
63
+ * `serial`. Undefined for an optimistic (not-yet-acked) node, which tail-sorts.
64
+ * @param node - The node to read.
65
+ * @returns The sort serial, or undefined for an optimistic node.
66
+ */
67
+ const sortSerial = <TProjection>(node: ConversationNode<TProjection>): string | undefined =>
68
+ node.kind === 'run' ? node.startSerial : node.serial;
69
+
70
+ /**
71
+ * Add a value to a `Map<K, Set<V>>`, creating the bucket Set on first use.
72
+ * @param map - The Map to mutate.
73
+ * @param key - The bucket key.
74
+ * @param value - The value to add.
75
+ */
76
+ const addToSetMap = <K, V>(map: Map<K, Set<V>>, key: K, value: V): void => {
77
+ let set = map.get(key);
78
+ if (!set) {
79
+ set = new Set();
80
+ map.set(key, set);
81
+ }
82
+ set.add(value);
83
+ };
84
+
85
+ /**
86
+ * Remove a value from a `Map<K, Set<V>>`, dropping the bucket when it empties.
87
+ * @param map - The Map to mutate.
88
+ * @param key - The bucket key.
89
+ * @param value - The value to remove.
90
+ */
91
+ const deleteFromSetMap = <K, V>(map: Map<K, Set<V>>, key: K, value: V): void => {
92
+ const set = map.get(key);
93
+ if (!set) return;
94
+ set.delete(value);
95
+ if (set.size === 0) map.delete(key);
96
+ };
97
+
36
98
  // ---------------------------------------------------------------------------
37
- // Internal interface — extended surface consumed by View
99
+ // Internal interface — extended surface consumed by View / ClientSession
38
100
  // ---------------------------------------------------------------------------
39
101
 
40
- /** Internal tree surface used by View — not part of the public Tree API. */
41
- export interface TreeInternal<TMessage> extends Tree<TMessage> {
102
+ /** Internal tree surface used by View and ClientSession — not part of the public Tree API. */
103
+ export interface TreeInternal<
104
+ TInput extends CodecInputEvent,
105
+ TOutput extends CodecOutputEvent,
106
+ TProjection,
107
+ > extends Tree<TOutput, TProjection> {
108
+ /**
109
+ * Walk the visible node chain (both input nodes and reply runs) along the
110
+ * selected branches, in chronological order. The View renders from this.
111
+ * @param selections - Per-group selected member key, keyed by group root.
112
+ * @returns The visible nodes in chronological order.
113
+ */
114
+ visibleNodes(selections?: Map<string, string>): ConversationNode<TProjection>[];
115
+
116
+ /**
117
+ * Get the "group root" key for a sibling group — the stable key the
118
+ * selection map is keyed by (the earliest edit version for input nodes, the
119
+ * original reply for a regenerate group).
120
+ */
121
+ getGroupRoot(key: string): string;
122
+
123
+ /**
124
+ * The reply runs parented at an input node (its codec-message-id), in
125
+ * iteration order. Empty when none have been observed. Used to resolve a
126
+ * user prompt to its reply run(s).
127
+ * @param inputCodecMessageId - The input node's codec-message-id.
128
+ * @returns The reply runs parented at that input.
129
+ */
130
+ getReplyRuns(inputCodecMessageId: string): RunNode<TProjection>[];
131
+
42
132
  /**
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.
133
+ * Apply an inbound channel message to the tree.
134
+ *
135
+ * Classifies the message and routes it to the owning node:
136
+ * 1. Run-less user input (no run-id, a `user`-role message carrying a
137
+ * codec-message-id and input events): creates or promotes the input node
138
+ * keyed by that codec-message-id, folds the input events.
139
+ * 2. Run-bearing wire (assistant output, continuation tool-resolution, or a
140
+ * fresh agent-minted run): routes to the reply run by run-id (reconciling
141
+ * an optimistic insert by codec-message-id), folds events.
142
+ * @param events - Decoded codec events, split by wire direction. Both are
143
+ * folded into the node's projection, inputs first.
144
+ * @param events.inputs - Client-published events (`ai-input` wire).
145
+ * @param events.outputs - Agent-published events (`ai-output` wire).
146
+ * @param headers - Transport headers from the inbound Ably message.
147
+ * @param serial - Ably channel serial; undefined for optimistic inserts.
47
148
  */
48
- readonly structuralVersion: number;
149
+ applyMessage(
150
+ events: { inputs: TInput[]; outputs: TOutput[] },
151
+ headers: Record<string, string>,
152
+ serial?: string,
153
+ ): void;
49
154
 
50
155
  /**
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.
156
+ * Apply a run-lifecycle event.
157
+ *
158
+ * - `start`: creates the reply run (if missing) or, for an existing run,
159
+ * sets RunNode.status to 'active', promotes startSerial, and backfills
160
+ * structural metadata (parent / forkOf / regenerates / invocationId).
161
+ * - `suspend`: sets RunNode.status to 'suspended' and records `endSerial`.
162
+ * The run stays live so a resume under the same `runId` picks up where it
163
+ * left off.
164
+ * - `resume`: re-activates an existing suspended Run (status back to
165
+ * 'active') without touching its structure or serials — a pure re-entry
166
+ * signal. A no-op if the Run is not yet known.
167
+ * - `end`: sets RunNode.status to the terminal reason and records
168
+ * `endSerial`.
169
+ *
170
+ * Always emits a 'run' event to subscribers.
171
+ * @param event - Lifecycle event payload, including the channel serial.
56
172
  */
57
- flattenNodes(selections: Map<string, string>): MessageNode<TMessage>[];
173
+ applyRunLifecycle(event: RunLifecycleEvent): void;
58
174
 
59
175
  /**
60
- * Get the "group root" msgId for a sibling group the original message
61
- * that all forks in the group trace back to.
176
+ * Get the node keyed by `key`, or undefined if `key` names no node. The
177
+ * key is a {@link nodeKey} a runId (reply run) or an input node's
178
+ * codec-message-id — so the result is a {@link ConversationNode} union:
179
+ * narrow on `kind` before reading kind-specific fields. Pairs with
180
+ * {@link getNodeByCodecMessageId}, which resolves an arbitrary owned
181
+ * codec-message-id (including an assistant message's) to its node.
182
+ * @param key - The node key to look up.
183
+ * @returns The node, or undefined if not found.
62
184
  */
63
- getGroupRoot(msgId: string): string;
185
+ getNode(key: string): ConversationNode<TProjection> | undefined;
64
186
 
65
187
  /**
66
- * Get the sibling group that `msgId` belongs to, as full MessageNode objects.
67
- * Allows callers to resolve index msgId without losing identity.
188
+ * Remove a node from the tree by its key ({@link nodeKey} a runId or an
189
+ * input node's codec-message-id). Children become unreachable because their
190
+ * parent is no longer on the active path.
191
+ * @param key - The node key to remove.
68
192
  */
69
- getSiblingNodes(msgId: string): MessageNode<TMessage>[];
193
+ delete(key: string): void;
70
194
 
71
195
  /** Forward a raw Ably message event to tree subscribers. */
72
196
  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
197
  }
80
198
 
81
199
  // ---------------------------------------------------------------------------
@@ -83,49 +201,85 @@ export interface TreeInternal<TMessage> extends Tree<TMessage> {
83
201
  // ---------------------------------------------------------------------------
84
202
 
85
203
  /** EventEmitter events map for the tree. */
86
- interface TreeEventsMap {
204
+ interface TreeEventsMap<TOutput extends CodecOutputEvent> {
87
205
  update: undefined;
88
206
  'ably-message': Ably.InboundMessage;
89
- turn: TurnLifecycleEvent;
207
+ run: RunLifecycleEvent;
208
+ output: OutputEvent<TOutput>;
90
209
  }
91
210
 
92
211
  // Spec: AIT-CT13
93
- export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
94
- /** All nodes indexed by msgId (x-ably-msg-id). */
95
- private readonly _nodeIndex = new Map<string, InternalNode<TMessage>>();
212
+ export class DefaultTree<
213
+ TInput extends CodecInputEvent,
214
+ TOutput extends CodecOutputEvent,
215
+ TProjection,
216
+ > implements TreeInternal<TInput, TOutput, TProjection> {
217
+ private readonly _codec: Reducer<TInput | TOutput, TProjection>;
218
+ private readonly _logger: Logger;
219
+ private readonly _emitter: EventEmitter<TreeEventsMap<TOutput>>;
96
220
 
97
221
  /**
98
- * All nodes sorted by serial (lexicographic). Null-serial messages
99
- * (optimistic inserts, seed data) sort after all serial-bearing messages,
100
- * ordered among themselves by insertion sequence.
222
+ * All nodes indexed by their primary key ({@link nodeKey}): a reply run's
223
+ * runId, or an input node's codec-message-id.
101
224
  */
102
- private readonly _sortedList: InternalNode<TMessage>[] = [];
225
+ private readonly _nodeIndex = new Map<string, InternalNode<TProjection>>();
103
226
 
104
227
  /**
105
- * Parent index: parentId to set of child msgIds.
106
- * Nodes with no parent are indexed under the key `null`.
228
+ * Maps every observed `codec-message-id` to its owning node's key
229
+ * ({@link nodeKey}). For a reply run that is the runId of every message the
230
+ * run published; for an input node it is the input's own codec-message-id.
231
+ * Resolves fork-of / parent codec-message-ids to node keys, routes
232
+ * continuation amend wires to existing nodes, and backs UI lookups that hold
233
+ * a codec-message-id.
107
234
  */
108
- private readonly _parentIndex = new Map<string | undefined, Set<string>>();
235
+ private readonly _codecMessageIdToNodeKey = new Map<string, string>();
109
236
 
110
- private readonly _emitter: EventEmitter<TreeEventsMap>;
111
- private readonly _logger: Logger;
237
+ /**
238
+ * All nodes sorted by their sort serial ({@link sortSerial}: `startSerial`
239
+ * for runs, `serial` for input nodes), lexicographically. Nodes with no sort
240
+ * serial (optimistic) sort after all serial-bearing nodes, ordered among
241
+ * themselves by insertion sequence.
242
+ */
243
+ private readonly _sortedNodes: InternalNode<TProjection>[] = [];
112
244
 
113
- /** Active turns: turnId → clientId. */
114
- private readonly _turnClientIds = new Map<string, string>();
245
+ /**
246
+ * Parent index: parent node key (the key its children's
247
+ * `parentCodecMessageId` resolves to) to the set of child node keys. Root
248
+ * nodes (no parent) are indexed under the key `undefined`. Kind-blind — a
249
+ * reply run and an input node parent off each other through the same index.
250
+ */
251
+ private readonly _parentIndex = new Map<string | undefined, Set<string>>();
252
+
253
+ /**
254
+ * Reverse edge: an input node's codec-message-id to the set of reply-run ids
255
+ * parented at it. Lets the View resolve a user prompt to its (selected) reply
256
+ * run, and groups regenerate siblings (which all parent at the same input
257
+ * node).
258
+ */
259
+ private readonly _replyRunsByInput = new Map<string, Set<string>>();
115
260
 
116
261
  /** Monotonically increasing counter for insertion sequence. */
117
262
  private _seqCounter = 0;
118
263
 
119
- /** Incremented on structural changes; unchanged on content-only updates. */
264
+ /** Incremented on structural changes; unchanged on projection-only updates. */
120
265
  private _structuralVersion = 0;
121
266
 
122
- get structuralVersion(): number {
123
- return this._structuralVersion;
124
- }
267
+ /**
268
+ * Cached sibling-group lookups keyed by node key. The walk over forkOf
269
+ * chains and the per-parent fan-out are pure functions of the node
270
+ * graph, so the cache is keyed against {@link _structuralVersion}:
271
+ * any topology mutation drops the cache and the next lookup
272
+ * recomputes. Hits matter most during a single render pass where
273
+ * the View calls `getSiblingNodes` once per visible node plus extra
274
+ * per-message branch-anchor probes from React components.
275
+ */
276
+ private _siblingCache = new Map<string, InternalNode<TProjection>[]>();
277
+ private _siblingCacheVersion = -1;
125
278
 
126
- constructor(logger: Logger) {
279
+ constructor(codec: Reducer<TInput | TOutput, TProjection>, logger: Logger) {
280
+ this._codec = codec;
127
281
  this._logger = logger;
128
- this._emitter = new EventEmitter<TreeEventsMap>(logger);
282
+ this._emitter = new EventEmitter<TreeEventsMap<TOutput>>(logger);
129
283
  }
130
284
 
131
285
  // -------------------------------------------------------------------------
@@ -133,83 +287,147 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
133
287
  // -------------------------------------------------------------------------
134
288
 
135
289
  /**
136
- * Compare two nodes for sorted list ordering.
137
- * Serial-bearing nodes sort by serial (lexicographic).
138
- * Null-serial nodes sort after all serial-bearing nodes.
139
- * Among null-serial nodes, sort by insertion sequence.
290
+ * Compare two nodes (Run or input) for sorted list ordering.
291
+ * Serial-bearing nodes sort by their sort serial (`startSerial` for runs,
292
+ * `serial` for input nodes), lexicographically.
293
+ * Nodes with no sort serial sort after all serial-bearing nodes.
294
+ * Among them, sort by insertion sequence.
295
+ *
296
+ * Optimistic (null-serial) nodes intentionally tail-sort so they reorder
297
+ * into place when the server relay arrives and `applyMessage` promotes
298
+ * startSerial — see {@link applyMessage}'s `_removeSortedNode` /
299
+ * `_insertSortedNode` pair on the promotion path.
140
300
  * @param a - First node to compare.
141
301
  * @param b - Second node to compare.
142
302
  * @returns Negative if a sorts before b, positive if after, zero if equal.
143
303
  */
144
304
  // Spec: AIT-CT13a
145
- private _compareNodes(a: InternalNode<TMessage>, b: InternalNode<TMessage>): number {
146
- const sa = a.node.serial;
147
- const sb = b.node.serial;
305
+ private _compareNodes(a: InternalNode<TProjection>, b: InternalNode<TProjection>): number {
306
+ const sa = sortSerial(a.node);
307
+ const sb = sortSerial(b.node);
148
308
  if (sa === undefined && sb === undefined) return a.insertSeq - b.insertSeq;
149
- if (sa === undefined) return 1; // a sorts after serial-bearing b
150
- if (sb === undefined) return -1; // b sorts after serial-bearing a
309
+ if (sa === undefined) return 1;
310
+ if (sb === undefined) return -1;
151
311
  if (sa < sb) return -1;
152
312
  if (sa > sb) return 1;
153
- return a.insertSeq - b.insertSeq; // same serial: preserve insertion order
313
+ return a.insertSeq - b.insertSeq;
154
314
  }
155
315
 
156
316
  /**
157
- * Insert a node into sortedList at the correct position via binary search.
317
+ * Insert a node into the sorted list at the correct position via binary search.
158
318
  * @param internal - The node to insert.
159
319
  */
160
- private _insertSorted(internal: InternalNode<TMessage>): void {
161
- const serial = internal.node.serial;
320
+ private _insertSortedNode(internal: InternalNode<TProjection>): void {
321
+ const startSerial = sortSerial(internal.node);
162
322
 
163
- // Fast path: null-serial always appends to end (among other null-serials)
164
- if (serial === undefined) {
165
- this._sortedList.push(internal);
323
+ // Fast path: null-startSerial always appends to end.
324
+ if (startSerial === undefined) {
325
+ this._sortedNodes.push(internal);
166
326
  return;
167
327
  }
168
328
 
169
- // Binary search for insertion point among serial-bearing nodes.
170
329
  let lo = 0;
171
- let hi = this._sortedList.length;
330
+ let hi = this._sortedNodes.length;
172
331
  while (lo < hi) {
173
332
  const mid = (lo + hi) >>> 1;
174
- const midNode = this._sortedList[mid];
175
- if (!midNode) break; // unreachable: mid is always in bounds
333
+ const midNode = this._sortedNodes[mid];
334
+ if (!midNode) break; // unreachable
176
335
  if (this._compareNodes(midNode, internal) <= 0) {
177
336
  lo = mid + 1;
178
337
  } else {
179
338
  hi = mid;
180
339
  }
181
340
  }
182
- this._sortedList.splice(lo, 0, internal);
341
+ this._sortedNodes.splice(lo, 0, internal);
183
342
  }
184
343
 
185
344
  /**
186
- * Remove a node from sortedList.
345
+ * Remove a node from the sorted list.
187
346
  * @param internal - The node to remove.
188
347
  */
189
- private _removeSorted(internal: InternalNode<TMessage>): void {
190
- const idx = this._sortedList.indexOf(internal);
191
- if (idx !== -1) this._sortedList.splice(idx, 1);
348
+ private _removeSortedNode(internal: InternalNode<TProjection>): void {
349
+ const idx = this._sortedNodes.indexOf(internal);
350
+ if (idx !== -1) this._sortedNodes.splice(idx, 1);
351
+ }
352
+
353
+ /**
354
+ * Insert a freshly-created node into the primary store, the parent index, and
355
+ * the sorted list, then bump the structural version. Kind-specific secondary
356
+ * indexing — the codec-message-id map for input nodes, the reply→input edge
357
+ * for reply runs — is the caller's responsibility.
358
+ * @param key - The node's primary key ({@link nodeKey}).
359
+ * @param entry - The internal node to insert.
360
+ * @param parentCodecMessageId - The node's structural parent, or undefined for a root.
361
+ */
362
+ private _insertNode(key: string, entry: InternalNode<TProjection>, parentCodecMessageId: string | undefined): void {
363
+ this._nodeIndex.set(key, entry);
364
+ this._addToParentIndex(parentCodecMessageId, key);
365
+ this._insertSortedNode(entry);
366
+ this._structuralVersion++;
367
+ }
368
+
369
+ /**
370
+ * Re-sort a node whose sort key just changed and bump the structural version.
371
+ * The caller mutates the serial field (`serial` for input nodes, `startSerial`
372
+ * for runs); this keeps the sorted list and version in step. Used on the
373
+ * optimistic-serial promotion paths when the server relay/echo arrives.
374
+ * @param entry - The internal node whose serial was just promoted.
375
+ */
376
+ private _promoteSerial(entry: InternalNode<TProjection>): void {
377
+ this._removeSortedNode(entry);
378
+ this._insertSortedNode(entry);
379
+ this._structuralVersion++;
380
+ }
381
+
382
+ /**
383
+ * Fold a batch of events into a node's projection in place, isolating each
384
+ * fold in a try/catch so a throwing reducer can't abort the rest of the batch
385
+ * or the surrounding apply.
386
+ * @param entry - The internal node whose projection is folded in place.
387
+ * @param events - The decoded events to fold, in wire order.
388
+ * @param serial - Ably channel serial; coerced to '' for an optimistic insert.
389
+ * @param messageId - The reducer routing key (codec-message-id), or undefined.
390
+ */
391
+ private _foldInto(
392
+ entry: InternalNode<TProjection>,
393
+ events: (TInput | TOutput)[],
394
+ serial: string | undefined,
395
+ messageId: string | undefined,
396
+ ): void {
397
+ for (const event of events) {
398
+ try {
399
+ entry.node.projection = this._codec.fold(entry.node.projection, event, { serial: serial ?? '', messageId });
400
+ } catch (error) {
401
+ this._logger.error('Tree._foldInto(); fold threw', { key: nodeKey(entry.node), messageId, err: error });
402
+ }
403
+ }
192
404
  }
193
405
 
194
406
  // -------------------------------------------------------------------------
195
407
  // Parent index maintenance
196
408
  // -------------------------------------------------------------------------
197
409
 
198
- private _addToParentIndex(parentId: string | undefined, msgId: string): void {
199
- let set = this._parentIndex.get(parentId);
200
- if (!set) {
201
- set = new Set();
202
- this._parentIndex.set(parentId, set);
203
- }
204
- set.add(msgId);
410
+ private _addToParentIndex(parentNodeKey: string | undefined, childKey: string): void {
411
+ addToSetMap(this._parentIndex, parentNodeKey, childKey);
205
412
  }
206
413
 
207
- private _removeFromParentIndex(parentId: string | undefined, msgId: string): void {
208
- const set = this._parentIndex.get(parentId);
209
- if (set) {
210
- set.delete(msgId);
211
- if (set.size === 0) this._parentIndex.delete(parentId);
212
- }
414
+ private _removeFromParentIndex(parentNodeKey: string | undefined, childKey: string): void {
415
+ deleteFromSetMap(this._parentIndex, parentNodeKey, childKey);
416
+ }
417
+
418
+ /**
419
+ * Resolve a node's structural parent to the parent node's key
420
+ * ({@link nodeKey}), or undefined for a root. The parent is named by a
421
+ * codec-message-id (`parentCodecMessageId`); this maps it through the
422
+ * codec-message-id index to the owning node's key (a runId for a reply run,
423
+ * a codec-message-id for an input node). Returns undefined when the parent
424
+ * hasn't been observed yet (the node is treated as a root until it arrives).
425
+ * @param node - The node whose parent to resolve.
426
+ * @returns The parent node's key, or undefined.
427
+ */
428
+ private _parentKeyOf(node: ConversationNode<TProjection>): string | undefined {
429
+ const parentCodecMessageId = node.parentCodecMessageId;
430
+ return parentCodecMessageId === undefined ? undefined : this._codecMessageIdToNodeKey.get(parentCodecMessageId);
213
431
  }
214
432
 
215
433
  // -------------------------------------------------------------------------
@@ -217,286 +435,709 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
217
435
  // -------------------------------------------------------------------------
218
436
 
219
437
  /**
220
- * Get the sibling group that `msgId` belongs to.
438
+ * Walk an input node's `forkOf` chain to the group root — the earliest edit
439
+ * version sharing the same structural parent. Stops at a missing target, a
440
+ * non-input target, a parent mismatch, or a cycle.
441
+ * @param node - The input node to walk from.
442
+ * @returns The group-root input node (the node itself when it is the root).
443
+ */
444
+ private _inputGroupRoot(node: InputNode<TProjection>): InputNode<TProjection> {
445
+ let current = node;
446
+ const visited = new Set<string>([nodeKey(current)]);
447
+ while (current.forkOf !== undefined) {
448
+ if (visited.has(current.forkOf)) break;
449
+ const forkTarget = this._nodeIndex.get(current.forkOf);
450
+ if (forkTarget?.node.kind !== 'input' || forkTarget.node.parentCodecMessageId !== current.parentCodecMessageId) {
451
+ break;
452
+ }
453
+ current = forkTarget.node;
454
+ visited.add(nodeKey(current));
455
+ }
456
+ return current;
457
+ }
458
+
459
+ /**
460
+ * Get the sibling group that the node keyed by `key` belongs to. Kind-split:
461
+ *
462
+ * - **Reply runs** — every reply run sharing the same input-node parent is a
463
+ * sibling (the original reply + its regenerators all parent at the same
464
+ * input node M_user). No fork-of involved.
465
+ * - **Input nodes** — edit versions: nodes sharing a parent AND linked by a
466
+ * `forkOf` chain to the group root.
221
467
  *
222
- * A sibling group is: the original message + all messages whose `forkOf`
223
- * points to the original (or transitively to a sibling). We find the
224
- * group root by following `forkOf` chains to the earliest ancestor that
225
- * has no `forkOf` (or whose `forkOf` target doesn't share the same parent).
226
- * @param msgId - The msg-id to look up the sibling group for.
468
+ * Returned ordered by startSerial (original/oldest first). A group of one is
469
+ * returned as a single-element array (no branching).
470
+ * @param key - The node key ({@link nodeKey}) to look up the group for.
227
471
  * @returns The ordered list of sibling nodes.
228
472
  */
229
473
  // Spec: AIT-CT13b
230
- private _getSiblingGroup(msgId: string): MessageNode<TMessage>[] {
231
- const entry = this._nodeIndex.get(msgId);
474
+ private _getSiblingGroup(key: string): InternalNode<TProjection>[] {
475
+ if (this._siblingCacheVersion !== this._structuralVersion) {
476
+ this._siblingCache.clear();
477
+ this._siblingCacheVersion = this._structuralVersion;
478
+ }
479
+ const cached = this._siblingCache.get(key);
480
+ if (cached) return cached;
481
+
482
+ const entry = this._nodeIndex.get(key);
232
483
  if (!entry) return [];
233
484
 
234
- // Find the "original" the message at the root of the fork chain
235
- // that shares the same parentId. Guard against cycles in forkOf chains.
485
+ // The "original" anchors the group's parent + kind. For an input node,
486
+ // walk the forkOf chain to the earliest version sharing the parent; for a
487
+ // reply run the node itself anchors (all same-parent runs are siblings).
236
488
  let original = entry.node;
237
- const visitedGroup = new Set<string>([original.msgId]);
238
- while (original.forkOf) {
239
- if (visitedGroup.has(original.forkOf)) break; // cycle guard
240
- const forkTarget = this._nodeIndex.get(original.forkOf);
241
- if (!forkTarget || forkTarget.node.parentId !== original.parentId) break;
242
- original = forkTarget.node;
243
- visitedGroup.add(original.msgId);
489
+ if (original.kind === 'input') {
490
+ original = this._inputGroupRoot(original);
244
491
  }
245
492
 
246
- // Collect all siblings: nodes with the same parentId that either
247
- // ARE the original, or have a forkOf chain leading to the original.
248
- const parentId = original.parentId;
249
- const originalId = original.msgId;
250
- const siblings: InternalNode<TMessage>[] = [];
251
-
252
- const candidateIds = this._parentIndex.get(parentId);
253
- if (candidateIds) {
254
- for (const childId of candidateIds) {
255
- const childEntry = this._nodeIndex.get(childId);
256
- if (childEntry && this._isSiblingOf(childEntry.node, originalId)) {
493
+ // `_parentIndex` is keyed by the raw structural `parentCodecMessageId` (not
494
+ // the resolved parent node key) so a run observed before its input node
495
+ // still files/groups correctly — the parent codec-message-id is known at
496
+ // creation, the resolved key may not be.
497
+ const parentKey = original.parentCodecMessageId;
498
+ const siblings: InternalNode<TProjection>[] = [];
499
+ const candidateKeys = this._parentIndex.get(parentKey);
500
+ if (candidateKeys) {
501
+ for (const childKey of candidateKeys) {
502
+ const childEntry = this._nodeIndex.get(childKey);
503
+ if (childEntry && this._isSiblingOf(childEntry.node, original)) {
257
504
  siblings.push(childEntry);
258
505
  }
259
506
  }
260
507
  }
261
508
 
262
- // Sort by Ably serial (lexicographic). Messages without a serial
263
- // (optimistic inserts before server relay) sort after all serial-bearing
264
- // siblings — they represent the user's most recent action.
265
509
  siblings.sort((a, b) => this._compareNodes(a, b));
266
- return siblings.map((s) => s.node);
510
+ // Cache against the queried key AND every member of the group: a single
511
+ // group is the same array regardless of which member triggered the lookup,
512
+ // so subsequent queries against any member hit without recomputing.
513
+ for (const sib of siblings) {
514
+ this._siblingCache.set(nodeKey(sib.node), siblings);
515
+ }
516
+ this._siblingCache.set(key, siblings);
517
+ return siblings;
267
518
  }
268
519
 
269
520
  /**
270
- * Check if `node` belongs to the sibling group rooted at `originalId`.
271
- * A node is a sibling if it IS the original or its forkOf chain leads
272
- * to the original (with the same parentId).
273
- * @param node - The node to check.
274
- * @param originalId - The group root to match against.
275
- * @returns True if the node belongs to the sibling group.
521
+ * Whether `node` belongs to the sibling group anchored at `original`.
522
+ * Requires the same kind and the same structural parent; reply runs need
523
+ * nothing more (same-parent runs are regenerate siblings), input nodes must
524
+ * additionally be forkOf-linked to the original (edit versions).
525
+ * @param node - The candidate node.
526
+ * @param original - The group's anchor node.
527
+ * @returns True if `node` is a sibling of `original`.
276
528
  */
277
- private _isSiblingOf(node: MessageNode<TMessage>, originalId: string): boolean {
278
- if (node.msgId === originalId) return true;
279
- let current = node;
280
- const visited = new Set<string>([current.msgId]);
281
- while (current.forkOf) {
282
- if (current.forkOf === originalId) return true;
283
- if (visited.has(current.forkOf)) break; // cycle guard
529
+ private _isSiblingOf(node: ConversationNode<TProjection>, original: ConversationNode<TProjection>): boolean {
530
+ if (node.kind !== original.kind) return false;
531
+ if (node.parentCodecMessageId !== original.parentCodecMessageId) return false;
532
+ // Same-parent reply runs are regenerate siblings — no fork-of needed.
533
+ if (node.kind === 'run') return true;
534
+ // Input nodes: must be forkOf-linked to the original (edit versions).
535
+ const originalKey = nodeKey(original);
536
+ if (nodeKey(node) === originalKey) return true;
537
+ let current: ConversationNode<TProjection> = node;
538
+ const visited = new Set<string>([nodeKey(current)]);
539
+ while (current.kind === 'input' && current.forkOf !== undefined) {
540
+ if (current.forkOf === originalKey) return true;
541
+ if (visited.has(current.forkOf)) break;
284
542
  const target = this._nodeIndex.get(current.forkOf);
285
543
  if (!target) break;
286
544
  current = target.node;
287
- visited.add(current.msgId);
545
+ visited.add(nodeKey(current));
288
546
  }
289
547
  return false;
290
548
  }
291
549
 
292
550
  /**
293
- * Get the "group root" msgId for a sibling group — the original message
294
- * that all forks trace back to.
295
- * @param msgId - Any msg-id in the sibling group.
296
- * @returns The msg-id of the group root.
551
+ * Get the "group root" key for a sibling group — the stable key the
552
+ * selection map is keyed by. For an input node (edit versions) that is the
553
+ * earliest fork-of ancestor; for a reply run (regenerate group) it is the
554
+ * oldest same-parent run (the original reply).
555
+ * @param key - Any node key in the sibling group.
556
+ * @returns The group root's key.
297
557
  */
298
- getGroupRoot(msgId: string): string {
299
- const entry = this._nodeIndex.get(msgId);
300
- if (!entry) return msgId;
558
+ getGroupRoot(key: string): string {
559
+ const entry = this._nodeIndex.get(key);
560
+ if (!entry) return key;
301
561
 
302
- let current = entry.node;
303
- const visited = new Set<string>([current.msgId]);
304
- while (current.forkOf) {
305
- if (visited.has(current.forkOf)) break; // cycle guard
306
- const forkTarget = this._nodeIndex.get(current.forkOf);
307
- if (!forkTarget || forkTarget.node.parentId !== current.parentId) break;
308
- current = forkTarget.node;
309
- visited.add(current.msgId);
562
+ if (entry.node.kind === 'input') {
563
+ return nodeKey(this._inputGroupRoot(entry.node));
310
564
  }
311
- return current.msgId;
565
+
566
+ // Reply run: the oldest same-parent run is the original reply.
567
+ const group = this._getSiblingGroup(key);
568
+ const root = group[0]?.node;
569
+ return root ? nodeKey(root) : key;
312
570
  }
313
571
 
314
572
  // -------------------------------------------------------------------------
315
573
  // Public query methods
316
574
  // -------------------------------------------------------------------------
317
575
 
318
- flattenNodes(selections: Map<string, string>): MessageNode<TMessage>[] {
319
- this._logger.trace('DefaultTree.flattenNodes();');
320
- const result: MessageNode<TMessage>[] = [];
576
+ /**
577
+ * Walk the visible node chain along the selected branches, kind-blind. An
578
+ * input node and a reply run reach each other through the same
579
+ * parent-membership check, so seed-only user→user chains and the
580
+ * input→reply→input weave both resolve here. Sibling groups (edit versions /
581
+ * regenerate runs) collapse to the selected member.
582
+ * @param selections - Per-group selected member key, keyed by group root.
583
+ * @returns The visible nodes (both kinds) in chronological order.
584
+ */
585
+ visibleNodes(selections: Map<string, string> = new Map<string, string>()): ConversationNode<TProjection>[] {
586
+ this._logger.trace('DefaultTree.visibleNodes();');
587
+ const result: ConversationNode<TProjection>[] = [];
321
588
  const currentPath = new Set<string>();
322
- // Track which sibling groups we've already resolved to avoid
323
- // re-resolving for every member of the group.
324
- const resolvedGroups = new Map<string, string>(); // groupRootId → selected msgId
589
+ const resolvedGroups = new Map<string, string>(); // groupRootKey -> selected key
325
590
 
326
- for (const internal of this._sortedList) {
591
+ for (const internal of this._sortedNodes) {
327
592
  const node = internal.node;
328
- const { msgId, parentId } = node;
593
+ const key = nodeKey(node);
329
594
 
330
- // Step 1: Check parent reachability.
331
- if (parentId !== undefined && !currentPath.has(parentId)) {
595
+ // Step 1: Parent reachability (kind-blind — the parent may be an input
596
+ // node or a reply run; resolve its key and check the active path).
597
+ const parentKey = this._parentKeyOf(node);
598
+ if (parentKey !== undefined && !currentPath.has(parentKey)) {
332
599
  continue;
333
600
  }
334
601
 
335
- // Step 2: Check sibling selection.
336
- const group = this._getSiblingGroup(msgId);
602
+ // Step 2: Sibling selection.
603
+ const group = this._getSiblingGroup(key);
337
604
  if (group.length > 1) {
338
- const groupRootId = this.getGroupRoot(msgId);
339
- let selectedId = resolvedGroups.get(groupRootId);
340
- if (selectedId === undefined) {
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;
605
+ const groupRootKey = this.getGroupRoot(key);
606
+ let selectedKey = resolvedGroups.get(groupRootKey);
607
+ if (selectedKey === undefined) {
608
+ const preferredKey = selections.get(groupRootKey);
609
+ if (preferredKey !== undefined && group.some((n) => nodeKey(n.node) === preferredKey)) {
610
+ selectedKey = preferredKey;
345
611
  } else {
346
612
  const latest = group.at(-1);
347
613
  if (!latest) break; // unreachable: group.length > 1
348
- selectedId = latest.msgId;
614
+ selectedKey = nodeKey(latest.node);
349
615
  }
350
- resolvedGroups.set(groupRootId, selectedId);
616
+ resolvedGroups.set(groupRootKey, selectedKey);
351
617
  }
352
- if (msgId !== selectedId) {
618
+ if (key !== selectedKey) {
353
619
  continue;
354
620
  }
355
621
  }
356
622
 
357
- currentPath.add(msgId);
623
+ currentPath.add(key);
358
624
  result.push(node);
359
625
  }
360
626
 
361
627
  return result;
362
628
  }
363
629
 
364
- getSiblings(msgId: string): TMessage[] {
365
- this._logger.trace('DefaultTree.getSiblings();', { msgId });
366
- return this._getSiblingGroup(msgId).map((n) => n.message);
630
+ getRunNode(runId: string): RunNode<TProjection> | undefined {
631
+ this._logger.trace('DefaultTree.getRunNode();', { runId });
632
+ const node = this._nodeIndex.get(runId)?.node;
633
+ return node?.kind === 'run' ? node : undefined;
367
634
  }
368
635
 
369
- getSiblingNodes(msgId: string): MessageNode<TMessage>[] {
370
- return this._getSiblingGroup(msgId);
636
+ getNode(key: string): ConversationNode<TProjection> | undefined {
637
+ this._logger.trace('DefaultTree.getNode();', { key });
638
+ return this._nodeIndex.get(key)?.node;
371
639
  }
372
640
 
373
- hasSiblings(msgId: string): boolean {
374
- return this._getSiblingGroup(msgId).length > 1;
641
+ getNodeByCodecMessageId(codecMessageId: string): ConversationNode<TProjection> | undefined {
642
+ this._logger.trace('DefaultTree.getNodeByCodecMessageId();', { codecMessageId });
643
+ const key = this._codecMessageIdToNodeKey.get(codecMessageId);
644
+ return key === undefined ? undefined : this._nodeIndex.get(key)?.node;
375
645
  }
376
646
 
377
- getNode(msgId: string): MessageNode<TMessage> | undefined {
378
- this._logger.trace('DefaultTree.getNode();', { msgId });
379
- return this._nodeIndex.get(msgId)?.node;
647
+ getReplyRuns(inputCodecMessageId: string): RunNode<TProjection>[] {
648
+ const runIds = this._replyRunsByInput.get(inputCodecMessageId);
649
+ if (!runIds) return [];
650
+ const result: RunNode<TProjection>[] = [];
651
+ for (const runId of runIds) {
652
+ const node = this._nodeIndex.get(runId)?.node;
653
+ if (node?.kind === 'run') result.push(node);
654
+ }
655
+ return result;
380
656
  }
381
657
 
382
- getHeaders(msgId: string): Record<string, string> | undefined {
383
- this._logger.trace('DefaultTree.getHeaders();', { msgId });
384
- return this._nodeIndex.get(msgId)?.node.headers;
658
+ getSiblingNodes(key: string): ConversationNode<TProjection>[] {
659
+ this._logger.trace('DefaultTree.getSiblingNodes();', { key });
660
+ return this._getSiblingGroup(key).map((n) => n.node);
385
661
  }
386
662
 
387
663
  // -------------------------------------------------------------------------
388
664
  // Mutation
389
665
  // -------------------------------------------------------------------------
390
666
 
391
- upsert(msgId: string, message: TMessage, headers: Record<string, string>, serial?: string): void {
392
- const parentId = headers[HEADER_PARENT] ?? undefined;
393
- const forkOf = headers[HEADER_FORK_OF] ?? undefined;
394
-
395
- const existing = this._nodeIndex.get(msgId);
396
- if (existing) {
397
- // Update in place — message content may have changed (e.g. streaming).
398
- // Only update headers if the new headers are non-empty (prevents
399
- // streaming updates from erasing canonical headers).
400
- existing.node.message = message;
401
- if (Object.keys(headers).length > 0) {
402
- existing.node.headers = { ...headers };
403
- }
404
- // Spec: AIT-CT13d
405
- // Promote serial: optimistic (null) → server-assigned on relay.
406
- if (serial && !existing.node.serial) {
407
- this._logger.debug('Tree.upsert(); promoting serial', { msgId, serial });
408
- existing.node.serial = serial;
409
- // Re-sort: remove from current position, re-insert at correct position.
410
- this._removeSorted(existing);
411
- this._insertSorted(existing);
412
- this._structuralVersion++;
413
- }
414
- this._emitter.emit('update');
667
+ applyMessage(
668
+ events: { inputs: TInput[]; outputs: TOutput[] },
669
+ headers: Record<string, string>,
670
+ serial?: string,
671
+ ): void {
672
+ const wireRunId = headers[HEADER_RUN_ID];
673
+ const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
674
+
675
+ // Classify: with NO run-id, a user message carrying a codec-message-id and
676
+ // at least one input event forms an INPUT node keyed by that
677
+ // codec-message-id the client owns it; the agent mints the reply run-id
678
+ // separately. Everything else needs a run-id to route to a reply run.
679
+ // Capturing the id (not a boolean) narrows it to `string` for the input path.
680
+ const inputNodeCodecMessageId =
681
+ wireRunId === undefined &&
682
+ codecMessageId !== undefined &&
683
+ headers[HEADER_ROLE] === 'user' &&
684
+ events.inputs.length > 0
685
+ ? codecMessageId
686
+ : undefined;
687
+
688
+ if (wireRunId === undefined && inputNodeCodecMessageId === undefined) {
689
+ this._logger.warn('Tree.applyMessage(); message has no run-id and is not a user input; skipping');
690
+ return;
691
+ }
692
+
693
+ // Fold inputs first, then outputs, preserving wire order.
694
+ const all: (TInput | TOutput)[] = [...events.inputs, ...events.outputs];
695
+
696
+ // Wire-only metadata-carrier messages (e.g. `ait-regenerate`) decode to
697
+ // zero events and don't need a node at the tree level — the eventual reply
698
+ // run is created later by run-start, and any regenerate / parent
699
+ // information the wire carried is reread from the run-start headers.
700
+ // Skipping here avoids a phantom node that would inflate sibling counts.
701
+ const existingKey = inputNodeCodecMessageId ?? wireRunId;
702
+ if (all.length === 0 && existingKey !== undefined && !this._nodeIndex.has(existingKey)) {
415
703
  return;
416
704
  }
417
705
 
418
- this._logger.trace('Tree.upsert(); inserting new node', { msgId, parentId, forkOf });
706
+ // `update` is the structural channel: emit it only when this apply
707
+ // actually changes the tree shape (new node, startSerial promotion).
708
+ // Content-only folds (streaming chunks into an existing node) flow through
709
+ // `output` instead, so they leave `_structuralVersion` untouched.
710
+ const structuralBefore = this._structuralVersion;
711
+
712
+ if (inputNodeCodecMessageId !== undefined) {
713
+ this._applyInputMessage(inputNodeCodecMessageId, headers, serial, all);
714
+ } else if (wireRunId !== undefined) {
715
+ this._applyRunMessage(wireRunId, events, headers, serial);
716
+ }
717
+
718
+ if (this._structuralVersion !== structuralBefore) this._emitter.emit('update');
719
+ }
720
+
721
+ /**
722
+ * Apply a run-less user input wire: create (or promote the serial of) the
723
+ * input node keyed by its codec-message-id, fold the input events into its
724
+ * own projection, and emit an `output` event (with empty outputs — input
725
+ * folds carry none) so the View observes the optimistic insert.
726
+ * @param codecMessageId - The input node's codec-message-id (its primary key).
727
+ * @param headers - Transport headers from the inbound Ably message.
728
+ * @param serial - Ably channel serial; undefined for an optimistic insert.
729
+ * @param all - The decoded input events to fold, in wire order.
730
+ */
731
+ private _applyInputMessage(
732
+ codecMessageId: string,
733
+ headers: Record<string, string>,
734
+ serial: string | undefined,
735
+ all: (TInput | TOutput)[],
736
+ ): void {
737
+ let entry = this._nodeIndex.get(codecMessageId);
738
+ if (!entry) {
739
+ entry = this._createInputNodeFromHeaders(codecMessageId, headers, serial);
740
+ this._insertNode(codecMessageId, entry, entry.node.parentCodecMessageId);
741
+ this._codecMessageIdToNodeKey.set(codecMessageId, codecMessageId);
742
+ this._logger.debug('Tree.applyMessage(); created input node', { codecMessageId });
743
+ } else if (entry.node.kind === 'input' && serial && !entry.node.serial) {
744
+ // Promote optimistic serial when the relay/echo arrives.
745
+ this._logger.debug('Tree.applyMessage(); promoting input serial', { codecMessageId, serial });
746
+ entry.node.serial = serial;
747
+ this._promoteSerial(entry);
748
+ }
749
+
750
+ this._foldInto(entry, all, serial, codecMessageId);
419
751
 
420
- const node: MessageNode<TMessage> = {
421
- kind: 'message',
422
- message,
423
- msgId,
424
- parentId,
425
- forkOf,
426
- headers: { ...headers },
752
+ // An input node owns no agent outputs; the event still fires (empty
753
+ // outputs) so consumers observe the projection change. It has no run-id —
754
+ // the causal routing key is the input's own codec-message-id.
755
+ this._emitter.emit('output', {
756
+ runId: undefined,
757
+ inputCodecMessageId: codecMessageId,
758
+ codecMessageId,
427
759
  serial,
428
- };
760
+ events: [],
761
+ });
762
+ }
429
763
 
430
- const internal: InternalNode<TMessage> = { node, insertSeq: this._seqCounter++ };
431
- this._nodeIndex.set(msgId, internal);
432
- this._addToParentIndex(parentId, msgId);
433
- this._insertSorted(internal);
434
- this._structuralVersion++;
435
- this._emitter.emit('update');
764
+ /**
765
+ * Apply a reply-run wire (assistant output, continuation tool-resolution, or
766
+ * a fresh run keyed by the agent-minted run-id): create or reconcile the run
767
+ * node, fold its events, maintain the codec-message-id and reply→input
768
+ * indices, and emit the `output` event. Derives the codec-message-id,
769
+ * triggering-input id, fold list, and outputs from `events`/`headers`,
770
+ * mirroring `applyMessage`.
771
+ * @param wireRunId - The run-id from the inbound wire (the node's primary key).
772
+ * @param events - The decoded inputs and outputs from the wire.
773
+ * @param events.inputs - Client-published events (`ai-input` wire).
774
+ * @param events.outputs - Agent-published events (`ai-output` wire).
775
+ * @param headers - Transport headers from the inbound Ably message.
776
+ * @param serial - Ably channel serial; undefined for an optimistic insert.
777
+ */
778
+ private _applyRunMessage(
779
+ wireRunId: string,
780
+ events: { inputs: TInput[]; outputs: TOutput[] },
781
+ headers: Record<string, string>,
782
+ serial: string | undefined,
783
+ ): void {
784
+ const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
785
+ // The triggering input's codec-message-id (the agent's echo), surfaced on
786
+ // the `output` event as the stream's causal routing key.
787
+ const inputCodecMessageId = headers[HEADER_INPUT_CODEC_MESSAGE_ID];
788
+ // Fold inputs first, then outputs, preserving wire order.
789
+ const all: (TInput | TOutput)[] = [...events.inputs, ...events.outputs];
790
+ const outputs = events.outputs;
791
+
792
+ let run = this._nodeIndex.get(wireRunId);
793
+
794
+ // Reconcile an optimistic insert with its serial-bearing echo by
795
+ // codec-message-id rather than the wire run-id — covers assistant content
796
+ // that pins a codec-message-id before its run-id is indexed.
797
+ if (!run && codecMessageId !== undefined) {
798
+ const indexedKey = this._codecMessageIdToNodeKey.get(codecMessageId);
799
+ const indexed = indexedKey === undefined ? undefined : this._nodeIndex.get(indexedKey);
800
+ if (indexed?.node.kind === 'run' && indexed.node.startSerial === undefined) run = indexed;
801
+ }
802
+
803
+ if (!run) {
804
+ run = this._createRunFromHeaders(wireRunId, headers, serial);
805
+ this._insertNode(wireRunId, run, run.node.parentCodecMessageId);
806
+ this._indexReplyRun(run.node, wireRunId);
807
+ this._logger.debug('Tree.applyMessage(); created new Run', { runId: wireRunId });
808
+ } else if (serial && run.node.kind === 'run' && !run.node.startSerial) {
809
+ // Promote optimistic startSerial when the relay/echo arrives.
810
+ this._logger.debug('Tree.applyMessage(); promoting startSerial', { runId: wireRunId, serial });
811
+ run.node.startSerial = serial;
812
+ this._promoteSerial(run);
813
+ }
814
+
815
+ // Index the codec-message-id against the node that actually owns it.
816
+ const ownerKey = nodeKey(run.node);
817
+ if (codecMessageId) this._codecMessageIdToNodeKey.set(codecMessageId, ownerKey);
818
+
819
+ this._foldInto(run, all, serial, codecMessageId);
820
+
821
+ this._emitter.emit('output', { runId: ownerKey, inputCodecMessageId, codecMessageId, serial, events: outputs });
436
822
  }
437
823
 
438
- delete(msgId: string): void {
439
- const entry = this._nodeIndex.get(msgId);
440
- if (!entry) return;
824
+ /**
825
+ * Record a reply run against its input-node parent (the reverse edge powering
826
+ * `getReplyRuns` and regenerate sibling grouping). A reply run's
827
+ * `parentCodecMessageId` is its input node's codec-message-id (the master
828
+ * invariant), so no resolution is needed.
829
+ * @param node - The reply run node.
830
+ * @param runId - The run's id.
831
+ */
832
+ private _indexReplyRun(node: ConversationNode<TProjection>, runId: string): void {
833
+ if (node.parentCodecMessageId === undefined) return;
834
+ addToSetMap(this._replyRunsByInput, node.parentCodecMessageId, runId);
835
+ }
836
+
837
+ applyRunLifecycle(event: RunLifecycleEvent): void {
838
+ this._logger.trace('DefaultTree.applyRunLifecycle();', { type: event.type, runId: event.runId });
839
+ // Structural channel: emit `update` only when the lifecycle event changes
840
+ // the tree shape. Only run-start can do that (a new Run, startSerial
841
+ // promotion, or structural-metadata backfill); suspend/resume/end mutate
842
+ // status/endSerial on an existing node — content, not structure — so the
843
+ // conditional naturally never fires for them.
844
+ const structuralBefore = this._structuralVersion;
845
+ switch (event.type) {
846
+ case 'start': {
847
+ this._applyRunStart(event);
848
+ break;
849
+ }
850
+ case 'suspend': {
851
+ this._applyRunSuspend(event);
852
+ break;
853
+ }
854
+ case 'resume': {
855
+ this._applyRunResume(event);
856
+ break;
857
+ }
858
+ case 'end': {
859
+ this._applyRunEnd(event);
860
+ break;
861
+ }
862
+ }
863
+ this._emitter.emit('run', event);
864
+ if (this._structuralVersion !== structuralBefore) this._emitter.emit('update');
865
+ }
866
+
867
+ /**
868
+ * Apply a run-start lifecycle event's structural effect: create the reply
869
+ * run if it doesn't exist yet, or backfill an optimistic / wire-created
870
+ * node's structure and metadata from the canonical run-start. Mutates
871
+ * `_structuralVersion` when the tree shape changes; the caller owns the
872
+ * `run`/`update` emits.
873
+ * @param event - The run-start lifecycle event.
874
+ */
875
+ private _applyRunStart(event: RunLifecycleEvent & { type: 'start' }): void {
876
+ const existing = this._nodeIndex.get(event.runId);
877
+ if (existing?.node.kind === 'run') {
878
+ const node = existing.node;
879
+ if (node.status !== 'active') {
880
+ node.status = 'active';
881
+ }
882
+ if (event.serial && !node.startSerial) {
883
+ node.startSerial = event.serial;
884
+ this._promoteSerial(existing);
885
+ }
886
+ // Backfill structural metadata if the Run was created from an
887
+ // assistant wire that arrived before run-start (history pagination
888
+ // boundary or out-of-order delivery). The run-start lifecycle event is
889
+ // the canonical source for parent/forkOf/regenerates; only fill in
890
+ // fields the wire didn't already populate. A run-start is always a
891
+ // first start (continuations re-enter via `ai-run-resume`, which
892
+ // carries no structural metadata), so it is unconditionally
893
+ // authoritative here. `parent` is the run's STRUCTURAL parent (its
894
+ // input node) — reachability and the reply→input edge read it.
895
+ if (node.parentCodecMessageId === undefined && event.parent !== undefined) {
896
+ node.parentCodecMessageId = event.parent;
897
+ this._removeFromParentIndex(undefined, event.runId);
898
+ this._addToParentIndex(node.parentCodecMessageId, event.runId);
899
+ this._indexReplyRun(node, event.runId);
900
+ this._structuralVersion++;
901
+ }
902
+ if (node.forkOf === undefined && event.forkOf !== undefined) {
903
+ const forkOfKey = this._codecMessageIdToNodeKey.get(event.forkOf);
904
+ if (forkOfKey !== undefined && forkOfKey !== event.runId) {
905
+ node.forkOf = forkOfKey;
906
+ this._structuralVersion++;
907
+ }
908
+ }
909
+ if (node.regeneratesCodecMessageId === undefined && event.regenerates !== undefined) {
910
+ node.regeneratesCodecMessageId = event.regenerates;
911
+ this._structuralVersion++;
912
+ }
913
+ // Adopt the agent-minted invocation-id onto the optimistic node. The
914
+ // agent mints it, so a node created from an optimistic insert (or an
915
+ // assistant wire that arrived before run-start) carries an empty id
916
+ // until the agent's run-start delivers it. Metadata, not structure —
917
+ // consumers re-read it on the `run` emit, so no structural-version
918
+ // bump.
919
+ if (node.invocationId === '' && event.invocationId !== '') {
920
+ node.invocationId = event.invocationId;
921
+ }
922
+ } else if (!existing) {
923
+ const run = this._createRunFromLifecycle(event);
924
+ this._insertNode(event.runId, run, run.node.parentCodecMessageId);
925
+ this._indexReplyRun(run.node, event.runId);
926
+ }
927
+ }
928
+
929
+ /**
930
+ * Apply a run-suspend lifecycle event: pause the run without ending it —
931
+ * mark the node 'suspended' and record the serial it paused at, but keep the
932
+ * Run live so a resume under the same runId resumes it. Status/endSerial are
933
+ * content, not structure, so this never mutates `_structuralVersion`; the
934
+ * caller owns the emits.
935
+ * @param event - The run-suspend lifecycle event.
936
+ */
937
+ private _applyRunSuspend(event: RunLifecycleEvent & { type: 'suspend' }): void {
938
+ const run = this._nodeIndex.get(event.runId);
939
+ if (run?.node.kind === 'run') {
940
+ run.node.status = 'suspended';
941
+ run.node.endSerial = event.serial;
942
+ }
943
+ }
441
944
 
442
- this._logger.debug('Tree.delete();', { msgId });
945
+ /**
946
+ * Apply a run-resume lifecycle event: re-enter an already-started run by
947
+ * flipping a suspended run back to 'active'. Pure re-entry — it carries no
948
+ * parent/forkOf and does not promote startSerial (the original run-start owns
949
+ * the run's structure). Only a suspended run resumes: a no-op when the run
950
+ * isn't known (e.g. a resume replayed from a newer history page before its
951
+ * run-start) and a no-op for an already-active or terminal
952
+ * (complete/cancelled/error) run — a stray resume must never resurrect a run
953
+ * that has ended. The caller owns the emits.
954
+ * @param event - The run-resume lifecycle event.
955
+ */
956
+ private _applyRunResume(event: RunLifecycleEvent & { type: 'resume' }): void {
957
+ const run = this._nodeIndex.get(event.runId);
958
+ if (run?.node.kind === 'run' && run.node.status === 'suspended') {
959
+ run.node.status = 'active';
960
+ }
961
+ }
443
962
 
444
- const { node } = entry;
963
+ /**
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.
968
+ * @param event - The run-end lifecycle event.
969
+ */
970
+ private _applyRunEnd(event: RunLifecycleEvent & { type: 'end' }): void {
971
+ const run = this._nodeIndex.get(event.runId);
972
+ if (run?.node.kind === 'run') {
973
+ run.node.status = event.reason;
974
+ run.node.endSerial = event.serial;
975
+ }
976
+ }
445
977
 
446
- // Remove from parent index
447
- this._removeFromParentIndex(node.parentId, msgId);
978
+ delete(key: string): void {
979
+ const entry = this._nodeIndex.get(key);
980
+ if (!entry) return;
448
981
 
449
- // Remove from sorted list
450
- this._removeSorted(entry);
982
+ this._logger.debug('Tree.delete();', { key });
451
983
 
452
- // Remove from primary index
453
- this._nodeIndex.delete(msgId);
984
+ this._removeFromParentIndex(entry.node.parentCodecMessageId, key);
985
+ this._removeSortedNode(entry);
986
+ this._nodeIndex.delete(key);
987
+ // Drop the reply→input reverse edge.
988
+ if (entry.node.kind === 'run' && entry.node.parentCodecMessageId !== undefined) {
989
+ deleteFromSetMap(this._replyRunsByInput, entry.node.parentCodecMessageId, key);
990
+ }
991
+ // _codecMessageIdToNodeKey entries pointing at this node linger but are
992
+ // harmless; they'll be overwritten if the node is re-created and remain
993
+ // dangling otherwise. Cleanup not worth the index walk.
454
994
 
455
- // Children are NOT deleted — they become unreachable in flattenNodes()
456
- // because their parent is no longer on the active path.
457
995
  this._structuralVersion++;
458
996
  this._emitter.emit('update');
459
997
  }
460
998
 
461
999
  // -------------------------------------------------------------------------
462
- // Events
1000
+ // Internal helpers
463
1001
  // -------------------------------------------------------------------------
464
1002
 
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;
1003
+ /**
1004
+ * Build a fresh RunNode from a wire message's headers. Used when an
1005
+ * inbound message arrives before any run-start event for its runId.
1006
+ * @param runId - The run-id from the inbound wire.
1007
+ * @param headers - Transport headers from the inbound Ably message.
1008
+ * @param serial - Ably channel serial; undefined for optimistic inserts.
1009
+ * @returns A newly-allocated internal run node ready for insertion.
1010
+ */
1011
+ private _createRunFromHeaders(
1012
+ runId: string,
1013
+ headers: Record<string, string>,
1014
+ serial: string | undefined,
1015
+ ): InternalNode<TProjection> {
1016
+ const forkOfMsgId = headers[HEADER_FORK_OF];
1017
+ return this._buildRunNode({
1018
+ runId,
1019
+ parentCodecMessageId: headers[HEADER_PARENT],
1020
+ // forkOf is resolved to the fork target's node key (an input node's
1021
+ // codec-message-id, or a run's id) — the same space `_isSiblingOf` walks.
1022
+ forkOf: forkOfMsgId ? this._codecMessageIdToNodeKey.get(forkOfMsgId) : undefined,
1023
+ regeneratesCodecMessageId: headers[HEADER_MSG_REGENERATE],
1024
+ clientId: headers[HEADER_RUN_CLIENT_ID] ?? '',
1025
+ invocationId: headers[HEADER_INVOCATION_ID] ?? '',
1026
+ startSerial: serial,
1027
+ });
1028
+ }
1029
+
1030
+ /**
1031
+ * Allocate a RunNode from already-resolved fields. Shared by the
1032
+ * header-driven and lifecycle-driven run creators: both build the identical
1033
+ * RunNode literal and stamp an insert sequence.
1034
+ * @param params - The resolved run fields.
1035
+ * @param params.runId - The run's id (its primary key).
1036
+ * @param params.parentCodecMessageId - Structural parent codec-message-id, or undefined for a root.
1037
+ * @param params.forkOf - The resolved fork target's node key (already mapped through the codec-message-id index), or undefined.
1038
+ * @param params.regeneratesCodecMessageId - The codec-message-id this run regenerates, or undefined.
1039
+ * @param params.clientId - The publishing client's id.
1040
+ * @param params.invocationId - The agent invocation id.
1041
+ * @param params.startSerial - Ably channel serial; undefined for optimistic inserts.
1042
+ * @returns A newly-allocated internal run node ready for insertion.
1043
+ */
1044
+ private _buildRunNode(params: {
1045
+ runId: string;
1046
+ parentCodecMessageId: string | undefined;
1047
+ forkOf: string | undefined;
1048
+ regeneratesCodecMessageId: string | undefined;
1049
+ clientId: string;
1050
+ invocationId: string;
1051
+ startSerial: string | undefined;
1052
+ }): InternalNode<TProjection> {
1053
+ const node: RunNode<TProjection> = {
1054
+ kind: 'run',
1055
+ runId: params.runId,
1056
+ parentCodecMessageId: params.parentCodecMessageId,
1057
+ forkOf: params.forkOf,
1058
+ regeneratesCodecMessageId: params.regeneratesCodecMessageId,
1059
+ clientId: params.clientId,
1060
+ invocationId: params.invocationId,
1061
+ status: 'active',
1062
+ projection: this._codec.init(),
1063
+ startSerial: params.startSerial,
1064
+ endSerial: undefined,
1065
+ };
1066
+
1067
+ return { node, insertSeq: this._seqCounter++ };
1068
+ }
1069
+
1070
+ /**
1071
+ * Build a fresh InputNode from a run-less user input wire's headers.
1072
+ * @param codecMessageId - The input's codec-message-id (its primary key).
1073
+ * @param headers - Transport headers from the inbound Ably message.
1074
+ * @param serial - Ably channel serial; undefined for optimistic inserts.
1075
+ * @returns A newly-allocated internal input node ready for insertion.
1076
+ */
1077
+ private _createInputNodeFromHeaders(
1078
+ codecMessageId: string,
1079
+ headers: Record<string, string>,
1080
+ serial: string | undefined,
1081
+ ): InternalNode<TProjection> {
1082
+ const forkOfMsgId = headers[HEADER_FORK_OF];
1083
+ const node: InputNode<TProjection> = {
1084
+ kind: 'input',
1085
+ codecMessageId,
1086
+ parentCodecMessageId: headers[HEADER_PARENT],
1087
+ // An edit's fork-of names the original prompt's codec-message-id, which
1088
+ // IS that input node's key — no resolution needed.
1089
+ forkOf: forkOfMsgId,
1090
+ projection: this._codec.init(),
1091
+ serial,
1092
+ };
1093
+ return { node, insertSeq: this._seqCounter++ };
1094
+ }
1095
+
1096
+ /**
1097
+ * Build a fresh RunNode from a run-start lifecycle event. Used when a
1098
+ * run-start event arrives before any message for its runId.
1099
+ * @param event - The run-start lifecycle event from the agent, including
1100
+ * its channel serial.
1101
+ * @returns A newly-allocated internal run node ready for insertion.
1102
+ */
1103
+ private _createRunFromLifecycle(event: RunLifecycleEvent & { type: 'start' }): InternalNode<TProjection> {
1104
+ const forkOfMsgId = event.forkOf;
1105
+ return this._buildRunNode({
1106
+ runId: event.runId,
1107
+ parentCodecMessageId: event.parent,
1108
+ forkOf: forkOfMsgId ? this._codecMessageIdToNodeKey.get(forkOfMsgId) : undefined,
1109
+ regeneratesCodecMessageId: event.regenerates,
1110
+ clientId: event.clientId,
1111
+ invocationId: event.invocationId,
1112
+ startSerial: event.serial,
1113
+ });
478
1114
  }
479
1115
 
1116
+ // -------------------------------------------------------------------------
1117
+ // Events
1118
+ // -------------------------------------------------------------------------
1119
+
480
1120
  // Spec: AIT-CT8b, AIT-CT8e
481
1121
  on(event: 'update', handler: () => void): () => void;
482
1122
  on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
483
- on(event: 'turn', handler: (event: TurnLifecycleEvent) => void): () => void;
1123
+ on(event: 'run', handler: (event: RunLifecycleEvent) => void): () => void;
1124
+ on(event: 'output', handler: (event: OutputEvent<TOutput>) => void): () => void;
484
1125
  on(
485
- event: 'update' | 'ably-message' | 'turn',
486
- handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: TurnLifecycleEvent) => void),
1126
+ event: 'update' | 'ably-message' | 'run' | 'output',
1127
+ handler:
1128
+ | (() => void)
1129
+ | ((msg: Ably.InboundMessage) => void)
1130
+ | ((event: RunLifecycleEvent) => void)
1131
+ | ((event: OutputEvent<TOutput>) => void),
487
1132
  ): () => void {
488
1133
  // CAST: overload signatures enforce correct handler types per event name.
489
- const cb = handler as (arg: TreeEventsMap[keyof TreeEventsMap]) => void;
1134
+ const cb = handler as (arg: TreeEventsMap<TOutput>[keyof TreeEventsMap<TOutput>]) => void;
490
1135
  this._emitter.on(event, cb);
491
1136
  return () => {
492
1137
  this._emitter.off(event, cb);
493
1138
  };
494
1139
  }
495
1140
 
496
- // -------------------------------------------------------------------------
497
- // Internal methods (called by the transport, not part of Tree interface)
498
- // -------------------------------------------------------------------------
499
-
500
1141
  /**
501
1142
  * Forward a raw Ably message event to tree subscribers.
502
1143
  * @param msg - The raw Ably message to emit.
@@ -505,34 +1146,6 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
505
1146
  this._logger.trace('DefaultTree.emitAblyMessage();');
506
1147
  this._emitter.emit('ably-message', msg);
507
1148
  }
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);
535
- }
536
1149
  }
537
1150
 
538
1151
  // ---------------------------------------------------------------------------
@@ -540,10 +1153,15 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
540
1153
  // ---------------------------------------------------------------------------
541
1154
 
542
1155
  /**
543
- * Create a Tree that materializes branching history from a flat oplog.
1156
+ * Create a Tree that materializes branching conversation history from a flat
1157
+ * oplog of Ably messages as a two-node-per-turn forest (input node + reply run).
1158
+ * @param codec - Codec used to fold inbound events into per-Run projections.
544
1159
  * @param logger - Logger for diagnostic output.
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.
1160
+ * @returns A new {@link DefaultTree} instance. The session uses DefaultTree
1161
+ * directly for internal methods (applyMessage, applyRunLifecycle,
1162
+ * emitAblyMessage). Public consumers see the narrower {@link Tree} interface.
548
1163
  */
549
- export const createTree = <TMessage>(logger: Logger): DefaultTree<TMessage> => new DefaultTree(logger);
1164
+ export const createTree = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection>(
1165
+ codec: Reducer<TInput | TOutput, TProjection>,
1166
+ logger: Logger,
1167
+ ): DefaultTree<TInput, TOutput, TProjection> => new DefaultTree<TInput, TOutput, TProjection>(codec, logger);