@ably/ai-transport 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +114 -116
  2. package/dist/ably-ai-transport.js +1743 -961
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +117 -39
  7. package/dist/core/agent.d.ts +29 -0
  8. package/dist/core/codec/decoder.d.ts +20 -23
  9. package/dist/core/codec/encoder.d.ts +11 -8
  10. package/dist/core/codec/index.d.ts +1 -2
  11. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  12. package/dist/core/codec/types.d.ts +410 -101
  13. package/dist/core/transport/agent-session.d.ts +10 -0
  14. package/dist/core/transport/branch-chain.d.ts +43 -0
  15. package/dist/core/transport/client-session.d.ts +13 -0
  16. package/dist/core/transport/decode-fold.d.ts +47 -0
  17. package/dist/core/transport/headers.d.ts +97 -17
  18. package/dist/core/transport/index.d.ts +5 -3
  19. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  20. package/dist/core/transport/invocation.d.ts +74 -0
  21. package/dist/core/transport/load-conversation.d.ts +128 -0
  22. package/dist/core/transport/load-history.d.ts +39 -0
  23. package/dist/core/transport/pipe-stream.d.ts +9 -8
  24. package/dist/core/transport/run-manager.d.ts +78 -0
  25. package/dist/core/transport/tree.d.ts +435 -0
  26. package/dist/core/transport/types/agent.d.ts +353 -0
  27. package/dist/core/transport/types/client.d.ts +168 -0
  28. package/dist/core/transport/types/shared.d.ts +24 -0
  29. package/dist/core/transport/types/tree.d.ts +315 -0
  30. package/dist/core/transport/types/view.d.ts +222 -0
  31. package/dist/core/transport/types.d.ts +13 -402
  32. package/dist/core/transport/view.d.ts +354 -0
  33. package/dist/errors.d.ts +37 -9
  34. package/dist/index.d.ts +6 -6
  35. package/dist/logger.d.ts +12 -0
  36. package/dist/react/ably-ai-transport-react.js +1164 -645
  37. package/dist/react/ably-ai-transport-react.js.map +1 -1
  38. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  39. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  40. package/dist/react/contexts/client-session-context.d.ts +36 -0
  41. package/dist/react/contexts/client-session-provider.d.ts +53 -0
  42. package/dist/react/create-session-hooks.d.ts +116 -0
  43. package/dist/react/index.d.ts +16 -10
  44. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  45. package/dist/react/use-ably-messages.d.ts +20 -11
  46. package/dist/react/use-client-session.d.ts +81 -0
  47. package/dist/react/use-create-view.d.ts +23 -0
  48. package/dist/react/use-tree.d.ts +35 -0
  49. package/dist/react/use-view.d.ts +110 -0
  50. package/dist/utils.d.ts +32 -23
  51. package/dist/vercel/ably-ai-transport-vercel.js +2748 -1625
  52. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  53. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  55. package/dist/vercel/codec/decoder.d.ts +5 -18
  56. package/dist/vercel/codec/encoder.d.ts +6 -36
  57. package/dist/vercel/codec/events.d.ts +51 -0
  58. package/dist/vercel/codec/index.d.ts +24 -12
  59. package/dist/vercel/codec/reducer.d.ts +144 -0
  60. package/dist/vercel/codec/tool-transitions.d.ts +50 -0
  61. package/dist/vercel/index.d.ts +4 -2
  62. package/dist/vercel/react/ably-ai-transport-vercel-react.js +10298 -1410
  63. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  64. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +70 -1
  65. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  66. package/dist/vercel/react/contexts/chat-transport-context.d.ts +33 -0
  67. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +96 -0
  68. package/dist/vercel/react/index.d.ts +4 -0
  69. package/dist/vercel/react/use-chat-transport.d.ts +66 -21
  70. package/dist/vercel/react/use-message-sync.d.ts +31 -12
  71. package/dist/vercel/run-end-reason.d.ts +29 -0
  72. package/dist/vercel/transport/chat-transport.d.ts +71 -30
  73. package/dist/vercel/transport/index.d.ts +25 -18
  74. package/dist/vercel/transport/run-output-stream.d.ts +56 -0
  75. package/dist/version.d.ts +2 -0
  76. package/package.json +47 -34
  77. package/src/constants.ts +126 -47
  78. package/src/core/agent.ts +68 -0
  79. package/src/core/codec/decoder.ts +71 -98
  80. package/src/core/codec/encoder.ts +115 -58
  81. package/src/core/codec/index.ts +13 -6
  82. package/src/core/codec/lifecycle-tracker.ts +10 -9
  83. package/src/core/codec/types.ts +438 -106
  84. package/src/core/transport/agent-session.ts +1344 -0
  85. package/src/core/transport/branch-chain.ts +58 -0
  86. package/src/core/transport/client-session.ts +775 -0
  87. package/src/core/transport/decode-fold.ts +91 -0
  88. package/src/core/transport/headers.ts +182 -19
  89. package/src/core/transport/index.ts +29 -22
  90. package/src/core/transport/internal/bounded-map.ts +27 -0
  91. package/src/core/transport/invocation.ts +98 -0
  92. package/src/core/transport/load-conversation.ts +355 -0
  93. package/src/core/transport/load-history.ts +269 -0
  94. package/src/core/transport/pipe-stream.ts +58 -40
  95. package/src/core/transport/run-manager.ts +249 -0
  96. package/src/core/transport/tree.ts +1167 -0
  97. package/src/core/transport/types/agent.ts +407 -0
  98. package/src/core/transport/types/client.ts +211 -0
  99. package/src/core/transport/types/shared.ts +27 -0
  100. package/src/core/transport/types/tree.ts +344 -0
  101. package/src/core/transport/types/view.ts +259 -0
  102. package/src/core/transport/types.ts +13 -527
  103. package/src/core/transport/view.ts +1271 -0
  104. package/src/errors.ts +42 -9
  105. package/src/event-emitter.ts +3 -2
  106. package/src/index.ts +55 -39
  107. package/src/logger.ts +14 -1
  108. package/src/react/contexts/client-session-context.ts +41 -0
  109. package/src/react/contexts/client-session-provider.tsx +186 -0
  110. package/src/react/create-session-hooks.ts +141 -0
  111. package/src/react/index.ts +27 -10
  112. package/src/react/internal/use-resolved-session.ts +63 -0
  113. package/src/react/use-ably-messages.ts +47 -19
  114. package/src/react/use-client-session.ts +201 -0
  115. package/src/react/use-create-view.ts +72 -0
  116. package/src/react/use-tree.ts +84 -0
  117. package/src/react/use-view.ts +275 -0
  118. package/src/react/vite.config.ts +4 -1
  119. package/src/utils.ts +63 -45
  120. package/src/vercel/codec/decoder.ts +336 -255
  121. package/src/vercel/codec/encoder.ts +348 -196
  122. package/src/vercel/codec/events.ts +87 -0
  123. package/src/vercel/codec/index.ts +59 -14
  124. package/src/vercel/codec/reducer.ts +977 -0
  125. package/src/vercel/codec/tool-transitions.ts +122 -0
  126. package/src/vercel/index.ts +7 -3
  127. package/src/vercel/react/contexts/chat-transport-context.ts +41 -0
  128. package/src/vercel/react/contexts/chat-transport-provider.tsx +150 -0
  129. package/src/vercel/react/index.ts +13 -1
  130. package/src/vercel/react/use-chat-transport.ts +162 -42
  131. package/src/vercel/react/use-message-sync.ts +121 -22
  132. package/src/vercel/react/vite.config.ts +4 -2
  133. package/src/vercel/run-end-reason.ts +78 -0
  134. package/src/vercel/transport/chat-transport.ts +553 -113
  135. package/src/vercel/transport/index.ts +40 -28
  136. package/src/vercel/transport/run-output-stream.ts +170 -0
  137. package/src/version.ts +2 -0
  138. package/dist/core/transport/client-transport.d.ts +0 -10
  139. package/dist/core/transport/conversation-tree.d.ts +0 -9
  140. package/dist/core/transport/decode-history.d.ts +0 -41
  141. package/dist/core/transport/server-transport.d.ts +0 -7
  142. package/dist/core/transport/stream-router.d.ts +0 -19
  143. package/dist/core/transport/turn-manager.d.ts +0 -34
  144. package/dist/react/use-active-turns.d.ts +0 -8
  145. package/dist/react/use-client-transport.d.ts +0 -7
  146. package/dist/react/use-conversation-tree.d.ts +0 -20
  147. package/dist/react/use-edit.d.ts +0 -7
  148. package/dist/react/use-history.d.ts +0 -19
  149. package/dist/react/use-messages.d.ts +0 -7
  150. package/dist/react/use-regenerate.d.ts +0 -7
  151. package/dist/react/use-send.d.ts +0 -7
  152. package/dist/vercel/codec/accumulator.d.ts +0 -21
  153. package/src/core/transport/client-transport.ts +0 -959
  154. package/src/core/transport/conversation-tree.ts +0 -434
  155. package/src/core/transport/decode-history.ts +0 -337
  156. package/src/core/transport/server-transport.ts +0 -458
  157. package/src/core/transport/stream-router.ts +0 -118
  158. package/src/core/transport/turn-manager.ts +0 -147
  159. package/src/react/use-active-turns.ts +0 -61
  160. package/src/react/use-client-transport.ts +0 -37
  161. package/src/react/use-conversation-tree.ts +0 -71
  162. package/src/react/use-edit.ts +0 -24
  163. package/src/react/use-history.ts +0 -111
  164. package/src/react/use-messages.ts +0 -32
  165. package/src/react/use-regenerate.ts +0 -24
  166. package/src/react/use-send.ts +0 -25
  167. package/src/vercel/codec/accumulator.ts +0 -603
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Agent-side conversation reconstruction from the channel wire log.
3
+ *
4
+ * When an agent wakes (or resumes) it has no in-memory tree — it rebuilds the
5
+ * state it needs by paging channel history and folding the wires through the
6
+ * codec. Two entry points:
7
+ *
8
+ * - {@link loadRunProjection} — fold a single run's wires into one projection
9
+ * (used to resume a suspended run with its client tool-output amends).
10
+ * - {@link loadConversation} — walk the structural parent chain from the
11
+ * current run's input node to the root and fold each node, producing the full
12
+ * multi-turn message history along the taken branch.
13
+ *
14
+ * Both reuse {@link foldMessageInto} (the shared per-message fold primitive) and
15
+ * {@link buildBranchChain} (the shared parent-chain walk), so the agent's
16
+ * reconstruction can't drift from the client/View decode paths.
17
+ */
18
+
19
+ import * as Ably from 'ably';
20
+
21
+ import { EVENT_RUN_START, HEADER_CODEC_MESSAGE_ID, HEADER_PARENT, HEADER_RUN_ID } from '../../constants.js';
22
+ import { ErrorCode } from '../../errors.js';
23
+ import type { Logger } from '../../logger.js';
24
+ import { compareBySerial, getTransportHeaders } from '../../utils.js';
25
+ import type { Codec, CodecInputEvent, CodecOutputEvent } from '../codec/types.js';
26
+ import type { BranchChainNode } from './branch-chain.js';
27
+ import { buildBranchChain } from './branch-chain.js';
28
+ import { foldMessageInto } from './decode-fold.js';
29
+ import { isRunLifecycleName } from './headers.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // History collection + dedup
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Merge messages observed live (e.g. by the input-event lookup) into a set of
37
+ * collected history messages, dedup by serial, and sort chronologically.
38
+ *
39
+ * History messages take priority in deduplication (history serial wins if the
40
+ * same message appears in both). Messages without a serial are dropped because
41
+ * they cannot be reliably ordered.
42
+ * @param collected - Raw messages from channel.history (any order).
43
+ * @param live - Messages observed live (e.g. by the input-event lookup); may be undefined.
44
+ * @returns Deduplicated, chronologically sorted messages.
45
+ */
46
+ export const withLiveMessages = (
47
+ collected: readonly Ably.InboundMessage[],
48
+ live?: readonly Ably.InboundMessage[],
49
+ ): Ably.InboundMessage[] => {
50
+ const seen = new Set<string>();
51
+ const result: Ably.InboundMessage[] = [];
52
+ for (const msg of collected) {
53
+ if (msg.serial !== undefined && !seen.has(msg.serial)) {
54
+ seen.add(msg.serial);
55
+ result.push(msg);
56
+ }
57
+ }
58
+ if (live !== undefined) {
59
+ for (const msg of live) {
60
+ if (msg.serial !== undefined && !seen.has(msg.serial)) {
61
+ seen.add(msg.serial);
62
+ result.push(msg);
63
+ }
64
+ }
65
+ }
66
+ return result.toSorted(compareBySerial);
67
+ };
68
+
69
+ /**
70
+ * Page through a channel's history and collect raw messages, bounded so a
71
+ * long-lived channel can't exhaust memory. No `untilAttach` — callers need
72
+ * messages published after the channel first attached (e.g. client tool-output
73
+ * amends on a suspended run).
74
+ * @param channel - The Ably channel to read history from.
75
+ * @param pageLimit - Messages requested per history page.
76
+ * @param maxMessages - Stop paging once this many messages are collected.
77
+ * @returns The collected messages in history order (newest first per Ably).
78
+ */
79
+ const collectHistory = async (
80
+ channel: Ably.RealtimeChannel,
81
+ pageLimit: number,
82
+ maxMessages: number,
83
+ ): Promise<Ably.InboundMessage[]> => {
84
+ const collected: Ably.InboundMessage[] = [];
85
+ let page = await channel.history({ limit: pageLimit });
86
+ collected.push(...page.items);
87
+ while (page.hasNext() && collected.length < maxMessages) {
88
+ const nextPage: Ably.PaginatedResult<Ably.InboundMessage> | null = await page.next();
89
+ if (!nextPage) break;
90
+ collected.push(...nextPage.items);
91
+ page = nextPage;
92
+ }
93
+ return collected;
94
+ };
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Per-node folds
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Fold a pre-sorted array of wire messages for a single run into a projection.
102
+ *
103
+ * Skips lifecycle events (they carry no codec content) and stops before the
104
+ * message whose `codec-message-id` equals `truncateAt` (exclusive — that
105
+ * message is not folded). Used by both {@link loadRunProjection} (no
106
+ * truncation) and {@link loadConversation} (per-ancestor folding).
107
+ * @param codec - Codec used to decode and fold events.
108
+ * @param sortedMessages - Chronologically ordered wire messages (all runs).
109
+ * @param runId - Only messages stamped with this run-id are folded.
110
+ * @param truncateAt - Stop before this codec-message-id; omit to fold all messages.
111
+ * @returns The projection and the count of messages that were folded.
112
+ */
113
+ export const foldRunMessages = <
114
+ TInput extends CodecInputEvent,
115
+ TOutput extends CodecOutputEvent,
116
+ TProjection,
117
+ TMessage,
118
+ >(
119
+ codec: Codec<TInput, TOutput, TProjection, TMessage>,
120
+ sortedMessages: readonly Ably.InboundMessage[],
121
+ runId: string,
122
+ truncateAt?: string,
123
+ ): { projection: TProjection; folded: number } => {
124
+ const decoder = codec.createDecoder();
125
+ let projection = codec.init();
126
+ let folded = 0;
127
+ for (const msg of sortedMessages) {
128
+ const h = getTransportHeaders(msg);
129
+ if (h[HEADER_RUN_ID] !== runId) continue;
130
+ if (isRunLifecycleName(msg.name)) continue;
131
+ const codecMsgId = h[HEADER_CODEC_MESSAGE_ID];
132
+ if (truncateAt !== undefined && codecMsgId === truncateAt) break;
133
+ projection = foldMessageInto(codec, decoder, projection, msg, codecMsgId ?? '');
134
+ folded++;
135
+ }
136
+ return { projection, folded };
137
+ };
138
+
139
+ /**
140
+ * Fold a single run-less INPUT node's events into a fresh projection: every
141
+ * wire stamped with `codecMessageId` and NO run-id (the user prompt the client
142
+ * published before the agent minted a run-id). The two-node analogue of
143
+ * {@link foldRunMessages} for the user-input side of the conversation chain.
144
+ * @param codec - Codec used to decode and fold events.
145
+ * @param sortedMessages - Chronologically ordered wire messages (all runs).
146
+ * @param codecMessageId - The input node's codec-message-id.
147
+ * @returns The folded projection for that input node.
148
+ */
149
+ export const foldInputMessages = <
150
+ TInput extends CodecInputEvent,
151
+ TOutput extends CodecOutputEvent,
152
+ TProjection,
153
+ TMessage,
154
+ >(
155
+ codec: Codec<TInput, TOutput, TProjection, TMessage>,
156
+ sortedMessages: readonly Ably.InboundMessage[],
157
+ codecMessageId: string,
158
+ ): TProjection => {
159
+ const decoder = codec.createDecoder();
160
+ let projection = codec.init();
161
+ for (const msg of sortedMessages) {
162
+ const h = getTransportHeaders(msg);
163
+ if (h[HEADER_RUN_ID] !== undefined) continue;
164
+ if (h[HEADER_CODEC_MESSAGE_ID] !== codecMessageId) continue;
165
+ projection = foldMessageInto(codec, decoder, projection, msg, codecMessageId);
166
+ }
167
+ return projection;
168
+ };
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Run-state reconstruction
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Fetch all messages on the channel that belong to `runId`, decode them
176
+ * through the codec, and fold them into a single projection. Used by the agent
177
+ * to reconstruct a run's full state — including client-published tool-output
178
+ * amends — when resuming a suspended run in a fresh agent session.
179
+ *
180
+ * Doesn't require channel rewind: an explicit `channel.history()` call returns
181
+ * the same data even if the channel is already attached from a prior session.
182
+ * @param opts - Load parameters.
183
+ * @param opts.channel - The Ably channel to read history from.
184
+ * @param opts.codec - Codec used to decode and fold events.
185
+ * @param opts.runId - Run identifier whose events should be folded.
186
+ * @param opts.signal - AbortSignal checked once at entry: if already aborted the call throws immediately and no history is fetched. It does not interrupt an in-flight load.
187
+ * @param opts.logger - Optional logger for diagnostic output.
188
+ * @param opts.liveMessages - Raw Ably messages already observed live (e.g. by
189
+ * the input-event lookup). Folded alongside the history fetch so just-published
190
+ * client wires don't depend on Ably's history-indexing window.
191
+ * @returns The projection produced by folding all run events in serial order.
192
+ * @throws {Ably.ErrorInfo} with code ErrorCode.InvalidArgument if `signal` is already aborted at entry (the run was cancelled before loading began).
193
+ */
194
+ export const loadRunProjection = async <
195
+ TInput extends CodecInputEvent,
196
+ TOutput extends CodecOutputEvent,
197
+ TProjection,
198
+ TMessage,
199
+ >(opts: {
200
+ channel: Ably.RealtimeChannel;
201
+ codec: Codec<TInput, TOutput, TProjection, TMessage>;
202
+ runId: string;
203
+ signal: AbortSignal;
204
+ logger: Logger | undefined;
205
+ liveMessages?: readonly Ably.InboundMessage[];
206
+ }): Promise<TProjection> => {
207
+ const { channel, codec, runId, signal, logger, liveMessages } = opts;
208
+
209
+ if (signal.aborted) {
210
+ throw new Ably.ErrorInfo(
211
+ `unable to load run projection; run ${runId} was cancelled`,
212
+ ErrorCode.InvalidArgument,
213
+ 400,
214
+ );
215
+ }
216
+
217
+ await channel.attach();
218
+
219
+ // 2000 wire messages is generously more than any single run could produce.
220
+ const collected = await collectHistory(channel, 200, 2000);
221
+
222
+ const sorted = withLiveMessages(collected, liveMessages);
223
+ const { projection, folded } = foldRunMessages(codec, sorted, runId);
224
+
225
+ logger?.debug('loadRunProjection(); folded run events', { runId, folded });
226
+ return projection;
227
+ };
228
+
229
+ /** A node in the reconstruction index — {@link BranchChainNode} plus its run-id. */
230
+ interface NodeMeta extends BranchChainNode {
231
+ /** The run-id this node belongs to, or undefined for a run-less input node. */
232
+ runId: string | undefined;
233
+ }
234
+
235
+ /**
236
+ * Reconstruct the full multi-turn conversation history along the branch the
237
+ * current run sits on.
238
+ *
239
+ * Pages channel history (merging live lookup messages), indexes each
240
+ * codec-message-id's structural parent and run-id with sticky identity (the
241
+ * first wire wins; later amends can't poison it), backfills a reply run's
242
+ * parent from its `ai-run-start` when the output wire wasn't indexed, then
243
+ * walks the structural parent chain from the current run's input node
244
+ * (`assistantParentFallback`) to the root and folds each node in chain order.
245
+ * The current run is folded once, wholesale, at the tail.
246
+ * @param opts - Reconstruction parameters.
247
+ * @param opts.channel - The Ably channel to read history from.
248
+ * @param opts.codec - Codec used to decode and fold events.
249
+ * @param opts.runId - The current run's id.
250
+ * @param opts.signal - AbortSignal checked once at entry; if already aborted the call throws before any history is fetched. It does not interrupt an in-flight load.
251
+ * @param opts.logger - Optional logger for diagnostic output.
252
+ * @param opts.liveMessages - Wires already observed live, merged into history.
253
+ * @param opts.assistantParentFallback - The current run's input node
254
+ * (codec-message-id) — the anchor the parent-chain walk starts from. When
255
+ * undefined, only the current run is folded.
256
+ * @param opts.pageLimit - Messages requested per history page.
257
+ * @param opts.maxMessages - Stop paging once this many messages are collected.
258
+ * @returns The branch's messages (root-first) and the current run's projection.
259
+ * @throws {Ably.ErrorInfo} with code ErrorCode.InvalidArgument when `signal` is already aborted at entry.
260
+ */
261
+ export const loadConversation = async <
262
+ TInput extends CodecInputEvent,
263
+ TOutput extends CodecOutputEvent,
264
+ TProjection,
265
+ TMessage,
266
+ >(opts: {
267
+ channel: Ably.RealtimeChannel;
268
+ codec: Codec<TInput, TOutput, TProjection, TMessage>;
269
+ runId: string;
270
+ signal: AbortSignal;
271
+ logger: Logger | undefined;
272
+ liveMessages: readonly Ably.InboundMessage[] | undefined;
273
+ assistantParentFallback: string | undefined;
274
+ pageLimit: number;
275
+ maxMessages: number;
276
+ }): Promise<{ messages: TMessage[]; projection: TProjection }> => {
277
+ const { channel, codec, runId, signal, logger, liveMessages, assistantParentFallback, pageLimit, maxMessages } = opts;
278
+
279
+ if (signal.aborted) {
280
+ throw new Ably.ErrorInfo(`unable to load conversation; run ${runId} was cancelled`, ErrorCode.InvalidArgument, 400);
281
+ }
282
+
283
+ // Single channel.history() fetch for all runs. Live lookup messages are
284
+ // merged in so the current run's just-published client wires don't depend on
285
+ // Ably's history-indexing window. Deduped by serial (history wins), sorted.
286
+ const collected = await collectHistory(channel, pageLimit, maxMessages);
287
+ const sortedMessages = withLiveMessages(collected, liveMessages);
288
+
289
+ // Index pass — node metadata per codec-message-id from the serial-sorted
290
+ // history, with sticky identity (the first wire for a codec-message-id wins
291
+ // for the structural parent; a later amend can't poison it). Run-bearing
292
+ // wires record their runId; run-less user inputs are input nodes (runId
293
+ // undefined).
294
+ const nodeMeta = new Map<string, NodeMeta>();
295
+ const runIdToCodecMessageId = new Map<string, string>();
296
+ for (const msg of sortedMessages) {
297
+ if (isRunLifecycleName(msg.name)) continue;
298
+ const h = getTransportHeaders(msg);
299
+ const cid = h[HEADER_CODEC_MESSAGE_ID];
300
+ if (cid === undefined) continue;
301
+ const msgRunId = h[HEADER_RUN_ID];
302
+ if (msgRunId !== undefined) runIdToCodecMessageId.set(msgRunId, cid);
303
+ if (!nodeMeta.has(cid)) {
304
+ nodeMeta.set(cid, { runId: msgRunId, parentCodecMessageId: h[HEADER_PARENT] });
305
+ }
306
+ }
307
+ // Backfill a reply run's structural parent from ai-run-start when its output
308
+ // wire wasn't indexed (rare history lag). Keyed by runId → codec-message-id.
309
+ for (const msg of sortedMessages) {
310
+ if (msg.name !== EVENT_RUN_START) continue;
311
+ const h = getTransportHeaders(msg);
312
+ const msgRunId = h[HEADER_RUN_ID];
313
+ if (msgRunId === undefined) continue;
314
+ const cid = runIdToCodecMessageId.get(msgRunId);
315
+ if (cid === undefined) continue;
316
+ const meta = nodeMeta.get(cid);
317
+ if (meta && meta.parentCodecMessageId === undefined) meta.parentCodecMessageId = h[HEADER_PARENT];
318
+ }
319
+
320
+ // Walk the structural parent chain from the current run's input node up to
321
+ // the conversation root, then fold each node in chain order. The upward walk
322
+ // naturally excludes un-taken branch siblings (an edit's alternate prompt, a
323
+ // regenerate's superseded reply), so no per-ancestor truncation is needed.
324
+ // (Open caveat, deferred with a golden test: regenerating a non-trailing
325
+ // message of a multi-message reply — the node walk can't slice inside one
326
+ // run's projection.)
327
+ const messages: TMessage[] = [];
328
+ let chainLength = 0;
329
+ if (assistantParentFallback !== undefined) {
330
+ const chain = buildBranchChain(nodeMeta, assistantParentFallback);
331
+ chainLength = chain.length;
332
+ for (const cid of chain) {
333
+ const meta = nodeMeta.get(cid);
334
+ // Skip any chain node belonging to the CURRENT run — it is folded once,
335
+ // wholesale, at the tail below. For a continuation the run-id is reused
336
+ // and `assistantParentFallback` points at a message INSIDE the current
337
+ // run, so it would otherwise be folded twice and emit duplicate tool_use
338
+ // ids.
339
+ if (meta?.runId === runId) continue;
340
+ const projection =
341
+ meta?.runId === undefined
342
+ ? foldInputMessages(codec, sortedMessages, cid)
343
+ : foldRunMessages(codec, sortedMessages, meta.runId).projection;
344
+ messages.push(...codec.getMessages(projection).map((m) => m.message));
345
+ }
346
+ }
347
+
348
+ // Current run — folded from the same sorted messages, appended at the chain
349
+ // tail (the chain ended at this run's input node).
350
+ const { projection, folded } = foldRunMessages(codec, sortedMessages, runId);
351
+ messages.push(...codec.getMessages(projection).map((m) => m.message));
352
+
353
+ logger?.debug('loadConversation(); built', { runId, chainLength, totalMessages: messages.length, folded });
354
+ return { messages, projection };
355
+ };
@@ -0,0 +1,269 @@
1
+ /**
2
+ * loadHistory — load conversation history from an Ably channel and return
3
+ * the raw wire messages as a paginated HistoryPage result.
4
+ *
5
+ * This does NOT decode: it pages back through Ably history until `limit`
6
+ * complete messages are present, then hands the raw Ably messages
7
+ * (oldest-first) to the caller. The View re-decodes them into the Tree
8
+ * itself, so load-history only needs a cheap, header-based completion
9
+ * counter to decide when to stop paging — the decoder never runs here.
10
+ *
11
+ * The `limit` option controls the number of complete **messages** per page,
12
+ * not the number of Ably wire messages fetched. A message is complete when
13
+ * its terminal wire signal — `status: "complete"` / `"cancelled"`, or a
14
+ * `discrete` create — has been seen. Runs that span a
15
+ * page boundary are handled by the counter requiring both a start and a
16
+ * terminal signal before counting a message complete.
17
+ *
18
+ * Because Ably history returns newest-first, each page's `rawMessages` are
19
+ * reversed to chronological (oldest-first) so the caller can fold them in
20
+ * order.
21
+ */
22
+
23
+ import type * as Ably from 'ably';
24
+
25
+ import { HEADER_CODEC_MESSAGE_ID, HEADER_DISCRETE, HEADER_STATUS, HEADER_STREAM } from '../../constants.js';
26
+ import type { Logger } from '../../logger.js';
27
+ import { getTransportHeaders } from '../../utils.js';
28
+ import type { HistoryPage, LoadHistoryOptions } from './types.js';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Shared state across pages within one history traversal
32
+ // ---------------------------------------------------------------------------
33
+
34
+ interface HistoryState {
35
+ /** All raw Ably messages collected so far, in newest-first order (as received from Ably). */
36
+ rawMessages: Ably.InboundMessage[];
37
+ /**
38
+ * How many complete messages have been served to the consumer so far.
39
+ * Drives the buffered-page logic: when a single fetch gathers more than
40
+ * `limit` completions, later pages are served from the buffer without
41
+ * fetching, advancing this counter `limit` at a time.
42
+ */
43
+ returnedCount: number;
44
+ /** How many raw Ably messages have been served to the consumer so far. */
45
+ returnedRawCount: number;
46
+ /** The last Ably page cursor for continued pagination. */
47
+ lastAblyPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined;
48
+ /**
49
+ * `codec-message-id`s for which a start signal has been seen: any
50
+ * `message.create` / `message.update` / `message.append` with
51
+ * `stream: "true"` (the decoder establishes a tracker via create or
52
+ * first-contact), or a `message.create` carrying `discrete` (a discrete
53
+ * message, created and terminated in one wire message).
54
+ */
55
+ startedCodecMessageIds: Set<string>;
56
+ /**
57
+ * `codec-message-id`s with a terminal wire signal: either `discrete`
58
+ * on a `message.create` (discrete message) or `status: "complete"`
59
+ * / `"cancelled"` on any action (closed stream).
60
+ */
61
+ terminatedCodecMessageIds: Set<string>;
62
+ /**
63
+ * `codec-message-id`s that are both started AND terminated — counted as
64
+ * complete. The fetch loop reads this set's size to decide when to stop
65
+ * paging. Maintained incrementally by {@link countNewCompletions}. Grows
66
+ * monotonically.
67
+ */
68
+ completedCodecMessageIds: Set<string>;
69
+ logger: Logger;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Incremental completion counting (header scan, no decode)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Scan newly-added raw messages and track which `codec-message-id`s have
78
+ * become complete. Used by {@link fetchUntilLimit} to decide when enough
79
+ * completed messages have been collected, without running the decoder.
80
+ *
81
+ * A codec-message-id is considered complete only when BOTH of these have been seen:
82
+ * - a "start" signal: either `discrete` on a `message.create`
83
+ * (discrete messages are created and terminated by the same wire
84
+ * message), OR any `message.create` / `message.update` / `message.append`
85
+ * with `stream: "true"` (the decoder establishes a tracker via
86
+ * create or first-contact).
87
+ * - a "terminal" signal: `discrete` on the create, or
88
+ * `status: "complete"` / `"cancelled"` on any later action.
89
+ *
90
+ * Why update and append count as starts: Ably history can compact a live
91
+ * `create + append + ... + append{status:complete}` sequence into a single
92
+ * `message.update` with `STREAM=true` and `STATUS=complete`. The decoder
93
+ * handles that via first-contact. Counting only `message.create` as a start
94
+ * would cause the fetch loop to page past a compacted run without ever
95
+ * marking it complete.
96
+ *
97
+ * Requiring both halves matters when a streaming run spans a page
98
+ * boundary: the terminal arrives in the newer page (fetched first) while
99
+ * the start sits in an older page. Counting the terminal alone would stop
100
+ * the fetch loop prematurely - the decoder would have no stream state to
101
+ * resolve, and the message wouldn't make it into the result.
102
+ *
103
+ * Messages skipped for counting:
104
+ * - Missing `codec-message-id`: lifecycle events not tied to a domain message.
105
+ * - `message.delete`: clears the tracker, doesn't produce output.
106
+ *
107
+ * Amend-class wire messages (events targeting an existing message via
108
+ * `HEADER_CODEC_MESSAGE_ID`) flow through the same counter — the Sets naturally
109
+ * dedup so a tool-output amend on an already-seen codec-message-id is idempotent.
110
+ *
111
+ * Known edge case: if Ably history is truncated and a terminal survives
112
+ * while every start signal for its codec-message-id has rolled off, the counter will
113
+ * never mark that `codec-message-id` complete. The loop keeps fetching until it runs
114
+ * out of pages, then returns whatever raw messages it collected.
115
+ * @param state - The shared history traversal state.
116
+ * @param newMessages - The Ably messages just pushed onto `state.rawMessages`.
117
+ */
118
+ const countNewCompletions = (state: HistoryState, newMessages: readonly Ably.InboundMessage[]): void => {
119
+ for (const msg of newMessages) {
120
+ const headers = getTransportHeaders(msg);
121
+ const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
122
+ if (!codecMessageId) continue;
123
+
124
+ const action = msg.action;
125
+ const isDiscreteCreate = action === 'message.create' && HEADER_DISCRETE in headers;
126
+ // Any content-producing action on a streamed serial counts as a start:
127
+ // the decoder uses create or first-contact (update/append) to establish
128
+ // its tracker. Delete clears tracker state and emits nothing, so it
129
+ // never counts as a start.
130
+ const hasStreamContent =
131
+ headers[HEADER_STREAM] === 'true' &&
132
+ (action === 'message.create' || action === 'message.update' || action === 'message.append');
133
+ const status = headers[HEADER_STATUS];
134
+ const isTerminal = status === 'complete' || status === 'cancelled';
135
+
136
+ if (isDiscreteCreate || hasStreamContent) state.startedCodecMessageIds.add(codecMessageId);
137
+ if (isDiscreteCreate || isTerminal) state.terminatedCodecMessageIds.add(codecMessageId);
138
+ if (state.startedCodecMessageIds.has(codecMessageId) && state.terminatedCodecMessageIds.has(codecMessageId)) {
139
+ state.completedCodecMessageIds.add(codecMessageId);
140
+ }
141
+ }
142
+ };
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Fetch Ably pages until we have enough completed messages
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Fetch Ably history pages until we have enough completed messages.
150
+ *
151
+ * The loop uses {@link countNewCompletions} - a cheap O(new messages) header
152
+ * scan - to decide when to stop, rather than running the decoder per page.
153
+ * @param state - The shared history traversal state.
154
+ * @param ablyPage - The current Ably paginated result to start from.
155
+ * @param limit - Target number of completed messages beyond what has already been returned.
156
+ */
157
+ const fetchUntilLimit = async (
158
+ state: HistoryState,
159
+ ablyPage: Ably.PaginatedResult<Ably.InboundMessage>,
160
+ limit: number,
161
+ ): Promise<void> => {
162
+ state.rawMessages.push(...ablyPage.items);
163
+ state.lastAblyPage = ablyPage;
164
+ countNewCompletions(state, ablyPage.items);
165
+
166
+ const target = state.returnedCount + limit;
167
+ while (state.completedCodecMessageIds.size < target && ablyPage.hasNext()) {
168
+ state.logger.debug('loadHistory.fetchUntilLimit(); fetching next page', {
169
+ collected: state.rawMessages.length,
170
+ completed: state.completedCodecMessageIds.size,
171
+ });
172
+ const nextPage = await ablyPage.next();
173
+ if (!nextPage) break;
174
+ ablyPage = nextPage;
175
+ state.rawMessages.push(...nextPage.items);
176
+ state.lastAblyPage = nextPage;
177
+ countNewCompletions(state, nextPage.items);
178
+ }
179
+ };
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Build HistoryPage result from current state
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /**
186
+ * Build a HistoryPage of raw wire messages from the current fetch state.
187
+ * @param state - The shared history traversal state.
188
+ * @param limit - Max complete messages per page.
189
+ * @returns A page of raw history messages with a `next()` cursor.
190
+ */
191
+ const buildResult = (state: HistoryState, limit: number): HistoryPage => {
192
+ // Advance the served-completion counter by up to `limit`, mirroring the
193
+ // page granularity the consumer asked for. `rawMessages` for this page are
194
+ // all wires fetched since the previous page (empty for buffered pages).
195
+ const totalCompleted = state.completedCodecMessageIds.size;
196
+ const served = Math.min(limit, Math.max(0, totalCompleted - state.returnedCount));
197
+ state.returnedCount += served;
198
+
199
+ const moreCompleted = totalCompleted > state.returnedCount;
200
+ const moreAblyPages = state.lastAblyPage?.hasNext() ?? false;
201
+
202
+ // Raw Ably messages for this page in chronological order (oldest first).
203
+ const newRawCount = state.rawMessages.length - state.returnedRawCount;
204
+ const rawMessages = newRawCount > 0 ? state.rawMessages.slice(state.returnedRawCount).toReversed() : [];
205
+ state.returnedRawCount = state.rawMessages.length;
206
+
207
+ return {
208
+ rawMessages,
209
+ hasNext: () => moreCompleted || moreAblyPages,
210
+ next: async () => {
211
+ if (moreCompleted) {
212
+ return buildResult(state, limit);
213
+ }
214
+ if (!moreAblyPages || !state.lastAblyPage) return;
215
+ const nextAbly = await state.lastAblyPage.next();
216
+ if (!nextAbly) return;
217
+ await fetchUntilLimit(state, nextAbly, limit);
218
+ return buildResult(state, limit);
219
+ },
220
+ };
221
+ };
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Public API
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * Load conversation history from a channel and return the raw wire messages.
229
+ *
230
+ * Attaches the channel if not already attached, then calls
231
+ * `channel.history({ untilAttach: true })` to guarantee no gap between
232
+ * historical and live messages. The attach is idempotent.
233
+ *
234
+ * The `limit` option controls the number of complete messages
235
+ * returned per page, not the number of Ably wire messages fetched.
236
+ * @param channel - The Ably channel to load history from.
237
+ * @param options - Pagination options.
238
+ * @param logger - Logger for diagnostic output.
239
+ * @returns The first page of raw history messages.
240
+ */
241
+ // Spec: AIT-CT11, AIT-CT11b
242
+ export const loadHistory = async (
243
+ channel: Ably.RealtimeChannel,
244
+ options: LoadHistoryOptions | undefined,
245
+ logger: Logger,
246
+ ): Promise<HistoryPage> => {
247
+ const limit = options?.limit ?? 100;
248
+ const state: HistoryState = {
249
+ rawMessages: [],
250
+ returnedCount: 0,
251
+ returnedRawCount: 0,
252
+ lastAblyPage: undefined,
253
+ startedCodecMessageIds: new Set<string>(),
254
+ terminatedCodecMessageIds: new Set<string>(),
255
+ completedCodecMessageIds: new Set<string>(),
256
+ logger,
257
+ };
258
+
259
+ logger.trace('loadHistory();', { limit });
260
+
261
+ // Request more Ably messages than the domain limit to account for
262
+ // the many-to-one ratio (multiple wire messages per message).
263
+ const wireLimit = limit * 10;
264
+
265
+ await channel.attach();
266
+ const ablyPage = await channel.history({ untilAttach: true, limit: wireLimit });
267
+ await fetchUntilLimit(state, ablyPage, limit);
268
+ return buildResult(state, limit);
269
+ };