@ably/ai-transport 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/README.md +93 -111
  2. package/dist/ably-ai-transport.js +2401 -1387
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +116 -42
  7. package/dist/core/agent.d.ts +44 -0
  8. package/dist/core/channel-options.d.ts +57 -0
  9. package/dist/core/codec/codec-event.d.ts +9 -0
  10. package/dist/core/codec/decoder.d.ts +24 -24
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +10 -12
  13. package/dist/core/codec/field-bag.d.ts +85 -0
  14. package/dist/core/codec/fields.d.ts +141 -0
  15. package/dist/core/codec/index.d.ts +8 -2
  16. package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
  17. package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
  18. package/dist/core/codec/input-descriptors.d.ts +281 -0
  19. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  20. package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
  21. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  22. package/dist/core/codec/output-descriptors.d.ts +237 -0
  23. package/dist/core/codec/types.d.ts +470 -119
  24. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  25. package/dist/core/transport/agent-session.d.ts +10 -0
  26. package/dist/core/transport/agent-view.d.ts +296 -0
  27. package/dist/core/transport/client-session.d.ts +13 -0
  28. package/dist/core/transport/decode-fold.d.ts +55 -0
  29. package/dist/core/transport/headers.d.ts +121 -14
  30. package/dist/core/transport/index.d.ts +5 -6
  31. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  32. package/dist/core/transport/invocation.d.ts +74 -0
  33. package/dist/core/transport/load-history-pages.d.ts +71 -0
  34. package/dist/core/transport/load-history.d.ts +44 -0
  35. package/dist/core/transport/pipe-stream.d.ts +9 -9
  36. package/dist/core/transport/run-manager.d.ts +76 -0
  37. package/dist/core/transport/session-support.d.ts +55 -0
  38. package/dist/core/transport/tree.d.ts +523 -109
  39. package/dist/core/transport/types/agent.d.ts +375 -0
  40. package/dist/core/transport/types/client.d.ts +201 -0
  41. package/dist/core/transport/types/shared.d.ts +24 -0
  42. package/dist/core/transport/types/tree.d.ts +357 -0
  43. package/dist/core/transport/types/view.d.ts +249 -0
  44. package/dist/core/transport/types.d.ts +13 -553
  45. package/dist/core/transport/view.d.ts +390 -84
  46. package/dist/core/transport/wire-log.d.ts +102 -0
  47. package/dist/errors.d.ts +27 -10
  48. package/dist/index.d.ts +8 -9
  49. package/dist/logger.d.ts +12 -0
  50. package/dist/react/ably-ai-transport-react.js +1365 -1010
  51. package/dist/react/ably-ai-transport-react.js.map +1 -1
  52. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  53. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  54. package/dist/react/contexts/client-session-context.d.ts +37 -0
  55. package/dist/react/contexts/client-session-provider.d.ts +56 -0
  56. package/dist/react/create-session-hooks.d.ts +116 -0
  57. package/dist/react/index.d.ts +13 -12
  58. package/dist/react/internal/skipped-session.d.ts +8 -0
  59. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  60. package/dist/react/use-ably-messages.d.ts +17 -14
  61. package/dist/react/use-client-session.d.ts +81 -0
  62. package/dist/react/use-create-view.d.ts +14 -13
  63. package/dist/react/use-tree.d.ts +30 -15
  64. package/dist/react/use-view.d.ts +81 -50
  65. package/dist/utils.d.ts +48 -71
  66. package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
  67. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  68. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  69. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  70. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  71. package/dist/vercel/codec/events.d.ts +50 -0
  72. package/dist/vercel/codec/fields.d.ts +44 -0
  73. package/dist/vercel/codec/fold-content.d.ts +16 -0
  74. package/dist/vercel/codec/fold-data.d.ts +16 -0
  75. package/dist/vercel/codec/fold-input.d.ts +67 -0
  76. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  77. package/dist/vercel/codec/fold-text.d.ts +16 -0
  78. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  79. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  80. package/dist/vercel/codec/index.d.ts +7 -20
  81. package/dist/vercel/codec/inputs.d.ts +11 -0
  82. package/dist/vercel/codec/outputs.d.ts +11 -0
  83. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  84. package/dist/vercel/codec/reducer.d.ts +62 -0
  85. package/dist/vercel/codec/tool-transitions.d.ts +2 -8
  86. package/dist/vercel/codec/wire-data.d.ts +34 -0
  87. package/dist/vercel/index.d.ts +5 -5
  88. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
  89. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  90. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -45
  91. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  92. package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
  93. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
  94. package/dist/vercel/react/index.d.ts +1 -2
  95. package/dist/vercel/react/use-chat-transport.d.ts +30 -26
  96. package/dist/vercel/react/use-message-sync.d.ts +17 -30
  97. package/dist/vercel/run-end-reason.d.ts +84 -0
  98. package/dist/vercel/tool-part.d.ts +21 -0
  99. package/dist/vercel/transport/chat-transport.d.ts +41 -24
  100. package/dist/vercel/transport/index.d.ts +24 -20
  101. package/dist/vercel/transport/run-output-stream.d.ts +54 -0
  102. package/dist/version.d.ts +2 -0
  103. package/package.json +31 -24
  104. package/src/constants.ts +124 -51
  105. package/src/core/agent.ts +92 -0
  106. package/src/core/channel-options.ts +89 -0
  107. package/src/core/codec/codec-event.ts +27 -0
  108. package/src/core/codec/decoder.ts +202 -105
  109. package/src/core/codec/define-codec.ts +432 -0
  110. package/src/core/codec/encoder.ts +114 -107
  111. package/src/core/codec/field-bag.ts +142 -0
  112. package/src/core/codec/fields.ts +193 -0
  113. package/src/core/codec/index.ts +56 -6
  114. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  115. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  116. package/src/core/codec/input-descriptors.ts +373 -0
  117. package/src/core/codec/lifecycle-tracker.ts +10 -9
  118. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  119. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  120. package/src/core/codec/output-descriptors.ts +307 -0
  121. package/src/core/codec/types.ts +505 -126
  122. package/src/core/codec/well-known-inputs.ts +96 -0
  123. package/src/core/transport/agent-session.ts +1085 -0
  124. package/src/core/transport/agent-view.ts +738 -0
  125. package/src/core/transport/client-session.ts +780 -0
  126. package/src/core/transport/decode-fold.ts +101 -0
  127. package/src/core/transport/headers.ts +234 -22
  128. package/src/core/transport/index.ts +27 -27
  129. package/src/core/transport/internal/bounded-map.ts +27 -0
  130. package/src/core/transport/invocation.ts +98 -0
  131. package/src/core/transport/load-history-pages.ts +220 -0
  132. package/src/core/transport/load-history.ts +271 -0
  133. package/src/core/transport/pipe-stream.ts +63 -39
  134. package/src/core/transport/run-manager.ts +243 -0
  135. package/src/core/transport/session-support.ts +96 -0
  136. package/src/core/transport/tree.ts +1293 -308
  137. package/src/core/transport/types/agent.ts +434 -0
  138. package/src/core/transport/types/client.ts +247 -0
  139. package/src/core/transport/types/shared.ts +27 -0
  140. package/src/core/transport/types/tree.ts +393 -0
  141. package/src/core/transport/types/view.ts +288 -0
  142. package/src/core/transport/types.ts +13 -706
  143. package/src/core/transport/view.ts +1229 -450
  144. package/src/core/transport/wire-log.ts +189 -0
  145. package/src/errors.ts +29 -9
  146. package/src/event-emitter.ts +3 -2
  147. package/src/index.ts +86 -42
  148. package/src/logger.ts +14 -1
  149. package/src/react/contexts/client-session-context.ts +41 -0
  150. package/src/react/contexts/client-session-provider.tsx +222 -0
  151. package/src/react/create-session-hooks.ts +141 -0
  152. package/src/react/index.ts +24 -13
  153. package/src/react/internal/skipped-session.ts +62 -0
  154. package/src/react/internal/use-resolved-session.ts +63 -0
  155. package/src/react/use-ably-messages.ts +32 -22
  156. package/src/react/use-client-session.ts +178 -0
  157. package/src/react/use-create-view.ts +33 -29
  158. package/src/react/use-tree.ts +61 -30
  159. package/src/react/use-view.ts +138 -96
  160. package/src/utils.ts +83 -131
  161. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  162. package/src/vercel/codec/events.ts +85 -0
  163. package/src/vercel/codec/fields.ts +58 -0
  164. package/src/vercel/codec/fold-content.ts +54 -0
  165. package/src/vercel/codec/fold-data.ts +46 -0
  166. package/src/vercel/codec/fold-input.ts +255 -0
  167. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  168. package/src/vercel/codec/fold-text.ts +55 -0
  169. package/src/vercel/codec/fold-tool-input.ts +86 -0
  170. package/src/vercel/codec/fold-tool-output.ts +79 -0
  171. package/src/vercel/codec/index.ts +28 -21
  172. package/src/vercel/codec/inputs.ts +116 -0
  173. package/src/vercel/codec/outputs.ts +207 -0
  174. package/src/vercel/codec/reducer-state.ts +169 -0
  175. package/src/vercel/codec/reducer.ts +191 -0
  176. package/src/vercel/codec/tool-transitions.ts +3 -14
  177. package/src/vercel/codec/wire-data.ts +64 -0
  178. package/src/vercel/index.ts +7 -19
  179. package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
  180. package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
  181. package/src/vercel/react/index.ts +3 -5
  182. package/src/vercel/react/use-chat-transport.ts +44 -66
  183. package/src/vercel/react/use-message-sync.ts +75 -39
  184. package/src/vercel/run-end-reason.ts +157 -0
  185. package/src/vercel/tool-part.ts +25 -0
  186. package/src/vercel/transport/chat-transport.ts +380 -98
  187. package/src/vercel/transport/index.ts +38 -37
  188. package/src/vercel/transport/run-output-stream.ts +169 -0
  189. package/src/version.ts +2 -0
  190. package/dist/core/transport/client-transport.d.ts +0 -10
  191. package/dist/core/transport/decode-history.d.ts +0 -43
  192. package/dist/core/transport/server-transport.d.ts +0 -7
  193. package/dist/core/transport/stream-router.d.ts +0 -29
  194. package/dist/core/transport/turn-manager.d.ts +0 -37
  195. package/dist/react/contexts/transport-context.d.ts +0 -31
  196. package/dist/react/contexts/transport-provider.d.ts +0 -49
  197. package/dist/react/create-transport-hooks.d.ts +0 -124
  198. package/dist/react/use-active-turns.d.ts +0 -12
  199. package/dist/react/use-client-transport.d.ts +0 -80
  200. package/dist/vercel/codec/accumulator.d.ts +0 -21
  201. package/dist/vercel/codec/decoder.d.ts +0 -22
  202. package/dist/vercel/codec/encoder.d.ts +0 -41
  203. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
  204. package/dist/vercel/tool-approvals.d.ts +0 -124
  205. package/dist/vercel/tool-events.d.ts +0 -26
  206. package/src/core/transport/client-transport.ts +0 -977
  207. package/src/core/transport/decode-history.ts +0 -485
  208. package/src/core/transport/server-transport.ts +0 -612
  209. package/src/core/transport/stream-router.ts +0 -136
  210. package/src/core/transport/turn-manager.ts +0 -165
  211. package/src/react/contexts/transport-context.ts +0 -37
  212. package/src/react/contexts/transport-provider.tsx +0 -164
  213. package/src/react/create-transport-hooks.ts +0 -144
  214. package/src/react/use-active-turns.ts +0 -72
  215. package/src/react/use-client-transport.ts +0 -197
  216. package/src/vercel/codec/accumulator.ts +0 -588
  217. package/src/vercel/codec/decoder.ts +0 -618
  218. package/src/vercel/codec/encoder.ts +0 -410
  219. package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
  220. package/src/vercel/tool-approvals.ts +0 -380
  221. package/src/vercel/tool-events.ts +0 -53
