@ably/ai-transport 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/README.md +93 -111
  2. package/dist/ably-ai-transport.js +2401 -1387
  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 +44 -0
  8. package/dist/core/channel-options.d.ts +57 -0
  9. package/dist/core/codec/codec-event.d.ts +9 -0
  10. package/dist/core/codec/decoder.d.ts +24 -24
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +10 -12
  13. package/dist/core/codec/field-bag.d.ts +85 -0
  14. package/dist/core/codec/fields.d.ts +141 -0
  15. package/dist/core/codec/index.d.ts +8 -2
  16. package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
  17. package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
  18. package/dist/core/codec/input-descriptors.d.ts +281 -0
  19. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  20. package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
  21. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  22. package/dist/core/codec/output-descriptors.d.ts +237 -0
  23. package/dist/core/codec/types.d.ts +470 -119
  24. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  25. package/dist/core/transport/agent-session.d.ts +10 -0
  26. package/dist/core/transport/agent-view.d.ts +296 -0
  27. package/dist/core/transport/client-session.d.ts +13 -0
  28. package/dist/core/transport/decode-fold.d.ts +55 -0
  29. package/dist/core/transport/headers.d.ts +121 -14
  30. package/dist/core/transport/index.d.ts +5 -6
  31. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  32. package/dist/core/transport/invocation.d.ts +74 -0
  33. package/dist/core/transport/load-history-pages.d.ts +71 -0
  34. package/dist/core/transport/load-history.d.ts +44 -0
  35. package/dist/core/transport/pipe-stream.d.ts +9 -9
  36. package/dist/core/transport/run-manager.d.ts +76 -0
  37. package/dist/core/transport/session-support.d.ts +55 -0
  38. package/dist/core/transport/tree.d.ts +523 -109
  39. package/dist/core/transport/types/agent.d.ts +375 -0
  40. package/dist/core/transport/types/client.d.ts +201 -0
  41. package/dist/core/transport/types/shared.d.ts +24 -0
  42. package/dist/core/transport/types/tree.d.ts +357 -0
  43. package/dist/core/transport/types/view.d.ts +249 -0
  44. package/dist/core/transport/types.d.ts +13 -553
  45. package/dist/core/transport/view.d.ts +390 -84
  46. package/dist/core/transport/wire-log.d.ts +102 -0
  47. package/dist/errors.d.ts +27 -10
  48. package/dist/index.d.ts +8 -9
  49. package/dist/logger.d.ts +12 -0
  50. package/dist/react/ably-ai-transport-react.js +1365 -1010
  51. package/dist/react/ably-ai-transport-react.js.map +1 -1
  52. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  53. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  54. package/dist/react/contexts/client-session-context.d.ts +37 -0
  55. package/dist/react/contexts/client-session-provider.d.ts +56 -0
  56. package/dist/react/create-session-hooks.d.ts +116 -0
  57. package/dist/react/index.d.ts +13 -12
  58. package/dist/react/internal/skipped-session.d.ts +8 -0
  59. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  60. package/dist/react/use-ably-messages.d.ts +17 -14
  61. package/dist/react/use-client-session.d.ts +81 -0
  62. package/dist/react/use-create-view.d.ts +14 -13
  63. package/dist/react/use-tree.d.ts +30 -15
  64. package/dist/react/use-view.d.ts +81 -50
  65. package/dist/utils.d.ts +48 -71
  66. package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
  67. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  68. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  69. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  70. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  71. package/dist/vercel/codec/events.d.ts +50 -0
  72. package/dist/vercel/codec/fields.d.ts +44 -0
  73. package/dist/vercel/codec/fold-content.d.ts +16 -0
  74. package/dist/vercel/codec/fold-data.d.ts +16 -0
  75. package/dist/vercel/codec/fold-input.d.ts +67 -0
  76. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  77. package/dist/vercel/codec/fold-text.d.ts +16 -0
  78. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  79. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  80. package/dist/vercel/codec/index.d.ts +7 -20
  81. package/dist/vercel/codec/inputs.d.ts +11 -0
  82. package/dist/vercel/codec/outputs.d.ts +11 -0
  83. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  84. package/dist/vercel/codec/reducer.d.ts +62 -0
  85. package/dist/vercel/codec/tool-transitions.d.ts +2 -8
  86. package/dist/vercel/codec/wire-data.d.ts +34 -0
  87. package/dist/vercel/index.d.ts +5 -5
  88. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
  89. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  90. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -45
  91. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  92. package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
  93. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
  94. package/dist/vercel/react/index.d.ts +1 -2
  95. package/dist/vercel/react/use-chat-transport.d.ts +30 -26
  96. package/dist/vercel/react/use-message-sync.d.ts +17 -30
  97. package/dist/vercel/run-end-reason.d.ts +84 -0
  98. package/dist/vercel/tool-part.d.ts +21 -0
  99. package/dist/vercel/transport/chat-transport.d.ts +41 -24
  100. package/dist/vercel/transport/index.d.ts +24 -20
  101. package/dist/vercel/transport/run-output-stream.d.ts +54 -0
  102. package/dist/version.d.ts +2 -0
  103. package/package.json +31 -24
  104. package/src/constants.ts +124 -51
  105. package/src/core/agent.ts +92 -0
  106. package/src/core/channel-options.ts +89 -0
  107. package/src/core/codec/codec-event.ts +27 -0
  108. package/src/core/codec/decoder.ts +202 -105
  109. package/src/core/codec/define-codec.ts +432 -0
  110. package/src/core/codec/encoder.ts +114 -107
  111. package/src/core/codec/field-bag.ts +142 -0
  112. package/src/core/codec/fields.ts +193 -0
  113. package/src/core/codec/index.ts +56 -6
  114. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  115. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  116. package/src/core/codec/input-descriptors.ts +373 -0
  117. package/src/core/codec/lifecycle-tracker.ts +10 -9
  118. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  119. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  120. package/src/core/codec/output-descriptors.ts +307 -0
  121. package/src/core/codec/types.ts +505 -126
  122. package/src/core/codec/well-known-inputs.ts +96 -0
  123. package/src/core/transport/agent-session.ts +1085 -0
  124. package/src/core/transport/agent-view.ts +738 -0
  125. package/src/core/transport/client-session.ts +780 -0
  126. package/src/core/transport/decode-fold.ts +101 -0
  127. package/src/core/transport/headers.ts +234 -22
  128. package/src/core/transport/index.ts +27 -27
  129. package/src/core/transport/internal/bounded-map.ts +27 -0
  130. package/src/core/transport/invocation.ts +98 -0
  131. package/src/core/transport/load-history-pages.ts +220 -0
  132. package/src/core/transport/load-history.ts +271 -0
  133. package/src/core/transport/pipe-stream.ts +63 -39
  134. package/src/core/transport/run-manager.ts +243 -0
  135. package/src/core/transport/session-support.ts +96 -0
  136. package/src/core/transport/tree.ts +1293 -308
  137. package/src/core/transport/types/agent.ts +434 -0
  138. package/src/core/transport/types/client.ts +247 -0
  139. package/src/core/transport/types/shared.ts +27 -0
  140. package/src/core/transport/types/tree.ts +393 -0
  141. package/src/core/transport/types/view.ts +288 -0
  142. package/src/core/transport/types.ts +13 -706
  143. package/src/core/transport/view.ts +1229 -450
  144. package/src/core/transport/wire-log.ts +189 -0
  145. package/src/errors.ts +29 -9
  146. package/src/event-emitter.ts +3 -2
  147. package/src/index.ts +86 -42
  148. package/src/logger.ts +14 -1
  149. package/src/react/contexts/client-session-context.ts +41 -0
  150. package/src/react/contexts/client-session-provider.tsx +222 -0
  151. package/src/react/create-session-hooks.ts +141 -0
  152. package/src/react/index.ts +24 -13
  153. package/src/react/internal/skipped-session.ts +62 -0
  154. package/src/react/internal/use-resolved-session.ts +63 -0
  155. package/src/react/use-ably-messages.ts +32 -22
  156. package/src/react/use-client-session.ts +178 -0
  157. package/src/react/use-create-view.ts +33 -29
  158. package/src/react/use-tree.ts +61 -30
  159. package/src/react/use-view.ts +138 -96
  160. package/src/utils.ts +83 -131
  161. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  162. package/src/vercel/codec/events.ts +85 -0
  163. package/src/vercel/codec/fields.ts +58 -0
  164. package/src/vercel/codec/fold-content.ts +54 -0
  165. package/src/vercel/codec/fold-data.ts +46 -0
  166. package/src/vercel/codec/fold-input.ts +255 -0
  167. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  168. package/src/vercel/codec/fold-text.ts +55 -0
  169. package/src/vercel/codec/fold-tool-input.ts +86 -0
  170. package/src/vercel/codec/fold-tool-output.ts +79 -0
  171. package/src/vercel/codec/index.ts +28 -21
  172. package/src/vercel/codec/inputs.ts +116 -0
  173. package/src/vercel/codec/outputs.ts +207 -0
  174. package/src/vercel/codec/reducer-state.ts +169 -0
  175. package/src/vercel/codec/reducer.ts +191 -0
  176. package/src/vercel/codec/tool-transitions.ts +3 -14
  177. package/src/vercel/codec/wire-data.ts +64 -0
  178. package/src/vercel/index.ts +7 -19
  179. package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
  180. package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
  181. package/src/vercel/react/index.ts +3 -5
  182. package/src/vercel/react/use-chat-transport.ts +44 -66
  183. package/src/vercel/react/use-message-sync.ts +75 -39
  184. package/src/vercel/run-end-reason.ts +157 -0
  185. package/src/vercel/tool-part.ts +25 -0
  186. package/src/vercel/transport/chat-transport.ts +380 -98
  187. package/src/vercel/transport/index.ts +38 -37
  188. package/src/vercel/transport/run-output-stream.ts +169 -0
  189. package/src/version.ts +2 -0
  190. package/dist/core/transport/client-transport.d.ts +0 -10
  191. package/dist/core/transport/decode-history.d.ts +0 -43
  192. package/dist/core/transport/server-transport.d.ts +0 -7
  193. package/dist/core/transport/stream-router.d.ts +0 -29
  194. package/dist/core/transport/turn-manager.d.ts +0 -37
  195. package/dist/react/contexts/transport-context.d.ts +0 -31
  196. package/dist/react/contexts/transport-provider.d.ts +0 -49
  197. package/dist/react/create-transport-hooks.d.ts +0 -124
  198. package/dist/react/use-active-turns.d.ts +0 -12
  199. package/dist/react/use-client-transport.d.ts +0 -80
  200. package/dist/vercel/codec/accumulator.d.ts +0 -21
  201. package/dist/vercel/codec/decoder.d.ts +0 -22
  202. package/dist/vercel/codec/encoder.d.ts +0 -41
  203. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
  204. package/dist/vercel/tool-approvals.d.ts +0 -124
  205. package/dist/vercel/tool-events.d.ts +0 -26
  206. package/src/core/transport/client-transport.ts +0 -977
  207. package/src/core/transport/decode-history.ts +0 -485
  208. package/src/core/transport/server-transport.ts +0 -612
  209. package/src/core/transport/stream-router.ts +0 -136
  210. package/src/core/transport/turn-manager.ts +0 -165
  211. package/src/react/contexts/transport-context.ts +0 -37
  212. package/src/react/contexts/transport-provider.tsx +0 -164
  213. package/src/react/create-transport-hooks.ts +0 -144
  214. package/src/react/use-active-turns.ts +0 -72
  215. package/src/react/use-client-transport.ts +0 -197
  216. package/src/vercel/codec/accumulator.ts +0 -588
  217. package/src/vercel/codec/decoder.ts +0 -618
  218. package/src/vercel/codec/encoder.ts +0 -410
  219. package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
  220. package/src/vercel/tool-approvals.ts +0 -380
  221. package/src/vercel/tool-events.ts +0 -53
