@ably/ai-transport 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +91 -100
  2. package/dist/ably-ai-transport.js +1553 -1238
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +116 -42
  7. package/dist/core/agent.d.ts +29 -0
  8. package/dist/core/codec/decoder.d.ts +20 -23
  9. package/dist/core/codec/encoder.d.ts +11 -8
  10. package/dist/core/codec/index.d.ts +1 -2
  11. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  12. package/dist/core/codec/types.d.ts +407 -115
  13. package/dist/core/transport/agent-session.d.ts +10 -0
  14. package/dist/core/transport/branch-chain.d.ts +43 -0
  15. package/dist/core/transport/client-session.d.ts +13 -0
  16. package/dist/core/transport/decode-fold.d.ts +47 -0
  17. package/dist/core/transport/headers.d.ts +96 -18
  18. package/dist/core/transport/index.d.ts +5 -6
  19. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  20. package/dist/core/transport/invocation.d.ts +74 -0
  21. package/dist/core/transport/load-conversation.d.ts +128 -0
  22. package/dist/core/transport/load-history.d.ts +39 -0
  23. package/dist/core/transport/pipe-stream.d.ts +9 -9
  24. package/dist/core/transport/run-manager.d.ts +78 -0
  25. package/dist/core/transport/tree.d.ts +373 -109
  26. package/dist/core/transport/types/agent.d.ts +353 -0
  27. package/dist/core/transport/types/client.d.ts +168 -0
  28. package/dist/core/transport/types/shared.d.ts +24 -0
  29. package/dist/core/transport/types/tree.d.ts +315 -0
  30. package/dist/core/transport/types/view.d.ts +222 -0
  31. package/dist/core/transport/types.d.ts +13 -553
  32. package/dist/core/transport/view.d.ts +272 -84
  33. package/dist/errors.d.ts +21 -10
  34. package/dist/index.d.ts +6 -8
  35. package/dist/logger.d.ts +12 -0
  36. package/dist/react/ably-ai-transport-react.js +976 -990
  37. package/dist/react/ably-ai-transport-react.js.map +1 -1
  38. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  39. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  40. package/dist/react/contexts/client-session-context.d.ts +36 -0
  41. package/dist/react/contexts/client-session-provider.d.ts +53 -0
  42. package/dist/react/create-session-hooks.d.ts +116 -0
  43. package/dist/react/index.d.ts +12 -12
  44. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  45. package/dist/react/use-ably-messages.d.ts +17 -14
  46. package/dist/react/use-client-session.d.ts +81 -0
  47. package/dist/react/use-create-view.d.ts +14 -13
  48. package/dist/react/use-tree.d.ts +30 -15
  49. package/dist/react/use-view.d.ts +82 -51
  50. package/dist/utils.d.ts +32 -23
  51. package/dist/vercel/ably-ai-transport-vercel.js +2573 -2086
  52. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  53. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  55. package/dist/vercel/codec/decoder.d.ts +5 -18
  56. package/dist/vercel/codec/encoder.d.ts +6 -36
  57. package/dist/vercel/codec/events.d.ts +51 -0
  58. package/dist/vercel/codec/index.d.ts +24 -12
  59. package/dist/vercel/codec/reducer.d.ts +144 -0
  60. package/dist/vercel/codec/tool-transitions.d.ts +2 -2
  61. package/dist/vercel/index.d.ts +4 -5
  62. package/dist/vercel/react/ably-ai-transport-vercel-react.js +3907 -3266
  63. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  64. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +33 -8
  65. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  66. package/dist/vercel/react/contexts/chat-transport-context.d.ts +7 -6
  67. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
  68. package/dist/vercel/react/index.d.ts +1 -2
  69. package/dist/vercel/react/use-chat-transport.d.ts +30 -26
  70. package/dist/vercel/react/use-message-sync.d.ts +17 -30
  71. package/dist/vercel/run-end-reason.d.ts +29 -0
  72. package/dist/vercel/transport/chat-transport.d.ts +43 -24
  73. package/dist/vercel/transport/index.d.ts +25 -21
  74. package/dist/vercel/transport/run-output-stream.d.ts +56 -0
  75. package/dist/version.d.ts +2 -0
  76. package/package.json +30 -23
  77. package/src/constants.ts +124 -51
  78. package/src/core/agent.ts +68 -0
  79. package/src/core/codec/decoder.ts +71 -98
  80. package/src/core/codec/encoder.ts +113 -65
  81. package/src/core/codec/index.ts +13 -6
  82. package/src/core/codec/lifecycle-tracker.ts +10 -9
  83. package/src/core/codec/types.ts +436 -120
  84. package/src/core/transport/agent-session.ts +1344 -0
  85. package/src/core/transport/branch-chain.ts +58 -0
  86. package/src/core/transport/client-session.ts +775 -0
  87. package/src/core/transport/decode-fold.ts +91 -0
  88. package/src/core/transport/headers.ts +181 -22
  89. package/src/core/transport/index.ts +25 -26
  90. package/src/core/transport/internal/bounded-map.ts +27 -0
  91. package/src/core/transport/invocation.ts +98 -0
  92. package/src/core/transport/load-conversation.ts +355 -0
  93. package/src/core/transport/load-history.ts +269 -0
  94. package/src/core/transport/pipe-stream.ts +54 -39
  95. package/src/core/transport/run-manager.ts +249 -0
  96. package/src/core/transport/tree.ts +926 -308
  97. package/src/core/transport/types/agent.ts +407 -0
  98. package/src/core/transport/types/client.ts +211 -0
  99. package/src/core/transport/types/shared.ts +27 -0
  100. package/src/core/transport/types/tree.ts +344 -0
  101. package/src/core/transport/types/view.ts +259 -0
  102. package/src/core/transport/types.ts +13 -706
  103. package/src/core/transport/view.ts +864 -433
  104. package/src/errors.ts +22 -9
  105. package/src/event-emitter.ts +3 -2
  106. package/src/index.ts +52 -41
  107. package/src/logger.ts +14 -1
  108. package/src/react/contexts/client-session-context.ts +41 -0
  109. package/src/react/contexts/client-session-provider.tsx +186 -0
  110. package/src/react/create-session-hooks.ts +141 -0
  111. package/src/react/index.ts +23 -13
  112. package/src/react/internal/use-resolved-session.ts +63 -0
  113. package/src/react/use-ably-messages.ts +32 -22
  114. package/src/react/use-client-session.ts +201 -0
  115. package/src/react/use-create-view.ts +33 -29
  116. package/src/react/use-tree.ts +61 -30
  117. package/src/react/use-view.ts +139 -97
  118. package/src/utils.ts +63 -45
  119. package/src/vercel/codec/decoder.ts +336 -258
  120. package/src/vercel/codec/encoder.ts +343 -205
  121. package/src/vercel/codec/events.ts +87 -0
  122. package/src/vercel/codec/index.ts +60 -13
  123. package/src/vercel/codec/reducer.ts +977 -0
  124. package/src/vercel/codec/tool-transitions.ts +2 -2
  125. package/src/vercel/index.ts +6 -19
  126. package/src/vercel/react/contexts/chat-transport-context.ts +7 -6
  127. package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
  128. package/src/vercel/react/index.ts +3 -5
  129. package/src/vercel/react/use-chat-transport.ts +47 -49
  130. package/src/vercel/react/use-message-sync.ts +80 -39
  131. package/src/vercel/run-end-reason.ts +78 -0
  132. package/src/vercel/transport/chat-transport.ts +392 -98
  133. package/src/vercel/transport/index.ts +39 -38
  134. package/src/vercel/transport/run-output-stream.ts +170 -0
  135. package/src/version.ts +2 -0
  136. package/dist/core/transport/client-transport.d.ts +0 -10
  137. package/dist/core/transport/decode-history.d.ts +0 -43
  138. package/dist/core/transport/server-transport.d.ts +0 -7
  139. package/dist/core/transport/stream-router.d.ts +0 -29
  140. package/dist/core/transport/turn-manager.d.ts +0 -37
  141. package/dist/react/contexts/transport-context.d.ts +0 -31
  142. package/dist/react/contexts/transport-provider.d.ts +0 -49
  143. package/dist/react/create-transport-hooks.d.ts +0 -124
  144. package/dist/react/use-active-turns.d.ts +0 -12
  145. package/dist/react/use-client-transport.d.ts +0 -80
  146. package/dist/vercel/codec/accumulator.d.ts +0 -21
  147. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
  148. package/dist/vercel/tool-approvals.d.ts +0 -124
  149. package/dist/vercel/tool-events.d.ts +0 -26
  150. package/src/core/transport/client-transport.ts +0 -977
  151. package/src/core/transport/decode-history.ts +0 -485
  152. package/src/core/transport/server-transport.ts +0 -612
  153. package/src/core/transport/stream-router.ts +0 -136
  154. package/src/core/transport/turn-manager.ts +0 -165
  155. package/src/react/contexts/transport-context.ts +0 -37
  156. package/src/react/contexts/transport-provider.tsx +0 -164
  157. package/src/react/create-transport-hooks.ts +0 -144
  158. package/src/react/use-active-turns.ts +0 -72
  159. package/src/react/use-client-transport.ts +0 -197
  160. package/src/vercel/codec/accumulator.ts +0 -588
  161. package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
  162. package/src/vercel/tool-approvals.ts +0 -380
  163. package/src/vercel/tool-events.ts +0 -53