@@ -1,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 type { WireApplier } 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,46 @@ 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
+ 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 and mint regenerate inputs. */
91
+ codec: Codec<TInput, TOutput, TProjection, TMessage>;
92
+ /**
93
+ * The Tree's single decode-and-apply engine, owned by the session and
94
+ * shared with the live decode loop. History replay feeds pages through it
95
+ * so the one decoder instance sees every route into the Tree.
96
+ */
97
+ applier: WireApplier;
98
+ /** Delegate for executing sends through the session. */
99
+ sendDelegate: SendDelegate<TInput>;
78
100
  /** Logger for diagnostic output. */
79
101
  logger: Logger;
80
102
  /** Called when the view is closed, allowing the owner to clean up references. */
@@ -86,83 +108,222 @@ export interface ViewOptions<TEvent, TMessage> {
86
108
  // ---------------------------------------------------------------------------
87
109
 
88
110
  /**
89
- * Tagged union representing why a branch was selected.
90
- * Stored per group root in the View's `_branchSelections` map.
111
+ * Internal tagged union representing why a branch was selected for an
112
+ * edit-fork group. Stored per group-root runId in the View's
113
+ * `_branchSelections` map. Not the public-facing {@link BranchSelection}
114
+ * — that's a UI-facing bundle returned by `view.branchSelection(id)`.
91
115
  */
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 }
116
+ type BranchSelectionState =
117
+ /** Explicit navigation via `selectSibling()`. The selected input-node key. */
118
+ | { kind: 'user'; selectedKey: string }
119
+ /** This view initiated an edit fork — auto-selected the new input node. */
120
+ | { kind: 'auto'; selectedKey: string }
97
121
  /** 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 };
122
+ | { kind: 'pinned'; selectedKey: string };
123
+
124
+ /**
125
+ * Selection state for a regenerate group. Keyed by the anchor codec-message-id (the
126
+ * assistant codec-message-id being regenerated). Distinct from {@link BranchSelectionState}
127
+ * because regenerate groups are message-level (group members share an
128
+ * anchor codec-message-id), not edit forks of the user prompt.
129
+ *
130
+ * Unlike fork-of groups, regenerate groups do not "pin to current visible"
131
+ * when a new member appears externally — the default for a regenerate
132
+ * slot is always the latest member, so an external regenerator auto-rolls
133
+ * forward unless the user has explicitly selected an earlier member.
134
+ */
135
+ type RegenSelection =
136
+ /** Explicit navigation via `selectSibling()`. The selected reply-run id. */
137
+ | { kind: 'user'; selectedRunId: string }
138
+ /** This view initiated a regenerate — auto-selected the new reply run when it arrived. */
139
+ | { kind: 'auto'; selectedRunId: string }
140
+ /**
141
+ * This view's `regenerate()` is in flight. Keyed (in `_regenSelections`) by
142
+ * the regenerate group's root; `carrierCodecMessageId` is the regenerate
143
+ * carrier event's id, used to recognise the new reply run when it appears.
144
+ */
145
+ | { kind: 'pending'; carrierCodecMessageId: string };
146
+
147
+ /**
148
+ * One alternative inside a {@link MessageBranchPoint}. The representative is the
149
+ * member's own head message for fork-of and whole-reply regen groups, but the
150
+ * *regenerate target* (a non-head message) for a non-head regen group - so it is
151
+ * tracked explicitly rather than re-derived from the node's head.
152
+ */
153
+ interface BranchMember {
154
+ /**
155
+ * The member node's `nodeKey` (tree.ts): a runId for a reply/regenerator run,
156
+ * a codecMessageId for an input node. Matched by `_resolveSelectedIndex`.
157
+ */
158
+ memberNodeKey: string;
159
+ /** The codec-message-id rendered in this member's branch-arrow slot. */
160
+ representativeCodecMessageId: string;
161
+ }
162
+
163
+ /**
164
+ * A resolved branch point: the group `kind` plus the member alternatives.
165
+ *
166
+ * Terms: "regenerate target" = the message being replaced; "regenerator run" =
167
+ * the run that replaces it; "non-head message" = any message after a run's
168
+ * first (index > 0, includes the tail).
169
+ *
170
+ * The three kinds, by anchor:
171
+ * - `fork-of` — edit-style branch anchored at the user input node; members are
172
+ * the alternate prompts (input-node sibling group).
173
+ * - `regen` — whole-reply regenerate branch anchored at the assistant slot;
174
+ * members are the original reply + its regenerator runs (same-input-node
175
+ * sibling reply runs).
176
+ * - `non-head-regen` — a regenerate that replaced a non-head message inside a
177
+ * multi-message reply run; members are the owner run (the regenerate target in
178
+ * place) plus each regenerator run. Not expressible as a same-parent
179
+ * sibling-run group, so the View resolves and renders it itself (see
180
+ * `_extractMessages`).
181
+ *
182
+ * `groupRoot` is the selection-map key: the input group root for fork-of, the
183
+ * original reply's group root for regen, and the regenerate target's
184
+ * codec-message-id for non-head-regen.
185
+ */
186
+ type MessageBranchPoint =
187
+ | { kind: 'fork-of'; groupRoot: string; members: BranchMember[] }
188
+ | { kind: 'regen'; groupRoot: string; members: BranchMember[] }
189
+ | { kind: 'non-head-regen'; groupRoot: string; members: BranchMember[] };
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Send-input normalisation
193
+ // ---------------------------------------------------------------------------
194
+
195
+ /**
196
+ * Normalise the two input shapes `View.send` accepts (a single TInput
197
+ * or an array) into the array shape the SendDelegate consumes.
198
+ * @param input - The raw input from `View.send`.
199
+ * @returns The normalised input array.
200
+ */
201
+ const _normaliseSend = <TInput extends CodecInputEvent>(input: TInput | TInput[]): TInput[] =>
202
+ Array.isArray(input) ? input : [input];
203
+
204
+ /**
205
+ * Project a Tree `RunNode` down to the View-facing `RunInfo` shape:
206
+ * drop the codec projection and the structural fields that callers
207
+ * reach via `session.tree` when they need them.
208
+ * @param run - The tree's RunNode.
209
+ * @returns A projection-free RunInfo.
210
+ */
211
+ const _toRunInfo = <TProjection>(run: RunNode<TProjection>): RunInfo => ({
212
+ runId: run.runId,
213
+ clientId: run.clientId,
214
+ invocationId: run.invocationId,
215
+ ...run.state,
216
+ });
101
217
 
102
218
  // ---------------------------------------------------------------------------
103
219
  // Implementation
104
220
  // ---------------------------------------------------------------------------
105
221
 
