@ably/ai-transport 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +114 -116
  2. package/dist/ably-ai-transport.js +1743 -961
  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 +117 -39
  7. package/dist/core/agent.d.ts +29 -0
  8. package/dist/core/codec/decoder.d.ts +20 -23
  9. package/dist/core/codec/encoder.d.ts +11 -8
  10. package/dist/core/codec/index.d.ts +1 -2
  11. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  12. package/dist/core/codec/types.d.ts +410 -101
  13. package/dist/core/transport/agent-session.d.ts +10 -0
  14. package/dist/core/transport/branch-chain.d.ts +43 -0
  15. package/dist/core/transport/client-session.d.ts +13 -0
  16. package/dist/core/transport/decode-fold.d.ts +47 -0
  17. package/dist/core/transport/headers.d.ts +97 -17
  18. package/dist/core/transport/index.d.ts +5 -3
  19. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  20. package/dist/core/transport/invocation.d.ts +74 -0
  21. package/dist/core/transport/load-conversation.d.ts +128 -0
  22. package/dist/core/transport/load-history.d.ts +39 -0
  23. package/dist/core/transport/pipe-stream.d.ts +9 -8
  24. package/dist/core/transport/run-manager.d.ts +78 -0
  25. package/dist/core/transport/tree.d.ts +435 -0
  26. package/dist/core/transport/types/agent.d.ts +353 -0
  27. package/dist/core/transport/types/client.d.ts +168 -0
  28. package/dist/core/transport/types/shared.d.ts +24 -0
  29. package/dist/core/transport/types/tree.d.ts +315 -0
  30. package/dist/core/transport/types/view.d.ts +222 -0
  31. package/dist/core/transport/types.d.ts +13 -402
  32. package/dist/core/transport/view.d.ts +354 -0
  33. package/dist/errors.d.ts +37 -9
  34. package/dist/index.d.ts +6 -6
  35. package/dist/logger.d.ts +12 -0
  36. package/dist/react/ably-ai-transport-react.js +1164 -645
  37. package/dist/react/ably-ai-transport-react.js.map +1 -1
  38. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  39. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  40. package/dist/react/contexts/client-session-context.d.ts +36 -0
  41. package/dist/react/contexts/client-session-provider.d.ts +53 -0
  42. package/dist/react/create-session-hooks.d.ts +116 -0
  43. package/dist/react/index.d.ts +16 -10
  44. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  45. package/dist/react/use-ably-messages.d.ts +20 -11
  46. package/dist/react/use-client-session.d.ts +81 -0
  47. package/dist/react/use-create-view.d.ts +23 -0
  48. package/dist/react/use-tree.d.ts +35 -0
  49. package/dist/react/use-view.d.ts +110 -0
  50. package/dist/utils.d.ts +32 -23
  51. package/dist/vercel/ably-ai-transport-vercel.js +2748 -1625
  52. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  53. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  55. package/dist/vercel/codec/decoder.d.ts +5 -18
  56. package/dist/vercel/codec/encoder.d.ts +6 -36
  57. package/dist/vercel/codec/events.d.ts +51 -0
  58. package/dist/vercel/codec/index.d.ts +24 -12
  59. package/dist/vercel/codec/reducer.d.ts +144 -0
  60. package/dist/vercel/codec/tool-transitions.d.ts +50 -0
  61. package/dist/vercel/index.d.ts +4 -2
  62. package/dist/vercel/react/ably-ai-transport-vercel-react.js +10298 -1410
  63. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  64. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +70 -1
  65. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  66. package/dist/vercel/react/contexts/chat-transport-context.d.ts +33 -0
  67. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +96 -0
  68. package/dist/vercel/react/index.d.ts +4 -0
  69. package/dist/vercel/react/use-chat-transport.d.ts +66 -21
  70. package/dist/vercel/react/use-message-sync.d.ts +31 -12
  71. package/dist/vercel/run-end-reason.d.ts +29 -0
  72. package/dist/vercel/transport/chat-transport.d.ts +71 -30
  73. package/dist/vercel/transport/index.d.ts +25 -18
  74. package/dist/vercel/transport/run-output-stream.d.ts +56 -0
  75. package/dist/version.d.ts +2 -0
  76. package/package.json +47 -34
  77. package/src/constants.ts +126 -47
  78. package/src/core/agent.ts +68 -0
  79. package/src/core/codec/decoder.ts +71 -98
  80. package/src/core/codec/encoder.ts +115 -58
  81. package/src/core/codec/index.ts +13 -6
  82. package/src/core/codec/lifecycle-tracker.ts +10 -9
  83. package/src/core/codec/types.ts +438 -106
  84. package/src/core/transport/agent-session.ts +1344 -0
  85. package/src/core/transport/branch-chain.ts +58 -0
  86. package/src/core/transport/client-session.ts +775 -0
  87. package/src/core/transport/decode-fold.ts +91 -0
  88. package/src/core/transport/headers.ts +182 -19
  89. package/src/core/transport/index.ts +29 -22
  90. package/src/core/transport/internal/bounded-map.ts +27 -0
  91. package/src/core/transport/invocation.ts +98 -0
  92. package/src/core/transport/load-conversation.ts +355 -0
  93. package/src/core/transport/load-history.ts +269 -0
  94. package/src/core/transport/pipe-stream.ts +58 -40
  95. package/src/core/transport/run-manager.ts +249 -0
  96. package/src/core/transport/tree.ts +1167 -0
  97. package/src/core/transport/types/agent.ts +407 -0
  98. package/src/core/transport/types/client.ts +211 -0
  99. package/src/core/transport/types/shared.ts +27 -0
  100. package/src/core/transport/types/tree.ts +344 -0
  101. package/src/core/transport/types/view.ts +259 -0
  102. package/src/core/transport/types.ts +13 -527
  103. package/src/core/transport/view.ts +1271 -0
  104. package/src/errors.ts +42 -9
  105. package/src/event-emitter.ts +3 -2
  106. package/src/index.ts +55 -39
  107. package/src/logger.ts +14 -1
  108. package/src/react/contexts/client-session-context.ts +41 -0
  109. package/src/react/contexts/client-session-provider.tsx +186 -0
  110. package/src/react/create-session-hooks.ts +141 -0
  111. package/src/react/index.ts +27 -10
  112. package/src/react/internal/use-resolved-session.ts +63 -0
  113. package/src/react/use-ably-messages.ts +47 -19
  114. package/src/react/use-client-session.ts +201 -0
  115. package/src/react/use-create-view.ts +72 -0
  116. package/src/react/use-tree.ts +84 -0
  117. package/src/react/use-view.ts +275 -0
  118. package/src/react/vite.config.ts +4 -1
  119. package/src/utils.ts +63 -45
  120. package/src/vercel/codec/decoder.ts +336 -255
  121. package/src/vercel/codec/encoder.ts +348 -196
  122. package/src/vercel/codec/events.ts +87 -0
  123. package/src/vercel/codec/index.ts +59 -14
  124. package/src/vercel/codec/reducer.ts +977 -0
  125. package/src/vercel/codec/tool-transitions.ts +122 -0
  126. package/src/vercel/index.ts +7 -3
  127. package/src/vercel/react/contexts/chat-transport-context.ts +41 -0
  128. package/src/vercel/react/contexts/chat-transport-provider.tsx +150 -0
  129. package/src/vercel/react/index.ts +13 -1
  130. package/src/vercel/react/use-chat-transport.ts +162 -42
  131. package/src/vercel/react/use-message-sync.ts +121 -22
  132. package/src/vercel/react/vite.config.ts +4 -2
  133. package/src/vercel/run-end-reason.ts +78 -0
  134. package/src/vercel/transport/chat-transport.ts +553 -113
  135. package/src/vercel/transport/index.ts +40 -28
  136. package/src/vercel/transport/run-output-stream.ts +170 -0
  137. package/src/version.ts +2 -0
  138. package/dist/core/transport/client-transport.d.ts +0 -10
  139. package/dist/core/transport/conversation-tree.d.ts +0 -9
  140. package/dist/core/transport/decode-history.d.ts +0 -41
  141. package/dist/core/transport/server-transport.d.ts +0 -7
  142. package/dist/core/transport/stream-router.d.ts +0 -19
  143. package/dist/core/transport/turn-manager.d.ts +0 -34
  144. package/dist/react/use-active-turns.d.ts +0 -8
  145. package/dist/react/use-client-transport.d.ts +0 -7
  146. package/dist/react/use-conversation-tree.d.ts +0 -20
  147. package/dist/react/use-edit.d.ts +0 -7
  148. package/dist/react/use-history.d.ts +0 -19
  149. package/dist/react/use-messages.d.ts +0 -7
  150. package/dist/react/use-regenerate.d.ts +0 -7
  151. package/dist/react/use-send.d.ts +0 -7
  152. package/dist/vercel/codec/accumulator.d.ts +0 -21
  153. package/src/core/transport/client-transport.ts +0 -959
  154. package/src/core/transport/conversation-tree.ts +0 -434
  155. package/src/core/transport/decode-history.ts +0 -337
  156. package/src/core/transport/server-transport.ts +0 -458
  157. package/src/core/transport/stream-router.ts +0 -118
  158. package/src/core/transport/turn-manager.ts +0 -147
  159. package/src/react/use-active-turns.ts +0 -61
  160. package/src/react/use-client-transport.ts +0 -37
  161. package/src/react/use-conversation-tree.ts +0 -71
  162. package/src/react/use-edit.ts +0 -24
  163. package/src/react/use-history.ts +0 -111
  164. package/src/react/use-messages.ts +0 -32
  165. package/src/react/use-regenerate.ts +0 -24
  166. package/src/react/use-send.ts +0 -25
  167. package/src/vercel/codec/accumulator.ts +0 -603