@@ -1,35 +1,44 @@
1
1
  /**
2
2
  * DefaultView — a paginated, branch-aware projection over the Tree.
3
3
  *
4
- * Wraps a Tree and manages a pagination window that controls which nodes
5
- * are visible to the UI. New live messages appear immediately; older messages
6
- * are revealed progressively via `loadOlder()`.
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.
7
12
  *
8
13
  * Each View owns its own branch selection state and pagination window,
9
14
  * allowing multiple independent Views over the same Tree.
10
15
  *
11
16
  * Events are scoped to the visible window — 'update' only fires when the
12
17
  * visible output changes, 'ably-message' only for messages corresponding to
13
- * visible nodes, and 'turn' only for turns with visible messages.
18
+ * visible Runs, and 'run' only for runs with visible content.
14
19
  */
15
20
 
16
21
  import * as Ably from 'ably';
17
22
 
18
- import { EVENT_TURN_END, EVENT_TURN_START, HEADER_MSG_ID, HEADER_TURN_ID } from '../../constants.js';
23
+ import { HEADER_CODEC_MESSAGE_ID, HEADER_RUN_ID } from '../../constants.js';
19
24
  import { ErrorCode } from '../../errors.js';
20
25
  import { EventEmitter } from '../../event-emitter.js';
21
26
  import type { Logger } from '../../logger.js';
22
- import { getHeaders } from '../../utils.js';
23
- import type { Codec } from '../codec/types.js';
24
- import { decodeHistory } from './decode-history.js';
25
- import type { TreeInternal } from './tree.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';
26
32
  import type {
27
- ActiveTurn,
28
- EventsNode,
33
+ ActiveRun,
34
+ BranchSelection,
35
+ ConversationNode,
29
36
  HistoryPage,
30
- MessageNode,
37
+ OutputEvent,
38
+ RunInfo,
39
+ RunLifecycleEvent,
40
+ RunNode,
31
41
  SendOptions,
32
- TurnLifecycleEvent,
33
42
  View,
34
43
  } from './types.js';
35
44
 