106
- export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
107
- private readonly _tree: TreeInternal<TMessage>;
222
+ export class DefaultView<
223
+ TInput extends CodecInputEvent,
224
+ TOutput extends CodecOutputEvent,
225
+ TProjection,
226
+ TMessage,
227
+ > implements View<TInput, TMessage> {
228
+ private readonly _tree: TreeInternal<TInput, TOutput, TProjection>;
108
229
  private readonly _channel: Ably.RealtimeChannel;
109
- private readonly _codec: Codec<TEvent, TMessage>;
110
- private readonly _sendDelegate: SendDelegate<TEvent, TMessage>;
230
+ private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
231
+ private readonly _applier: WireApplier;
232
+ private readonly _sendDelegate: SendDelegate<TInput>;
111
233
  private readonly _logger: Logger;
112
234
  private readonly _emitter: EventEmitter<ViewEventsMap>;
113
235
  private readonly _onClose?: () => void;
114
236
 
115
237
  /**
116
- * View-local branch selections: group root msgId → selection intent.
238
+ * View-local branch selections: group-root runId → selection intent.
117
239
  * 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
240
  */
122
- private readonly _branchSelections = new Map<string, BranchSelection>();
241
+ private readonly _branchSelections = new Map<string, BranchSelectionState>();
123
242
 
124
- /** Spec: AIT-CT11c — msg-ids loaded from history but not yet revealed to the UI. */
125
- private readonly _withheldMsgIds = new Set<string>();
243
+ /**
244
+ * View-local regenerate-group selections: anchor codec-message-id (the assistant
245
+ * codec-message-id being regenerated) → selection intent. Distinct from
246
+ * {@link _branchSelections} because a regenerate group is a set of
247
+ * same-parent reply runs — message-level alternatives at a single
248
+ * conversation slot, not edit forks of the prompt. Groups not present here default to the latest
249
+ * member (the most recent regenerator, or the original if no regen has
250
+ * landed).
251
+ */
252
+ private readonly _regenSelections = new Map<string, RegenSelection>();
126
253
 
127
- /** Snapshot of visible msgIds — used to detect structural changes and for selection pinning. */
128
- private _lastVisibleIds: string[] = [];
254
+ /**
255
+ * Non-head regenerate selections, keyed by the regenerate target's
256
+ * codec-message-id. Separate from {@link _regenSelections} because a non-head
257
+ * regenerator parents inside the owner run rather than as a same-parent
258
+ * sibling, so it lives outside the Tree's `visibleNodes` selection space and
259
+ * is resolved at extraction (see `_extractMessages`). Value is the selected
260
+ * member's nodeKey (the owner run id, or a regenerator run id); absent groups
261
+ * default to the newest regenerator.
262
+ */
263
+ private readonly _nonHeadRegenSelections = new Map<string, RegenSelection>();
129
264
 
130
- /** Snapshot of visible message references used to detect in-place content updates (streaming). */
131
- private _lastVisibleMessages: TMessage[] = [];
265
+ /** Spec: AIT-CT11c runIds loaded from history but not yet revealed to the UI. */
266
+ private readonly _withheldRunIds = new Set<string>();
132
267
 
133
- /** Cached set of turn IDs present on the visible branch avoids recomputing flattenNodes() on turn events. */
134
- private _lastVisibleTurnIds = new Set<string>();
268
+ /** Snapshot of visible node keys used to detect structural changes and for selection pinning. */
269
+ private _lastVisibleNodeKeys: string[] = [];
270
+
271
+ /**
272
+ * Snapshot of visible projection references — used to detect in-place
273
+ * projection updates (streaming). One entry per visible Run.
274
+ */
275
+ private _lastVisibleProjections: TProjection[] = [];
276
+
277
+ /**
278
+ * Snapshot of the visible flat message chain with codec-message-ids —
279
+ * exposed verbatim via `getMessages()` and the internal correlation
280
+ * source for parent/branch routing.
281
+ */
282
+ private _lastVisibleMessagePairs: CodecMessage<TMessage>[] = [];
283
+
284
+ /** Cached visible node-key Set — for O(1) lookup in event scoping. */
285
+ private _lastVisibleNodeKeySet = new Set<string>();
135
286
 
136
287
  /** Whether there are more history pages to fetch from the channel. */
137
288
  private _hasMoreHistory = false;
138
289
 
139
290
  /** Internal state for continuing history pagination. */
140
- private _lastHistoryPage: HistoryPage<TMessage> | undefined;
291
+ private _lastHistoryPage: HistoryPage | undefined;
141
292
 
142
- /** Buffer of withheld nodes, drained newest-first by successive loadOlder() calls. */
143
- private readonly _withheldBuffer: MessageNode<TMessage>[] = [];
293
+ /** Buffer of withheld nodes (input + reply), drained newest-first by successive loadOlder() calls. */
294
+ private readonly _withheldBuffer: ConversationNode<TProjection>[] = [];
295
+
296
+ /**
297
+ * Message-level trim on top of the run-level pagination window. Runs are
298
+ * revealed whole (via `_withheldRunIds`/`_withheldBuffer`), so a `loadOlder`
299
+ * may surface more messages than asked; this is the count of OLDEST messages
300
+ * of the visible node chain to hide from `getMessages()` so a page lands on
301
+ * exactly `limit` messages. The boundary run still appears in `runs()` (it's
302
+ * a revealed node); only its oldest messages are trimmed from the flat list.
303
+ * Live messages append at the newest end and are never trimmed.
304
+ */
305
+ private _hiddenMessageCount = 0;
144
306
 
145
307
  /** Unsubscribe functions for tree event subscriptions. */
146
308
  private readonly _unsubs: (() => void)[] = [];
147
309
 
148
310
  /**
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).
311
+ * Cached result of the last flat-nodes computation. Drives the visible
312
+ * message snapshot exposed via `getMessages()`; refreshed by
313
+ * `_computeFlatNodes()` on structural changes, selection changes,
314
+ * and history reveal.
152
315
  */
153
- private _cachedNodes: MessageNode<TMessage>[] = [];
154
-
155
- /** Last seen tree structural version - used to distinguish content-only from structural updates. */
156
- private _lastStructuralVersion = -1;
316
+ private _cachedNodes: ConversationNode<TProjection>[] = [];
157
317
 
158
318
  private _loadingOlder = false;
159
319
  private _processingHistory = false;
160
320
  private _closed = false;
161
321
 
162
- constructor(options: ViewOptions<TEvent, TMessage>) {
322
+ constructor(options: ViewOptions<TInput, TOutput, TProjection, TMessage>) {
163
323
  this._tree = options.tree;
164
324
  this._channel = options.channel;
165
325
  this._codec = options.codec;
326
+ this._applier = options.applier;
166
327
  this._sendDelegate = options.sendDelegate;
167
328
  this._onClose = options.onClose;
168
329
  this._logger = options.logger.withContext({ component: 'View' });
@@ -171,7 +332,6 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
171
332
 
172
333
  // Compute initial cache and snapshot visible state
173
334
  this._cachedNodes = this._computeFlatNodes();
174
- this._lastStructuralVersion = this._tree.structuralVersion;
175
335
  this._updateVisibleSnapshot(this._cachedNodes);
176
336
 
177
337
  // Subscribe to tree events and re-emit scoped versions
@@ -182,79 +342,294 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
182
342
  this._tree.on('ably-message', (msg) => {
183
343
  this._onTreeAblyMessage(msg);
184
344
  }),
185
- this._tree.on('turn', (event) => {
186
- this._onTreeTurn(event);
345
+ this._tree.on('run', (event) => {
346
+ this._onTreeRun(event);
347
+ }),
348
+ this._tree.on('output', (event) => {
349
+ this._onTreeOutput(event);
187
350
  }),
188
351
  );
189
352
  }
190
353
 
354
+ /**
355
+ * Handle decoded outputs folded into a Run (streaming delta). If the run
356
+ * is on the visible chain, recompute the flat message list and emit
357
+ * `update`.
358
+ * @param event - The output event from the Tree.
359
+ */
360
+ private _onTreeOutput(event: OutputEvent<TOutput>): void {
361
+ if (this._processingHistory) return;
362
+ // The fold target may be a reply run (event.runId) or a user input node
363
+ // (event.runId undefined — the agent mints run-ids, so an input fold has
364
+ // none). Gate on whichever key the visible set holds.
365
+ const folded =
366
+ (event.runId !== undefined && this._lastVisibleNodeKeySet.has(event.runId)) ||
367
+ (event.inputCodecMessageId !== undefined && this._lastVisibleNodeKeySet.has(event.inputCodecMessageId));
368
+ if (!folded) return;
369
+
370
+ // The Tree emits `output` once per inbound message fold (with empty
371
+ // `events` for inputs-only folds), so it fires whenever a visible Run's
372
+ // projection changed and we always re-emit. The Reducer contract permits
373
+ // in-place mutation, which means we cannot use projection-ref or
374
+ // TMessage-ref equality to detect change: a streaming chunk legitimately
375
+ // mutates the same UIMessage object, and a ref-equality short-circuit
376
+ // would suppress every update. React state setters at the subscriber
377
+ // boundary already dedup by array reference, so a redundant emit is a
378
+ // no-op for unchanged hook consumers.
379
+ this._lastVisibleProjections = this._cachedNodes.map((n) => n.projection);
380
+ this._lastVisibleMessagePairs = this._extractMessages(this._cachedNodes).slice(this._hiddenMessageCount);
381
+ this._emitter.emit('update');
382
+ }
383
+
191
384
  // -------------------------------------------------------------------------
192
385
  // Public query methods
193
386
  // -------------------------------------------------------------------------
194
387
 
195
- getMessages(): TMessage[] {
196
- return this.flattenNodes().map((n) => n.message);
388
+ getMessages(): CodecMessage<TMessage>[] {
389
+ return this._lastVisibleMessagePairs;
390
+ }
391
+
392
+ runs(): RunInfo[] {
393
+ // `_cachedNodes` is the visible node chain (inputs + reply runs) with
394
+ // pagination and sibling selection already applied. RunInfo is reply-run
395
+ // shaped, so filter to runs before projecting.
396
+ return this._cachedNodes
397
+ .filter((node): node is RunNode<TProjection> => node.kind === 'run')
398
+ .map((node) => _toRunInfo(node));
399
+ }
400
+
401
+ /**
402
+ * Compute the fresh visible node chain. The Tree's `visibleNodes` already
403
+ * applies kind-blind reachability and sibling selection (edit versions /
404
+ * regenerate runs collapse to the selected member), so the View only layers
405
+ * its pagination window on top: drop nodes whose key is currently withheld.
406
+ * @returns A fresh array of visible nodes (inputs + reply runs).
407
+ */
408
+ private _computeFlatNodes(): ConversationNode<TProjection>[] {
409
+ const treeNodes = this._treeVisibleNodes();
410
+ if (this._withheldRunIds.size === 0) return treeNodes;
411
+ return treeNodes.filter((node) => !this._withheldRunIds.has(nodeKey(node)));
412
+ }
413
+
414
+ /**
415
+ * Recompute the visible node chain, refresh the cache + snapshot, and emit
416
+ * `update` unconditionally. Use after a mutation that always changes the
417
+ * visible output (e.g. an explicit selection or a withheld-batch reveal).
418
+ */
419
+ private _recomputeAndEmit(): void {
420
+ this._cachedNodes = this._computeFlatNodes();
421
+ this._updateVisibleSnapshot(this._cachedNodes);
422
+ this._emitter.emit('update');
423
+ }
424
+
425
+ /**
426
+ * Recompute the visible node chain and, only if it differs from the current
427
+ * snapshot, refresh the cache + snapshot and emit `update`. Use after a
428
+ * mutation that may or may not move the visible window (e.g. a structural
429
+ * tree update, or a deferred regenerate promotion that may already match).
430
+ */
431
+ private _recomputeAndEmitIfChanged(): void {
432
+ const nodes = this._computeFlatNodes();
433
+ if (this._visibleChanged(nodes)) {
434
+ this._cachedNodes = nodes;
435
+ this._updateVisibleSnapshot(nodes);
436
+ this._emitter.emit('update');
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Resolve the reply Run that owns a codec-message-id, narrowing the Tree's
442
+ * node union to a {@link RunNode}. A user-input codec-message-id resolves to
443
+ * an input node and yields `undefined` here — callers that must handle input
444
+ * nodes use {@link _tree.getNodeByCodecMessageId} directly.
445
+ * @param codecMessageId - The codec-message-id to resolve.
446
+ * @returns The owning RunNode, or undefined if absent or not a reply Run.
447
+ */
448
+ private _runByCodecMessageId(codecMessageId: string): RunNode<TProjection> | undefined {
449
+ const node = this._tree.getNodeByCodecMessageId(codecMessageId);
450
+ return node?.kind === 'run' ? node : undefined;
451
+ }
452
+
453
+ /**
454
+ * The regenerator runs that replaced a non-head message of a reply run. They
455
+ * file under the target's predecessor (not the owner run's input node), so the
456
+ * Tree's `visibleNodes` cannot collapse them into the owner's slot; this
457
+ * surfaces them for the View to resolve and render. Head-message (index 0)
458
+ * regenerates are excluded - those are whole-reply sibling runs the Tree
459
+ * already groups.
460
+ * @param targetCodecMessageId - The regenerate target's (non-head) message id.
461
+ * @param predecessorCodecMessageId - The codec-message-id immediately before it in the owner run.
462
+ * @returns The regenerator runs in startSerial order (oldest first).
463
+ */
464
+ private _nonHeadRegenerators(
465
+ targetCodecMessageId: string,
466
+ predecessorCodecMessageId: string,
467
+ ): RunNode<TProjection>[] {
468
+ return this._tree
469
+ .getReplyRuns(predecessorCodecMessageId)
470
+ .filter((r) => r.regeneratesCodecMessageId === targetCodecMessageId)
471
+ .toSorted((a, b) => (a.startSerial ?? '￿').localeCompare(b.startSerial ?? '￿'));
472
+ }
473
+
474
+ /**
475
+ * Resolve the selected member of a non-head regenerate group anchored at
476
+ * `targetCodecMessageId`. Members are the owner run `O` (memberNodeKey =
477
+ * `ownerRunId`, the regenerate target in place) followed by each regenerator
478
+ * run. Honours an explicit {@link _nonHeadRegenSelections} entry, else
479
+ * defaults to the latest member (newest regenerator), mirroring the
480
+ * whole-reply regenerate default.
481
+ * @param targetCodecMessageId - The regenerate target's message id (the group anchor).
482
+ * @param ownerRunId - The runId of the run that owns the regenerate target.
483
+ * @param regenerators - The regenerator runs (oldest first) from `_nonHeadRegenerators`.
484
+ * @returns The selected member's node key (`ownerRunId` or a regenerator runId).
485
+ */
486
+ private _selectedNonHeadMember(
487
+ targetCodecMessageId: string,
488
+ ownerRunId: string,
489
+ regenerators: RunNode<TProjection>[],
490
+ ): string {
491
+ const sel = this._nonHeadRegenSelections.get(targetCodecMessageId);
492
+ if (sel && sel.kind !== 'pending') {
493
+ const keys = [ownerRunId, ...regenerators.map((r) => r.runId)];
494
+ if (keys.includes(sel.selectedRunId)) return sel.selectedRunId;
495
+ }
496
+ // Default: latest member = newest regenerator (regenerators is oldest-first).
497
+ return regenerators.at(-1)?.runId ?? ownerRunId;
197
498
  }
198
499
 
199
- // Spec: AIT-CT9, AIT-CT11c
200
- flattenNodes(): MessageNode<TMessage>[] {
201
- return this._cachedNodes;
500
+ /**
501
+ * Flatten visible nodes to messages, collapsing a non-head regenerate into the
502
+ * slot it replaces: while emitting a reply run, at each non-head message that
503
+ * has a selected regenerator, drop that message and the run's tail and emit the
504
+ * selected regenerator instead (recursive for regen-of-regen). Whole-reply
505
+ * regenerates need nothing here - visibleNodes already picks the sibling.
506
+ * @param nodes - Visible nodes (inputs + reply runs), chronological.
507
+ * @returns The flat message list, each paired with its codec-message-id.
508
+ */
509
+ private _extractMessages(nodes: ConversationNode<TProjection>[]): CodecMessage<TMessage>[] {
510
+ const messages: CodecMessage<TMessage>[] = [];
511
+ // Regenerator runs already emitted via substitution at their anchor — skip
512
+ // them when the node walk reaches them directly.
513
+ const consumedRunIds = new Set<string>();
514
+
515
+ for (const node of nodes) {
516
+ if (node.kind === 'run' && consumedRunIds.has(node.runId)) continue;
517
+ this._emitNodeMessages(node, messages, consumedRunIds);
518
+ }
519
+ return messages;
202
520
  }
203
521
 
204
522
  /**
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.
523
+ * Emit one visible node's messages into `out`, applying non-head regenerate
524
+ * substitution for a reply run (see `_extractMessages`). Input nodes and runs
525
+ * with no non-head regenerators emit their projection verbatim.
526
+ * @param node - The node to emit.
527
+ * @param out - The accumulating flat message list (mutated in place).
528
+ * @param consumedRunIds - Set of regenerator runIds already emitted via substitution (mutated in place).
210
529
  */
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));
530
+ private _emitNodeMessages(
531
+ node: ConversationNode<TProjection>,
532
+ out: CodecMessage<TMessage>[],
533
+ consumedRunIds: Set<string>,
534
+ ): void {
535
+ const own = this._codec.getMessages(node.projection);
536
+ if (node.kind !== 'run') {
537
+ out.push(...own);
538
+ return;
539
+ }
540
+ for (let i = 0; i < own.length; i++) {
541
+ const m = own[i];
542
+ if (!m) continue;
543
+ // Head message (i === 0) regenerates are whole-reply sibling runs, already
544
+ // resolved by visibleNodes — only non-head messages anchor a non-head group.
545
+ const predecessor = i > 0 ? own[i - 1]?.codecMessageId : undefined;
546
+ if (predecessor !== undefined) {
547
+ const regenerators = this._nonHeadRegenerators(m.codecMessageId, predecessor);
548
+ if (regenerators.length > 0) {
549
+ // Every regenerator (and any same-anchor sibling the Tree already
550
+ // collapsed in `visibleNodes`) is an alternative at THIS one slot, so
551
+ // mark them all consumed up front — the node walk must not re-emit the
552
+ // Tree's default-latest sibling once we render a different member here.
553
+ for (const r of regenerators) consumedRunIds.add(r.runId);
554
+ const selectedKey = this._selectedNonHeadMember(m.codecMessageId, node.runId, regenerators);
555
+ if (selectedKey !== node.runId) {
556
+ // A regenerator is selected: drop M and the rest of O, emit the
557
+ // selected regenerator in M's place (recursively for nested regen).
558
+ const chosen = regenerators.find((r) => r.runId === selectedKey);
559
+ if (chosen) {
560
+ this._emitNodeMessages(chosen, out, consumedRunIds);
561
+ return;
562
+ }
563
+ }
564
+ // Original (owner run) selected: fall through and emit M from O.
565
+ }
566
+ }
567
+ out.push(m);
568
+ }
215
569
  }
216
570
 
217
571
  hasOlder(): boolean {
218
- return this._withheldBuffer.length > 0 || this._hasMoreHistory;
572
+ return this._hiddenMessageCount > 0 || this._withheldBuffer.length > 0 || this._hasMoreHistory;
219
573
  }
220
574
 
221
- async loadOlder(limit = 100): Promise<void> {
575
+ /**
576
+ * Reveal `limit` more older codecMessages in this view — fewer only when
577
+ * channel history is exhausted.
578
+ *
579
+ * Internally runs are revealed WHOLE (run-granular withholding), counting
580
+ * codecMessages to decide how many runs to bring in, then the flat list
581
+ * returned by {@link getMessages} is trimmed to exactly `limit` more
582
+ * messages. So a run straddling the boundary still appears in {@link runs}
583
+ * (it's a revealed node) while only its newest messages show in
584
+ * `getMessages`. Live messages append at the newest end and are never
585
+ * trimmed.
586
+ * @param limit - Number of older codecMessages to reveal. Defaults to 10.
587
+ */
588
+ async loadOlder(limit = 10): Promise<void> {
222
589
  if (this._closed || this._loadingOlder) return;
223
590
  this._loadingOlder = true;
224
591
  this._logger.trace('DefaultView.loadOlder();', { limit });
225
592
 
226
593
  try {
227
- // Drain withheld buffer first (older messages, released newest-first)
228
- if (this._withheldBuffer.length > 0) {
229
- const batch = this._withheldBuffer.splice(-limit, limit);
230
- this._releaseWithheld(batch);
594
+ // Phase A: the boundary run is already revealed (a previous loadOlder
595
+ // pulled in a whole run that overshot the message limit); reveal more of
596
+ // its trimmed-off oldest messages without fetching or revealing new runs.
597
+ if (this._hiddenMessageCount >= limit) {
598
+ this._hiddenMessageCount -= limit;
599
+ this._recomputeAndEmit();
231
600
  return;
232
601
  }
233
602
 
234
- // Buffer exhausted load from channel history
235
- if (!this._hasMoreHistory && !this._lastHistoryPage) {
236
- // First load
237
- await this._loadFirstPage(limit);
238
- return;
239
- }
603
+ // Phase B: reveal whole older runs covering the remaining message budget,
604
+ // then re-trim so exactly `limit` new messages surface. Runs are revealed
605
+ // whole (node granularity); the trim makes the message count exact.
606
+ const need = limit - this._hiddenMessageCount;
607
+ const before = this._extractMessages(this._computeFlatNodes()).length;
608
+ const revealedSoFar = (): number => this._extractMessages(this._computeFlatNodes()).length - before;
240
609
 
241
- if (!this._hasMoreHistory) return;
242
-
243
- // Continue from last page
244
- if (!this._lastHistoryPage?.hasNext()) {
245
- this._hasMoreHistory = false;
246
- return;
610
+ // Drain the withheld buffer toward `need` (whole older runs, newest-first).
611
+ if (this._withheldBuffer.length > 0) {
612
+ const splitIdx = this._messageTailSplitIndex(this._withheldBuffer, need);
613
+ const batch = this._withheldBuffer.splice(splitIdx);
614
+ this._releaseWithheld(batch);
247
615
  }
248
616
 
249
- const nextPage = await this._lastHistoryPage.next();
250
- // Re-check: close() may be called during the await from another call stack
251
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
252
- if (this._closed || !nextPage) {
253
- if (!nextPage) this._hasMoreHistory = false;
254
- return;
617
+ // If the buffer was empty or fell short of `need` (e.g. it held a
618
+ // zero-message run), fetch channel history for the remainder. The fetch
619
+ // path loops over pages internally until it covers its target or history
620
+ // is exhausted, so a single call here suffices.
621
+ if (revealedSoFar() < need) {
622
+ await this._fetchOlder(need - revealedSoFar());
623
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be set during the await above
624
+ if (this._closed) return;
255
625
  }
256
626
 
257
- await this._loadAndReveal(nextPage, limit);
627
+ const after = this._extractMessages(this._computeFlatNodes()).length;
628
+ // `after - before` whole-run messages were added at the oldest end; show
629
+ // `limit` of them (newest), hiding the overshoot plus what was already
630
+ // trimmed. `<= 0` when history is exhausted before `limit` is reached.
631
+ this._hiddenMessageCount = Math.max(0, this._hiddenMessageCount + (after - before) - limit);
632
+ this._recomputeAndEmit();
258
633
  } catch (error) {
259
634
  this._logger.error('DefaultView.loadOlder(); failed', { error });
260
635
  throw error;
@@ -263,48 +638,363 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
263
638
  }
264
639
  }
265
640
 
641
+ /**
642
+ * Fetch older channel history covering at least `target` more codecMessages,
643
+ * buffering the older nodes and revealing whole runs. The withheld buffer is
644
+ * assumed already drained by the caller. Loads the first page when no history
645
+ * has been fetched yet, otherwise advances to the next older page; the
646
+ * page-walk inside {@link _revealFromPage} loops until `target` messages are
647
+ * covered or history runs out. No-op (leaving `_hasMoreHistory` false) once
648
+ * channel history is exhausted.
649
+ * @param target - Minimum additional codecMessages this fetch aims to cover.
650
+ */
651
+ private async _fetchOlder(target: number): Promise<void> {
652
+ if (!this._hasMoreHistory && !this._lastHistoryPage) {
653
+ await this._loadFirstPage(target);
654
+ return;
655
+ }
656
+
657
+ if (!this._hasMoreHistory) return;
658
+
659
+ if (!this._lastHistoryPage?.hasNext()) {
660
+ this._hasMoreHistory = false;
661
+ return;
662
+ }
663
+
664
+ const nextPage = await this._lastHistoryPage.next();
665
+ if (this._closed || !nextPage) {
666
+ if (!nextPage) this._hasMoreHistory = false;
667
+ return;
668
+ }
669
+
670
+ await this._revealFromPage(nextPage, target);
671
+ }
672
+
673
+ /**
674
+ * Find the index in `nodes` (chronological, oldest-first) at which the newest
675
+ * whole runs covering at least `target` codecMessages begin. Walks newest-first
676
+ * summing each node's `codec.getMessages(projection)` count; once the running
677
+ * total reaches `target`, the current node (and everything newer) is the
678
+ * revealed batch — so whole runs are revealed and the batch may overshoot
679
+ * `target` (the caller trims). Returns `0` when the nodes hold fewer than
680
+ * `target` messages — reveal everything.
681
+ *
682
+ * Shared by the buffer-drain and history-fetch reveal paths so they agree on
683
+ * "covering `target` messages".
684
+ * @param nodes - Candidate nodes, oldest-first.
685
+ * @param target - Minimum codecMessages the revealed batch must cover.
686
+ * @returns The split index; `nodes[splitIdx..]` is the revealed batch.
687
+ */
688
+ private _messageTailSplitIndex(nodes: ConversationNode<TProjection>[], target: number): number {
689
+ let messages = 0;
690
+ for (let i = nodes.length - 1; i >= 0; i--) {
691
+ const node = nodes[i];
692
+ if (!node) continue;
693
+ messages += this._codec.getMessages(node.projection).length;
694
+ if (messages >= target) return i; // reveal nodes[i..]
695
+ }
696
+ return 0; // fewer than `target` messages — reveal everything
697
+ }
698
+
699
+ // -------------------------------------------------------------------------
700
+ // Run lookup
701
+ // -------------------------------------------------------------------------
702
+
703
+ runOf(codecMessageId: string): RunInfo | undefined {
704
+ this._logger.trace('DefaultView.runOf();', { codecMessageId });
705
+ const node = this._tree.getNodeByCodecMessageId(codecMessageId);
706
+ if (!node) return undefined;
707
+ if (node.kind === 'run') return _toRunInfo(node);
708
+ // Input node: resolve to its selected reply run (undefined if none started).
709
+ const reply = this._selectedReplyRun(node.codecMessageId);
710
+ return reply ? _toRunInfo(reply) : undefined;
711
+ }
712
+
713
+ /**
714
+ * Resolve the reply run currently selected for an input node, honouring the
715
+ * View's regenerate selection. Falls back to the latest reply run when no
716
+ * selection has been recorded; undefined when no reply run has started.
717
+ * @param inputCodecMessageId - The input node's codec-message-id.
718
+ * @returns The selected reply RunNode, or undefined.
719
+ */
720
+ private _selectedReplyRun(inputCodecMessageId: string): RunNode<TProjection> | undefined {
721
+ const replies = this._tree.getReplyRuns(inputCodecMessageId);
722
+ if (replies.length === 0) return undefined;
723
+ if (replies.length === 1) return replies[0];
724
+ // Multiple reply runs = a regenerate group. Honour the View's selection
725
+ // (keyed by group root) else default to the latest.
726
+ const groupRoot = this._tree.getGroupRoot(replies[0]?.runId ?? '');
727
+ const sel = this._regenSelections.get(groupRoot);
728
+ const selectedKey = sel && sel.kind !== 'pending' ? sel.selectedRunId : undefined;
729
+ if (selectedKey !== undefined) {
730
+ const chosen = replies.find((r) => r.runId === selectedKey);
731
+ if (chosen) return chosen;
732
+ }
733
+ // Latest by startSerial; getReplyRuns is set-ordered, so sort defensively.
734
+ return replies.toSorted((a, b) => (a.startSerial ?? '￿').localeCompare(b.startSerial ?? '￿')).at(-1);
735
+ }
736
+
737
+ run(runId: string): RunInfo | undefined {
738
+ this._logger.trace('DefaultView.run();', { runId });
739
+ const run = this._tree.getRunNode(runId);
740
+ return run ? _toRunInfo(run) : undefined;
741
+ }
742
+
266
743
  // -------------------------------------------------------------------------
267
- // Branch navigation
744
+ // Branch navigation (msg-anchored)
268
745
  // -------------------------------------------------------------------------
269
746
 
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];
747
+ // Spec: AIT-CT13c, AIT-CT13d — branch points are codec-message-id
748
+ // anchored. The View resolves the anchor (the user prompt for edits,
749
+ // the assistant slot for regens) and routes the selection to the
750
+ // appropriate internal selection map. Tree-level introspection
751
+ // (RunNode access, runId-keyed queries) remains on the {@link Tree}.
752
+
753
+ branchSelection(codecMessageId: string): BranchSelection<TMessage> {
754
+ const branch = this._resolveMessageBranchPoint(codecMessageId);
755
+ if (branch) {
756
+ // Each member contributes its representative message as the branch-arrow
757
+ // slot: for an edit fork that is the alternate user prompt; for a
758
+ // whole-reply regenerate group the variant's first message; for a non-head
759
+ // regenerate group the regenerate target (original) or the regenerator's
760
+ // first message.
761
+ const siblings = branch.members.flatMap((member) => {
762
+ const owner = this._tree.getNodeByCodecMessageId(member.representativeCodecMessageId);
763
+ if (!owner) return [];
764
+ const found = this._codec
765
+ .getMessages(owner.projection)
766
+ .find((m) => m.codecMessageId === member.representativeCodecMessageId);
767
+ return found ? [found.message] : [];
768
+ });
769
+
770
+ if (siblings.length > 0) {
771
+ const index = this._resolveSelectedIndex(branch);
772
+ const clamped = Math.max(0, Math.min(index, siblings.length - 1));
773
+ const selected = siblings[clamped];
774
+ return {
775
+ hasSiblings: siblings.length > 1,
776
+ siblings,
777
+ index: clamped,
778
+ selected,
779
+ };
780
+ }
781
+ }
782
+
783
+ // Known non-anchor message: the bundle's invariant is that
784
+ // `siblings` contains the rendered message itself for any known
785
+ // codec-message-id, so plain bubbles get `siblings.length === 1`
786
+ // (not `0`) and the indexing space matches between read and write.
787
+ // Resolve the owning node kind-blind — a plain user prompt is an input
788
+ // node, an assistant message lives in a reply run; both carry a projection.
789
+ const owner = this._tree.getNodeByCodecMessageId(codecMessageId);
790
+ if (owner) {
791
+ const found = this._codec.getMessages(owner.projection).find((m) => m.codecMessageId === codecMessageId);
792
+ if (found !== undefined) {
793
+ return { hasSiblings: false, siblings: [found.message], index: 0, selected: found.message };
794
+ }
795
+ }
796
+
797
+ // Unknown id, or the owner Run is known but the codec doesn't surface
798
+ // a message with this id from the projection (e.g. an event-only fold
799
+ // such as a tool result that mutates an assistant in-place without
800
+ // exposing its own TMessage). Treat both as "no rendered message",
801
+ // returning the safe empty bundle.
802
+ return { hasSiblings: false, siblings: [], index: 0, selected: undefined };
803
+ }
804
+
805
+ // Spec: AIT-CT13c, AIT-CT13d
806
+ selectSibling(codecMessageId: string, index: number): void {
807
+ this._logger.trace('DefaultView.selectSibling();', { codecMessageId, index });
808
+ const branch = this._resolveMessageBranchPoint(codecMessageId);
809
+ if (!branch) return;
810
+ const clamped = Math.max(0, Math.min(index, branch.members.length - 1));
811
+ const selected = branch.members[clamped];
278
812
  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');
813
+ if (branch.kind === 'fork-of') {
814
+ this._branchSelections.set(branch.groupRoot, { kind: 'user', selectedKey: selected.memberNodeKey });
815
+ this._logger.debug('DefaultView.selectSibling(); fork-of', {
816
+ codecMessageId,
817
+ index: clamped,
818
+ selectedKey: selected.memberNodeKey,
819
+ });
820
+ } else if (branch.kind === 'non-head-regen') {
821
+ // Non-head groups live outside the visibleNodes sibling space — store in
822
+ // the dedicated map the message-extraction substitution reads.
823
+ this._nonHeadRegenSelections.set(branch.groupRoot, { kind: 'user', selectedRunId: selected.memberNodeKey });
824
+ this._logger.debug('DefaultView.selectSibling(); non-head-regen', {
825
+ codecMessageId,
826
+ index: clamped,
827
+ selectedRunId: selected.memberNodeKey,
828
+ anchor: branch.groupRoot,
829
+ });
830
+ } else {
831
+ this._regenSelections.set(branch.groupRoot, { kind: 'user', selectedRunId: selected.memberNodeKey });
832
+ this._logger.debug('DefaultView.selectSibling(); regenerate', {
833
+ codecMessageId,
834
+ index: clamped,
835
+ selectedRunId: selected.memberNodeKey,
836
+ groupRoot: branch.groupRoot,
837
+ });
838
+ }
839
+ this._recomputeAndEmit();
284
840
  }
285
841
 
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;
842
+ /**
843
+ * Resolve the currently selected sibling's index inside a branch group.
844
+ * Pending selections fall back to the latest sibling. The caller clamps
845
+ * the returned index against any post-extraction filtering.
846
+ * @param branch - Resolved branch-point descriptor from `_resolveMessageBranchPoint`.
847
+ * @returns The selected sibling's index within `branch.siblings`.
848
+ */
849
+ private _resolveSelectedIndex(branch: MessageBranchPoint): number {
850
+ if (branch.kind === 'fork-of') {
851
+ const sel = this._branchSelections.get(branch.groupRoot);
852
+ if (!sel) return branch.members.length - 1;
853
+ const idx = branch.members.findIndex((m) => m.memberNodeKey === sel.selectedKey);
854
+ return idx === -1 ? branch.members.length - 1 : idx;
855
+ }
856
+ const sel =
857
+ branch.kind === 'non-head-regen'
858
+ ? this._nonHeadRegenSelections.get(branch.groupRoot)
859
+ : this._regenSelections.get(branch.groupRoot);
860
+ if (!sel || sel.kind === 'pending') return branch.members.length - 1;
861
+ const idx = branch.members.findIndex((m) => m.memberNodeKey === sel.selectedRunId);
862
+ return idx === -1 ? branch.members.length - 1 : idx;
296
863
  }
297
864
 
298
- getSiblings(msgId: string): TMessage[] {
299
- return this._tree.getSiblings(msgId);
865
+ /**
866
+ * Resolve the branch point anchored at `codecMessageId`, if any, returning the
867
+ * group `kind` + members + groupRoot so the caller routes to the correct
868
+ * selection map directly (not via a runId dispatch that would mis-route when
869
+ * the owning Run is in both a fork-of and a regen group).
870
+ * @param codecMessageId - The codec-message-id to look up.
871
+ * @returns The resolved branch point, or undefined when `codecMessageId`
872
+ * anchors no group.
873
+ */
874
+ private _resolveMessageBranchPoint(codecMessageId: string): MessageBranchPoint | undefined {
875
+ const node = this._tree.getNodeByCodecMessageId(codecMessageId);
876
+ if (!node) return undefined;
877
+
878
+ // Edit-fork branch point: `codecMessageId` is a user INPUT node that has
879
+ // sibling input nodes (alternate prompts via fork-of). The anchor is the
880
+ // input node's own codec-message-id.
881
+ if (node.kind === 'input') {
882
+ const siblings = this._tree.getSiblingNodes(node.codecMessageId);
883
+ if (siblings.length > 1) {
884
+ return {
885
+ kind: 'fork-of',
886
+ groupRoot: this._tree.getGroupRoot(node.codecMessageId),
887
+ members: this._nodeHeadMembers(siblings),
888
+ };
889
+ }
890
+ return undefined;
891
+ }
892
+
893
+ // Non-head regenerate branch point: `codecMessageId` is the rendered slot for
894
+ // a regenerate that replaced a non-head message inside a multi-message reply
895
+ // run. Resolved BEFORE the same-parent `regen` group below: several non-head
896
+ // regenerators of one anchor share a parent (the anchor's predecessor), so
897
+ // the Tree files them as their own sibling group excluding the owner run; the
898
+ // non-head resolver instead gathers the owner plus every regenerator into one
899
+ // anchor-keyed group.
900
+ const ownMessages = this._codec.getMessages(node.projection);
901
+ const nonHead = this._resolveNonHeadBranchPoint(node, ownMessages, codecMessageId);
902
+ if (nonHead) return nonHead;
903
+
904
+ // Regenerate branch point: `codecMessageId` is owned by a reply run that has
905
+ // sibling reply runs (the original reply + its regenerators, all parented at
906
+ // the same input node). Anchor on the head message of the run so arrows
907
+ // appear once per variant, not on every follow-up message.
908
+ const siblings = this._tree.getSiblingNodes(node.runId);
909
+ if (siblings.length > 1 && ownMessages.at(0)?.codecMessageId === codecMessageId) {
910
+ return {
911
+ kind: 'regen',
912
+ groupRoot: this._tree.getGroupRoot(node.runId),
913
+ members: this._nodeHeadMembers(siblings),
914
+ };
915
+ }
916
+
917
+ return undefined;
300
918
  }
301
919
 
302
- hasSiblings(msgId: string): boolean {
303
- return this._tree.hasSiblings(msgId);
920
+ /**
921
+ * Resolve a non-head regenerate branch point from a reply-run message, if any.
922
+ * `codecMessageId` is either (a) a non-head message `M` of its owner run with
923
+ * regenerators, or (b) a regenerator run's head; both resolve to the same group
924
+ * anchored at `M` (key matching {@link _nonHeadRegenSelections}).
925
+ * @param node - The reply run owning `codecMessageId`.
926
+ * @param ownMessages - That run's projected messages (already extracted).
927
+ * @param codecMessageId - The slot's codec-message-id (an `M`, or a regenerator head).
928
+ * @returns The non-head branch point, or undefined when `codecMessageId` anchors none.
929
+ */
930
+ private _resolveNonHeadBranchPoint(
931
+ node: RunNode<TProjection>,
932
+ ownMessages: CodecMessage<TMessage>[],
933
+ codecMessageId: string,
934
+ ): MessageBranchPoint | undefined {
935
+ // Case (b): `codecMessageId` is a regenerator run's head. Re-anchor on the
936
+ // message it regenerates and resolve from the owner run's perspective.
937
+ const isHead = ownMessages.at(0)?.codecMessageId === codecMessageId;
938
+ if (isHead && node.regeneratesCodecMessageId !== undefined) {
939
+ const anchorId = node.regeneratesCodecMessageId;
940
+ const owner = this._runByCodecMessageId(anchorId);
941
+ if (owner) {
942
+ const ownerMsgs = this._codec.getMessages(owner.projection);
943
+ const idx = ownerMsgs.findIndex((mm) => mm.codecMessageId === anchorId);
944
+ const predecessor = idx > 0 ? ownerMsgs[idx - 1]?.codecMessageId : undefined;
945
+ if (predecessor !== undefined) {
946
+ return this._buildNonHeadGroup(anchorId, owner.runId, predecessor);
947
+ }
948
+ }
949
+ return undefined;
950
+ }
951
+
952
+ // Case (a): `codecMessageId` is a non-head message of its owner run.
953
+ const idx = ownMessages.findIndex((mm) => mm.codecMessageId === codecMessageId);
954
+ const predecessor = idx > 0 ? ownMessages[idx - 1]?.codecMessageId : undefined;
955
+ if (predecessor === undefined) return undefined;
956
+ return this._buildNonHeadGroup(codecMessageId, node.runId, predecessor);
304
957
  }
305
958
 
306
- getNode(msgId: string): MessageNode<TMessage> | undefined {
307
- return this._tree.getNode(msgId);
959
+ /**
960
+ * Build the {@link MessageBranchPoint} for a non-head regenerate group, or
961
+ * undefined when the anchor has no regenerators. The owner member's
962
+ * representative is the anchor message (the regenerate target); each
963
+ * regenerator's is its head message.
964
+ * @param anchorCodecMessageId - The regenerate target's (non-head) message id.
965
+ * @param ownerRunId - The runId owning the regenerate target.
966
+ * @param predecessorCodecMessageId - The codec-message-id immediately before the anchor in the owner run.
967
+ * @returns The non-head branch point, or undefined when there are no regenerators.
968
+ */
969
+ private _buildNonHeadGroup(
970
+ anchorCodecMessageId: string,
971
+ ownerRunId: string,
972
+ predecessorCodecMessageId: string,
973
+ ): MessageBranchPoint | undefined {
974
+ const regenerators = this._nonHeadRegenerators(anchorCodecMessageId, predecessorCodecMessageId);
975
+ if (regenerators.length === 0) return undefined;
976
+ const members: BranchMember[] = [{ memberNodeKey: ownerRunId, representativeCodecMessageId: anchorCodecMessageId }];
977
+ for (const r of regenerators) {
978
+ const head = this._codec.getMessages(r.projection).at(0);
979
+ if (head) members.push({ memberNodeKey: r.runId, representativeCodecMessageId: head.codecMessageId });
980
+ }
981
+ return { kind: 'non-head-regen', groupRoot: anchorCodecMessageId, members };
982
+ }
983
+
984
+ /**
985
+ * Project nodes to {@link BranchMember}s for fork-of / whole-reply regen
986
+ * groups, where each member's branch-arrow representative is its own head
987
+ * message and its memberNodeKey is its node key.
988
+ * @param nodes - The sibling nodes.
989
+ * @returns One member per node that has a head message.
990
+ */
991
+ private _nodeHeadMembers(nodes: ConversationNode<TProjection>[]): BranchMember[] {
992
+ const members: BranchMember[] = [];
993
+ for (const n of nodes) {
994
+ const head = this._codec.getMessages(n.projection).at(0);
995
+ if (head) members.push({ memberNodeKey: nodeKey(n), representativeCodecMessageId: head.codecMessageId });
996
+ }
997
+ return members;
308
998
  }
309
999
 
310
1000
  // -------------------------------------------------------------------------
@@ -312,159 +1002,237 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
312
1002
  // -------------------------------------------------------------------------
313
1003
 
314
1004
  // Spec: AIT-CT3, AIT-CT4
315
- async send(input: TMessage | TMessage[], options?: SendOptions): Promise<ActiveTurn<TEvent>> {
1005
+ async send(input: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
316
1006
  this._logger.trace('DefaultView.send();');
317
1007
  if (this._closed) {
318
1008
  throw new Ably.ErrorInfo('unable to send; view is closed', ErrorCode.InvalidArgument, 400);
319
1009
  }
320
1010
 
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);
1011
+ const normalised = _normaliseSend<TInput>(input);
1012
+
1013
+ // The codec-message-id of the visible branch tail — the delegate uses it
1014
+ // for auto-parent routing on fresh user messages.
1015
+ const parentCodecMessageId = this._lastVisibleMessagePairs.at(-1)?.codecMessageId;
1016
+
1017
+ const result = await this._sendDelegate(normalised, options, parentCodecMessageId);
1018
+ this._applyForkAutoSelect(result, options);
1019
+ return result;
1020
+ }
325
1021
 
1022
+ /**
1023
+ * Auto-select / pin branch selections after a forking send.
1024
+ * @param result - The ActiveRun returned by the delegate.
1025
+ * @param options - The SendOptions passed by the caller.
1026
+ */
1027
+ private _applyForkAutoSelect(result: ActiveRun, options: SendOptions | undefined): void {
326
1028
  // 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
- }
1029
+ if (!options?.forkOf) return;
1030
+
1031
+ // An edit inserts a NEW user input node optimistically; its codec-message-id
1032
+ // is the (only) optimistic id and IS its node key. Edit forks are input-node
1033
+ // sibling groups, so the selection is keyed by the input group root and the
1034
+ // selected member is the new input node's key.
1035
+ const editedInputKey = result.optimisticCodecMessageIds.at(0);
1036
+ if (editedInputKey === undefined) return;
1037
+ const groupRoot = this._tree.getGroupRoot(editedInputKey);
1038
+
1039
+ this._branchSelections.set(groupRoot, { kind: 'auto', selectedKey: editedInputKey });
1040
+ this._recomputeAndEmit();
1041
+ }
1042
+
1043
+ /**
1044
+ * Auto-select / pin the regenerate group anchored at `anchorCodecMessageId` so
1045
+ * the new Run's content appears as soon as the agent's run-start lands.
1046
+ *
1047
+ * `View.regenerate()` calls this with the assistant codec-message-id being
1048
+ * regenerated. The Run doesn't exist yet on the channel (the regenerate
1049
+ * wire is wire-only); the selection is recorded as `pending` and
1050
+ * promoted to `auto` by `_pinRegenSelections` once the corresponding
1051
+ * Run is created in the tree.
1052
+ * @param result - The ActiveRun returned by the delegate (run-id is the new regenerator's).
1053
+ * @param anchorCodecMessageId - The codec-message-id of the assistant being regenerated.
1054
+ */
1055
+ private _applyRegenerateAutoSelect(result: ActiveRun, anchorCodecMessageId: string): void {
1056
+ // A regenerate produces a new reply run parented at the SAME input node as
1057
+ // the original reply (the regenerate group). The agent mints the run-id, so
1058
+ // we cannot pin by it synchronously. Resolve the group root from the
1059
+ // original reply run owning the anchor, and pin a pending selection keyed by
1060
+ // that group root, carrying the regenerate carrier's codec-message-id
1061
+ // (`result.inputCodecMessageId`) so we can promote when the new reply run lands.
1062
+ const anchorRun = this._runByCodecMessageId(anchorCodecMessageId);
1063
+ if (!anchorRun) return;
1064
+
1065
+ // Non-head regenerate: the anchor is a non-head message of its owner run, so
1066
+ // the new run won't be a same-parent sibling — it parents at the anchor's
1067
+ // predecessor. Defer in the dedicated non-head map (keyed by the anchor
1068
+ // message), not the sibling-group regen map.
1069
+ const anchorMsgs = this._codec.getMessages(anchorRun.projection);
1070
+ if (anchorMsgs.at(0)?.codecMessageId !== anchorCodecMessageId) {
1071
+ this._nonHeadRegenSelections.set(anchorCodecMessageId, {
1072
+ kind: 'pending',
1073
+ carrierCodecMessageId: result.inputCodecMessageId,
1074
+ });
1075
+ this._logger.debug('DefaultView._applyRegenerateAutoSelect(); deferring non-head regenerate selection', {
1076
+ anchorCodecMessageId,
1077
+ carrier: result.inputCodecMessageId,
1078
+ });
1079
+ this._resolvePendingNonHeadRegenSelections();
1080
+ this._recomputeAndEmitIfChanged();
1081
+ return;
367
1082
  }
368
1083
 
369
- return result;
1084
+ const groupRoot = this._tree.getGroupRoot(anchorRun.runId);
1085
+
1086
+ this._regenSelections.set(groupRoot, {
1087
+ kind: 'pending',
1088
+ carrierCodecMessageId: result.inputCodecMessageId,
1089
+ });
1090
+ this._logger.debug('DefaultView._applyRegenerateAutoSelect(); deferring regenerate selection', {
1091
+ anchorCodecMessageId,
1092
+ groupRoot,
1093
+ carrier: result.inputCodecMessageId,
1094
+ });
1095
+
1096
+ // The new reply run may already be in the tree (run-start raced ahead of the
1097
+ // sendDelegate resolution). Promote now and recompute so the visible set
1098
+ // catches up without waiting for the next structural change.
1099
+ this._resolvePendingRegenSelections();
1100
+ this._recomputeAndEmitIfChanged();
370
1101
  }
371
1102
 
372
- // Spec: AIT-CT5
373
- async regenerate(messageId: string, options?: SendOptions): Promise<ActiveTurn<TEvent>> {
1103
+ // Spec: AIT-CT5, AIT-CT13d
1104
+ async regenerate(messageId: string, options?: SendOptions): Promise<ActiveRun> {
374
1105
  this._logger.trace('DefaultView.regenerate();', { messageId });
375
1106
 
376
- const node = this._tree.getNode(messageId);
377
- if (!node) {
1107
+ if (this._closed) {
1108
+ throw new Ably.ErrorInfo('unable to regenerate; view is closed', ErrorCode.InvalidArgument, 400);
1109
+ }
1110
+
1111
+ // `messageId` is the assistant being regenerated. The new Run is a
1112
+ // continuation of the regenerated message's Run, not a fork: the
1113
+ // message-level replacement (new assistant supersedes the original)
1114
+ // happens at projection extraction time. We still resolve the parent
1115
+ // user prompt so the new assistant's wire `parent` is correct,
1116
+ // and we send the truncated history (through the parent inclusive)
1117
+ // so the LLM re-answers the right message.
1118
+ const targetRun = this._runByCodecMessageId(messageId);
1119
+ if (!targetRun) {
378
1120
  throw new Ably.ErrorInfo(
379
1121
  `unable to regenerate; message not found in tree: ${messageId}`,
380
1122
  ErrorCode.InvalidArgument,
381
1123
  400,
382
1124
  );
383
1125
  }
384
- const parentId = node.parentId;
1126
+ const parentCodecMessageId = this._findParentMsgId(targetRun, messageId);
1127
+ if (!parentCodecMessageId) {
1128
+ throw new Ably.ErrorInfo(
1129
+ `unable to regenerate; parent user message not found for ${messageId}`,
1130
+ ErrorCode.InvalidArgument,
1131
+ 400,
1132
+ );
1133
+ }
385
1134
 
386
- return this.send([], {
1135
+ // Canonical regen anchor: when the user clicks Regenerate on an
1136
+ // already-regenerated assistant, the new alternative SHOULD belong
1137
+ // to the SAME branch point as the previous regen — but ONLY when
1138
+ // the target is the position-equivalent of the group anchor (the
1139
+ // head message of the regenerator Run). For a trailing follow-up
1140
+ // message inside a regenerator Run (e.g. the LLM text after the
1141
+ // regenerated tool call), the user expects the regen to anchor at
1142
+ // the specific message they clicked, not roll up to the group root.
1143
+ // Rebasing trailing regens to the group root produces a confusing
1144
+ // "N+1 / N+1" counter on the tool-call bubble and runs the whole
1145
+ // turn from scratch instead of just regenerating the text.
1146
+ let regenAnchorMsgId = messageId;
1147
+ if (targetRun.regeneratesCodecMessageId !== undefined) {
1148
+ const firstMsg = this._codec.getMessages(targetRun.projection).at(0);
1149
+ if (firstMsg?.codecMessageId === messageId) {
1150
+ regenAnchorMsgId = targetRun.regeneratesCodecMessageId;
1151
+ }
1152
+ }
1153
+
1154
+ const sendOptions: SendOptions = {
387
1155
  ...options,
388
- body: {
389
- history: this._getHistoryBefore(messageId),
390
- ...options?.body,
391
- },
392
- forkOf: messageId,
393
- parent: parentId,
394
- });
1156
+ parent: parentCodecMessageId,
1157
+ };
1158
+
1159
+ // Mint a regenerate input via the codec. The codec's well-known
1160
+ // `Regenerate` carries `target: regenAnchorMsgId` and `parent:
1161
+ // parentCodecMessageId`; the session reads those fields off the input
1162
+ // directly when building transport headers (`fork-of` and
1163
+ // `parent`). The agent's input-event lookup catches the wire signal;
1164
+ // no tree-upsert / projection fold runs locally.
1165
+ const regenerate = this._codec.createRegenerate(regenAnchorMsgId, parentCodecMessageId);
1166
+ const result = await this._sendDelegate([regenerate], sendOptions, parentCodecMessageId);
1167
+ this._applyRegenerateAutoSelect(result, regenAnchorMsgId);
1168
+ return result;
395
1169
  }
396
1170
 
397
1171
  // Spec: AIT-CT6
398
- async edit(
399
- messageId: string,
400
- newMessages: TMessage | TMessage[],
401
- options?: SendOptions,
402
- ): Promise<ActiveTurn<TEvent>> {
1172
+ async edit(messageId: string, inputs: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
403
1173
  this._logger.trace('DefaultView.edit();', { messageId });
404
1174
 
405
- const node = this._tree.getNode(messageId);
406
- if (!node) {
1175
+ if (this._closed) {
1176
+ throw new Ably.ErrorInfo('unable to edit; view is closed', ErrorCode.InvalidArgument, 400);
1177
+ }
1178
+
1179
+ // The edit target is a user prompt — a run-less INPUT node — so resolve
1180
+ // it kind-blind, not via the reply-run-only lookup.
1181
+ const targetNode = this._tree.getNodeByCodecMessageId(messageId);
1182
+ if (!targetNode) {
407
1183
  throw new Ably.ErrorInfo(
408
1184
  `unable to edit; message not found in tree: ${messageId}`,
409
1185
  ErrorCode.InvalidArgument,
410
1186
  400,
411
1187
  );
412
1188
  }
413
- const parentId = node.parentId;
1189
+ const parentCodecMessageId = this._findParentMsgId(targetNode, messageId);
414
1190
 
415
- return this.send(newMessages, {
1191
+ return this.send(inputs, {
416
1192
  ...options,
417
- body: {
418
- history: this._getHistoryBefore(messageId),
419
- ...options?.body,
420
- },
421
1193
  forkOf: messageId,
422
- parent: parentId,
1194
+ parent: parentCodecMessageId,
423
1195
  });
424
1196
  }
425
1197
 
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);
1198
+ /**
1199
+ * Find the codec-message-id of the message immediately preceding `targetMsgId` in
1200
+ * the visible conversation.
1201
+ *
1202
+ * Consults the View's visible message chain first so message-level
1203
+ * replacements (regenerate) are respected: regenerating an
1204
+ * already-regenerated assistant lands the predecessor on the user
1205
+ * prompt the regen is responding to, NOT on the hidden original
1206
+ * assistant that occupies the same conversation slot. Falls back to a
1207
+ * projection-walk for the rare case where `targetMsgId` isn't on the
1208
+ * visible chain (e.g. caller is operating on a Run that's selection-
1209
+ * hidden by the current branch).
1210
+ * @param targetNode - The node (input node or reply run) that owns `targetMsgId`.
1211
+ * @param targetMsgId - The codec-message-id to find the parent of.
1212
+ * @returns The parent codec-message-id, or undefined if no predecessor exists.
1213
+ */
1214
+ private _findParentMsgId(targetNode: ConversationNode<TProjection>, targetMsgId: string): string | undefined {
1215
+ const visible = this._lastVisibleMessagePairs;
1216
+ const visIdx = visible.findIndex((m) => m.codecMessageId === targetMsgId);
1217
+ if (visIdx > 0) {
1218
+ return visible[visIdx - 1]?.codecMessageId;
429
1219
  }
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
- }
1220
+ if (visIdx === 0) return undefined;
434
1221
 
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;
1222
+ const messages = this._codec.getMessages(targetNode.projection);
1223
+ const idx = messages.findIndex((m) => m.codecMessageId === targetMsgId);
1224
+ if (idx > 0) {
1225
+ return messages[idx - 1]?.codecMessageId;
444
1226
  }
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);
1227
+ if (idx === 0 && targetNode.parentCodecMessageId !== undefined) {
1228
+ // The structural predecessor is the node owning parentCodecMessageId
1229
+ // (an input node, or a prior reply run). Its tail message is the parent.
1230
+ const parentNode = this._tree.getNodeByCodecMessageId(targetNode.parentCodecMessageId);
1231
+ if (parentNode) {
1232
+ return this._codec.getMessages(parentNode.projection).at(-1)?.codecMessageId;
464
1233
  }
465
- if (filtered.size > 0) result.set(clientId, filtered);
466
1234
  }
467
- return result;
1235
+ return undefined;
468
1236
  }
469
1237
 
470
1238
  // -------------------------------------------------------------------------
@@ -474,10 +1242,10 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
474
1242
  // Spec: AIT-CT8a, AIT-CT8b, AIT-CT8e
475
1243
  on(event: 'update', handler: () => void): () => void;
476
1244
  on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
477
- on(event: 'turn', handler: (event: TurnLifecycleEvent) => void): () => void;
1245
+ on(event: 'run', handler: (event: RunLifecycleEvent) => void): () => void;
478
1246
  on(
479
- event: 'update' | 'ably-message' | 'turn',
480
- handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: TurnLifecycleEvent) => void),
1247
+ event: 'update' | 'ably-message' | 'run',
1248
+ handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: RunLifecycleEvent) => void),
481
1249
  ): () => void {
482
1250
  // CAST: overload signatures enforce correct handler types per event name.
483
1251
  const cb = handler as (arg: ViewEventsMap[keyof ViewEventsMap]) => void;
@@ -491,10 +1259,8 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
491
1259
  // Lifecycle
492
1260
  // -------------------------------------------------------------------------
493
1261
 
494
- /**
495
- * Tear down the view — unsubscribe from tree events.
496
- */
497
1262
  close(): void {
1263
+ if (this._closed) return;
498
1264
  this._logger.info('DefaultView.close();');
499
1265
  this._closed = true;
500
1266
  this._loadingOlder = false;
@@ -502,8 +1268,11 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
502
1268
  this._unsubs.length = 0;
503
1269
  this._emitter.off();
504
1270
  this._branchSelections.clear();
505
- this._withheldMsgIds.clear();
1271
+ this._regenSelections.clear();
1272
+ this._nonHeadRegenSelections.clear();
1273
+ this._withheldRunIds.clear();
506
1274
  this._withheldBuffer.length = 0;
1275
+ this._hiddenMessageCount = 0;
507
1276
  this._onClose?.();
508
1277
  }
509
1278
 
@@ -511,63 +1280,73 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
511
1280
  // Private: history loading
512
1281
  // -------------------------------------------------------------------------
513
1282
 
514
- 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
1283
+ private async _loadFirstPage(target: number): Promise<void> {
1284
+ // `loadHistory`'s limit and this view's reveal target both count complete
1285
+ // domain messages (codecMessages), so the target passes straight through.
1286
+ const firstPage = await loadHistory(this._channel, { limit: target }, this._logger);
522
1287
  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);
1288
+ await this._revealFromPage(firstPage, target);
538
1289
  }
539
1290
 
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));
1291
+ /**
1292
+ * Walk channel history from `page` until the newly-observed nodes hold at
1293
+ * least `target` codecMessages (or the channel is exhausted), then reveal the
1294
+ * newest whole runs covering `target` and withhold the rest. Snapshots the
1295
+ * already-visible nodes up front so only newly-observed nodes count toward
1296
+ * `target`. No-op if the view closed during the page walk.
1297
+ * @param page - The decoded history page to start from.
1298
+ * @param target - Minimum codecMessages to reveal in this batch.
1299
+ */
1300
+ private async _revealFromPage(page: HistoryPage, target: number): Promise<void> {
1301
+ // Snapshot before loading: every node already in the tree stays visible.
1302
+ const beforeRunIds = new Set(this._treeVisibleNodes().map((n) => nodeKey(n)));
543
1303
 
544
- const { newVisible, lastPage } = await this._loadUntilVisible(page, limit, alreadyKnown);
1304
+ const { newVisible, lastPage } = await this._loadUntilVisible(page, target, beforeRunIds);
545
1305
  if (this._closed) return;
546
1306
  this._lastHistoryPage = lastPage;
547
1307
  this._hasMoreHistory = lastPage.hasNext();
1308
+ this._splitReveal(newVisible, target);
1309
+ }
548
1310
 
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);
1311
+ /**
1312
+ * Reveal the newest whole runs covering `target` codecMessages from
1313
+ * `newVisible` and withhold the rest so subsequent `loadOlder` calls can
1314
+ * drain them. Reveal granularity is the whole run; the caller trims the flat
1315
+ * message list (via `_hiddenMessageCount`) to make the visible message count
1316
+ * exact. Called by {@link _revealFromPage}.
1317
+ * @param newVisible - Newly observed nodes (inputs + reply runs) from the history fetch, chronological.
1318
+ * @param target - Minimum codecMessages the revealed batch must cover.
1319
+ */
1320
+ private _splitReveal(newVisible: ConversationNode<TProjection>[], target: number): void {
1321
+ const splitIdx = this._messageTailSplitIndex(newVisible, target);
1322
+ const batch = newVisible.slice(splitIdx);
1323
+ const withheld = newVisible.slice(0, splitIdx);
555
1324
  for (const n of withheld) {
556
- this._withheldMsgIds.add(n.msgId);
1325
+ this._withheldRunIds.add(nodeKey(n));
557
1326
  }
558
1327
  this._withheldBuffer.push(...withheld);
559
1328
  this._releaseWithheld(batch);
560
1329
  }
561
1330
 
562
- private _processHistoryPage(page: HistoryPage<TMessage>): void {
1331
+ /**
1332
+ * Replay a history page's raw messages into the Tree through the Tree's
1333
+ * single decode-and-apply engine — the same applier (and decoder instance)
1334
+ * the client's live loop uses, so history replay can't drift from it. The
1335
+ * shared decoder's version-guarded trackers make the overlap between the
1336
+ * two routes safe: an in-flight stream that spans the attach boundary is
1337
+ * continued rather than re-started, and content the live route already
1338
+ * incorporated decodes to nothing.
1339
+ * @param page - The history page returned by `loadHistory`.
1340
+ */
1341
+ private _processHistoryPage(page: HistoryPage): void {
563
1342
  this._processingHistory = true;
564
1343
  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);
1344
+ for (const rawMsg of page.rawMessages) {
1345
+ this._applier.apply(rawMsg);
569
1346
  }
570
1347
 
1348
+ // Emit ably-message in a batch AFTER the whole page is applied, so a
1349
+ // subscriber resolving the owning Run sees the fully-rebuilt tree.
571
1350
  for (const msg of page.rawMessages) {
572
1351
  this._tree.emitAblyMessage(msg);
573
1352
  }
@@ -577,17 +1356,19 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
577
1356
  }
578
1357
 
579
1358
  private async _loadUntilVisible(
580
- firstPage: HistoryPage<TMessage>,
1359
+ firstPage: HistoryPage,
581
1360
  target: number,
582
- beforeMsgIds: Set<string>,
583
- ): Promise<{ newVisible: MessageNode<TMessage>[]; lastPage: HistoryPage<TMessage> }> {
1361
+ beforeRunIds: Set<string>,
1362
+ ): Promise<{ newVisible: ConversationNode<TProjection>[]; lastPage: HistoryPage }> {
584
1363
  this._processHistoryPage(firstPage);
585
1364
  let page = firstPage;
586
1365
 
587
1366
  const newVisibleCount = (): number => {
588
1367
  let count = 0;
589
- for (const n of this._tree.flattenNodes(this._resolveSelections())) {
590
- if (!beforeMsgIds.has(n.msgId)) count++;
1368
+ for (const n of this._treeVisibleNodes()) {
1369
+ // Count newly-visible codecMessages toward the target (whole runs are
1370
+ // revealed; the caller trims to the exact message count).
1371
+ if (!beforeRunIds.has(nodeKey(n))) count += this._codec.getMessages(n.projection).length;
591
1372
  }
592
1373
  return count;
593
1374
  };
@@ -599,19 +1380,17 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
599
1380
  page = nextPage;
600
1381
  }
601
1382
 
602
- const newVisible = this._tree.flattenNodes(this._resolveSelections()).filter((n) => !beforeMsgIds.has(n.msgId));
1383
+ const newVisible = this._treeVisibleNodes().filter((n) => !beforeRunIds.has(nodeKey(n)));
603
1384
  return { newVisible, lastPage: page };
604
1385
  }
605
1386
 
606
1387
  // Spec: AIT-CT11a
607
- private _releaseWithheld(nodes: MessageNode<TMessage>[]): void {
1388
+ private _releaseWithheld(nodes: ConversationNode<TProjection>[]): void {
608
1389
  for (const n of nodes) {
609
- this._withheldMsgIds.delete(n.msgId);
1390
+ this._withheldRunIds.delete(nodeKey(n));
610
1391
  }
611
1392
  if (nodes.length > 0) {
612
- this._cachedNodes = this._computeFlatNodes();
613
- this._updateVisibleSnapshot(this._cachedNodes);
614
- this._emitter.emit('update');
1393
+ this._recomputeAndEmit();
615
1394
  }
616
1395
  }
617
1396
 
@@ -619,209 +1398,208 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
619
1398
  // Private: scoped event forwarding
620
1399
  // -------------------------------------------------------------------------
621
1400
 
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
- }
1401
+ private _updateVisibleSnapshot(nodes?: ConversationNode<TProjection>[]): void {
1402
+ const resolved = nodes ?? this._cachedNodes;
1403
+ // Identity key = nodeKey (runId for reply runs, codecMessageId for inputs),
1404
+ // so the visible set scopes events for both kinds and input-node parents.
1405
+ this._lastVisibleNodeKeys = resolved.map((n) => nodeKey(n));
1406
+ this._lastVisibleNodeKeySet = new Set(this._lastVisibleNodeKeys);
1407
+ this._lastVisibleProjections = resolved.map((n) => n.projection);
1408
+ // Run-level reveal, message-level trim: drop the oldest `_hiddenMessageCount`
1409
+ // messages so a `loadOlder` page lands on exactly `limit` messages even
1410
+ // though whole runs were revealed.
1411
+ this._lastVisibleMessagePairs = this._extractMessages(resolved).slice(this._hiddenMessageCount);
631
1412
  }
632
1413
 
633
1414
  private _onTreeUpdate(): void {
634
1415
  // 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.
1416
+ // _processHistoryPage, each tree.applyMessage() fires this handler
1417
+ // synchronously — but _withheldRunIds hasn't been populated yet, so
1418
+ // _computeFlatNodes() would return unfiltered history. Without this guard,
1419
+ // subscribers briefly see all history Runs before the pagination window
1420
+ // is applied. The final update is emitted by _releaseWithheld after
1421
+ // withholding is set up.
642
1422
  if (this._processingHistory) return;
643
1423
 
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;
1424
+ // The Tree emits `update` only on structural change (new/removed Run,
1425
+ // sort-reorder, startSerial promotion, run-start backfill), so every
1426
+ // update reaching here warrants a full re-walk. Content-only folds flow
1427
+ // through `output` (_onTreeOutput) instead.
664
1428
 
665
- // Pin selections for previously-visible nodes that now have siblings.
1429
+ // Pin selections for previously-visible Runs that now have siblings.
666
1430
  // This prevents new forks (from other views' edits/regenerates) from
667
1431
  // shifting this view to a branch the user didn't navigate to.
668
1432
  this._pinBranchSelections();
669
- this._resolvePendingSelections();
1433
+ this._resolvePendingRegenSelections();
1434
+ this._resolvePendingNonHeadRegenSelections();
670
1435
 
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
- }
1436
+ this._recomputeAndEmitIfChanged();
679
1437
  }
680
1438
 
681
1439
  /**
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.
1440
+ * Build the unified selection map the Tree's `visibleNodes` consumes:
1441
+ * `groupRootKey -> selectedKey`, covering both edit forks (input-node groups,
1442
+ * keyed by the input group root) and regenerate groups (reply-run groups,
1443
+ * keyed by the original reply's group root). Pending entries (no chosen
1444
+ * member yet) are omitted so the Tree falls back to the latest sibling.
1445
+ * @returns The merged group-root → selected-key map.
686
1446
  */
687
1447
  private _resolveSelections(): Map<string, string> {
688
1448
  const resolved = new Map<string, string>();
689
1449
  for (const [groupRoot, sel] of this._branchSelections) {
1450
+ resolved.set(groupRoot, sel.selectedKey);
1451
+ }
1452
+ for (const [groupRoot, sel] of this._regenSelections) {
690
1453
  if (sel.kind === 'pending') continue;
691
- resolved.set(groupRoot, sel.selectedId);
1454
+ resolved.set(groupRoot, sel.selectedRunId);
692
1455
  }
693
1456
  return resolved;
694
1457
  }
695
1458
 
696
1459
  /**
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.
1460
+ * The Tree's visible node chain under this view's current selections — the
1461
+ * reachable, sibling-resolved nodes before the View's pagination window is
1462
+ * applied.
1463
+ * @returns The selection-resolved visible node chain.
1464
+ */
1465
+ private _treeVisibleNodes(): ConversationNode<TProjection>[] {
1466
+ return this._tree.visibleNodes(this._resolveSelections());
1467
+ }
1468
+
1469
+ /**
1470
+ * For each previously-visible Run that now has siblings but no explicit
1471
+ * selection, pin the selection to that Run's runId. This preserves the
1472
+ * current branch when new forks appear from other views or external
1473
+ * sources.
701
1474
  *
702
1475
  * 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.
1476
+ * `pending` BranchSelection), select the newest sibling (the awaited Run)
1477
+ * instead of pinning the old one.
706
1478
  */
707
1479
  private _pinBranchSelections(): void {
708
- for (const msgId of this._lastVisibleIds) {
709
- if (!this._tree.hasSiblings(msgId)) continue;
710
- const groupRoot = this._tree.getGroupRoot(msgId);
1480
+ for (const key of this._lastVisibleNodeKeys) {
1481
+ const node = this._tree.getNode(key);
1482
+ // Edit forks are INPUT-node sibling groups; only input nodes pin here.
1483
+ // Regenerate (reply-run) groups roll forward via _resolvePendingRegenSelections.
1484
+ if (node?.kind !== 'input') continue;
1485
+ const siblings = this._tree.getSiblingNodes(key);
1486
+ if (siblings.length <= 1) continue;
1487
+ const groupRoot = this._tree.getGroupRoot(key);
711
1488
  const existing = this._branchSelections.get(groupRoot);
712
1489
 
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
- }
1490
+ // Spec: AIT-CT13f — external edit fork: pin to the currently-visible
1491
+ // sibling so a fork from another view doesn't drift this view's branch.
1492
+ if (existing) continue;
1493
+ this._branchSelections.set(groupRoot, { kind: 'pinned', selectedKey: key });
1494
+ }
1495
+ }
734
1496
 
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 });
1497
+ /**
1498
+ * Roll `pending` and `auto` regenerate selections forward to the newest
1499
+ * group member. A regenerate slot defaults to the latest member, so each
1500
+ * new regenerator (this view's awaited run, or an external one) auto-rolls
1501
+ * the slot forward — UNLESS the user explicitly selected an earlier member
1502
+ * (`user`), which pins and is left untouched. The agent mints the run-id, so
1503
+ * we can't match the awaited run by id — once the group grows we adopt the
1504
+ * newest as the selected member.
1505
+ */
1506
+ private _resolvePendingRegenSelections(): void {
1507
+ for (const [groupRoot, sel] of this._regenSelections) {
1508
+ if (sel.kind === 'user') continue;
1509
+ const group = this._tree.getSiblingNodes(groupRoot).filter((n): n is RunNode<TProjection> => n.kind === 'run');
1510
+ if (group.length <= 1) continue;
1511
+ const newest = group.at(-1);
1512
+ if (!newest) continue;
1513
+ this._regenSelections.set(groupRoot, { kind: 'auto', selectedRunId: newest.runId });
739
1514
  }
740
1515
  }
741
1516
 
742
1517
  /**
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.
1518
+ * Roll `pending` and `auto` non-head regenerate selections forward to the
1519
+ * newest regenerator of their anchor message. Mirrors
1520
+ * {@link _resolvePendingRegenSelections} for the non-head group, which lives in
1521
+ * a separate selection map (anchored by the regenerate target rather than a
1522
+ * sibling-group root): a `user` selection pins and is left untouched; a
1523
+ * `pending`/`auto` slot adopts the newest regenerator once one lands. The
1524
+ * anchor's predecessor — the key the regenerators file under — is recovered
1525
+ * from the owning run's projection.
747
1526
  */
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
- }
1527
+ private _resolvePendingNonHeadRegenSelections(): void {
1528
+ for (const [anchorId, sel] of this._nonHeadRegenSelections) {
1529
+ if (sel.kind === 'user') continue;
1530
+ const owner = this._runByCodecMessageId(anchorId);
1531
+ if (!owner) continue;
1532
+ const ownerMsgs = this._codec.getMessages(owner.projection);
1533
+ const idx = ownerMsgs.findIndex((m) => m.codecMessageId === anchorId);
1534
+ const predecessor = idx > 0 ? ownerMsgs[idx - 1]?.codecMessageId : undefined;
1535
+ if (predecessor === undefined) continue;
1536
+ const newest = this._nonHeadRegenerators(anchorId, predecessor).at(-1);
1537
+ if (!newest) continue;
1538
+ this._nonHeadRegenSelections.set(anchorId, { kind: 'auto', selectedRunId: newest.runId });
764
1539
  }
765
1540
  }
766
1541
 
767
1542
  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
1543
+ // Re-emit only if the message corresponds to a visible Run
1544
+ const headers = getTransportHeaders(msg);
1545
+ const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
1546
+ const runId = headers[HEADER_RUN_ID];
1547
+
1548
+ if (!codecMessageId && !runId) {
1549
+ // Lifecycle / control events with no run/message identity (cancel, error)
1550
+ // are always forwarded.
773
1551
  this._emitter.emit('ably-message', msg);
774
1552
  return;
775
1553
  }
776
- // Check that msgId is on the visible branch and not withheld
777
- if (this._lastVisibleIds.includes(msgId)) {
1554
+
1555
+ if (runId && this._lastVisibleNodeKeySet.has(runId)) {
778
1556
  this._emitter.emit('ably-message', msg);
779
1557
  }
780
1558
  }
781
1559
 
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);
1560
+ private _onTreeRun(event: RunLifecycleEvent): void {
1561
+ // Check if the run is already on the visible branch.
1562
+ if (this._lastVisibleNodeKeySet.has(event.runId)) {
1563
+ this._emitter.emit('run', event);
786
1564
  return;
787
1565
  }
788
1566
 
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);
1567
+ // For run-start, use branch metadata to predict visibility before
1568
+ // messages arrive. Own runs have optimistic inserts (caught above).
1569
+ // Remote runs carry parent/forkOf from the agent.
1570
+ if (event.type === 'start' && this._isRunStartVisible(event)) {
1571
+ this._lastVisibleNodeKeySet.add(event.runId);
1572
+ this._emitter.emit('run', event);
797
1573
  }
798
1574
  }