@@ -0,0 +1,1271 @@
1
+ /**
2
+ * DefaultView — a paginated, branch-aware projection over the Tree.
3
+ *
4
+ * Wraps a Tree (RunNode-keyed) and manages a pagination window that controls
5
+ * which Runs are visible to the UI. New live Runs appear immediately; older
6
+ * Runs are revealed progressively via `loadOlder()`.
7
+ *
8
+ * `getMessages()` reads the Tree's visible node chain (input nodes + reply
9
+ * runs, with sibling selection applied) and concatenates each node's
10
+ * `codec.getMessages(node.projection)` to produce the flat
11
+ * `CodecMessage<TMessage>[]` the UI renders.
12
+ *
13
+ * Each View owns its own branch selection state and pagination window,
14
+ * allowing multiple independent Views over the same Tree.
15
+ *
16
+ * Events are scoped to the visible window — 'update' only fires when the
17
+ * visible output changes, 'ably-message' only for messages corresponding to
18
+ * visible Runs, and 'run' only for runs with visible content.
19
+ */
20
+
21
+ import * as Ably from 'ably';
22
+
23
+ import { HEADER_CODEC_MESSAGE_ID, HEADER_RUN_ID } from '../../constants.js';
24
+ import { ErrorCode } from '../../errors.js';
25
+ import { EventEmitter } from '../../event-emitter.js';
26
+ import type { Logger } from '../../logger.js';
27
+ import { getTransportHeaders } from '../../utils.js';
28
+ import type { Codec, CodecInputEvent, CodecMessage, CodecOutputEvent } from '../codec/types.js';
29
+ import { applyWireMessage } from './decode-fold.js';
30
+ import { loadHistory } from './load-history.js';
31
+ import { nodeKey, type TreeInternal } from './tree.js';
32
+ import type {
33
+ ActiveRun,
34
+ BranchSelection,
35
+ ConversationNode,
36
+ HistoryPage,
37
+ OutputEvent,
38
+ RunInfo,
39
+ RunLifecycleEvent,
40
+ RunNode,
41
+ SendOptions,
42
+ View,
43
+ } from './types.js';
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Events map
47
+ // ---------------------------------------------------------------------------
48
+
49
+ interface ViewEventsMap {
50
+ update: undefined;
51
+ 'ably-message': Ably.InboundMessage;
52
+ run: RunLifecycleEvent;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Send delegate
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Internal delegate function provided by the session for executing sends.
61
+ * The View pre-computes the visible branch's flat message list and the
62
+ * codec-message-id of its tail (for auto-parent routing) before calling
63
+ * the delegate, so the delegate has no back-reference to the View.
64
+ *
65
+ * Each TInput carries its own routing metadata (`parent` / `target` /
66
+ * `codecMessageId`) via the {@link CodecInputEvent} base; the delegate
67
+ * reads those fields directly without runtime classification.
68
+ *
69
+ * `parentCodecMessageId` is the codec-message-id of the last message in
70
+ * the visible branch (extracted from the tail Run's projection per codec
71
+ * convention), or `undefined` for an empty conversation. The session
72
+ * uses it as the auto-parent for fresh user messages.
73
+ */
74
+ export type SendDelegate<TInput extends CodecInputEvent> = (
75
+ input: TInput[],
76
+ options: SendOptions | undefined,
77
+ parentCodecMessageId: string | undefined,
78
+ ) => Promise<ActiveRun>;
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Options
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Options for creating a View. */
85
+ export interface ViewOptions<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage> {
86
+ /** The tree to project. */
87
+ tree: TreeInternal<TInput, TOutput, TProjection>;
88
+ /** The Ably channel to load history from. */
89
+ channel: Ably.RealtimeChannel;
90
+ /** The codec used to project messages, mint regenerate inputs, and decode history. */
91
+ codec: Codec<TInput, TOutput, TProjection, TMessage>;
92
+ /** Delegate for executing sends through the session. */
93
+ sendDelegate: SendDelegate<TInput>;
94
+ /** Logger for diagnostic output. */
95
+ logger: Logger;
96
+ /** Called when the view is closed, allowing the owner to clean up references. */
97
+ onClose?: () => void;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Branch selection
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Internal tagged union representing why a branch was selected for an
106
+ * edit-fork group. Stored per group-root runId in the View's
107
+ * `_branchSelections` map. Not the public-facing {@link BranchSelection}
108
+ * — that's a UI-facing bundle returned by `view.branchSelection(id)`.
109
+ */
110
+ type BranchSelectionState =
111
+ /** Explicit navigation via `selectSibling()`. The selected input-node key. */
112
+ | { kind: 'user'; selectedKey: string }
113
+ /** This view initiated an edit fork — auto-selected the new input node. */
114
+ | { kind: 'auto'; selectedKey: string }
115
+ /** An external fork appeared — pinned to the currently-visible sibling to prevent drift. */
116
+ | { kind: 'pinned'; selectedKey: string };
117
+
118
+ /**
119
+ * Selection state for a regenerate group. Keyed by the anchor codec-message-id (the
120
+ * assistant codec-message-id being regenerated). Distinct from {@link BranchSelectionState}
121
+ * because regenerate groups are message-level (group members share an
122
+ * anchor codec-message-id), not edit forks of the user prompt.
123
+ *
124
+ * Unlike fork-of groups, regenerate groups do not "pin to current visible"
125
+ * when a new member appears externally — the default for a regenerate
126
+ * slot is always the latest member, so an external regenerator auto-rolls
127
+ * forward unless the user has explicitly selected an earlier member.
128
+ */
129
+ type RegenSelection =
130
+ /** Explicit navigation via `selectSibling()`. The selected reply-run id. */
131
+ | { kind: 'user'; selectedRunId: string }
132
+ /** This view initiated a regenerate — auto-selected the new reply run when it arrived. */
133
+ | { kind: 'auto'; selectedRunId: string }
134
+ /**
135
+ * This view's `regenerate()` is in flight. Keyed (in `_regenSelections`) by
136
+ * the regenerate group's root; `carrierCodecMessageId` is the regenerate
137
+ * carrier event's id, used to recognise the new reply run when it appears.
138
+ */
139
+ | { kind: 'pending'; carrierCodecMessageId: string };
140
+
141
+ /**
142
+ * A resolved branch point: the group `kind` plus the sibling nodes that make
143
+ * up the alternatives. `fork-of` is an edit-style branch anchored at the user
144
+ * input node; `regen` is a regenerate-style branch anchored at the assistant
145
+ * slot. `groupRoot` is the group's key (input group root for fork-of, the
146
+ * original reply's group root for regen).
147
+ */
148
+ type MessageBranchPoint<TProjection> =
149
+ | { kind: 'fork-of'; groupRoot: string; siblings: ConversationNode<TProjection>[] }
150
+ | { kind: 'regen'; groupRoot: string; siblings: ConversationNode<TProjection>[] };
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Send-input normalisation
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Normalise the two input shapes `View.send` accepts (a single TInput
158
+ * or an array) into the array shape the SendDelegate consumes.
159
+ * @param input - The raw input from `View.send`.
160
+ * @returns The normalised input array.
161
+ */
162
+ const _normaliseSend = <TInput extends CodecInputEvent>(input: TInput | TInput[]): TInput[] =>
163
+ Array.isArray(input) ? input : [input];
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Fetch tuning
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Multiplier applied to the user-supplied Run-unit `loadOlder(limit)`
171
+ * when issuing the first `loadHistory` page request. `loadHistory`
172
+ * counts complete domain *messages* per page, not Runs; a typical Run
173
+ * produces ~2 messages (user + assistant). Asking for `limit * factor`
174
+ * messages on the first page reduces extra round-trips when the actual
175
+ * messages-per-Run ratio is around the factor. `_loadUntilVisible`
176
+ * still loops on the Run count regardless, so this is purely a
177
+ * fetch-efficiency hint.
178
+ */
179
+ const _RUN_TO_MESSAGE_FETCH_FACTOR = 3;
180
+
181
+ /**
182
+ * Project a Tree `RunNode` down to the View-facing `RunInfo` shape:
183
+ * drop the codec projection and the structural fields that callers
184
+ * reach via `session.tree` when they need them.
185
+ * @param run - The tree's RunNode.
186
+ * @returns A projection-free RunInfo.
187
+ */
188
+ const _toRunInfo = <TProjection>(run: RunNode<TProjection>): RunInfo => ({
189
+ runId: run.runId,
190
+ clientId: run.clientId,
191
+ status: run.status,
192
+ invocationId: run.invocationId,
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Implementation
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export class DefaultView<
200
+ TInput extends CodecInputEvent,
201
+ TOutput extends CodecOutputEvent,
202
+ TProjection,
203
+ TMessage,
204
+ > implements View<TInput, TMessage> {
205
+ private readonly _tree: TreeInternal<TInput, TOutput, TProjection>;
206
+ private readonly _channel: Ably.RealtimeChannel;
207
+ private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
208
+ private readonly _sendDelegate: SendDelegate<TInput>;
209
+ private readonly _logger: Logger;
210
+ private readonly _emitter: EventEmitter<ViewEventsMap>;
211
+ private readonly _onClose?: () => void;
212
+
213
+ /**
214
+ * View-local branch selections: group-root runId → selection intent.
215
+ * Fork points not present here default to the latest sibling.
216
+ */
217
+ private readonly _branchSelections = new Map<string, BranchSelectionState>();
218
+
219
+ /**
220
+ * View-local regenerate-group selections: anchor codec-message-id (the assistant
221
+ * codec-message-id being regenerated) → selection intent. Distinct from
222
+ * {@link _branchSelections} because a regenerate group is a set of
223
+ * same-parent reply runs — message-level alternatives at a single
224
+ * conversation slot, not edit forks of the prompt. Groups not present here default to the latest
225
+ * member (the most recent regenerator, or the original if no regen has
226
+ * landed).
227
+ */
228
+ private readonly _regenSelections = new Map<string, RegenSelection>();
229
+
230
+ /** Spec: AIT-CT11c — runIds loaded from history but not yet revealed to the UI. */
231
+ private readonly _withheldRunIds = new Set<string>();
232
+
233
+ /** Snapshot of visible node keys — used to detect structural changes and for selection pinning. */
234
+ private _lastVisibleNodeKeys: string[] = [];
235
+
236
+ /**
237
+ * Snapshot of visible projection references — used to detect in-place
238
+ * projection updates (streaming). One entry per visible Run.
239
+ */
240
+ private _lastVisibleProjections: TProjection[] = [];
241
+
242
+ /**
243
+ * Snapshot of the visible flat message chain with codec-message-ids —
244
+ * exposed verbatim via `getMessages()` and the internal correlation
245
+ * source for parent/branch routing.
246
+ */
247
+ private _lastVisibleMessagePairs: CodecMessage<TMessage>[] = [];
248
+
249
+ /** Cached visible node-key Set — for O(1) lookup in event scoping. */
250
+ private _lastVisibleNodeKeySet = new Set<string>();
251
+
252
+ /** Whether there are more history pages to fetch from the channel. */
253
+ private _hasMoreHistory = false;
254
+
255
+ /** Internal state for continuing history pagination. */
256
+ private _lastHistoryPage: HistoryPage | undefined;
257
+
258
+ /** Buffer of withheld nodes (input + reply), drained newest-first by successive loadOlder() calls. */
259
+ private readonly _withheldBuffer: ConversationNode<TProjection>[] = [];
260
+
261
+ /** Unsubscribe functions for tree event subscriptions. */
262
+ private readonly _unsubs: (() => void)[] = [];
263
+
264
+ /**
265
+ * Cached result of the last flat-nodes computation. Drives the visible
266
+ * message snapshot exposed via `getMessages()`; refreshed by
267
+ * `_computeFlatNodes()` on structural changes, selection changes,
268
+ * and history reveal.
269
+ */
270
+ private _cachedNodes: ConversationNode<TProjection>[] = [];
271
+
272
+ private _loadingOlder = false;
273
+ private _processingHistory = false;
274
+ private _closed = false;
275
+
276
+ constructor(options: ViewOptions<TInput, TOutput, TProjection, TMessage>) {
277
+ this._tree = options.tree;
278
+ this._channel = options.channel;
279
+ this._codec = options.codec;
280
+ this._sendDelegate = options.sendDelegate;
281
+ this._onClose = options.onClose;
282
+ this._logger = options.logger.withContext({ component: 'View' });
283
+ this._logger.trace('DefaultView();');
284
+ this._emitter = new EventEmitter<ViewEventsMap>(this._logger);
285
+
286
+ // Compute initial cache and snapshot visible state
287
+ this._cachedNodes = this._computeFlatNodes();
288
+ this._updateVisibleSnapshot(this._cachedNodes);
289
+
290
+ // Subscribe to tree events and re-emit scoped versions
291
+ this._unsubs.push(
292
+ this._tree.on('update', () => {
293
+ this._onTreeUpdate();
294
+ }),
295
+ this._tree.on('ably-message', (msg) => {
296
+ this._onTreeAblyMessage(msg);
297
+ }),
298
+ this._tree.on('run', (event) => {
299
+ this._onTreeRun(event);
300
+ }),
301
+ this._tree.on('output', (event) => {
302
+ this._onTreeOutput(event);
303
+ }),
304
+ );
305
+ }
306
+
307
+ /**
308
+ * Handle decoded outputs folded into a Run (streaming delta). If the run
309
+ * is on the visible chain, recompute the flat message list and emit
310
+ * `update`.
311
+ * @param event - The output event from the Tree.
312
+ */
313
+ private _onTreeOutput(event: OutputEvent<TOutput>): void {
314
+ if (this._processingHistory) return;
315
+ // The fold target may be a reply run (event.runId) or a user input node
316
+ // (event.runId undefined — the agent mints run-ids, so an input fold has
317
+ // none). Gate on whichever key the visible set holds.
318
+ const folded =
319
+ (event.runId !== undefined && this._lastVisibleNodeKeySet.has(event.runId)) ||
320
+ (event.inputCodecMessageId !== undefined && this._lastVisibleNodeKeySet.has(event.inputCodecMessageId));
321
+ if (!folded) return;
322
+
323
+ // The Tree emits `output` once per inbound message fold (with empty
324
+ // `events` for inputs-only folds), so it fires whenever a visible Run's
325
+ // projection changed and we always re-emit. The Reducer contract permits
326
+ // in-place mutation, which means we cannot use projection-ref or
327
+ // TMessage-ref equality to detect change: a streaming chunk legitimately
328
+ // mutates the same UIMessage object, and a ref-equality short-circuit
329
+ // would suppress every update. React state setters at the subscriber
330
+ // boundary already dedup by array reference, so a redundant emit is a
331
+ // no-op for unchanged hook consumers.
332
+ this._lastVisibleProjections = this._cachedNodes.map((n) => n.projection);
333
+ this._lastVisibleMessagePairs = this._extractMessages(this._cachedNodes);
334
+ this._emitter.emit('update');
335
+ }
336
+
337
+ // -------------------------------------------------------------------------
338
+ // Public query methods
339
+ // -------------------------------------------------------------------------
340
+
341
+ getMessages(): CodecMessage<TMessage>[] {
342
+ return this._lastVisibleMessagePairs;
343
+ }
344
+
345
+ runs(): RunInfo[] {
346
+ // `_cachedNodes` is the visible node chain (inputs + reply runs) with
347
+ // pagination and sibling selection already applied. RunInfo is reply-run
348
+ // shaped, so filter to runs before projecting.
349
+ return this._cachedNodes
350
+ .filter((node): node is RunNode<TProjection> => node.kind === 'run')
351
+ .map((node) => _toRunInfo(node));
352
+ }
353
+
354
+ /**
355
+ * Compute the fresh visible node chain. The Tree's `visibleNodes` already
356
+ * applies kind-blind reachability and sibling selection (edit versions /
357
+ * regenerate runs collapse to the selected member), so the View only layers
358
+ * its pagination window on top: drop nodes whose key is currently withheld.
359
+ * @returns A fresh array of visible nodes (inputs + reply runs).
360
+ */
361
+ private _computeFlatNodes(): ConversationNode<TProjection>[] {
362
+ const treeNodes = this._treeVisibleNodes();
363
+ if (this._withheldRunIds.size === 0) return treeNodes;
364
+ return treeNodes.filter((node) => !this._withheldRunIds.has(nodeKey(node)));
365
+ }
366
+
367
+ /**
368
+ * Recompute the visible node chain, refresh the cache + snapshot, and emit
369
+ * `update` unconditionally. Use after a mutation that always changes the
370
+ * visible output (e.g. an explicit selection or a withheld-batch reveal).
371
+ */
372
+ private _recomputeAndEmit(): void {
373
+ this._cachedNodes = this._computeFlatNodes();
374
+ this._updateVisibleSnapshot(this._cachedNodes);
375
+ this._emitter.emit('update');
376
+ }
377
+
378
+ /**
379
+ * Recompute the visible node chain and, only if it differs from the current
380
+ * snapshot, refresh the cache + snapshot and emit `update`. Use after a
381
+ * mutation that may or may not move the visible window (e.g. a structural
382
+ * tree update, or a deferred regenerate promotion that may already match).
383
+ */
384
+ private _recomputeAndEmitIfChanged(): void {
385
+ const nodes = this._computeFlatNodes();
386
+ if (this._visibleChanged(nodes)) {
387
+ this._cachedNodes = nodes;
388
+ this._updateVisibleSnapshot(nodes);
389
+ this._emitter.emit('update');
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Resolve the reply Run that owns a codec-message-id, narrowing the Tree's
395
+ * node union to a {@link RunNode}. A user-input codec-message-id resolves to
396
+ * an input node and yields `undefined` here — callers that must handle input
397
+ * nodes use {@link _tree.getNodeByCodecMessageId} directly.
398
+ * @param codecMessageId - The codec-message-id to resolve.
399
+ * @returns The owning RunNode, or undefined if absent or not a reply Run.
400
+ */
401
+ private _runByCodecMessageId(codecMessageId: string): RunNode<TProjection> | undefined {
402
+ const node = this._tree.getNodeByCodecMessageId(codecMessageId);
403
+ return node?.kind === 'run' ? node : undefined;
404
+ }
405
+
406
+ /**
407
+ * Extract the flat TMessage[] from a visible node chain.
408
+ *
409
+ * In the two-node model the Tree's `visibleNodes` has already selected one
410
+ * member per sibling group (the chosen edit version, the chosen regenerate
411
+ * run), so a regenerate is just a sibling reply run that appears in place of
412
+ * the original. Each visible node contributes its own messages in projection
413
+ * order; the flat list is their concatenation.
414
+ *
415
+ * Deferred caveat: a mid-reply regenerate that replaces a non-head message
416
+ * inside a multi-message reply run is not expressible as a sibling run in
417
+ * this model and is not handled here (see the `regenerate-of-multi-message`
418
+ * golden test).
419
+ * @param nodes - The visible nodes (inputs + reply runs) in chronological order.
420
+ * @returns The flat message list, each message paired with its codec-message-id.
421
+ */
422
+ private _extractMessages(nodes: ConversationNode<TProjection>[]): CodecMessage<TMessage>[] {
423
+ const messages: CodecMessage<TMessage>[] = [];
424
+ for (const node of nodes) {
425
+ for (const m of this._codec.getMessages(node.projection)) {
426
+ messages.push(m);
427
+ }
428
+ }
429
+ return messages;
430
+ }
431
+
432
+ hasOlder(): boolean {
433
+ return this._withheldBuffer.length > 0 || this._hasMoreHistory;
434
+ }
435
+
436
+ /**
437
+ * Reveal up to `limit` older Runs in this view.
438
+ *
439
+ * The pagination unit is the **Run**, not the message. A single Run
440
+ * typically materialises into multiple messages (e.g. user + assistant
441
+ * pair) so revealing `limit` Runs may add several messages to the flat
442
+ * list returned by {@link getMessages}. Channel pages don't align to
443
+ * Run boundaries, so {@link _loadUntilVisible} keeps fetching channel
444
+ * pages until at least `limit` Runs are buffered (or the channel is
445
+ * exhausted).
446
+ * @param limit - Maximum number of older Runs to reveal. Defaults to 100.
447
+ */
448
+ async loadOlder(limit = 100): Promise<void> {
449
+ if (this._closed || this._loadingOlder) return;
450
+ this._loadingOlder = true;
451
+ this._logger.trace('DefaultView.loadOlder();', { limit });
452
+
453
+ try {
454
+ // Drain withheld buffer first (older nodes, released newest-first). The
455
+ // buffer holds a union of input + reply nodes, so this splices the newest
456
+ // `limit` NODES, not `limit` runs. Because an input node travels with the
457
+ // reply run it precedes, a drain may surface fewer than `limit` runs.
458
+ if (this._withheldBuffer.length > 0) {
459
+ const batch = this._withheldBuffer.splice(-limit, limit);
460
+ this._releaseWithheld(batch);
461
+ return;
462
+ }
463
+
464
+ // Buffer exhausted - load from channel history.
465
+ if (!this._hasMoreHistory && !this._lastHistoryPage) {
466
+ await this._loadFirstPage(limit);
467
+ return;
468
+ }
469
+
470
+ if (!this._hasMoreHistory) return;
471
+
472
+ if (!this._lastHistoryPage?.hasNext()) {
473
+ this._hasMoreHistory = false;
474
+ return;
475
+ }
476
+
477
+ const nextPage = await this._lastHistoryPage.next();
478
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
479
+ if (this._closed || !nextPage) {
480
+ if (!nextPage) this._hasMoreHistory = false;
481
+ return;
482
+ }
483
+
484
+ await this._revealFromPage(nextPage, limit);
485
+ } catch (error) {
486
+ this._logger.error('DefaultView.loadOlder(); failed', { error });
487
+ throw error;
488
+ } finally {
489
+ this._loadingOlder = false;
490
+ }
491
+ }
492
+
493
+ // -------------------------------------------------------------------------
494
+ // Run lookup
495
+ // -------------------------------------------------------------------------
496
+
497
+ runOf(codecMessageId: string): RunInfo | undefined {
498
+ this._logger.trace('DefaultView.runOf();', { codecMessageId });
499
+ const node = this._tree.getNodeByCodecMessageId(codecMessageId);
500
+ if (!node) return undefined;
501
+ if (node.kind === 'run') return _toRunInfo(node);
502
+ // Input node: resolve to its selected reply run (undefined if none started).
503
+ const reply = this._selectedReplyRun(node.codecMessageId);
504
+ return reply ? _toRunInfo(reply) : undefined;
505
+ }
506
+
507
+ /**
508
+ * Resolve the reply run currently selected for an input node, honouring the
509
+ * View's regenerate selection. Falls back to the latest reply run when no
510
+ * selection has been recorded; undefined when no reply run has started.
511
+ * @param inputCodecMessageId - The input node's codec-message-id.
512
+ * @returns The selected reply RunNode, or undefined.
513
+ */
514
+ private _selectedReplyRun(inputCodecMessageId: string): RunNode<TProjection> | undefined {
515
+ const replies = this._tree.getReplyRuns(inputCodecMessageId);
516
+ if (replies.length === 0) return undefined;
517
+ if (replies.length === 1) return replies[0];
518
+ // Multiple reply runs = a regenerate group. Honour the View's selection
519
+ // (keyed by group root) else default to the latest.
520
+ const groupRoot = this._tree.getGroupRoot(replies[0]?.runId ?? '');
521
+ const sel = this._regenSelections.get(groupRoot);
522
+ const selectedKey = sel && sel.kind !== 'pending' ? sel.selectedRunId : undefined;
523
+ if (selectedKey !== undefined) {
524
+ const chosen = replies.find((r) => r.runId === selectedKey);
525
+ if (chosen) return chosen;
526
+ }
527
+ // Latest by startSerial; getReplyRuns is set-ordered, so sort defensively.
528
+ return replies.toSorted((a, b) => (a.startSerial ?? '￿').localeCompare(b.startSerial ?? '￿')).at(-1);
529
+ }
530
+
531
+ run(runId: string): RunInfo | undefined {
532
+ this._logger.trace('DefaultView.run();', { runId });
533
+ const run = this._tree.getRunNode(runId);
534
+ return run ? _toRunInfo(run) : undefined;
535
+ }
536
+
537
+ // -------------------------------------------------------------------------
538
+ // Branch navigation (msg-anchored)
539
+ // -------------------------------------------------------------------------
540
+
541
+ // Spec: AIT-CT13c, AIT-CT13d — branch points are codec-message-id
542
+ // anchored. The View resolves the anchor (the user prompt for edits,
543
+ // the assistant slot for regens) and routes the selection to the
544
+ // appropriate internal selection map. Tree-level introspection
545
+ // (RunNode access, runId-keyed queries) remains on the {@link Tree}.
546
+
547
+ branchSelection(codecMessageId: string): BranchSelection<TMessage> {
548
+ const branch = this._resolveMessageBranchPoint(codecMessageId);
549
+ if (branch) {
550
+ // Each sibling contributes its head message as the branch-arrow slot:
551
+ // for an edit fork that is the alternate user prompt; for a regenerate
552
+ // group it is the variant's first (anchor-equivalent) message.
553
+ const siblings = branch.siblings.flatMap((s) => {
554
+ const first = this._codec.getMessages(s.projection).at(0);
555
+ return first ? [first.message] : [];
556
+ });
557
+
558
+ if (siblings.length > 0) {
559
+ const index = this._resolveSelectedIndex(branch);
560
+ const clamped = Math.max(0, Math.min(index, siblings.length - 1));
561
+ const selected = siblings[clamped];
562
+ return {
563
+ hasSiblings: siblings.length > 1,
564
+ siblings,
565
+ index: clamped,
566
+ selected,
567
+ };
568
+ }
569
+ }
570
+
571
+ // Known non-anchor message: the bundle's invariant is that
572
+ // `siblings` contains the rendered message itself for any known
573
+ // codec-message-id, so plain bubbles get `siblings.length === 1`
574
+ // (not `0`) and the indexing space matches between read and write.
575
+ // Resolve the owning node kind-blind — a plain user prompt is an input
576
+ // node, an assistant message lives in a reply run; both carry a projection.
577
+ const owner = this._tree.getNodeByCodecMessageId(codecMessageId);
578
+ if (owner) {
579
+ const found = this._codec.getMessages(owner.projection).find((m) => m.codecMessageId === codecMessageId);
580
+ if (found !== undefined) {
581
+ return { hasSiblings: false, siblings: [found.message], index: 0, selected: found.message };
582
+ }
583
+ }
584
+
585
+ // Unknown id, or the owner Run is known but the codec doesn't surface
586
+ // a message with this id from the projection (e.g. an event-only fold
587
+ // such as a tool result that mutates an assistant in-place without
588
+ // exposing its own TMessage). Treat both as "no rendered message",
589
+ // returning the safe empty bundle.
590
+ return { hasSiblings: false, siblings: [], index: 0, selected: undefined };
591
+ }
592
+
593
+ // Spec: AIT-CT13c, AIT-CT13d
594
+ selectSibling(codecMessageId: string, index: number): void {
595
+ this._logger.trace('DefaultView.selectSibling();', { codecMessageId, index });
596
+ const branch = this._resolveMessageBranchPoint(codecMessageId);
597
+ if (!branch) return;
598
+ const clamped = Math.max(0, Math.min(index, branch.siblings.length - 1));
599
+ const selected = branch.siblings[clamped];
600
+ if (!selected) return; // unreachable: clamped is always in bounds
601
+ if (branch.kind === 'fork-of') {
602
+ this._branchSelections.set(branch.groupRoot, { kind: 'user', selectedKey: nodeKey(selected) });
603
+ this._logger.debug('DefaultView.selectSibling(); fork-of', {
604
+ codecMessageId,
605
+ index: clamped,
606
+ selectedKey: nodeKey(selected),
607
+ });
608
+ } else {
609
+ this._regenSelections.set(branch.groupRoot, { kind: 'user', selectedRunId: nodeKey(selected) });
610
+ this._logger.debug('DefaultView.selectSibling(); regenerate', {
611
+ codecMessageId,
612
+ index: clamped,
613
+ selectedRunId: nodeKey(selected),
614
+ groupRoot: branch.groupRoot,
615
+ });
616
+ }
617
+ this._recomputeAndEmit();
618
+ }
619
+
620
+ /**
621
+ * Resolve the currently selected sibling's index inside a branch group.
622
+ * Pending selections fall back to the latest sibling. The caller clamps
623
+ * the returned index against any post-extraction filtering.
624
+ * @param branch - Resolved branch-point descriptor from `_resolveMessageBranchPoint`.
625
+ * @returns The selected sibling's index within `branch.siblings`.
626
+ */
627
+ private _resolveSelectedIndex(branch: MessageBranchPoint<TProjection>): number {
628
+ if (branch.kind === 'fork-of') {
629
+ const sel = this._branchSelections.get(branch.groupRoot);
630
+ if (!sel) return branch.siblings.length - 1;
631
+ const idx = branch.siblings.findIndex((n) => nodeKey(n) === sel.selectedKey);
632
+ return idx === -1 ? branch.siblings.length - 1 : idx;
633
+ }
634
+ const sel = this._regenSelections.get(branch.groupRoot);
635
+ if (!sel || sel.kind === 'pending') return branch.siblings.length - 1;
636
+ const idx = branch.siblings.findIndex((n) => nodeKey(n) === sel.selectedRunId);
637
+ return idx === -1 ? branch.siblings.length - 1 : idx;
638
+ }
639
+
640
+ /**
641
+ * Resolve the branch point anchored at `codecMessageId`, if any.
642
+ *
643
+ * Returns the resolved group `kind` along with the sibling list so the
644
+ * caller can update the correct selection map without re-entering the
645
+ * runId-based `select()` dispatch (which biases to fork-of first and
646
+ * would mis-route a regen-anchor codec-message-id when the owning Run is in
647
+ * BOTH groups — e.g. R1 owns both a user prompt that got edited and
648
+ * an assistant that got regenerated).
649
+ *
650
+ * Two anchor cases:
651
+ * - **fork-of** — `codecMessageId` is the first message of a Run in a fork-of
652
+ * sibling group (edit-style branch point anchored at the user prompt).
653
+ * - **regen** — `codecMessageId` is the regen-anchor itself (in the owner Run)
654
+ * or content of a regenerator Run (regen-style branch point anchored
655
+ * at the assistant slot).
656
+ * @param codecMessageId - The codec-message-id to look up.
657
+ * @returns The kind + sibling list + group key (runId for fork-of,
658
+ * anchor codec-message-id for regen), or undefined when `codecMessageId` is not an
659
+ * anchor in either group type.
660
+ */
661
+ private _resolveMessageBranchPoint(codecMessageId: string): MessageBranchPoint<TProjection> | undefined {
662
+ const node = this._tree.getNodeByCodecMessageId(codecMessageId);
663
+ if (!node) return undefined;
664
+
665
+ // Edit-fork branch point: `codecMessageId` is a user INPUT node that has
666
+ // sibling input nodes (alternate prompts via fork-of). The anchor is the
667
+ // input node's own codec-message-id.
668
+ if (node.kind === 'input') {
669
+ const siblings = this._tree.getSiblingNodes(node.codecMessageId);
670
+ if (siblings.length > 1) {
671
+ return { kind: 'fork-of', groupRoot: this._tree.getGroupRoot(node.codecMessageId), siblings };
672
+ }
673
+ return undefined;
674
+ }
675
+
676
+ // Regenerate branch point: `codecMessageId` is owned by a reply run that has
677
+ // sibling reply runs (the original reply + its regenerators, all parented at
678
+ // the same input node). Anchor on the head message of the run so arrows
679
+ // appear once per variant, not on every follow-up message.
680
+ const siblings = this._tree.getSiblingNodes(node.runId);
681
+ if (siblings.length > 1) {
682
+ const firstMsg = this._codec.getMessages(node.projection).at(0);
683
+ if (firstMsg?.codecMessageId === codecMessageId) {
684
+ return { kind: 'regen', groupRoot: this._tree.getGroupRoot(node.runId), siblings };
685
+ }
686
+ }
687
+
688
+ return undefined;
689
+ }
690
+
691
+ // -------------------------------------------------------------------------
692
+ // Write operations
693
+ // -------------------------------------------------------------------------
694
+
695
+ // Spec: AIT-CT3, AIT-CT4
696
+ async send(input: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
697
+ this._logger.trace('DefaultView.send();');
698
+ if (this._closed) {
699
+ throw new Ably.ErrorInfo('unable to send; view is closed', ErrorCode.InvalidArgument, 400);
700
+ }
701
+
702
+ const normalised = _normaliseSend<TInput>(input);
703
+
704
+ // The codec-message-id of the visible branch tail — the delegate uses it
705
+ // for auto-parent routing on fresh user messages.
706
+ const parentCodecMessageId = this._lastVisibleMessagePairs.at(-1)?.codecMessageId;
707
+
708
+ const result = await this._sendDelegate(normalised, options, parentCodecMessageId);
709
+ this._applyForkAutoSelect(result, options);
710
+ return result;
711
+ }
712
+
713
+ /**
714
+ * Auto-select / pin branch selections after a forking send.
715
+ * @param result - The ActiveRun returned by the delegate.
716
+ * @param options - The SendOptions passed by the caller.
717
+ */
718
+ private _applyForkAutoSelect(result: ActiveRun, options: SendOptions | undefined): void {
719
+ // Spec: AIT-CT13e
720
+ if (!options?.forkOf) return;
721
+
722
+ // An edit inserts a NEW user input node optimistically; its codec-message-id
723
+ // is the (only) optimistic id and IS its node key. Edit forks are input-node
724
+ // sibling groups, so the selection is keyed by the input group root and the
725
+ // selected member is the new input node's key.
726
+ const editedInputKey = result.optimisticCodecMessageIds.at(0);
727
+ if (editedInputKey === undefined) return;
728
+ const groupRoot = this._tree.getGroupRoot(editedInputKey);
729
+
730
+ this._branchSelections.set(groupRoot, { kind: 'auto', selectedKey: editedInputKey });
731
+ this._recomputeAndEmit();
732
+ }
733
+
734
+ /**
735
+ * Auto-select / pin the regenerate group anchored at `anchorCodecMessageId` so
736
+ * the new Run's content appears as soon as the agent's run-start lands.
737
+ *
738
+ * `View.regenerate()` calls this with the assistant codec-message-id being
739
+ * regenerated. The Run doesn't exist yet on the channel (the regenerate
740
+ * wire is wire-only); the selection is recorded as `pending` and
741
+ * promoted to `auto` by `_pinRegenSelections` once the corresponding
742
+ * Run is created in the tree.
743
+ * @param result - The ActiveRun returned by the delegate (run-id is the new regenerator's).
744
+ * @param anchorCodecMessageId - The codec-message-id of the assistant being regenerated.
745
+ */
746
+ private _applyRegenerateAutoSelect(result: ActiveRun, anchorCodecMessageId: string): void {
747
+ // A regenerate produces a new reply run parented at the SAME input node as
748
+ // the original reply (the regenerate group). The agent mints the run-id, so
749
+ // we cannot pin by it synchronously. Resolve the group root from the
750
+ // original reply run owning the anchor, and pin a pending selection keyed by
751
+ // that group root, carrying the regenerate carrier's codec-message-id
752
+ // (`result.inputCodecMessageId`) so we can promote when the new reply run lands.
753
+ const anchorRun = this._runByCodecMessageId(anchorCodecMessageId);
754
+ if (!anchorRun) return;
755
+ const groupRoot = this._tree.getGroupRoot(anchorRun.runId);
756
+
757
+ this._regenSelections.set(groupRoot, {
758
+ kind: 'pending',
759
+ carrierCodecMessageId: result.inputCodecMessageId,
760
+ });
761
+ this._logger.debug('DefaultView._applyRegenerateAutoSelect(); deferring regenerate selection', {
762
+ anchorCodecMessageId,
763
+ groupRoot,
764
+ carrier: result.inputCodecMessageId,
765
+ });
766
+
767
+ // The new reply run may already be in the tree (run-start raced ahead of the
768
+ // sendDelegate resolution). Promote now and recompute so the visible set
769
+ // catches up without waiting for the next structural change.
770
+ this._resolvePendingRegenSelections();
771
+ this._recomputeAndEmitIfChanged();
772
+ }
773
+
774
+ // Spec: AIT-CT5, AIT-CT13d
775
+ async regenerate(messageId: string, options?: SendOptions): Promise<ActiveRun> {
776
+ this._logger.trace('DefaultView.regenerate();', { messageId });
777
+
778
+ if (this._closed) {
779
+ throw new Ably.ErrorInfo('unable to regenerate; view is closed', ErrorCode.InvalidArgument, 400);
780
+ }
781
+
782
+ // `messageId` is the assistant being regenerated. The new Run is a
783
+ // continuation of the regenerated message's Run, not a fork: the
784
+ // message-level replacement (new assistant supersedes the original)
785
+ // happens at projection extraction time. We still resolve the parent
786
+ // user prompt so the new assistant's wire `parent` is correct,
787
+ // and we send the truncated history (through the parent inclusive)
788
+ // so the LLM re-answers the right message.
789
+ const targetRun = this._runByCodecMessageId(messageId);
790
+ if (!targetRun) {
791
+ throw new Ably.ErrorInfo(
792
+ `unable to regenerate; message not found in tree: ${messageId}`,
793
+ ErrorCode.InvalidArgument,
794
+ 400,
795
+ );
796
+ }
797
+ const parentCodecMessageId = this._findParentMsgId(targetRun, messageId);
798
+ if (!parentCodecMessageId) {
799
+ throw new Ably.ErrorInfo(
800
+ `unable to regenerate; parent user message not found for ${messageId}`,
801
+ ErrorCode.InvalidArgument,
802
+ 400,
803
+ );
804
+ }
805
+
806
+ // Canonical regen anchor: when the user clicks Regenerate on an
807
+ // already-regenerated assistant, the new alternative SHOULD belong
808
+ // to the SAME branch point as the previous regen — but ONLY when
809
+ // the target is the position-equivalent of the group anchor (the
810
+ // head message of the regenerator Run). For a trailing follow-up
811
+ // message inside a regenerator Run (e.g. the LLM text after the
812
+ // regenerated tool call), the user expects the regen to anchor at
813
+ // the specific message they clicked, not roll up to the group root.
814
+ // Rebasing trailing regens to the group root produces a confusing
815
+ // "N+1 / N+1" counter on the tool-call bubble and runs the whole
816
+ // turn from scratch instead of just regenerating the text.
817
+ let regenAnchorMsgId = messageId;
818
+ if (targetRun.regeneratesCodecMessageId !== undefined) {
819
+ const firstMsg = this._codec.getMessages(targetRun.projection).at(0);
820
+ if (firstMsg?.codecMessageId === messageId) {
821
+ regenAnchorMsgId = targetRun.regeneratesCodecMessageId;
822
+ }
823
+ }
824
+
825
+ const sendOptions: SendOptions = {
826
+ ...options,
827
+ parent: parentCodecMessageId,
828
+ };
829
+
830
+ // Mint a regenerate input via the codec. The codec's well-known
831
+ // `Regenerate` carries `target: regenAnchorMsgId` and `parent:
832
+ // parentCodecMessageId`; the session reads those fields off the input
833
+ // directly when building transport headers (`fork-of` and
834
+ // `parent`). The agent's input-event lookup catches the wire signal;
835
+ // no tree-upsert / projection fold runs locally.
836
+ const regenerate = this._codec.createRegenerate(regenAnchorMsgId, parentCodecMessageId);
837
+ const result = await this._sendDelegate([regenerate], sendOptions, parentCodecMessageId);
838
+ this._applyRegenerateAutoSelect(result, regenAnchorMsgId);
839
+ return result;
840
+ }
841
+
842
+ // Spec: AIT-CT6
843
+ async edit(messageId: string, inputs: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
844
+ this._logger.trace('DefaultView.edit();', { messageId });
845
+
846
+ if (this._closed) {
847
+ throw new Ably.ErrorInfo('unable to edit; view is closed', ErrorCode.InvalidArgument, 400);
848
+ }
849
+
850
+ // The edit target is a user prompt — a run-less INPUT node — so resolve
851
+ // it kind-blind, not via the reply-run-only lookup.
852
+ const targetNode = this._tree.getNodeByCodecMessageId(messageId);
853
+ if (!targetNode) {
854
+ throw new Ably.ErrorInfo(
855
+ `unable to edit; message not found in tree: ${messageId}`,
856
+ ErrorCode.InvalidArgument,
857
+ 400,
858
+ );
859
+ }
860
+ const parentCodecMessageId = this._findParentMsgId(targetNode, messageId);
861
+
862
+ return this.send(inputs, {
863
+ ...options,
864
+ forkOf: messageId,
865
+ parent: parentCodecMessageId,
866
+ });
867
+ }
868
+
869
+ /**
870
+ * Find the codec-message-id of the message immediately preceding `targetMsgId` in
871
+ * the visible conversation.
872
+ *
873
+ * Consults the View's visible message chain first so message-level
874
+ * replacements (regenerate) are respected: regenerating an
875
+ * already-regenerated assistant lands the predecessor on the user
876
+ * prompt the regen is responding to, NOT on the hidden original
877
+ * assistant that occupies the same conversation slot. Falls back to a
878
+ * projection-walk for the rare case where `targetMsgId` isn't on the
879
+ * visible chain (e.g. caller is operating on a Run that's selection-
880
+ * hidden by the current branch).
881
+ * @param targetNode - The node (input node or reply run) that owns `targetMsgId`.
882
+ * @param targetMsgId - The codec-message-id to find the parent of.
883
+ * @returns The parent codec-message-id, or undefined if no predecessor exists.
884
+ */
885
+ private _findParentMsgId(targetNode: ConversationNode<TProjection>, targetMsgId: string): string | undefined {
886
+ const visible = this._lastVisibleMessagePairs;
887
+ const visIdx = visible.findIndex((m) => m.codecMessageId === targetMsgId);
888
+ if (visIdx > 0) {
889
+ return visible[visIdx - 1]?.codecMessageId;
890
+ }
891
+ if (visIdx === 0) return undefined;
892
+
893
+ const messages = this._codec.getMessages(targetNode.projection);
894
+ const idx = messages.findIndex((m) => m.codecMessageId === targetMsgId);
895
+ if (idx > 0) {
896
+ return messages[idx - 1]?.codecMessageId;
897
+ }
898
+ if (idx === 0 && targetNode.parentCodecMessageId !== undefined) {
899
+ // The structural predecessor is the node owning parentCodecMessageId
900
+ // (an input node, or a prior reply run). Its tail message is the parent.
901
+ const parentNode = this._tree.getNodeByCodecMessageId(targetNode.parentCodecMessageId);
902
+ if (parentNode) {
903
+ return this._codec.getMessages(parentNode.projection).at(-1)?.codecMessageId;
904
+ }
905
+ }
906
+ return undefined;
907
+ }
908
+
909
+ // -------------------------------------------------------------------------
910
+ // Event subscription
911
+ // -------------------------------------------------------------------------
912
+
913
+ // Spec: AIT-CT8a, AIT-CT8b, AIT-CT8e
914
+ on(event: 'update', handler: () => void): () => void;
915
+ on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
916
+ on(event: 'run', handler: (event: RunLifecycleEvent) => void): () => void;
917
+ on(
918
+ event: 'update' | 'ably-message' | 'run',
919
+ handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: RunLifecycleEvent) => void),
920
+ ): () => void {
921
+ // CAST: overload signatures enforce correct handler types per event name.
922
+ const cb = handler as (arg: ViewEventsMap[keyof ViewEventsMap]) => void;
923
+ this._emitter.on(event, cb);
924
+ return () => {
925
+ this._emitter.off(event, cb);
926
+ };
927
+ }
928
+
929
+ // -------------------------------------------------------------------------
930
+ // Lifecycle
931
+ // -------------------------------------------------------------------------
932
+
933
+ close(): void {
934
+ if (this._closed) return;
935
+ this._logger.info('DefaultView.close();');
936
+ this._closed = true;
937
+ this._loadingOlder = false;
938
+ for (const unsub of this._unsubs) unsub();
939
+ this._unsubs.length = 0;
940
+ this._emitter.off();
941
+ this._branchSelections.clear();
942
+ this._regenSelections.clear();
943
+ this._withheldRunIds.clear();
944
+ this._withheldBuffer.length = 0;
945
+ this._onClose?.();
946
+ }
947
+
948
+ // -------------------------------------------------------------------------
949
+ // Private: history loading
950
+ // -------------------------------------------------------------------------
951
+
952
+ private async _loadFirstPage(limit: number): Promise<void> {
953
+ // loadHistory's limit counts complete domain messages per page (not
954
+ // Runs); see `_RUN_TO_MESSAGE_FETCH_FACTOR` for the scaling rationale.
955
+ const messageLimit = limit * _RUN_TO_MESSAGE_FETCH_FACTOR;
956
+ const firstPage = await loadHistory(this._channel, { limit: messageLimit }, this._logger);
957
+ if (this._closed) return;
958
+ await this._revealFromPage(firstPage, limit);
959
+ }
960
+
961
+ /**
962
+ * Walk channel history from `page` until at least `limit` new Runs are
963
+ * observed (or the channel is exhausted), then reveal the newest batch and
964
+ * withhold the rest. Snapshots the already-visible nodes up front so only
965
+ * newly-observed Runs count toward `limit`. No-op if the view closed during
966
+ * the page walk.
967
+ * @param page - The decoded history page to start from.
968
+ * @param limit - Max Runs to reveal in this batch.
969
+ */
970
+ private async _revealFromPage(page: HistoryPage, limit: number): Promise<void> {
971
+ // Snapshot before loading: every node already in the tree stays visible.
972
+ const beforeRunIds = new Set(this._treeVisibleNodes().map((n) => nodeKey(n)));
973
+
974
+ const { newVisible, lastPage } = await this._loadUntilVisible(page, limit, beforeRunIds);
975
+ if (this._closed) return;
976
+ this._lastHistoryPage = lastPage;
977
+ this._hasMoreHistory = lastPage.hasNext();
978
+ this._splitReveal(newVisible, limit);
979
+ }
980
+
981
+ /**
982
+ * Reveal the newest `limit` Runs from `newVisible` and withhold the rest
983
+ * so subsequent `loadOlder` calls can drain them. Called by
984
+ * {@link _revealFromPage} to enforce the Run-unit pagination contract.
985
+ * @param newVisible - Newly observed Runs from the history fetch.
986
+ * @param limit - Max Runs to reveal in this batch.
987
+ */
988
+ private _splitReveal(newVisible: ConversationNode<TProjection>[], limit: number): void {
989
+ // Reveal granularity is the reply RUN; an input node travels with the reply
990
+ // run it precedes. Walk newest-first, counting reply runs toward `limit`,
991
+ // and split the union list at the resulting boundary so an input + its reply
992
+ // are revealed or withheld together.
993
+ let runs = 0;
994
+ let splitIdx = newVisible.length; // index of first revealed node
995
+ for (let i = newVisible.length - 1; i >= 0; i--) {
996
+ const node = newVisible[i];
997
+ if (node?.kind === 'run') {
998
+ if (runs === limit) break;
999
+ runs++;
1000
+ }
1001
+ splitIdx = i;
1002
+ }
1003
+ const batch = newVisible.slice(splitIdx);
1004
+ const withheld = newVisible.slice(0, splitIdx);
1005
+ for (const n of withheld) {
1006
+ this._withheldRunIds.add(nodeKey(n));
1007
+ }
1008
+ this._withheldBuffer.push(...withheld);
1009
+ this._releaseWithheld(batch);
1010
+ }
1011
+
1012
+ /**
1013
+ * Replay a history page's raw messages into the Tree. Dispatches by Ably
1014
+ * message name to run-lifecycle vs. regular wire messages, mirroring the
1015
+ * live `client-session._handleMessage` decode loop. Uses a fresh decoder
1016
+ * since the session's live decoder maintains its own stream-tracker state.
1017
+ * @param page - The history page returned by `loadHistory`.
1018
+ */
1019
+ private _processHistoryPage(page: HistoryPage): void {
1020
+ this._processingHistory = true;
1021
+ try {
1022
+ // Reconstruct the tree via the shared decode-fold engine — the same path
1023
+ // the client's live loop uses, so history replay can't drift from it.
1024
+ const decoder = this._codec.createDecoder();
1025
+ for (const rawMsg of page.rawMessages) {
1026
+ applyWireMessage(this._tree, decoder, rawMsg);
1027
+ }
1028
+
1029
+ // Emit ably-message in a batch AFTER the whole page is applied, so a
1030
+ // subscriber resolving the owning Run sees the fully-rebuilt tree.
1031
+ for (const msg of page.rawMessages) {
1032
+ this._tree.emitAblyMessage(msg);
1033
+ }
1034
+ } finally {
1035
+ this._processingHistory = false;
1036
+ }
1037
+ }
1038
+
1039
+ private async _loadUntilVisible(
1040
+ firstPage: HistoryPage,
1041
+ target: number,
1042
+ beforeRunIds: Set<string>,
1043
+ ): Promise<{ newVisible: ConversationNode<TProjection>[]; lastPage: HistoryPage }> {
1044
+ this._processHistoryPage(firstPage);
1045
+ let page = firstPage;
1046
+
1047
+ const newVisibleCount = (): number => {
1048
+ let count = 0;
1049
+ for (const n of this._treeVisibleNodes()) {
1050
+ // Pagination counts reply RUNS toward the target (an input node travels
1051
+ // with the reply run it precedes — see `_splitReveal`).
1052
+ if (n.kind === 'run' && !beforeRunIds.has(nodeKey(n))) count++;
1053
+ }
1054
+ return count;
1055
+ };
1056
+
1057
+ while (newVisibleCount() < target && page.hasNext()) {
1058
+ const nextPage = await page.next();
1059
+ if (!nextPage || this._closed) break;
1060
+ this._processHistoryPage(nextPage);
1061
+ page = nextPage;
1062
+ }
1063
+
1064
+ const newVisible = this._treeVisibleNodes().filter((n) => !beforeRunIds.has(nodeKey(n)));
1065
+ return { newVisible, lastPage: page };
1066
+ }
1067
+
1068
+ // Spec: AIT-CT11a
1069
+ private _releaseWithheld(nodes: ConversationNode<TProjection>[]): void {
1070
+ for (const n of nodes) {
1071
+ this._withheldRunIds.delete(nodeKey(n));
1072
+ }
1073
+ if (nodes.length > 0) {
1074
+ this._recomputeAndEmit();
1075
+ }
1076
+ }
1077
+
1078
+ // -------------------------------------------------------------------------
1079
+ // Private: scoped event forwarding
1080
+ // -------------------------------------------------------------------------
1081
+
1082
+ private _updateVisibleSnapshot(nodes?: ConversationNode<TProjection>[]): void {
1083
+ const resolved = nodes ?? this._cachedNodes;
1084
+ // Identity key = nodeKey (runId for reply runs, codecMessageId for inputs),
1085
+ // so the visible set scopes events for both kinds and input-node parents.
1086
+ this._lastVisibleNodeKeys = resolved.map((n) => nodeKey(n));
1087
+ this._lastVisibleNodeKeySet = new Set(this._lastVisibleNodeKeys);
1088
+ this._lastVisibleProjections = resolved.map((n) => n.projection);
1089
+ this._lastVisibleMessagePairs = this._extractMessages(resolved);
1090
+ }
1091
+
1092
+ private _onTreeUpdate(): void {
1093
+ // Suppress update forwarding while processing history pages. During
1094
+ // _processHistoryPage, each tree.applyMessage() fires this handler
1095
+ // synchronously — but _withheldRunIds hasn't been populated yet, so
1096
+ // _computeFlatNodes() would return unfiltered history. Without this guard,
1097
+ // subscribers briefly see all history Runs before the pagination window
1098
+ // is applied. The final update is emitted by _releaseWithheld after
1099
+ // withholding is set up.
1100
+ if (this._processingHistory) return;
1101
+
1102
+ // The Tree emits `update` only on structural change (new/removed Run,
1103
+ // sort-reorder, startSerial promotion, run-start backfill), so every
1104
+ // update reaching here warrants a full re-walk. Content-only folds flow
1105
+ // through `output` (_onTreeOutput) instead.
1106
+
1107
+ // Pin selections for previously-visible Runs that now have siblings.
1108
+ // This prevents new forks (from other views' edits/regenerates) from
1109
+ // shifting this view to a branch the user didn't navigate to.
1110
+ this._pinBranchSelections();
1111
+ this._resolvePendingRegenSelections();
1112
+
1113
+ this._recomputeAndEmitIfChanged();
1114
+ }
1115
+
1116
+ /**
1117
+ * Build the unified selection map the Tree's `visibleNodes` consumes:
1118
+ * `groupRootKey -> selectedKey`, covering both edit forks (input-node groups,
1119
+ * keyed by the input group root) and regenerate groups (reply-run groups,
1120
+ * keyed by the original reply's group root). Pending entries (no chosen
1121
+ * member yet) are omitted so the Tree falls back to the latest sibling.
1122
+ * @returns The merged group-root → selected-key map.
1123
+ */
1124
+ private _resolveSelections(): Map<string, string> {
1125
+ const resolved = new Map<string, string>();
1126
+ for (const [groupRoot, sel] of this._branchSelections) {
1127
+ resolved.set(groupRoot, sel.selectedKey);
1128
+ }
1129
+ for (const [groupRoot, sel] of this._regenSelections) {
1130
+ if (sel.kind === 'pending') continue;
1131
+ resolved.set(groupRoot, sel.selectedRunId);
1132
+ }
1133
+ return resolved;
1134
+ }
1135
+
1136
+ /**
1137
+ * The Tree's visible node chain under this view's current selections — the
1138
+ * reachable, sibling-resolved nodes before the View's pagination window is
1139
+ * applied.
1140
+ * @returns The selection-resolved visible node chain.
1141
+ */
1142
+ private _treeVisibleNodes(): ConversationNode<TProjection>[] {
1143
+ return this._tree.visibleNodes(this._resolveSelections());
1144
+ }
1145
+
1146
+ /**
1147
+ * For each previously-visible Run that now has siblings but no explicit
1148
+ * selection, pin the selection to that Run's runId. This preserves the
1149
+ * current branch when new forks appear from other views or external
1150
+ * sources.
1151
+ *
1152
+ * Exception: if the fork was initiated by this view (tracked as a
1153
+ * `pending` BranchSelection), select the newest sibling (the awaited Run)
1154
+ * instead of pinning the old one.
1155
+ */
1156
+ private _pinBranchSelections(): void {
1157
+ for (const key of this._lastVisibleNodeKeys) {
1158
+ const node = this._tree.getNode(key);
1159
+ // Edit forks are INPUT-node sibling groups; only input nodes pin here.
1160
+ // Regenerate (reply-run) groups roll forward via _resolvePendingRegenSelections.
1161
+ if (node?.kind !== 'input') continue;
1162
+ const siblings = this._tree.getSiblingNodes(key);
1163
+ if (siblings.length <= 1) continue;
1164
+ const groupRoot = this._tree.getGroupRoot(key);
1165
+ const existing = this._branchSelections.get(groupRoot);
1166
+
1167
+ // Spec: AIT-CT13f — external edit fork: pin to the currently-visible
1168
+ // sibling so a fork from another view doesn't drift this view's branch.
1169
+ if (existing) continue;
1170
+ this._branchSelections.set(groupRoot, { kind: 'pinned', selectedKey: key });
1171
+ }
1172
+ }
1173
+
1174
+ /**
1175
+ * Roll `pending` and `auto` regenerate selections forward to the newest
1176
+ * group member. A regenerate slot defaults to the latest member, so each
1177
+ * new regenerator (this view's awaited run, or an external one) auto-rolls
1178
+ * the slot forward — UNLESS the user explicitly selected an earlier member
1179
+ * (`user`), which pins and is left untouched. The agent mints the run-id, so
1180
+ * we can't match the awaited run by id — once the group grows we adopt the
1181
+ * newest as the selected member.
1182
+ */
1183
+ private _resolvePendingRegenSelections(): void {
1184
+ for (const [groupRoot, sel] of this._regenSelections) {
1185
+ if (sel.kind === 'user') continue;
1186
+ const group = this._tree.getSiblingNodes(groupRoot).filter((n): n is RunNode<TProjection> => n.kind === 'run');
1187
+ if (group.length <= 1) continue;
1188
+ const newest = group.at(-1);
1189
+ if (!newest) continue;
1190
+ this._regenSelections.set(groupRoot, { kind: 'auto', selectedRunId: newest.runId });
1191
+ }
1192
+ }
1193
+
1194
+ private _onTreeAblyMessage(msg: Ably.InboundMessage): void {
1195
+ // Re-emit only if the message corresponds to a visible Run
1196
+ const headers = getTransportHeaders(msg);
1197
+ const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
1198
+ const runId = headers[HEADER_RUN_ID];
1199
+
1200
+ if (!codecMessageId && !runId) {
1201
+ // Lifecycle / control events with no run/message identity (cancel, error)
1202
+ // are always forwarded.
1203
+ this._emitter.emit('ably-message', msg);
1204
+ return;
1205
+ }
1206
+
1207
+ if (runId && this._lastVisibleNodeKeySet.has(runId)) {
1208
+ this._emitter.emit('ably-message', msg);
1209
+ }
1210
+ }
1211
+
1212
+ private _onTreeRun(event: RunLifecycleEvent): void {
1213
+ // Check if the run is already on the visible branch.
1214
+ if (this._lastVisibleNodeKeySet.has(event.runId)) {
1215
+ this._emitter.emit('run', event);
1216
+ return;
1217
+ }
1218
+
1219
+ // For run-start, use branch metadata to predict visibility before
1220
+ // messages arrive. Own runs have optimistic inserts (caught above).
1221
+ // Remote runs carry parent/forkOf from the agent.
1222
+ if (event.type === 'start' && this._isRunStartVisible(event)) {
1223
+ this._lastVisibleNodeKeySet.add(event.runId);
1224
+ this._emitter.emit('run', event);
1225
+ }
1226
+ }
1227
+
1228
+ /**
1229
+ * Predict whether a run-start's messages will be visible on this view's
1230
+ * branch using the parent/forkOf metadata from the event.
1231
+ * @param event - The run-start lifecycle event.
1232
+ * @returns True if the run is expected to be visible on this view's branch.
1233
+ */
1234
+ private _isRunStartVisible(event: RunLifecycleEvent & { type: 'start' }): boolean {
1235
+ const { parent } = event;
1236
+
1237
+ // No parent metadata — can't determine branch, forward as default.
1238
+ if (parent === undefined) return true;
1239
+
1240
+ // The wire `parent` is a codec-message-id (the prior message). Resolve it
1241
+ // kind-blind to its owning NODE — an input node (the user prompt this run
1242
+ // replies to) or a prior reply run — and check that node's key against the
1243
+ // visible set. Input-node keys are populated into the set by
1244
+ // _updateVisibleSnapshot.
1245
+ const parentNode = this._tree.getNodeByCodecMessageId(parent);
1246
+ if (!parentNode) return true; // unknown parent: forward conservatively
1247
+ return this._lastVisibleNodeKeySet.has(nodeKey(parentNode));
1248
+ }
1249
+
1250
+ private _visibleChanged(newNodes: ConversationNode<TProjection>[]): boolean {
1251
+ if (newNodes.length !== this._lastVisibleNodeKeys.length) return true;
1252
+ for (const [i, node] of newNodes.entries()) {
1253
+ if (nodeKey(node) !== this._lastVisibleNodeKeys[i]) return true;
1254
+ if (node.projection !== this._lastVisibleProjections[i]) return true;
1255
+ }
1256
+ return false;
1257
+ }
1258
+ }
1259
+
1260
+ // ---------------------------------------------------------------------------
1261
+ // Factory
1262
+ // ---------------------------------------------------------------------------
1263
+
1264
+ /**
1265
+ * Create a View that projects a paginated window over a Tree.
1266
+ * @param options - The tree, channel, codec, and logger to use.
1267
+ * @returns A new {@link DefaultView} instance.
1268
+ */
1269
+ export const createView = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage>(
1270
+ options: ViewOptions<TInput, TOutput, TProjection, TMessage>,
1271
+ ): DefaultView<TInput, TOutput, TProjection, TMessage> => new DefaultView(options);