@@ -40,7 +49,7 @@ import type {
40
49
  interface ViewEventsMap {
41
50
  update: undefined;
42
51
  'ably-message': Ably.InboundMessage;
43
- turn: TurnLifecycleEvent;
52
+ run: RunLifecycleEvent;
44
53
  }
45
54
 
46
55
  // ---------------------------------------------------------------------------
@@ -48,33 +57,40 @@ interface ViewEventsMap {
48
57
  // ---------------------------------------------------------------------------
49
58
 
50
59
  /**
51
- * Internal delegate function provided by the transport for executing sends.
52
- * The View pre-computes the visible branch history and passes it directly,
53
- * so the delegate has no back-reference to the View.
54
- * When `eventNodes` is provided, the transport includes them in the POST body
55
- * for the server to publish as cross-turn events.
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.
56
73
  */
57
- export type SendDelegate<TEvent, TMessage> = (
58
- input: TMessage | TMessage[],
74
+ export type SendDelegate<TInput extends CodecInputEvent> = (
75
+ input: TInput[],
59
76
  options: SendOptions | undefined,
60
- history: MessageNode<TMessage>[],
61
- eventNodes?: EventsNode<TEvent>[],
62
- ) => Promise<ActiveTurn<TEvent>>;
77
+ parentCodecMessageId: string | undefined,
78
+ ) => Promise<ActiveRun>;
63
79
 
64
80
  // ---------------------------------------------------------------------------
65
81
  // Options
66
82
  // ---------------------------------------------------------------------------
67
83
 
68
84
  /** Options for creating a View. */
69
- export interface ViewOptions<TEvent, TMessage> {
85
+ export interface ViewOptions<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage> {
70
86
  /** The tree to project. */
71
- tree: TreeInternal<TMessage>;
87
+ tree: TreeInternal<TInput, TOutput, TProjection>;
72
88
  /** The Ably channel to load history from. */
73
89
  channel: Ably.RealtimeChannel;
74
- /** The codec for decoding history messages. */
75
- codec: Codec<TEvent, TMessage>;
76
- /** Delegate for executing sends through the transport. */
77
- sendDelegate: SendDelegate<TEvent, TMessage>;
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>;
78
94
  /** Logger for diagnostic output. */
79
95
  logger: Logger;
80
96
  /** Called when the view is closed, allowing the owner to clean up references. */
@@ -86,80 +102,178 @@ export interface ViewOptions<TEvent, TMessage> {
86
102
  // ---------------------------------------------------------------------------
87
103
 
88
104
  /**
89
- * Tagged union representing why a branch was selected.
90
- * Stored per group root in the View's `_branchSelections` map.
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)`.
91
109
  */
92
- type BranchSelection =
93
- /** Explicit navigation via `select()`. */
94
- | { kind: 'user'; selectedId: string }
95
- /** This view initiated a fork (edit or regenerate) — auto-selected the result. */
96
- | { kind: 'auto'; selectedId: string }
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 }
97
115
  /** An external fork appeared — pinned to the currently-visible sibling to prevent drift. */
98
- | { kind: 'pinned'; selectedId: string }
99
- /** This view's `regenerate()` is in flight — select newest when turn's response arrives. */
100
- | { kind: 'pending'; turnId: string };
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
+ });
101
194
 
102
195
  // ---------------------------------------------------------------------------
103
196
  // Implementation
104
197
  // ---------------------------------------------------------------------------
105
198
 
106
- export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
107
- private readonly _tree: TreeInternal<TMessage>;
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>;
108
206
  private readonly _channel: Ably.RealtimeChannel;
109
- private readonly _codec: Codec<TEvent, TMessage>;
110
- private readonly _sendDelegate: SendDelegate<TEvent, TMessage>;
207
+ private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
208
+ private readonly _sendDelegate: SendDelegate<TInput>;
111
209
  private readonly _logger: Logger;
112
210
  private readonly _emitter: EventEmitter<ViewEventsMap>;
113
211
  private readonly _onClose?: () => void;
114
212
 
115
213
  /**
116
- * View-local branch selections: group root msgId → selection intent.
214
+ * View-local branch selections: group-root runId → selection intent.
117
215
  * Fork points not present here default to the latest sibling.
118
- * Replaces the previous numeric-index _selections and _pendingForkSelections
119
- * with a single tagged-union map that carries the selected msgId (not index)
120
- * and the reason for the selection.
121
216
  */
122
- private readonly _branchSelections = new Map<string, BranchSelection>();
217
+ private readonly _branchSelections = new Map<string, BranchSelectionState>();
123
218
 
124
- /** Spec: AIT-CT11c — msg-ids loaded from history but not yet revealed to the UI. */
125
- private readonly _withheldMsgIds = new Set<string>();
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>();
126
229
 
127
- /** Snapshot of visible msgIds used to detect structural changes and for selection pinning. */
128
- private _lastVisibleIds: string[] = [];
230
+ /** Spec: AIT-CT11c runIds loaded from history but not yet revealed to the UI. */
231
+ private readonly _withheldRunIds = new Set<string>();
129
232
 
130
- /** Snapshot of visible message references — used to detect in-place content updates (streaming). */
131
- private _lastVisibleMessages: TMessage[] = [];
233
+ /** Snapshot of visible node keys — used to detect structural changes and for selection pinning. */
234
+ private _lastVisibleNodeKeys: string[] = [];
132
235
 
133
- /** Cached set of turn IDs present on the visible branch — avoids recomputing flattenNodes() on turn events. */
134
- private _lastVisibleTurnIds = new Set<string>();
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>();
135
251
 
136
252
  /** Whether there are more history pages to fetch from the channel. */
137
253
  private _hasMoreHistory = false;
138
254
 
139
255
  /** Internal state for continuing history pagination. */
140
- private _lastHistoryPage: HistoryPage<TMessage> | undefined;
256
+ private _lastHistoryPage: HistoryPage | undefined;
141
257
 
142
- /** Buffer of withheld nodes, drained newest-first by successive loadOlder() calls. */
143
- private readonly _withheldBuffer: MessageNode<TMessage>[] = [];
258
+ /** Buffer of withheld nodes (input + reply), drained newest-first by successive loadOlder() calls. */
259
+ private readonly _withheldBuffer: ConversationNode<TProjection>[] = [];
144
260
 
145
261
  /** Unsubscribe functions for tree event subscriptions. */
146
262
  private readonly _unsubs: (() => void)[] = [];
147
263
 
148
264
  /**
149
- * Cached result of the last flattenNodes computation. Public `flattenNodes()`
150
- * returns this in O(1); internal callers use `_computeFlatNodes()` when a
151
- * fresh tree walk is needed (structural changes, selection changes, history reveal).
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.
152
269
  */
153
- private _cachedNodes: MessageNode<TMessage>[] = [];
154
-
155
- /** Last seen tree structural version - used to distinguish content-only from structural updates. */
156
- private _lastStructuralVersion = -1;
270
+ private _cachedNodes: ConversationNode<TProjection>[] = [];
157
271
 
158
272
  private _loadingOlder = false;
159
273
  private _processingHistory = false;
160
274
  private _closed = false;
161
275
 
162
- constructor(options: ViewOptions<TEvent, TMessage>) {
276
+ constructor(options: ViewOptions<TInput, TOutput, TProjection, TMessage>) {
163
277
  this._tree = options.tree;
164
278
  this._channel = options.channel;
165
279
  this._codec = options.codec;
@@ -171,7 +285,6 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
171
285
 
172
286
  // Compute initial cache and snapshot visible state
173
287
  this._cachedNodes = this._computeFlatNodes();
174
- this._lastStructuralVersion = this._tree.structuralVersion;
175
288
  this._updateVisibleSnapshot(this._cachedNodes);
176
289
 
177
290
  // Subscribe to tree events and re-emit scoped versions
@@ -182,79 +295,193 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
182
295
  this._tree.on('ably-message', (msg) => {
183
296
  this._onTreeAblyMessage(msg);
184
297
  }),
185
- this._tree.on('turn', (event) => {
186
- this._onTreeTurn(event);
298
+ this._tree.on('run', (event) => {
299
+ this._onTreeRun(event);
300
+ }),
301
+ this._tree.on('output', (event) => {
302
+ this._onTreeOutput(event);
187
303
  }),
188
304
  );
189
305
  }
190
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
+
191
337
  // -------------------------------------------------------------------------
192
338
  // Public query methods
193
339
  // -------------------------------------------------------------------------
194
340
 
195
- getMessages(): TMessage[] {
196
- return this.flattenNodes().map((n) => n.message);
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
+ }
197
391
  }
198
392
 
199
- // Spec: AIT-CT9, AIT-CT11c
200
- flattenNodes(): MessageNode<TMessage>[] {
201
- return this._cachedNodes;
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;
202
404
  }
203
405
 
204
406
  /**
205
- * Walk the tree and compute a fresh visible node list, applying branch
206
- * selections and withheld-message filtering. Use this instead of the
207
- * public `flattenNodes()` when the cache may be stale (structural
208
- * changes, selection changes, history reveal).
209
- * @returns A fresh array of visible nodes.
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.
210
421
  */
211
- private _computeFlatNodes(): MessageNode<TMessage>[] {
212
- const nodes = this._tree.flattenNodes(this._resolveSelections());
213
- if (this._withheldMsgIds.size === 0) return nodes;
214
- return nodes.filter((n) => !this._withheldMsgIds.has(n.msgId));
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;
215
430
  }
216
431
 
217
432
  hasOlder(): boolean {
218
433
  return this._withheldBuffer.length > 0 || this._hasMoreHistory;
219
434
  }
220
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
+ */
221
448
  async loadOlder(limit = 100): Promise<void> {
222
449
  if (this._closed || this._loadingOlder) return;
223
450
  this._loadingOlder = true;
224
451
  this._logger.trace('DefaultView.loadOlder();', { limit });
225
452
 
226
453
  try {
227
- // Drain withheld buffer first (older messages, released newest-first)
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.
228
458
  if (this._withheldBuffer.length > 0) {
229
459
  const batch = this._withheldBuffer.splice(-limit, limit);
230
460
  this._releaseWithheld(batch);
231
461
  return;
232
462
  }
233
463
 
234
- // Buffer exhausted load from channel history
464
+ // Buffer exhausted - load from channel history.
235
465
  if (!this._hasMoreHistory && !this._lastHistoryPage) {
236
- // First load
237
466
  await this._loadFirstPage(limit);
238
467
  return;
239
468
  }
240
469
 
241
470
  if (!this._hasMoreHistory) return;
242
471
 
243
- // Continue from last page
244
472
  if (!this._lastHistoryPage?.hasNext()) {
245
473
  this._hasMoreHistory = false;
246
474
  return;
247
475
  }
248
476
 
249
477
  const nextPage = await this._lastHistoryPage.next();
250
- // Re-check: close() may be called during the await from another call stack
251
478
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
252
479
  if (this._closed || !nextPage) {
253
480
  if (!nextPage) this._hasMoreHistory = false;
254
481
  return;
255
482
  }
256
483
 
257
- await this._loadAndReveal(nextPage, limit);
484
+ await this._revealFromPage(nextPage, limit);
258
485
  } catch (error) {
259
486
  this._logger.error('DefaultView.loadOlder(); failed', { error });
260
487
  throw error;
@@ -264,47 +491,201 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
264
491
  }
265
492
 
266
493
  // -------------------------------------------------------------------------
267
- // Branch navigation
494
+ // Run lookup
268
495
  // -------------------------------------------------------------------------
269
496
 
270
- // Spec: AIT-CT13c
271
- select(msgId: string, index: number): void {
272
- this._logger.trace('DefaultView.select();', { msgId, index });
273
- const nodes = this._tree.getSiblingNodes(msgId);
274
- if (nodes.length <= 1) return;
275
- const groupRootId = this._tree.getGroupRoot(msgId);
276
- const clamped = Math.max(0, Math.min(index, nodes.length - 1));
277
- const selected = nodes[clamped];
278
- if (!selected) return; // unreachable: clamped is always in bounds
279
- this._branchSelections.set(groupRootId, { kind: 'user', selectedId: selected.msgId });
280
- this._logger.debug('DefaultView.select();', { msgId, index: clamped, selectedId: selected.msgId });
281
- this._cachedNodes = this._computeFlatNodes();
282
- this._updateVisibleSnapshot(this._cachedNodes);
283
- this._emitter.emit('update');
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;
284
505
  }
285
506
 
286
- getSelectedIndex(msgId: string): number {
287
- this._logger.trace('DefaultView.getSelectedIndex();', { msgId });
288
- const nodes = this._tree.getSiblingNodes(msgId);
289
- if (nodes.length <= 1) return 0;
290
- const groupRootId = this._tree.getGroupRoot(msgId);
291
- const sel = this._branchSelections.get(groupRootId);
292
- if (!sel || sel.kind === 'pending') return nodes.length - 1; // default: latest
293
- const idx = nodes.findIndex((n) => n.msgId === sel.selectedId);
294
- if (idx === -1) return nodes.length - 1; // fallback if stale
295
- return idx;
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);
296
529
  }
297
530
 
298
- getSiblings(msgId: string): TMessage[] {
299
- return this._tree.getSiblings(msgId);
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;
300
535
  }
301
536
 
302
- hasSiblings(msgId: string): boolean {
303
- return this._tree.hasSiblings(msgId);
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();
304
618
  }
305
619
 
306
- getNode(msgId: string): MessageNode<TMessage> | undefined {
307
- return this._tree.getNode(msgId);
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;
308
689
  }
309
690
 
310
691
  // -------------------------------------------------------------------------
@@ -312,159 +693,217 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
312
693
  // -------------------------------------------------------------------------
313
694
 
314
695
  // Spec: AIT-CT3, AIT-CT4
315
- async send(input: TMessage | TMessage[], options?: SendOptions): Promise<ActiveTurn<TEvent>> {
696
+ async send(input: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
316
697
  this._logger.trace('DefaultView.send();');
317
698
  if (this._closed) {
318
699
  throw new Ably.ErrorInfo('unable to send; view is closed', ErrorCode.InvalidArgument, 400);
319
700
  }
320
701
 
321
- // Pre-compute visible branch history before the delegate call so the
322
- // transport has no back-reference to the View (one-way dependency).
323
- const history = this.flattenNodes();
324
- const result = await this._sendDelegate(input, options, history);
702
+ const normalised = _normaliseSend<TInput>(input);
325
703
 
326
- // Spec: AIT-CT13e
327
- // Auto-select the new fork in this view when creating a fork.
328
- if (options?.forkOf) {
329
- const groupRoot = this._tree.getGroupRoot(options.forkOf);
330
-
331
- if (result.optimisticMsgIds.length > 0) {
332
- // The delegate optimistically inserted user messages (edit path).
333
- // Auto-select the last optimistic msgId — this is deterministic and
334
- // avoids the sibling-count race that exists when inferring from tree state.
335
- const lastMsgId = result.optimisticMsgIds.at(-1);
336
- if (lastMsgId) {
337
- this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: lastMsgId });
338
- this._cachedNodes = this._computeFlatNodes();
339
- this._updateVisibleSnapshot(this._cachedNodes);
340
- this._emitter.emit('update');
341
- }
342
- } else {
343
- // No optimistic insert (e.g. regenerate sends no user messages). Defer
344
- // auto-selection until the server response creates the new sibling.
345
- // Store the group root (not the raw forkOf) so _pinBranchSelections
346
- // can match it regardless of which sibling is currently visible.
347
- this._branchSelections.set(groupRoot, { kind: 'pending', turnId: result.turnId });
348
- this._logger.debug('DefaultView.send(); deferring fork auto-selection', {
349
- forkOf: options.forkOf,
350
- groupRoot,
351
- turnId: result.turnId,
352
- });
353
-
354
- // Bound pending entry lifetime to the turn — clean up on turn-end.
355
- const turnUnsub = this._tree.on('turn', (evt) => {
356
- if (evt.type !== EVENT_TURN_END || evt.turnId !== result.turnId) return;
357
- const sel = this._branchSelections.get(groupRoot);
358
- if (sel?.kind === 'pending' && sel.turnId === result.turnId) {
359
- this._branchSelections.delete(groupRoot);
360
- }
361
- turnUnsub();
362
- const idx = this._unsubs.indexOf(turnUnsub);
363
- if (idx !== -1) this._unsubs.splice(idx, 1);
364
- });
365
- this._unsubs.push(turnUnsub);
366
- }
367
- }
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;
368
707
 
708
+ const result = await this._sendDelegate(normalised, options, parentCodecMessageId);
709
+ this._applyForkAutoSelect(result, options);
369
710
  return result;
370
711
  }
371
712
 
372
- // Spec: AIT-CT5
373
- async regenerate(messageId: string, options?: SendOptions): Promise<ActiveTurn<TEvent>> {
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> {
374
776
  this._logger.trace('DefaultView.regenerate();', { messageId });
375
777
 
376
- const node = this._tree.getNode(messageId);
377
- if (!node) {
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) {
378
791
  throw new Ably.ErrorInfo(
379
792
  `unable to regenerate; message not found in tree: ${messageId}`,
380
793
  ErrorCode.InvalidArgument,
381
794
  400,
382
795
  );
383
796
  }
384
- const parentId = node.parentId;
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
+ }
385
824
 
386
- return this.send([], {
825
+ const sendOptions: SendOptions = {
387
826
  ...options,
388
- body: {
389
- history: this._getHistoryBefore(messageId),
390
- ...options?.body,
391
- },
392
- forkOf: messageId,
393
- parent: parentId,
394
- });
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;
395
840
  }
396
841
 
397
842
  // Spec: AIT-CT6
398
- async edit(
399
- messageId: string,
400
- newMessages: TMessage | TMessage[],
401
- options?: SendOptions,
402
- ): Promise<ActiveTurn<TEvent>> {
843
+ async edit(messageId: string, inputs: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
403
844
  this._logger.trace('DefaultView.edit();', { messageId });
404
845
 
405
- const node = this._tree.getNode(messageId);
406
- if (!node) {
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) {
407
854
  throw new Ably.ErrorInfo(
408
855
  `unable to edit; message not found in tree: ${messageId}`,
409
856
  ErrorCode.InvalidArgument,
410
857
  400,
411
858
  );
412
859
  }
413
- const parentId = node.parentId;
860
+ const parentCodecMessageId = this._findParentMsgId(targetNode, messageId);
414
861
 
415
- return this.send(newMessages, {
862
+ return this.send(inputs, {
416
863
  ...options,
417
- body: {
418
- history: this._getHistoryBefore(messageId),
419
- ...options?.body,
420
- },
421
864
  forkOf: messageId,
422
- parent: parentId,
865
+ parent: parentCodecMessageId,
423
866
  });
424
867
  }
425
868
 
426
- async update(msgId: string, events: TEvent[], options?: SendOptions): Promise<ActiveTurn<TEvent>> {
427
- if (this._closed) {
428
- throw new Ably.ErrorInfo('unable to update; view is closed', ErrorCode.InvalidArgument, 400);
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;
429
890
  }
430
- this._logger.trace('DefaultView.update();', { msgId, eventCount: events.length });
431
- const eventNodes: EventsNode<TEvent>[] = [{ kind: 'event', msgId, events }];
432
- return this._sendDelegate([], options, this.flattenNodes(), eventNodes);
433
- }
891
+ if (visIdx === 0) return undefined;
434
892
 
435
- private _getHistoryBefore(messageId: string): MessageNode<TMessage>[] {
436
- this._logger.trace('DefaultView._getHistoryBefore();', { messageId });
437
- const all = this.flattenNodes();
438
- const idx = all.findIndex((n) => n.msgId === messageId);
439
- if (idx === -1) {
440
- this._logger.warn('DefaultView._getHistoryBefore(); target not in visible nodes, returning full list', {
441
- messageId,
442
- });
443
- return all;
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;
444
897
  }
445
- return all.slice(0, idx);
446
- }
447
-
448
- // -------------------------------------------------------------------------
449
- // Observation
450
- // -------------------------------------------------------------------------
451
-
452
- // Spec: AIT-CT17
453
- getActiveTurnIds(): Map<string, Set<string>> {
454
- this._logger.trace('DefaultView.getActiveTurnIds();');
455
- const allTurns = this._tree.getActiveTurnIds();
456
- if (this._withheldMsgIds.size === 0) return allTurns;
457
-
458
- // Filter to turns that have at least one visible message
459
- const result = new Map<string, Set<string>>();
460
- for (const [clientId, turnIds] of allTurns) {
461
- const filtered = new Set<string>();
462
- for (const turnId of turnIds) {
463
- if (this._lastVisibleTurnIds.has(turnId)) filtered.add(turnId);
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;
464
904
  }
465
- if (filtered.size > 0) result.set(clientId, filtered);
466
905
  }
467
- return result;
906
+ return undefined;
468
907
  }
469
908
 
470
909
  // -------------------------------------------------------------------------
@@ -474,10 +913,10 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
474
913
  // Spec: AIT-CT8a, AIT-CT8b, AIT-CT8e
475
914
  on(event: 'update', handler: () => void): () => void;
476
915
  on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
477
- on(event: 'turn', handler: (event: TurnLifecycleEvent) => void): () => void;
916
+ on(event: 'run', handler: (event: RunLifecycleEvent) => void): () => void;
478
917
  on(
479
- event: 'update' | 'ably-message' | 'turn',
480
- handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: TurnLifecycleEvent) => void),
918
+ event: 'update' | 'ably-message' | 'run',
919
+ handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: RunLifecycleEvent) => void),
481
920
  ): () => void {
482
921
  // CAST: overload signatures enforce correct handler types per event name.
483
922
  const cb = handler as (arg: ViewEventsMap[keyof ViewEventsMap]) => void;
@@ -491,10 +930,8 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
491
930
  // Lifecycle
492
931
  // -------------------------------------------------------------------------
493
932
 
494
- /**
495
- * Tear down the view — unsubscribe from tree events.
496
- */
497
933
  close(): void {
934
+ if (this._closed) return;
498
935
  this._logger.info('DefaultView.close();');
499
936
  this._closed = true;
500
937
  this._loadingOlder = false;
@@ -502,7 +939,8 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
502
939
  this._unsubs.length = 0;
503
940
  this._emitter.off();
504
941
  this._branchSelections.clear();
505
- this._withheldMsgIds.clear();
942
+ this._regenSelections.clear();
943
+ this._withheldRunIds.clear();
506
944
  this._withheldBuffer.length = 0;
507
945
  this._onClose?.();
508
946
  }
@@ -512,62 +950,84 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
512
950
  // -------------------------------------------------------------------------
513
951
 
514
952
  private async _loadFirstPage(limit: number): Promise<void> {
515
- // Snapshot before loading everything already in the tree stays visible
516
- const beforeMsgIds = new Set(this._tree.flattenNodes(this._resolveSelections()).map((n) => n.msgId));
517
-
518
- const firstPage = await decodeHistory(this._channel, this._codec, { limit }, this._logger);
519
- if (this._closed) return;
520
- const { newVisible, lastPage } = await this._loadUntilVisible(firstPage, limit, beforeMsgIds);
521
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
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);
522
957
  if (this._closed) return;
523
-
524
- this._lastHistoryPage = lastPage;
525
- this._hasMoreHistory = lastPage.hasNext();
526
-
527
- // Split into withheld (older, kept hidden) and released (newest, shown now).
528
- // Only add the actually-withheld messages to the set — adding all then
529
- // releasing would cause a spurious empty-list update if a tree event fires
530
- // between the two operations.
531
- const released = newVisible.slice(-limit);
532
- const withheld = newVisible.slice(0, -limit);
533
- for (const n of withheld) {
534
- this._withheldMsgIds.add(n.msgId);
535
- }
536
- this._withheldBuffer.push(...withheld);
537
- this._releaseWithheld(released);
958
+ await this._revealFromPage(firstPage, limit);
538
959
  }
539
960
 
540
- private async _loadAndReveal(page: HistoryPage<TMessage>, limit: number): Promise<void> {
541
- // Everything currently in the tree is "already known"
542
- const alreadyKnown = new Set(this._tree.flattenNodes(this._resolveSelections()).map((n) => n.msgId));
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)));
543
973
 