@@ -1,81 +1,254 @@
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_EVENT_ID,
29
+ HEADER_FORK_OF,
30
+ HEADER_INPUT_CODEC_MESSAGE_ID,
31
+ HEADER_INVOCATION_ID,
32
+ HEADER_MSG_REGENERATE,
33
+ HEADER_PARENT,
34
+ HEADER_ROLE,
35
+ HEADER_RUN_CLIENT_ID,
36
+ HEADER_RUN_ID,
37
+ HEADER_STREAM,
38
+ } from '../../constants.js';
22
39
  import { EventEmitter } from '../../event-emitter.js';
23
40
  import type { Logger } from '../../logger.js';
24
- import type { MessageNode, Tree, TurnLifecycleEvent } from './types.js';
41
+ import { getTransportHeaders } from '../../utils.js';
42
+ import { toCodecEvents } from '../codec/codec-event.js';
43
+ import type { CodecEvent, CodecInputEvent, CodecOutputEvent, Reducer } from '../codec/types.js';
44
+ import type { ConversationNode, InputNode, OutputEvent, RunLifecycleEvent, RunNode, Tree } from './types.js';
45
+ import { WireLog } from './wire-log.js';
25
46
 
26
47
  // ---------------------------------------------------------------------------
27
48
  // Internal node type
28
49
  // ---------------------------------------------------------------------------
29
50
 