799
1575
 
800
1576
  /**
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.
1577
+ * Predict whether a run-start's messages will be visible on this view's
1578
+ * branch using the parent/forkOf metadata from the event.
1579
+ * @param event - The run-start lifecycle event.
1580
+ * @returns True if the run is expected to be visible on this view's branch.
805
1581
  */
806
- private _isTurnStartVisible(event: TurnLifecycleEvent & { type: typeof EVENT_TURN_START }): boolean {
1582
+ private _isRunStartVisible(event: RunLifecycleEvent & { type: 'start' }): boolean {
807
1583
  const { parent } = event;
808
1584
 
809
1585
  // No parent metadata — can't determine branch, forward as default.
810
- // This covers root turns (parent omitted) and backward compat.
811
1586
  if (parent === undefined) return true;
812
1587
 
813
- // Check if the parent is on the visible branch
814
- return this._lastVisibleIds.includes(parent);
1588
+ // The wire `parent` is a codec-message-id (the prior message). Resolve it
1589
+ // kind-blind to its owning NODE — an input node (the user prompt this run
1590
+ // replies to) or a prior reply run — and check that node's key against the
1591
+ // visible set. Input-node keys are populated into the set by
1592
+ // _updateVisibleSnapshot.
1593
+ const parentNode = this._tree.getNodeByCodecMessageId(parent);
1594
+ if (!parentNode) return true; // unknown parent: forward conservatively
1595
+ return this._lastVisibleNodeKeySet.has(nodeKey(parentNode));
815
1596
  }
816
1597
 
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;
1598
+ private _visibleChanged(newNodes: ConversationNode<TProjection>[]): boolean {
1599
+ if (newNodes.length !== this._lastVisibleNodeKeys.length) return true;
1600
+ for (const [i, node] of newNodes.entries()) {
1601
+ if (nodeKey(node) !== this._lastVisibleNodeKeys[i]) return true;
1602
+ if (node.projection !== this._lastVisibleProjections[i]) return true;
825
1603
  }
826
1604
  return false;
827
1605
  }
@@ -836,5 +1614,6 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
836
1614
  * @param options - The tree, channel, codec, and logger to use.
837
1615
  * @returns A new {@link DefaultView} instance.
838
1616
  */
839
- export const createView = <TEvent, TMessage>(options: ViewOptions<TEvent, TMessage>): DefaultView<TEvent, TMessage> =>
840
- new DefaultView(options);
1617
+ export const createView = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage>(
1618
+ options: ViewOptions<TInput, TOutput, TProjection, TMessage>,
1619
+ ): DefaultView<TInput, TOutput, TProjection, TMessage> => new DefaultView(options);