544
- const { newVisible, lastPage } = await this._loadUntilVisible(page, limit, alreadyKnown);
974
+ const { newVisible, lastPage } = await this._loadUntilVisible(page, limit, beforeRunIds);
545
975
  if (this._closed) return;
546
976
  this._lastHistoryPage = lastPage;
547
977
  this._hasMoreHistory = lastPage.hasNext();
978
+ this._splitReveal(newVisible, limit);
979
+ }
548
980
 
549
- // Release the newest `limit` items; rest stays in buffer.
550
- // Only add actually-withheld messages to the set adding all then
551
- // releasing would cause a spurious empty-list update if a tree event
552
- // fires between the two operations.
553
- const batch = newVisible.slice(-limit);
554
- const withheld = newVisible.slice(0, -limit);
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);
555
1005
  for (const n of withheld) {
556
- this._withheldMsgIds.add(n.msgId);
1006
+ this._withheldRunIds.add(nodeKey(n));
557
1007
  }
558
1008
  this._withheldBuffer.push(...withheld);
559
1009
  this._releaseWithheld(batch);
560
1010
  }
561
1011
 
562
- private _processHistoryPage(page: HistoryPage<TMessage>): void {
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 {
563
1020
  this._processingHistory = true;
564
1021
  try {
565
- for (const item of page.items) {
566
- const msgId = item.headers[HEADER_MSG_ID];
567
- if (!msgId) continue;
568
- this._tree.upsert(msgId, item.message, item.headers, item.serial);
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);
569
1027
  }
570
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.
571
1031
  for (const msg of page.rawMessages) {
572
1032
  this._tree.emitAblyMessage(msg);
573
1033
  }
@@ -577,17 +1037,19 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
577
1037
  }
578
1038
 
579
1039
  private async _loadUntilVisible(
580
- firstPage: HistoryPage<TMessage>,
1040
+ firstPage: HistoryPage,
581
1041
  target: number,
582
- beforeMsgIds: Set<string>,
583
- ): Promise<{ newVisible: MessageNode<TMessage>[]; lastPage: HistoryPage<TMessage> }> {
1042
+ beforeRunIds: Set<string>,
1043
+ ): Promise<{ newVisible: ConversationNode<TProjection>[]; lastPage: HistoryPage }> {
584
1044
  this._processHistoryPage(firstPage);
585
1045
  let page = firstPage;
586
1046
 
587
1047
  const newVisibleCount = (): number => {
588
1048
  let count = 0;
589
- for (const n of this._tree.flattenNodes(this._resolveSelections())) {
590
- if (!beforeMsgIds.has(n.msgId)) count++;
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++;
591
1053
  }
592
1054
  return count;
593
1055
  };
@@ -599,19 +1061,17 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
599
1061
  page = nextPage;
600
1062
  }
601
1063
 
602
- const newVisible = this._tree.flattenNodes(this._resolveSelections()).filter((n) => !beforeMsgIds.has(n.msgId));
1064
+ const newVisible = this._treeVisibleNodes().filter((n) => !beforeRunIds.has(nodeKey(n)));
603
1065
  return { newVisible, lastPage: page };
604
1066
  }