30
- interface InternalNode<TMessage> {
31
- node: MessageNode<TMessage>;
32
- /** Insertion sequence tiebreaker for null-serial messages. */
51
+ /**
52
+ * How long (in ms, on the Ably message-timestamp timeline) a structurally
53
+ * complete run's event log is retained after the node's last observed
54
+ * activity. Bounds cross-publisher live delivery reorder: a wire can be
55
+ * delivered after a higher-serial wire by at most this window. Conservative
56
+ * placeholder pending confirmation of the actual cross-region bound.
57
+ */
58
+ export const REORDER_WINDOW_MS = 120_000;
59
+
60
+ interface InternalNode<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection> {
61
+ node: ConversationNode<TProjection>;
62
+ /** Insertion sequence — tiebreaker for nodes with no sort serial (optimistic). */
33
63
  insertSeq: number;
64
+ /**
65
+ * The node's event log: every serial-bearing wire applied to this node, in
66
+ * canonical serial order. Owns its own record/refold/replay-guard/sweep
67
+ * mutation (see {@link WireLog}). Optimistic (serial-less) applies are not
68
+ * recorded.
69
+ */
70
+ log: WireLog<CodecEvent<TInput, TOutput>>;
71
+ /**
72
+ * Max Ably message timestamp (epoch ms) of everything applied to this node,
73
+ * including its run lifecycle events; 0 until a timestamped apply. The
74
+ * retention sweep measures {@link REORDER_WINDOW_MS} from here.
75
+ */
76
+ lastActivityTs: number;
77
+ /**
78
+ * Whether this run's `ai-run-start` has been observed (run nodes only —
79
+ * always false for input nodes). The structural half of log retention:
80
+ * run-start is the run's serial floor, so once it is observed no older
81
+ * history page can deliver further wires for this node.
82
+ */
83
+ runStartSeen: boolean;
84
+ /** Whether this node is already queued for sweeping (guards double-enqueue). */
85
+ sweepQueued: boolean;
86
+ /**
87
+ * Whether an optimistic (serial-less) seed has been folded into the
88
+ * projection but not into the log. The first serial-bearing wire (the echo)
89
+ * refolds the node from the log alone, discarding the seed, then clears
90
+ * this — so a codec needs no seed-replacement logic of its own.
91
+ */
92
+ optimistic: boolean;
34
93
  }
35
94
 
95
+ /**
96
+ * The primary key a node is indexed under: a reply run's `runId`, or an input
97
+ * node's `codecMessageId` (the client owns it before the agent mints a runId).
98
+ * @param node - The node to key.
99
+ * @returns The node's primary key.
100
+ */
101
+ export const nodeKey = <TProjection>(node: ConversationNode<TProjection>): string =>
102
+ node.kind === 'run' ? node.runId : node.codecMessageId;
103
+
104
+ /**
105
+ * The serial a node sorts by: a reply run's `startSerial`, an input node's
106
+ * `serial`. Undefined for an optimistic (not-yet-acked) node, which tail-sorts.
107
+ * @param node - The node to read.
108
+ * @returns The sort serial, or undefined for an optimistic node.
109
+ */
110
+ const sortSerial = <TProjection>(node: ConversationNode<TProjection>): string | undefined =>
111
+ node.kind === 'run' ? node.startSerial : node.serial;
112
+
113
+ /**
114
+ * Add a value to a `Map<K, Set<V>>`, creating the bucket Set on first use.
115
+ * @param map - The Map to mutate.
116
+ * @param key - The bucket key.
117
+ * @param value - The value to add.
118
+ */
119
+ const addToSetMap = <K, V>(map: Map<K, Set<V>>, key: K, value: V): void => {
120
+ let set = map.get(key);
121
+ if (!set) {
122
+ set = new Set();
123
+ map.set(key, set);
124
+ }
125
+ set.add(value);
126
+ };
127
+
128
+ /**
129
+ * Remove a value from a `Map<K, Set<V>>`, dropping the bucket when it empties.
130
+ * @param map - The Map to mutate.
131
+ * @param key - The bucket key.
132
+ * @param value - The value to remove.
133
+ */
134
+ const deleteFromSetMap = <K, V>(map: Map<K, Set<V>>, key: K, value: V): void => {
135
+ const set = map.get(key);
136
+ if (!set) return;
137
+ set.delete(value);
138
+ if (set.size === 0) map.delete(key);
139
+ };
140
+
36
141
  // ---------------------------------------------------------------------------
37
- // Internal interface — extended surface consumed by View
142
+ // Internal interface — extended surface consumed by View / ClientSession
38
143
  // ---------------------------------------------------------------------------
39
144
 
40
- /** Internal tree surface used by View — not part of the public Tree API. */
41
- export interface TreeInternal<TMessage> extends Tree<TMessage> {
145
+ /** Internal tree surface used by View and ClientSession — not part of the public Tree API. */
146
+ export interface TreeInternal<
147
+ TInput extends CodecInputEvent,
148
+ TOutput extends CodecOutputEvent,
149
+ TProjection,
150
+ > extends Tree<TOutput, TProjection> {
151
+ /**
152
+ * Walk the visible node chain (both input nodes and reply runs) along the
153
+ * selected branches, in chronological order. The View renders from this.
154
+ * @param selections - Per-group selected member key, keyed by group root.
155
+ * @returns The visible nodes in chronological order.
156
+ */
157
+ visibleNodes(selections?: Map<string, string>): ConversationNode<TProjection>[];
158
+
159
+ /**
160
+ * Get the "group root" key for a sibling group — the stable key the
161
+ * selection map is keyed by (the earliest edit version for input nodes, the
162
+ * original reply for a regenerate group).
163
+ */
164
+ getGroupRoot(key: string): string;
165
+
42
166
  /**
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.
167
+ * The reply runs parented at an input node (its codec-message-id), in
168
+ * iteration order. Empty when none have been observed. Used to resolve a
169
+ * user prompt to its reply run(s).
170
+ * @param inputCodecMessageId - The input node's codec-message-id.
171
+ * @returns The reply runs parented at that input.
47
172
  */
48
- readonly structuralVersion: number;
173
+ getReplyRuns(inputCodecMessageId: string): RunNode<TProjection>[];
49
174
 
50
175
  /**
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.
176
+ * Apply an inbound channel message to the tree.
177
+ *
178
+ * Classifies the message and routes it to the owning node:
179
+ * 1. Run-less user input (no run-id, a `user`-role message carrying a
180
+ * codec-message-id and input events): creates or promotes the input node
181
+ * keyed by that codec-message-id, folds the input events.
182
+ * 2. Run-bearing wire (assistant output, continuation tool-resolution, or a
183
+ * fresh agent-minted run): routes to the reply run by run-id (reconciling
184
+ * an optimistic insert by codec-message-id), folds events.
185
+ * @param events - Decoded codec events, split by wire direction. Both are
186
+ * folded into the node's projection, inputs first.
187
+ * @param events.inputs - Client-published events (`ai-input` wire).
188
+ * @param events.outputs - Agent-published events (`ai-output` wire).
189
+ * @param headers - Transport headers from the inbound Ably message.
190
+ * @param serial - Ably channel serial; undefined for optimistic inserts.
191
+ * @param timestamp - Ably server timestamp (epoch ms) of the message —
192
+ * top-level `Message.timestamp`, the message's create time on every
193
+ * delivery (an append's own receive time lives in `version.timestamp`) —
194
+ * or undefined for optimistic inserts. Advances the Tree's event-log
195
+ * retention clock and the owning node's last-activity time.
196
+ * @param version - The delivery's `Message.version.serial`, or undefined
197
+ * when the delivery carried none (optimistic inserts, never-mutated
198
+ * deliveries from sources that omit it). Guards the node's event log
199
+ * against whole-wire replays: a delivery at or below the version already
200
+ * decoded into its log entry is dropped.
56
201
  */
57
- flattenNodes(selections: Map<string, string>): MessageNode<TMessage>[];
202
+ applyMessage(
203
+ events: { inputs: TInput[]; outputs: TOutput[] },
204
+ headers: Record<string, string>,
205
+ serial?: string,
206
+ timestamp?: number,
207
+ version?: string,
208
+ ): void;
58
209
 
59
210
  /**
60
- * Get the "group root" msgId for a sibling group — the original message
61
- * that all forks in the group trace back to.
211
+ * Apply a run-lifecycle event.
212
+ *
213
+ * - `start`: creates the reply run (if missing) or, for an existing run,
214
+ * sets RunNode.state to 'active', promotes startSerial, and backfills
215
+ * structural metadata (parent / forkOf / regenerates / invocationId).
216
+ * - `suspend`: sets RunNode.state to 'suspended' and records `endSerial`.
217
+ * The run stays live so a resume under the same `runId` picks up where it
218
+ * left off.
219
+ * - `resume`: re-activates an existing suspended Run (state back to
220
+ * 'active') without touching its structure or serials — a pure re-entry
221
+ * signal. A no-op if the Run is not yet known.
222
+ * - `end`: sets RunNode.state to the terminal reason and records
223
+ * `endSerial`.
224
+ *
225
+ * Always emits a 'run' event to subscribers.
226
+ * @param event - Lifecycle event payload, including the channel serial.
62
227
  */
63
- getGroupRoot(msgId: string): string;
228
+ applyRunLifecycle(event: RunLifecycleEvent): void;
64
229
 
65
230
  /**
66
- * Get the sibling group that `msgId` belongs to, as full MessageNode objects.
67
- * Allows callers to resolve index msgId without losing identity.
231
+ * Get the node keyed by `key`, or undefined if `key` names no node. The
232
+ * key is a {@link nodeKey} a runId (reply run) or an input node's
233
+ * codec-message-id — so the result is a {@link ConversationNode} union:
234
+ * narrow on `kind` before reading kind-specific fields. Pairs with
235
+ * {@link getNodeByCodecMessageId}, which resolves an arbitrary owned
236
+ * codec-message-id (including an assistant message's) to its node.
237
+ * @param key - The node key to look up.
238
+ * @returns The node, or undefined if not found.
68
239
  */
69
- getSiblingNodes(msgId: string): MessageNode<TMessage>[];
240
+ getNode(key: string): ConversationNode<TProjection> | undefined;
241
+
242
+ /**
243
+ * Remove a node from the tree by its key ({@link nodeKey} — a runId or an
244
+ * input node's codec-message-id). Children become unreachable because their
245
+ * parent is no longer on the active path.
246
+ * @param key - The node key to remove.
247
+ */
248
+ delete(key: string): void;
70
249
 
71
250
  /** Forward a raw Ably message event to tree subscribers. */
72
251
  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
252
  }
80
253
 
81
254
  // ---------------------------------------------------------------------------
@@ -83,49 +256,112 @@ export interface TreeInternal<TMessage> extends Tree<TMessage> {
83
256
  // ---------------------------------------------------------------------------
84
257
 
85
258
  /** EventEmitter events map for the tree. */
86
- interface TreeEventsMap {
259
+ interface TreeEventsMap<TOutput extends CodecOutputEvent> {
87
260
  update: undefined;
88
261
  'ably-message': Ably.InboundMessage;
89
- turn: TurnLifecycleEvent;
262
+ run: RunLifecycleEvent;
263
+ output: OutputEvent<TOutput>;
90
264
  }
91
265
 
92
266
  // 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>>();
267
+ export class DefaultTree<
268
+ TInput extends CodecInputEvent,
269
+ TOutput extends CodecOutputEvent,
270
+ TProjection,
271
+ > implements TreeInternal<TInput, TOutput, TProjection> {
272
+ private readonly _codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>;
273
+ private readonly _logger: Logger;
274
+ private readonly _emitter: EventEmitter<TreeEventsMap<TOutput>>;
96
275
 
97
276
  /**
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.
277
+ * All nodes indexed by their primary key ({@link nodeKey}): a reply run's
278
+ * runId, or an input node's codec-message-id.
101
279
  */
102
- private readonly _sortedList: InternalNode<TMessage>[] = [];
280
+ private readonly _nodeIndex = new Map<string, InternalNode<TInput, TOutput, TProjection>>();
103
281
 
104
282
  /**
105
- * Parent index: parentId to set of child msgIds.
106
- * Nodes with no parent are indexed under the key `null`.
283
+ * Maps every observed `codec-message-id` to its owning node's key
284
+ * ({@link nodeKey}). For a reply run that is the runId of every message the
285
+ * run published; for an input node it is the input's own codec-message-id.
286
+ * Resolves fork-of / parent codec-message-ids to node keys, routes
287
+ * continuation amend wires to existing nodes, and backs UI lookups that hold
288
+ * a codec-message-id.
107
289
  */
108
- private readonly _parentIndex = new Map<string | undefined, Set<string>>();
290
+ private readonly _codecMessageIdToNodeKey = new Map<string, string>();
109
291
 
110
- private readonly _emitter: EventEmitter<TreeEventsMap>;
111
- private readonly _logger: Logger;
292
+ /**
293
+ * All nodes sorted by their sort serial ({@link sortSerial}: `startSerial`
294
+ * for runs, `serial` for input nodes), lexicographically. Nodes with no sort
295
+ * serial (optimistic) sort after all serial-bearing nodes, ordered among
296
+ * themselves by insertion sequence.
297
+ */
298
+ private readonly _sortedNodes: InternalNode<TInput, TOutput, TProjection>[] = [];
112
299
 
113
- /** Active turns: turnId → clientId. */
114
- private readonly _turnClientIds = new Map<string, string>();
300
+ /**
301
+ * Parent index: parent node key (the key its children's
302
+ * `parentCodecMessageId` resolves to) to the set of child node keys. Root
303
+ * nodes (no parent) are indexed under the key `undefined`. Kind-blind — a
304
+ * reply run and an input node parent off each other through the same index.
305
+ */
306
+ private readonly _parentIndex = new Map<string | undefined, Set<string>>();
307
+
308
+ /**
309
+ * Reverse edge: an input node's codec-message-id to the set of reply-run ids
310
+ * parented at it. Lets the View resolve a user prompt to its (selected) reply
311
+ * run, and groups regenerate siblings (which all parent at the same input
312
+ * node).
313
+ */
314
+ private readonly _replyRunsByInput = new Map<string, Set<string>>();
115
315
 
116
316
  /** Monotonically increasing counter for insertion sequence. */
117
317
  private _seqCounter = 0;
118
318
 
119
- /** Incremented on structural changes; unchanged on content-only updates. */
319
+ /** Incremented on structural changes; unchanged on projection-only updates. */
120
320
  private _structuralVersion = 0;
121
321
 
122
- get structuralVersion(): number {
123
- return this._structuralVersion;
124
- }
322
+ /**
323
+ * Cached sibling-group lookups keyed by node key. The walk over forkOf
324
+ * chains and the per-parent fan-out are pure functions of the node
325
+ * graph, so the cache is keyed against {@link _structuralVersion}:
326
+ * any topology mutation drops the cache and the next lookup
327
+ * recomputes. Hits matter most during a single render pass where
328
+ * the View calls `getSiblingNodes` once per visible node plus extra
329
+ * per-message branch-anchor probes from React components.
330
+ */
331
+ private _siblingCache = new Map<string, InternalNode<TInput, TOutput, TProjection>[]>();
332
+ private _siblingCacheVersion = -1;
333
+
334
+ /**
335
+ * Index from `event-id` header to the raw Ably message that carried it.
336
+ * Populated incrementally as messages arrive via {@link emitAblyMessage};
337
+ * reads back the raw message for the agent's input-event lookup
338
+ * ({@link findAblyMessageByEventId}). Bounded by the Tree's lifetime — cleared
339
+ * when the Tree is replaced on continuity loss / session close.
340
+ */
341
+ private readonly _eventIdIndex = new Map<string, Ably.InboundMessage>();
342
+
343
+ /**
344
+ * Event-log retention logical clock: the max Ably message timestamp (epoch
345
+ * ms) observed across every apply, 0 until the first timestamped one. Only
346
+ * ever advances — older-page history application carries smaller timestamps
347
+ * and leaves it (and therefore the sweep) untouched.
348
+ */
349
+ private _clock = 0;
350
+
351
+ /**
352
+ * Keys of structurally complete run nodes (run-start and run-end both
353
+ * observed) whose event logs await the retention window, in completion
354
+ * order. Drained from the front whenever {@link _clock} advances; sweeping
355
+ * only at clock advances keeps a history page's batch atomic — applying an
356
+ * older page can never advance the clock, so a node cannot be swept between
357
+ * its run-start and the rest of its wires in the same page.
358
+ */
359
+ private readonly _sweepQueue: string[] = [];
125
360
 
126
- constructor(logger: Logger) {
361
+ constructor(codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>, logger: Logger) {
362
+ this._codec = codec;
127
363
  this._logger = logger;
128
- this._emitter = new EventEmitter<TreeEventsMap>(logger);
364
+ this._emitter = new EventEmitter<TreeEventsMap<TOutput>>(logger);
129
365
  }
130
366
 
131
367
  // -------------------------------------------------------------------------
@@ -133,405 +369,1149 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
133
369
  // -------------------------------------------------------------------------
134
370
 
135
371
  /**
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.
372
+ * Compare two nodes (Run or input) for sorted list ordering.
373
+ * Serial-bearing nodes sort by their sort serial (`startSerial` for runs,
374
+ * `serial` for input nodes), lexicographically.
375
+ * Nodes with no sort serial sort after all serial-bearing nodes.
376
+ * Among them, sort by insertion sequence.
377
+ *
378
+ * Optimistic (null-serial) nodes intentionally tail-sort so they reorder
379
+ * into place when the server relay arrives and `applyMessage` promotes
380
+ * startSerial — see {@link applyMessage}'s `_removeSortedNode` /
381
+ * `_insertSortedNode` pair on the promotion path.
140
382
  * @param a - First node to compare.
141
383
  * @param b - Second node to compare.
142
384
  * @returns Negative if a sorts before b, positive if after, zero if equal.
143
385
  */
144
386
  // 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;
387
+ private _compareNodes(
388
+ a: InternalNode<TInput, TOutput, TProjection>,
389
+ b: InternalNode<TInput, TOutput, TProjection>,
390
+ ): number {
391
+ const sa = sortSerial(a.node);
392
+ const sb = sortSerial(b.node);
148
393
  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
394
+ if (sa === undefined) return 1;
395
+ if (sb === undefined) return -1;
151
396
  if (sa < sb) return -1;
152
397
  if (sa > sb) return 1;
153
- return a.insertSeq - b.insertSeq; // same serial: preserve insertion order
398
+ return a.insertSeq - b.insertSeq;
154
399
  }
155
400
 
156
401
  /**
157
- * Insert a node into sortedList at the correct position via binary search.
402
+ * Insert a node into the sorted list at the correct position via binary search.
158
403
  * @param internal - The node to insert.
159
404
  */
160
- private _insertSorted(internal: InternalNode<TMessage>): void {
161
- const serial = internal.node.serial;
405
+ private _insertSortedNode(internal: InternalNode<TInput, TOutput, TProjection>): void {
406
+ const startSerial = sortSerial(internal.node);
162
407
 
163
- // Fast path: null-serial always appends to end (among other null-serials)
164
- if (serial === undefined) {
165
- this._sortedList.push(internal);
408
+ // Fast path: null-startSerial always appends to end.
409
+ if (startSerial === undefined) {
410
+ this._sortedNodes.push(internal);
166
411
  return;
167
412
  }
168
413
 
169
- // Binary search for insertion point among serial-bearing nodes.
170
414
  let lo = 0;
171
- let hi = this._sortedList.length;
415
+ let hi = this._sortedNodes.length;
172
416
  while (lo < hi) {
173
417
  const mid = (lo + hi) >>> 1;
174
- const midNode = this._sortedList[mid];
175
- if (!midNode) break; // unreachable: mid is always in bounds
418
+ const midNode = this._sortedNodes[mid];
419
+ if (!midNode) break; // unreachable
176
420
  if (this._compareNodes(midNode, internal) <= 0) {
177
421
  lo = mid + 1;
178
422
  } else {
179
423
  hi = mid;
180
424
  }
181
425
  }
182
- this._sortedList.splice(lo, 0, internal);
426
+ this._sortedNodes.splice(lo, 0, internal);
183
427
  }
184
428
 
185
429
  /**
186
- * Remove a node from sortedList.
430
+ * Remove a node from the sorted list.
187
431
  * @param internal - The node to remove.
188
432
  */
189
- private _removeSorted(internal: InternalNode<TMessage>): void {
190
- const idx = this._sortedList.indexOf(internal);
191
- if (idx !== -1) this._sortedList.splice(idx, 1);
433
+ private _removeSortedNode(internal: InternalNode<TInput, TOutput, TProjection>): void {
434
+ const idx = this._sortedNodes.indexOf(internal);
435
+ if (idx !== -1) this._sortedNodes.splice(idx, 1);
436
+ }
437
+
438
+ /**
439
+ * Insert a freshly-created node into the primary store, the parent index, and
440
+ * the sorted list, then bump the structural version. Kind-specific secondary
441
+ * indexing — the codec-message-id map for input nodes, the reply→input edge
442
+ * for reply runs — is the caller's responsibility.
443
+ * @param key - The node's primary key ({@link nodeKey}).
444
+ * @param entry - The internal node to insert.
445
+ * @param parentCodecMessageId - The node's structural parent, or undefined for a root.
446
+ */
447
+ private _insertNode(
448
+ key: string,
449
+ entry: InternalNode<TInput, TOutput, TProjection>,
450
+ parentCodecMessageId: string | undefined,
451
+ ): void {
452
+ this._nodeIndex.set(key, entry);
453
+ this._addToParentIndex(parentCodecMessageId, key);
454
+ this._insertSortedNode(entry);
455
+ this._structuralVersion++;
456
+ }
457
+
458
+ /**
459
+ * Re-sort a node whose sort key just changed and bump the structural version.
460
+ * The caller mutates the serial field (`serial` for input nodes, `startSerial`
461
+ * for runs); this keeps the sorted list and version in step. Used on the
462
+ * optimistic-serial promotion paths when the server relay/echo arrives.
463
+ * @param entry - The internal node whose serial was just promoted.
464
+ */
465
+ private _promoteSerial(entry: InternalNode<TInput, TOutput, TProjection>): void {
466
+ this._removeSortedNode(entry);
467
+ this._insertSortedNode(entry);
468
+ this._structuralVersion++;
469
+ }
470
+
471
+ /**
472
+ * Fold a batch of events into a node's projection in place, isolating each
473
+ * fold in a try/catch so a throwing reducer can't abort the rest of the batch
474
+ * or the surrounding apply.
475
+ * @param entry - The internal node whose projection is folded in place.
476
+ * @param events - The decoded events to fold, in wire order.
477
+ * @param serial - Ably channel serial; coerced to '' for an optimistic insert.
478
+ * @param messageId - The reducer routing key (codec-message-id), or undefined.
479
+ */
480
+ private _foldInto(
481
+ entry: InternalNode<TInput, TOutput, TProjection>,
482
+ events: CodecEvent<TInput, TOutput>[],
483
+ serial: string | undefined,
484
+ messageId: string | undefined,
485
+ ): void {
486
+ for (const event of events) {
487
+ try {
488
+ entry.node.projection = this._codec.fold(entry.node.projection, event, { serial: serial ?? '', messageId });
489
+ } catch (error) {
490
+ this._logger.error('Tree._foldInto(); fold threw', { key: nodeKey(entry.node), messageId, err: error });
491
+ }
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Record a serial-bearing wire in the node's event log and fold it. Events
497
+ * extending the log tail (the common case — in-order live delivery) fold
498
+ * incrementally onto the existing projection, identical to a bare
499
+ * {@link _foldInto}. Events that land earlier in the log (an earlier-serial
500
+ * wire delivered late — cross-publisher reorder, or a history page applying
501
+ * an older message after a newer one) cannot be folded incrementally without
502
+ * corrupting serial order, so the node is refolded from the whole log via
503
+ * {@link _refold}.
504
+ *
505
+ * Optimistic (serial-less) applies and empty event batches are not logged;
506
+ * an optimistic seed folds into the projection but never into the log, and
507
+ * marks the node `optimistic`. The first serial-bearing wire (the echo of
508
+ * the optimistic input, which re-delivers the seeded content) refolds the
509
+ * node from the log alone — rebuilding the projection without the seed
510
+ * rather than folding the echo on top of it. The codec therefore never sees
511
+ * the seed and its echo in one projection, and needs no seed-replacement
512
+ * logic. The seed must be a faithful preview of the echo, since the echo's
513
+ * content is what survives.
514
+ *
515
+ * Whole-wire replays are dropped at the log: each entry records the highest
516
+ * `Message.version.serial` decoded into it (`decodedThrough`), so a
517
+ * version-bearing delivery the entry has already incorporated — a second
518
+ * hydration over a populated Tree, a remounted View's re-fetch, an agent
519
+ * re-walk — records nothing and folds nothing. A newer version of a
520
+ * discrete wire (an edited discrete) is likewise dropped; propagating edits
521
+ * into projections is deliberately out of scope.
522
+ * @param entry - The internal node whose log and projection are updated.
523
+ * @param events - The decoded events to fold, in wire order.
524
+ * @param serial - Ably channel serial; undefined for an optimistic insert.
525
+ * @param messageId - The reducer routing key (codec-message-id), or undefined.
526
+ * @param version - The delivery's `Message.version.serial`, or undefined.
527
+ * @param streamed - Whether the delivery is part of a streamed wire.
528
+ */
529
+ private _recordAndFold(
530
+ entry: InternalNode<TInput, TOutput, TProjection>,
531
+ events: CodecEvent<TInput, TOutput>[],
532
+ serial: string | undefined,
533
+ messageId: string | undefined,
534
+ version: string | undefined,
535
+ streamed: boolean,
536
+ ): void {
537
+ // A serial-less optimistic seed (or an empty batch) is not logged. Fold it
538
+ // in; a non-empty seed marks the node so its echo refolds the seed away.
539
+ if (serial === undefined || events.length === 0) {
540
+ if (serial === undefined && events.length > 0) entry.optimistic = true;
541
+ this._foldInto(entry, events, serial, messageId);
542
+ return;
543
+ }
544
+
545
+ const fold = entry.log.record(serial, messageId, events, version, streamed);
546
+ if (fold === 'dropped') {
547
+ // The version guard rejected a re-delivery the log already incorporated —
548
+ // a whole-wire replay (second hydration, remount, agent re-walk, or a
549
+ // `loadOlder()` re-applying a swept run's history) or an edit to a
550
+ // discrete. Nothing to fold.
551
+ this._logger.debug('Tree._recordAndFold(); version guard dropped re-delivered wire', {
552
+ key: nodeKey(entry.node),
553
+ serial,
554
+ version,
555
+ swept: entry.log.swept,
556
+ });
557
+ return;
558
+ }
559
+ if (entry.optimistic && !entry.log.swept) {
560
+ // First serial-bearing wire (the echo) on a node that carries an
561
+ // optimistic seed. The seed is in the projection but not the log, so
562
+ // refold from the log alone — the echo re-delivers the seeded content —
563
+ // rebuilding the projection without the seed instead of folding the echo
564
+ // on top of it.
565
+ entry.optimistic = false;
566
+ this._refold(entry);
567
+ return;
568
+ }
569
+ if (fold === 'refold') {
570
+ this._refold(entry);
571
+ return;
572
+ }
573
+ // 'incremental'. On a swept log this is a genuinely-new wire outside the
574
+ // reorder window (it should not occur) folding in arrival order — the log
575
+ // could not refold it.
576
+ if (entry.log.swept) {
577
+ this._logger.warn('Tree._recordAndFold(); late wire after log retention window; folding in arrival order', {
578
+ key: nodeKey(entry.node),
579
+ serial,
580
+ });
581
+ }
582
+ this._foldInto(entry, events, serial, messageId);
583
+ }
584
+
585
+ /**
586
+ * Rebuild a node's projection from its event log in canonical serial order:
587
+ * a fresh {@link Reducer.init} folded through every logged event, each with
588
+ * its own wire's serial and messageId. Used when a late, earlier-serial wire
589
+ * makes incremental folding unsound. Reducer purity (a fold is a function of
590
+ * its inputs alone) is what makes the rebuild faithful; the per-fold
591
+ * try/catch mirrors {@link _foldInto} so one throwing event can't abort the
592
+ * rebuild.
593
+ *
594
+ * Rebuilds the projection only; the surrounding apply emits its usual
595
+ * `output` event carrying just the triggering wire's events. Consumers read
596
+ * the rebuilt state from `node.projection` (the View recomputes its message
597
+ * list from it), so on the refold path the event's `events` payload is not a
598
+ * delta of the full projection change.
599
+ * @param entry - The internal node whose projection is rebuilt in place.
600
+ */
601
+ private _refold(entry: InternalNode<TInput, TOutput, TProjection>): void {
602
+ let projection = this._codec.init();
603
+ entry.log.replay((event, serial, messageId) => {
604
+ try {
605
+ projection = this._codec.fold(projection, event, { serial, messageId });
606
+ } catch (error) {
607
+ this._logger.error('Tree._refold(); fold threw', { key: nodeKey(entry.node), messageId, err: error });
608
+ }
609
+ });
610
+ entry.node.projection = projection;
192
611
  }
193
612
 
194
613
  // -------------------------------------------------------------------------
195
- // Parent index maintenance
614
+ // Event-log retention
196
615
  // -------------------------------------------------------------------------
197
616
 
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);
617
+ /**
618
+ * Record activity on a node and advance the retention clock. Updates the
619
+ * node's `lastActivityTs` and the Tree-wide `_clock` to the given timestamp
620
+ * when it is newer; a clock advance drains the sweep queue. `undefined`
621
+ * (an optimistic local apply) advances nothing.
622
+ * @param entry - The node the activity belongs to.
623
+ * @param timestamp - Ably message timestamp (epoch ms), or undefined.
624
+ */
625
+ private _recordActivity(entry: InternalNode<TInput, TOutput, TProjection>, timestamp: number | undefined): void {
626
+ if (timestamp === undefined) return;
627
+ if (timestamp > entry.lastActivityTs) entry.lastActivityTs = timestamp;
628
+ if (timestamp > this._clock) {
629
+ this._clock = timestamp;
630
+ this._drainSweepQueue();
203
631
  }
204
- set.add(msgId);
205
632
  }
206
633
 
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);
634
+ /**
635
+ * Queue a run node's event log for retention sweeping once the node is
636
+ * structurally complete: its run-start (serial floor — no older history page
637
+ * can add to it) and its run-end (no further agent output) have both been
638
+ * observed. The actual drop happens in {@link _drainSweepQueue} once the
639
+ * reorder window has also lapsed. No-op for input nodes (never swept — no
640
+ * floor marker, and their logs are bounded by one user message), for nodes
641
+ * already queued or swept, and while either marker is missing.
642
+ * @param entry - The node to consider for sweeping.
643
+ */
644
+ private _maybeQueueSweep(entry: InternalNode<TInput, TOutput, TProjection>): void {
645
+ const node = entry.node;
646
+ if (node.kind !== 'run') return;
647
+ if (entry.log.swept || entry.sweepQueued) return;
648
+ if (!entry.runStartSeen) return;
649
+ if (node.state.status === 'active' || node.state.status === 'suspended') return;
650
+ entry.sweepQueued = true;
651
+ this._sweepQueue.push(node.runId);
652
+ }
653
+
654
+ /**
655
+ * Drop the event logs of queued nodes whose retention window has lapsed:
656
+ * `lastActivityTs + REORDER_WINDOW_MS < _clock`. Drains from the front and
657
+ * stops at the first node still inside the window — completion order is
658
+ * time-ordered for live traffic, so this is amortised O(1) per apply, and
659
+ * stopping early only ever over-retains (memory, never correctness). Called
660
+ * only when the clock advances, so applying an older history page (smaller
661
+ * timestamps) can never sweep mid-batch. Deleted nodes are skipped.
662
+ */
663
+ private _drainSweepQueue(): void {
664
+ while (this._sweepQueue.length > 0) {
665
+ const key = this._sweepQueue[0];
666
+ const entry = key === undefined ? undefined : this._nodeIndex.get(key);
667
+ if (!entry || entry.log.swept) {
668
+ this._sweepQueue.shift();
669
+ continue;
670
+ }
671
+ if (entry.lastActivityTs + REORDER_WINDOW_MS >= this._clock) return;
672
+ this._sweepQueue.shift();
673
+ entry.sweepQueued = false;
674
+ // Drop the decoded payloads (the unbounded cost) but keep each entry's
675
+ // replay key, so a post-sweep whole-wire replay is still recognised and
676
+ // dropped rather than re-folded (a refold can no longer rebuild them).
677
+ entry.log.sweep();
678
+ this._logger.debug('Tree._drainSweepQueue(); dropped event-log payloads, kept replay keys', {
679
+ key,
680
+ lastActivityTs: entry.lastActivityTs,
681
+ });
212
682
  }
213
683
  }
214
684
 
685
+ // -------------------------------------------------------------------------
686
+ // Parent index maintenance
687
+ // -------------------------------------------------------------------------
688
+
689
+ private _addToParentIndex(parentNodeKey: string | undefined, childKey: string): void {
690
+ addToSetMap(this._parentIndex, parentNodeKey, childKey);
691
+ }
692
+
693
+ private _removeFromParentIndex(parentNodeKey: string | undefined, childKey: string): void {
694
+ deleteFromSetMap(this._parentIndex, parentNodeKey, childKey);
695
+ }
696
+
697
+ /**
698
+ * Resolve a node's structural parent to the parent node's key
699
+ * ({@link nodeKey}), or undefined for a root. The parent is named by a
700
+ * codec-message-id (`parentCodecMessageId`); this maps it through the
701
+ * codec-message-id index to the owning node's key (a runId for a reply run,
702
+ * a codec-message-id for an input node). Returns undefined when the parent
703
+ * hasn't been observed yet (the node is treated as a root until it arrives).
704
+ * @param node - The node whose parent to resolve.
705
+ * @returns The parent node's key, or undefined.
706
+ */
707
+ private _parentKeyOf(node: ConversationNode<TProjection>): string | undefined {
708
+ const parentCodecMessageId = node.parentCodecMessageId;
709
+ return parentCodecMessageId === undefined ? undefined : this._codecMessageIdToNodeKey.get(parentCodecMessageId);
710
+ }
711
+
215
712
  // -------------------------------------------------------------------------
216
713
  // Sibling grouping
217
714
  // -------------------------------------------------------------------------
218
715
 
219
716
  /**
220
- * Get the sibling group that `msgId` belongs to.
717
+ * Walk an input node's `forkOf` chain to the group root — the earliest edit
718
+ * version sharing the same structural parent. Stops at a missing target, a
719
+ * non-input target, a parent mismatch, or a cycle.
720
+ * @param node - The input node to walk from.
721
+ * @returns The group-root input node (the node itself when it is the root).
722
+ */
723
+ private _inputGroupRoot(node: InputNode<TProjection>): InputNode<TProjection> {
724
+ let current = node;
725
+ const visited = new Set<string>([nodeKey(current)]);
726
+ while (current.forkOf !== undefined) {
727
+ if (visited.has(current.forkOf)) break;
728
+ const forkTarget = this._nodeIndex.get(current.forkOf);
729
+ if (forkTarget?.node.kind !== 'input' || forkTarget.node.parentCodecMessageId !== current.parentCodecMessageId) {
730
+ break;
731
+ }
732
+ current = forkTarget.node;
733
+ visited.add(nodeKey(current));
734
+ }
735
+ return current;
736
+ }
737
+
738
+ /**
739
+ * Get the sibling group that the node keyed by `key` belongs to. Kind-split:
221
740
  *
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.
741
+ * - **Reply runs** every reply run sharing the same input-node parent is a
742
+ * sibling (the original reply + its regenerators all parent at the same
743
+ * input node M_user). No fork-of involved.
744
+ * - **Input nodes** edit versions: nodes sharing a parent AND linked by a
745
+ * `forkOf` chain to the group root.
746
+ *
747
+ * Returned ordered by startSerial (original/oldest first). A group of one is
748
+ * returned as a single-element array (no branching).
749
+ * @param key - The node key ({@link nodeKey}) to look up the group for.
227
750
  * @returns The ordered list of sibling nodes.
228
751
  */
229
752
  // Spec: AIT-CT13b
230
- private _getSiblingGroup(msgId: string): MessageNode<TMessage>[] {
231
- const entry = this._nodeIndex.get(msgId);
753
+ private _getSiblingGroup(key: string): InternalNode<TInput, TOutput, TProjection>[] {
754
+ if (this._siblingCacheVersion !== this._structuralVersion) {
755
+ this._siblingCache.clear();
756
+ this._siblingCacheVersion = this._structuralVersion;
757
+ }
758
+ const cached = this._siblingCache.get(key);
759
+ if (cached) return cached;
760
+
761
+ const entry = this._nodeIndex.get(key);
232
762
  if (!entry) return [];
233
763
 
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.
764
+ // The "original" anchors the group's parent + kind. For an input node,
765
+ // walk the forkOf chain to the earliest version sharing the parent; for a
766
+ // reply run the node itself anchors (all same-parent runs are siblings).
236
767
  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);
244
- }
245
-
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)) {
768
+ if (original.kind === 'input') {
769
+ original = this._inputGroupRoot(original);
770
+ }
771
+
772
+ // `_parentIndex` is keyed by the raw structural `parentCodecMessageId` (not
773
+ // the resolved parent node key) so a run observed before its input node
774
+ // still files/groups correctly — the parent codec-message-id is known at
775
+ // creation, the resolved key may not be.
776
+ const parentKey = original.parentCodecMessageId;
777
+ const siblings: InternalNode<TInput, TOutput, TProjection>[] = [];
778
+ const candidateKeys = this._parentIndex.get(parentKey);
779
+ if (candidateKeys) {
780
+ for (const childKey of candidateKeys) {
781
+ const childEntry = this._nodeIndex.get(childKey);
782
+ if (childEntry && this._isSiblingOf(childEntry.node, original)) {
257
783
  siblings.push(childEntry);
258
784
  }
259
785
  }
260
786
  }
261
787
 
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
788
  siblings.sort((a, b) => this._compareNodes(a, b));
266
- return siblings.map((s) => s.node);
789
+ // Cache against the queried key AND every member of the group: a single
790
+ // group is the same array regardless of which member triggered the lookup,
791
+ // so subsequent queries against any member hit without recomputing.
792
+ for (const sib of siblings) {
793
+ this._siblingCache.set(nodeKey(sib.node), siblings);
794
+ }
795
+ this._siblingCache.set(key, siblings);
796
+ return siblings;
267
797
  }
268
798
 
269
799
  /**
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.
800
+ * Whether `node` belongs to the sibling group anchored at `original`.
801
+ * Requires the same kind and the same structural parent; reply runs need
802
+ * nothing more (same-parent runs are regenerate siblings), input nodes must
803
+ * additionally be forkOf-linked to the original (edit versions).
804
+ * @param node - The candidate node.
805
+ * @param original - The group's anchor node.
806
+ * @returns True if `node` is a sibling of `original`.
276
807
  */
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
808
+ private _isSiblingOf(node: ConversationNode<TProjection>, original: ConversationNode<TProjection>): boolean {
809
+ if (node.kind !== original.kind) return false;
810
+ if (node.parentCodecMessageId !== original.parentCodecMessageId) return false;
811
+ // Same-parent reply runs are regenerate siblings — no fork-of needed.
812
+ if (node.kind === 'run') return true;
813
+ // Input nodes: must be forkOf-linked to the original (edit versions).
814
+ const originalKey = nodeKey(original);
815
+ if (nodeKey(node) === originalKey) return true;
816
+ let current: ConversationNode<TProjection> = node;
817
+ const visited = new Set<string>([nodeKey(current)]);
818
+ while (current.kind === 'input' && current.forkOf !== undefined) {
819
+ if (current.forkOf === originalKey) return true;
820
+ if (visited.has(current.forkOf)) break;
284
821
  const target = this._nodeIndex.get(current.forkOf);
285
822
  if (!target) break;
286
823
  current = target.node;
287
- visited.add(current.msgId);
824
+ visited.add(nodeKey(current));
288
825
  }
289
826
  return false;
290
827
  }
291
828
 
292
829
  /**
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.
830
+ * Get the "group root" key for a sibling group — the stable key the
831
+ * selection map is keyed by. For an input node (edit versions) that is the
832
+ * earliest fork-of ancestor; for a reply run (regenerate group) it is the
833
+ * oldest same-parent run (the original reply).
834
+ * @param key - Any node key in the sibling group.
835
+ * @returns The group root's key.
297
836
  */
298
- getGroupRoot(msgId: string): string {
299
- const entry = this._nodeIndex.get(msgId);
300
- if (!entry) return msgId;
837
+ getGroupRoot(key: string): string {
838
+ const entry = this._nodeIndex.get(key);
839
+ if (!entry) return key;
301
840
 
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);
841
+ if (entry.node.kind === 'input') {
842
+ return nodeKey(this._inputGroupRoot(entry.node));
310
843
  }
311
- return current.msgId;
844
+
845
+ // Reply run: the oldest same-parent run is the original reply.
846
+ const group = this._getSiblingGroup(key);
847
+ const root = group[0]?.node;
848
+ return root ? nodeKey(root) : key;
312
849
  }
313
850
 
314
851
  // -------------------------------------------------------------------------
315
852
  // Public query methods
316
853
  // -------------------------------------------------------------------------
317
854
 
318
- flattenNodes(selections: Map<string, string>): MessageNode<TMessage>[] {
319
- this._logger.trace('DefaultTree.flattenNodes();');
320
- const result: MessageNode<TMessage>[] = [];
855
+ /**
856
+ * Walk the visible node chain along the selected branches, kind-blind. An
857
+ * input node and a reply run reach each other through the same
858
+ * parent-membership check, so seed-only user→user chains and the
859
+ * input→reply→input weave both resolve here. Sibling groups (edit versions /
860
+ * regenerate runs) collapse to the selected member.
861
+ * @param selections - Per-group selected member key, keyed by group root.
862
+ * @returns The visible nodes (both kinds) in chronological order.
863
+ */
864
+ visibleNodes(selections: Map<string, string> = new Map<string, string>()): ConversationNode<TProjection>[] {
865
+ this._logger.trace('DefaultTree.visibleNodes();');
866
+ const result: ConversationNode<TProjection>[] = [];
321
867
  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
868
+ const resolvedGroups = new Map<string, string>(); // groupRootKey -> selected key
325
869
 
326
- for (const internal of this._sortedList) {
870
+ for (const internal of this._sortedNodes) {
327
871
  const node = internal.node;
328
- const { msgId, parentId } = node;
872
+ const key = nodeKey(node);
329
873
 
330
- // Step 1: Check parent reachability.
331
- if (parentId !== undefined && !currentPath.has(parentId)) {
874
+ // Step 1: Parent reachability (kind-blind — the parent may be an input
875
+ // node or a reply run; resolve its key and check the active path).
876
+ const parentKey = this._parentKeyOf(node);
877
+ if (parentKey !== undefined && !currentPath.has(parentKey)) {
332
878
  continue;
333
879
  }
334
880
 
335
- // Step 2: Check sibling selection.
336
- const group = this._getSiblingGroup(msgId);
881
+ // Step 2: Sibling selection.
882
+ const group = this._getSiblingGroup(key);
337
883
  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;
884
+ const groupRootKey = this.getGroupRoot(key);
885
+ let selectedKey = resolvedGroups.get(groupRootKey);
886
+ if (selectedKey === undefined) {
887
+ const preferredKey = selections.get(groupRootKey);
888
+ if (preferredKey !== undefined && group.some((n) => nodeKey(n.node) === preferredKey)) {
889
+ selectedKey = preferredKey;
345
890
  } else {
346
891
  const latest = group.at(-1);
347
892
  if (!latest) break; // unreachable: group.length > 1
348
- selectedId = latest.msgId;
893
+ selectedKey = nodeKey(latest.node);
349
894
  }
350
- resolvedGroups.set(groupRootId, selectedId);
895
+ resolvedGroups.set(groupRootKey, selectedKey);
351
896
  }
352
- if (msgId !== selectedId) {
897
+ if (key !== selectedKey) {
353
898
  continue;
354
899
  }
355
900
  }
356
901
 
357
- currentPath.add(msgId);
902
+ currentPath.add(key);
358
903
  result.push(node);
359
904
  }
360
905
 
361
906
  return result;
362
907
  }
363
908
 
364
- getSiblings(msgId: string): TMessage[] {
365
- this._logger.trace('DefaultTree.getSiblings();', { msgId });
366
- return this._getSiblingGroup(msgId).map((n) => n.message);
909
+ getRunNode(runId: string): RunNode<TProjection> | undefined {
910
+ this._logger.trace('DefaultTree.getRunNode();', { runId });
911
+ const node = this._nodeIndex.get(runId)?.node;
912
+ return node?.kind === 'run' ? node : undefined;
367
913
  }
368
914
 
369
- getSiblingNodes(msgId: string): MessageNode<TMessage>[] {
370
- return this._getSiblingGroup(msgId);
915
+ getNode(key: string): ConversationNode<TProjection> | undefined {
916
+ this._logger.trace('DefaultTree.getNode();', { key });
917
+ return this._nodeIndex.get(key)?.node;
371
918
  }
372
919
 
373
- hasSiblings(msgId: string): boolean {
374
- return this._getSiblingGroup(msgId).length > 1;
920
+ getNodeByCodecMessageId(codecMessageId: string): ConversationNode<TProjection> | undefined {
921
+ this._logger.trace('DefaultTree.getNodeByCodecMessageId();', { codecMessageId });
922
+ const key = this._codecMessageIdToNodeKey.get(codecMessageId);
923
+ return key === undefined ? undefined : this._nodeIndex.get(key)?.node;
375
924
  }
376
925
 
377
- getNode(msgId: string): MessageNode<TMessage> | undefined {
378
- this._logger.trace('DefaultTree.getNode();', { msgId });
379
- return this._nodeIndex.get(msgId)?.node;
926
+ getReplyRuns(inputCodecMessageId: string): RunNode<TProjection>[] {
927
+ const runIds = this._replyRunsByInput.get(inputCodecMessageId);
928
+ if (!runIds) return [];
929
+ const result: RunNode<TProjection>[] = [];
930
+ for (const runId of runIds) {
931
+ const node = this._nodeIndex.get(runId)?.node;
932
+ if (node?.kind === 'run') result.push(node);
933
+ }
934
+ return result;
380
935
  }
381
936
 
382
- getHeaders(msgId: string): Record<string, string> | undefined {
383
- this._logger.trace('DefaultTree.getHeaders();', { msgId });
384
- return this._nodeIndex.get(msgId)?.node.headers;
937
+ getSiblingNodes(key: string): ConversationNode<TProjection>[] {
938
+ this._logger.trace('DefaultTree.getSiblingNodes();', { key });
939
+ return this._getSiblingGroup(key).map((n) => n.node);
385
940
  }
386
941
 
387
942
  // -------------------------------------------------------------------------
388
943
  // Mutation
389
944
  // -------------------------------------------------------------------------
390
945
 
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');
946
+ applyMessage(
947
+ events: { inputs: TInput[]; outputs: TOutput[] },
948
+ headers: Record<string, string>,
949
+ serial?: string,
950
+ timestamp?: number,
951
+ version?: string,
952
+ ): void {
953
+ const wireRunId = headers[HEADER_RUN_ID];
954
+ const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
955
+
956
+ // Classify: with NO run-id, a user message carrying a codec-message-id and
957
+ // at least one input event forms an INPUT node keyed by that
958
+ // codec-message-id — the client owns it; the agent mints the reply run-id
959
+ // separately. Everything else needs a run-id to route to a reply run.
960
+ // Capturing the id (not a boolean) narrows it to `string` for the input path.
961
+ const inputNodeCodecMessageId =
962
+ wireRunId === undefined &&
963
+ codecMessageId !== undefined &&
964
+ headers[HEADER_ROLE] === 'user' &&
965
+ events.inputs.length > 0
966
+ ? codecMessageId
967
+ : undefined;
968
+
969
+ if (wireRunId === undefined && inputNodeCodecMessageId === undefined) {
970
+ this._logger.warn('Tree.applyMessage(); message has no run-id and is not a user input; skipping');
415
971
  return;
416
972
  }
417
973
 
418
- this._logger.trace('Tree.upsert(); inserting new node', { msgId, parentId, forkOf });
974
+ // Fold inputs first, then outputs, preserving wire order.
975
+ const all: CodecEvent<TInput, TOutput>[] = toCodecEvents(events);
419
976
 
420
- const node: MessageNode<TMessage> = {
421
- kind: 'message',
422
- message,
423
- msgId,
424
- parentId,
425
- forkOf,
426
- headers: { ...headers },
977
+ // Wire-only metadata-carrier messages (e.g. `ait-regenerate`) decode to
978
+ // zero events and don't need a node at the tree level — the eventual reply
979
+ // run is created later by run-start, and any regenerate / parent
980
+ // information the wire carried is reread from the run-start headers.
981
+ // Skipping here avoids a phantom node that would inflate sibling counts.
982
+ const existingKey = inputNodeCodecMessageId ?? wireRunId;
983
+ if (all.length === 0 && existingKey !== undefined && !this._nodeIndex.has(existingKey)) {
984
+ return;
985
+ }
986
+
987
+ // `update` is the structural channel: emit it only when this apply
988
+ // actually changes the tree shape (new node, startSerial promotion).
989
+ // Content-only folds (streaming chunks into an existing node) flow through
990
+ // `output` instead, so they leave `_structuralVersion` untouched.
991
+ const structuralBefore = this._structuralVersion;
992
+
993
+ if (inputNodeCodecMessageId !== undefined) {
994
+ this._applyInputMessage(inputNodeCodecMessageId, headers, serial, timestamp, version, all);
995
+ } else if (wireRunId !== undefined) {
996
+ this._applyRunMessage(wireRunId, events, headers, serial, timestamp, version);
997
+ }
998
+
999
+ if (this._structuralVersion !== structuralBefore) this._emitter.emit('update');
1000
+ }
1001
+
1002
+ /**
1003
+ * Apply a run-less user input wire: create (or promote the serial of) the
1004
+ * input node keyed by its codec-message-id, fold the input events into its
1005
+ * own projection, and emit an `output` event (with empty outputs — input
1006
+ * folds carry none) so the View observes the optimistic insert.
1007
+ * @param codecMessageId - The input node's codec-message-id (its primary key).
1008
+ * @param headers - Transport headers from the inbound Ably message.
1009
+ * @param serial - Ably channel serial; undefined for an optimistic insert.
1010
+ * @param timestamp - Ably server timestamp (epoch ms); undefined for an optimistic insert.
1011
+ * @param version - The delivery's `Message.version.serial`, or undefined.
1012
+ * @param all - The direction-tagged input events to fold, in wire order.
1013
+ */
1014
+ private _applyInputMessage(
1015
+ codecMessageId: string,
1016
+ headers: Record<string, string>,
1017
+ serial: string | undefined,
1018
+ timestamp: number | undefined,
1019
+ version: string | undefined,
1020
+ all: CodecEvent<TInput, TOutput>[],
1021
+ ): void {
1022
+ let entry = this._nodeIndex.get(codecMessageId);
1023
+ if (!entry) {
1024
+ entry = this._createInputNodeFromHeaders(codecMessageId, headers, serial);
1025
+ this._insertNode(codecMessageId, entry, entry.node.parentCodecMessageId);
1026
+ this._codecMessageIdToNodeKey.set(codecMessageId, codecMessageId);
1027
+ this._logger.debug('Tree.applyMessage(); created input node', { codecMessageId });
1028
+ } else if (entry.node.kind === 'input' && serial && !entry.node.serial) {
1029
+ // Promote optimistic serial when the relay/echo arrives.
1030
+ this._logger.debug('Tree.applyMessage(); promoting input serial', { codecMessageId, serial });
1031
+ entry.node.serial = serial;
1032
+ this._promoteSerial(entry);
1033
+ }
1034
+
1035
+ this._recordActivity(entry, timestamp);
1036
+
1037
+ // Log the wire and fold it — incrementally onto the tail in the common
1038
+ // case, or by refolding the node if this wire arrived out of serial order.
1039
+ this._recordAndFold(entry, all, serial, codecMessageId, version, headers[HEADER_STREAM] === 'true');
1040
+
1041
+ // An input node owns no agent outputs; the event still fires (empty
1042
+ // outputs) so consumers observe the projection change. It has no run-id —
1043
+ // the causal routing key is the input's own codec-message-id.
1044
+ this._emitter.emit('output', {
1045
+ runId: undefined,
1046
+ inputCodecMessageId: codecMessageId,
1047
+ codecMessageId,
427
1048
  serial,
428
- };
1049
+ events: [],
1050
+ });
1051
+ }
429
1052
 
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');
1053
+ /**
1054
+ * Apply a reply-run wire (assistant output, continuation tool-resolution, or
1055
+ * a fresh run keyed by the agent-minted run-id): create or reconcile the run
1056
+ * node, fold its events, maintain the codec-message-id and reply→input
1057
+ * indices, and emit the `output` event. Derives the codec-message-id,
1058
+ * triggering-input id, fold list, and outputs from `events`/`headers`,
1059
+ * mirroring `applyMessage`.
1060
+ * @param wireRunId - The run-id from the inbound wire (the node's primary key).
1061
+ * @param events - The decoded inputs and outputs from the wire.
1062
+ * @param events.inputs - Client-published events (`ai-input` wire).
1063
+ * @param events.outputs - Agent-published events (`ai-output` wire).
1064
+ * @param headers - Transport headers from the inbound Ably message.
1065
+ * @param serial - Ably channel serial; undefined for an optimistic insert.
1066
+ * @param timestamp - Ably server timestamp (epoch ms); undefined for an optimistic insert.
1067
+ * @param version - The delivery's `Message.version.serial`, or undefined.
1068
+ */
1069
+ private _applyRunMessage(
1070
+ wireRunId: string,
1071
+ events: { inputs: TInput[]; outputs: TOutput[] },
1072
+ headers: Record<string, string>,
1073
+ serial: string | undefined,
1074
+ timestamp: number | undefined,
1075
+ version: string | undefined,
1076
+ ): void {
1077
+ const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
1078
+ // The triggering input's codec-message-id (the agent's echo), surfaced on
1079
+ // the `output` event as the stream's causal routing key.
1080
+ const inputCodecMessageId = headers[HEADER_INPUT_CODEC_MESSAGE_ID];
1081
+ // Fold inputs first, then outputs, preserving wire order.
1082
+ const all: CodecEvent<TInput, TOutput>[] = toCodecEvents(events);
1083
+ const outputs = events.outputs;
1084
+
1085
+ let run = this._nodeIndex.get(wireRunId);
1086
+
1087
+ // Reconcile an optimistic insert with its serial-bearing echo by
1088
+ // codec-message-id rather than the wire run-id — covers assistant content
1089
+ // that pins a codec-message-id before its run-id is indexed.
1090
+ if (!run && codecMessageId !== undefined) {
1091
+ const indexedKey = this._codecMessageIdToNodeKey.get(codecMessageId);
1092
+ const indexed = indexedKey === undefined ? undefined : this._nodeIndex.get(indexedKey);
1093
+ if (indexed?.node.kind === 'run' && indexed.node.startSerial === undefined) run = indexed;
1094
+ }
1095
+
1096
+ if (!run) {
1097
+ run = this._createRunFromHeaders(wireRunId, headers, serial);
1098
+ this._insertNode(wireRunId, run, run.node.parentCodecMessageId);
1099
+ this._indexReplyRun(run.node, wireRunId);
1100
+ this._logger.debug('Tree.applyMessage(); created new Run', { runId: wireRunId });
1101
+ } else if (serial && run.node.kind === 'run' && !run.node.startSerial) {
1102
+ // Promote optimistic startSerial when the relay/echo arrives.
1103
+ this._logger.debug('Tree.applyMessage(); promoting startSerial', { runId: wireRunId, serial });
1104
+ run.node.startSerial = serial;
1105
+ this._promoteSerial(run);
1106
+ }
1107
+
1108
+ // Index the codec-message-id against the node that actually owns it.
1109
+ const ownerKey = nodeKey(run.node);
1110
+ if (codecMessageId) this._codecMessageIdToNodeKey.set(codecMessageId, ownerKey);
1111
+
1112
+ this._recordActivity(run, timestamp);
1113
+
1114
+ // Log the wire and fold it — incrementally onto the tail in the common
1115
+ // case, or by refolding the node if this wire arrived out of serial order.
1116
+ // `run` may be a reconciled optimistic node: record on whichever entry
1117
+ // owns the fold.
1118
+ this._recordAndFold(run, all, serial, codecMessageId, version, headers[HEADER_STREAM] === 'true');
1119
+
1120
+ this._emitter.emit('output', { runId: ownerKey, inputCodecMessageId, codecMessageId, serial, events: outputs });
436
1121
  }
437
1122
 
438
- delete(msgId: string): void {
439
- const entry = this._nodeIndex.get(msgId);
440
- if (!entry) return;
1123
+ /**
1124
+ * Record a reply run against its input-node parent (the reverse edge powering
1125
+ * `getReplyRuns` and regenerate sibling grouping). A reply run's
1126
+ * `parentCodecMessageId` is its input node's codec-message-id (the master
1127
+ * invariant), so no resolution is needed.
1128
+ * @param node - The reply run node.
1129
+ * @param runId - The run's id.
1130
+ */
1131
+ private _indexReplyRun(node: ConversationNode<TProjection>, runId: string): void {
1132
+ if (node.parentCodecMessageId === undefined) return;
1133
+ addToSetMap(this._replyRunsByInput, node.parentCodecMessageId, runId);
1134
+ }
1135
+
1136
+ applyRunLifecycle(event: RunLifecycleEvent): void {
1137
+ this._logger.trace('DefaultTree.applyRunLifecycle();', { type: event.type, runId: event.runId });
1138
+ // Structural channel: emit `update` only when the lifecycle event changes
1139
+ // the tree shape. Only run-start can do that (a new Run, startSerial
1140
+ // promotion, or structural-metadata backfill); suspend/resume/end mutate
1141
+ // status/endSerial on an existing node — content, not structure — so the
1142
+ // conditional naturally never fires for them.
1143
+ const structuralBefore = this._structuralVersion;
1144
+ switch (event.type) {
1145
+ case 'start': {
1146
+ this._applyRunStart(event);
1147
+ break;
1148
+ }
1149
+ case 'suspend': {
1150
+ this._applyRunSuspend(event);
1151
+ break;
1152
+ }
1153
+ case 'resume': {
1154
+ this._applyRunResume(event);
1155
+ break;
1156
+ }
1157
+ case 'end': {
1158
+ this._applyRunEnd(event);
1159
+ break;
1160
+ }
1161
+ }
1162
+ this._emitter.emit('run', event);
1163
+ if (this._structuralVersion !== structuralBefore) this._emitter.emit('update');
1164
+ }
1165
+
1166
+ /**
1167
+ * Apply a run-start lifecycle event's structural effect: create the reply
1168
+ * run if it doesn't exist yet, or backfill an optimistic / wire-created
1169
+ * node's structure and metadata from the canonical run-start. Mutates
1170
+ * `_structuralVersion` when the tree shape changes; the caller owns the
1171
+ * `run`/`update` emits.
1172
+ * @param event - The run-start lifecycle event.
1173
+ */
1174
+ private _applyRunStart(event: RunLifecycleEvent & { type: 'start' }): void {
1175
+ const existing = this._nodeIndex.get(event.runId);
1176
+ if (existing?.node.kind === 'run') {
1177
+ const node = existing.node;
1178
+ // Activate only a suspended run. A run-start can be observed AFTER the
1179
+ // run's terminal event (history pages replay newest-first, so an older
1180
+ // page delivers the start last) — like a stray resume, it must never
1181
+ // resurrect a run that has ended.
1182
+ if (node.state.status === 'suspended') {
1183
+ node.state = { status: 'active' };
1184
+ }
1185
+ if (event.serial && !node.startSerial) {
1186
+ node.startSerial = event.serial;
1187
+ this._promoteSerial(existing);
1188
+ }
1189
+ // Backfill structural metadata if the Run was created from an
1190
+ // assistant wire that arrived before run-start (history pagination
1191
+ // boundary or out-of-order delivery). The run-start lifecycle event is
1192
+ // the canonical source for parent/forkOf/regenerates; only fill in
1193
+ // fields the wire didn't already populate. A run-start is always a
1194
+ // first start (continuations re-enter via `ai-run-resume`, which
1195
+ // carries no structural metadata), so it is unconditionally
1196
+ // authoritative here. `parent` is the run's STRUCTURAL parent (its
1197
+ // input node) — reachability and the reply→input edge read it.
1198
+ if (node.parentCodecMessageId === undefined && event.parent !== undefined) {
1199
+ node.parentCodecMessageId = event.parent;
1200
+ this._removeFromParentIndex(undefined, event.runId);
1201
+ this._addToParentIndex(node.parentCodecMessageId, event.runId);
1202
+ this._indexReplyRun(node, event.runId);
1203
+ this._structuralVersion++;
1204
+ }
1205
+ if (node.forkOf === undefined && event.forkOf !== undefined) {
1206
+ const forkOfKey = this._codecMessageIdToNodeKey.get(event.forkOf);
1207
+ if (forkOfKey !== undefined && forkOfKey !== event.runId) {
1208
+ node.forkOf = forkOfKey;
1209
+ this._structuralVersion++;
1210
+ }
1211
+ }
1212
+ if (node.regeneratesCodecMessageId === undefined && event.regenerates !== undefined) {
1213
+ node.regeneratesCodecMessageId = event.regenerates;
1214
+ this._structuralVersion++;
1215
+ }
1216
+ // Adopt the agent-minted invocation-id onto the optimistic node. The
1217
+ // agent mints it, so a node created from an optimistic insert (or an
1218
+ // assistant wire that arrived before run-start) carries an empty id
1219
+ // until the agent's run-start delivers it. Metadata, not structure —
1220
+ // consumers re-read it on the `run` emit, so no structural-version
1221
+ // bump.
1222
+ if (node.invocationId === '' && event.invocationId !== '') {
1223
+ node.invocationId = event.invocationId;
1224
+ }
1225
+ // The run's serial floor is now observed: no older history page can
1226
+ // deliver further wires for this node. With a terminal status this
1227
+ // makes the node structurally complete — eligible for log retention
1228
+ // sweeping once the reorder window lapses.
1229
+ existing.runStartSeen = true;
1230
+ this._recordActivity(existing, event.timestamp);
1231
+ this._maybeQueueSweep(existing);
1232
+ } else if (!existing) {
1233
+ const run = this._createRunFromLifecycle(event);
1234
+ this._insertNode(event.runId, run, run.node.parentCodecMessageId);
1235
+ this._indexReplyRun(run.node, event.runId);
1236
+ this._recordActivity(run, event.timestamp);
1237
+ }
1238
+ }
441
1239
 
442
- this._logger.debug('Tree.delete();', { msgId });
1240
+ /**
1241
+ * Apply a run-suspend lifecycle event: pause the run without ending it —
1242
+ * mark the node 'suspended' and record the serial it paused at, but keep the
1243
+ * Run live so a resume under the same runId resumes it. Status/endSerial are
1244
+ * content, not structure, so this never mutates `_structuralVersion`; the
1245
+ * caller owns the emits.
1246
+ * @param event - The run-suspend lifecycle event.
1247
+ */
1248
+ private _applyRunSuspend(event: RunLifecycleEvent & { type: 'suspend' }): void {
1249
+ const run = this._nodeIndex.get(event.runId);
1250
+ if (run?.node.kind === 'run') {
1251
+ run.node.state = { status: 'suspended' };
1252
+ run.node.endSerial = event.serial;
1253
+ this._recordActivity(run, event.timestamp);
1254
+ }
1255
+ }
443
1256
 
444
- const { node } = entry;
1257
+ /**
1258
+ * Apply a run-resume lifecycle event: re-enter an already-started run by
1259
+ * flipping a suspended run back to 'active'. Pure re-entry — it carries no
1260
+ * parent/forkOf and does not promote startSerial (the original run-start owns
1261
+ * the run's structure). Only a suspended run resumes: a no-op when the run
1262
+ * isn't known (e.g. a resume replayed from a newer history page before its
1263
+ * run-start) and a no-op for an already-active or terminal
1264
+ * (complete/cancelled/error) run — a stray resume must never resurrect a run
1265
+ * that has ended. The caller owns the emits.
1266
+ * @param event - The run-resume lifecycle event.
1267
+ */
1268
+ private _applyRunResume(event: RunLifecycleEvent & { type: 'resume' }): void {
1269
+ const run = this._nodeIndex.get(event.runId);
1270
+ if (run?.node.kind === 'run' && run.node.state.status === 'suspended') {
1271
+ run.node.state = { status: 'active' };
1272
+ this._recordActivity(run, event.timestamp);
1273
+ }
1274
+ }
445
1275
 
446
- // Remove from parent index
447
- this._removeFromParentIndex(node.parentId, msgId);
1276
+ /**
1277
+ * Apply a run-end lifecycle event: record the terminal reason (and, for an
1278
+ * error end, the error) as the node's state, plus the serial it ended at.
1279
+ * State/endSerial are content, not structure, so this never mutates
1280
+ * `_structuralVersion`; the caller owns the emits.
1281
+ *
1282
+ * A run-end for an unknown runId is a no-op: nothing else is known about the
1283
+ * run yet, so there is no node to mark. When that happens during history
1284
+ * replay (a page boundary falling just before the run-end, so the run's
1285
+ * other wires arrive in later pages), the run is never marked terminal and
1286
+ * its event log is retained for the Tree's lifetime — over-retention, never
1287
+ * corruption.
1288
+ * @param event - The run-end lifecycle event.
1289
+ */
1290
+ private _applyRunEnd(event: RunLifecycleEvent & { type: 'end' }): void {
1291
+ const run = this._nodeIndex.get(event.runId);
1292
+ if (run?.node.kind === 'run') {
1293
+ run.node.state = event.reason === 'error' ? { status: 'error', error: event.error } : { status: event.reason };
1294
+ run.node.endSerial = event.serial;
1295
+ this._recordActivity(run, event.timestamp);
1296
+ this._maybeQueueSweep(run);
1297
+ }
1298
+ }
448
1299
 
449
- // Remove from sorted list
450
- this._removeSorted(entry);
1300
+ delete(key: string): void {
1301
+ const entry = this._nodeIndex.get(key);
1302
+ if (!entry) return;
1303
+
1304
+ this._logger.debug('Tree.delete();', { key });
451
1305
 
452
- // Remove from primary index
453
- this._nodeIndex.delete(msgId);
1306
+ this._removeFromParentIndex(entry.node.parentCodecMessageId, key);
1307
+ this._removeSortedNode(entry);
1308
+ this._nodeIndex.delete(key);
1309
+ // Drop the reply→input reverse edge.
1310
+ if (entry.node.kind === 'run' && entry.node.parentCodecMessageId !== undefined) {
1311
+ deleteFromSetMap(this._replyRunsByInput, entry.node.parentCodecMessageId, key);
1312
+ }
1313
+ // _codecMessageIdToNodeKey entries pointing at this node linger but are
1314
+ // harmless; they'll be overwritten if the node is re-created and remain
1315
+ // dangling otherwise. Cleanup not worth the index walk.
454
1316
 
455
- // Children are NOT deleted — they become unreachable in flattenNodes()
456
- // because their parent is no longer on the active path.
457
1317
  this._structuralVersion++;
458
1318
  this._emitter.emit('update');
459
1319
  }
460
1320
 
461
1321
  // -------------------------------------------------------------------------
462
- // Events
1322
+ // Internal helpers
463
1323
  // -------------------------------------------------------------------------
464
1324
 
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;
1325
+ /**
1326
+ * Build a fresh RunNode from a wire message's headers. Used when an
1327
+ * inbound message arrives before any run-start event for its runId.
1328
+ * @param runId - The run-id from the inbound wire.
1329
+ * @param headers - Transport headers from the inbound Ably message.
1330
+ * @param serial - Ably channel serial; undefined for optimistic inserts.
1331
+ * @returns A newly-allocated internal run node ready for insertion.
1332
+ */
1333
+ private _createRunFromHeaders(
1334
+ runId: string,
1335
+ headers: Record<string, string>,
1336
+ serial: string | undefined,
1337
+ ): InternalNode<TInput, TOutput, TProjection> {
1338
+ const forkOfMsgId = headers[HEADER_FORK_OF];
1339
+ return this._buildRunNode({
1340
+ runId,
1341
+ parentCodecMessageId: headers[HEADER_PARENT],
1342
+ // forkOf is resolved to the fork target's node key (an input node's
1343
+ // codec-message-id, or a run's id) — the same space `_isSiblingOf` walks.
1344
+ forkOf: forkOfMsgId ? this._codecMessageIdToNodeKey.get(forkOfMsgId) : undefined,
1345
+ regeneratesCodecMessageId: headers[HEADER_MSG_REGENERATE],
1346
+ clientId: headers[HEADER_RUN_CLIENT_ID] ?? '',
1347
+ invocationId: headers[HEADER_INVOCATION_ID] ?? '',
1348
+ startSerial: serial,
1349
+ // Created from a content wire — the run's ai-run-start has not been
1350
+ // observed (it may still be in an unloaded older history page).
1351
+ runStartSeen: false,
1352
+ });
1353
+ }
1354
+
1355
+ /**
1356
+ * Wrap a freshly-built conversation node in its internal envelope — sort
1357
+ * sequence, event log, and retention/promotion state. The single home for
1358
+ * those per-node fields, so a new field is added in one place rather than at
1359
+ * every node-construction site.
1360
+ * @param node - The conversation node to wrap.
1361
+ * @param runStartSeen - Whether the run's ai-run-start has been observed
1362
+ * (run nodes only; always false for input nodes).
1363
+ * @returns A newly-allocated internal node ready for insertion.
1364
+ */
1365
+ private _wrapNode(
1366
+ node: ConversationNode<TProjection>,
1367
+ runStartSeen = false,
1368
+ ): InternalNode<TInput, TOutput, TProjection> {
1369
+ return {
1370
+ node,
1371
+ insertSeq: this._seqCounter++,
1372
+ log: new WireLog<CodecEvent<TInput, TOutput>>(),
1373
+ lastActivityTs: 0,
1374
+ runStartSeen,
1375
+ sweepQueued: false,
1376
+ optimistic: false,
1377
+ };
478
1378
  }
479
1379
 
1380
+ /**
1381
+ * Allocate a RunNode from already-resolved fields. Shared by the
1382
+ * header-driven and lifecycle-driven run creators: both build the identical
1383
+ * RunNode literal and stamp an insert sequence.
1384
+ * @param params - The resolved run fields.
1385
+ * @param params.runId - The run's id (its primary key).
1386
+ * @param params.parentCodecMessageId - Structural parent codec-message-id, or undefined for a root.
1387
+ * @param params.forkOf - The resolved fork target's node key (already mapped through the codec-message-id index), or undefined.
1388
+ * @param params.regeneratesCodecMessageId - The codec-message-id this run regenerates, or undefined.
1389
+ * @param params.clientId - The publishing client's id.
1390
+ * @param params.invocationId - The agent invocation id.
1391
+ * @param params.startSerial - Ably channel serial; undefined for optimistic inserts.
1392
+ * @param params.runStartSeen - Whether the run's ai-run-start has been observed (true only for lifecycle-created runs).
1393
+ * @returns A newly-allocated internal run node ready for insertion.
1394
+ */
1395
+ private _buildRunNode(params: {
1396
+ runId: string;
1397
+ parentCodecMessageId: string | undefined;
1398
+ forkOf: string | undefined;
1399
+ regeneratesCodecMessageId: string | undefined;
1400
+ clientId: string;
1401
+ invocationId: string;
1402
+ startSerial: string | undefined;
1403
+ runStartSeen: boolean;
1404
+ }): InternalNode<TInput, TOutput, TProjection> {
1405
+ const node: RunNode<TProjection> = {
1406
+ kind: 'run',
1407
+ runId: params.runId,
1408
+ parentCodecMessageId: params.parentCodecMessageId,
1409
+ forkOf: params.forkOf,
1410
+ regeneratesCodecMessageId: params.regeneratesCodecMessageId,
1411
+ clientId: params.clientId,
1412
+ invocationId: params.invocationId,
1413
+ state: { status: 'active' },
1414
+ projection: this._codec.init(),
1415
+ startSerial: params.startSerial,
1416
+ endSerial: undefined,
1417
+ };
1418
+
1419
+ return this._wrapNode(node, params.runStartSeen);
1420
+ }
1421
+
1422
+ /**
1423
+ * Build a fresh InputNode from a run-less user input wire's headers.
1424
+ * @param codecMessageId - The input's codec-message-id (its primary key).
1425
+ * @param headers - Transport headers from the inbound Ably message.
1426
+ * @param serial - Ably channel serial; undefined for optimistic inserts.
1427
+ * @returns A newly-allocated internal input node ready for insertion.
1428
+ */
1429
+ private _createInputNodeFromHeaders(
1430
+ codecMessageId: string,
1431
+ headers: Record<string, string>,
1432
+ serial: string | undefined,
1433
+ ): InternalNode<TInput, TOutput, TProjection> {
1434
+ const forkOfMsgId = headers[HEADER_FORK_OF];
1435
+ const node: InputNode<TProjection> = {
1436
+ kind: 'input',
1437
+ codecMessageId,
1438
+ parentCodecMessageId: headers[HEADER_PARENT],
1439
+ // An edit's fork-of names the original prompt's codec-message-id, which
1440
+ // IS that input node's key — no resolution needed.
1441
+ forkOf: forkOfMsgId,
1442
+ projection: this._codec.init(),
1443
+ serial,
1444
+ };
1445
+ return this._wrapNode(node);
1446
+ }
1447
+
1448
+ /**
1449
+ * Build a fresh RunNode from a run-start lifecycle event. Used when a
1450
+ * run-start event arrives before any message for its runId.
1451
+ * @param event - The run-start lifecycle event from the agent, including
1452
+ * its channel serial.
1453
+ * @returns A newly-allocated internal run node ready for insertion.
1454
+ */
1455
+ private _createRunFromLifecycle(
1456
+ event: RunLifecycleEvent & { type: 'start' },
1457
+ ): InternalNode<TInput, TOutput, TProjection> {
1458
+ const forkOfMsgId = event.forkOf;
1459
+ return this._buildRunNode({
1460
+ runId: event.runId,
1461
+ parentCodecMessageId: event.parent,
1462
+ forkOf: forkOfMsgId ? this._codecMessageIdToNodeKey.get(forkOfMsgId) : undefined,
1463
+ regeneratesCodecMessageId: event.regenerates,
1464
+ clientId: event.clientId,
1465
+ invocationId: event.invocationId,
1466
+ startSerial: event.serial,
1467
+ // Created from the run-start itself — the serial floor is observed.
1468
+ runStartSeen: true,
1469
+ });
1470
+ }
1471
+
1472
+ // -------------------------------------------------------------------------
1473
+ // Events
1474
+ // -------------------------------------------------------------------------
1475
+
480
1476
  // Spec: AIT-CT8b, AIT-CT8e
481
1477
  on(event: 'update', handler: () => void): () => void;
482
1478
  on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
483
- on(event: 'turn', handler: (event: TurnLifecycleEvent) => void): () => void;
1479
+ on(event: 'run', handler: (event: RunLifecycleEvent) => void): () => void;
1480
+ on(event: 'output', handler: (event: OutputEvent<TOutput>) => void): () => void;
484
1481
  on(
485
- event: 'update' | 'ably-message' | 'turn',
486
- handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: TurnLifecycleEvent) => void),
1482
+ event: 'update' | 'ably-message' | 'run' | 'output',
1483
+ handler:
1484
+ | (() => void)
1485
+ | ((msg: Ably.InboundMessage) => void)
1486
+ | ((event: RunLifecycleEvent) => void)
1487
+ | ((event: OutputEvent<TOutput>) => void),
487
1488
  ): () => void {
488
1489
  // CAST: overload signatures enforce correct handler types per event name.
489
- const cb = handler as (arg: TreeEventsMap[keyof TreeEventsMap]) => void;
1490
+ const cb = handler as (arg: TreeEventsMap<TOutput>[keyof TreeEventsMap<TOutput>]) => void;
490
1491
  this._emitter.on(event, cb);
491
1492
  return () => {
492
1493
  this._emitter.off(event, cb);
493
1494
  };
494
1495
  }
495
1496
 
496
- // -------------------------------------------------------------------------
497
- // Internal methods (called by the transport, not part of Tree interface)
498
- // -------------------------------------------------------------------------
499
-
500
1497
  /**
501
- * Forward a raw Ably message event to tree subscribers.
1498
+ * Forward a raw Ably message event to tree subscribers. Also indexes the
1499
+ * Ably message by `event-id` header (if present) for
1500
+ * {@link findAblyMessageByEventId} lookups.
502
1501
  * @param msg - The raw Ably message to emit.
503
1502
  */
504
1503
  emitAblyMessage(msg: Ably.InboundMessage): void {
505
1504
  this._logger.trace('DefaultTree.emitAblyMessage();');
1505
+ const headers = getTransportHeaders(msg);
1506
+ const eventId = headers[HEADER_EVENT_ID];
1507
+ if (eventId !== undefined && !this._eventIdIndex.has(eventId)) {
1508
+ this._eventIdIndex.set(eventId, msg);
1509
+ }
506
1510
  this._emitter.emit('ably-message', msg);
507
1511
  }
508
1512
 
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);
1513
+ findAblyMessageByEventId(eventId: string): Ably.InboundMessage | undefined {
1514
+ return this._eventIdIndex.get(eventId);
535
1515
  }
536
1516
  }
537
1517
 
@@ -540,10 +1520,15 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
540
1520
  // ---------------------------------------------------------------------------
541
1521
 
542
1522
  /**
543
- * Create a Tree that materializes branching history from a flat oplog.
1523
+ * Create a Tree that materializes branching conversation history from a flat
1524
+ * oplog of Ably messages as a two-node-per-turn forest (input node + reply run).
1525
+ * @param codec - Codec used to fold inbound events into per-Run projections.
544
1526
  * @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.
1527
+ * @returns A new {@link DefaultTree} instance. The session uses DefaultTree
1528
+ * directly for internal methods (applyMessage, applyRunLifecycle,
1529
+ * emitAblyMessage). Public consumers see the narrower {@link Tree} interface.
548
1530
  */
549
- export const createTree = <TMessage>(logger: Logger): DefaultTree<TMessage> => new DefaultTree(logger);
1531
+ export const createTree = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection>(
1532
+ codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>,
1533
+ logger: Logger,
1534
+ ): DefaultTree<TInput, TOutput, TProjection> => new DefaultTree<TInput, TOutput, TProjection>(codec, logger);