605
1067
 
606
1068
  // Spec: AIT-CT11a
607
- private _releaseWithheld(nodes: MessageNode<TMessage>[]): void {
1069
+ private _releaseWithheld(nodes: ConversationNode<TProjection>[]): void {
608
1070
  for (const n of nodes) {
609
- this._withheldMsgIds.delete(n.msgId);
1071
+ this._withheldRunIds.delete(nodeKey(n));
610
1072
  }
611
1073
  if (nodes.length > 0) {
612
- this._cachedNodes = this._computeFlatNodes();
613
- this._updateVisibleSnapshot(this._cachedNodes);
614
- this._emitter.emit('update');
1074
+ this._recomputeAndEmit();
615
1075
  }
616
1076
  }
617
1077
 
@@ -619,209 +1079,179 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
619
1079
  // Private: scoped event forwarding
620
1080
  // -------------------------------------------------------------------------
621
1081
 
622
- private _updateVisibleSnapshot(nodes?: MessageNode<TMessage>[]): void {
623
- const resolved = nodes ?? this.flattenNodes();
624
- this._lastVisibleIds = resolved.map((n) => n.msgId);
625
- this._lastVisibleMessages = resolved.map((n) => n.message);
626
- this._lastVisibleTurnIds = new Set<string>();
627
- for (const n of resolved) {
628
- const turnId = n.headers[HEADER_TURN_ID];
629
- if (turnId) this._lastVisibleTurnIds.add(turnId);
630
- }
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);
631
1090
  }
632
1091
 
633
1092
  private _onTreeUpdate(): void {
634
1093
  // Suppress update forwarding while processing history pages. During
635
- // _processHistoryPage, each tree.upsert() fires this handler synchronously
636
- // — but _withheldMsgIds hasn't been populated yet, so flattenNodes() would
637
- // return unfiltered history. Without this guard, subscribers briefly see all
638
- // history messages before the pagination window is applied. The final update
639
- // is emitted by _releaseWithheld after withholding is set up.
640
- // Scoped to _processingHistory (not _loadingOlder) so that live streaming
641
- // updates arriving during the async history fetch are still forwarded.
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.
642
1100
  if (this._processingHistory) return;
643
1101
 
644
- const currentVersion = this._tree.structuralVersion;
645
-
646
- // Content-only fast path: the tree structure hasn't changed (no new
647
- // nodes, deletions, or serial reorders), so the cached node list is
648
- // still structurally valid. The tree mutated an existing node's
649
- // .message in place - check if any visible message reference changed.
650
- // JS single-threaded: structuralVersion cannot change between the
651
- // check and the response within this synchronous handler invocation.
652
- if (currentVersion === this._lastStructuralVersion) {
653
- const changed = this._cachedNodes.some((node, i) => node.message !== this._lastVisibleMessages[i]);
654
- if (changed) {
655
- this._lastVisibleMessages = this._cachedNodes.map((n) => n.message);
656
- this._cachedNodes = [...this._cachedNodes];
657
- this._emitter.emit('update');
658
- }
659
- return;
660
- }
661
-
662
- // Structural update: full re-walk required.
663
- this._lastStructuralVersion = currentVersion;
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.
664
1106
 
665
- // Pin selections for previously-visible nodes that now have siblings.
1107
+ // Pin selections for previously-visible Runs that now have siblings.
666
1108
  // This prevents new forks (from other views' edits/regenerates) from
667
1109
  // shifting this view to a branch the user didn't navigate to.
668
1110
  this._pinBranchSelections();
669
- this._resolvePendingSelections();
1111
+ this._resolvePendingRegenSelections();
670
1112
 
671
- const nodes = this._computeFlatNodes();
672
- const newIds = nodes.map((n) => n.msgId);
673
- const newMessages = nodes.map((n) => n.message);
674
- if (this._visibleChanged(newIds, newMessages)) {
675
- this._cachedNodes = nodes;
676
- this._updateVisibleSnapshot(nodes);
677
- this._emitter.emit('update');
678
- }
1113
+ this._recomputeAndEmitIfChanged();
679
1114
  }
680
1115
 
681
1116
  /**
682
- * Build a resolved selections map from `_branchSelections` for passing
683
- * to `tree.flattenNodes()`. Pending entries (no sibling yet) are omitted,
684
- * causing the tree to use the default (latest sibling).
685
- * @returns Resolved map of groupRoot selectedMsgId.
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.
686
1123
  */
687
1124
  private _resolveSelections(): Map<string, string> {
688
1125
  const resolved = new Map<string, string>();
689
1126
  for (const [groupRoot, sel] of this._branchSelections) {
1127
+ resolved.set(groupRoot, sel.selectedKey);
1128
+ }
1129
+ for (const [groupRoot, sel] of this._regenSelections) {
690
1130
  if (sel.kind === 'pending') continue;
691
- resolved.set(groupRoot, sel.selectedId);
1131
+ resolved.set(groupRoot, sel.selectedRunId);
692
1132
  }
693
1133
  return resolved;
694
1134
  }
695
1135
 
696
1136
  /**
697
- * For each previously-visible message that now has siblings but no
698
- * explicit selection, pin the selection to that message's msgId.
699
- * This preserves the current branch when new forks appear from
700
- * other views or external sources.
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.
701
1151
  *
702
1152
  * Exception: if the fork was initiated by this view (tracked as a
703
- * `pending` BranchSelection), select the newest sibling instead of
704
- * pinning the old one. This handles regenerate, where no optimistic
705
- * insert was possible at send time.
1153
+ * `pending` BranchSelection), select the newest sibling (the awaited Run)
1154
+ * instead of pinning the old one.
706
1155
  */
707
1156
  private _pinBranchSelections(): void {
708
- for (const msgId of this._lastVisibleIds) {
709
- if (!this._tree.hasSiblings(msgId)) continue;
710
- const groupRoot = this._tree.getGroupRoot(msgId);
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);
711
1165
  const existing = this._branchSelections.get(groupRoot);
712
1166
 
713
- // Spec: AIT-CT13e
714
- // Check if this fork was initiated by this view (e.g. regenerate).
715
- // If so, select the newest sibling — but only if it belongs to the
716
- // pending turn. Without this check, a sibling from another view's
717
- // concurrent fork would be incorrectly auto-selected.
718
- if (existing?.kind === 'pending') {
719
- const nodes = this._tree.getSiblingNodes(msgId);
720
- const newest = nodes.at(-1);
721
- if (newest && newest.msgId !== msgId) {
722
- const newestTurnId = newest.headers[HEADER_TURN_ID];
723
- if (newestTurnId === existing.turnId) {
724
- this._logger.debug('DefaultView._pinBranchSelections(); auto-selecting pending fork', {
725
- msgId,
726
- newestId: newest.msgId,
727
- turnId: existing.turnId,
728
- });
729
- this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: newest.msgId });
730
- }
731
- }
732
- continue;
733
- }
734
-
735
- // Spec: AIT-CT13f
736
- // External fork — pin to the currently-visible sibling.
737
- if (existing) continue; // already have a selection
738
- this._branchSelections.set(groupRoot, { kind: 'pinned', selectedId: msgId });
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 });
739
1171
  }
740
1172
  }
741
1173
 
742
1174
  /**
743
- * Resolve pending selections that are no longer on the visible branch.
744
- * `_pinBranchSelections` only checks visible nodes, so if the user navigated
745
- * away before the server response arrived, the pending entry would linger.
746
- * This pass checks all pending entries against the tree directly.
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.
747
1182
  */
748
- private _resolvePendingSelections(): void {
749
- for (const [groupRoot, sel] of this._branchSelections) {
750
- if (sel.kind !== 'pending') continue;
751
- const nodes = this._tree.getSiblingNodes(groupRoot);
752
- if (nodes.length <= 1) continue;
753
- const newest = nodes.at(-1);
754
- if (!newest || newest.msgId === groupRoot) continue;
755
- const newestTurnId = newest.headers[HEADER_TURN_ID];
756
- if (newestTurnId === sel.turnId) {
757
- this._logger.debug('DefaultView._resolvePendingSelections(); resolving off-branch pending', {
758
- groupRoot,
759
- newestId: newest.msgId,
760
- turnId: sel.turnId,
761
- });
762
- this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: newest.msgId });
763
- }
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 });
764
1191
  }
765
1192
  }
766
1193
 
767
1194
  private _onTreeAblyMessage(msg: Ably.InboundMessage): void {
768
- // Re-emit only if the message corresponds to a visible node
769
- const headers = getHeaders(msg);
770
- const msgId = headers[HEADER_MSG_ID];
771
- if (!msgId) {
772
- // Non-message events (turn-start, turn-end, cancel) — always forward
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.
773
1203
  this._emitter.emit('ably-message', msg);
774
1204
  return;
775
1205
  }
776
- // Check that msgId is on the visible branch and not withheld
777
- if (this._lastVisibleIds.includes(msgId)) {
1206
+
1207
+ if (runId && this._lastVisibleNodeKeySet.has(runId)) {
778
1208
  this._emitter.emit('ably-message', msg);
779
1209
  }
780
1210
  }
781
1211
 
782
- private _onTreeTurn(event: TurnLifecycleEvent): void {
783
- // Check if any messages for this turn are already on the visible branch.
784
- if (this._lastVisibleTurnIds.has(event.turnId)) {
785
- this._emitter.emit('turn', event);
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);
786
1216
  return;
787
1217
  }
788
1218
 
789
- // For turn-start, use branch metadata to predict visibility before
790
- // messages arrive. Own turns have optimistic inserts (caught above).
791
- // Remote turns carry parent/forkOf from the server.
792
- if (event.type === EVENT_TURN_START && this._isTurnStartVisible(event)) {
793
- // Track the predicted turnId so the corresponding turn-end is not
794
- // dropped if it arrives before messages update the snapshot.
795
- this._lastVisibleTurnIds.add(event.turnId);
796
- this._emitter.emit('turn', event);
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);
797
1225
  }
798
1226
  }
799
1227
 
800
1228
  /**
801
- * Predict whether a turn-start's messages will be visible on this view's branch
802
- * using the parent/forkOf metadata from the event.
803
- * @param event - The turn-start lifecycle event with optional branch metadata.
804
- * @returns True if the turn's messages are expected to be visible on this view's branch.
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.
805
1233
  */
806
- private _isTurnStartVisible(event: TurnLifecycleEvent & { type: typeof EVENT_TURN_START }): boolean {
1234
+ private _isRunStartVisible(event: RunLifecycleEvent & { type: 'start' }): boolean {
807
1235
  const { parent } = event;
808
1236
 
809
1237
  // No parent metadata — can't determine branch, forward as default.
810
- // This covers root turns (parent omitted) and backward compat.
811
1238
  if (parent === undefined) return true;
812
1239
 
813
- // Check if the parent is on the visible branch
814
- return this._lastVisibleIds.includes(parent);
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));
815
1248
  }
816
1249
 
817
- private _visibleChanged(newIds: string[], newMessages: TMessage[]): boolean {
818
- if (newIds.length !== this._lastVisibleIds.length) return true;
819
- for (const [i, newId] of newIds.entries()) {
820
- if (newId !== this._lastVisibleIds[i]) return true;
821
- }
822
- // Also detect in-place content updates (e.g. streaming) via reference comparison
823
- for (const [i, msg] of newMessages.entries()) {
824
- if (msg !== this._lastVisibleMessages[i]) return true;
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;
825
1255
  }
826
1256
  return false;
827
1257
  }
@@ -836,5 +1266,6 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
836
1266
  * @param options - The tree, channel, codec, and logger to use.
837
1267
  * @returns A new {@link DefaultView} instance.
838
1268
  */
839
- export const createView = <TEvent, TMessage>(options: ViewOptions<TEvent, TMessage>): DefaultView<TEvent, TMessage> =>
840
- new DefaultView(options);
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);